Compare commits

...
Author SHA1 Message Date
Jeppe B 10814e68ae Prepare API default branch protection 2026-07-16 12:33:44 +02:00
Jeppe B fefe18a719 Fix legacy customer attribute session query 2026-07-16 12:20:20 +02:00
Jeppe B 9b2d5d5291 Fix customer restriction CI regressions 2026-07-16 12:06:42 +02:00
Jeppe B e1fb79d9b6 Add customer rule product restrictions 2026-07-16 11:50:52 +02:00
Jeppe B 879dfcf79a Improve invoice period data and POS add-on validation 2026-07-15 17:04:52 +02:00
Jeppe B 0feb705059 Support collected invoice economic PDF downloads 2026-07-14 15:39:39 +02:00
Jeppe Bundgaard 69b3bf83c4 Allow custom subuser grant permission writes 2026-07-13 22:30:18 +02:00
Jeppe Bundgaard 5bac316e4b Add loading screen for session confirmation and enhance vehicle management labels 2026-07-13 22:11:54 +02:00
Jeppe Bundgaard 233133365d Guard chauffeur vehicles on legacy schemas 2026-07-13 20:03:50 +02:00
Jeppe Bundgaard 026492c3bd Queue cron runs through workers 2026-07-13 20:03:39 +02:00
Jeppe Bundgaard 403f93e62c Fix self-serve schema compatibility in CI 2026-07-13 15:36:03 +02:00
Jeppe Bundgaard 582edd3e6c Implement subuser verification and invoice/self-serve API fixes 2026-07-13 15:11:49 +02:00
Jeppe Bundgaard a4fafaf7fb Enhance subuser management functionality and improve UI responsiveness 2026-07-13 10:25:26 +02:00
Jeppe Bundgaard 327e9cf817 Update self-serve permissions and enhance UI components for customer interactions 2026-07-13 10:21:22 +02:00
Jeppe Bundgaard 012e5366ba Add system status displays for Minio and Redis, and enhance backup configuration 2026-07-13 10:08:00 +02:00
Jeppe B fa1ade555f Fix self-serve cron registry test
Fix self-serve cron registry test
2026-07-09 11:36:11 +02:00
Jeppe B 7a1c444df0 Activate self-serve opening relays
Activate self-serve opening relays
2026-07-09 11:27:32 +02:00
Jeppe Bundgaard 6a00f023b1 Refactor cron scheduling 2026-07-09 11:04:13 +02:00
Jeppe B 8aefbd8fb3 Guard wash subscription distribution SQL
Guard the wash subscription distribution query after invoice-inclusion filtering removes all candidate orders, preventing an empty IN () clause on the invoicing distribution endpoint.

Verified with focused syntax, Pest, PHPStan, and invoicing unit-suite checks.
2026-07-09 10:24:02 +02:00
Jeppe B a7181a4ab2 Fix edge gateway relay binding reactivation
Reactivate existing relay binding rows when a gateway/relay pair is re-added after soft deletion, avoiding duplicate uniq_edge_gateway_binding inserts. Add regression coverage for the reactivation path.
2026-07-08 18:46:33 +02:00
Jeppe Bundgaard fc6c76ad1b Add customer rule cleanup for spotfree addon products with preview and apply functionality 2026-07-08 14:40:46 +02:00
Jeppe Bundgaard 6a694f92cc Add bulk action preview and apply endpoints for collected invoices 2026-07-08 13:46:31 +02:00
Jeppe Bundgaard b7a2dc04d7 Merge remote-tracking branch 'origin/master' 2026-07-08 13:03:49 +02:00
Jeppe Bundgaard 870b88e707 Add assertError method to ApiResponse and enhance CI test failure handling 2026-07-08 13:03:33 +02:00
Jeppe B e14cddc1fb Fix API suite regressions 2026-07-08 12:54:26 +02:00
Jeppe B 31887fa8c9 Cover API CI skip prevention wiring 2026-07-08 12:32:27 +02:00
Jeppe B eac83b18a0 Preflight required extensions for API CI 2026-07-08 12:32:19 +02:00
Jeppe B 3817a37021 Make API CI fail on skipped bootstrap 2026-07-08 12:31:40 +02:00
Jeppe Bundgaard 940a3e5e9b Refactor superuser grant selection logic to prioritize the most recently updated grant 2026-07-08 12:16:22 +02:00
Jeppe Bundgaard 3221223865 Enhance vehicle summary retrieval for customers and update OpenAPI schema for subuser management grants 2026-07-08 12:15:18 +02:00
Jeppe Bundgaard b51006d9d1 Refactor subuser management payload and enhance grant deduplication logic 2026-07-08 12:13:12 +02:00
Jeppe Bundgaard 6b7592921d Add subuser permission templates service and related tests 2026-07-08 11:49:40 +02:00
Jeppe Bundgaard f26a427510 Add test for customer booking sessions to retrieve single-product final pricing with context 2026-07-08 10:41:56 +02:00
Jeppe Bundgaard 6de747252f Add department pricing normalization for order bookings 2026-07-08 10:35:59 +02:00
Jeppe Bundgaard ff225ff5e7 Refactor getTargetItems method to include customer and department parameters for improved item normalization 2026-07-08 10:35:39 +02:00
Jeppe Bundgaard dcef993f12 Implement department wash count service and refactor related reporting functions 2026-07-08 10:35:24 +02:00
Jeppe Bundgaard 23aca449f7 Add department wash count service for tracking wash counts and transaction summaries 2026-07-08 10:25:03 +02:00
Jeppe Bundgaard 31b5ba136a Add user-scoped routes for managing subusers and their grants 2026-07-08 10:24:50 +02:00
Jeppe Bundgaard f0b5479f30 Enhance department pricing functionality and improve related tests 2026-07-08 09:35:40 +02:00
Jeppe B b77efc538a Fix backend test gates and department product access 2026-07-07 22:16:37 +02:00
Jeppe Bundgaard 10d1eb5bac Refactor system search cache handling and update OpenAPI specifications for max results 2026-07-07 18:59:07 +02:00
Jeppe Bundgaard 084435e9b8 Add customer pricing permissions and update related tests 2026-07-07 17:58:39 +02:00
Jeppe Bundgaard 172a21c517 Implement department-specific customer pricing functionality 2026-07-07 17:27:56 +02:00
Jeppe Bundgaard c24428e4c7 Update ProductsApiTest to use 'user' session for department pricing tests 2026-07-07 14:38:41 +02:00
Jeppe Bundgaard bf1d6a583e Allow customers to read own attributes 2026-07-07 13:05:24 +02:00
Jeppe Bundgaard 08ac16e665 Fix customer wash certificate access and emails 2026-07-07 12:37:19 +02:00
Jeppe Bundgaard 79185a3c76 Enhance product listing for customer booking sessions: allow access to booking-visible products without requiring additional permissions 2026-07-07 12:23:56 +02:00
Jeppe Bundgaard 3a730e3507 Merge limited backoffice role permission templates 2026-07-07 03:27:39 +02:00
Jeppe Bundgaard ce43c4e064 Expose limited backoffice role permission templates 2026-07-07 03:23:42 +02:00
Jeppe B 579ddcf510 Merge pull request #307 from copenhagentruckwash/copilot/update-limited-backoffice-roles
Fix limited-backoffice role permissions and enforce department access on order mutations
2026-07-07 02:53:06 +02:00
Jeppe Bundgaard 0b342a7780 Align limited backoffice permission cap tests 2026-07-07 02:47:49 +02:00
copilot-swe-agent[bot] 57bcbaf72a Fix 11 failing API tests across 4 files 2026-07-07 00:31:43 +00:00
copilot-swe-agent[bot] d9fbba3130 Return 404 when order item not found in DELETE /order/items 2026-07-06 23:19:00 +00:00
copilot-swe-agent[bot] e4465d9d91 Improve DELETE /order/items: clearer error message, 404 when order not found 2026-07-06 23:17:50 +00:00
copilot-swe-agent[bot] 734cd13c87 Handle prepared statement failure with error response in DELETE /order/items 2026-07-06 23:16:55 +00:00
copilot-swe-agent[bot] d0f94ac549 Use prepared statements for all new DB queries in tests and route 2026-07-06 23:15:59 +00:00
copilot-swe-agent[bot] 1d25cbe21c Fix SQL injection concerns: use prepared statements in orderItemsRoute and tests 2026-07-06 23:14:34 +00:00
copilot-swe-agent[bot] 53d0636193 Fix limited-backoffice permissions and add department access restrictions 2026-07-06 23:12:01 +00:00
Jeppe B 04bb26f1b0 Merge pull request #306 from copenhagentruckwash/copilot/fix-php-api-job-failure
Fix two failing LimitedBackofficeApiTest assertions
2026-07-07 00:44:53 +02:00
copilot-swe-agent[bot] df0d4783d0 Remove search_customers and search_vehicles from raw-permission exclusion list 2026-07-06 22:37:40 +00:00
copilot-swe-agent[bot] 39c06ceab6 Address code review: restore filter passthrough and add clarifying comments 2026-07-06 22:28:45 +00:00
copilot-swe-agent[bot] 7dd428d18e Fix two failing LimitedBackofficeApiTest tests
Fix 1: Remove add_order_attachments and download_order_attachments from
the raw-permissions exclusion check in the test. These strings are valid
capability names that legitimately appear in the /limited-backoffice/roles
response, so including them in the 'should not contain' list caused a
false failure.

Fix 2: Update limitedBackofficeEmployeeListMode() in usersRoute.php to
exclude active limited backoffice employees when include_limited_backoffice_employees
is not set and the customer_number:0 filter is in use. Previously the
method returned additional_where:null in this case, so limited employees
were included in the result set alongside regular backoffice employees.
2026-07-06 22:27:45 +00:00
copilot-swe-agent[bot] 0103a40156 Initial plan 2026-07-06 22:19:38 +00:00
Jeppe B e208b1b2a4 Merge pull request #305 from copenhagentruckwash/codex/limited-backoffice-employee-migration
Add limited backoffice employee migration
2026-07-07 00:14:25 +02:00
Jeppe Bundgaard 6b4b55cb62 Add limited backoffice employee migration 2026-07-07 00:10:22 +02:00
Jeppe Bundgaard 0cca597fdc Fix XLVask usage import dates
Fix XLVask usage-log import metadata and period-scoped Selvvask automation.
2026-07-06 23:49:28 +02:00
Jeppe B 709c6acbba Fix product null department permissions
Treats null-like optional product query params as omitted and avoids department_access_0 permission checks.
2026-07-06 20:14:45 +02:00
Jeppe Bundgaard ed2736e528 Fix product null department permissions 2026-07-06 19:56:07 +02:00
Jeppe B c7f5c73a9e Merge pull request #303 from copenhagentruckwash/codex/daily-report-product-targets-api
[codex] Add daily report product target API
2026-07-06 19:35:37 +02:00
Jeppe Bundgaard c10af48954 Add daily report product target API 2026-07-06 18:52:24 +02:00
Jeppe Bundgaard 7ac5c5585b Add limited backoffice employee QR login links 2026-07-06 17:32:58 +02:00
Jeppe B 8544ce0a18 Merge pull request #297 from copenhagentruckwash/codex/customer-product-fixed-price-overrides
Add customer product fixed price overrides
2026-07-06 17:23:06 +02:00
Jeppe Bundgaard 614715822f Fix backend merge fallout for booking and limited employees 2026-07-06 17:16:22 +02:00
Jeppe Bundgaard 1da02e2486 Fix limited backoffice price save reset 2026-07-06 17:13:04 +02:00
Jeppe B 742b15116d Merge pull request #295 from copenhagentruckwash/fix/economic-ean-transfer
Fix e-conomic EAN customer transfer
2026-07-06 17:00:57 +02:00
Jeppe Bundgaard e0ae74bdc2 Merge remote-tracking branch 'origin/master' into codex/customer-product-fixed-price-overrides 2026-07-06 17:00:52 +02:00
Jeppe Bundgaard 08dc803b3e Make limited backoffice employees regular employees 2026-07-06 16:59:32 +02:00
Jeppe Bundgaard 248a901f24 Merge master into fixed price override branch 2026-07-06 16:54:01 +02:00
Jeppe Bundgaard 8bbdf9daf5 Require booking add node for subuser booking creation 2026-07-06 16:48:07 +02:00
Jeppe B c089186046 Merge pull request #302 from copenhagentruckwash/codex/customer-orderbooking-create-without-permission
Allow customer order booking creation without booking permission
2026-07-06 16:39:17 +02:00
Jeppe Bundgaard 2ae1fc3fcf Allow customer order booking creation without booking permission 2026-07-06 16:33:31 +02:00
Jeppe Bundgaard d9eacf6f84 Deduplicate limited backoffice price products 2026-07-06 16:28:08 +02:00
Jeppe B f262047476 Merge pull request #300 from copenhagentruckwash/codex/scoped-monthly-split-api
Scope monthly invoice split API
2026-07-06 16:01:54 +02:00
Jeppe B b8390ac0d3 Merge pull request #298 from copenhagentruckwash/codex/only-tankcleaning-order-enforcement
Enforce only tankcleaning order products
2026-07-06 16:01:40 +02:00
Jeppe B 0d4a5470e5 Add superuser department overview API (#301)
Merge backend API for the superuser department overview.
2026-07-06 16:01:04 +02:00
Jeppe B 845ca6e48e Merge pull request #290 from copenhagentruckwash/codex/custom-pricing-only-departments
Add custom-only department pricing enforcement
2026-07-06 15:31:27 +02:00
Jeppe Bundgaard 1cda2a81aa Merge remote-tracking branch 'origin/master' into codex/custom-pricing-only-departments
# Conflicts:
#	services/nginx/app/tests/Api/OrderItemsApiTest.php
2026-07-06 15:21:31 +02:00
Jeppe BandJeppe Bundgaard 8e46ce1b04 [codex] Allow error reports without screenshots (#299)
* Allow error reports without screenshots

* Stabilize edge gateway shell transcript smoke

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 14:27:47 +02:00
Jeppe Bundgaard 9f797bf6b8 Add opening hours table to API test schema 2026-07-06 14:27:39 +02:00
Jeppe Bundgaard d345db927f Add daily report table to API test schema 2026-07-06 14:16:41 +02:00
Jeppe BandJeppe Bundgaard 11c2a1b72e Block restricted customer order items (#296)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 14:06:29 +02:00
Jeppe Bundgaard eca7a81f9d Add superuser department overview API 2026-07-06 13:59:40 +02:00
Jeppe Bundgaard 62f2c80dda Scope monthly invoice split endpoint 2026-07-06 13:37:31 +02:00
Jeppe BandJeppe Bundgaard 6f3d7e0f7d Add limited backoffice employee contact fields (#294)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 13:14:06 +02:00
Jeppe Bundgaard 430c90cbca Enforce only tankcleaning order products 2026-07-06 12:53:42 +02:00
Jeppe Bundgaard f02dfd8c9c Add customer product fixed price overrides 2026-07-06 12:52:33 +02:00
Jeppe B a8fba73d99 Add limited backoffice role permission details (#291)
Adds grouped safe permission metadata for limited backoffice role presets.
2026-07-06 12:24:54 +02:00
Jeppe B 669759461d Merge pull request #293 from copenhagentruckwash/codex/economic-collected-invoice-transfer-speed
Optimize collected e-conomic invoice transfers
2026-07-06 11:49:47 +02:00
Jeppe Bundgaard db1b9a2c96 Fix e-conomic EAN customer transfer 2026-07-06 11:34:23 +02:00
Jeppe Bundgaard 38814545c4 Optimize collected e-conomic invoice transfers 2026-07-06 11:31:22 +02:00
Jeppe B 94c3654240 Merge pull request #292 from copenhagentruckwash/fix/limited-backoffice-price-save
[codex] Fix limited backoffice price saves on legacy schema
2026-07-06 11:03:44 +02:00
Jeppe Bundgaard 9fa249cc11 Fix limited backoffice price saves on legacy schema 2026-07-06 10:53:18 +02:00
Jeppe Bundgaard 215c8d0fbb Add limited backoffice role permission details 2026-07-06 10:38:51 +02:00
Jeppe Bundgaard 84dec4c0a2 Stabilize custom pricing API fixture 2026-07-06 10:34:57 +02:00
Jeppe Bundgaard d47ea1d659 Add custom-only department pricing enforcement 2026-07-06 10:15:11 +02:00
Jeppe BandJeppe Bundgaard 3f41eebdf6 Default vehicle subscriptions to false (#288)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 09:21:31 +02:00
Jeppe B 64e0b2444b Merge pull request #289 from copenhagentruckwash/fix/limited-backoffice-api-compat
[codex] Fix limited backoffice schema compatibility
2026-07-06 09:20:56 +02:00
Jeppe Bundgaard 18fede78f8 Fix limited backoffice schema compatibility 2026-07-06 09:15:55 +02:00
Jeppe Bundgaard 243d68ab59 Fix edge gateway relay command draining 2026-07-02 12:41:34 +02:00
Jeppe Bundgaard 62c1393f62 Refactor product data handling and improve error management in superuser products layout 2026-07-02 11:48:21 +02:00
Jeppe B f0a8299133 Merge pull request #287 from copenhagentruckwash/fix/api-ci-limited-backoffice
Fix API CI failures
2026-07-02 11:03:12 +02:00
Jeppe Bundgaard e36f6da926 Run Qodana on backend runner pool 2026-07-02 10:57:47 +02:00
Jeppe Bundgaard 4a8c2a9fd9 Avoid npm cache hang in edge CI 2026-07-02 10:50:25 +02:00
Jeppe Bundgaard 248b2e4eca Fix API CI failures 2026-07-02 10:42:13 +02:00
Jeppe Bundgaard 1d1ebd2176 Add limited backoffice functionality with employee management and department pricing 2026-07-01 16:37:32 +02:00
Jeppe Bundgaard 4252f9a42b Stabilize edge gateway API CI 2026-07-01 14:01:50 +02:00
Jeppe Bundgaard 4fdeedab45 Handle invalid edge installer tokens 2026-07-01 13:41:20 +02:00
Jeppe Bundgaard 866a5be126 Refactor subuser permissions and enhance artifact management 2026-07-01 13:18:27 +02:00
Jeppe Bundgaard 11d39af934 Normalize edge broker URL updates 2026-07-01 11:53:01 +02:00
Jeppe Bundgaard f706531534 Stabilize gateway E2E broker config 2026-07-01 11:44:36 +02:00
Jeppe Bundgaard a839eac4c1 Allow HTTP operation completion in gateway E2E 2026-07-01 11:34:39 +02:00
Jeppe Bundgaard 581d28e9ce Stabilize edge gateway CI assertions 2026-07-01 11:25:01 +02:00
Jeppe Bundgaard 2ba39b8174 Fix edge gateway CI broker path 2026-07-01 11:17:01 +02:00
Jeppe Bundgaard 6af55a44c9 Fix API CI broker and transport fixtures 2026-07-01 11:09:38 +02:00
Jeppe Bundgaard 24badc39d7 Keep broker path in API CI public URL 2026-07-01 11:02:26 +02:00
Jeppe Bundgaard f5e0baaab6 Use explicit Docker subnets in API CI 2026-07-01 11:00:13 +02:00
Jeppe Bundgaard 178c84ba60 Use direct broker port for API CI smoke 2026-07-01 10:57:39 +02:00
Jeppe Bundgaard 713d40a876 Prune stale Docker networks in API CI 2026-07-01 10:53:25 +02:00
Jeppe Bundgaard 9db1964038 Fix edge agent CI setup 2026-07-01 10:51:14 +02:00
Jeppe Bundgaard 1ca42055b0 Use docker-capable API runners 2026-07-01 10:42:30 +02:00
Jeppe Bundgaard c04bda7368 Normalize API runner Docker access 2026-07-01 10:38:49 +02:00
Jeppe Bundgaard 1f47843699 Fix edge expected state CI coverage 2026-07-01 10:33:56 +02:00
Jeppe Bundgaard dca738db82 Resolve edge schema DB config from env 2026-07-01 10:28:40 +02:00
Jeppe Bundgaard 57f364ad0f Use backend runners for API tests 2026-07-01 10:21:08 +02:00
Jeppe Bundgaard a826153bb5 Use namespaced DB helper in schema bootstrap 2026-07-01 10:04:28 +02:00
Jeppe Bundgaard 72bd22a707 Load DB helper for edge gateway schema bootstrap 2026-07-01 09:57:34 +02:00
Jeppe Bundgaard cc73d80dbc Use PDO in edge gateway schema bootstrap 2026-07-01 09:46:35 +02:00
Jeppe Bundgaard f0a5b15442 Add edge agent expected relay state API 2026-07-01 09:40:54 +02:00
Jeppe Bundgaard eefe5630f4 Honor staged edge gateway update windows 2026-06-30 17:23:04 +02:00
Jeppe Bundgaard b0ea771e6a Add handling for self-serve machine signals in edge agent 2026-06-30 16:19:43 +02:00
Jeppe Bundgaard eb21405a3d Add outbox replay handling with configurable limits and timeouts 2026-06-30 16:16:08 +02:00
Jeppe Bundgaard 8ea10ef808 Implement relay toggle handling with configurable timers and device generation resolution 2026-06-30 15:53:29 +02:00
Jeppe Bundgaard ac7da807bd Route local lane gate opens through edge bindings 2026-06-30 13:52:12 +02:00
Jeppe Bundgaard 3eafc597c6 Use edge bindings for lane hardware batch relays 2026-06-30 13:28:03 +02:00
Jeppe Bundgaard f7a4126718 Fix router matching for underscored route params 2026-06-30 13:10:06 +02:00
Jeppe Bundgaard c6cf953ede Fix relay batch gateway binding resolution 2026-06-30 12:52:53 +02:00
Jeppe Bundgaard d902202fe9 Fallback relay commands after broker dispatch failures 2026-06-30 12:32:10 +02:00
Jeppe Bundgaard acc80920f9 Preserve LAN worker auth after agent config reload 2026-06-30 11:39:19 +02:00
Jeppe Bundgaard 53246af629 Update EdgeGatewayManagerUrlTest to improve installation phase checks and remove obsolete heartbeat expectations 2026-06-30 11:18:00 +02:00
Jeppe Bundgaard bd87e94472 Normalize line endings in install scripts and enhance service stop commands for robustness 2026-06-30 11:06:33 +02:00
Jeppe Bundgaard 7ccbb68ffa Refactor Dockerfiles to streamline PHP extension checks and remove unnecessary installations 2026-06-30 10:58:08 +02:00
Jeppe Bundgaard 4a7fc7c534 Refactor Dockerfiles to conditionally install curl extension if not already present 2026-06-30 10:35:30 +02:00
Jeppe Bundgaard f4343ae114 Enhance stopping wash functionality with local storage management and restoration logic 2026-06-30 10:16:13 +02:00
Jeppe Bundgaard 0da02dfeb5 Add batch processing for lane hardware commands and status retrieval 2026-06-30 09:30:43 +02:00
Jeppe Bundgaard 5f13242cfa Fix eligibility check by ensuring user is not null in lane access condition 2026-06-29 15:35:26 +02:00
Jeppe Bundgaard b492292642 Add machine wash configuration and update self-serve lane service checks 2026-06-29 15:22:22 +02:00
Jeppe Bundgaard ce8ba88b16 Add machine wash configuration and controls to self-serve module 2026-06-29 15:15:29 +02:00
Jeppe Bundgaard 02b6df5e3b Refactor authentication handling and permission checks in self-serve routes 2026-06-29 14:53:58 +02:00
Jeppe Bundgaard 6cc4f2759d Enhance self-serve functionality by adding lane availability checks and updating response data 2026-06-29 14:25:51 +02:00
Jeppe Bundgaard 3ea92be722 Add endpoint and functionality to test Slack internal department goal progress webhook 2026-06-29 11:32:47 +02:00
Jeppe Bundgaard 42f8ae0c47 Refactor input value handling and improve validation in ObjectsGlobal component 2026-06-29 11:08:50 +02:00
Jeppe Bundgaard 148b575767 Refactor input value handling and improve validation in ObjectsGlobal component 2026-06-29 08:28:00 +02:00
Jeppe B 605efacece Merge pull request #284 from copenhagentruckwash/codex/optimize-scanner-lpr-backend
[codex] optimize scanner LPR backend
2026-06-12 22:08:35 +02:00
Jeppe Bundgaard 1ccd7749d0 optimize scanner lpr backend 2026-06-12 21:42:36 +02:00
Jeppe B b3ba3c8de5 Merge pull request #283 from copenhagentruckwash/fix/pwa-selfserve-stop-latency
[codex] Reduce self-serve latency and add PHP-FPM workers
2026-06-12 13:15:28 +02:00
Jeppe Bundgaard 4a65b669bd Fix CI FPM worker config test 2026-06-12 12:35:06 +02:00
Jeppe Bundgaard aaec443140 Configure multiple PHP-FPM workers 2026-06-12 12:27:27 +02:00
Jeppe Bundgaard 3cdf1571c5 Avoid duplicate self-serve stop relay cleanup 2026-06-12 11:48:51 +02:00
Jeppe Bundgaard 36b934e835 Increase password reset token validity to 72 hours and update related email message 2026-06-11 21:45:22 +02:00
Jeppe B 5027d0c919 Merge pull request #282 from copenhagentruckwash/codex/register-cvr-welcome-email-fix
[codex] Fix register CVR welcome email rendering
2026-06-11 21:22:28 +02:00
Jeppe Bundgaard f0baadd59f Register welcome email legacy test 2026-06-11 21:08:28 +02:00
Jeppe Bundgaard 19cacebaa1 Fix register CVR welcome email rendering 2026-06-11 21:01:34 +02:00
Jeppe Bundgaard 4d9d61455f Refactor company phone number registration error handling and enhance CVR lookup test cases 2026-06-11 20:25:16 +02:00
Jeppe Bundgaard fc87b3a8aa Improve CVR lookup error handling and unify phone number registration error messages 2026-06-11 20:17:48 +02:00
Jeppe Bundgaard 8e0936001d Fix company phone number registration error messages for clarity 2026-06-11 20:03:55 +02:00
Jeppe B af8968a87e Merge pull request #281 from copenhagentruckwash/codex/customer-registration-notifications
Add Slack customer registration webhook test endpoint
2026-06-11 15:18:07 +02:00
Jeppe Bundgaard e6a18ce5d8 Add Slack customer registration webhook test endpoint 2026-06-11 15:06:31 +02:00
Jeppe B b5c24ef80a Merge pull request #280 from copenhagentruckwash/fix/self-serve-path-outcome-case-limit
Fix self-serve path outcome case limit
2026-06-11 14:57:16 +02:00
Jeppe Bundgaard df7153a5ba Fix self-serve path outcome case limit 2026-06-11 14:46:27 +02:00
Jeppe Bundgaard d06c78119b Fix customer registration duplicate recovery 2026-06-11 12:04:56 +02:00
Jeppe B bdb1a0074b Merge pull request #279 from copenhagentruckwash/fix/self-serve-customer-property-gates
Allow customers to open property gates for active washes
2026-06-10 22:00:06 +02:00
Jeppe Bundgaard c0de0e9d6b Allow customers to open property gates for active washes 2026-06-10 21:00:18 +02:00
Jeppe B 574b263a54 Merge pull request #278 from copenhagentruckwash/fix/self-serve-start-wash-type
Honor wash type in self-serve lane start
2026-06-10 20:07:46 +02:00
Jeppe B 36ff5bb438 Merge pull request #277 from copenhagentruckwash/fix-completion-confirmation-route
Add order booking completion confirmation resend route
2026-06-10 20:07:30 +02:00
Jeppe Bundgaard 1beca924fc Fallback composer installs to source in CI 2026-06-10 19:47:17 +02:00
Jeppe B d605eca574 Fallback composer installs to source in CI 2026-06-10 19:22:52 +02:00
Jeppe Bundgaard cea469c95a Honor wash type in self-serve lane start 2026-06-10 19:18:19 +02:00
Jeppe B 7e85c74e60 Retry composer installs in CI 2026-06-10 19:12:12 +02:00
Jeppe B 67d62eff70 Sync fake email deliveries across API tests 2026-06-10 18:53:13 +02:00
Jeppe B 8ebbd52a99 Normalize attachment object type lookups 2026-06-10 18:40:34 +02:00
Jeppe B 6d6cc501db Force completion confirmation resend email 2026-06-10 18:33:53 +02:00
Jeppe B ce999afbb3 Fix MinIO local test storage fallback 2026-06-10 18:22:53 +02:00
Jeppe B cb34b030c8 Add order booking completion confirmation resend route 2026-06-10 17:55:57 +02:00
Jeppe Bundgaard e26034dfae Refactor dynamic image export methods to use binary output and improve caching logic 2026-06-09 14:48:46 +02:00
Jeppe Bundgaard 0aad41fd0f Add program picker relay status handling for wash start and update related tests 2026-06-09 14:09:49 +02:00
Jeppe Bundgaard 5c67fe419f Add timeout settings for Shelly cloud HTTP requests and update related tests 2026-06-09 14:03:12 +02:00
Jeppe Bundgaard aca8be51dc Implement move collected invoice to customer functionality with API endpoint and associated tests 2026-06-09 13:12:55 +02:00
Jeppe Bundgaard fb1f0883e1 Add selfserve dynamic image sizing config 2026-06-09 12:46:14 +02:00
Jeppe Bundgaard 6446eb2e36 Test manual wash program picker selection 2026-06-09 12:05:27 +02:00
Jeppe Bundgaard a1224ec2f4 Fix self-serve program picker wash type sync 2026-06-09 11:59:42 +02:00
Jeppe Bundgaard 07441c4ed1 Harden API auto deploy gate 2026-06-08 18:44:25 +02:00
Jeppe Bundgaard cd100f1180 Invalidate cached session payloads on notification preferences update; enhance flag tab filtering logic 2026-06-08 18:40:15 +02:00
Jeppe Bundgaard 4fc66c72b8 Prefer selected Coolify deployment commit 2026-06-08 18:14:45 +02:00
Jeppe Bundgaard a3ea5fee83 Clean up stale Coolify API routes 2026-06-08 18:02:08 +02:00
Jeppe Bundgaard ef8d97c821 Verify API release commit in gateway gate 2026-06-08 17:48:01 +02:00
Jeppe Bundgaard 327a77edf4 Require API gateway release check 2026-06-08 17:07:49 +02:00
Jeppe Bundgaard d8abc8f87d Refactor session relay synchronization logic for improved clarity 2026-06-08 16:59:53 +02:00
Jeppe Bundgaard 325b35beb7 Add session synchronization tests and ensure atomic session closure 2026-06-08 16:53:24 +02:00
Jeppe Bundgaard 49364864d2 Implement session mutation locking and enhance session management methods 2026-06-08 16:49:41 +02:00
Jeppe Bundgaard a19178a042 Add PHPStan and Rector configuration files for static analysis and code quality 2026-06-08 16:33:37 +02:00
Jeppe Bundgaard 91d3332d4e Add Slack customer registration notification functionality 2026-06-08 12:42:03 +02:00
Jeppe Bundgaard bedbf21c29 Add superuser new customer email notification preferences 2026-06-08 12:20:07 +02:00
Jeppe Bundgaard 75c19bcce4 Fix MyWash active summary refresh import 2026-06-04 08:31:51 +02:00
Jeppe Bundgaard 458fe7399d Persist MyWash sessions on start command 2026-06-04 08:18:31 +02:00
Jeppe Bundgaard 2b6a8eedcc Avoid session creation on MyWash summary refresh 2026-06-04 08:07:40 +02:00
Jeppe Bundgaard c0ed107f75 Resolve MyWash services from published config 2026-06-04 07:50:14 +02:00
Jeppe Bundgaard 30dceff0b5 Avoid self-serve preview sessions on eligibility reads 2026-06-04 06:55:59 +02:00
Jeppe Bundgaard 716929bd7b Inject frontend commit SHA into Coolify runtime environment for manifest builds 2026-06-04 00:38:31 +02:00
Jeppe Bundgaard 3d221f3379 Merge remote-tracking branch 'origin/master' 2026-06-03 21:03:27 +02:00
Jeppe B 6f1c160fbb Sync generated Copilot workflow 2026-06-03 20:57:06 +02:00
Jeppe Bundgaard 9694695f00 Sync generated Copilot workflow 2026-06-03 20:56:06 +02:00
Jeppe Bundgaard 1d43221b4d Sync self-serve machine relay session state 2026-06-03 20:35:50 +02:00
Jeppe Bundgaard 33b7c3e51a Widen self-serve task descriptions 2026-06-03 19:06:09 +02:00
Jeppe Bundgaard 1e64bd63b8 Update PHPUnit test results cache with latest version and defect counts 2026-06-03 18:19:45 +02:00
Jeppe B bcbc2481c3 Source self-serve lane products from published config 2026-06-02 19:05:06 +02:00
Jeppe B 8288a1069c Merge pull request #276
coolify-github-runner-management
2026-06-02 17:27:42 +02:00
Jeppe Bundgaard 1b99523366 Enhance self-serve lane functionality with new relay management and configuration updates 2026-06-02 17:27:22 +02:00
378 changed files with 186692 additions and 4799 deletions
+49
View File
@@ -0,0 +1,49 @@
# Default branch protection
`master` is changed through pull requests. Do not push or publish directly to
the default branch, including through automation or the Git Data API.
## Normal publishing flow
1. Create a scoped `agent/*` or feature branch from the current `origin/master`.
2. Commit and push only the intended changes.
3. Open a pull request targeting `master`.
4. Wait for the `Required CI` check. If `master` moves, update the branch and
wait for the strict check to rerun.
5. Resolve every review conversation and squash-merge the pull request.
6. Confirm the post-merge `Release Manager gate` completes on `master`.
The aggregate check covers the PHP unit, integration, API, and legacy matrix,
plus Edge Agent, Edge Broker, and Edge Gateway Backend. Qodana is advisory and
the Release Manager gate is intentionally post-merge.
## Desired ruleset
[`rulesets/protect-default-branch.json`](rulesets/protect-default-branch.json)
is the importable final desired-state repository-ruleset request body. For the
initial POST, copy the file and override `enforcement` to `disabled`. Inspect
the normalized ruleset and verify a green preparation PR and post-merge run,
then PUT the exact committed file to activate it.
The desired rule targets `~DEFAULT_BRANCH`, requires pull requests with zero
approvals, conversation resolution, strict `Required CI` from GitHub Actions
integration `15368`, squash-only linear history, and blocks deletion and force
pushes. Repository administrators receive pull-request-only bypass; they do not
receive a standing direct-push bypass.
When the ruleset is activated, align repository settings at the same time:
retain squash merging, disable merge commits and rebase merging, enable
auto-merge and branch-update suggestions, delete merged branches automatically,
keep the Actions token read-only, and prevent Actions from approving reviews.
## Break glass
When an incident cannot wait for the normal gate:
1. Open a pull request and describe the incident, risk, and reason for bypass.
2. Have a repository administrator use the pull-request-only bypass.
3. Monitor `Required CI` and the post-merge Release Manager workflow.
4. Open a follow-up pull request for any deferred validation or remediation.
Never bypass by updating `refs/heads/master` directly. Ruleset changes and
emergency bypasses must remain visible in GitHub's audit trail.
+8
View File
@@ -11,6 +11,8 @@ services:
edge-broker:
container_name: "${COMPOSE_PROJECT_NAME:-api}-edge-broker"
ports:
- "127.0.0.1:${EDGE_BROKER_CI_PORT:-14300}:4300"
labels:
- "traefik.http.routers.edge-broker-local-ci.rule=PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local-ci.entrypoints=web"
@@ -80,3 +82,9 @@ services:
volumes:
ci_php_app:
networks:
default:
ipam:
config:
- subnet: "${CI_DOCKER_SUBNET:-10.240.0.0/24}"
@@ -0,0 +1,57 @@
{
"name": "Protect default branch",
"target": "branch",
"enforcement": "active",
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "pull_request"
}
],
"conditions": {
"ref_name": {
"exclude": [],
"include": [
"~DEFAULT_BRANCH"
]
}
},
"rules": [
{
"type": "deletion"
},
{
"type": "non_fast_forward"
},
{
"type": "required_linear_history"
},
{
"type": "pull_request",
"parameters": {
"allowed_merge_methods": [
"squash"
],
"dismiss_stale_reviews_on_push": false,
"require_code_owner_review": false,
"require_last_push_approval": false,
"required_approving_review_count": 0,
"required_review_thread_resolution": true
}
},
{
"type": "required_status_checks",
"parameters": {
"do_not_enforce_on_create": false,
"required_status_checks": [
{
"context": "Required CI",
"integration_id": 15368
}
],
"strict_required_status_checks_policy": true
}
}
]
}
+6 -4
View File
@@ -3,14 +3,16 @@ on:
workflow_dispatch:
pull_request:
push:
branches: # Specify your branches here
- main # The 'main' branch
- 'releases/*' # The release branches
branches:
- master
- beta
- canary
- internal
jobs:
qodana:
# CI runs on the repository's self-hosted runner pool.
runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
permissions:
contents: read
pull-requests: read
+1 -1
View File
@@ -9,7 +9,7 @@ on:
jobs:
assign-task:
runs-on: [self-hosted, Linux, X64, default]
runs-on: ubuntu-latest
permissions:
issues: write
steps:
+167 -44
View File
@@ -3,11 +3,23 @@ name: Tests
on:
pull_request:
push:
branches:
- master
- beta
- canary
- internal
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
php:
name: PHP ${{ matrix.suite }} (required)
runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
strategy:
fail-fast: false
matrix:
@@ -19,6 +31,20 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Ensure Docker access
run: |
set -euo pipefail
if docker ps >/dev/null 2>&1; then
exit 0
fi
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
if command -v sudo >/dev/null 2>&1; then
sudo -n chmod 666 /var/run/docker.sock
else
chmod 666 /var/run/docker.sock
fi
docker ps >/dev/null
- name: Setup Node.js
if: ${{ matrix.suite == 'unit' }}
uses: actions/setup-node@v4
@@ -44,7 +70,7 @@ jobs:
edge-agent:
name: Edge Agent (required)
runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64, pleno, backend]
steps:
- name: Checkout
@@ -54,8 +80,6 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: services/edge-agent/package-lock.json
- name: Install native build tools
run: |
@@ -91,12 +115,26 @@ jobs:
edge-broker:
name: Edge Broker (required)
runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Ensure Docker access
run: |
set -euo pipefail
if docker ps >/dev/null 2>&1; then
exit 0
fi
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
if command -v sudo >/dev/null 2>&1; then
sudo -n chmod 666 /var/run/docker.sock
else
chmod 666 /var/run/docker.sock
fi
docker ps >/dev/null
- name: Materialize CI compose env files
run: |
set -euo pipefail
@@ -112,8 +150,6 @@ jobs:
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
@@ -125,26 +161,83 @@ jobs:
edge-gateway-backend:
name: Edge Gateway Backend (required)
runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
env:
COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml
COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }}
COMPOSE_PROFILES: dev
TRAEFIK_WEB_PORT: "18080"
TRAEFIK_WEBSECURE_PORT: "18443"
TRAEFIK_WEBSECURE_STAGING_PORT: "18433"
TRAEFIK_METRICS_PORT: "19100"
EDGE_BROKER_CI_PORT: "14300"
EDGE_GATEWAY_E2E_BASE_URL: "http://localhost:18080/api"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Ensure Docker access
run: |
set -euo pipefail
if docker ps >/dev/null 2>&1; then
exit 0
fi
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
if command -v sudo >/dev/null 2>&1; then
sudo -n chmod 666 /var/run/docker.sock
else
chmod 666 /var/run/docker.sock
fi
docker ps >/dev/null
- name: Allocate CI ports
run: |
set -euo pipefail
find_free_port() {
start="$1"
end="$2"
port="$start"
while [ "$port" -le "$end" ]; do
if ! ss -H -ltn "sport = :$port" 2>/dev/null | grep -q .; then
echo "$port"
return 0
fi
port=$((port + 1))
done
echo "No free port in range ${start}-${end}." >&2
exit 1
}
base=$((20000 + (GITHUB_RUN_ID % 20000)))
web_port="$(find_free_port "$base" "$((base + 2000))")"
websecure_port="$(find_free_port "$((web_port + 1))" "$((web_port + 2000))")"
staging_port="$(find_free_port "$((websecure_port + 1))" "$((websecure_port + 2000))")"
metrics_port="$(find_free_port "$((staging_port + 1))" "$((staging_port + 2000))")"
broker_port="$(find_free_port "$((metrics_port + 1))" "$((metrics_port + 2000))")"
checksum="$(printf '%s' "$COMPOSE_PROJECT_NAME" | cksum | awk '{print $1}')"
subnet_second=$((64 + ((checksum / 256) % 64)))
subnet_third=$((checksum % 256))
ci_docker_subnet="10.${subnet_second}.${subnet_third}.0/24"
{
echo "TRAEFIK_WEB_PORT=${web_port}"
echo "TRAEFIK_WEBSECURE_PORT=${websecure_port}"
echo "TRAEFIK_WEBSECURE_STAGING_PORT=${staging_port}"
echo "TRAEFIK_METRICS_PORT=${metrics_port}"
echo "EDGE_BROKER_CI_PORT=${broker_port}"
echo "CI_DOCKER_SUBNET=${ci_docker_subnet}"
echo "EDGE_GATEWAY_E2E_BASE_URL=http://localhost:${web_port}/api"
echo "EDGE_GATEWAY_E2E_COMPOSE_PROJECT=${COMPOSE_PROJECT_NAME}"
} >> "$GITHUB_ENV"
- 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
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300/edge-broker\n' >> .env
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -152,7 +245,7 @@ jobs:
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
run: sh scripts/ci-docker-compose-up.sh traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
- name: Sync PHP app checkout
run: >
@@ -161,10 +254,33 @@ jobs:
--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 -
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar --no-same-owner -C /var/www/html -xf -
- name: Resolve dependencies
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"
run: |
set -euo pipefail
composer_install() {
install_mode="$1"
max_attempts="$2"
attempt=1
while :; do
if docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer install --no-interaction ${install_mode} --no-progress"; then
return 0
fi
if [ "$attempt" -ge "$max_attempts" ]; then
return 1
fi
sleep_seconds=$((attempt * 5))
echo "composer install ${install_mode} failed; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/${max_attempts})" >&2
sleep "$sleep_seconds"
attempt=$((attempt + 1))
done
}
composer_install --prefer-dist 3 || {
echo "Composer dist install failed; retrying with --prefer-source." >&2
composer_install --prefer-source 2
}
- name: Verify edge gateway test files
run: >
@@ -223,44 +339,51 @@ jobs:
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"
env:
EDGE_GATEWAY_E2E_COPY_CONFIG: "true"
EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP: "true"
run: node scripts/edge-gateway-e2e.mjs
- name: Tear down local stack
if: always()
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
required-ci:
name: Required CI
runs-on: ubuntu-latest
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
if: ${{ always() }}
steps:
- name: Verify required jobs succeeded
env:
PHP_RESULT: ${{ needs.php.result }}
EDGE_AGENT_RESULT: ${{ needs.edge-agent.result }}
EDGE_BROKER_RESULT: ${{ needs.edge-broker.result }}
EDGE_GATEWAY_BACKEND_RESULT: ${{ needs.edge-gateway-backend.result }}
run: |
set -euo pipefail
failed=0
for dependency in \
"php=${PHP_RESULT}" \
"edge-agent=${EDGE_AGENT_RESULT}" \
"edge-broker=${EDGE_BROKER_RESULT}" \
"edge-gateway-backend=${EDGE_GATEWAY_BACKEND_RESULT}"
do
name="${dependency%%=*}"
result="${dependency#*=}"
if [ "$result" != "success" ]; then
echo "Required dependency ${name} completed with result: ${result:-missing}" >&2
failed=1
fi
done
test "$failed" -eq 0
release-manager-gate:
name: Release Manager gate
runs-on: [self-hosted, Linux, X64, default]
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
runs-on: [self-hosted, Linux, X64, pleno, backend]
needs: [required-ci]
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.required-ci.result == 'success' }}
steps:
- name: Record Release Manager API gate
@@ -279,7 +402,7 @@ jobs:
-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\":[]}")"
--data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}")"
response_body="$(cat "$response_file")"
rm -f "$response_file"
+3
View File
@@ -14,3 +14,6 @@
/.tmp/
/.env.staging
/services/nginx/app/storage/replication-bootstrap.json
/.env_old_2
/.openclaw/
/services/nginx/app/build/phpstan/
+1
View File
@@ -40,6 +40,7 @@ COPY . /var/www/html
# Copy Nginx configuration file
COPY nginx.conf /etc/nginx/nginx.conf
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
# Install Composer
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
+1
View File
@@ -47,6 +47,7 @@ RUN set -eux; \
COPY services/nginx/app/ /var/www/html/
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
+5
View File
@@ -2,6 +2,11 @@
Backend API for Copenhagen Truck Wash services.
Changes are published from a scoped feature branch through a pull request to
`master`; direct default-branch pushes are not part of the release workflow.
See [default branch protection](.github/BRANCH_PROTECTION.md) for the CI gate
and emergency procedure.
## Architecture & Stack
- **Edge Proxy:** [Traefik 2.11](https://doc.traefik.io/traefik/) (Handles TLS termination, routing, and rate limiting).
- **Web Server:** [Caddy 2.7](https://caddyserver.com/) (Serves the PHP application via FastCGI).
BIN
View File
Binary file not shown.
+3
View File
@@ -3,6 +3,8 @@ services:
traefik:
image: traefik:2.11
container_name: traefik
group_add:
- "${DOCKER_SOCKET_GID:-65534}"
ports:
- "${TRAEFIK_WEB_PORT:-80}:80"
- "${TRAEFIK_WEBSECURE_PORT:-443}:443"
@@ -102,6 +104,7 @@ services:
image: mysql:8.4
container_name: mysql-debug
profiles: [dev]
command: ["mysqld", "--innodb-use-native-aio=0"]
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}
+105
View File
@@ -3357,6 +3357,9 @@
},
"email_notifications_enabled": {
"type": "boolean"
},
"superuser_new_customer_email_notifications_enabled": {
"type": "boolean"
}
}
}
@@ -12955,6 +12958,54 @@
}
}
},
"/slack/config": {
"get": {
"tags": [
"Config"
],
"summary": "Get Slack config",
"operationId": "getSlackConfig",
"responses": {
"200": {
"description": "Slack configuration retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SlackConfigListResponse"
}
}
}
}
}
},
"post": {
"tags": [
"Config"
],
"summary": "Update Slack config",
"operationId": "updateSlackConfig",
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {}
}
}
},
"responses": {
"200": {
"description": "Slack configuration updated successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModuleConfigUpdateResponse"
}
}
}
}
}
}
},
"/backups/config": {
"get": {
"tags": [
@@ -15496,6 +15547,39 @@
"value"
]
},
"SlackConfigEntry": {
"type": "object",
"properties": {
"module": {
"type": "string",
"enum": [
"Slack"
]
},
"variable": {
"type": "string",
"enum": [
"customer_registration_webhook_url"
]
},
"type": {
"type": "string",
"enum": [
"string"
]
},
"value": {
"type": "string",
"example": "https://hooks.slack.com/services/..."
}
},
"required": [
"module",
"variable",
"type",
"value"
]
},
"BackupsConfigEntry": {
"type": "object",
"properties": {
@@ -16240,6 +16324,27 @@
}
]
},
"SlackConfigListResponse": {
"allOf": [
{
"$ref": "#/components/schemas/ModuleConfigEnvelopeBase"
},
{
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SlackConfigEntry"
}
}
},
"required": [
"data"
]
}
]
},
"BackupsConfigListResponse": {
"allOf": [
{
+4514 -295
View File
File diff suppressed because it is too large Load Diff
+132545
View File
File diff suppressed because one or more lines are too long
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env sh
set -eu
if [ "$#" -eq 0 ]; then
echo "Usage: $0 <service> [service ...]" >&2
exit 2
fi
compose_files="${CI_DOCKER_COMPOSE_FILES:--f docker-compose.yml -f .github/docker-compose.ci.yml}"
lock_file="${CI_DOCKER_LOCK_FILE:-/tmp/pleno-api-ci-docker-compose-up.lock}"
max_attempts="${CI_DOCKER_UP_RETRIES:-${PHP_CI_DOCKER_RETRIES:-3}}"
export COMPOSE_PROFILES="${COMPOSE_PROFILES:-dev}"
compose_up() {
attempt=1
while :; do
docker network prune -f >/dev/null 2>&1 || true
if docker compose $compose_files up -d "$@"; then
return 0
fi
status="$?"
docker compose $compose_files down -v --remove-orphans >/dev/null 2>&1 || true
if [ "$attempt" -ge "$max_attempts" ]; then
return "$status"
fi
sleep_seconds=$((attempt * 5))
echo "Docker compose up failed with status $status; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/$max_attempts)." >&2
sleep "$sleep_seconds"
attempt=$((attempt + 1))
done
}
if command -v flock >/dev/null 2>&1; then
(
flock 9
compose_up "$@"
) 9>"$lock_file"
else
echo "flock is not available; running Docker compose startup without a host lock." >&2
compose_up "$@"
fi
+453
View File
@@ -0,0 +1,453 @@
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const DEFAULT_AGENT_PATH = path.join(
repoRoot,
"services/nginx/app/resources/edge-gateway-agent/agent.php"
);
const DEFAULT_PHP_IMAGE = "php:8.2-cli-bookworm";
const DEFAULT_TIMEOUT_MS = 12000;
function parseArgs(argv = process.argv.slice(2)) {
const options = {
agentPath: DEFAULT_AGENT_PATH,
phpImage: DEFAULT_PHP_IMAGE,
timeoutMs: DEFAULT_TIMEOUT_MS,
keepTemp: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const next = argv[index + 1];
switch (arg) {
case "--agent-path":
options.agentPath = path.resolve(String(next || "").trim());
index += 1;
break;
case "--php-image":
options.phpImage = String(next || "").trim() || DEFAULT_PHP_IMAGE;
index += 1;
break;
case "--timeout-ms":
options.timeoutMs = Number.parseInt(String(next || ""), 10) || DEFAULT_TIMEOUT_MS;
index += 1;
break;
case "--keep-temp":
options.keepTemp = true;
break;
case "--help":
case "-h":
options.help = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
function printUsage() {
process.stdout.write(`Usage:
node scripts/edge-agent-command-drain-proof.mjs [options]
Verifies that a broker-connected PHP compose edge agent still drains API-queued
SET_RELAY_STATE jobs to the LAN worker /relay/switch endpoint.
Options:
--agent-path <path> PHP agent artifact to execute.
Default: ${DEFAULT_AGENT_PATH}
--php-image <image> Docker PHP image with curl, sqlite3, and pdo_sqlite.
Default: ${DEFAULT_PHP_IMAGE}
--timeout-ms <ms> Proof timeout. Default: ${DEFAULT_TIMEOUT_MS}
--keep-temp Keep the temporary config/runtime directory.
--help Show this help text.
`);
}
function readJson(request) {
return new Promise((resolve) => {
let raw = "";
request.setEncoding("utf8");
request.on("data", (chunk) => {
raw += chunk;
});
request.on("end", () => {
if (raw.trim() === "") {
resolve({});
return;
}
try {
resolve(JSON.parse(raw));
} catch {
resolve({ __invalid: raw });
}
});
});
}
function sendJson(response, status, payload) {
const body = JSON.stringify(payload);
response.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(body),
});
response.end(body);
}
function listen(server) {
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
});
}
function closeServer(server) {
return new Promise((resolve) => {
server.close(() => resolve());
});
}
function websocketAcceptKey(key) {
return crypto
.createHash("sha1")
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
.digest("base64");
}
function createBrokerServer(state) {
const sockets = new Set();
const server = net.createServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
let buffer = "";
socket.on("data", (chunk) => {
buffer += chunk.toString("binary");
if (state.brokerHandshakeSeen || !buffer.includes("\r\n\r\n")) {
return;
}
const requestText = Buffer.from(buffer, "binary").toString("utf8");
const key = requestText.match(/Sec-WebSocket-Key:\s*(.+)\r\n/i)?.[1]?.trim();
const requestLine = requestText.split("\r\n")[0] || "";
if (!requestLine.includes("/ws/agent?")) {
state.failure = new Error(`unexpected broker path: ${requestLine}`);
}
if (!key) {
state.failure = new Error("broker handshake missing Sec-WebSocket-Key");
return;
}
socket.write([
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${websocketAcceptKey(key)}`,
"",
"",
].join("\r\n"));
state.brokerHandshakeSeen = true;
buffer = "";
});
});
return { server, sockets };
}
function createWorkerServer(state) {
return http.createServer(async (request, response) => {
const url = new URL(request.url, "http://127.0.0.1");
const body = await readJson(request);
state.requests.push({ service: "worker", method: request.method, path: url.pathname, body });
if (request.method === "GET" && url.pathname === "/health") {
sendJson(response, 200, { status: "healthy", timestamp: new Date().toISOString() });
return;
}
if (request.method === "POST" && url.pathname === "/relay/switch") {
state.relaySwitchSeen = true;
if (body.local_ip !== "10.123.0.31" || body.channel !== 0 || body.on !== true) {
state.failure = new Error(`unexpected relay switch payload: ${JSON.stringify(body)}`);
}
sendJson(response, 200, {
online: true,
on: true,
output: true,
raw: { source: "fake-worker" },
});
return;
}
sendJson(response, 404, { message: "not found" });
});
}
function createApiServer(state, brokerPort, workerPort) {
return http.createServer(async (request, response) => {
const url = new URL(request.url, "http://127.0.0.1");
const body = await readJson(request);
state.requests.push({ service: "api", method: request.method, path: url.pathname, body });
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/heartbeat") {
sendJson(response, 200, { data: { ok: true, broker_url: `ws://127.0.0.1:${brokerPort}` } });
return;
}
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/selfserve/machine-signal-bindings") {
sendJson(response, 200, { data: { monitors: [] } });
return;
}
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/poll") {
state.commandPollSeen = true;
if (body.wait_seconds !== 0) {
state.failure = new Error(
`broker-connected command poll should be non-blocking, got wait_seconds=${body.wait_seconds}`
);
}
if (!state.commandDelivered) {
state.commandDelivered = true;
sendJson(response, 200, {
data: {
id: 77,
command_type: "SET_RELAY_STATE",
payload: {
localIp: "10.123.0.31",
channel: 0,
on: true,
relayId: "relay-proof",
},
},
});
return;
}
sendJson(response, 200, { data: null });
return;
}
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/77/result") {
state.resultSeen = true;
if (body.ok !== true || body.result?.on !== true || body.result?.raw?.source !== "fake-worker") {
state.failure = new Error(`unexpected command result: ${JSON.stringify(body)}`);
}
sendJson(response, 200, { data: { acknowledged: true } });
return;
}
sendJson(response, 404, { message: "not found", path: url.pathname, workerPort });
});
}
function writeConfig(tempDir, apiPort, brokerPort, workerPort) {
const containerProofDir = "/proof";
const runtimeDir = `${containerProofDir}/runtime`;
const config = {
apiUrl: `http://127.0.0.1:${apiPort}`,
brokerUrl: `ws://127.0.0.1:${brokerPort}`,
gatewayId: 42,
agentToken: "agent-token",
installDir: containerProofDir,
runtimeDir,
stateDatabasePath: `${runtimeDir}/gateway-state.sqlite`,
workerBaseUrl: `http://127.0.0.1:${workerPort}`,
heartbeatIntervalSeconds: 60,
operationPollTimeoutSeconds: 20,
};
const configPath = path.join(tempDir, "config.json");
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return { configPath, containerConfigPath: `${containerProofDir}/config.json` };
}
function spawnAgent({ agentPath, phpImage, tempDir, containerConfigPath }) {
return spawn("docker", [
"run",
"--rm",
"--network",
"host",
"-v",
`${agentPath}:/agent.php:ro`,
"-v",
`${tempDir}:/proof`,
phpImage,
"php",
"/agent.php",
"--config",
containerConfigPath,
], { stdio: ["ignore", "pipe", "pipe"] });
}
async function stopChild(child) {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
child.kill("SIGTERM");
const hardKill = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}, 1500);
await Promise.race([
new Promise((resolve) => child.once("exit", resolve)),
new Promise((resolve) => setTimeout(resolve, 2200)),
]);
clearTimeout(hardKill);
}
function evidenceFromState(state, childExited) {
return {
brokerHandshakeSeen: state.brokerHandshakeSeen,
commandPollSeen: state.commandPollSeen,
relaySwitchSeen: state.relaySwitchSeen,
resultSeen: state.resultSeen,
agentStayedRunningUntilProofComplete: !childExited,
};
}
export async function runProof(options) {
if (process.platform !== "linux") {
throw new Error("This proof uses Docker --network host and currently expects Linux.");
}
if (!fs.existsSync(options.agentPath)) {
throw new Error(`Agent artifact not found: ${options.agentPath}`);
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "edge-agent-command-drain-proof-"));
fs.mkdirSync(path.join(tempDir, "runtime"), { recursive: true });
const state = {
brokerHandshakeSeen: false,
commandPollSeen: false,
relaySwitchSeen: false,
resultSeen: false,
commandDelivered: false,
failure: null,
requests: [],
};
const broker = createBrokerServer(state);
const workerServer = createWorkerServer(state);
let apiServer = null;
let child = null;
let stdout = "";
let stderr = "";
let childExited = false;
try {
const brokerPort = await listen(broker.server);
const workerPort = await listen(workerServer);
apiServer = createApiServer(state, brokerPort, workerPort);
const apiPort = await listen(apiServer);
const { containerConfigPath } = writeConfig(tempDir, apiPort, brokerPort, workerPort);
child = spawnAgent({ ...options, tempDir, containerConfigPath });
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.once("exit", () => {
childExited = true;
});
const deadline = Date.now() + options.timeoutMs;
while (Date.now() < deadline && !state.failure && !childExited) {
if (state.brokerHandshakeSeen && state.commandPollSeen && state.relaySwitchSeen && state.resultSeen) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
const evidence = evidenceFromState(state, childExited);
if (
state.failure ||
!state.brokerHandshakeSeen ||
!state.commandPollSeen ||
!state.relaySwitchSeen ||
!state.resultSeen
) {
const error = state.failure || new Error("missing proof evidence");
error.evidence = evidence;
error.requests = state.requests;
error.stdout = stdout.slice(-3000);
error.stderr = stderr.slice(-3000);
throw error;
}
return {
evidence,
agentPath: options.agentPath,
phpImage: options.phpImage,
tempDir,
requestCount: state.requests.length,
};
} finally {
if (child) {
await stopChild(child);
}
for (const socket of broker.sockets) {
socket.destroy();
}
await Promise.allSettled([
closeServer(broker.server),
closeServer(workerServer),
apiServer ? closeServer(apiServer) : Promise.resolve(),
]);
if (!options.keepTemp) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
}
async function main() {
const options = parseArgs();
if (options.help) {
printUsage();
return;
}
const result = await runProof(options);
process.stdout.write("PASS broker-connected API command poll triggered local relay switch and posted result\n");
process.stdout.write(`${JSON.stringify(result.evidence)}\n`);
process.stdout.write(`Agent: ${result.agentPath}\n`);
process.stdout.write(`PHP image: ${result.phpImage}\n`);
if (options.keepTemp) {
process.stdout.write(`Temp dir: ${result.tempDir}\n`);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((error) => {
process.stderr.write(`FAIL ${error.message}\n`);
if (error.evidence) {
process.stderr.write(`Evidence: ${JSON.stringify(error.evidence)}\n`);
}
if (error.requests) {
process.stderr.write(`Requests: ${JSON.stringify(error.requests, null, 2)}\n`);
}
if (error.stdout) {
process.stderr.write(`stdout: ${error.stdout}\n`);
}
if (error.stderr) {
process.stderr.write(`stderr: ${error.stderr}\n`);
}
process.exit(1);
});
}
+619
View File
@@ -0,0 +1,619 @@
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const DEFAULT_AGENT_PATH = path.join(
repoRoot,
"services/nginx/app/resources/edge-gateway-agent/agent.php"
);
const DEFAULT_WORKER_PATH = path.join(
repoRoot,
"services/nginx/app/resources/edge-gateway-agent/lan-worker.php"
);
const DEFAULT_PHP_IMAGE = "php:8.2-cli-bookworm";
const DEFAULT_TIMEOUT_MS = 15000;
const AGENT_TOKEN = "agent-token";
function parseArgs(argv = process.argv.slice(2)) {
const options = {
agentPath: DEFAULT_AGENT_PATH,
workerPath: DEFAULT_WORKER_PATH,
phpImage: DEFAULT_PHP_IMAGE,
timeoutMs: DEFAULT_TIMEOUT_MS,
keepTemp: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const next = argv[index + 1];
switch (arg) {
case "--agent-path":
options.agentPath = path.resolve(String(next || "").trim());
index += 1;
break;
case "--worker-path":
options.workerPath = path.resolve(String(next || "").trim());
index += 1;
break;
case "--php-image":
options.phpImage = String(next || "").trim() || DEFAULT_PHP_IMAGE;
index += 1;
break;
case "--timeout-ms":
options.timeoutMs = Number.parseInt(String(next || ""), 10) || DEFAULT_TIMEOUT_MS;
index += 1;
break;
case "--keep-temp":
options.keepTemp = true;
break;
case "--help":
case "-h":
options.help = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
function printUsage() {
process.stdout.write(`Usage:
node scripts/edge-agent-to-shelly-proof.mjs [options]
Runs the PHP edge agent and real LAN worker against fake broker, API, and
Shelly RPC endpoints. Verifies that a broker-connected SET_RELAY_STATE command
drains from the API, reaches the worker, triggers a Shelly-style Switch.Set
call, reads Switch.GetStatus, and posts the command result.
Options:
--agent-path <path> PHP agent artifact to execute.
Default: ${DEFAULT_AGENT_PATH}
--worker-path <path> PHP LAN worker artifact to execute.
Default: ${DEFAULT_WORKER_PATH}
--php-image <image> Docker PHP image with curl, sqlite3, and pdo_sqlite.
Default: ${DEFAULT_PHP_IMAGE}
--timeout-ms <ms> Proof timeout. Default: ${DEFAULT_TIMEOUT_MS}
--keep-temp Keep the temporary config/runtime directory.
--help Show this help text.
`);
}
function readJson(request) {
return new Promise((resolve) => {
let raw = "";
request.setEncoding("utf8");
request.on("data", (chunk) => {
raw += chunk;
});
request.on("end", () => {
if (raw.trim() === "") {
resolve({});
return;
}
try {
resolve(JSON.parse(raw));
} catch {
resolve({ __invalid: raw });
}
});
});
}
function sendJson(response, status, payload) {
const body = JSON.stringify(payload);
response.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(body),
});
response.end(body);
}
function requestJson({ method = "GET", port, path: requestPath, body = null, headers = {} }) {
return new Promise((resolve, reject) => {
const payload = body === null ? null : JSON.stringify(body);
const request = http.request({
hostname: "127.0.0.1",
port,
path: requestPath,
method,
headers: {
accept: "application/json",
...(payload === null ? {} : {
"content-type": "application/json",
"content-length": Buffer.byteLength(payload),
}),
...headers,
},
timeout: 1000,
}, (response) => {
let raw = "";
response.setEncoding("utf8");
response.on("data", (chunk) => {
raw += chunk;
});
response.on("end", () => {
let decoded = {};
try {
decoded = raw.trim() === "" ? {} : JSON.parse(raw);
} catch {
decoded = { __invalid: raw };
}
resolve({ status: response.statusCode || 0, body: decoded });
});
});
request.on("error", reject);
request.on("timeout", () => {
request.destroy(new Error("request timed out"));
});
if (payload !== null) {
request.write(payload);
}
request.end();
});
}
function listen(server) {
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
});
}
function closeServer(server) {
return new Promise((resolve) => {
server.close(() => resolve());
});
}
async function reservePort() {
const server = net.createServer();
const port = await new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
});
await closeServer(server);
return port;
}
function websocketAcceptKey(key) {
return crypto
.createHash("sha1")
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
.digest("base64");
}
function createBrokerServer(state) {
const sockets = new Set();
const server = net.createServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
let buffer = "";
socket.on("data", (chunk) => {
buffer += chunk.toString("binary");
if (state.brokerHandshakeSeen || !buffer.includes("\r\n\r\n")) {
return;
}
const requestText = Buffer.from(buffer, "binary").toString("utf8");
const key = requestText.match(/Sec-WebSocket-Key:\s*(.+)\r\n/i)?.[1]?.trim();
const requestLine = requestText.split("\r\n")[0] || "";
if (!requestLine.includes("/ws/agent?")) {
state.failure = new Error(`unexpected broker path: ${requestLine}`);
}
if (!key) {
state.failure = new Error("broker handshake missing Sec-WebSocket-Key");
return;
}
socket.write([
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${websocketAcceptKey(key)}`,
"",
"",
].join("\r\n"));
state.brokerHandshakeSeen = true;
buffer = "";
});
});
return { server, sockets };
}
function createShellyServer(state) {
return http.createServer((request, response) => {
const url = new URL(request.url, "http://127.0.0.1");
state.requests.push({
service: "shelly",
method: request.method,
path: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
});
if (request.method === "GET" && url.pathname === "/rpc/Switch.Set") {
state.shellySwitchSetSeen = true;
if (url.searchParams.get("id") !== "0" || url.searchParams.get("on") !== "true") {
state.failure = new Error(`unexpected Shelly Switch.Set query: ${url.search}`);
}
sendJson(response, 200, { was_on: false, output: true });
return;
}
if (request.method === "GET" && url.pathname === "/rpc/Switch.GetStatus") {
state.shellyStatusSeen = true;
if (url.searchParams.get("id") !== "0") {
state.failure = new Error(`unexpected Shelly Switch.GetStatus query: ${url.search}`);
}
sendJson(response, 200, { id: 0, output: true, source: "fake-shelly-rpc" });
return;
}
if (url.pathname.startsWith("/relay/")) {
state.failure = new Error(`legacy Shelly endpoint should not be used for generation 2 proof: ${url.pathname}`);
}
sendJson(response, 404, { message: "not found" });
});
}
function createApiServer(state, brokerPort, shellyAddress) {
return http.createServer(async (request, response) => {
const url = new URL(request.url, "http://127.0.0.1");
const body = await readJson(request);
state.requests.push({ service: "api", method: request.method, path: url.pathname, body });
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/heartbeat") {
sendJson(response, 200, { data: { ok: true, broker_url: `ws://127.0.0.1:${brokerPort}` } });
return;
}
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/selfserve/machine-signal-bindings") {
sendJson(response, 200, { data: { monitors: [] } });
return;
}
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/poll") {
state.commandPollSeen = true;
if (body.wait_seconds !== 0) {
state.failure = new Error(
`broker-connected command poll should be non-blocking, got wait_seconds=${body.wait_seconds}`
);
}
if (!state.commandDelivered) {
state.commandDelivered = true;
sendJson(response, 200, {
data: {
id: 77,
command_type: "SET_RELAY_STATE",
payload: {
localIp: shellyAddress,
local_ip: shellyAddress,
channel: 0,
on: true,
relayId: "relay-proof",
relay_id: "relay-proof",
deviceGeneration: 2,
device_generation: 2,
},
},
});
return;
}
sendJson(response, 200, { data: null });
return;
}
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/77/result") {
state.resultSeen = true;
if (
body.ok !== true ||
body.result?.on !== true ||
body.result?.output !== true ||
body.result?.raw?.source !== "fake-shelly-rpc"
) {
state.failure = new Error(`unexpected command result: ${JSON.stringify(body)}`);
}
sendJson(response, 200, { data: { acknowledged: true } });
return;
}
sendJson(response, 404, { message: "not found", path: url.pathname });
});
}
function writeConfig(tempDir, apiPort, brokerPort, workerPort) {
const containerProofDir = "/proof";
const runtimeDir = `${containerProofDir}/runtime`;
const config = {
apiUrl: `http://127.0.0.1:${apiPort}`,
brokerUrl: `ws://127.0.0.1:${brokerPort}`,
gatewayId: 42,
agentToken: AGENT_TOKEN,
installDir: containerProofDir,
runtimeDir,
stateDatabasePath: `${runtimeDir}/gateway-state.sqlite`,
workerBaseUrl: `http://127.0.0.1:${workerPort}`,
heartbeatIntervalSeconds: 60,
operationPollTimeoutSeconds: 20,
};
const configPath = path.join(tempDir, "config.json");
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return { containerConfigPath: `${containerProofDir}/config.json` };
}
function spawnWorker({ workerPath, phpImage, workerPort }) {
return spawn("docker", [
"run",
"--rm",
"--network",
"host",
"-e",
`TRUCKWASH_WORKER_TOKEN=${AGENT_TOKEN}`,
"-v",
`${workerPath}:/lan-worker.php:ro`,
phpImage,
"php",
"-S",
`127.0.0.1:${workerPort}`,
"/lan-worker.php",
], { stdio: ["ignore", "pipe", "pipe"] });
}
function spawnAgent({ agentPath, phpImage, tempDir, containerConfigPath }) {
return spawn("docker", [
"run",
"--rm",
"--network",
"host",
"-v",
`${agentPath}:/agent.php:ro`,
"-v",
`${tempDir}:/proof`,
phpImage,
"php",
"/agent.php",
"--config",
containerConfigPath,
], { stdio: ["ignore", "pipe", "pipe"] });
}
async function waitForWorker(workerPort, child, timeoutMs) {
const deadline = Date.now() + timeoutMs;
let lastError = null;
while (Date.now() < deadline) {
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(`LAN worker exited before becoming healthy: ${child.exitCode ?? child.signalCode}`);
}
try {
const response = await requestJson({ port: workerPort, path: "/health" });
if (response.status === 200 && response.body?.service === "lan-worker") {
return;
}
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw lastError || new Error("LAN worker did not become healthy");
}
async function stopChild(child) {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
child.kill("SIGTERM");
const hardKill = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}, 1500);
await Promise.race([
new Promise((resolve) => child.once("exit", resolve)),
new Promise((resolve) => setTimeout(resolve, 2200)),
]);
clearTimeout(hardKill);
}
function evidenceFromState(state, agentExited, workerExited) {
return {
brokerHandshakeSeen: state.brokerHandshakeSeen,
commandPollSeen: state.commandPollSeen,
shellySwitchSetSeen: state.shellySwitchSetSeen,
shellyStatusSeen: state.shellyStatusSeen,
resultSeen: state.resultSeen,
agentStayedRunningUntilProofComplete: !agentExited,
workerStayedRunningUntilProofComplete: !workerExited,
};
}
export async function runProof(options) {
if (process.platform !== "linux") {
throw new Error("This proof uses Docker --network host and currently expects Linux.");
}
if (!fs.existsSync(options.agentPath)) {
throw new Error(`Agent artifact not found: ${options.agentPath}`);
}
if (!fs.existsSync(options.workerPath)) {
throw new Error(`LAN worker artifact not found: ${options.workerPath}`);
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "edge-agent-to-shelly-proof-"));
fs.mkdirSync(path.join(tempDir, "runtime"), { recursive: true });
const state = {
brokerHandshakeSeen: false,
commandPollSeen: false,
shellySwitchSetSeen: false,
shellyStatusSeen: false,
resultSeen: false,
commandDelivered: false,
failure: null,
requests: [],
};
const broker = createBrokerServer(state);
const shellyServer = createShellyServer(state);
let apiServer = null;
let agent = null;
let worker = null;
let agentStdout = "";
let agentStderr = "";
let workerStdout = "";
let workerStderr = "";
let agentExited = false;
let workerExited = false;
try {
const brokerPort = await listen(broker.server);
const shellyPort = await listen(shellyServer);
const workerPort = await reservePort();
const shellyAddress = `127.0.0.1:${shellyPort}`;
apiServer = createApiServer(state, brokerPort, shellyAddress);
const apiPort = await listen(apiServer);
const { containerConfigPath } = writeConfig(tempDir, apiPort, brokerPort, workerPort);
worker = spawnWorker({ ...options, workerPort });
worker.stdout.on("data", (chunk) => {
workerStdout += chunk.toString();
});
worker.stderr.on("data", (chunk) => {
workerStderr += chunk.toString();
});
worker.once("exit", () => {
workerExited = true;
});
await waitForWorker(workerPort, worker, 5000);
agent = spawnAgent({ ...options, tempDir, containerConfigPath });
agent.stdout.on("data", (chunk) => {
agentStdout += chunk.toString();
});
agent.stderr.on("data", (chunk) => {
agentStderr += chunk.toString();
});
agent.once("exit", () => {
agentExited = true;
});
const deadline = Date.now() + options.timeoutMs;
while (Date.now() < deadline && !state.failure && !agentExited && !workerExited) {
if (
state.brokerHandshakeSeen &&
state.commandPollSeen &&
state.shellySwitchSetSeen &&
state.shellyStatusSeen &&
state.resultSeen
) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
const evidence = evidenceFromState(state, agentExited, workerExited);
if (
state.failure ||
!state.brokerHandshakeSeen ||
!state.commandPollSeen ||
!state.shellySwitchSetSeen ||
!state.shellyStatusSeen ||
!state.resultSeen
) {
const error = state.failure || new Error("missing proof evidence");
error.evidence = evidence;
error.requests = state.requests;
error.agentStdout = agentStdout.slice(-3000);
error.agentStderr = agentStderr.slice(-3000);
error.workerStdout = workerStdout.slice(-3000);
error.workerStderr = workerStderr.slice(-3000);
throw error;
}
return {
evidence,
agentPath: options.agentPath,
workerPath: options.workerPath,
phpImage: options.phpImage,
tempDir,
requestCount: state.requests.length,
};
} finally {
if (agent) {
await stopChild(agent);
}
if (worker) {
await stopChild(worker);
}
for (const socket of broker.sockets) {
socket.destroy();
}
await Promise.allSettled([
closeServer(broker.server),
closeServer(shellyServer),
apiServer ? closeServer(apiServer) : Promise.resolve(),
]);
if (!options.keepTemp) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
}
async function main() {
const options = parseArgs();
if (options.help) {
printUsage();
return;
}
const result = await runProof(options);
process.stdout.write("PASS broker-connected API command triggered real LAN worker Shelly RPC signal and posted result\n");
process.stdout.write(`${JSON.stringify(result.evidence)}\n`);
process.stdout.write(`Agent: ${result.agentPath}\n`);
process.stdout.write(`LAN worker: ${result.workerPath}\n`);
process.stdout.write(`PHP image: ${result.phpImage}\n`);
if (options.keepTemp) {
process.stdout.write(`Temp dir: ${result.tempDir}\n`);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((error) => {
process.stderr.write(`FAIL ${error.message}\n`);
if (error.evidence) {
process.stderr.write(`Evidence: ${JSON.stringify(error.evidence)}\n`);
}
if (error.requests) {
process.stderr.write(`Requests: ${JSON.stringify(error.requests, null, 2)}\n`);
}
if (error.agentStdout) {
process.stderr.write(`agent stdout: ${error.agentStdout}\n`);
}
if (error.agentStderr) {
process.stderr.write(`agent stderr: ${error.agentStderr}\n`);
}
if (error.workerStdout) {
process.stderr.write(`worker stdout: ${error.workerStdout}\n`);
}
if (error.workerStderr) {
process.stderr.write(`worker stderr: ${error.workerStderr}\n`);
}
process.exit(1);
});
}
+125 -54
View File
@@ -94,9 +94,23 @@ function directCaddyBaseUrl(baseUrl) {
return normalizeBaseUrl(url.toString());
}
function isLocalHost(hostname) {
const normalized = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
}
function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
const websocketUrl = new URL(String(rawUrl));
const apiUrl = new URL(normalizeBaseUrl(apiBaseUrl));
const ciBrokerPort = String(process.env.EDGE_BROKER_CI_PORT || "").trim();
if (isLocalHost(apiUrl.hostname) && websocketUrl.hostname === "edge-broker" && ciBrokerPort !== "") {
websocketUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
websocketUrl.hostname = apiUrl.hostname;
websocketUrl.port = ciBrokerPort;
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
return websocketUrl.toString();
}
if (apiUrl.hostname === "caddy" && websocketUrl.hostname === "caddy") {
websocketUrl.hostname = "edge-broker";
@@ -104,6 +118,18 @@ function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
}
if (isLocalHost(apiUrl.hostname) && ["caddy", "edge-broker"].includes(websocketUrl.hostname)) {
const brokerPath = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
websocketUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
websocketUrl.hostname = apiUrl.hostname;
websocketUrl.port = apiUrl.port;
websocketUrl.pathname = `/api/edge-broker${brokerPath}`;
}
if (isLocalHost(websocketUrl.hostname)) {
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
}
return websocketUrl.toString();
}
@@ -555,6 +581,38 @@ function summarizeStreamMessages(messages, limit = 12) {
.filter(Boolean);
}
async function readGatewayDiagnostics({ baseUrl, authToken, gatewayId, containerName }) {
const diagnostics = {};
if (gatewayId !== null && gatewayId > 0 && authToken) {
try {
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
token: authToken,
});
diagnostics.gateway = {
status: detail?.data?.status ?? null,
channelStatus: detail?.data?.channel_status ?? null,
brokerPresence: detail?.data?.metadata?.broker_presence ?? null,
brokerConnected: detail?.data?.metadata?.broker_connected ?? null,
brokerLastError: detail?.data?.metadata?.broker_last_error ?? null,
};
} catch (error) {
diagnostics.gatewayError = error instanceof Error ? error.message : String(error);
}
}
try {
const logs = await runCommand("docker", ["logs", "--tail", "120", containerName], {
allowFailure: true,
});
diagnostics.containerLogs = String(`${logs.stdout || ""}${logs.stderr || ""}`).trim().split(/\r?\n/).slice(-120);
} catch (error) {
diagnostics.containerLogError = error instanceof Error ? error.message : String(error);
}
return diagnostics;
}
async function main() {
const scriptPath = fileURLToPath(import.meta.url);
const rootDir = await resolveRootDir(scriptPath);
@@ -669,22 +727,35 @@ async function main() {
}
);
await waitForCondition(
async () => {
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
token: authToken,
});
try {
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.",
}
);
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.",
}
);
} catch (error) {
const diagnostics = await readGatewayDiagnostics({
baseUrl,
authToken,
gatewayId,
containerName,
});
throw new Error([
error instanceof Error ? error.message : String(error),
`Broker diagnostics: ${JSON.stringify(diagnostics, null, 2)}`,
].join("\n"));
}
const WebSocketImpl = await loadWebSocketImplementation();
const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, {
@@ -707,11 +778,14 @@ async function main() {
{ 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."
await waitForSocketMessage(
streamMessages,
(message) => (
message?.type === "gateway.stream.ready" && message?.connected === true
) || (
message?.type === "presence.changed" && message?.status === "connected"
),
{ timeoutMs: 45_000, message: "Gateway stream never observed a connected broker presence." }
);
const operationResponse = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/operations`, {
@@ -739,10 +813,24 @@ async function main() {
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." }
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"
|| streamMessages.some((message) => (
message?.type === "task.updated"
&& Number(message?.operationId || 0) === operationId
&& message?.operation?.status === "COMPLETED"
));
},
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
);
} catch (error) {
let operationSnapshot = null;
@@ -765,21 +853,6 @@ async function main() {
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",
@@ -855,22 +928,20 @@ async function main() {
{ 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 || ""))
: [];
await waitForCondition(
async () => {
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 || ""))
: [];
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
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."
return shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell"))
&& timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED");
},
{ timeoutMs: 30_000, message: "Gateway logs page did not persist the shell transcript and close audit event." }
);
process.stdout.write("Edge gateway E2E smoke completed successfully.\n");
+43 -6
View File
@@ -18,6 +18,7 @@ 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}}"
export COMPOSE_PROFILES="${COMPOSE_PROFILES:-dev}"
log_dir=".tmp/ci-logs/$suite"
mkdir -p "$log_dir"
@@ -68,6 +69,33 @@ retry_command() {
done
}
composer_install() {
dist_attempts="${PHP_CI_COMPOSER_RETRIES:-3}"
source_attempts="${PHP_CI_COMPOSER_SOURCE_RETRIES:-2}"
if retry_command "$dist_attempts" \
docker compose $compose_files exec -T php1 sh -lc \
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'; then
return 0
fi
echo "Composer dist install failed after ${dist_attempts} attempts; retrying with --prefer-source." >&2
retry_command "$source_attempts" \
docker compose $compose_files exec -T php1 sh -lc \
'cd /var/www/html && composer install --no-interaction --prefer-source --no-progress'
}
configure_ci_docker_subnet() {
if [ -n "${CI_DOCKER_SUBNET:-}" ]; then
return
fi
checksum="$(printf '%s' "$COMPOSE_PROJECT_NAME" | cksum | awk '{print $1}')"
subnet_second=$((64 + ((checksum / 256) % 64)))
subnet_third=$((checksum % 256))
export CI_DOCKER_SUBNET="10.${subnet_second}.${subnet_third}.0/24"
}
cleanup() {
status="$?"
collect_logs "$status"
@@ -87,7 +115,8 @@ cleanup() {
}
trap cleanup EXIT INT TERM
retry_command "${PHP_CI_DOCKER_RETRIES:-3}" docker compose $compose_files up -d redis mysql-debug php1
configure_ci_docker_subnet
sh scripts/ci-docker-compose-up.sh redis mysql-debug php1
docker compose $compose_files exec -T php1 sh -lc '
set -eu
@@ -110,10 +139,18 @@ tar \
--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 tar --no-same-owner -C /var/www/html -xf -
docker compose $compose_files exec -T php1 sh -lc 'rm -rf /var/www/repo-root && mkdir -p /var/www/repo-root'
tar \
-cf - \
Dockerfile \
Dockerfile.coolify-api \
services/php/Dockerfile \
services/php/php-fpm-pool.conf \
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/repo-root -xf -
composer_install
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"
"cd /var/www/html && PLENO_REPO_ROOT_FOR_TESTS=/var/www/repo-root composer test:ci:$suite"
+73 -3
View File
@@ -1,14 +1,32 @@
import process from "node:process";
import path from "node:path";
import { createHash } from "node:crypto";
import { pathToFileURL } from "node:url";
export const DEFAULT_STAGING_BASE_URL = "https://api.truckwash.io:4433";
export const EXPECTED_INSTALL_VERSION = "compose-php-agent-v3";
export const REQUIRED_MANIFEST_ARTIFACTS = [
"agent.php",
"lan-worker.php",
"auto-updater.php",
"docker-compose.gateway.yml",
"Dockerfile.edge-agent",
"Dockerfile.lan-worker",
"Dockerfile.auto-updater",
"gateway-launcher.sh",
"truckwash-edge-gateway-stack.service",
"truckwash-edge-agent.service",
];
export const INSTALLER_SCRIPT_REQUIRED_SNIPPETS = [
"/edge-agent/install-token/status",
"/edge-agent/artifacts/manifest.json",
"report_install_status",
'begin_install_phase "VERIFY_TOKEN"',
'begin_install_phase "VERIFY_ARTIFACTS"',
'begin_install_phase "WAIT_FOR_CLAIM"',
'report_install_status "FAILED"',
"verify_manifest_artifact",
EXPECTED_INSTALL_VERSION,
];
export function normalizeBaseUrl(url) {
@@ -55,13 +73,20 @@ export function buildChecks(baseUrl, installToken) {
name: "Ping",
url: `${normalizedBaseUrl}/ping`,
},
{
name: "Artifact manifest",
url: `${normalizedBaseUrl}/edge-agent/artifacts/manifest.json`,
artifactName: "manifest.json",
},
{
name: "Agent PHP artifact",
url: `${normalizedBaseUrl}/edge-agent/artifacts/agent.php`,
artifactName: "agent.php",
},
{
name: "Service unit artifact",
url: `${normalizedBaseUrl}/edge-agent/artifacts/truckwash-edge-agent.service`,
artifactName: "truckwash-edge-agent.service",
},
{
name: "Installer script",
@@ -70,6 +95,42 @@ export function buildChecks(baseUrl, installToken) {
];
}
export function validateArtifactManifestBody(body) {
const manifest = JSON.parse(String(body || ""));
if (manifest.version !== EXPECTED_INSTALL_VERSION) {
throw new Error(`Artifact manifest version mismatch: expected ${EXPECTED_INSTALL_VERSION}, got ${manifest.version}`);
}
if (!Array.isArray(manifest.artifacts)) {
throw new Error("Artifact manifest is missing artifacts.");
}
const byName = new Map(manifest.artifacts.map((artifact) => [artifact?.name, artifact]));
const missingArtifacts = REQUIRED_MANIFEST_ARTIFACTS.filter((artifactName) => !byName.has(artifactName));
if (missingArtifacts.length) {
throw new Error(`Artifact manifest is missing required artifacts: ${missingArtifacts.join(", ")}`);
}
return manifest;
}
export function validateArtifactBodyAgainstManifest(manifest, artifactName, body) {
const artifact = manifest?.artifacts?.find((entry) => entry?.name === artifactName);
if (!artifact) {
throw new Error(`Artifact ${artifactName} is missing from manifest.`);
}
const buffer = Buffer.isBuffer(body) ? body : Buffer.from(String(body || ""));
const sha256 = createHash("sha256").update(buffer).digest("hex");
if (sha256 !== artifact.sha256) {
throw new Error(`Artifact ${artifactName} hash mismatch: ${sha256} !== ${artifact.sha256}`);
}
if (buffer.length !== artifact.bytes) {
throw new Error(`Artifact ${artifactName} size mismatch: ${buffer.length} !== ${artifact.bytes}`);
}
return artifact;
}
export function validateInstallerScriptBody(body) {
const source = String(body || "");
const missingSnippets = INSTALLER_SCRIPT_REQUIRED_SNIPPETS.filter((snippet) => !source.includes(snippet));
@@ -107,16 +168,17 @@ export async function runSmoke({ baseUrl, installToken }) {
const checks = buildChecks(baseUrl, installToken);
const results = [];
let artifactManifest = null;
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 body = Buffer.from(await response.arrayBuffer());
const result = {
...check,
status: response.status,
ok: response.ok,
bodyPreview: previewBody(body),
bodyPreview: previewBody(body.toString("utf8")),
};
results.push(result);
@@ -127,8 +189,16 @@ export async function runSmoke({ baseUrl, installToken }) {
);
}
if (check.name === "Artifact manifest") {
artifactManifest = validateArtifactManifestBody(body.toString("utf8"));
result.version = artifactManifest.version;
result.artifactCount = artifactManifest.artifacts.length;
}
if (artifactManifest && check.artifactName && check.artifactName !== "manifest.json") {
result.verifiedArtifact = validateArtifactBodyAgainstManifest(artifactManifest, check.artifactName, body);
}
if (check.name === "Installer script") {
result.verifiedSnippets = validateInstallerScriptBody(body);
result.verifiedSnippets = validateInstallerScriptBody(body.toString("utf8"));
}
}
@@ -3,10 +3,13 @@ import assert from "node:assert/strict";
import {
DEFAULT_STAGING_BASE_URL,
EXPECTED_INSTALL_VERSION,
INSTALLER_SCRIPT_REQUIRED_SNIPPETS,
buildChecks,
normalizeBaseUrl,
parseArgs,
validateArtifactBodyAgainstManifest,
validateArtifactManifestBody,
validateInstallerScriptBody,
} from "./staging-edge-gateway-smoke.mjs";
@@ -32,18 +35,58 @@ test("buildChecks targets the public staging endpoints", () => {
assert.deepEqual(checks.map((check) => check.url), [
"https://api.truckwash.io:4433/ping",
"https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json",
"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("validateArtifactManifestBody requires v3 install artifacts", () => {
const artifacts = [
"agent.php",
"lan-worker.php",
"auto-updater.php",
"docker-compose.gateway.yml",
"Dockerfile.edge-agent",
"Dockerfile.lan-worker",
"Dockerfile.auto-updater",
"gateway-launcher.sh",
"truckwash-edge-gateway-stack.service",
"truckwash-edge-agent.service",
].map((name) => ({ name, sha256: "abc", bytes: 1 }));
const manifest = validateArtifactManifestBody(JSON.stringify({
version: EXPECTED_INSTALL_VERSION,
artifacts,
}));
assert.equal(manifest.version, EXPECTED_INSTALL_VERSION);
});
test("validateArtifactBodyAgainstManifest verifies size and hash", () => {
const body = Buffer.from("hello");
const manifest = {
artifacts: [{
name: "agent.php",
sha256: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
bytes: body.length,
}],
};
assert.equal(validateArtifactBodyAgainstManifest(manifest, "agent.php", body).name, "agent.php");
});
test("validateInstallerScriptBody requires install-session reporting wiring", () => {
const script = `
INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"
fetch_http "Download artifact manifest" "https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json"
report_install_status "FAILED"
begin_install_phase "VERIFY_TOKEN" "Verifying install token"
begin_install_phase "VERIFY_ARTIFACTS" "Verifying edge gateway artifacts"
begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"
verify_manifest_artifact
${EXPECTED_INSTALL_VERSION}
`;
assert.deepEqual(validateInstallerScriptBody(script), INSTALLER_SCRIPT_REQUIRED_SNIPPETS);
+15 -9
View File
@@ -12,7 +12,7 @@ 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_CONTAINER_BROKER_URL = "ws://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`;
@@ -409,14 +409,20 @@ async function startContainer({
});
if (copyConfig) {
await runCommand("docker", [
"cp",
path.join(configDir, DEFAULT_CONFIG_FILE_NAME),
`${containerName}:${containerConfigPath}`,
], {
cwd: rootDir,
stdio: "inherit",
});
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
await fs.chmod(configFilePath, 0o666).catch(() => {});
try {
await runCommand("docker", [
"cp",
configFilePath,
`${containerName}:${containerConfigPath}`,
], {
cwd: rootDir,
stdio: "inherit",
});
} finally {
await fs.chmod(configFilePath, 0o600).catch(() => {});
}
await runCommand("docker", ["start", containerName], {
cwd: rootDir,
+58
View File
@@ -754,6 +754,60 @@ export async function setRelayState(payload, fetchImpl = fetch) {
}
}
async function mapWithConcurrency(items, limit, mapper) {
const results = new Array(items.length);
let nextIndex = 0;
const workerCount = Math.max(1, Math.min(Number(limit) || 1, items.length || 1));
await Promise.all(Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await mapper(items[index], index);
}
}));
return results;
}
async function executeRelayBatch(command, handler, fetchImpl = fetch) {
const commands = Array.isArray(command?.payload?.commands)
? command.payload.commands
: Array.isArray(command?.commands)
? command.commands
: [];
const concurrency = Math.max(1, Math.min(Number(command?.payload?.concurrency || command?.concurrency || 5), 5));
const results = await mapWithConcurrency(commands, concurrency, async (entry = {}) => {
const target = String(entry.target || entry.relay || "");
const relayId = String(entry.relayId || entry.relay_id || "");
try {
const payload = await handler(entry, fetchImpl);
return {
target,
relayId,
relay_id: relayId,
ok: true,
payload,
};
} catch (error) {
return {
target,
relayId,
relay_id: relayId,
ok: false,
error: error instanceof Error ? error.message : String(error),
};
}
});
return {
batchId: command?.payload?.batchId || command?.payload?.batch_id || command?.batchId || command?.batch_id || null,
batch_id: command?.payload?.batch_id || command?.payload?.batchId || command?.batch_id || command?.batchId || null,
results,
};
}
async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch) {
if (!url) {
return null;
@@ -1416,6 +1470,10 @@ export async function handleAgentCommand(command, deps = {}) {
return await getRelayStatus(command.payload || {}, fetchImpl);
case "SET_RELAY_STATE":
return await setRelayState(command.payload || {}, fetchImpl);
case "BATCH_RELAY_STATUS":
return await executeRelayBatch(command, getRelayStatus, fetchImpl);
case "BATCH_SET_RELAY_STATE":
return await executeRelayBatch(command, setRelayState, fetchImpl);
case "RUN_UPDATE":
return await runUpdate(command.payload || {}, fetchImpl, deps);
case "UNINSTALL_AGENT":
+33
View File
@@ -127,6 +127,39 @@ test("relay status and switch commands support both Shelly RPC and legacy endpoi
assert.equal(switched.on, false);
});
test("batch relay commands return per-relay results without failing the whole batch", async () => {
const fakeFetch = async (url) => {
const value = String(url);
if (value.includes("10.1.0.31")) {
return {
ok: true,
async json() {
return { output: true };
},
};
}
throw new Error("relay offline");
};
const result = await handleAgentCommand({
commandType: "BATCH_SET_RELAY_STATE",
payload: {
batch_id: "batch-1",
commands: [
{ target: "MACHINE", relayId: "relay-machine", localIp: "10.1.0.31", channel: 0, on: true },
{ target: "EXIT", relayId: "relay-out", localIp: "10.1.0.32", channel: 0, on: true },
],
},
}, { fetchImpl: fakeFetch });
assert.equal(result.batch_id, "batch-1");
assert.equal(result.results.length, 2);
assert.equal(result.results[0].ok, true);
assert.equal(result.results[0].target, "MACHINE");
assert.equal(result.results[1].ok, false);
assert.match(result.results[1].error, /relay offline/);
});
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");
+11
View File
@@ -296,6 +296,12 @@ export function createBrokerServer(options = {}) {
? async (_gatewayId, payload = {}) => payload
: async (gatewayId, payload = {}) =>
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/logs`, payload));
const ingestMachineSignal =
options.ingestMachineSignal ||
(authMode === "stub"
? async (_gatewayId, payload = {}) => payload
: async (gatewayId, payload = {}) =>
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/selfserve/machine-signal`, payload));
const broadcastGatewayEvent = (gatewayId, message) => {
const sessionIds = gatewayStreamSessions.get(String(gatewayId));
@@ -853,6 +859,11 @@ export function createBrokerServer(options = {}) {
return;
}
if (message.type === "MACHINE_SIGNAL") {
await ingestMachineSignal(String(ws.gatewayId), message.payload || {});
return;
}
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
const sessionRecord = browserShellSessions.get(String(message.sessionId));
if (!sessionRecord) {
+45
View File
@@ -766,6 +766,51 @@ test("broker fans out telemetry, task, log, and presence updates to browser gate
await broker.close();
});
test("broker ingests self-serve machine signals from connected agents", async () => {
const machineSignals = [];
const broker = createBrokerServer({
authMode: "stub",
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
ingestMachineSignal: async (gatewayId, payload) => {
machineSignals.push({ gatewayId, payload });
return { recorded: true, lane_id: payload.lane_id };
},
});
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: "MACHINE_SIGNAL",
payload: {
lane_id: 3,
relay_id: "machine-relay",
component: "input",
channel: 0,
event: "input.toggle_on",
state: true,
},
})
);
await waitFor(() => machineSignals.length === 1, { description: "machine signal ingestion" });
assert.equal(machineSignals[0].gatewayId, "701");
assert.deepEqual(machineSignals[0].payload, {
lane_id: 3,
relay_id: "machine-relay",
component: "input",
channel: 0,
event: "input.toggle_on",
state: true,
});
agent.terminate();
await broker.close();
});
test("broker survives telemetry ingestion failures for stale gateways", async () => {
const broker = createBrokerServer({
authMode: "stub",
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -37,8 +37,21 @@ class attachment_store implements minio_uploads_i
*/
public function isValidFilePath(string $filePath): bool
{
// Check if the file path is valid
return preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $filePath) === 1;
if (
$filePath === ''
|| str_starts_with($filePath, '/')
|| preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $filePath) !== 1
) {
return false;
}
foreach (explode('/', $filePath) as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
return false;
}
}
return true;
}
/**
@@ -82,7 +95,10 @@ class attachment_store implements minio_uploads_i
{
$host = 'https://api.truckwash.io';
$this->requireValidFilePath($fileName);
$encodedPath = implode('/', array_map('rawurlencode', explode('/', $fileName)));
// Generate a direct download URL for the given file name
return $host . '/files/' . $fileName;
return $host . '/files/' . $encodedPath;
}
}
+7 -1
View File
@@ -133,8 +133,14 @@ class attachments implements attachments_i
protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array
{
$options = $this->normalizeAttachmentOptions($options);
$rawType = trim($type, '`');
$objectTypes = array_values(array_unique([
$rawType,
'`' . $rawType . '`',
]));
return (new object_attachments_o())->getFieldsWhereIn([
'object_type' => $type,
'object_type' => $objectTypes,
'object_id' => $object_ids,
'deleted_at' => null
], $options);
@@ -0,0 +1,127 @@
<?php
namespace classes;
class backup_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"CREATE TABLE IF NOT EXISTS backup_records (
backup_uuid VARCHAR(64) NOT NULL PRIMARY KEY,
name VARCHAR(191) NOT NULL,
description TEXT NULL,
source VARCHAR(32) NOT NULL DEFAULT 'manual',
status VARCHAR(32) NOT NULL DEFAULT 'queued',
schema_version INT UNSIGNED NOT NULL DEFAULT 2,
storage_bucket VARCHAR(191) NOT NULL DEFAULT 'backups',
storage_prefix VARCHAR(255) NOT NULL,
manifest_key VARCHAR(255) NULL,
manifest_sha256 CHAR(64) NULL,
encryption_key_id VARCHAR(191) NULL,
component_count INT UNSIGNED NOT NULL DEFAULT 0,
object_count INT UNSIGNED NOT NULL DEFAULT 0,
total_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
requested_by_user_id INT NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
verified_at DATETIME NULL,
expires_at DATETIME NULL,
last_error TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_backup_records_status_created (status, created_at),
KEY idx_backup_records_verified (verified_at),
KEY idx_backup_records_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS backup_components (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
backup_uuid VARCHAR(64) NOT NULL,
component_type VARCHAR(32) NOT NULL,
logical_name VARCHAR(191) NOT NULL,
source_bucket VARCHAR(191) NULL,
source_prefix VARCHAR(255) NULL,
storage_key VARCHAR(255) NULL,
manifest_key VARCHAR(255) NULL,
object_count INT UNSIGNED NOT NULL DEFAULT 0,
byte_size BIGINT UNSIGNED NOT NULL DEFAULT 0,
content_sha256 CHAR(64) NULL,
encrypted_sha256 CHAR(64) NULL,
encryption_key_id VARCHAR(191) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
error_message TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_backup_components_backup (backup_uuid),
KEY idx_backup_components_status (status),
KEY idx_backup_components_type_name (component_type, logical_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS backup_jobs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
job_type VARCHAR(32) NOT NULL,
backup_uuid VARCHAR(64) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
progress_percent TINYINT UNSIGNED NOT NULL DEFAULT 0,
progress_message VARCHAR(255) NULL,
payload_json LONGTEXT NULL,
result_json LONGTEXT NULL,
actor_user_id INT NULL,
locked_at DATETIME NULL,
lock_owner VARCHAR(191) NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
error_message TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_backup_jobs_status_created (status, created_at),
KEY idx_backup_jobs_backup (backup_uuid),
KEY idx_backup_jobs_type_status (job_type, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS backup_restore_audit (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
restore_job_id BIGINT UNSIGNED NULL,
preview_job_id BIGINT UNSIGNED NULL,
backup_uuid VARCHAR(64) NOT NULL,
actor_user_id INT NULL,
target_environment VARCHAR(64) NOT NULL DEFAULT 'production',
confirmation_fingerprint CHAR(64) NULL,
reason TEXT NULL,
ip_address VARCHAR(64) NULL,
user_agent VARCHAR(255) NULL,
pre_restore_backup_uuid VARCHAR(64) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'queued',
started_at DATETIME NULL,
completed_at DATETIME NULL,
error_message TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_backup_restore_audit_backup (backup_uuid),
KEY idx_backup_restore_audit_job (restore_job_id),
KEY idx_backup_restore_audit_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::$initialized = true;
}
}
File diff suppressed because it is too large Load Diff
@@ -64,6 +64,11 @@ class coolify_api_client
return $this->request('GET', '/services');
}
public function listApplications(): array
{
return $this->request('GET', '/applications');
}
public function listGithubApps(): array
{
return $this->request('GET', '/github-apps');
@@ -121,6 +126,16 @@ class coolify_api_client
]);
}
public function listApplicationEnvs(string $uuid): array
{
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/envs');
}
public function deleteApplicationEnv(string $uuid, string $envUuid): array
{
return $this->request('DELETE', '/applications/' . rawurlencode($uuid) . '/envs/' . rawurlencode($envUuid));
}
private static function bulkEnvData(array $env): array
{
$data = [];
@@ -159,11 +174,26 @@ class coolify_api_client
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
}
public function stopService(string $uuid): array
{
return $this->request('GET', '/services/' . rawurlencode($uuid) . '/stop');
}
public function stopApplication(string $uuid): array
{
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/stop');
}
public function deleteService(string $uuid): array
{
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
}
public function deleteApplication(string $uuid): array
{
return $this->request('DELETE', '/applications/' . rawurlencode($uuid));
}
public function listDeployments(): array
{
return $this->request('GET', '/deployments');
@@ -6,6 +6,7 @@ class cors_policy
{
public const ALLOWED_HEADERS = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
public const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
public const EXPOSED_HEADERS = 'Server-Timing';
public const MAX_AGE_SECONDS = '86400';
private const REQUIRED_ALLOWED_ORIGINS = [
@@ -25,6 +26,9 @@ class cors_policy
'https://localhost:4433',
'https://twdev.jeppeb.dk',
'http://localhost:5173',
'http://localhost:5174',
'http://127.0.0.1:5173',
'http://127.0.0.1:5174',
];
public static function normalizeOrigin(?string $value): string
@@ -125,7 +129,9 @@ class cors_policy
'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
'Access-Control-Expose-Headers' => self::EXPOSED_HEADERS,
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
'Timing-Allow-Origin' => $origin,
'Vary' => 'Origin',
];
}
@@ -0,0 +1,58 @@
<?php
namespace classes;
use InvalidArgumentException;
class cron_schedule
{
public static function normalize(array $schedule): array
{
$type = strtolower(trim((string)($schedule['type'] ?? 'interval')));
if ($type !== 'interval') {
throw new InvalidArgumentException('Unsupported cron schedule type: ' . $type);
}
$seconds = (int)($schedule['seconds'] ?? $schedule['interval'] ?? 0);
if ($seconds < 30 || $seconds > 2678400) {
throw new InvalidArgumentException('Cron interval must be between 30 seconds and 31 days.');
}
return [
'type' => 'interval',
'seconds' => $seconds,
];
}
public static function nextRunAt(array $schedule, ?string $anchorDateTime, int $now): string
{
$normalized = self::normalize($schedule);
$anchor = $anchorDateTime !== null && trim($anchorDateTime) !== ''
? strtotime($anchorDateTime)
: false;
$base = $anchor !== false ? (int)$anchor : $now;
$next = $base + (int)$normalized['seconds'];
if ($next <= $now) {
$missed = (int)floor(($now - $next) / (int)$normalized['seconds']) + 1;
$next += $missed * (int)$normalized['seconds'];
}
return date('Y-m-d H:i:s', $next);
}
public static function dueAt(array $schedule, ?string $lastRunAt, int $now, ?int $legacyLastRun = null): string
{
$normalized = self::normalize($schedule);
if ($lastRunAt !== null && trim($lastRunAt) !== '') {
return self::nextRunAt($normalized, $lastRunAt, $now);
}
if ($legacyLastRun !== null && $legacyLastRun > 0) {
return date('Y-m-d H:i:s', $legacyLastRun + (int)$normalized['seconds']);
}
return date('Y-m-d H:i:s', $now);
}
}
@@ -0,0 +1,681 @@
<?php
namespace classes;
use RuntimeException;
use Throwable;
class cron_scheduler
{
private cron_task_registry $registry;
private string $lock_owner;
public function __construct(?cron_task_registry $registry = null)
{
$this->registry = $registry ?? new cron_task_registry();
$this->lock_owner = gethostname() . ':' . getmypid() . ':' . bin2hex(random_bytes(4));
}
public function listTasks(): array
{
$this->ensureReady();
$this->syncDefinitions();
$states = $this->stateRows();
$estimates = $this->durationEstimates();
$tasks = [];
$now = time();
foreach ($this->registry->definitions() as $definition) {
$state = $states[$definition->id] ?? [];
$schedule = is_array($state['schedule'] ?? null) && $state['schedule'] !== []
? $state['schedule']
: $definition->schedule;
$nextRunAt = $state['next_run_at'] ?? null;
if ($nextRunAt === null || trim((string)$nextRunAt) === '') {
$nextRunAt = cron_schedule::dueAt($schedule, $state['last_run_at'] ?? null, $now);
}
$task = $definition->asArray($state + ['next_run_at' => $nextRunAt], $estimates[$definition->id] ?? null);
$task['due'] = strtotime($nextRunAt) !== false && strtotime($nextRunAt) <= $now;
$task['seconds_until_due'] = max(0, (int)strtotime($nextRunAt) - $now);
$tasks[] = $task;
}
return [
'tasks' => $tasks,
'summary' => [
'total' => count($tasks),
'enabled' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['enabled'])),
'due' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['due'] && (bool)$task['enabled'])),
],
];
}
public function listRuns(?string $task_id = null, int $limit = 50): array
{
$this->ensureReady();
$limit = max(1, min(200, $limit));
$where = '';
if ($task_id !== null && trim($task_id) !== '') {
$where = "WHERE task_id = " . $this->sql($task_id);
}
return $this->fetchAll(
"SELECT * FROM cron_task_runs $where ORDER BY id DESC LIMIT $limit"
);
}
public function queueTaskRun(string $task_id_or_legacy_name, ?int $actor_user_id = null, bool $force = false): array
{
$this->ensureReady();
$this->syncDefinitions();
$definition = $this->registry->get($task_id_or_legacy_name);
if ($definition === null) {
throw new RuntimeException('Cron task not found.');
}
$state = $this->stateRows()[$definition->id] ?? [];
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
if (!$enabled && !$force) {
throw new RuntimeException('Cron task is disabled.');
}
if ($this->taskIsLocked($state)) {
throw new RuntimeException('Cron task is already running.');
}
$existing = $this->fetchOne(
"SELECT * FROM cron_task_runs
WHERE task_id = " . $this->sql($definition->id) . " AND status = 'queued'
ORDER BY id DESC LIMIT 1"
);
if ($existing !== null) {
$this->markTaskQueued($definition);
return $this->publicRun($existing);
}
$scheduled_for = date('Y-m-d H:i:s');
$this->query(
"INSERT INTO cron_task_runs
(task_id, module, source, status, actor_user_id, scheduled_for, force_run)
VALUES ("
. $this->sql($definition->id) . ', '
. $this->sql($definition->module) . ", 'manual', 'queued', "
. ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', '
. $this->sql($scheduled_for) . ', '
. ($force ? '1' : '0')
. ")"
);
$run_id = (int)$this->insertId();
$this->markTaskQueued($definition);
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
}
public function runDue(string $source = 'automatic'): array
{
$this->ensureReady();
$this->syncDefinitions();
$ran = [];
foreach ($this->queuedRuns() as $queuedRun) {
try {
$run = $this->runQueuedRun($queuedRun);
if ($run !== null) {
$ran[] = $run;
}
} catch (Throwable $throwable) {
$ran[] = [
'task_id' => (string)($queuedRun['task_id'] ?? ''),
'module' => (string)($queuedRun['module'] ?? ''),
'source' => (string)($queuedRun['source'] ?? 'manual'),
'status' => 'skipped',
'error_message' => $throwable->getMessage(),
];
}
}
$now = time();
$states = $this->stateRows();
foreach ($this->registry->definitions() as $definition) {
$state = $states[$definition->id] ?? [];
if (!(bool)($state['enabled'] ?? $definition->enabled)) {
continue;
}
$nextRunAt = (string)($state['next_run_at'] ?? '');
if ($nextRunAt === '' || strtotime($nextRunAt) === false || strtotime($nextRunAt) > $now) {
continue;
}
try {
$ran[] = $this->runTask($definition->id, $source, null, false, $nextRunAt);
} catch (Throwable $throwable) {
$ran[] = [
'task_id' => $definition->id,
'module' => $definition->module,
'source' => $source,
'status' => 'skipped',
'error_message' => $throwable->getMessage(),
];
}
}
return [
'ran' => $ran,
'count' => count($ran),
];
}
public function markExpiredRunningRuns(): int
{
$this->ensureReady();
$now = date('Y-m-d H:i:s');
$message = 'Task lock expired before completion.';
$this->query(
"UPDATE cron_task_runs r
INNER JOIN cron_task_state s ON s.task_id = r.task_id AND s.current_run_id = r.id
SET r.status = 'timed_out',
r.completed_at = COALESCE(s.locked_until, " . $this->sql($now) . "),
r.error_message = COALESCE(r.error_message, " . $this->sql($message) . "),
s.current_run_id = NULL,
s.locked_until = NULL,
s.lock_owner = NULL,
s.last_status = 'timed_out',
s.last_error = " . $this->sql($message) . "
WHERE r.status = 'running'
AND s.locked_until IS NOT NULL
AND s.locked_until < " . $this->sql($now)
);
return $this->affectedRows();
}
public function runTask(
string $task_id_or_legacy_name,
string $source = 'manual',
?int $actor_user_id = null,
bool $force = false,
?string $scheduled_for = null
): array {
$this->ensureReady();
$this->syncDefinitions();
$definition = $this->registry->get($task_id_or_legacy_name);
if ($definition === null) {
throw new RuntimeException('Cron task not found.');
}
$state = $this->stateRows()[$definition->id] ?? [];
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
if (!$enabled && !$force) {
throw new RuntimeException('Cron task is disabled.');
}
if (!$this->claimLock($definition)) {
throw new RuntimeException('Cron task is already running.');
}
$started = microtime(true);
$started_at = date('Y-m-d H:i:s', (int)$started);
$run_id = $this->createRun($definition, $source, $actor_user_id, $scheduled_for, $started_at, $force);
$this->query(
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
);
return $this->executeClaimedRun($definition, $run_id, $started);
}
private function executeClaimedRun(cron_task_definition $definition, int $run_id, float $started): array
{
$status = 'succeeded';
$summary = [];
$error_message = null;
$output = '';
try {
if (function_exists('set_time_limit')) {
@set_time_limit($definition->timeout_seconds + 30);
}
$this->ensureLegacyFunctionsLoaded($definition);
if (!is_callable($definition->handler)) {
throw new RuntimeException('Cron task handler is not callable: ' . $definition->handler);
}
ob_start();
$result = call_user_func($definition->handler);
$output = (string)ob_get_clean();
$summary = is_array($result) ? $result : [];
} catch (Throwable $throwable) {
if (ob_get_level() > 0) {
$output .= (string)ob_get_clean();
}
$status = 'failed';
$error_message = $throwable->getMessage();
}
$completed = microtime(true);
$duration_ms = (int)round(($completed - $started) * 1000);
if ($duration_ms > ($definition->timeout_seconds * 1000) && $status === 'succeeded') {
$status = 'timed_out';
$error_message = 'Task exceeded its configured timeout window.';
}
if ($output !== '') {
$summary['output'] = substr($output, 0, 8000);
}
$completed_at = date('Y-m-d H:i:s', (int)$completed);
$this->completeRun($run_id, $status, $completed_at, $duration_ms, $summary, $error_message);
$this->releaseLock($definition, $status, $error_message, $completed_at);
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
}
public function updateTaskConfig(string $task_id, array $config): array
{
$this->ensureReady();
$this->syncDefinitions();
$definition = $this->registry->get($task_id);
if ($definition === null) {
throw new RuntimeException('Cron task not found.');
}
$updates = [];
if (array_key_exists('enabled', $config)) {
$updates[] = 'enabled = ' . ((bool)$config['enabled'] ? '1' : '0');
}
if (array_key_exists('schedule', $config)) {
$schedule = $config['schedule'] === null ? null : cron_schedule::normalize((array)$config['schedule']);
$updates[] = 'schedule_json = ' . ($schedule === null ? 'NULL' : $this->sql(json_encode($schedule)));
$anchor = (string)($this->fetchOne("SELECT last_run_at FROM cron_task_state WHERE task_id = " . $this->sql($definition->id))['last_run_at'] ?? '');
$updates[] = 'next_run_at = ' . $this->sql(cron_schedule::dueAt($schedule ?? $definition->schedule, $anchor !== '' ? $anchor : null, time()));
}
if ($updates !== []) {
$this->query(
"UPDATE cron_task_state SET " . implode(', ', $updates) . " WHERE task_id = " . $this->sql($definition->id)
);
}
return $this->listTasks();
}
private function ensureReady(): void
{
cron_schema_bootstrap::ensureTables();
}
private function syncDefinitions(): void
{
$now = time();
foreach ($this->registry->definitions() as $definition) {
$row = $this->fetchOne(
"SELECT * FROM cron_task_state WHERE task_id = " . $this->sql($definition->id)
);
if ($row !== null) {
continue;
}
$legacyLastRun = $this->legacyLastRun($definition);
$nextRunAt = cron_schedule::dueAt($definition->schedule, null, $now, $legacyLastRun);
$this->query(
"INSERT INTO cron_task_state (task_id, module, enabled, schedule_json, next_run_at)
VALUES ("
. $this->sql($definition->id) . ', '
. $this->sql($definition->module) . ', '
. ($definition->enabled ? '1' : '0') . ', NULL, '
. $this->sql($nextRunAt)
. ")"
);
}
}
private function legacyLastRun(cron_task_definition $definition): ?int
{
if ($definition->legacy_name === null || !defined('redis')) {
return null;
}
try {
$last_run = redis->get_last_crond_run($definition->legacy_name);
return $last_run !== null ? (int)$last_run : null;
} catch (Throwable) {
return null;
}
}
private function claimLock(cron_task_definition $definition): bool
{
$now = date('Y-m-d H:i:s');
$locked_until = date('Y-m-d H:i:s', time() + $definition->timeout_seconds + 60);
$this->query(
"UPDATE cron_task_state
SET locked_until = " . $this->sql($locked_until) . ",
lock_owner = " . $this->sql($this->lock_owner) . "
WHERE task_id = " . $this->sql($definition->id) . "
AND (locked_until IS NULL OR locked_until < " . $this->sql($now) . ")"
);
return $this->affectedRows() === 1;
}
private function taskIsLocked(array $state): bool
{
$lockedUntil = (string)($state['locked_until'] ?? '');
return $lockedUntil !== ''
&& strtotime($lockedUntil) !== false
&& strtotime($lockedUntil) >= time();
}
private function markTaskQueued(cron_task_definition $definition): void
{
$now = date('Y-m-d H:i:s');
$this->query(
"UPDATE cron_task_state
SET last_status = 'queued',
last_error = NULL,
next_run_at = CASE
WHEN next_run_at IS NULL OR next_run_at > " . $this->sql($now) . " THEN " . $this->sql($now) . "
ELSE next_run_at
END
WHERE task_id = " . $this->sql($definition->id)
);
}
/**
* @return array<int, array<string, mixed>>
*/
private function queuedRuns(): array
{
return $this->fetchAll("SELECT * FROM cron_task_runs WHERE status = 'queued' ORDER BY id ASC LIMIT 50");
}
private function runQueuedRun(array $queuedRun): ?array
{
$run_id = (int)($queuedRun['id'] ?? 0);
$definition = $this->registry->get((string)($queuedRun['task_id'] ?? ''));
if ($run_id < 1 || $definition === null) {
if ($run_id > 0) {
$this->skipQueuedRun($run_id, 'Cron task not found.');
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
}
return null;
}
$force = (bool)($queuedRun['force_run'] ?? false);
$state = $this->stateRows()[$definition->id] ?? [];
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
if (!$enabled && !$force) {
$this->skipQueuedRun($run_id, 'Cron task is disabled.', $definition);
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
}
if (!$this->claimLock($definition)) {
return null;
}
$started = microtime(true);
$started_at = date('Y-m-d H:i:s', (int)$started);
$this->query(
"UPDATE cron_task_runs
SET status = 'running',
started_at = " . $this->sql($started_at) . ",
lock_owner = " . $this->sql($this->lock_owner) . "
WHERE id = $run_id AND status = 'queued'"
);
if ($this->affectedRows() !== 1) {
$this->clearClaimedLock($definition);
return null;
}
$this->query(
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
);
return $this->executeClaimedRun($definition, $run_id, $started);
}
private function skipQueuedRun(int $run_id, string $message, ?cron_task_definition $definition = null): void
{
$completed_at = date('Y-m-d H:i:s');
$this->query(
"UPDATE cron_task_runs
SET status = 'skipped',
completed_at = " . $this->sql($completed_at) . ",
duration_ms = 0,
error_message = " . $this->sql($message) . "
WHERE id = $run_id AND status = 'queued'"
);
$updated = $this->affectedRows() === 1;
if (!$updated || $definition === null) {
return;
}
$this->query(
"UPDATE cron_task_state
SET last_status = 'skipped',
last_error = " . $this->sql($message) . "
WHERE task_id = " . $this->sql($definition->id)
);
}
private function clearClaimedLock(cron_task_definition $definition): void
{
$this->query(
"UPDATE cron_task_state
SET locked_until = NULL,
lock_owner = NULL
WHERE task_id = " . $this->sql($definition->id) . "
AND lock_owner = " . $this->sql($this->lock_owner)
);
}
private function releaseLock(cron_task_definition $definition, string $status, ?string $error_message, string $completed_at): void
{
$state = $this->fetchOne("SELECT schedule_json FROM cron_task_state WHERE task_id = " . $this->sql($definition->id));
$schedule = $this->decodeJson($state['schedule_json'] ?? null);
if ($schedule === []) {
$schedule = $definition->schedule;
}
$nextRunAt = cron_schedule::nextRunAt($schedule, $completed_at, time());
if ($status !== 'succeeded') {
$retrySeconds = min(300, max(60, (int)$schedule['seconds']));
$nextRunAt = date('Y-m-d H:i:s', time() + $retrySeconds);
}
$this->query(
"UPDATE cron_task_state
SET last_run_at = " . $this->sql($completed_at) . ",
next_run_at = " . $this->sql($nextRunAt) . ",
locked_until = NULL,
lock_owner = NULL,
current_run_id = NULL,
last_status = " . $this->sql($status) . ",
last_error = " . $this->nullableSql($error_message) . "
WHERE task_id = " . $this->sql($definition->id) . "
AND lock_owner = " . $this->sql($this->lock_owner)
);
if ($definition->legacy_name !== null && defined('redis')) {
try {
redis->set_last_crond_run($definition->legacy_name, time());
} catch (Throwable) {
}
}
}
private function createRun(
cron_task_definition $definition,
string $source,
?int $actor_user_id,
?string $scheduled_for,
string $started_at,
bool $force = false
): int {
$this->query(
"INSERT INTO cron_task_runs
(task_id, module, source, status, actor_user_id, scheduled_for, started_at, lock_owner, force_run)
VALUES ("
. $this->sql($definition->id) . ', '
. $this->sql($definition->module) . ', '
. $this->sql($source) . ", 'running', "
. ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', '
. $this->nullableSql($scheduled_for) . ', '
. $this->sql($started_at) . ', '
. $this->sql($this->lock_owner) . ', '
. ($force ? '1' : '0')
. ")"
);
return $this->insertId();
}
private function completeRun(
int $run_id,
string $status,
string $completed_at,
int $duration_ms,
array $summary,
?string $error_message
): void {
$this->query(
"UPDATE cron_task_runs
SET status = " . $this->sql($status) . ",
completed_at = " . $this->sql($completed_at) . ",
duration_ms = " . (string)$duration_ms . ",
summary_json = " . $this->sql(json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . ",
error_message = " . $this->nullableSql($error_message) . "
WHERE id = " . (string)$run_id
);
}
private function ensureLegacyFunctionsLoaded(cron_task_definition $definition): void
{
if (function_exists($definition->handler)) {
return;
}
if (!defined('WD')) {
return;
}
if (!defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY')) {
define('CRON_LOAD_LEGACY_FUNCTIONS_ONLY', true);
}
require_once WD . '/cron/Cron.php';
}
/**
* @return array<string, array<string, mixed>>
*/
private function stateRows(): array
{
$rows = $this->fetchAll("SELECT * FROM cron_task_state");
$states = [];
foreach ($rows as $row) {
$row['enabled'] = (bool)$row['enabled'];
$row['schedule'] = $this->decodeJson($row['schedule_json'] ?? null);
$states[(string)$row['task_id']] = $row;
}
return $states;
}
/**
* @return array<string, int>
*/
private function durationEstimates(): array
{
$rows = $this->fetchAll(
"SELECT task_id, AVG(duration_ms) AS avg_duration_ms
FROM (
SELECT task_id, duration_ms
FROM cron_task_runs
WHERE status = 'succeeded' AND duration_ms IS NOT NULL
ORDER BY id DESC
LIMIT 500
) recent_runs
GROUP BY task_id"
);
$estimates = [];
foreach ($rows as $row) {
$estimates[(string)$row['task_id']] = (int)round((float)$row['avg_duration_ms']);
}
return $estimates;
}
private function decodeJson(mixed $json): array
{
if (!is_string($json) || trim($json) === '') {
return [];
}
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : [];
}
private function publicRun(array $run): array
{
if ($run === []) {
return [];
}
$run['force_run'] = (bool)($run['force_run'] ?? false);
$run['summary'] = $this->decodeJson($run['summary_json'] ?? null);
return $run;
}
private function fetchOne(string $sql): ?array
{
$rows = $this->fetchAll($sql);
return $rows[0] ?? null;
}
private function fetchAll(string $sql): array
{
$result = $this->query($sql);
if ($result === false || $result === true) {
return [];
}
return $result->fetch_all(MYSQLI_ASSOC);
}
private function query(string $sql): \mysqli_result|bool
{
global $db;
return $db->query($sql);
}
private function sql(string $value): string
{
global $db;
return "'" . $db->escape_string($value) . "'";
}
private function nullableSql(?string $value): string
{
return $value === null ? 'NULL' : $this->sql($value);
}
private function affectedRows(): int
{
global $db;
return (int)$db->conn()->affected_rows;
}
private function insertId(): int
{
global $db;
return (int)$db->insert_id();
}
}
@@ -0,0 +1,117 @@
<?php
namespace classes;
class cron_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"CREATE TABLE IF NOT EXISTS cron_task_state (
task_id VARCHAR(191) NOT NULL PRIMARY KEY,
module VARCHAR(64) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
schedule_json LONGTEXT NULL,
last_run_at DATETIME NULL,
next_run_at DATETIME NULL,
locked_until DATETIME NULL,
lock_owner VARCHAR(191) NULL,
current_run_id BIGINT UNSIGNED NULL,
last_status VARCHAR(32) NULL,
last_error TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_cron_task_state_next_run (enabled, next_run_at),
KEY idx_cron_task_state_lock (locked_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS cron_task_runs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(191) NOT NULL,
module VARCHAR(64) NOT NULL,
source VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'running',
actor_user_id INT NULL,
scheduled_for DATETIME NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
duration_ms INT UNSIGNED NULL,
summary_json LONGTEXT NULL,
error_message TEXT NULL,
lock_owner VARCHAR(191) NULL,
force_run TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_cron_task_runs_task_created (task_id, created_at),
KEY idx_cron_task_runs_status_created (status, created_at),
KEY idx_cron_task_runs_module_created (module, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::ensureColumn('cron_task_runs', 'force_run', 'TINYINT(1) NOT NULL DEFAULT 0 AFTER lock_owner');
$db->query(
"CREATE TABLE IF NOT EXISTS cron_worker_state (
worker_id VARCHAR(191) NOT NULL PRIMARY KEY,
name VARCHAR(191) NOT NULL,
hostname VARCHAR(191) NULL,
pid INT UNSIGNED NULL,
source VARCHAR(64) NOT NULL DEFAULT 'coolify_worker',
status VARCHAR(32) NOT NULL DEFAULT 'starting',
release_channel_id BIGINT UNSIGNED NULL,
release_target_id BIGINT UNSIGNED NULL,
coolify_resource_uuid VARCHAR(128) NULL,
coolify_resource_type VARCHAR(32) NULL,
commit_sha VARCHAR(64) NULL,
poll_seconds INT UNSIGNED NOT NULL DEFAULT 15,
last_run_count INT UNSIGNED NOT NULL DEFAULT 0,
last_stale_run_count INT UNSIGNED NOT NULL DEFAULT 0,
last_error TEXT NULL,
started_at DATETIME NULL,
last_heartbeat_at DATETIME NULL,
last_loop_started_at DATETIME NULL,
last_loop_finished_at DATETIME NULL,
stopped_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_cron_worker_state_heartbeat (last_heartbeat_at),
KEY idx_cron_worker_state_status (status),
KEY idx_cron_worker_state_release_target (release_target_id),
KEY idx_cron_worker_state_coolify_resource (coolify_resource_uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
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 && $result->num_rows > 0) {
return;
}
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
}
}
@@ -0,0 +1,85 @@
<?php
namespace classes;
use InvalidArgumentException;
class cron_task_definition
{
public string $id;
public string $name;
public string $description;
public string $module;
public string $handler;
public array $schedule;
public bool $enabled;
public int $timeout_seconds;
public int $estimated_duration_ms;
public int $priority;
public ?string $legacy_name;
public function __construct(array $definition)
{
$this->id = self::requiredString($definition, 'id');
$this->name = self::requiredString($definition, 'name');
$this->description = (string)($definition['description'] ?? '');
$this->module = self::requiredString($definition, 'module');
$this->handler = self::requiredString($definition, 'handler');
$this->schedule = cron_schedule::normalize($definition['schedule'] ?? []);
$this->enabled = (bool)($definition['enabled'] ?? true);
$this->timeout_seconds = max(30, (int)($definition['timeout_seconds'] ?? 600));
$this->estimated_duration_ms = max(0, (int)($definition['estimated_duration_ms'] ?? 0));
$this->priority = (int)($definition['priority'] ?? 100);
$legacy_name = trim((string)($definition['legacy_name'] ?? ''));
$this->legacy_name = $legacy_name !== '' ? $legacy_name : null;
if (!preg_match('/^[a-z0-9][a-z0-9_.-]{1,190}$/', $this->id)) {
throw new InvalidArgumentException('Invalid cron task id: ' . $this->id);
}
if (!preg_match('/^[a-z0-9][a-z0-9_-]{1,63}$/', $this->module)) {
throw new InvalidArgumentException('Invalid cron task module: ' . $this->module);
}
}
public function asArray(?array $state = null, ?int $estimatedDurationMs = null): array
{
$schedule = is_array($state['schedule'] ?? null) && ($state['schedule'] ?? []) !== []
? $state['schedule']
: $this->schedule;
$enabled = array_key_exists('enabled', $state ?? [])
? (bool)$state['enabled']
: $this->enabled;
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'module' => $this->module,
'handler' => $this->handler,
'schedule' => $schedule,
'default_schedule' => $this->schedule,
'enabled' => $enabled,
'default_enabled' => $this->enabled,
'timeout_seconds' => $this->timeout_seconds,
'estimated_duration_ms' => $estimatedDurationMs ?? $this->estimated_duration_ms,
'priority' => $this->priority,
'legacy_name' => $this->legacy_name,
'last_run_at' => $state['last_run_at'] ?? null,
'next_run_at' => $state['next_run_at'] ?? null,
'locked_until' => $state['locked_until'] ?? null,
'lock_owner' => $state['lock_owner'] ?? null,
'current_run_id' => $state['current_run_id'] ?? null,
'last_status' => $state['last_status'] ?? null,
'last_error' => $state['last_error'] ?? null,
];
}
private static function requiredString(array $definition, string $key): string
{
$value = trim((string)($definition[$key] ?? ''));
if ($value === '') {
throw new InvalidArgumentException('Missing cron task definition field: ' . $key);
}
return $value;
}
}
@@ -0,0 +1,85 @@
<?php
namespace classes;
use InvalidArgumentException;
class cron_task_registry
{
private string $modules_root;
/** @var array<string, cron_task_definition>|null */
private ?array $definitions = null;
public function __construct(?string $modules_root = null)
{
$this->modules_root = $modules_root ?? (defined('WD') ? WD . '/modules' : dirname(__DIR__) . '/modules');
}
/**
* @return array<string, cron_task_definition>
*/
public function definitions(): array
{
if ($this->definitions !== null) {
return $this->definitions;
}
$definitions = [];
foreach ($this->definitionFiles() as $file) {
$module_definitions = require $file;
if (!is_array($module_definitions)) {
throw new InvalidArgumentException('Cron definition file must return an array: ' . $file);
}
foreach ($module_definitions as $definition) {
$task = new cron_task_definition($definition);
if (isset($definitions[$task->id])) {
throw new InvalidArgumentException('Duplicate cron task id: ' . $task->id);
}
$definitions[$task->id] = $task;
}
}
uasort($definitions, static function (cron_task_definition $left, cron_task_definition $right): int {
if ($left->priority !== $right->priority) {
return $left->priority <=> $right->priority;
}
return strcmp($left->id, $right->id);
});
$this->definitions = $definitions;
return $definitions;
}
public function get(string $id_or_legacy_name): ?cron_task_definition
{
$normalized = trim($id_or_legacy_name);
if ($normalized === '') {
return null;
}
$definitions = $this->definitions();
if (isset($definitions[$normalized])) {
return $definitions[$normalized];
}
foreach ($definitions as $definition) {
if ($definition->legacy_name !== null && hash_equals($definition->legacy_name, $normalized)) {
return $definition;
}
}
return null;
}
/**
* @return array<int, string>
*/
private function definitionFiles(): array
{
$files = glob($this->modules_root . '/*/cron/tasks.php') ?: [];
sort($files, SORT_STRING);
return $files;
}
}
+322
View File
@@ -0,0 +1,322 @@
<?php
namespace classes;
use Throwable;
class cron_worker
{
private cron_scheduler $scheduler;
private string $worker_id;
private string $name;
private string $source;
private int $poll_seconds;
private int $heartbeat_seconds;
private int $max_runtime_seconds;
private bool $should_stop = false;
private int $last_heartbeat = 0;
public function __construct(?cron_scheduler $scheduler = null, array $options = [])
{
$this->scheduler = $scheduler ?? new cron_scheduler();
$this->name = $this->stringOption($options, 'name', 'CRON_WORKER_NAME', 'cron-worker');
$this->worker_id = $this->stringOption($options, 'worker_id', 'CRON_WORKER_ID', $this->name);
$this->source = $this->stringOption($options, 'source', 'CRON_WORKER_SOURCE', 'coolify_worker');
$this->poll_seconds = $this->intOption($options, 'poll_seconds', 'CRON_WORKER_POLL_SECONDS', 15, 1, 300);
$this->heartbeat_seconds = $this->intOption($options, 'heartbeat_seconds', 'CRON_WORKER_HEARTBEAT_SECONDS', 30, 5, 300);
$this->max_runtime_seconds = $this->intOption($options, 'max_runtime_seconds', 'CRON_WORKER_MAX_RUNTIME_SECONDS', 0, 0, 86400);
}
public function run(): int
{
if (!$this->boolOption('CRON_WORKER_ENABLED', true)) {
$this->heartbeat('disabled', 0, 0, null, true);
return 0;
}
$this->registerSignalHandlers();
$started = time();
$this->heartbeat('starting', 0, 0, null, true);
while (!$this->should_stop) {
$result = $this->tick();
$this->writeStatusLine($result);
if ($this->max_runtime_seconds > 0 && time() - $started >= $this->max_runtime_seconds) {
$this->should_stop = true;
break;
}
$this->sleepUntilNextPoll();
}
$this->heartbeat('stopped', 0, 0, null, true, true);
return 0;
}
public function tick(): array
{
$this->heartbeat('running');
$loopStartedAt = date('Y-m-d H:i:s');
$staleRuns = 0;
$ran = ['count' => 0, 'ran' => []];
$error = null;
$status = 'running';
try {
$staleRuns = $this->scheduler->markExpiredRunningRuns();
$ran = $this->scheduler->runDue($this->source);
} catch (Throwable $throwable) {
$status = 'failed';
$error = $throwable->getMessage();
}
$this->heartbeat($status, (int)($ran['count'] ?? 0), $staleRuns, $error, true, false, $loopStartedAt);
return [
'worker_id' => $this->worker_id,
'status' => $status,
'ran' => (int)($ran['count'] ?? 0),
'stale_runs' => $staleRuns,
'error' => $error,
];
}
public function listWorkers(): array
{
cron_schema_bootstrap::ensureTables();
$rows = $this->fetchAll('SELECT * FROM cron_worker_state ORDER BY last_heartbeat_at DESC, worker_id');
$workers = [];
foreach ($rows as $row) {
$workers[] = $this->publicWorker($row);
}
return [
'workers' => $workers,
'summary' => [
'total' => count($workers),
'running' => count(array_filter($workers, static fn(array $worker): bool => ($worker['status'] ?? '') === 'running')),
'stale' => count(array_filter($workers, static fn(array $worker): bool => (bool)($worker['stale'] ?? false))),
],
];
}
private function registerSignalHandlers(): void
{
if (!function_exists('pcntl_signal')) {
return;
}
if (function_exists('pcntl_async_signals')) {
pcntl_async_signals(true);
}
pcntl_signal(SIGTERM, function (): void {
$this->should_stop = true;
});
pcntl_signal(SIGINT, function (): void {
$this->should_stop = true;
});
}
private function sleepUntilNextPoll(): void
{
$remaining = $this->poll_seconds;
while ($remaining > 0 && !$this->should_stop) {
$sleep = min(1, $remaining);
sleep($sleep);
$remaining -= $sleep;
if (time() - $this->last_heartbeat >= $this->heartbeat_seconds) {
$this->heartbeat('running');
}
}
}
private function heartbeat(
string $status,
int $runCount = 0,
int $staleRunCount = 0,
?string $error = null,
bool $force = false,
bool $stopped = false,
?string $loopStartedAt = null
): void {
if (!$force && time() - $this->last_heartbeat < $this->heartbeat_seconds) {
return;
}
cron_schema_bootstrap::ensureTables();
$this->last_heartbeat = time();
$now = date('Y-m-d H:i:s');
$workerId = $this->sql($this->worker_id);
$name = $this->sql($this->name);
$hostname = $this->nullableSql(gethostname() ?: null);
$pid = getmypid() ?: 0;
$source = $this->sql($this->source);
$statusSql = $this->sql($status);
$releaseChannelId = $this->nullableInt($this->env('CRON_WORKER_RELEASE_CHANNEL_ID'));
$releaseTargetId = $this->nullableInt($this->env('CRON_WORKER_RELEASE_TARGET_ID'));
$resourceUuid = $this->nullableSql($this->env('COOLIFY_RESOURCE_UUID') ?: $this->env('CRON_WORKER_COOLIFY_RESOURCE_UUID'));
$resourceType = $this->nullableSql($this->env('COOLIFY_RESOURCE_TYPE') ?: $this->env('CRON_WORKER_COOLIFY_RESOURCE_TYPE') ?: 'application');
$commitSha = $this->nullableSql($this->commitSha());
$errorSql = $this->nullableSql($error);
$loopStarted = $this->nullableSql($loopStartedAt);
$stoppedAt = $stopped ? $this->sql($now) : 'NULL';
$this->query(
"INSERT INTO cron_worker_state (
worker_id, name, hostname, pid, source, status, release_channel_id, release_target_id,
coolify_resource_uuid, coolify_resource_type, commit_sha, poll_seconds, last_run_count,
last_stale_run_count, last_error, started_at, last_heartbeat_at, last_loop_started_at,
last_loop_finished_at, stopped_at
) VALUES (
$workerId, $name, $hostname, $pid, $source, $statusSql, $releaseChannelId, $releaseTargetId,
$resourceUuid, $resourceType, $commitSha, $this->poll_seconds, $runCount,
$staleRunCount, $errorSql, $this->sql($now), $this->sql($now), $loopStarted,
$this->sql($now), $stoppedAt
)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
hostname = VALUES(hostname),
pid = VALUES(pid),
source = VALUES(source),
status = VALUES(status),
release_channel_id = VALUES(release_channel_id),
release_target_id = VALUES(release_target_id),
coolify_resource_uuid = VALUES(coolify_resource_uuid),
coolify_resource_type = VALUES(coolify_resource_type),
commit_sha = VALUES(commit_sha),
poll_seconds = VALUES(poll_seconds),
last_run_count = VALUES(last_run_count),
last_stale_run_count = VALUES(last_stale_run_count),
last_error = VALUES(last_error),
last_heartbeat_at = VALUES(last_heartbeat_at),
last_loop_started_at = COALESCE(VALUES(last_loop_started_at), last_loop_started_at),
last_loop_finished_at = VALUES(last_loop_finished_at),
stopped_at = VALUES(stopped_at)"
);
}
private function publicWorker(array $row): array
{
$heartbeatAt = (string)($row['last_heartbeat_at'] ?? '');
$heartbeatTs = strtotime($heartbeatAt);
$threshold = max(60, ((int)($row['poll_seconds'] ?? 15) * 4) + 30);
$age = $heartbeatTs !== false ? max(0, time() - $heartbeatTs) : null;
return [
'worker_id' => (string)($row['worker_id'] ?? ''),
'name' => (string)($row['name'] ?? ''),
'hostname' => $row['hostname'] ?? null,
'pid' => isset($row['pid']) ? (int)$row['pid'] : null,
'source' => (string)($row['source'] ?? ''),
'status' => (string)($row['status'] ?? 'unknown'),
'release_channel_id' => isset($row['release_channel_id']) ? (int)$row['release_channel_id'] : null,
'release_target_id' => isset($row['release_target_id']) ? (int)$row['release_target_id'] : null,
'coolify_resource_uuid' => $row['coolify_resource_uuid'] ?? null,
'coolify_resource_type' => $row['coolify_resource_type'] ?? null,
'commit_sha' => $row['commit_sha'] ?? null,
'poll_seconds' => (int)($row['poll_seconds'] ?? 0),
'last_run_count' => (int)($row['last_run_count'] ?? 0),
'last_stale_run_count' => (int)($row['last_stale_run_count'] ?? 0),
'last_error' => $row['last_error'] ?? null,
'started_at' => $row['started_at'] ?? null,
'last_heartbeat_at' => $heartbeatAt !== '' ? $heartbeatAt : null,
'last_heartbeat_age_seconds' => $age,
'last_loop_started_at' => $row['last_loop_started_at'] ?? null,
'last_loop_finished_at' => $row['last_loop_finished_at'] ?? null,
'stopped_at' => $row['stopped_at'] ?? null,
'stale' => $age === null || $age > $threshold,
'stale_after_seconds' => $threshold,
];
}
private function writeStatusLine(array $result): void
{
echo '[' . date('Y-m-d H:i:s') . '][CRON_WORKER] '
. json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
. PHP_EOL;
}
private function stringOption(array $options, string $key, string $env, string $default): string
{
$value = trim((string)($options[$key] ?? $this->env($env) ?? ''));
return $value !== '' ? $value : $default;
}
private function intOption(array $options, string $key, string $env, int $default, int $min, int $max): int
{
$value = (int)($options[$key] ?? $this->env($env) ?? $default);
return max($min, min($max, $value));
}
private function boolOption(string $env, bool $default): bool
{
$value = $this->env($env);
if ($value === null || trim($value) === '') {
return $default;
}
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true);
}
private function commitSha(): string
{
foreach (['CRON_WORKER_COMMIT_SHA', 'API_COMMIT_SHA', 'RELEASE_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA'] as $key) {
$value = trim((string)($this->env($key) ?? ''));
if ($value !== '') {
return $value;
}
}
return '';
}
private function env(string $key): ?string
{
$value = getenv($key);
if ($value !== false) {
return (string)$value;
}
return isset($_SERVER[$key]) ? (string)$_SERVER[$key] : null;
}
private function nullableInt(?string $value): string
{
$value = trim((string)$value);
if ($value === '' || filter_var($value, FILTER_VALIDATE_INT) === false) {
return 'NULL';
}
return (string)max(0, (int)$value);
}
private function nullableSql(?string $value): string
{
$value = $value !== null ? trim($value) : '';
return $value === '' ? 'NULL' : $this->sql($value);
}
private function fetchAll(string $sql): array
{
$result = $this->query($sql);
if ($result === false || $result === true) {
return [];
}
return $result->fetch_all(MYSQLI_ASSOC);
}
private function query(string $sql): \mysqli_result|bool
{
global $db;
return $db->query($sql);
}
private function sql(string $value): string
{
global $db;
return "'" . $db->escape_string($value) . "'";
}
}
@@ -137,6 +137,10 @@ class customer_mass_import_service
if ($cvrLength < 8 || $cvrLength > 20) {
throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400);
}
if ($normalized['ean'] !== null && strlen((string)$normalized['ean']) > 13) {
throw new \RuntimeException('EAN must be at most 13 digits.', 400);
}
}
protected function normalizePositiveInt(mixed $value): ?int
@@ -0,0 +1,46 @@
<?php
namespace classes;
use RuntimeException;
class customer_order_product_policy
{
public static function assertOrderAllowsProduct(int $orderId, int $productId): void
{
$message = self::orderProductViolationMessage($orderId, $productId);
if ($message !== null) {
throw new RuntimeException($message);
}
}
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
{
$customerNumber = self::loadOrderCustomerNumber($orderId);
if ($customerNumber === null) {
return null;
}
$violation = (new customer_rule_product_restriction_service())
->violationForCustomerProduct($customerNumber, $productId);
return $violation === null ? null : (string)$violation['message'];
}
private static function loadOrderCustomerNumber(int $orderId): ?int
{
global $db;
if ($orderId < 1) {
return null;
}
$result = $db->query("SELECT customer_id FROM orders WHERE id = {$orderId} LIMIT 1");
if (!$result || $result->num_rows < 1) {
return null;
}
$row = $result->fetch_assoc();
$customerNumber = (int)($row['customer_id'] ?? 0);
return $customerNumber > 0 ? $customerNumber : null;
}
}
@@ -0,0 +1,33 @@
<?php
namespace classes;
use objects\orders_o;
class customer_product_rule_service
{
public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer';
/**
* @return array{rule:string,rules:list<string>,collections:list<int>,product_id:int,code:string,message:string}|null
*/
public function firstViolationForOrderItem(int $orderId, int $productId, ?int $relatedItemId): ?array
{
$order = (new orders_o())->getOrderById($orderId);
if (!$order->exists()) {
return null;
}
$violation = (new customer_rule_product_restriction_service())->violationForCustomerProduct(
(int)$order->customer_id->value(),
$productId
);
if ($violation === null) {
return null;
}
// Keep the singular key during the API migration for existing invoice
// and logging consumers while also returning every matching rule.
return ['rule' => (string)$violation['rules'][0]] + $violation;
}
}
@@ -0,0 +1,291 @@
<?php
namespace classes;
use RuntimeException;
use Throwable;
/**
* Additive schema and the one-time legacy-to-exact-product migration for
* customer-rule product restrictions.
*/
class customer_rule_product_restriction_schema_bootstrap
{
public const LEGACY_SEED_KEY = 'legacy_exact_product_sets_v1';
private static bool $initialized = false;
/** @var array<string, string> */
private const RULES = [
'restrictAdditionalServices' => 'Additional services',
'restrictTankCleaning' => 'Tank cleaning',
'restrictSpotFree' => 'SpotFree',
'restrictInteriorCleaning' => 'Interior cleaning',
'onlyTankCleaning' => 'Non-tank products',
];
public static function ensureSchema(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
self::createTables($db);
self::deduplicateCustomerAttributes($db);
self::seedLegacyProductSets($db);
self::$initialized = true;
}
private static function createTables(object $db): void
{
$statements = [
"CREATE TABLE IF NOT EXISTS customer_rule_product_restrictions (
attribute VARCHAR(191) NOT NULL,
version INT UNSIGNED NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (attribute)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS customer_rule_product_collections (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
attribute VARCHAR(191) NOT NULL,
name VARCHAR(191) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
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_customer_rule_collection_name (attribute, name),
KEY idx_customer_rule_collection_attribute_order (attribute, sort_order, id),
CONSTRAINT fk_customer_rule_collection_attribute
FOREIGN KEY (attribute) REFERENCES customer_rule_product_restrictions(attribute)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS customer_rule_product_collection_products (
collection_id INT UNSIGNED NOT NULL,
product_id INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (collection_id, product_id),
KEY idx_customer_rule_collection_product (product_id, collection_id),
CONSTRAINT fk_customer_rule_collection_product_collection
FOREIGN KEY (collection_id) REFERENCES customer_rule_product_collections(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS customer_rule_product_migrations (
migration_key VARCHAR(191) NOT NULL,
details_json LONGTEXT NULL,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (migration_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS customer_rule_product_audit_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
actor_user_id INT UNSIGNED NULL,
attribute VARCHAR(191) NOT NULL,
old_version INT UNSIGNED NOT NULL,
new_version INT UNSIGNED NOT NULL,
changes_json LONGTEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_customer_rule_product_audit_attribute (attribute, created_at),
KEY idx_customer_rule_product_audit_actor (actor_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($statements as $statement) {
if ($db->query($statement) === false) {
throw new RuntimeException('Unable to initialize customer-rule product restriction schema');
}
}
}
private static function deduplicateCustomerAttributes(object $db): void
{
if (!self::tableExists($db, 'customer_attributes')) {
return;
}
if (self::indexExists($db, 'customer_attributes', 'uniq_customer_attributes_user_attribute')) {
return;
}
if ($db->query(
'DELETE duplicate_row FROM customer_attributes duplicate_row
INNER JOIN customer_attributes keep_row
ON keep_row.user_id = duplicate_row.user_id
AND keep_row.attribute = duplicate_row.attribute
AND keep_row.id < duplicate_row.id'
) === false) {
throw new RuntimeException('Unable to deduplicate customer attributes');
}
if ($db->query(
'ALTER TABLE customer_attributes
ADD UNIQUE KEY uniq_customer_attributes_user_attribute (user_id, attribute)'
) === false) {
throw new RuntimeException('Unable to enforce unique customer attributes');
}
}
private static function seedLegacyProductSets(object $db): void
{
if (!self::tableExists($db, 'products') || !self::tableExists($db, 'categories')) {
return;
}
$migrationKey = self::escape($db, self::LEGACY_SEED_KEY);
$existing = $db->query(
"SELECT migration_key FROM customer_rule_product_migrations WHERE migration_key = '{$migrationKey}' LIMIT 1"
);
if ($existing && (int)$existing->num_rows > 0) {
return;
}
if ($db->query('START TRANSACTION') === false) {
throw new RuntimeException('Unable to start customer-rule product migration');
}
try {
if ($db->query(
"INSERT IGNORE INTO customer_rule_product_migrations (migration_key, details_json)
VALUES ('{$migrationKey}', '{\"status\":\"in_progress\"}')"
) === false) {
throw new RuntimeException('Unable to claim customer-rule product migration');
}
if (self::affectedRows($db) === 0) {
$db->query('ROLLBACK');
return;
}
foreach (array_keys(self::RULES) as $attribute) {
$safeAttribute = self::escape($db, $attribute);
if ($db->query(
"INSERT IGNORE INTO customer_rule_product_restrictions (attribute, version)
VALUES ('{$safeAttribute}', 1)"
) === false) {
throw new RuntimeException("Unable to initialize restriction {$attribute}");
}
}
$counts = [];
$seededProductIds = [];
foreach (self::RULES as $attribute => $collectionName) {
$safeAttribute = self::escape($db, $attribute);
$safeName = self::escape($db, 'Legacy migration: ' . $collectionName);
if ($db->query(
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
VALUES ('{$safeAttribute}', '{$safeName}', 0)"
) === false) {
throw new RuntimeException("Unable to create seed collection for {$attribute}");
}
$collectionId = (int)$db->insert_id();
if ($collectionId < 1) {
throw new RuntimeException("Unable to create seed collection for {$attribute}");
}
$predicate = self::legacyPredicate($db, $attribute);
$activePredicate = self::columnExists($db, 'products', 'deleted_at')
? 'p.deleted_at IS NULL'
: '1 = 1';
$insert = $db->query(
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
SELECT {$collectionId}, p.id
FROM products p
LEFT JOIN categories c ON c.id = p.category
WHERE ({$activePredicate}) AND ({$predicate})"
);
if ($insert === false) {
throw new RuntimeException("Unable to seed products for {$attribute}");
}
$counts[$attribute] = self::affectedRows($db);
$seeded = $db->query(
"SELECT product_id FROM customer_rule_product_collection_products
WHERE collection_id = {$collectionId} ORDER BY product_id"
);
$seededProductIds[$attribute] = [];
if ($seeded) {
while ($row = $seeded->fetch_assoc()) {
$seededProductIds[$attribute][] = (int)$row['product_id'];
}
}
}
$details = self::escape($db, (string)json_encode([
'counts' => $counts,
'product_ids' => $seededProductIds,
'seeded_at' => gmdate(DATE_ATOM),
], JSON_UNESCAPED_SLASHES));
if ($db->query(
"UPDATE customer_rule_product_migrations
SET details_json = '{$details}', applied_at = NOW()
WHERE migration_key = '{$migrationKey}'"
) === false) {
throw new RuntimeException('Unable to record customer-rule product migration');
}
if ($db->query('COMMIT') === false) {
throw new RuntimeException('Unable to commit customer-rule product migration');
}
} catch (Throwable $throwable) {
$db->query('ROLLBACK');
throw $throwable;
}
}
private static function legacyPredicate(object $db, string $attribute): string
{
$text = "LOWER(CONCAT(COALESCE(p.name, ''), ' ', COALESCE(c.name, '')))";
return match ($attribute) {
'restrictAdditionalServices' => "p.category = 8 OR LOWER(COALESCE(c.name, '')) IN ('tillægsydelser', 'tillaegsydelser')" .
(self::tableExists($db, 'products_options') && self::columnExists($db, 'products_options', 'option_id')
? ' OR EXISTS (SELECT 1 FROM products_options po WHERE po.option_id = p.id)'
: ''),
'restrictTankCleaning' => "p.category = 5 OR {$text} LIKE '%tank cleaning%' OR {$text} LIKE '%tankcleaning%' OR {$text} LIKE '%tankrens%' OR {$text} LIKE '%tank rens%'",
'restrictSpotFree' => "p.id IN (23, 24) OR {$text} LIKE '%spot free%' OR {$text} LIKE '%spotfree%' OR {$text} LIKE '%skylning med ro%'",
'restrictInteriorCleaning' => "{$text} LIKE '%interior%' OR {$text} LIKE '%indvendig%'",
'onlyTankCleaning' => "NOT (p.category = 5 OR {$text} LIKE '%tank cleaning%' OR {$text} LIKE '%tankcleaning%' OR {$text} LIKE '%tankrens%' OR {$text} LIKE '%tank rens%')",
default => '0 = 1',
};
}
private static function tableExists(object $db, string $table): bool
{
$safeTable = self::escape($db, $table);
$result = $db->query("SHOW TABLES LIKE '{$safeTable}'");
return $result && (int)$result->num_rows > 0;
}
private static function indexExists(object $db, string $table, string $index): bool
{
$safeTable = str_replace('`', '', $table);
$safeIndex = self::escape($db, $index);
$result = $db->query("SHOW INDEX FROM `{$safeTable}` WHERE Key_name = '{$safeIndex}'");
return $result && (int)$result->num_rows > 0;
}
private static function columnExists(object $db, string $table, string $column): bool
{
$safeTable = str_replace('`', '', $table);
$safeColumn = self::escape($db, $column);
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
return $result && (int)$result->num_rows > 0;
}
private static function escape(object $db, string $value): string
{
return method_exists($db, 'escape_string')
? $db->escape_string($value)
: addslashes($value);
}
private static function affectedRows(object $db): int
{
if (method_exists($db, 'conn')) {
$connection = $db->conn();
return (int)($connection->affected_rows ?? 0);
}
return (int)($db->affected_rows ?? 0);
}
}
@@ -0,0 +1,508 @@
<?php
namespace classes;
use RuntimeException;
use Throwable;
class customer_rule_product_restriction_exception extends RuntimeException
{
public function __construct(string $message, private readonly int $httpStatus = 422, string $code = 'INVALID_CUSTOMER_RULE_CONFIGURATION')
{
parent::__construct($message);
$this->restrictionCode = $code;
}
private string $restrictionCode;
public function httpStatus(): int
{
return $this->httpStatus;
}
public function restrictionCode(): string
{
return $this->restrictionCode;
}
}
/**
* Source of truth for globally configured customer-rule product collections.
*/
class customer_rule_product_restriction_service
{
/** @var list<string> */
public const PRODUCT_IMPACT_ATTRIBUTES = [
'restrictAdditionalServices',
'restrictTankCleaning',
'restrictSpotFree',
'restrictInteriorCleaning',
'onlyTankCleaning',
];
/** @var list<string> */
public const SUPPORTED_ATTRIBUTES = [
'restrictAdditionalServices',
'restrictTankCleaning',
'restrictSpotFree',
'restrictInteriorCleaning',
'onlyTankCleaning',
'requiresReferenceNumber',
'requiresRegistrationNumbersInvoice',
'invoiceAllOrdersIndividually',
'invoiceWithStripe',
'showPricesOnBookingPage',
'usePONumbers',
'exemptFromAdministrationFee',
];
public function __construct()
{
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
}
/** @return array{rules:list<array<string,mixed>>,products:list<array<string,mixed>>} */
public function listConfiguration(): array
{
return [
'rules' => array_map(fn(string $attribute): array => $this->ruleConfiguration($attribute), self::PRODUCT_IMPACT_ATTRIBUTES),
'products' => $this->productCatalog(),
];
}
/** @return array<string,mixed> */
public function ruleConfiguration(string $attribute): array
{
$this->assertSupportedAttribute($attribute);
global $db;
$safeAttribute = $this->escape($attribute);
$versionResult = $db->query(
"SELECT version FROM customer_rule_product_restrictions WHERE attribute = '{$safeAttribute}' LIMIT 1"
);
if (!$versionResult || $versionResult->num_rows < 1) {
throw new RuntimeException("Unable to load customer-rule restriction version for {$attribute}");
}
$versionRow = $versionResult->fetch_assoc();
$result = $db->query(
"SELECT c.id AS collection_id, c.name, c.sort_order, cp.product_id
FROM customer_rule_product_collections c
LEFT JOIN customer_rule_product_collection_products cp ON cp.collection_id = c.id
WHERE c.attribute = '{$safeAttribute}'
ORDER BY c.sort_order ASC, c.id ASC, cp.product_id ASC"
);
if (!$result) {
throw new RuntimeException("Unable to load customer-rule restriction collections for {$attribute}");
}
$collections = [];
$disabled = [];
while ($row = $result->fetch_assoc()) {
$collectionId = (int)$row['collection_id'];
if (!isset($collections[$collectionId])) {
$collections[$collectionId] = [
'id' => $collectionId,
'name' => (string)$row['name'],
'sort_order' => (int)$row['sort_order'],
'product_ids' => [],
];
}
if ($row['product_id'] !== null) {
$productId = (int)$row['product_id'];
$collections[$collectionId]['product_ids'][] = $productId;
$disabled[$productId] = true;
}
}
return [
'attribute' => $attribute,
'version' => max(1, (int)($versionRow['version'] ?? 1)),
'collections' => array_values($collections),
'disabled_product_ids' => array_map('intval', array_keys($disabled)),
];
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
public function replaceRuleConfiguration(string $attribute, array $payload, int $actorUserId): array
{
$this->assertSupportedAttribute($attribute);
$expectedVersion = $this->positiveInt($payload['version'] ?? null, 'version');
$collections = $this->validateCollections($attribute, $payload['collections'] ?? null);
global $db;
$safeAttribute = $this->escape($attribute);
if ($db->query('START TRANSACTION') === false) {
throw new customer_rule_product_restriction_exception('Unable to start configuration transaction', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
try {
$versionResult = $db->query(
"SELECT version FROM customer_rule_product_restrictions
WHERE attribute = '{$safeAttribute}' FOR UPDATE"
);
if (!$versionResult || $versionResult->num_rows < 1) {
throw new customer_rule_product_restriction_exception('Customer rule configuration was not found', 404, 'CUSTOMER_RULE_CONFIGURATION_NOT_FOUND');
}
$versionRow = $versionResult->fetch_assoc();
$currentVersion = (int)$versionRow['version'];
if ($currentVersion !== $expectedVersion) {
throw new customer_rule_product_restriction_exception(
'Customer rule configuration has changed; reload before saving',
409,
'CUSTOMER_RULE_CONFIGURATION_CONFLICT'
);
}
$old = $this->ruleConfiguration($attribute);
$existingIds = $this->existingCollectionIds($attribute);
foreach ($collections as $collection) {
if ($collection['id'] !== null && !isset($existingIds[$collection['id']])) {
throw new customer_rule_product_restriction_exception('A collection does not belong to this customer rule');
}
}
// Avoid temporary unique-name collisions while two collections swap names.
foreach ($existingIds as $collectionId => $_) {
$temporaryName = $this->escape('__pending_' . $collectionId . '_' . bin2hex(random_bytes(6)));
if ($db->query(
"UPDATE customer_rule_product_collections
SET name = '{$temporaryName}'
WHERE id = {$collectionId} AND attribute = '{$safeAttribute}'"
) === false) {
throw new customer_rule_product_restriction_exception('Unable to prepare collection update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
}
$keptIds = [];
foreach ($collections as $collection) {
$name = $this->escape($collection['name']);
$sortOrder = (int)$collection['sort_order'];
$collectionId = $collection['id'];
if ($collectionId === null) {
if ($db->query(
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
VALUES ('{$safeAttribute}', '{$name}', {$sortOrder})"
) === false) {
throw new customer_rule_product_restriction_exception('Unable to create collection');
}
$collectionId = (int)$db->insert_id();
} else {
if ($db->query(
"UPDATE customer_rule_product_collections
SET name = '{$name}', sort_order = {$sortOrder}
WHERE id = {$collectionId} AND attribute = '{$safeAttribute}'"
) === false) {
throw new customer_rule_product_restriction_exception('Unable to update collection');
}
}
$keptIds[$collectionId] = true;
if ($db->query("DELETE FROM customer_rule_product_collection_products WHERE collection_id = {$collectionId}") === false) {
throw new customer_rule_product_restriction_exception('Unable to replace collection products', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
foreach ($collection['product_ids'] as $productId) {
if ($db->query(
"INSERT INTO customer_rule_product_collection_products (collection_id, product_id)
VALUES ({$collectionId}, {$productId})"
) === false) {
throw new customer_rule_product_restriction_exception('Unable to save collection products');
}
}
}
$removeIds = array_values(array_diff(array_keys($existingIds), array_keys($keptIds)));
if ($removeIds !== []) {
if ($db->query(
'DELETE FROM customer_rule_product_collections WHERE attribute = \'' . $safeAttribute . '\' AND id IN (' .
implode(',', array_map('intval', $removeIds)) . ')'
) === false) {
throw new customer_rule_product_restriction_exception('Unable to remove collections', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
}
$newVersion = $currentVersion + 1;
if ($db->query(
"UPDATE customer_rule_product_restrictions
SET version = {$newVersion}, updated_at = NOW()
WHERE attribute = '{$safeAttribute}'"
) === false) {
throw new customer_rule_product_restriction_exception('Unable to update configuration version', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
$new = $this->ruleConfiguration($attribute);
$changes = $this->escape((string)json_encode([
'before' => $old,
'after' => $new,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
if ($db->query(
"INSERT INTO customer_rule_product_audit_logs
(actor_user_id, attribute, old_version, new_version, changes_json)
VALUES ({$actorUserId}, '{$safeAttribute}', {$currentVersion}, {$newVersion}, '{$changes}')"
) === false) {
throw new customer_rule_product_restriction_exception('Unable to audit configuration update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
if ($db->query('COMMIT') === false) {
throw new customer_rule_product_restriction_exception('Unable to commit configuration update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
}
return $new;
} catch (Throwable $throwable) {
$db->query('ROLLBACK');
throw $throwable;
}
}
/**
* Return configured product restrictions for all active product-impact
* attributes belonging to any account with the customer number.
*
* @return list<array<string,mixed>>
*/
public function restrictionsForCustomerNumber(int $customerNumber): array
{
if ($customerNumber < 1) {
return [];
}
global $db;
$result = $db->query(
"SELECT DISTINCT ca.attribute
FROM users u
INNER JOIN customer_attributes ca ON ca.user_id = u.id
WHERE u.customer_number = {$customerNumber}"
);
if (!$result) {
throw new RuntimeException('Unable to load active customer-rule product restrictions');
}
$activeAttributes = [];
while ($row = $result->fetch_assoc()) {
$attribute = (string)$row['attribute'];
if (in_array($attribute, self::PRODUCT_IMPACT_ATTRIBUTES, true)) {
$activeAttributes[$attribute] = true;
}
}
$active = [];
foreach (self::PRODUCT_IMPACT_ATTRIBUTES as $attribute) {
if (isset($activeAttributes[$attribute])) {
$active[] = $this->ruleConfiguration($attribute);
}
}
return $active;
}
/** @return array{rules:list<string>,collections:list<int>,message:string,code:string,product_id:int}|null */
public function violationForCustomerProduct(int $customerNumber, int $productId): ?array
{
if ($productId < 1) {
return null;
}
$rules = [];
$collections = [];
foreach ($this->restrictionsForCustomerNumber($customerNumber) as $restriction) {
if (!in_array($productId, $restriction['disabled_product_ids'], true)) {
continue;
}
$rules[] = (string)$restriction['attribute'];
foreach ($restriction['collections'] as $collection) {
if (in_array($productId, $collection['product_ids'], true)) {
$collections[] = (int)$collection['id'];
}
}
}
if ($rules === []) {
return null;
}
return [
'code' => 'CUSTOMER_RULE_PRODUCT_RESTRICTED',
'message' => customer_product_rule_service::BLOCK_MESSAGE,
'product_id' => $productId,
'rules' => array_values(array_unique($rules)),
'collections' => array_values(array_unique($collections)),
];
}
/**
* @param list<array<string,mixed>> $attributes
* @return list<array<string,mixed>>
*/
public function enrichAttributes(int $customerNumber, array $attributes): array
{
$restrictions = [];
foreach ($this->restrictionsForCustomerNumber($customerNumber) as $restriction) {
$restrictions[(string)$restriction['attribute']] = [
'attribute' => (string)$restriction['attribute'],
'version' => (int)$restriction['version'],
'collections' => $restriction['collections'],
'disabled_product_ids' => $restriction['disabled_product_ids'],
];
}
foreach ($attributes as &$attribute) {
$key = (string)($attribute['attribute'] ?? '');
$attribute['product_restriction'] = $restrictions[$key] ?? null;
}
unset($attribute);
return $attributes;
}
/** @return list<array<string,mixed>> */
private function productCatalog(): array
{
global $db;
$activeExpression = $this->columnExists('products', 'deleted_at')
? 'CASE WHEN p.deleted_at IS NULL THEN 1 ELSE 0 END'
: '1';
$result = $db->query(
"SELECT p.id, p.name, p.category AS category_id, c.name AS category_name,
{$activeExpression} AS active
FROM products p
LEFT JOIN categories c ON c.id = p.category
ORDER BY c.name ASC, p.name ASC, p.id ASC"
);
if (!$result) {
throw new RuntimeException('Unable to load the customer-rule product catalog');
}
$products = [];
while ($row = $result->fetch_assoc()) {
$products[] = [
'id' => (int)$row['id'],
'name' => (string)$row['name'],
'category_id' => (int)$row['category_id'],
'category_name' => (string)($row['category_name'] ?? ''),
'active' => (bool)$row['active'],
];
}
return $products;
}
/**
* @return array<int,true>
*/
private function existingCollectionIds(string $attribute): array
{
global $db;
$safeAttribute = $this->escape($attribute);
$result = $db->query("SELECT id FROM customer_rule_product_collections WHERE attribute = '{$safeAttribute}'");
if (!$result) {
throw new RuntimeException("Unable to load existing collections for {$attribute}");
}
$ids = [];
while ($row = $result->fetch_assoc()) {
$ids[(int)$row['id']] = true;
}
return $ids;
}
/** @return list<array{id:?int,name:string,sort_order:int,product_ids:list<int>}> */
private function validateCollections(string $attribute, mixed $value): array
{
if (!is_array($value)) {
throw new customer_rule_product_restriction_exception('collections must be an array');
}
$normalized = [];
$names = [];
$collectionIds = [];
$allProductIds = [];
foreach (array_values($value) as $index => $collection) {
if (!is_array($collection)) {
throw new customer_rule_product_restriction_exception("Collection {$index} must be an object");
}
$name = trim((string)($collection['name'] ?? ''));
if ($name === '' || mb_strlen($name) > 191) {
throw new customer_rule_product_restriction_exception('Collection names must be between 1 and 191 characters');
}
$nameKey = mb_strtolower($name);
if (isset($names[$nameKey])) {
throw new customer_rule_product_restriction_exception('Collection names must be unique within a rule');
}
$names[$nameKey] = true;
if (!isset($collection['product_ids']) || !is_array($collection['product_ids'])) {
throw new customer_rule_product_restriction_exception('product_ids must be an array');
}
$productIds = [];
foreach ($collection['product_ids'] as $productId) {
$id = $this->positiveInt($productId, 'product_id');
$productIds[$id] = true;
$allProductIds[$id] = true;
}
$id = isset($collection['id']) && $collection['id'] !== null
? $this->positiveInt($collection['id'], 'collection id')
: null;
if ($id !== null && isset($collectionIds[$id])) {
throw new customer_rule_product_restriction_exception('Collection IDs must be unique within a rule');
}
if ($id !== null) {
$collectionIds[$id] = true;
}
$normalized[] = [
'id' => $id,
'name' => $name,
'sort_order' => isset($collection['sort_order']) && is_numeric($collection['sort_order'])
? (int)$collection['sort_order']
: $index,
'product_ids' => array_map('intval', array_keys($productIds)),
];
}
$this->assertProductsExist(array_map('intval', array_keys($allProductIds)));
return $normalized;
}
/** @param list<int> $productIds */
private function assertProductsExist(array $productIds): void
{
if ($productIds === []) {
return;
}
global $db;
$result = $db->query('SELECT id FROM products WHERE id IN (' . implode(',', $productIds) . ')');
if (!$result) {
throw new customer_rule_product_restriction_exception(
'Unable to validate collection products',
500,
'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED'
);
}
$found = [];
while ($row = $result->fetch_assoc()) {
$found[(int)$row['id']] = true;
}
$missing = array_values(array_diff($productIds, array_keys($found)));
if ($missing !== []) {
throw new customer_rule_product_restriction_exception('Unknown product IDs: ' . implode(', ', $missing));
}
}
private function assertSupportedAttribute(string $attribute): void
{
if (!in_array($attribute, self::PRODUCT_IMPACT_ATTRIBUTES, true)) {
throw new customer_rule_product_restriction_exception('Unsupported product-impact customer rule');
}
}
private function positiveInt(mixed $value, string $field): int
{
if (!is_numeric($value) || (int)$value < 1 || (string)(int)$value !== trim((string)$value)) {
throw new customer_rule_product_restriction_exception("{$field} must be a positive integer");
}
return (int)$value;
}
private function escape(string $value): string
{
global $db;
return method_exists($db, 'escape_string') ? $db->escape_string($value) : addslashes($value);
}
private function columnExists(string $table, string $column): bool
{
global $db;
$safeTable = str_replace('`', '', $table);
$safeColumn = $this->escape($column);
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
return $result && (int)$result->num_rows > 0;
}
}
+41 -12
View File
@@ -177,15 +177,19 @@ class db
return $this->database;
}
public function getPort(): int
{
return $this->port;
}
public function getSslMode(): string
{
return $this->ssl_mode;
}
public function backupDatabase(string $path): bool
{
// Save the database to the path
// Build a safe mysqldump command with configurable SSL (MariaDB-compatible flags)
$mode = strtoupper(trim($this->ssl_mode));
// Map ssl_mode to MariaDB client flags
// DISABLED => --skip-ssl (no TLS)
// PREFERRED => (no flag; client decides)
// REQUIRED/VERIFY_* => --ssl (enable TLS without strict verification unless CA materials provided)
$sslFlag = '';
switch ($mode) {
case 'DISABLED':
@@ -201,17 +205,42 @@ class db
$sslFlag = '--ssl';
break;
}
$host = escapeshellarg($this->host);
$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 -P $port -u $user --password=$pass $db > $outfile 2>&1";
exec($command, $output, $return);
// Check if the command was successful
return $return === 0;
$command = "mysqldump {$sslPart}--single-transaction --quick --routines --triggers --events --hex-blob -h $host -P $port -u $user $db";
$directory = dirname($path);
if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) {
return false;
}
$environment = array_merge(getenv() ?: [], $_ENV);
$environment['MYSQL_PWD'] = $this->password;
$descriptors = [
0 => ['pipe', 'r'],
1 => ['file', $path, 'w'],
2 => ['pipe', 'w'],
];
$process = proc_open($command, $descriptors, $pipes, null, $environment);
if (!is_resource($process)) {
return false;
}
fclose($pipes[0]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[2]);
$return = proc_close($process);
if ($return !== 0 && is_string($stderr) && $stderr !== '') {
@file_put_contents($path . '.error.log', $stderr);
}
return $return === 0 && is_file($path) && filesize($path) !== false;
}
public function getView(string $view): array
@@ -0,0 +1,44 @@
<?php
namespace classes;
/**
* Ensures additive schema for department-scoped customer price overrides.
*/
class department_customer_price_overrides_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"CREATE TABLE IF NOT EXISTS `department_customer_price_overrides` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`department_id` INT NOT NULL,
`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,
`fixed_price` INT NULL DEFAULT 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_department_customer_price_overrides_lookup` (`department_id`, `user_id`, `is_category`, `product_or_category_id`),
KEY `idx_department_customer_price_overrides_department` (`department_id`),
KEY `idx_department_customer_price_overrides_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::$initialized = true;
}
}
@@ -0,0 +1,353 @@
<?php
namespace classes;
use objects\department_customer_price_overrides_o;
use objects\departments_o;
use objects\products_o;
use objects\users_o;
class department_customer_pricing_service
{
/**
* @return array<string, mixed>
*/
public function getPricing(int $departmentId, int $userId): array
{
$department = $this->department($departmentId);
$customer = $this->customer($userId);
$this->assertEnabled($department);
$overrides = (new department_customer_price_overrides_o())->getAllPrices($departmentId, $userId);
return [
'department' => $department,
'customer' => $customer,
'overrides' => $overrides,
'categories' => $this->catalog($departmentId, $customer['id']),
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function updatePricing(int $departmentId, int $userId, array $payload): array
{
$department = $this->department($departmentId);
$customer = $this->customer($userId);
$this->assertEnabled($department);
if (array_key_exists('department_id', $payload) && (int)$payload['department_id'] !== $departmentId) {
throw new limited_backoffice_exception('Department ID in body does not match the route.', 400);
}
if (array_key_exists('user_id', $payload) && (int)$payload['user_id'] !== $userId) {
throw new limited_backoffice_exception('User ID in body does not match the route.', 400);
}
$overrides = $payload['overrides'] ?? null;
if (!is_array($overrides)) {
throw new limited_backoffice_exception('Overrides are required.', 400);
}
$normalized = $this->normalizeOverrides($departmentId, $overrides);
$overrideObject = new department_customer_price_overrides_o();
$existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']);
$normalizedKeys = [];
foreach ($normalized as $override) {
$normalizedKeys[$this->overrideKey((bool)$override['is_category'], $override['product_or_category_id'])] = true;
}
global $db;
$db->conn()->begin_transaction();
try {
$db->query(
'DELETE FROM `department_customer_price_overrides` WHERE `department_id` = '
. (int)$departmentId . ' AND `user_id` = ' . (int)$customer['id']
);
foreach ($normalized as $override) {
$overrideObject->setPrice(
$departmentId,
$customer['id'],
(bool)$override['is_category'],
$override['product_or_category_id'],
(int)$override['percentage'],
$override['fixed_price']
);
}
$db->conn()->commit();
} catch (\Throwable) {
$db->conn()->rollback();
throw new limited_backoffice_exception('Unable to update department customer pricing.', 500);
}
foreach ($normalized as $override) {
$this->recordVersion($customer, $departmentId, $override);
}
foreach ($existingOverrides as $existingOverride) {
$key = $this->overrideKey((bool)$existingOverride['is_category'], $existingOverride['product_or_category_id']);
if (isset($normalizedKeys[$key])) {
continue;
}
$this->recordVersion($customer, $departmentId, [
'is_category' => (bool)$existingOverride['is_category'],
'product_or_category_id' => $existingOverride['product_or_category_id'],
'percentage' => 0,
'fixed_price' => null,
]);
}
return $this->getPricing($departmentId, $customer['id']);
}
/**
* @return array{id:int,name:string,description:string,custom_pricing_only:bool}
*/
private function department(int $departmentId): array
{
$department = (new departments_o())->getDepartmentById($departmentId);
if (!is_array($department) || empty($department)) {
throw new limited_backoffice_exception('Department not found', 404);
}
return [
'id' => (int)$department['id'],
'name' => (string)$department['name'],
'description' => (string)($department['description'] ?? ''),
'custom_pricing_only' => (bool)(int)($department['custom_pricing_only'] ?? 0),
];
}
/**
* @return array{id:int,customer_number:int,display_name:string}
*/
private function customer(int $userId): array
{
$customer = (new users_o())->getUserById($userId);
if (!$customer->exists()) {
throw new limited_backoffice_exception('Customer not found', 404);
}
return [
'id' => (int)$customer->id,
'customer_number' => (int)$customer->customer_number->value(),
'display_name' => (string)($customer->display_name->value() ?: ('Customer #' . $customer->customer_number->value())),
];
}
/**
* @param array<string, mixed> $department
*/
private function assertEnabled(array $department): void
{
if (!($department['custom_pricing_only'] ?? false)) {
throw new limited_backoffice_exception('Department customer pricing is disabled.', 409, [
'message' => 'Department customer pricing is disabled.',
'code' => 'department_customer_pricing_disabled',
'department' => $department,
]);
}
}
/**
* @return array<int, array<string, mixed>>
*/
private function catalog(int $departmentId, int $userId): array
{
global $db;
$sql = "
SELECT
c.`id` AS `category_id`,
c.`name` AS `category_name`,
c.`description` AS `category_description`,
p.*,
pdp.`price` AS `department_price`
FROM `department_categories` dc
INNER JOIN `categories` c ON c.`id` = dc.`category_id`
INNER JOIN `products` p ON p.`category` = dc.`category_id`
LEFT JOIN `product_department_prices` pdp
ON pdp.`department_id` = dc.`department_id`
AND pdp.`product_id` = p.`id`
WHERE dc.`department_id` = " . (int)$departmentId . "
AND dc.`deleted_at` IS NULL
ORDER BY c.`name` ASC, c.`id` ASC, p.`order_priority` ASC, p.`name` ASC, p.`id` ASC";
$result = $db->query($sql);
$rows = $result ? $db->fetch_all($result) : [];
$customer = (new users_o())->getUserById($userId);
$categories = [];
$seen = [];
foreach ($rows as $row) {
$productId = (int)$row['id'];
if (isset($seen[$productId])) {
continue;
}
$seen[$productId] = true;
$categoryId = (int)$row['category_id'];
if (!isset($categories[$categoryId])) {
$categories[$categoryId] = [
'id' => $categoryId,
'name' => (string)$row['category_name'],
'description' => (string)($row['category_description'] ?? ''),
'products' => [],
];
}
$departmentPrice = $row['department_price'] === null ? null : (int)$row['department_price'];
$effectivePrice = products_o::CUSTOM_PRICING_MISSING_PRICE;
if ($departmentPrice !== null) {
$effectivePrice = $customer->applyProductCustomerPricing($productId, $departmentPrice, true, $departmentId);
}
$categories[$categoryId]['products'][] = [
'id' => $productId,
'name' => (string)$row['name'],
'description' => (string)($row['description'] ?? ''),
'category' => $categoryId,
'apply_category_discount' => (bool)$row['apply_category_discount'],
'base_price' => (int)$row['price'],
'department_price' => $departmentPrice,
'effective_price' => $effectivePrice,
'missing_department_price' => $departmentPrice === null,
];
}
return array_values($categories);
}
/**
* @param array<int, mixed> $overrides
* @return array<int, array{is_category:bool,product_or_category_id:int|string,percentage:int,fixed_price:int|null}>
*/
private function normalizeOverrides(int $departmentId, array $overrides): array
{
$normalized = [];
foreach ($overrides as $override) {
if (!is_array($override)) {
throw new limited_backoffice_exception('Invalid override payload.', 400);
}
$isCategory = (bool)($override['is_category'] ?? false);
$objectId = $override['product_or_category_id'] ?? $override['object_id'] ?? null;
if ($objectId === null || $objectId === '') {
throw new limited_backoffice_exception('Override object is required.', 400);
}
$percentage = filter_var($override['discount'] ?? $override['percentage'] ?? 0, FILTER_VALIDATE_INT);
if ($percentage === false || $percentage < 0 || $percentage > 100) {
throw new limited_backoffice_exception('Discount must be between 0 and 100.', 400);
}
$fixedPrice = null;
if (array_key_exists('fixed_price', $override) && $override['fixed_price'] !== null && $override['fixed_price'] !== '') {
$fixedPrice = filter_var($override['fixed_price'], FILTER_VALIDATE_INT);
if ($fixedPrice === false || $fixedPrice < 0) {
throw new limited_backoffice_exception('Fixed price must be zero or more.', 400);
}
}
if ($isCategory) {
$fixedPrice = null;
$objectId = (string)$objectId;
if ($objectId !== 'global') {
$this->assertDepartmentCategory($departmentId, $objectId);
}
} else {
$objectId = (int)$objectId;
$this->assertDepartmentProduct($departmentId, $objectId);
}
if ($percentage <= 0 && $fixedPrice === null) {
continue;
}
$key = $this->overrideKey($isCategory, $objectId);
$normalized[$key] = [
'is_category' => $isCategory,
'product_or_category_id' => $objectId,
'percentage' => (int)$percentage,
'fixed_price' => $fixedPrice === null ? null : (int)$fixedPrice,
];
}
return array_values($normalized);
}
/**
* @param array{id:int,customer_number:int,display_name:string} $customer
* @param array{is_category:bool,product_or_category_id:int|string,percentage:int,fixed_price:int|null} $override
*/
private function recordVersion(array $customer, int $departmentId, array $override): void
{
try {
(new economic_v2_versioning_service())->recordDiscountOverrideVersion(
(int)$customer['id'],
(int)$customer['customer_number'],
(bool)$override['is_category'],
(string)$override['product_or_category_id'],
(int)$override['percentage'],
date('Y-m-d H:i:s'),
'live.department_discount_override.route',
1.0,
false,
[
'route' => 'department_customer_pricing',
'department_id' => $departmentId,
],
$override['fixed_price'],
$departmentId
);
} catch (\Throwable) {
}
}
private function overrideKey(bool $isCategory, int|string $objectId): string
{
return ((int)$isCategory) . ':' . (string)$objectId;
}
private function assertDepartmentProduct(int $departmentId, int $productId): void
{
global $db;
$result = $db->query(
'SELECT p.`id`
FROM `department_categories` dc
INNER JOIN `products` p ON p.`category` = dc.`category_id`
WHERE dc.`department_id` = ' . (int)$departmentId . '
AND dc.`deleted_at` IS NULL
AND p.`id` = ' . (int)$productId . '
LIMIT 1'
);
if (!$result || $result->num_rows < 1) {
throw new limited_backoffice_exception('Product is not available for this department.', 400);
}
}
private function assertDepartmentCategory(int $departmentId, string $categoryId): void
{
global $db;
$categoryId = $db->escape_string($categoryId);
$result = $db->query(
"SELECT `id`
FROM `department_categories`
WHERE `department_id` = " . (int)$departmentId . "
AND `deleted_at` IS NULL
AND `category_id` = '{$categoryId}'
LIMIT 1"
);
if (!$result || $result->num_rows < 1) {
throw new limited_backoffice_exception('Category is not available for this department.', 400);
}
}
}
@@ -0,0 +1,252 @@
<?php
namespace classes;
require_once WD . '/classes/selfserve_schema_bootstrap.php';
use Exception;
class department_wash_count_service
{
/**
* @throws Exception
*/
public function countInDateRange(string $date_start, string $date_end, int $department_id): int
{
$rows = $this->countByHourForDepartments($date_start, $date_end, [$department_id]);
$total = 0;
foreach ($rows as $row) {
$total += (int)($row['wash_count'] ?? 0);
}
return $total;
}
/**
* @param array<int|string> $department_ids
* @return array<int,array{department_id:int,hour_bucket:string,wash_count:int}>
* @throws Exception
*/
public function countByHourForDepartments(string $date_start, string $date_end, array $department_ids): array
{
global $db;
$this->validateDateRange($date_start, $date_end);
$normalized_department_ids = $this->normalizeIds($department_ids);
if ($normalized_department_ids === []) {
return [];
}
selfserve_schema_bootstrap::ensureTables();
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$candidate_sql = $this->candidateUnionSql($department_ids_sql, $escaped_start, $escaped_end);
$sql = "SELECT deduped.department_id,
DATE_FORMAT(deduped.counted_at, '%Y-%m-%d %H:00:00') AS hour_bucket,
COUNT(*) AS wash_count
FROM (
SELECT dedupe_key,
department_id,
MIN(counted_at) AS counted_at
FROM ($candidate_sql) candidates
GROUP BY dedupe_key, department_id
) deduped
GROUP BY deduped.department_id, DATE_FORMAT(deduped.counted_at, '%Y-%m-%d %H:00:00')
ORDER BY deduped.department_id ASC, hour_bucket ASC";
$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;
}
/**
* @param array<int|string> $department_ids
* @return array{quantity:int,products:int,earnings:int,washes:int}
* @throws Exception
*/
public function transactionSummary(string $date_start, string $date_end, array $department_ids): array
{
global $db;
$this->validateDateRange($date_start, $date_end);
$normalized_department_ids = $this->normalizeIds($department_ids);
if ($normalized_department_ids === []) {
return [
'quantity' => 0,
'products' => 0,
'earnings' => 0,
'washes' => 0,
];
}
$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
FROM orders o
JOIN order_items oi ON oi.order_id = o.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' => $this->countRows($date_start, $date_end, $normalized_department_ids),
];
}
/**
* @param array<int|string> $department_ids
* @return array<int,array{id:int,department_id:int,created_at:string}>
* @throws Exception
*/
public function listTransactions(string $date_start, string $date_end, array $department_ids): array
{
global $db;
$this->validateDateRange($date_start, $date_end);
$normalized_department_ids = $this->normalizeIds($department_ids);
if ($normalized_department_ids === []) {
return [];
}
selfserve_schema_bootstrap::ensureTables();
$department_ids_sql = implode(',', $normalized_department_ids);
$escaped_start = $db->escape_string($date_start);
$escaped_end = $db->escape_string($date_end);
$candidate_sql = $this->candidateUnionSql($department_ids_sql, $escaped_start, $escaped_end);
$sql = "SELECT CAST(SUBSTRING_INDEX(GROUP_CONCAT(entity_id ORDER BY source_priority ASC, entity_id ASC), ',', 1) AS UNSIGNED) AS id,
department_id,
MIN(counted_at) AS created_at
FROM ($candidate_sql) candidates
GROUP BY dedupe_key, department_id
ORDER BY 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<int> $department_ids
* @throws Exception
*/
private function countRows(string $date_start, string $date_end, array $department_ids): int
{
$rows = $this->countByHourForDepartments($date_start, $date_end, $department_ids);
$total = 0;
foreach ($rows as $row) {
$total += (int)($row['wash_count'] ?? 0);
}
return $total;
}
private function candidateUnionSql(string $department_ids_sql, string $escaped_start, string $escaped_end): string
{
return "SELECT CONCAT('order:', o.id) AS dedupe_key,
o.id AS entity_id,
o.department_id,
o.created_at AS counted_at,
0 AS source_priority
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
UNION ALL
SELECT CASE
WHEN linked_o.id IS NOT NULL THEN CONCAT('order:', linked_o.id)
ELSE CONCAT('selfserve:', s.id)
END AS dedupe_key,
CASE
WHEN linked_o.id IS NOT NULL THEN linked_o.id
ELSE s.id
END AS entity_id,
COALESCE(linked_o.department_id, s.department_id) AS department_id,
COALESCE(linked_o.created_at, s.completed_at) AS counted_at,
1 AS source_priority
FROM selfserve_wash_sessions s
LEFT JOIN orders linked_o
ON linked_o.id = s.order_id
AND linked_o.deleted_at IS NULL
WHERE COALESCE(linked_o.department_id, s.department_id) IN ($department_ids_sql)
AND COALESCE(linked_o.created_at, s.completed_at) BETWEEN '$escaped_start' AND '$escaped_end'
AND s.deleted_at IS NULL
AND s.completed_at IS NOT NULL
AND UPPER(TRIM(s.status)) = 'COMPLETED'";
}
/**
* @param array<int|string> $ids
* @return array<int>
*/
private function normalizeIds(array $ids): array
{
$normalized = [];
foreach ($ids as $id) {
$value = (int)$id;
if ($value > 0) {
$normalized[$value] = $value;
}
}
return array_values($normalized);
}
/**
* @throws Exception
*/
private function validateDateRange(string $date_start, string $date_end): void
{
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');
}
}
}
@@ -34,6 +34,14 @@ class departments_schema_bootstrap
);
}
if (!self::columnExists($db, 'departments', 'custom_pricing_only')) {
$db->query(
"ALTER TABLE departments
ADD COLUMN custom_pricing_only TINYINT(1) NOT NULL DEFAULT 0
AFTER archived"
);
}
if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
$db->query(
"ALTER TABLE departments
+29 -1
View File
@@ -172,7 +172,8 @@ class economic implements economic_i
string $email,
int $phone,
?int $mobile_phone = null,
object|array|null $company_information = null
object|array|null $company_information = null,
?string $ean = null
): object
{
$payload = [
@@ -196,10 +197,37 @@ class economic implements economic_i
];
$payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information));
$normalized_ean = self::normalizeCustomerEan($ean);
if ($normalized_ean !== null) {
$payload['ean'] = $normalized_ean;
}
return $this->customers->customers->create($payload);
}
public static function normalizeCustomerEan(mixed $value): ?string
{
if ($value === null) {
return null;
}
$digits = preg_replace('/\D+/', '', (string)$value);
if (!is_string($digits)) {
return null;
}
$digits = trim($digits);
if ($digits === '') {
return null;
}
if (strlen($digits) > 13) {
throw new \InvalidArgumentException('EAN must be at most 13 digits.');
}
return $digits;
}
private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array
{
if ($company_information === null) {
@@ -240,7 +240,13 @@ class economic_transfer_executor
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
);
return $collected_order_invoices->asArray();
$result = $collected_order_invoices->asArray();
$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();
if ($transfer_metrics !== null) {
$result['economic_transfer_metrics'] = $transfer_metrics;
}
return $result;
}
/**
@@ -40,6 +40,7 @@ class economic_v2_distribution_service
public function __construct(?economic_v2_versioning_service $versioning = null, ?economic $economic = null)
{
department_customer_price_overrides_schema_bootstrap::ensureTables();
$this->versioning = $versioning ?? new economic_v2_versioning_service();
$this->economic = $economic;
}
@@ -388,7 +389,16 @@ class economic_v2_distribution_service
continue;
}
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at);
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at, $department_id);
if ($discount_row === null) {
continue;
}
if (array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
$fixed_price = (float)$discount_row['fixed_price'];
$order_discount_total += (($base_price - $fixed_price) * $quantity);
continue;
}
$discount_percentage = (float)($discount_row['discount'] ?? 0);
if ($discount_percentage <= 0) {
continue;
@@ -1519,7 +1529,13 @@ class economic_v2_distribution_service
$line_price = ((float)$this->getProductDepartmentPrice($product_id, $department_id)) * $quantity;
}
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp);
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp, $department_id);
if ($discount_row !== null && array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
$line_price = ((float)$discount_row['fixed_price']) * $quantity;
$total += $line_price;
continue;
}
$discount_percentage = (float)($discount_row['discount'] ?? 0);
if ($discount_percentage > 0) {
$line_price *= (1 - ($discount_percentage / 100));
@@ -1529,15 +1545,22 @@ class economic_v2_distribution_service
return $total;
}
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp): ?array
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp, ?int $department_id = null): ?array
{
$cache_key = $customer_number . '|' . $product_id . '|' . substr($timestamp, 0, 19);
$cache_key = $customer_number . '|' . $product_id . '|' . (int)($department_id ?? 0) . '|' . 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) {
$scopedDepartmentId = $department_id !== null && (new \objects\departments_o())->isCustomPricingOnly((int)$department_id)
? (int)$department_id
: null;
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp, $scopedDepartmentId);
if ($direct !== null && (
(array_key_exists('fixed_price', $direct) && $direct['fixed_price'] !== null)
|| (int)($direct['discount'] ?? 0) > 0
)) {
return $this->discount_resolution_cache[$cache_key] = $direct;
}
@@ -1545,13 +1568,20 @@ class economic_v2_distribution_service
if ($product !== null) {
$category = (string)$product->category->value();
if ($category !== '') {
$category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp);
$category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp, $scopedDepartmentId);
if ($category_discount !== null && (int)($category_discount['discount'] ?? 0) > 0) {
return $this->discount_resolution_cache[$cache_key] = $category_discount;
}
}
}
if ($scopedDepartmentId !== null) {
$global_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, 'global', $timestamp, $scopedDepartmentId);
if ($global_discount !== null && (int)($global_discount['discount'] ?? 0) > 0) {
return $this->discount_resolution_cache[$cache_key] = $global_discount;
}
}
return $this->discount_resolution_cache[$cache_key] = null;
}
@@ -60,11 +60,13 @@ class economic_v2_schema_bootstrap
"CREATE TABLE IF NOT EXISTS customer_discount_override_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NULL DEFAULT NULL,
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,
fixed_price INT NULL DEFAULT NULL,
effective_from DATETIME NOT NULL,
effective_to DATETIME NULL,
source VARCHAR(64) NOT NULL DEFAULT 'live',
@@ -74,6 +76,7 @@ class economic_v2_schema_bootstrap
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_discount_override_versions_lookup (customer_number, is_category, object_id, effective_from, effective_to),
INDEX idx_discount_override_versions_department_lookup (department_id, customer_number, is_category, object_id, effective_from, effective_to),
INDEX idx_discount_override_versions_user (user_id),
INDEX idx_discount_override_versions_source (source)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
@@ -83,6 +86,22 @@ class economic_v2_schema_bootstrap
$db->query($sql);
}
if (!self::tableHasColumn('customer_discount_override_versions', 'fixed_price')) {
$db->query(
"ALTER TABLE customer_discount_override_versions
ADD COLUMN fixed_price INT NULL DEFAULT NULL
AFTER discount"
);
}
if (!self::tableHasColumn('customer_discount_override_versions', 'department_id')) {
$db->query(
"ALTER TABLE customer_discount_override_versions
ADD COLUMN department_id INT NULL DEFAULT NULL
AFTER id"
);
}
self::$initialized = true;
}
@@ -106,4 +125,3 @@ class economic_v2_schema_bootstrap
return ((int)($row['c'] ?? 0)) > 0;
}
}
@@ -135,7 +135,9 @@ class economic_v2_versioning_service
string $source = 'live.discount_override',
float $confidence = 1.0,
bool $inferred = false,
array $metadata = []
array $metadata = [],
?int $fixed_price = null,
?int $department_id = null
): array {
$identity = [
'user_id' => $user_id,
@@ -143,8 +145,11 @@ class economic_v2_versioning_service
'is_category' => (int)$is_category,
'object_id' => (string)$object_id,
];
if ($department_id !== null) {
$identity['department_id'] = (int)$department_id;
}
if ($discount === null || (int)$discount === 0) {
if (($discount === null || (int)$discount === 0) && $fixed_price === null) {
return $this->closeActiveVersion(
'customer_discount_override_versions',
$identity,
@@ -161,6 +166,7 @@ class economic_v2_versioning_service
$identity,
[
'discount' => (int)$discount,
'fixed_price' => $is_category ? null : $fixed_price,
],
$this->normalizeDatetime($effective_from),
$source,
@@ -238,15 +244,21 @@ class economic_v2_versioning_service
int $customer_number,
bool $is_category,
int|string $object_id,
string $timestamp
string $timestamp,
?int $department_id = null
): ?array {
$identity = [
'customer_number' => $customer_number,
'is_category' => (int)$is_category,
'object_id' => (string)$object_id,
];
if ($department_id !== null) {
$identity['department_id'] = (int)$department_id;
}
$rows = $this->resolveActiveVersions(
'customer_discount_override_versions',
[
'customer_number' => $customer_number,
'is_category' => (int)$is_category,
'object_id' => (string)$object_id,
],
$identity,
$timestamp,
'effective_from DESC, id DESC',
1
@@ -411,8 +423,11 @@ class economic_v2_versioning_service
}
// Discount overrides current state.
price_overrides_schema_bootstrap::ensureColumns();
$has_override_created_at = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'created_at');
$has_override_fixed_price = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'fixed_price');
$discount_cols = 'po.user_id, u.customer_number, po.is_category, po.product_or_category_id, po.percentage' .
($has_override_fixed_price ? ', po.fixed_price' : '') .
($has_override_created_at ? ', po.created_at' : '');
$discount_rows = $this->fetchAll(
"SELECT $discount_cols
@@ -434,7 +449,8 @@ class economic_v2_versioning_service
'backfill.current_discount_override',
$confidence,
true,
['table' => 'price_overrides']
['table' => 'price_overrides'],
$has_override_fixed_price && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null
);
$this->incrementReportAction($report['discount_overrides'], $result['action'] ?? 'noop');
}
@@ -707,4 +723,3 @@ class economic_v2_versioning_service
$bucket[$action]++;
}
}
+111 -2
View File
@@ -128,13 +128,13 @@ use Psr\Http\Client\ClientExceptionInterface;
private function sendEmailMailerSend(string $to, string $recipient_name, string $subject, string $message, string $html = null, string $references = null, array $attachments = []): void
{
if (self::isFakeDeliveryEnabled()) {
self::$fake_deliveries[] = [
self::recordFakeDelivery([
'to' => $to,
'recipient_name' => $recipient_name,
'subject' => $subject,
'message' => $message,
'html' => $html,
];
]);
return;
}
@@ -225,6 +225,72 @@ use Psr\Http\Client\ClientExceptionInterface;
public static function resetFakeDeliveries(): void
{
self::$fake_deliveries = [];
$path = self::getFakeDeliveriesPath();
if ($path !== null && is_file($path)) {
unlink($path);
}
}
public static function syncFakeDeliveries(): void
{
$path = self::getFakeDeliveriesPath();
if ($path === null || !is_file($path)) {
self::$fake_deliveries = [];
return;
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
self::$fake_deliveries = [];
return;
}
$deliveries = [];
foreach ($lines as $line) {
$delivery = json_decode($line, true);
if (is_array($delivery)) {
$deliveries[] = $delivery;
}
}
self::$fake_deliveries = $deliveries;
}
private static function recordFakeDelivery(array $delivery): void
{
self::$fake_deliveries[] = $delivery;
$path = self::getFakeDeliveriesPath();
if ($path === null) {
return;
}
$directory = dirname($path);
if (!is_dir($directory)) {
mkdir($directory, 0777, true);
}
file_put_contents($path, json_encode($delivery, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL, FILE_APPEND | LOCK_EX);
}
private static function getFakeDeliveriesPath(): ?string
{
if (!self::isFakeDeliveryEnabled()) {
return null;
}
$configuredPath = trim((string)(getenv('EMAIL_FAKE_DELIVERIES_PATH') ?: ''));
if ($configuredPath !== '') {
return $configuredPath;
}
if (getenv('RUN_API_TESTS') !== '1') {
return null;
}
return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
. DIRECTORY_SEPARATOR
. 'truckwash-email-fake-deliveries-' . md5((string)getcwd()) . '.jsonl';
}
private static function isFakeDeliveryEnabled(): bool
@@ -511,4 +577,47 @@ use Psr\Http\Client\ClientExceptionInterface;
$this->attachments
);
}
/**
* @throws Exception
*/
public function sendNewCustomerRegistrationNotifications(int $customer_number): void
{
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
if (!$customer->exists()) {
throw new Exception('Customer not found with customer number: ' . $customer_number);
}
$customerName = $customer->getCustomerName((int)$customer->customer_number->value()) ?: 'Unknown customer';
$safeCustomerName = htmlspecialchars($customerName, ENT_QUOTES, 'UTF-8');
$safeCustomerNumber = (int)$customer->customer_number->value();
$customerUrl = 'https://truckwash.io/superuser/users?search=' . $safeCustomerNumber;
$message = "
<p>A new customer has registered on truckwash.io.</p>
<p>
<strong>Customer number:</strong> $safeCustomerNumber<br>
<strong>Customer name:</strong> $safeCustomerName
</p>
<p><a href='$customerUrl'>Open customer in Superuser</a></p>
";
foreach ((new users_o())->getSuperuserNewCustomerEmailNotificationRecipients() as $recipient) {
$recipientEmail = trim((string)($recipient['email'] ?? ''));
if ($recipientEmail === '') {
continue;
}
$recipientName = trim((string)($recipient['display_name'] ?? ''));
if ($recipientName === '') {
$recipientName = $recipientEmail;
}
$this->sendEmail(
$recipientEmail,
$recipientName,
'New customer registered on Truck Wash',
$message,
);
}
}
}
@@ -92,12 +92,17 @@ class error_report_service
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'] : [];
$storedScreenshot = $this->storeOptionalScreenshot($payload['screenshot'] ?? null, $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);
$runtimeContext['screenshot_attachment'] = [
'status' => $storedScreenshot['status'],
'attached' => $storedScreenshot['key'] !== '',
'mime_type' => $storedScreenshot['mime_type'] !== '' ? $storedScreenshot['mime_type'] : null,
'size_bytes' => (int)$storedScreenshot['size_bytes'],
];
$this->execute(
"INSERT INTO error_reports (
@@ -295,6 +300,67 @@ class error_report_service
return $value === true || $value === 1 || $value === '1' || $value === 'true';
}
private function storeOptionalScreenshot(mixed $value, array $context): array
{
if (!is_scalar($value) && !$value instanceof \Stringable && $value !== null) {
return $this->emptyScreenshotAttachment('invalid');
}
$dataUri = trim((string)($value ?? ''));
if ($dataUri === '') {
return $this->emptyScreenshotAttachment($this->contextScreenshotStatus($context) ?? 'not_provided');
}
try {
$screenshot = self::decodeScreenshotDataUri($dataUri);
} catch (RuntimeException $exception) {
$message = strtolower($exception->getMessage());
return $this->emptyScreenshotAttachment(str_contains($message, 'too large') ? 'too_large' : 'invalid');
}
try {
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
} catch (Throwable) {
return $this->emptyScreenshotAttachment('storage_failed');
}
return [
'key' => (string)($storedScreenshot['key'] ?? ''),
'mime_type' => (string)($storedScreenshot['mime_type'] ?? $screenshot['mime_type']),
'size_bytes' => (int)($storedScreenshot['size_bytes'] ?? $screenshot['size_bytes']),
'status' => 'stored',
];
}
private function emptyScreenshotAttachment(string $status): array
{
return [
'key' => '',
'mime_type' => '',
'size_bytes' => 0,
'status' => $status,
];
}
private function contextScreenshotStatus(array $context): ?string
{
$attachment = $context['screenshot_attachment'] ?? null;
$status = is_array($attachment) ? ($attachment['status'] ?? null) : null;
$status ??= $context['screenshot_capture_status'] ?? $context['screenshot_status'] ?? null;
return $this->normalizeEmptyScreenshotStatus($status);
}
private function normalizeEmptyScreenshotStatus(mixed $status): ?string
{
$status = strtolower(trim((string)$status));
if (in_array($status, ['capture_failed', 'not_provided'], true)) {
return $status;
}
return null;
}
private function runtimeContext(array $payload, array $context): array
{
return [
@@ -432,6 +498,10 @@ class error_report_service
private function publicReport(array $row, bool $includeDetail): array
{
$screenshotMimeType = trim((string)($row['screenshot_mime_type'] ?? ''));
$screenshotSizeBytes = isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0;
$hasScreenshot = $screenshotMimeType !== '' && $screenshotSizeBytes > 0;
$report = [
'id' => (int)$row['id'],
'status' => (string)$row['status'],
@@ -449,10 +519,10 @@ class error_report_service
'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,
],
'screenshot' => $hasScreenshot ? [
'mime_type' => $screenshotMimeType,
'size_bytes' => $screenshotSizeBytes,
] : null,
'answers' => [
'before_error' => $row['before_error'] ?? '',
'expected' => $row['expected'] ?? '',
@@ -467,8 +537,11 @@ class error_report_service
];
if ($includeDetail) {
$report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? ''));
$report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null;
if ($hasScreenshot) {
$objectKey = trim((string)($row['screenshot_object_key'] ?? ''));
$report['screenshot']['url'] = $this->store->screenshotUrl($objectKey);
$report['screenshot']['object_key'] = $objectKey !== '' ? $objectKey : 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);
+22 -3
View File
@@ -11,6 +11,7 @@ use fxratesapi\actions\convert_rate_a;
use fxratesapi\fxratesapi_c;
use interfaces\fxratesapi_i;
use objects\fxratesapi_conversion_rates_o;
use Throwable;
class fxratesapi implements fxratesapi_i
{
@@ -117,10 +118,10 @@ class fxratesapi implements fxratesapi_i
// Validate the base and target currencies
self::requireValidCurrency($base);
self::requireValidCurrency($target);
// Validate the daily limit
self::requireDailyLimitNotExceeded();
// Validate the secret key
self::requireValidSecretKey();
// Reserve quota for the outbound provider call. Cached conversion reads return before this point.
$this->reserveRateFetchQuota($base, $target, $endpoint, $method);
// Send the request
$response = match ($method) {
'GET' => self::sendGetRequest($base, $target, $endpoint, $data),
@@ -167,7 +168,7 @@ class fxratesapi implements fxratesapi_i
function requireDailyLimitNotExceeded(): void
{
// Check if the daily limit is exceeded
if ($this->getDailyRequestCounter() >= $this->config->daily_limit->getVariableValue()) {
if ($this->getDailyRequestCounter() >= (int)$this->config->daily_limit->getVariableValue()) {
throw new Exception('Daily limit exceeded');
}
}
@@ -177,12 +178,30 @@ class fxratesapi implements fxratesapi_i
*/
function getDailyRequestCounter(): int
{
try {
return (new module_usage_service())->currentUsedQuantity('fxratesapi', 'rate_fetch_calls');
} catch (Throwable) {
}
// Count the rows from the fxratesapi request log that was made today
$fxratesapi_lookups = new fxratesapi_conversion_rates_o();
$fxratesapi_lookups->getTodayCount();
return $fxratesapi_lookups->getTodayCount();
}
/**
* @throws Exception
*/
private function reserveRateFetchQuota(string $base, string $target, string $endpoint, string $method): void
{
(new module_usage_service())->reserveOrFail('fxratesapi', 'rate_fetch_calls', 1, [
'base' => $base,
'target' => $target,
'endpoint' => $endpoint,
'method' => strtoupper($method),
]);
}
/**
* @inheritDoc
*/
@@ -38,6 +38,8 @@ class gateway_shelly_transport implements shelly_transport_i
return match ($endpoint) {
'/v2/devices/api/get' => $this->handleGetStates($department_id, $data),
'/v2/devices/api/set/switch' => $this->handleSetSwitch($department_id, $data),
'/v2/devices/api/batch/get' => $this->handleBatchGetStates($department_id, $data),
'/v2/devices/api/batch/set/switch' => $this->handleBatchSetSwitch($department_id, $data),
default => throw new Exception('Unsupported gateway Shelly transport endpoint: ' . $endpoint),
};
}
@@ -95,6 +97,52 @@ class gateway_shelly_transport implements shelly_transport_i
return [$this->normalizeRelayPayload($logicalRelayId, $status)];
}
/**
* @throws Exception
*/
private function handleBatchGetStates(int $departmentId, array $data): array
{
$requests = [];
foreach ((array)($data['commands'] ?? $data['targets'] ?? []) as $entry) {
$command = is_array($entry) ? $entry : ['relay_id' => $entry];
$relayId = trim((string)($command['relay_id'] ?? $command['relayId'] ?? $command['id'] ?? ''));
if ($relayId === '') {
continue;
}
$requests[] = [
'target' => strtoupper(trim((string)($command['target'] ?? $relayId))),
'relay_id' => $relayId,
];
}
return $this->manager()->queueRelayStatusBatch($departmentId, $requests, null, $this->localOnly);
}
/**
* @throws Exception
*/
private function handleBatchSetSwitch(int $departmentId, array $data): array
{
$requests = [];
foreach ((array)($data['commands'] ?? []) as $entry) {
if (!is_array($entry)) {
continue;
}
$relayId = trim((string)($entry['relay_id'] ?? $entry['relayId'] ?? $entry['id'] ?? ''));
if ($relayId === '') {
continue;
}
$requests[] = [
'target' => strtoupper(trim((string)($entry['target'] ?? $relayId))),
'relay_id' => $relayId,
'on' => (bool)($entry['on'] ?? false),
'toggle_after' => $entry['toggle_after'] ?? $entry['toggleAfter'] ?? $entry['timer'] ?? null,
];
}
return $this->manager()->queueRelaySwitchBatch($departmentId, $requests, null, $this->localOnly);
}
/**
* @param array<string,mixed> $status
* @return array<string,mixed>
@@ -0,0 +1,617 @@
<?php
namespace classes;
use Exception;
use objects\collected_order_invoices_o;
use objects\logs_o;
use objects\order_items_o;
use objects\orders_o;
use objects\products_o;
use objects\users_o;
class invoice_collection_bulk_action_service
{
public const ACTION_CLEAN_CUSTOMER_RULES = 'remove_customer_rule_violations';
public const ACTION_MERGE = 'merge_collections';
public const ACTION_SPLIT_BY_MONTH = 'split_by_month';
public const ACTION_RESET_HIDDEN_PRICES = 'reset_hidden_item_prices';
public const ACTION_QUEUE_ECONOMIC = 'queue_economic';
private const PREVIEW_TTL_SECONDS = 600;
private const MAX_COLLECTIONS = 100;
private const CONFIRMATION_PHRASES = [
'da' => 'Bekræft',
'en' => 'Confirm',
'sv' => 'Bekräfta',
'no' => 'Bekreft',
'de' => 'Bestätigen',
];
public function preview(string $action, array $invoiceCollectionIds, array $options = [], string $locale = 'da'): array
{
$action = $this->normalizeAction($action);
$invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
$options = $this->normalizeOptions($options);
$preview = $this->buildPreview($action, $invoiceCollectionIds, $options, $locale);
$previewId = $this->previewId();
$preview['preview_id'] = $previewId;
$preview['selection_hash'] = $this->selectionHash($action, $invoiceCollectionIds, $options);
$preview['confirmation_phrase'] = $this->confirmationPhrase($locale);
$this->cachePreview($previewId, [
'action' => $action,
'invoice_collection_ids' => $invoiceCollectionIds,
'options' => $options,
'locale' => $locale,
'selection_hash' => $preview['selection_hash'],
'preview' => $preview,
]);
return $preview;
}
public function apply(
string $previewId,
string $action,
array $invoiceCollectionIds,
array $options,
string $confirmationText,
int $actorUserId,
string $locale = 'da'
): array {
global $db;
$action = $this->normalizeAction($action);
$invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
$options = $this->normalizeOptions($options);
$cached = $this->getCachedPreview($previewId);
$selectionHash = $this->selectionHash($action, $invoiceCollectionIds, $options);
if (!$cached || ($cached['selection_hash'] ?? '') !== $selectionHash) {
throw new Exception('Preview is missing, expired, or no longer matches the selected invoice collections.');
}
$expectedConfirmation = (string)($cached['preview']['confirmation_phrase'] ?? $this->confirmationPhrase($locale));
if (trim($confirmationText) !== $expectedConfirmation) {
throw new Exception('Confirmation text does not match.');
}
$freshPreview = $this->buildPreview($action, $invoiceCollectionIds, $options, (string)($cached['locale'] ?? $locale));
if (!empty($freshPreview['blockers'])) {
throw new Exception('Action cannot be applied while blockers are present.');
}
$db->conn()->begin_transaction();
try {
$result = match ($action) {
self::ACTION_CLEAN_CUSTOMER_RULES => $this->applyCleanCustomerRules($freshPreview),
self::ACTION_MERGE => $this->applyMerge($freshPreview, $options),
self::ACTION_SPLIT_BY_MONTH => $this->applySplitByMonth($freshPreview),
self::ACTION_RESET_HIDDEN_PRICES => $this->applyResetHiddenPrices($freshPreview),
self::ACTION_QUEUE_ECONOMIC => [
'queued_invoice_collection_ids' => $invoiceCollectionIds,
'changed_count' => count($invoiceCollectionIds),
],
default => throw new Exception('Unsupported action'),
};
(new logs_o())->add(
'orderInvoices',
'global',
1,
$actorUserId,
'APPLY_COLLECTED_INVOICE_BULK_ACTION',
'Applied collected invoice bulk action ' . $action . ' to ' . count($invoiceCollectionIds) . ' invoice collections'
);
$db->conn()->commit();
} catch (\Throwable $e) {
$db->conn()->rollback();
throw $e;
}
$this->deleteCachedPreview($previewId);
return [
...$freshPreview,
'preview' => false,
'result' => $result,
];
}
private function buildPreview(string $action, array $invoiceCollectionIds, array $options, string $locale): array
{
$collections = $this->loadCollections($invoiceCollectionIds);
$base = [
'action' => $action,
'preview' => true,
'confirmation_phrase' => $this->confirmationPhrase($locale),
'invoice_collection_ids' => $invoiceCollectionIds,
'collections' => array_map(fn(collected_order_invoices_o $collection): array => $this->collectionSummary($collection), $collections),
'warnings' => [],
'blockers' => [],
];
return match ($action) {
self::ACTION_CLEAN_CUSTOMER_RULES => $this->previewCleanCustomerRules($base, $collections),
self::ACTION_MERGE => $this->previewMerge($base, $collections, $options),
self::ACTION_SPLIT_BY_MONTH => $this->previewSplitByMonth($base, $collections),
self::ACTION_RESET_HIDDEN_PRICES => $this->previewResetHiddenPrices($base, $collections),
self::ACTION_QUEUE_ECONOMIC => $this->previewQueueEconomic($base, $collections),
default => throw new Exception('Unsupported action'),
};
}
private function previewCleanCustomerRules(array $preview, array $collections): array
{
$items = [];
$blockers = [];
foreach ($collections as $collection) {
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
$rows = $this->orderItemRows((int)$collection->id);
$violatingItemIds = [];
$includedItemIds = [];
foreach ($rows as $row) {
$violation = (new customer_product_rule_service())->firstViolationForOrderItem(
(int)$row['order_id'],
(int)$row['product_id'],
empty($row['related_item_id']) ? null : (int)$row['related_item_id']
);
if ($violation === null) {
continue;
}
$violatingItemIds[] = (int)$row['order_item_id'];
$includedItemIds[] = (int)$row['order_item_id'];
$items[] = [
'invoice_collection_id' => (int)$collection->id,
'order_id' => (int)$row['order_id'],
'order_item_id' => (int)$row['order_item_id'],
'product_id' => (int)$row['product_id'],
'product_name' => (string)$row['product_name'],
'rule' => (string)$violation['rule'],
'price' => (int)$row['price'],
'quantity' => (int)$row['quantity'],
'will_soft_delete' => true,
];
}
foreach ($rows as $row) {
$orderItemId = (int)$row['order_item_id'];
$relatedItemId = empty($row['related_item_id']) ? null : (int)$row['related_item_id'];
if ($relatedItemId === null || !in_array($relatedItemId, $violatingItemIds, true) || in_array($orderItemId, $includedItemIds, true)) {
continue;
}
$includedItemIds[] = $orderItemId;
$items[] = [
'invoice_collection_id' => (int)$collection->id,
'order_id' => (int)$row['order_id'],
'order_item_id' => $orderItemId,
'product_id' => (int)$row['product_id'],
'product_name' => (string)$row['product_name'],
'rule' => 'related_to_removed_item',
'price' => (int)$row['price'],
'quantity' => (int)$row['quantity'],
'will_soft_delete' => true,
];
}
}
return [
...$preview,
'order_items' => $items,
'summary' => [
'collections' => count($collections),
'order_items' => count($items),
'changed_count' => count($items),
],
'blockers' => $blockers,
];
}
private function previewMerge(array $preview, array $collections, array $options): array
{
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
$blockers = [];
if (count($collections) < 2) {
$blockers[] = ['code' => 'merge_requires_multiple_collections', 'message' => 'Merge requires at least two invoice collections.'];
}
if ($targetId < 1 || !in_array($targetId, array_map(static fn($collection): int => (int)$collection->id, $collections), true)) {
$blockers[] = ['code' => 'invalid_merge_target', 'message' => 'A selected invoice collection must be chosen as merge target.'];
}
$customerNumbers = array_values(array_unique(array_map(static fn($collection): int => (int)$collection->customer_number->value(), $collections)));
if (count($customerNumbers) !== 1) {
$blockers[] = ['code' => 'merge_cross_customer', 'message' => 'Only invoice collections for the same customer can be merged.'];
}
foreach ($collections as $collection) {
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
}
$ordersToMove = [];
foreach ($collections as $collection) {
if ((int)$collection->id === $targetId) {
continue;
}
foreach ($collection->getOrderIds() as $orderIdRow) {
$ordersToMove[] = [
'order_id' => (int)$orderIdRow['id'],
'source_invoice_collection_id' => (int)$collection->id,
'target_invoice_collection_id' => $targetId,
];
}
}
return [
...$preview,
'target_invoice_collection_id' => $targetId,
'orders' => $ordersToMove,
'summary' => [
'collections' => count($collections),
'orders_to_move' => count($ordersToMove),
'changed_count' => count($ordersToMove),
],
'blockers' => $blockers,
];
}
private function previewSplitByMonth(array $preview, array $collections): array
{
$items = [];
$changed = [];
$skipped = [];
foreach ($collections as $collection) {
try {
$item = [
'invoice_collection_id' => (int)$collection->id,
...$collection->previewSplitByOrderMonth(),
];
if (($item['status'] ?? '') === 'changed') {
$changed[] = $item;
} else {
$skipped[] = $item;
}
$items[] = $item;
} catch (\Throwable $e) {
$item = [
'status' => 'skipped',
'invoice_collection_id' => (int)$collection->id,
'reason' => 'not_splittable',
'message' => $e->getMessage(),
];
$skipped[] = $item;
$items[] = $item;
}
}
return [
...$preview,
'items' => $items,
'changed' => $changed,
'skipped' => $skipped,
'summary' => [
'collections' => count($collections),
'changed_count' => count($changed),
'skipped_count' => count($skipped),
],
];
}
private function previewResetHiddenPrices(array $preview, array $collections): array
{
$items = [];
$blockers = [];
foreach ($collections as $collection) {
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
foreach ($this->orderItemRows((int)$collection->id, true) as $row) {
if ((int)$row['include_in_invoice'] !== 0) {
continue;
}
$order = (new orders_o())->select((int)$row['order_id']);
$product = (new products_o())->select((int)$row['product_id']);
if (!$order->exists() || !$product->exists()) {
continue;
}
$newPrice = (int)$order->getCustomerProductPrice($product);
if ((int)$row['price'] === $newPrice) {
continue;
}
$items[] = [
'invoice_collection_id' => (int)$collection->id,
'order_id' => (int)$row['order_id'],
'order_item_id' => (int)$row['order_item_id'],
'product_id' => (int)$row['product_id'],
'product_name' => (string)$row['product_name'],
'current_price' => (int)$row['price'],
'new_price' => $newPrice,
];
}
}
return [
...$preview,
'order_items' => $items,
'summary' => [
'collections' => count($collections),
'order_items' => count($items),
'changed_count' => count($items),
],
'blockers' => $blockers,
];
}
private function previewQueueEconomic(array $preview, array $collections): array
{
$blockers = [];
foreach ($collections as $collection) {
if (!empty($collection->booked_invoice_id->value())) {
$blockers[] = [
'code' => 'collection_booked',
'invoice_collection_id' => (int)$collection->id,
'message' => 'Invoice collection is already booked.',
];
}
}
return [
...$preview,
'summary' => [
'collections' => count($collections),
'changed_count' => count($collections),
],
'blockers' => $blockers,
];
}
private function applyCleanCustomerRules(array $preview): array
{
global $db;
$itemIds = array_values(array_unique(array_map(static fn(array $item): int => (int)$item['order_item_id'], $preview['order_items'] ?? [])));
if ($itemIds === []) {
return ['changed_count' => 0, 'order_item_ids' => []];
}
$ids = implode(',', array_map('intval', $itemIds));
$now = date('Y-m-d H:i:s');
$safeNow = $db->escape_string($now);
$db->query("UPDATE order_items SET deleted_at = '$safeNow' WHERE deleted_at IS NULL AND id IN ($ids)");
$this->touchOrdersForItems($itemIds);
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
return ['changed_count' => count($itemIds), 'order_item_ids' => $itemIds];
}
private function applyMerge(array $preview, array $options): array
{
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
$moved = [];
foreach ($preview['orders'] ?? [] as $row) {
$order = (new orders_o())->select((int)$row['order_id']);
if (!$order->exists()) {
continue;
}
$order->assignToInvoiceCollection($targetId);
$moved[] = (int)$order->id;
}
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
return ['changed_count' => count($moved), 'moved_order_ids' => $moved, 'target_invoice_collection_id' => $targetId];
}
private function applySplitByMonth(array $preview): array
{
$changed = [];
$skipped = [];
foreach ($preview['items'] ?? [] as $item) {
$invoiceCollectionId = (int)($item['invoice_collection_id'] ?? 0);
if (($item['status'] ?? '') !== 'changed' || $invoiceCollectionId < 1) {
$skipped[] = $item;
continue;
}
try {
$changed[] = (new collected_order_invoices_o())->select($invoiceCollectionId)->splitByOrderMonth();
} catch (\Throwable $e) {
$skipped[] = [
'invoice_collection_id' => $invoiceCollectionId,
'status' => 'skipped',
'message' => $e->getMessage(),
];
}
}
return ['changed_count' => count($changed), 'skipped_count' => count($skipped), 'changed' => $changed, 'skipped' => $skipped];
}
private function applyResetHiddenPrices(array $preview): array
{
$changed = [];
foreach ($preview['order_items'] ?? [] as $item) {
$orderItem = (new order_items_o())->select((int)$item['order_item_id']);
if (!$orderItem->exists()) {
continue;
}
$orderItem->price->set((int)$item['new_price']);
$orderItem->objectChanged();
$changed[] = (int)$orderItem->id;
}
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
return ['changed_count' => count($changed), 'order_item_ids' => $changed];
}
private function contentMutationBlockers(collected_order_invoices_o $collection): array
{
$blockers = [];
if (!empty($collection->booked_invoice_id->value())) {
$blockers[] = ['code' => 'collection_booked', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection is already booked.'];
}
if (!empty($collection->external_id->value())) {
$blockers[] = ['code' => 'collection_exported', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection already has an external invoice reference.'];
}
return $blockers;
}
private function orderItemRows(int $invoiceCollectionId, bool $includeHidden = false): array
{
global $db;
$hiddenCondition = $includeHidden ? '' : 'AND oi.include_in_invoice = 1';
$sql = "
SELECT
oi.id AS order_item_id,
oi.order_id,
oi.product_id,
oi.related_item_id,
oi.include_in_invoice,
oi.price,
oi.quantity,
p.name AS product_name
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
WHERE o.invoice_collection_id = {$invoiceCollectionId}
AND o.deleted_at IS NULL
AND oi.deleted_at IS NULL
{$hiddenCondition}
ORDER BY o.id ASC, oi.id ASC
";
$result = $db->query($sql);
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
private function touchOrdersForItems(array $itemIds): void
{
global $db;
if ($itemIds === []) {
return;
}
$ids = implode(',', array_map('intval', $itemIds));
$result = $db->query("SELECT DISTINCT order_id FROM order_items WHERE id IN ($ids)");
if (!$result) {
return;
}
while ($row = $result->fetch_assoc()) {
$order = (new orders_o())->select((int)$row['order_id']);
if ($order->exists()) {
$order->objectChanged();
}
}
}
private function touchCollections(array $invoiceCollectionIds): void
{
foreach ($invoiceCollectionIds as $invoiceCollectionId) {
$collection = (new collected_order_invoices_o())->select((int)$invoiceCollectionId);
if ($collection->exists()) {
$collection->objectChanged();
}
}
}
private function loadCollections(array $invoiceCollectionIds): array
{
return array_map(static function (int $invoiceCollectionId): collected_order_invoices_o {
$collection = (new collected_order_invoices_o())->select($invoiceCollectionId);
$collection->requireSelected();
return $collection;
}, $invoiceCollectionIds);
}
private function collectionSummary(collected_order_invoices_o $collection): array
{
return [
'id' => (int)$collection->id,
'customer_number' => (int)$collection->customer_number->value(),
'name' => (string)$collection->name->value(),
'created_at' => (string)$collection->created_at->value(),
'closed_at' => $collection->closed_at->value(),
'booked_invoice_id' => $collection->booked_invoice_id->value(),
'external_id' => $collection->external_id->value(),
'order_count' => (int)$collection->getOrders(true),
];
}
private function normalizeAction(string $action): string
{
$action = trim($action);
if (!in_array($action, [
self::ACTION_CLEAN_CUSTOMER_RULES,
self::ACTION_MERGE,
self::ACTION_SPLIT_BY_MONTH,
self::ACTION_RESET_HIDDEN_PRICES,
self::ACTION_QUEUE_ECONOMIC,
], true)) {
throw new Exception('Invalid invoice collection bulk action.');
}
return $action;
}
private function normalizeInvoiceCollectionIds(array $ids): array
{
$normalized = [];
foreach ($ids as $id) {
if (is_array($id) || is_object($id) || !is_numeric($id)) {
throw new Exception('invoice_collection_ids must contain only positive integer ids.');
}
$parsed = (int)$id;
if ($parsed < 1 || $parsed > 999999999) {
throw new Exception('invoice_collection_ids must contain only positive integer ids.');
}
$normalized[$parsed] = $parsed;
}
$normalized = array_values($normalized);
sort($normalized);
if ($normalized === []) {
throw new Exception('invoice_collection_ids must contain at least one id.');
}
if (count($normalized) > self::MAX_COLLECTIONS) {
throw new Exception('Too many invoice collections selected.');
}
return $normalized;
}
private function normalizeOptions(array $options): array
{
if (isset($options['target_invoice_collection_id'])) {
$options['target_invoice_collection_id'] = (int)$options['target_invoice_collection_id'];
}
ksort($options);
return $options;
}
private function selectionHash(string $action, array $invoiceCollectionIds, array $options): string
{
return hash('sha256', json_encode([
'action' => $action,
'invoice_collection_ids' => $invoiceCollectionIds,
'options' => $options,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
private function confirmationPhrase(string $locale): string
{
$language = strtolower(substr(trim($locale), 0, 2));
return self::CONFIRMATION_PHRASES[$language] ?? self::CONFIRMATION_PHRASES['en'];
}
private function previewId(): string
{
return bin2hex(random_bytes(16));
}
private function previewCacheKey(string $previewId): string
{
return 'collected_invoice_bulk_action_preview:' . preg_replace('/[^a-f0-9]/', '', strtolower($previewId));
}
private function cachePreview(string $previewId, array $payload): void
{
(new redis())->setEx($this->previewCacheKey($previewId), json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), self::PREVIEW_TTL_SECONDS);
}
private function getCachedPreview(string $previewId): ?array
{
$raw = (new redis())->get($this->previewCacheKey($previewId));
if (!$raw) {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
private function deleteCachedPreview(string $previewId): void
{
(new redis())->delete($this->previewCacheKey($previewId));
}
}
@@ -30,6 +30,8 @@ class invoice_period_flag_service
public function __construct()
{
invoice_period_flag_schema_bootstrap::ensureTables();
price_overrides_schema_bootstrap::ensureColumns();
department_customer_price_overrides_schema_bootstrap::ensureTables();
}
public function createManualFlag(array $payload, int $userId): array
@@ -688,6 +690,7 @@ class invoice_period_flag_service
o.po AS order_po,
o.notes AS order_notes,
o.department_id,
d.custom_pricing_only AS department_custom_pricing_only,
o.reg_1,
o.invoice_collection_id,
o.wash_id,
@@ -710,8 +713,21 @@ class invoice_period_flag_service
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
CASE
WHEN d.custom_pricing_only = 1 THEN department_product_discount.percentage
ELSE product_discount.percentage
END AS product_discount_percentage,
CASE
WHEN d.custom_pricing_only = 1 THEN department_product_discount.fixed_price
ELSE product_discount.fixed_price
END AS product_fixed_price,
CASE
WHEN d.custom_pricing_only = 1 THEN GREATEST(
COALESCE(department_category_discount.percentage, 0),
COALESCE(department_global_discount.percentage, 0)
)
ELSE category_discount.percentage
END AS category_discount_percentage
FROM orders o
LEFT JOIN (
SELECT customer_number, MIN(id) AS id, MAX(display_name) AS display_name
@@ -720,11 +736,12 @@ class invoice_period_flag_service
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 departments d ON d.id = o.department_id
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
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage, MAX(po.fixed_price) AS fixed_price
FROM price_overrides po
INNER JOIN users discount_user ON discount_user.id = po.user_id
WHERE po.is_category = 0
@@ -741,6 +758,32 @@ class invoice_period_flag_service
) category_discount
ON category_discount.customer_number = o.customer_id
AND category_discount.product_or_category_id = p.category
LEFT JOIN (
SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage, MAX(fixed_price) AS fixed_price
FROM department_customer_price_overrides
WHERE is_category = 0
GROUP BY department_id, user_id, product_or_category_id
) department_product_discount
ON department_product_discount.department_id = o.department_id
AND department_product_discount.user_id = u.id
AND department_product_discount.product_or_category_id = p.id
LEFT JOIN (
SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage
FROM department_customer_price_overrides
WHERE is_category = 1 AND product_or_category_id <> 'global'
GROUP BY department_id, user_id, product_or_category_id
) department_category_discount
ON department_category_discount.department_id = o.department_id
AND department_category_discount.user_id = u.id
AND department_category_discount.product_or_category_id = p.category
LEFT JOIN (
SELECT department_id, user_id, MAX(percentage) AS percentage
FROM department_customer_price_overrides
WHERE is_category = 1 AND product_or_category_id = 'global'
GROUP BY department_id, user_id
) department_global_discount
ON department_global_discount.department_id = o.department_id
AND department_global_discount.user_id = u.id
WHERE o.created_at BETWEEN '{$escapedDateFrom}' AND '{$escapedDateTo}'
AND o.deleted_at IS NULL
ORDER BY o.customer_id, o.id, oi.id";
@@ -804,11 +847,16 @@ class invoice_period_flag_service
{
global $db;
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
$customerFilter = $this->customerFilterSql('u.customer_number', $onlyCustomerNumbers);
$result = $db->query(
"SELECT u.customer_number, ca.attribute
"SELECT u.customer_number, ca.attribute, cp.product_id
FROM customer_attributes ca
JOIN users u ON u.id = ca.user_id
LEFT JOIN customer_rule_product_restrictions r ON r.attribute = ca.attribute
LEFT JOIN customer_rule_product_collections c ON c.attribute = r.attribute
LEFT JOIN customer_rule_product_collection_products cp ON cp.collection_id = c.id
WHERE 1=1 {$customerFilter}"
);
@@ -819,7 +867,11 @@ class invoice_period_flag_service
while ($row = $result->fetch_assoc()) {
$customerNumber = (int)$row['customer_number'];
$attributes[$customerNumber][(string)$row['attribute']] = true;
$attribute = (string)$row['attribute'];
$attributes[$customerNumber][$attribute] = true;
if ($row['product_id'] !== null && in_array($attribute, customer_rule_product_restriction_service::PRODUCT_IMPACT_ATTRIBUTES, true)) {
$attributes[$customerNumber]['__disabled_products'][(int)$row['product_id']][$attribute] = true;
}
}
return $attributes;
@@ -830,6 +882,8 @@ class invoice_period_flag_service
$flags = [];
$orders = [];
$collectionOrders = [];
$matchingRules = static fn(int $customerNumber, int $productId): array =>
array_keys($attributes[$customerNumber]['__disabled_products'][$productId] ?? []);
foreach ($rows as $row) {
$customerNumber = (int)$row['customer_number'];
@@ -855,57 +909,17 @@ class invoice_period_flag_service
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']],
$productRuleDefinitions = [
'restrictAdditionalServices' => 'customer_rule_restrict_addon_services',
'restrictTankCleaning' => 'customer_rule_restrict_tank_cleaning',
'onlyTankCleaning' => 'customer_rule_only_tank_cleaning',
'restrictSpotFree' => 'customer_rule_restrict_spot_free',
'restrictInteriorCleaning' => 'customer_rule_restrict_interior_cleaning',
];
foreach ($restrictedProducts as $attribute => [$definitionKey, $terms]) {
if ($this->hasAttribute($attributes, $customerNumber, $attribute)
&& $this->rowMatchesProductTerms($row, $terms)) {
foreach ($matchingRules($customerNumber, (int)($row['product_id'] ?? 0)) as $attribute) {
if (isset($productRuleDefinitions[$attribute])) {
$flags[] = $this->automaticFlag(
$definitionKey,
$productRuleDefinitions[$attribute],
'order_item',
(int)$row['order_item_id'],
null,
@@ -915,6 +929,19 @@ class invoice_period_flag_service
);
}
}
if ($this->hasAttribute($attributes, $customerNumber, 'exemptFromAdministrationFee')
&& $this->rowMatchesProductTerms($row, ['administration fee', 'administrationsgebyr', 'administration'])) {
$flags[] = $this->automaticFlag(
'customer_rule_exempt_from_administration_fees',
'order_item',
(int)$row['order_item_id'],
null,
$row,
['product' => $this->productLabel($row)],
$this->orderItemContext($row)
);
}
}
foreach ($orders as $orderId => $row) {
@@ -1921,7 +1948,19 @@ class invoice_period_flag_service
private function calculateExpectedPrice(array $row): int
{
$base = $row['department_price'] !== null ? (int)$row['department_price'] : (int)($row['product_base_price'] ?? 0);
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
if ($customMissingPrice) {
return \objects\products_o::CUSTOM_PRICING_MISSING_PRICE;
}
$fixedPrice = $this->rowProductFixedPrice($row);
if ($fixedPrice !== null) {
return $fixedPrice;
}
$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)));
}
@@ -1929,13 +1968,18 @@ class invoice_period_flag_service
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);
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
$base = $departmentPrice ?? ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0));
$discount = $this->discountBreakdown($row);
if ($customMissingPrice) {
$discount['applied_discount_percentage'] = 0;
}
return [
'product_price' => (int)($row['product_base_price'] ?? 0),
'product_price' => $customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0),
'department_price' => $departmentPrice,
'effective_base_price' => $base,
'product_fixed_price' => $this->rowProductFixedPrice($row),
'product_discount_percentage' => $discount['product_discount_percentage'],
'category_discount_percentage' => $discount['category_discount_percentage'],
'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'],
@@ -1944,21 +1988,36 @@ class invoice_period_flag_service
];
}
private function isCustomMissingDepartmentPrice(array $row): bool
{
return $row['department_price'] === null && (bool)(int)($row['department_custom_pricing_only'] ?? 0);
}
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;
$appliedDiscount = $this->rowProductFixedPrice($row) !== null
? 0
: max($productDiscount, $categoryDiscount, $economicDiscount);
return [
'product_discount_percentage' => $productDiscount,
'category_discount_percentage' => $categoryDiscount,
'economic_customer_discount_percentage' => $economicDiscount,
'applied_discount_percentage' => max($productDiscount, $categoryDiscount, $economicDiscount),
'applied_discount_percentage' => $appliedDiscount,
];
}
private function rowProductFixedPrice(array $row): ?int
{
return array_key_exists('product_fixed_price', $row) && $row['product_fixed_price'] !== null
? (int)$row['product_fixed_price']
: null;
}
private function economicCustomerDiscountPercentage(array $row): int
{
$customerNumber = (int)($row['customer_number'] ?? 0);
@@ -2034,12 +2093,6 @@ class invoice_period_flag_service
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;
+1 -1
View File
@@ -27,7 +27,7 @@ class invoice_store implements minio_invoices_i
return count($objects['Contents'] ?? []) > 0;
}
public function getInvoiceDownloadUrl(int $id): string
public function getInvoiceDownloadUrl(int|string $id): string
{
return self::getPresignedUrl('invoice_' . $id . '.pdf');
}
@@ -10,6 +10,22 @@ use licenseplaterecognizer\licenseplaterecognizer_c;
class licenseplaterecognizer implements licenseplaterecognizer_i
{
private const DEFAULT_API_URL = 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk';
private const PLATE_READER_CONFIG_JSON = '{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0}';
private const RESULT_CACHE_CONTEXT = '{"config":{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0},"regions":"dk,de,se,no"}';
private const PLATE_READER_REGIONS = 'dk,de,se,no';
private const DEFAULT_UPLOAD_FILE_NAME = 'license-plate.jpg';
private const RUNTIME_CONFIG_CACHE_TTL_SECONDS = 15;
private const RUNTIME_CONFIG_REDIS_CACHE_KEY = 'licenseplaterecognizer:runtime_config:v1';
private const RESULT_CACHE_TTL_SECONDS = 10;
private const RESULT_CACHE_REDIS_KEY_PREFIX = 'licenseplaterecognizer:result:v1:';
private const PLATE_READER_CONNECT_TIMEOUT_MS = 1000;
private const PLATE_READER_TOTAL_TIMEOUT_MS = 4500;
/**
* @var array<string, float>
*/
private array $last_timings = [];
/**
* The configuration of the module
* @var licenseplaterecognizer_c
@@ -19,12 +35,25 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
* API URL
* @var string
*/
private string $api_url = 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk'; // Default (cloud): 'https://api.platerecognizer.com'; (without /v1/plate-reader/)';
private string $api_url;
/**
* @var array{enabled: bool, api_key: string}|null
*/
private ?array $runtime_config = null;
public function __construct()
/**
* @var array{values: array{enabled: bool, api_key: string}, cached_at: float}|null
*/
private static ?array $runtime_config_cache = null;
public function __construct(bool $load_config = true, ?string $api_url = null)
{
$this->config = new licenseplaterecognizer_c();
$this->api_url = self::normalizeApiUrl($api_url ?? self::configuredApiUrl());
if ($load_config) {
$this->config = new licenseplaterecognizer_c();
}
}
@@ -33,7 +62,7 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
*/
public function requireModuleEnabled(): void
{
if (!(bool)$this->config->enabled->getVariableValue()) {
if (!$this->runtimeConfig()['enabled']) {
throw new Exception('licenseplaterecognizer module is not enabled.');
}
}
@@ -45,54 +74,521 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
*/
public function licenseplaterecognizer(string $base64_image): array
{
$image_processor = new image_processor();
//ADD PARAMETER IN REQUEST LIKE regions
$data = array(
'upload' => $base64_image,
//'regions' => 'dk' // Optional
return $this->recognizePlate(
fn () => $this->buildPlateReaderPayload($base64_image),
fn () => $this->buildResultCacheKeyFromUploadString($base64_image)
);
}
// Prepare new cURL resource
//$ch = curl_init('https://api.platerecognizer.com/v1/plate-reader/');
$ch = curl_init($this->api_url . '/v1/plate-reader/');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLINFO_HEADER_OUT, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2TLS);
public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array
{
return $this->recognizePlate(
fn () => $this->buildPlateReaderPayloadFromUpload(
$this->buildUploadValueFromBytes($image_data, $mime_type)
),
fn () => $this->buildResultCacheKeyFromBytes($image_data)
);
}
// Set HTTP Header for POST request
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
"Authorization: Token " . $this->config->api_key->getVariableValue()
public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array
{
return $this->recognizePlate(
fn () => $this->buildPlateReaderPayloadFromUpload(
$this->buildUploadValueFromBytes($image_data, $mime_type)
)
);
}
// Submit the POST request and close cURL session handle
$result = curl_exec($ch);
curl_close($ch);
// Print the response from the server
if ($result === false) {
throw new Exception('Error in API request.');
}
public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array
{
return $this->recognizePlate(
fn () => $this->buildPlateReaderPayloadFromUpload(
$this->buildUploadValueFromFile($image_path, $mime_type)
)
);
}
$response_data = json_decode($result, true);
if (isset($response_data['results']) && count($response_data['results']) > 0) {
return [
'success' => true,
'license_plate_number' => $response_data['results'][0]['plate'] ?? null,
'confidence' => $response_data['results'][0]['score'] ?? null,
'raw_response' => $response_data,
/**
* @throws Exception
*/
private function recognizePlate(callable $payload_factory, ?callable $result_cache_key_factory = null): array
{
$started_at = microtime(true);
$this->last_timings = [];
$result_cache = null;
$result_cache_key = null;
try {
$config_started_at = microtime(true);
$runtime_config = $this->runtimeConfig();
if (!$runtime_config['enabled']) {
throw new Exception('licenseplaterecognizer module is not enabled.');
}
$api_key = $runtime_config['api_key'];
$this->last_timings['config'] = $this->elapsedMs($config_started_at);
if ($result_cache_key_factory !== null) {
$cache_started_at = microtime(true);
try {
$result_cache = $this->resultCacheStore();
if ($result_cache !== null) {
$result_cache_key = $result_cache_key_factory();
if ($result_cache_key !== null) {
$cached_result = $this->readRecognitionResultCache($result_cache, $result_cache_key);
if ($cached_result !== null) {
$this->last_timings['cache_hit'] = 1;
return $cached_result;
}
}
}
$this->last_timings['cache_miss'] = 1;
} finally {
$this->last_timings['cache'] = $this->elapsedMs($cache_started_at);
}
}
$payload_started_at = microtime(true);
$data = $payload_factory();
$this->last_timings['payload'] = $this->elapsedMs($payload_started_at);
$ch = curl_init($this->api_url . '/v1/plate-reader/');
if (!$ch instanceof \CurlHandle) {
throw new Exception('Error initializing API request.');
}
$curl_options = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $data,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
CURLOPT_CONNECTTIMEOUT_MS => self::PLATE_READER_CONNECT_TIMEOUT_MS,
CURLOPT_TIMEOUT_MS => self::PLATE_READER_TOTAL_TIMEOUT_MS,
CURLOPT_NOSIGNAL => true,
CURLOPT_NOPROGRESS => false,
CURLOPT_XFERINFOFUNCTION => self::clientDisconnectAbortCallback(),
CURLOPT_HTTPHEADER => [
"Authorization: Token " . $api_key,
'Expect:',
],
];
} else {
return [
if (defined('CURLOPT_TCP_NODELAY')) {
$curl_options[(int)constant('CURLOPT_TCP_NODELAY')] = true;
}
curl_setopt_array($ch, $curl_options);
// Submit the POST request and close cURL session handle
$upstream_started_at = microtime(true);
$result = curl_exec($ch);
$this->last_timings['upstream'] = $this->elapsedMs($upstream_started_at);
$this->recordCurlTimings($ch);
curl_close($ch);
// Print the response from the server
if ($result === false) {
throw new Exception('Error in API request.');
}
$parse_started_at = microtime(true);
$response_data = json_decode($result, true);
$this->last_timings['parse'] = $this->elapsedMs($parse_started_at);
$this->recordResponseTimings($response_data);
if (isset($response_data['results']) && count($response_data['results']) > 0) {
$recognized_result = [
'success' => true,
'license_plate_number' => $response_data['results'][0]['plate'] ?? null,
'confidence' => $response_data['results'][0]['score'] ?? null,
];
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
return $recognized_result;
}
$recognized_result = [
'success' => false,
'message' => 'No license plate detected.',
'raw_response' => $response_data,
];
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
return $recognized_result;
} finally {
$this->last_timings['total'] = $this->elapsedMs($started_at);
}
}
private static function clientDisconnectAbortCallback(): callable
{
return static function (): int {
return connection_aborted() ? 1 : 0;
};
}
public function getLastTimings(): array
{
return $this->last_timings;
}
private function elapsedMs(float $started_at): float
{
return (microtime(true) - $started_at) * 1000;
}
private static function configuredApiUrl(): string
{
$configured = getenv('PLATE_RECOGNIZER_API_URL');
if ($configured === false || trim((string)$configured) === '') {
$configured = $_ENV['PLATE_RECOGNIZER_API_URL'] ?? $_SERVER['PLATE_RECOGNIZER_API_URL'] ?? self::DEFAULT_API_URL;
}
return (string)$configured;
}
public static function configuredApiBaseUrl(): string
{
return self::normalizeApiUrl(self::configuredApiUrl());
}
private static function normalizeApiUrl(string $api_url): string
{
$api_url = trim($api_url);
if ($api_url === '') {
return self::DEFAULT_API_URL;
}
return rtrim($api_url, '/');
}
private function recordCurlTimings(\CurlHandle $curl_handle): void
{
$mapping = [
CURLINFO_NAMELOOKUP_TIME => 'upstream_dns',
CURLINFO_CONNECT_TIME => 'upstream_connect',
CURLINFO_APPCONNECT_TIME => 'upstream_tls',
CURLINFO_PRETRANSFER_TIME => 'upstream_pretransfer',
CURLINFO_STARTTRANSFER_TIME => 'upstream_ttfb',
CURLINFO_TOTAL_TIME => 'upstream_total',
];
foreach ($mapping as $curl_info_option => $timing_key) {
$value = curl_getinfo($curl_handle, $curl_info_option);
if (!is_numeric($value)) {
continue;
}
$this->last_timings[$timing_key] = max(0, (float)$value * 1000);
}
}
private function recordResponseTimings(mixed $response_data): void
{
if (!is_array($response_data) || !isset($response_data['processing_time']) || !is_numeric($response_data['processing_time'])) {
return;
}
$this->last_timings['upstream_processing'] = max(0, (float)$response_data['processing_time']);
}
private function buildResultCacheKeyFromUploadString(string $base64_image): string
{
$base64_image = trim($base64_image);
if (preg_match('/^data:image\/[a-zA-Z0-9.+-]+;base64,(.*)$/s', $base64_image, $matches) === 1) {
$image_data = base64_decode((string)$matches[1], true);
if (is_string($image_data)) {
return $this->buildResultCacheKeyFromBytes($image_data);
}
}
return $this->buildResultCacheKeyFromBytes($base64_image);
}
private function buildResultCacheKeyFromBytes(string $image_data): string
{
$context = hash_init('sha256');
hash_update($context, $this->resultCacheContext());
hash_update($context, "\0");
hash_update($context, $image_data);
return self::RESULT_CACHE_REDIS_KEY_PREFIX . hash_final($context);
}
private function resultCacheContext(): string
{
return self::RESULT_CACHE_CONTEXT;
}
protected function resultCacheStore(): ?object
{
return $this->runtimeConfigCacheStore();
}
private function readRecognitionResultCache(?object $cache, ?string $key): ?array
{
if ($cache === null || $key === null || !method_exists($cache, 'get')) {
return null;
}
try {
$cached = $cache->get($key);
} catch (\Throwable) {
return null;
}
if (!is_string($cached) || trim($cached) === '') {
return null;
}
$decoded = json_decode($cached, true);
if (!is_array($decoded) || !array_key_exists('success', $decoded)) {
return null;
}
return $decoded;
}
private function writeRecognitionResultCache(?object $cache, ?string $key, array $result): void
{
if ($cache === null || $key === null || !method_exists($cache, 'setEx')) {
return;
}
try {
$encoded = json_encode($result, JSON_UNESCAPED_SLASHES);
if (is_string($encoded)) {
$cache->setEx($key, $encoded, self::RESULT_CACHE_TTL_SECONDS);
}
} catch (\Throwable) {
// Scanner result cache is best-effort; Plate Recognizer remains the source of truth.
}
}
protected function buildPlateReaderPayload(string $base64_image): array
{
return $this->buildPlateReaderPayloadFromUpload($this->buildUploadValue($base64_image));
}
protected function buildPlateReaderPayloadFromUpload(string|\CURLFile|\CURLStringFile $upload): array
{
return [
'upload' => $upload,
'config' => self::PLATE_READER_CONFIG_JSON,
'regions' => self::PLATE_READER_REGIONS,
];
}
private function buildUploadValue(string $base64_image): string|\CURLStringFile
{
$base64_image = trim($base64_image);
if (preg_match('/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.*)$/s', $base64_image, $matches) !== 1) {
return $base64_image;
}
$image_data = base64_decode((string)$matches[2], true);
if ($image_data === false || !class_exists(\CURLStringFile::class)) {
return (string)$matches[2];
}
return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, (string)$matches[1]);
}
private function buildUploadValueFromBytes(string $image_data, string $mime_type): string|\CURLStringFile
{
$mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg';
if (!str_starts_with($mime_type, 'image/')) {
$mime_type = 'image/jpeg';
}
if (!class_exists(\CURLStringFile::class)) {
return $image_data;
}
return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, $mime_type);
}
/**
* @throws Exception
*/
private function buildUploadValueFromFile(string $image_path, string $mime_type): \CURLFile
{
$image_path = trim($image_path);
$mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg';
if (!str_starts_with($mime_type, 'image/')) {
$mime_type = 'image/jpeg';
}
if ($image_path === '' || !is_file($image_path) || !class_exists(\CURLFile::class)) {
throw new Exception('Image upload file is invalid.');
}
return new \CURLFile($image_path, $mime_type, self::DEFAULT_UPLOAD_FILE_NAME);
}
protected function runtimeConfig(): array
{
if ($this->runtime_config !== null) {
return $this->runtime_config;
}
if ($this->shouldUseSharedRuntimeConfigCache()) {
$cached_config = self::getSharedRuntimeConfigCache();
if ($cached_config !== null) {
$this->runtime_config = $cached_config;
return $this->runtime_config;
}
$cached_config = $this->readRuntimeConfigCacheStore();
if ($cached_config !== null) {
self::$runtime_config_cache = [
'values' => $cached_config,
'cached_at' => microtime(true),
];
$this->runtime_config = $cached_config;
return $this->runtime_config;
}
}
$values = $this->readRuntimeModuleConfig();
$this->runtime_config = [
'enabled' => $this->parseModuleConfigBool($values['enabled'] ?? false),
'api_key' => (string)($values['api_key'] ?? ''),
];
if ($this->shouldUseSharedRuntimeConfigCache()) {
self::$runtime_config_cache = [
'values' => $this->runtime_config,
'cached_at' => microtime(true),
];
$this->writeRuntimeConfigCacheStore($this->runtime_config);
}
return $this->runtime_config;
}
protected function shouldUseSharedRuntimeConfigCache(): bool
{
return static::class === self::class;
}
private static function getSharedRuntimeConfigCache(): ?array
{
if (self::$runtime_config_cache === null) {
return null;
}
$cache_age_seconds = microtime(true) - self::$runtime_config_cache['cached_at'];
if ($cache_age_seconds > self::RUNTIME_CONFIG_CACHE_TTL_SECONDS) {
self::$runtime_config_cache = null;
return null;
}
return self::$runtime_config_cache['values'];
}
protected function runtimeConfigCacheStore(): ?object
{
return defined('redis') ? constant('redis') : null;
}
private function readRuntimeConfigCacheStore(): ?array
{
$cache = $this->runtimeConfigCacheStore();
if ($cache === null || !method_exists($cache, 'get')) {
return null;
}
try {
$cached = $cache->get(self::RUNTIME_CONFIG_REDIS_CACHE_KEY);
} catch (\Throwable) {
return null;
}
if (!is_string($cached) || trim($cached) === '') {
return null;
}
$decoded = json_decode($cached, true);
if (!is_array($decoded)) {
return null;
}
if (!array_key_exists('enabled', $decoded) || !array_key_exists('api_key', $decoded)) {
return null;
}
return [
'enabled' => $this->parseModuleConfigBool($decoded['enabled']),
'api_key' => (string)$decoded['api_key'],
];
}
/**
* @param array{enabled: bool, api_key: string} $config
*/
private function writeRuntimeConfigCacheStore(array $config): void
{
$cache = $this->runtimeConfigCacheStore();
if ($cache === null || !method_exists($cache, 'setEx')) {
return;
}
try {
$encoded = json_encode($config, JSON_UNESCAPED_SLASHES);
if (is_string($encoded)) {
$cache->setEx(self::RUNTIME_CONFIG_REDIS_CACHE_KEY, $encoded, self::RUNTIME_CONFIG_CACHE_TTL_SECONDS);
}
} catch (\Throwable) {
// Scanner config cache is best-effort; DB remains the source of truth.
}
}
private function parseModuleConfigBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_numeric($value)) {
return (int)$value === 1;
}
return strtolower(trim((string)$value)) === 'true';
}
protected function readRuntimeModuleConfig(): array
{
global $db;
if ($db instanceof db) {
$module = $db->escape_string('licenseplaterecognizer');
$result = $db->query("SELECT variable, value FROM module_config WHERE module = '$module' AND variable IN ('enabled', 'api_key')");
$values = [];
if ($result instanceof \mysqli_result) {
while ($row = $result->fetch_assoc()) {
$variable = (string)($row['variable'] ?? '');
if ($variable !== '') {
$values[$variable] = (string)($row['value'] ?? '');
}
}
}
return $values;
}
if (!isset($this->config)) {
$this->config = new licenseplaterecognizer_c();
}
return [
'enabled' => (string)$this->config->enabled->getVariableValue(),
'api_key' => (string)$this->config->api_key->getVariableValue(),
];
}
/**
* @inheritDoc
* @throws Exception If the module is not enabled or if there is an error in the API request
@@ -100,8 +596,8 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
*/
public function get_usage(): licenseplaterecognizer_info
{
// Require the module to be enabled
$this->requireModuleEnabled();
$api_key = $this->runtimeConfig()['api_key'];
$curl = curl_init();
curl_setopt_array($curl, array(
CURLOPT_URL => $this->api_url . '/info/',
@@ -112,9 +608,9 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => array(
'Authorization: Token ' . $this->config->api_key->getVariableValue()
),
CURLOPT_HTTPHEADER => [
'Authorization: Token ' . $api_key,
],
));
$response = curl_exec($curl);
curl_close($curl);
@@ -0,0 +1,24 @@
<?php
namespace classes;
class limited_backoffice_exception extends \RuntimeException
{
public function __construct(
string $message,
private readonly int $statusCode = 400,
private readonly ?array $payload = null
) {
parent::__construct($message);
}
public function statusCode(): int
{
return $this->statusCode;
}
public function payload(): array|string
{
return $this->payload ?? $this->getMessage();
}
}
@@ -0,0 +1,43 @@
<?php
namespace classes;
class limited_backoffice_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `limited_backoffice_employees` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`managed_group_id` INT NOT NULL,
`role_key` VARCHAR(64) NOT NULL,
`department_ids` LONGTEXT NOT NULL,
`created_by_user_id` INT NOT NULL,
`updated_by_user_id` INT NULL,
`deactivated_at` DATETIME 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_limited_backoffice_employees_user_id` (`user_id`),
KEY `idx_limited_backoffice_employees_group_id` (`managed_group_id`),
KEY `idx_limited_backoffice_employees_role_key` (`role_key`),
KEY `idx_limited_backoffice_employees_deactivated_at` (`deactivated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
self::$initialized = true;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,169 @@
<?php
namespace classes;
class module_usage_registry
{
public const DEFAULT_SOFT_LIMIT_PERCENT = 90.0;
public function all(): array
{
return array_map(
fn(array $descriptor): array => $this->withDefaults($descriptor),
[
[
'module_key' => 'motorapi',
'module_label' => 'MotorAPI',
'metric_key' => 'lookup_calls',
'metric_label' => 'License plate lookups',
'unit' => 'calls',
'period' => 'day',
'source' => 'internal_counter',
'default_enforce_mode' => 'block',
'config_module' => 'motorapi',
'config_variable' => 'daily_limit',
'config_type' => 'int',
'legacy_count_table' => 'motorapi_lookups',
'primary' => true,
'writable_limit' => true,
],
[
'module_key' => 'motorapi',
'module_label' => 'MotorAPI',
'metric_key' => 'provider_usage',
'metric_label' => 'Provider usage',
'unit' => 'calls',
'period' => 'provider',
'source' => 'provider_snapshot',
],
[
'module_key' => 'fxratesapi',
'module_label' => 'FXRatesAPI',
'metric_key' => 'rate_fetch_calls',
'metric_label' => 'Currency rate fetches',
'unit' => 'calls',
'period' => 'day',
'source' => 'internal_counter',
'default_enforce_mode' => 'block',
'config_module' => 'fxratesapi',
'config_variable' => 'daily_limit',
'config_type' => 'int',
'legacy_count_table' => 'fxratesapi_conversion_rates',
'primary' => true,
'writable_limit' => true,
],
[
'module_key' => 'virkdata',
'module_label' => 'VirkData',
'metric_key' => 'company_search_calls',
'metric_label' => 'Company searches',
'unit' => 'calls',
'period' => 'month',
'source' => 'internal_counter',
'default_enforce_mode' => 'observe',
'config_module' => 'virkdata',
'config_variable' => 'monthly_limit',
'config_type' => 'int',
'legacy_log_module' => 'VIRKDATA',
'legacy_log_action' => 'VIRKDATA_SEARCH',
'primary' => true,
'writable_limit' => true,
],
[
'module_key' => 'licenseplaterecognizer',
'module_label' => 'License Plate Recognizer',
'metric_key' => 'plate_recognition_calls',
'metric_label' => 'Plate recognition calls',
'unit' => 'calls',
'period' => 'provider',
'source' => 'provider_snapshot',
'default_enforce_mode' => 'observe',
'primary' => true,
],
[
'module_key' => 'email',
'module_label' => 'Email',
'metric_key' => 'mailersend_messages',
'metric_label' => 'MailerSend messages',
'unit' => 'messages',
'period' => 'provider',
'source' => 'provider_snapshot',
],
['module_key' => 'openai', 'module_label' => 'OpenAI', 'metric_key' => 'api_calls', 'metric_label' => 'API calls', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'openai', 'module_label' => 'OpenAI', 'metric_key' => 'total_tokens', 'metric_label' => 'Total tokens', 'unit' => 'tokens', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'weatherapi', 'module_label' => 'WeatherAPI', 'metric_key' => 'requests', 'metric_label' => 'Weather requests', 'unit' => 'calls', 'period' => 'day', 'source' => 'internal_counter'],
['module_key' => 'gatewayapi', 'module_label' => 'GatewayAPI', 'metric_key' => 'sms_messages', 'metric_label' => 'SMS messages', 'unit' => 'messages', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'bird', 'module_label' => 'Bird', 'metric_key' => 'messages_and_calls', 'metric_label' => 'Messages and calls', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'ocrspace', 'module_label' => 'OCRSpace', 'metric_key' => 'ocr_requests', 'metric_label' => 'OCR requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'limble', 'module_label' => 'Limble', 'metric_key' => 'api_requests', 'metric_label' => 'API requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'workfeed', 'module_label' => 'Workfeed', 'metric_key' => 'api_requests', 'metric_label' => 'API requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'stripe', 'module_label' => 'Stripe', 'metric_key' => 'payment_events', 'metric_label' => 'Payment events', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'coolify', 'module_label' => 'Coolify', 'metric_key' => 'operations', 'metric_label' => 'Operations', 'unit' => 'events', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'backups', 'module_label' => 'Backups', 'metric_key' => 'backup_jobs', 'metric_label' => 'Backup jobs', 'unit' => 'jobs', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'backups', 'module_label' => 'Backups', 'metric_key' => 'stored_bytes', 'metric_label' => 'Stored backup data', 'unit' => 'bytes', 'period' => 'all_time', 'source' => 'derived'],
['module_key' => 'selfserve', 'module_label' => 'Self Serve', 'metric_key' => 'wash_sessions', 'metric_label' => 'Wash sessions', 'unit' => 'sessions', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'selfserve', 'module_label' => 'Self Serve', 'metric_key' => 'lane_commands', 'metric_label' => 'Lane commands', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter', 'legacy_log_module' => 'SELFSERVE'],
['module_key' => 'xlvask', 'module_label' => 'XLVask', 'metric_key' => 'usage_rows', 'metric_label' => 'Usage rows', 'unit' => 'rows', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'attachments', 'module_label' => 'Attachments', 'metric_key' => 'stored_files', 'metric_label' => 'Stored files', 'unit' => 'files', 'period' => 'all_time', 'source' => 'derived'],
['module_key' => 'dynamicimages', 'module_label' => 'Dynamic Images', 'metric_key' => 'renders', 'metric_label' => 'Image renders', 'unit' => 'renders', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'html2pdf', 'module_label' => 'HTML2PDF', 'metric_key' => 'pdf_jobs', 'metric_label' => 'PDF jobs', 'unit' => 'jobs', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'forms', 'module_label' => 'Forms', 'metric_key' => 'submissions', 'metric_label' => 'Submissions', 'unit' => 'submissions', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'notifications', 'module_label' => 'Notifications', 'metric_key' => 'notification_sends', 'metric_label' => 'Notification sends', 'unit' => 'notifications', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'shelly', 'module_label' => 'Shelly', 'metric_key' => 'relay_commands', 'metric_label' => 'Relay commands', 'unit' => 'commands', 'period' => 'month', 'source' => 'internal_counter', 'legacy_log_module' => 'SHELLY'],
['module_key' => 'edgegateway', 'module_label' => 'Edge Gateway', 'metric_key' => 'relay_commands', 'metric_label' => 'Relay commands', 'unit' => 'commands', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'system', 'module_label' => 'System', 'metric_key' => 'cron_runs', 'metric_label' => 'Cron runs', 'unit' => 'runs', 'period' => 'day', 'source' => 'derived'],
]
);
}
public function find(string $moduleKey, string $metricKey): ?array
{
$moduleKey = $this->normalizeModuleKey($moduleKey);
$metricKey = $this->normalizeMetricKey($metricKey);
foreach ($this->all() as $descriptor) {
if ($descriptor['module_key'] === $moduleKey && $descriptor['metric_key'] === $metricKey) {
return $descriptor;
}
}
return null;
}
public function forModule(string $moduleKey): array
{
$moduleKey = $this->normalizeModuleKey($moduleKey);
return array_values(array_filter(
$this->all(),
static fn(array $descriptor): bool => $descriptor['module_key'] === $moduleKey
));
}
public function normalizeModuleKey(string $moduleKey): string
{
return strtolower(trim($moduleKey));
}
public function normalizeMetricKey(string $metricKey): string
{
return strtolower(trim($metricKey));
}
private function withDefaults(array $descriptor): array
{
$descriptor['module_key'] = $this->normalizeModuleKey((string)$descriptor['module_key']);
$descriptor['metric_key'] = $this->normalizeMetricKey((string)$descriptor['metric_key']);
$descriptor['module_label'] = (string)($descriptor['module_label'] ?? $descriptor['module_key']);
$descriptor['metric_label'] = (string)($descriptor['metric_label'] ?? $descriptor['metric_key']);
$descriptor['unit'] = (string)($descriptor['unit'] ?? 'count');
$descriptor['period'] = (string)($descriptor['period'] ?? 'all_time');
$descriptor['scope_type'] = (string)($descriptor['scope_type'] ?? 'global');
$descriptor['scope_id'] = (string)($descriptor['scope_id'] ?? '');
$descriptor['source'] = (string)($descriptor['source'] ?? 'internal_counter');
$descriptor['default_enforce_mode'] = (string)($descriptor['default_enforce_mode'] ?? 'observe');
$descriptor['soft_limit_percent'] = (float)($descriptor['soft_limit_percent'] ?? self::DEFAULT_SOFT_LIMIT_PERCENT);
$descriptor['writable_limit'] = (bool)($descriptor['writable_limit'] ?? false);
$descriptor['primary'] = (bool)($descriptor['primary'] ?? false);
return $descriptor;
}
}
@@ -0,0 +1,119 @@
<?php
namespace classes;
class module_usage_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"CREATE TABLE IF NOT EXISTS module_usage_counters (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
module_key VARCHAR(64) NOT NULL,
metric_key VARCHAR(128) NOT NULL,
scope_type VARCHAR(32) NOT NULL DEFAULT 'global',
scope_id VARCHAR(191) NOT NULL DEFAULT '',
period_key VARCHAR(32) NOT NULL DEFAULT 'all_time',
period_start DATETIME NOT NULL,
period_end DATETIME NULL,
unit VARCHAR(32) NOT NULL DEFAULT 'count',
used_quantity DECIMAL(20,4) NOT NULL DEFAULT 0,
limit_quantity DECIMAL(20,4) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'ok',
metadata_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_module_usage_counter (module_key, metric_key, scope_type, scope_id, period_key, period_start),
KEY idx_module_usage_counters_module_period (module_key, period_key, period_start),
KEY idx_module_usage_counters_status (status, updated_at),
KEY idx_module_usage_counters_scope (scope_type, scope_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS module_usage_snapshots (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
module_key VARCHAR(64) NOT NULL,
metric_key VARCHAR(128) NOT NULL,
source VARCHAR(32) NOT NULL DEFAULT 'provider',
period_key VARCHAR(32) NOT NULL DEFAULT 'provider',
period_start DATETIME NULL,
period_end DATETIME NULL,
unit VARCHAR(32) NOT NULL DEFAULT 'count',
used_quantity DECIMAL(20,4) NULL,
limit_quantity DECIMAL(20,4) NULL,
remaining_quantity DECIMAL(20,4) NULL,
usage_percent DECIMAL(8,4) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'unknown',
raw_payload_json LONGTEXT NULL,
checked_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_module_usage_snapshots_module_checked (module_key, metric_key, checked_at),
KEY idx_module_usage_snapshots_status (status, checked_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS module_quota_settings (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
module_key VARCHAR(64) NOT NULL,
metric_key VARCHAR(128) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
enforce_mode VARCHAR(16) NOT NULL DEFAULT 'observe',
soft_limit_percent DECIMAL(6,2) NOT NULL DEFAULT 90.00,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_module_quota_setting (module_key, metric_key),
KEY idx_module_quota_settings_mode (enforce_mode, enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS module_usage_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
module VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
status_code INT NOT NULL DEFAULT 0,
data LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_module_usage_logs_module_created (module, created_at),
KEY idx_module_usage_logs_action_created (action, created_at),
KEY idx_module_usage_logs_status_created (status_code, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::ensureColumn('module_quota_settings', 'enabled', "TINYINT(1) NOT NULL DEFAULT 1 AFTER metric_key");
self::ensureColumn('module_quota_settings', 'soft_limit_percent', "DECIMAL(6,2) NOT NULL DEFAULT 90.00 AFTER enforce_mode");
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 && $result->num_rows > 0) {
return;
}
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
}
}
@@ -0,0 +1,997 @@
<?php
namespace classes;
use Exception;
use mysqli_result;
use Throwable;
class module_usage_service
{
private module_usage_registry $registry;
public function __construct(?module_usage_registry $registry = null)
{
module_usage_schema_bootstrap::ensureTables();
$this->registry = $registry ?? new module_usage_registry();
}
public function summary(array $filters = []): array
{
$moduleFilter = isset($filters['module']) ? $this->registry->normalizeModuleKey((string)$filters['module']) : '';
$periodFilter = isset($filters['period']) ? strtolower(trim((string)$filters['period'])) : '';
$statusFilter = isset($filters['status']) ? strtolower(trim((string)$filters['status'])) : '';
$date = isset($filters['date']) ? (string)$filters['date'] : null;
$metrics = [];
foreach ($this->registry->all() as $descriptor) {
if ($moduleFilter !== '' && $descriptor['module_key'] !== $moduleFilter) {
continue;
}
if ($periodFilter !== '' && $periodFilter !== 'all' && $descriptor['period'] !== $periodFilter) {
continue;
}
$metric = $this->currentMetric($descriptor, $date);
if ($statusFilter !== '' && $metric['status'] !== $statusFilter) {
continue;
}
$metrics[] = $metric;
}
$modules = [];
foreach ($metrics as $metric) {
$moduleKey = $metric['module_key'];
if (!isset($modules[$moduleKey])) {
$modules[$moduleKey] = [
'key' => $moduleKey,
'label' => $metric['module_label'],
'status' => 'ok',
'metrics' => [],
];
}
$modules[$moduleKey]['metrics'][] = $metric;
$modules[$moduleKey]['status'] = $this->worseStatus($modules[$moduleKey]['status'], $metric['status']);
}
return [
'generated_at' => date('c'),
'filters' => [
'module' => $moduleFilter !== '' ? $moduleFilter : null,
'period' => $periodFilter !== '' ? $periodFilter : null,
'status' => $statusFilter !== '' ? $statusFilter : null,
'date' => $date,
],
'modules' => array_values($modules),
'metrics' => $metrics,
];
}
public function moduleDetail(string $moduleKey, array $filters = []): array
{
$moduleKey = $this->registry->normalizeModuleKey($moduleKey);
$descriptors = $this->registry->forModule($moduleKey);
$date = isset($filters['date']) ? (string)$filters['date'] : null;
$metrics = array_map(fn(array $descriptor): array => $this->currentMetric($descriptor, $date), $descriptors);
return [
'generated_at' => date('c'),
'module_key' => $moduleKey,
'metrics' => $metrics,
'history' => $this->historyForModule($moduleKey, $filters),
];
}
public function metricsForModule(string $moduleKey): array
{
$moduleKey = $this->registry->normalizeModuleKey($moduleKey);
return array_map(
fn(array $descriptor): array => $this->currentMetric($descriptor),
$this->registry->forModule($moduleKey)
);
}
/**
* Atomically records usage and enforces blocking quotas when the metric is configured for block mode.
*
* @throws Exception
*/
public function reserveOrFail(string $moduleKey, string $metricKey, float $quantity = 1.0, array $metadata = []): array
{
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
$quantity = max(0.0, $quantity);
if ($quantity <= 0.0) {
return $this->currentMetric($descriptor);
}
$setting = $this->settingFor($descriptor);
if (($setting['enabled'] ?? true) !== true || ($setting['enforce_mode'] ?? 'observe') !== 'block') {
return $this->recordUsage($moduleKey, $metricKey, $quantity, $metadata);
}
$limit = $this->resolveLimitQuantity($descriptor);
if ($limit === null) {
return $this->recordUsage($moduleKey, $metricKey, $quantity, $metadata);
}
$period = $this->periodWindow((string)$descriptor['period']);
$this->insertCounterIfMissing($descriptor, $period, $limit);
global $db;
$where = $this->counterWhereSql($descriptor, $period);
$quantitySql = $this->numberSql($quantity);
$limitSql = $this->numberSql($limit);
$metadataSql = $this->jsonSql($metadata);
$db->query(
"UPDATE module_usage_counters
SET used_quantity = used_quantity + {$quantitySql},
limit_quantity = {$limitSql},
metadata_json = {$metadataSql},
status = CASE
WHEN {$limitSql} <= 0 OR ((used_quantity + {$quantitySql}) >= {$limitSql}) THEN 'exhausted'
WHEN ((used_quantity + {$quantitySql}) / {$limitSql}) * 100 >= " . module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT . " THEN 'near_limit'
ELSE 'ok'
END
WHERE {$where} AND (used_quantity + {$quantitySql}) <= {$limitSql}"
);
if ((int)$db->conn()->affected_rows <= 0) {
throw new Exception($this->quotaExceededMessage($descriptor));
}
return $this->currentMetric($descriptor);
}
public function recordUsage(string $moduleKey, string $metricKey, float $quantity = 1.0, array $metadata = []): array
{
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
$quantity = max(0.0, $quantity);
if ($quantity <= 0.0 || !$this->databaseReady()) {
return $this->currentMetric($descriptor);
}
$limit = $this->resolveLimitQuantity($descriptor);
$setting = $this->settingFor($descriptor);
$period = $this->periodWindow((string)$descriptor['period']);
$this->insertCounterIfMissing($descriptor, $period, $limit);
global $db;
$where = $this->counterWhereSql($descriptor, $period);
$quantitySql = $this->numberSql($quantity);
$limitSql = $this->nullableNumberSql($limit);
$metadataSql = $this->jsonSql($metadata);
$softLimitSql = $this->numberSql((float)$setting['soft_limit_percent']);
$statusSql = (($setting['enabled'] ?? true) !== true)
? $this->sqlString('disabled')
: "CASE
WHEN {$limitSql} IS NULL THEN 'unlimited'
WHEN {$limitSql} <= 0 AND (used_quantity + {$quantitySql}) > 0 THEN 'exhausted'
WHEN {$limitSql} <= 0 THEN 'ok'
WHEN (used_quantity + {$quantitySql}) >= {$limitSql} THEN 'exhausted'
WHEN ((used_quantity + {$quantitySql}) / {$limitSql}) * 100 >= {$softLimitSql} THEN 'near_limit'
ELSE 'ok'
END";
$db->query(
"UPDATE module_usage_counters
SET used_quantity = used_quantity + {$quantitySql},
limit_quantity = {$limitSql},
metadata_json = {$metadataSql},
status = {$statusSql}
WHERE {$where}"
);
return $this->currentMetric($descriptor);
}
public function currentUsedQuantity(string $moduleKey, string $metricKey): int
{
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
$metric = $this->currentMetric($descriptor);
return (int)floor((float)($metric['used'] ?? 0));
}
public function updateQuotaSetting(string $moduleKey, string $metricKey, array $payload): array
{
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
$setting = $this->settingFor($descriptor);
$enabled = array_key_exists('enabled', $payload) ? $this->toBool($payload['enabled']) : (bool)$setting['enabled'];
$enforceMode = array_key_exists('enforce_mode', $payload) ? strtolower(trim((string)$payload['enforce_mode'])) : (string)$setting['enforce_mode'];
if (!in_array($enforceMode, ['observe', 'block'], true)) {
throw new Exception('Invalid enforce mode.');
}
$softLimitPercent = array_key_exists('soft_limit_percent', $payload)
? (float)$payload['soft_limit_percent']
: (float)$setting['soft_limit_percent'];
if ($softLimitPercent < 1.0 || $softLimitPercent > 100.0) {
throw new Exception('Soft limit percent must be between 1 and 100.');
}
if (array_key_exists('limit', $payload) || array_key_exists('hard_limit', $payload) || array_key_exists('hard_limit_quantity', $payload)) {
if (empty($descriptor['writable_limit']) || empty($descriptor['config_module']) || empty($descriptor['config_variable'])) {
throw new Exception('quota_not_writable');
}
$limitValue = $payload['limit'] ?? $payload['hard_limit'] ?? $payload['hard_limit_quantity'];
if (!is_numeric($limitValue) || (int)$limitValue < 0) {
throw new Exception('Limit must be a non-negative integer.');
}
$this->writeConfigLimit($descriptor, (int)$limitValue);
}
if ($this->databaseReady()) {
global $db;
$moduleKeySql = $this->sqlString((string)$descriptor['module_key']);
$metricKeySql = $this->sqlString((string)$descriptor['metric_key']);
$enabledSql = $enabled ? '1' : '0';
$modeSql = $this->sqlString($enforceMode);
$softLimitSql = $this->numberSql($softLimitPercent);
$db->query(
"INSERT INTO module_quota_settings (module_key, metric_key, enabled, enforce_mode, soft_limit_percent)
VALUES ({$moduleKeySql}, {$metricKeySql}, {$enabledSql}, {$modeSql}, {$softLimitSql})
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
enforce_mode = VALUES(enforce_mode),
soft_limit_percent = VALUES(soft_limit_percent)"
);
}
return $this->currentMetric($descriptor);
}
public function recordProviderSnapshotFromLegacyUsage(string $moduleKey, array $usage, array $rawPayload = []): ?array
{
$moduleKey = $this->registry->normalizeModuleKey($moduleKey);
$descriptor = null;
foreach ($this->registry->forModule($moduleKey) as $candidate) {
if (($candidate['source'] ?? '') === 'provider_snapshot') {
$descriptor = $candidate;
break;
}
}
if ($descriptor === null) {
return null;
}
$used = $this->firstNumeric($usage, ['used', 'calls_used', 'messages_used']);
$limit = $this->firstNumeric($usage, ['limit', 'quota', 'quota_calls', 'total_calls']);
$remaining = $this->firstNumeric($usage, ['remaining', 'calls_remaining', 'messages_remaining']);
$percent = $this->firstNumeric($usage, ['usage_percent', 'percent']);
$usageAvailable = !array_key_exists('usage_available', $usage) || $this->toBool($usage['usage_available']);
$unavailableReason = trim((string)($usage['unavailable_reason'] ?? ''));
if ($used === null && $limit === null && $usageAvailable && $unavailableReason === '') {
return null;
}
if ($remaining === null && $used !== null && $limit !== null) {
$remaining = max(0.0, $limit - $used);
}
if ($percent === null && $used !== null && $limit !== null && $limit > 0) {
$percent = round(($used / $limit) * 100, 4);
}
$status = (!$usageAvailable || $unavailableReason !== '')
? 'unknown'
: $this->statusForUsage($used, $limit, $this->settingFor($descriptor));
$payload = array_merge($rawPayload, ['usage' => $this->redactPayload($usage)]);
if ($this->databaseReady()) {
try {
global $db;
$db->query(
"INSERT INTO module_usage_snapshots
(module_key, metric_key, source, period_key, unit, used_quantity, limit_quantity, remaining_quantity, usage_percent, status, raw_payload_json, checked_at)
VALUES (
" . $this->sqlString((string)$descriptor['module_key']) . ",
" . $this->sqlString((string)$descriptor['metric_key']) . ",
'provider',
'provider',
" . $this->sqlString((string)$descriptor['unit']) . ",
" . $this->nullableNumberSql($used) . ",
" . $this->nullableNumberSql($limit) . ",
" . $this->nullableNumberSql($remaining) . ",
" . $this->nullableNumberSql($percent) . ",
" . $this->sqlString($status) . ",
" . $this->jsonSql($payload) . ",
" . $this->sqlString(date('Y-m-d H:i:s')) . "
)"
);
} catch (Throwable) {
// Provider snapshots are observability data. They must never break probes.
}
}
return $this->providerMetricFromValues($descriptor, $used, $limit, $remaining, $percent, $status, date('c'), $usage);
}
public function primarySystemUsage(array $metrics): ?array
{
$metrics = array_values(array_filter(
$metrics,
static fn(array $metric): bool => ($metric['limit'] ?? null) !== null || ($metric['used'] ?? null) !== null
));
if ($metrics === []) {
return null;
}
usort($metrics, function (array $left, array $right): int {
if (($left['primary'] ?? false) !== ($right['primary'] ?? false)) {
return ($right['primary'] ?? false) <=> ($left['primary'] ?? false);
}
$leftRank = $this->statusRank((string)($left['status'] ?? 'unknown'));
$rightRank = $this->statusRank((string)($right['status'] ?? 'unknown'));
return $rightRank <=> $leftRank;
});
$metric = $metrics[0];
return [
'provider' => $metric['module_key'],
'metric_key' => $metric['metric_key'],
'unit' => $metric['unit'],
'period' => $metric['period'],
'calls_used' => $metric['used'],
'quota_calls' => $metric['limit'],
'calls_remaining' => $metric['remaining'],
'usage_percent' => $metric['usage_percent'],
'status' => $metric['status'],
'source' => $metric['source'],
'enforce_mode' => $metric['enforce_mode'],
];
}
public function currentMetric(array $descriptor, ?string $date = null): array
{
$descriptor = $this->normalizeDescriptor($descriptor);
$setting = $this->settingFor($descriptor);
$window = $this->periodWindow((string)$descriptor['period'], $date);
$limit = $this->resolveLimitQuantity($descriptor);
$used = null;
$updatedAt = null;
$historyAvailable = false;
$snapshotExtra = [];
if (($descriptor['source'] ?? '') === 'provider_snapshot') {
$snapshot = $this->latestProviderSnapshot($descriptor);
if ($snapshot !== null) {
$used = $snapshot['used_quantity'];
$limit = $snapshot['limit_quantity'];
$updatedAt = $snapshot['checked_at'];
$snapshotExtra = $snapshot['extra'];
$historyAvailable = true;
}
} else {
$counter = $this->counterFor($descriptor, $window);
if ($counter !== null) {
$used = $counter['used_quantity'];
$limit = $counter['limit_quantity'] ?? $limit;
$updatedAt = $counter['updated_at'] ?? $counter['created_at'] ?? null;
$historyAvailable = true;
} else {
$derived = $this->derivedOrLegacyUsage($descriptor, $window);
if ($derived !== null) {
$used = $derived;
$historyAvailable = true;
} elseif (($descriptor['source'] ?? '') === 'internal_counter') {
$used = 0.0;
$historyAvailable = true;
}
}
}
$status = $this->statusForUsage($used, $limit, $setting);
$remaining = ($used !== null && $limit !== null) ? max(0.0, $limit - $used) : null;
$usagePercent = ($used !== null && $limit !== null && $limit > 0) ? round(($used / $limit) * 100, 2) : null;
return array_merge([
'module_key' => $descriptor['module_key'],
'module_label' => $descriptor['module_label'],
'metric_key' => $descriptor['metric_key'],
'metric_label' => $descriptor['metric_label'],
'unit' => $descriptor['unit'],
'period' => $descriptor['period'],
'scope_type' => $descriptor['scope_type'],
'scope_id' => $descriptor['scope_id'],
'source' => $descriptor['source'],
'primary' => (bool)$descriptor['primary'],
'writable_limit' => (bool)$descriptor['writable_limit'],
'limit_source' => isset($descriptor['config_variable']) ? 'module_config' : (($descriptor['source'] ?? '') === 'provider_snapshot' ? 'provider' : null),
'config_module' => $descriptor['config_module'] ?? null,
'config_variable' => $descriptor['config_variable'] ?? null,
'used' => $used,
'limit' => $limit,
'remaining' => $remaining,
'usage_percent' => $usagePercent,
'status' => $status,
'enabled' => (bool)$setting['enabled'],
'enforce_mode' => $setting['enforce_mode'],
'soft_limit_percent' => (float)$setting['soft_limit_percent'],
'window' => [
'start' => $window['start_c'],
'end' => $window['end_c'],
'timezone' => date_default_timezone_get(),
],
'updated_at' => $updatedAt,
'history_available' => $historyAvailable,
], $snapshotExtra);
}
private function requireDescriptor(string $moduleKey, string $metricKey): array
{
$descriptor = $this->registry->find($moduleKey, $metricKey);
if ($descriptor === null) {
throw new Exception('Unknown module usage metric.');
}
return $descriptor;
}
private function normalizeDescriptor(array $descriptor): array
{
$found = $this->registry->find((string)$descriptor['module_key'], (string)$descriptor['metric_key']);
return $found ?? $descriptor;
}
private function settingFor(array $descriptor): array
{
$default = [
'enabled' => true,
'enforce_mode' => (string)($descriptor['default_enforce_mode'] ?? 'observe'),
'soft_limit_percent' => (float)($descriptor['soft_limit_percent'] ?? module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT),
];
if (!$this->databaseReady()) {
return $default;
}
try {
global $db;
$result = $db->query(
"SELECT enabled, enforce_mode, soft_limit_percent
FROM module_quota_settings
WHERE module_key = " . $this->sqlString((string)$descriptor['module_key']) . "
AND metric_key = " . $this->sqlString((string)$descriptor['metric_key']) . "
LIMIT 1"
);
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
if (!is_array($row)) {
return $default;
}
return [
'enabled' => (bool)((int)($row['enabled'] ?? 1)),
'enforce_mode' => in_array((string)($row['enforce_mode'] ?? ''), ['observe', 'block'], true)
? (string)$row['enforce_mode']
: $default['enforce_mode'],
'soft_limit_percent' => is_numeric($row['soft_limit_percent'] ?? null)
? (float)$row['soft_limit_percent']
: $default['soft_limit_percent'],
];
} catch (Throwable) {
return $default;
}
}
private function resolveLimitQuantity(array $descriptor): ?float
{
if (empty($descriptor['config_module']) || empty($descriptor['config_variable']) || !$this->databaseReady()) {
return null;
}
try {
global $db;
$result = $db->query(
"SELECT value
FROM module_config
WHERE module = " . $this->sqlString((string)$descriptor['config_module']) . "
AND variable = " . $this->sqlString((string)$descriptor['config_variable']) . "
LIMIT 1"
);
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
if (!is_array($row) || !is_numeric($row['value'] ?? null)) {
return null;
}
return max(0.0, (float)$row['value']);
} catch (Throwable) {
return null;
}
}
private function writeConfigLimit(array $descriptor, int $limit): void
{
if (!$this->databaseReady()) {
return;
}
global $db;
$module = (string)$descriptor['config_module'];
$variable = (string)$descriptor['config_variable'];
$type = (string)($descriptor['config_type'] ?? 'int');
$existing = $db->query(
"SELECT id
FROM module_config
WHERE module = " . $this->sqlString($module) . "
AND variable = " . $this->sqlString($variable) . "
LIMIT 1"
);
if ($existing instanceof mysqli_result && $existing->num_rows > 0) {
$db->query(
"UPDATE module_config
SET value = " . $this->sqlString((string)$limit) . ", type = " . $this->sqlString($type) . "
WHERE module = " . $this->sqlString($module) . "
AND variable = " . $this->sqlString($variable)
);
} else {
$db->query(
"INSERT INTO module_config (module, variable, value, type)
VALUES (" . $this->sqlString($module) . ", " . $this->sqlString($variable) . ", " . $this->sqlString((string)$limit) . ", " . $this->sqlString($type) . ")"
);
}
system_search_cache::markDirtyTable('module_config');
}
private function insertCounterIfMissing(array $descriptor, array $period, ?float $limit): void
{
if (!$this->databaseReady()) {
return;
}
global $db;
$baseline = $this->derivedOrLegacyUsage($descriptor, $period);
$baseline = $baseline === null ? 0.0 : max(0.0, (float)$baseline);
$metadata = [
'created_from' => $baseline > 0 ? 'legacy_or_derived_baseline' : 'counter',
];
$db->query(
"INSERT IGNORE INTO module_usage_counters
(module_key, metric_key, scope_type, scope_id, period_key, period_start, period_end, unit, used_quantity, limit_quantity, status, metadata_json)
VALUES (
" . $this->sqlString((string)$descriptor['module_key']) . ",
" . $this->sqlString((string)$descriptor['metric_key']) . ",
" . $this->sqlString((string)$descriptor['scope_type']) . ",
" . $this->sqlString((string)$descriptor['scope_id']) . ",
" . $this->sqlString($period['key']) . ",
" . $this->sqlString($period['start_sql']) . ",
" . ($period['end_sql'] === null ? 'NULL' : $this->sqlString($period['end_sql'])) . ",
" . $this->sqlString((string)$descriptor['unit']) . ",
" . $this->numberSql($baseline) . ",
" . $this->nullableNumberSql($limit) . ",
" . $this->sqlString($this->statusForUsage($baseline, $limit, $this->settingFor($descriptor))) . ",
" . $this->jsonSql($metadata) . "
)"
);
}
private function counterFor(array $descriptor, array $period): ?array
{
if (!$this->databaseReady()) {
return null;
}
try {
global $db;
$result = $db->query(
"SELECT used_quantity, limit_quantity, status, created_at, updated_at
FROM module_usage_counters
WHERE " . $this->counterWhereSql($descriptor, $period) . "
LIMIT 1"
);
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
if (!is_array($row)) {
return null;
}
return [
'used_quantity' => (float)$row['used_quantity'],
'limit_quantity' => $row['limit_quantity'] === null ? null : (float)$row['limit_quantity'],
'status' => (string)$row['status'],
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
];
} catch (Throwable) {
return null;
}
}
private function latestProviderSnapshot(array $descriptor): ?array
{
if (!$this->databaseReady()) {
return null;
}
try {
global $db;
$result = $db->query(
"SELECT used_quantity, limit_quantity, remaining_quantity, usage_percent, status, raw_payload_json, checked_at
FROM module_usage_snapshots
WHERE module_key = " . $this->sqlString((string)$descriptor['module_key']) . "
AND metric_key = " . $this->sqlString((string)$descriptor['metric_key']) . "
ORDER BY checked_at DESC, id DESC
LIMIT 1"
);
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
if (!is_array($row)) {
return null;
}
$raw = json_decode((string)($row['raw_payload_json'] ?? ''), true);
$usage = is_array($raw) && isset($raw['usage']) && is_array($raw['usage']) ? $raw['usage'] : [];
$extra = [];
if (isset($usage['version'])) {
$extra['version'] = (string)$usage['version'];
}
if (array_key_exists('usage_available', $usage)) {
$extra['usage_available'] = $this->toBool($usage['usage_available']);
}
if (isset($usage['unavailable_reason'])) {
$extra['unavailable_reason'] = (string)$usage['unavailable_reason'];
}
if (isset($usage['detected_keys']) && is_array($usage['detected_keys'])) {
$extra['detected_keys'] = array_values(array_map('strval', $usage['detected_keys']));
}
return [
'used_quantity' => $row['used_quantity'] === null ? null : (float)$row['used_quantity'],
'limit_quantity' => $row['limit_quantity'] === null ? null : (float)$row['limit_quantity'],
'remaining_quantity' => $row['remaining_quantity'] === null ? null : (float)$row['remaining_quantity'],
'usage_percent' => $row['usage_percent'] === null ? null : (float)$row['usage_percent'],
'status' => (string)$row['status'],
'checked_at' => $row['checked_at'] ? date('c', strtotime((string)$row['checked_at'])) : null,
'extra' => $extra,
];
} catch (Throwable) {
return null;
}
}
private function providerMetricFromValues(array $descriptor, ?float $used, ?float $limit, ?float $remaining, ?float $percent, string $status, string $checkedAt, array $usage): array
{
return [
'module_key' => $descriptor['module_key'],
'module_label' => $descriptor['module_label'],
'metric_key' => $descriptor['metric_key'],
'metric_label' => $descriptor['metric_label'],
'unit' => $descriptor['unit'],
'period' => $descriptor['period'],
'scope_type' => $descriptor['scope_type'],
'scope_id' => $descriptor['scope_id'],
'source' => $descriptor['source'],
'primary' => (bool)$descriptor['primary'],
'writable_limit' => false,
'limit_source' => 'provider',
'used' => $used,
'limit' => $limit,
'remaining' => $remaining,
'usage_percent' => $percent,
'status' => $status,
'enabled' => true,
'enforce_mode' => 'observe',
'soft_limit_percent' => module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT,
'window' => ['start' => null, 'end' => null, 'timezone' => date_default_timezone_get()],
'updated_at' => $checkedAt,
'history_available' => true,
'version' => isset($usage['version']) ? (string)$usage['version'] : null,
'usage_available' => array_key_exists('usage_available', $usage) ? $this->toBool($usage['usage_available']) : true,
'unavailable_reason' => isset($usage['unavailable_reason']) ? (string)$usage['unavailable_reason'] : null,
'detected_keys' => isset($usage['detected_keys']) && is_array($usage['detected_keys'])
? array_values(array_map('strval', $usage['detected_keys']))
: [],
];
}
private function derivedOrLegacyUsage(array $descriptor, array $period): ?float
{
try {
if (isset($descriptor['legacy_count_table'])) {
return $this->countRowsInPeriod((string)$descriptor['legacy_count_table'], 'created_at', $period);
}
if (isset($descriptor['legacy_log_module'])) {
return $this->countActionLogs(
(string)$descriptor['legacy_log_module'],
isset($descriptor['legacy_log_action']) ? (string)$descriptor['legacy_log_action'] : null,
$period
);
}
return match ($descriptor['module_key'] . '.' . $descriptor['metric_key']) {
'backups.backup_jobs' => $this->countRowsInPeriod('backup_jobs', 'created_at', $period),
'backups.stored_bytes' => $this->sumColumn('backup_records', 'total_bytes'),
'coolify.operations' => $this->countRowsInPeriod('coolify_operations', 'created_at', $period),
'selfserve.wash_sessions' => $this->countRowsInPeriod('selfserve_wash_sessions', 'created_at', $period),
'xlvask.usage_rows' => $this->countRowsInPeriod('xlvask_usage_logs', 'StartTime', $period),
'attachments.stored_files' => $this->countRowsInPeriod('object_attachments', null, $period),
'forms.submissions' => $this->countRowsInPeriod('form_submissions', 'created_at', $period),
'notifications.notification_sends' => $this->countRowsInPeriod('notifications', 'created_at', $period),
'edgegateway.relay_commands' => $this->countRowsInPeriod('edge_gateway_operations', 'created_at', $period),
'system.cron_runs' => $this->countRowsInPeriod('cron_task_runs', 'created_at', $period),
default => null,
};
} catch (Throwable) {
return null;
}
}
private function countActionLogs(string $module, ?string $action, array $period): ?float
{
if (!$this->tableExists('module_usage_logs')) {
return null;
}
global $db;
$where = "UPPER(module) = " . $this->sqlString(strtoupper($module));
if ($action !== null && $action !== '') {
$where .= " AND UPPER(action) = " . $this->sqlString(strtoupper($action));
}
$where .= $this->periodWhereSql('created_at', $period);
$result = $db->query("SELECT COUNT(*) AS usage_count FROM module_usage_logs WHERE {$where}");
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
return is_array($row) ? (float)$row['usage_count'] : null;
}
private function countRowsInPeriod(string $table, ?string $dateColumn, array $period): ?float
{
if (!$this->tableExists($table)) {
return null;
}
if ($dateColumn !== null && !$this->columnExists($table, $dateColumn)) {
return null;
}
global $db;
$where = '1=1';
if ($dateColumn !== null) {
$where .= $this->periodWhereSql($dateColumn, $period);
} elseif ($period['key'] !== 'all_time') {
return null;
}
$result = $db->query("SELECT COUNT(*) AS usage_count FROM `{$table}` WHERE {$where}");
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
return is_array($row) ? (float)$row['usage_count'] : null;
}
private function sumColumn(string $table, string $column): ?float
{
if (!$this->tableExists($table) || !$this->columnExists($table, $column)) {
return null;
}
global $db;
$result = $db->query("SELECT COALESCE(SUM(`{$column}`), 0) AS usage_sum FROM `{$table}`");
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
return is_array($row) ? (float)$row['usage_sum'] : null;
}
private function historyForModule(string $moduleKey, array $filters): array
{
if (!$this->databaseReady()) {
return [];
}
$limit = isset($filters['limit']) && is_numeric($filters['limit']) ? max(1, min(200, (int)$filters['limit'])) : 100;
$rows = [];
try {
global $db;
$result = $db->query(
"SELECT module_key, metric_key, period_key, period_start, period_end, used_quantity, limit_quantity, status, updated_at, created_at
FROM module_usage_counters
WHERE module_key = " . $this->sqlString($moduleKey) . "
ORDER BY period_start DESC, id DESC
LIMIT {$limit}"
);
if ($result instanceof mysqli_result) {
while ($row = $result->fetch_assoc()) {
$rows[] = $row;
}
}
} catch (Throwable) {
return [];
}
return $rows;
}
private function statusForUsage(?float $used, ?float $limit, array $setting): string
{
if (($setting['enabled'] ?? true) !== true) {
return 'disabled';
}
if ($used === null) {
return 'unknown';
}
if ($limit === null) {
return 'unlimited';
}
if ($limit <= 0.0) {
return $used > 0.0 ? 'exhausted' : 'ok';
}
$percent = ($used / $limit) * 100;
if ($used >= $limit || $percent >= 100.0) {
return 'exhausted';
}
if ($percent >= (float)($setting['soft_limit_percent'] ?? module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT)) {
return 'near_limit';
}
return 'ok';
}
private function worseStatus(string $current, string $candidate): string
{
return $this->statusRank($candidate) > $this->statusRank($current) ? $candidate : $current;
}
private function statusRank(string $status): int
{
return match ($status) {
'exhausted' => 5,
'near_limit' => 4,
'unknown' => 3,
'disabled' => 2,
'unlimited' => 1,
'ok' => 0,
default => 0,
};
}
private function quotaExceededMessage(array $descriptor): string
{
return match ((string)$descriptor['period']) {
'day' => 'Daily limit exceeded',
'month' => 'Monthly limit exceeded',
default => 'Quota limit exceeded',
};
}
private function periodWindow(string $period, ?string $date = null): array
{
$timestamp = $date ? strtotime($date) : time();
if ($timestamp === false) {
$timestamp = time();
}
return match ($period) {
'day' => $this->periodFromTimestamps('day', strtotime(date('Y-m-d 00:00:00', $timestamp)), strtotime(date('Y-m-d 00:00:00', $timestamp) . ' +1 day')),
'month' => $this->periodFromTimestamps('month', strtotime(date('Y-m-01 00:00:00', $timestamp)), strtotime(date('Y-m-01 00:00:00', $timestamp) . ' +1 month')),
'provider' => ['key' => 'provider', 'start_sql' => date('Y-m-d 00:00:00', $timestamp), 'end_sql' => null, 'start_c' => null, 'end_c' => null],
default => ['key' => 'all_time', 'start_sql' => '1970-01-01 00:00:00', 'end_sql' => null, 'start_c' => null, 'end_c' => null],
};
}
private function periodFromTimestamps(string $key, int $start, int $end): array
{
return [
'key' => $key,
'start_sql' => date('Y-m-d H:i:s', $start),
'end_sql' => date('Y-m-d H:i:s', $end),
'start_c' => date('c', $start),
'end_c' => date('c', $end),
];
}
private function periodWhereSql(string $dateColumn, array $period): string
{
if ($period['key'] === 'all_time' || $period['key'] === 'provider') {
return '';
}
$dateColumn = preg_replace('/[^a-zA-Z0-9_]/', '', $dateColumn);
if ($dateColumn === '') {
return '';
}
return " AND `{$dateColumn}` >= " . $this->sqlString($period['start_sql']) . " AND `{$dateColumn}` < " . $this->sqlString((string)$period['end_sql']);
}
private function counterWhereSql(array $descriptor, array $period): string
{
return "module_key = " . $this->sqlString((string)$descriptor['module_key'])
. " AND metric_key = " . $this->sqlString((string)$descriptor['metric_key'])
. " AND scope_type = " . $this->sqlString((string)$descriptor['scope_type'])
. " AND scope_id = " . $this->sqlString((string)$descriptor['scope_id'])
. " AND period_key = " . $this->sqlString($period['key'])
. " AND period_start = " . $this->sqlString($period['start_sql']);
}
private function databaseReady(): bool
{
global $db;
return isset($db) && is_object($db) && method_exists($db, 'query') && method_exists($db, 'conn');
}
private function tableExists(string $table): bool
{
if (!$this->databaseReady()) {
return false;
}
try {
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
if ($table === '') {
return false;
}
$result = $db->query("SHOW TABLES LIKE " . $this->sqlString($table));
return $result instanceof mysqli_result && $result->num_rows > 0;
} catch (Throwable) {
return false;
}
}
private function columnExists(string $table, string $column): bool
{
if (!$this->databaseReady()) {
return false;
}
try {
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
if ($table === '' || $column === '') {
return false;
}
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE " . $this->sqlString($column));
return $result instanceof mysqli_result && $result->num_rows > 0;
} catch (Throwable) {
return false;
}
}
private function firstNumeric(array $payload, array $keys): ?float
{
foreach ($keys as $key) {
if (isset($payload[$key]) && is_numeric($payload[$key])) {
return (float)$payload[$key];
}
}
return null;
}
private function redactPayload(array $payload): array
{
$redacted = [];
foreach ($payload as $key => $value) {
$normalized = strtolower((string)$key);
if (str_contains($normalized, 'key') || str_contains($normalized, 'token') || str_contains($normalized, 'secret')) {
$redacted[$key] = '[redacted]';
continue;
}
$redacted[$key] = is_array($value) ? $this->redactPayload($value) : $value;
}
return $redacted;
}
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 sqlString(string $value): string
{
global $db;
return "'" . $db->escape_string($value) . "'";
}
private function numberSql(float $value): string
{
return rtrim(rtrim(sprintf('%.4F', $value), '0'), '.') ?: '0';
}
private function nullableNumberSql(?float $value): string
{
return $value === null ? 'NULL' : $this->numberSql($value);
}
private function jsonSql(array $value): string
{
return $this->sqlString(json_encode($this->redactPayload($value), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
}
+21 -3
View File
@@ -13,6 +13,7 @@ use motorapi\actions\license_plate_lookup_a;
use motorapi\helpers\motorapi_vehicle_types;
use motorapi\motorapi_c;
use objects\motorapi_lookups_o;
use Throwable;
class motorapi implements motorapi_i
{
@@ -182,10 +183,10 @@ class motorapi implements motorapi_i
self::requireModuleEnabled();
// Validate the license plate
self::requireValidLicensePlate($licensePlate);
// Validate the daily limit
self::requireDailyLimitNotExceeded();
// Validate the secret key
self::requireValidSecretKey();
// Reserve quota for the outbound provider call. Cache hits return before this point.
$this->reserveLookupQuota($licensePlate, $endpoint, $method);
// Send the request
$response = match ($method) {
'GET' => self::sendGetRequest($licensePlate, $endpoint, $data),
@@ -217,7 +218,7 @@ class motorapi implements motorapi_i
function requireDailyLimitNotExceeded(): void
{
// Check if the daily limit is exceeded
if ($this->getDailyRequestCounter() >= $this->config->daily_limit->getVariableValue()) {
if ($this->getDailyRequestCounter() >= (int)$this->config->daily_limit->getVariableValue()) {
throw new Exception('Daily limit exceeded');
}
}
@@ -227,12 +228,29 @@ class motorapi implements motorapi_i
*/
function getDailyRequestCounter(): int
{
try {
return (new module_usage_service())->currentUsedQuantity('motorapi', 'lookup_calls');
} catch (Throwable) {
}
// Count the rows from the motorapi request log that was made today
$motorapi_lookups = new motorapi_lookups_o();
$motorapi_lookups->getTodayCount();
return $motorapi_lookups->getTodayCount();
}
/**
* @throws Exception
*/
private function reserveLookupQuota(string $licensePlate, string $endpoint, string $method): void
{
(new module_usage_service())->reserveOrFail('motorapi', 'lookup_calls', 1, [
'license_plate' => $licensePlate,
'endpoint' => $endpoint,
'method' => strtoupper($method),
]);
}
/**
* @inheritDoc
*/
+6 -2
View File
@@ -47,8 +47,12 @@ class pdf_store implements minio_pdfs_i
*/
public function download(string $file): string
{
if ($this->shouldUseLocalTestStorage()) {
return $this->getLocalTestObjectPath($file);
}
$path = '/tmp/' . $file;
$result = self::getS3Client()->getObject([
self::getS3Client()->getObject([
'Bucket' => self::getBucket(),
'Key' => $file,
'SaveAs' => $path
@@ -58,7 +62,7 @@ class pdf_store implements minio_pdfs_i
public function isFileInStore(string $file): bool
{
return self::getS3Client()->doesObjectExist(self::getBucket(), $file);
return self::doesObjectExist($file);
}
/**
@@ -0,0 +1,68 @@
<?php
namespace classes;
/**
* Ensures additive schema for customer product price overrides.
*/
class price_overrides_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureColumns(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
if (!self::tableExists($db, 'price_overrides')) {
return;
}
if (!self::columnExists($db, 'price_overrides', 'fixed_price')) {
$db->query(
"ALTER TABLE price_overrides
ADD COLUMN fixed_price INT NULL DEFAULT NULL
AFTER percentage"
);
}
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);
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -89,7 +89,7 @@ class router
}
// Regex
$route = str_replace('/', '\/', $route);
$route = preg_replace('/{[a-zA-Z0-9]+}/', '([a-zA-Z0-9]+)', $route);
$route = preg_replace('/{[a-zA-Z0-9_]+}/', '([a-zA-Z0-9]+)', $route);
if (preg_match('/^' . $route . '$/', $this->url)) {
return true;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,140 @@
<?php
namespace classes;
class security_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
$queries = [
"CREATE TABLE IF NOT EXISTS security_firewall_rules (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
action VARCHAR(16) NOT NULL,
target_type VARCHAR(32) NOT NULL,
target_value VARCHAR(255) NOT NULL,
route_pattern VARCHAR(255) NULL,
priority INT NOT NULL DEFAULT 100,
reason TEXT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
expires_at DATETIME NULL,
metadata_json LONGTEXT NULL,
created_by INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
INDEX idx_security_firewall_rules_active (enabled, deleted_at, expires_at),
INDEX idx_security_firewall_rules_target (target_type, target_value),
INDEX idx_security_firewall_rules_priority (priority)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS security_policy_rules (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
rule_key VARCHAR(64) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
threshold_count INT NOT NULL,
window_seconds INT NOT NULL,
mode VARCHAR(16) NOT NULL DEFAULT 'observe',
exempt_permission_nodes_json LONGTEXT NULL,
updated_by INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_security_policy_rules_key (rule_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS security_policy_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
rule_key VARCHAR(64) NOT NULL,
subject_type VARCHAR(32) NOT NULL,
subject_key VARCHAR(191) NOT NULL,
route_path VARCHAR(255) NULL,
route_template VARCHAR(255) NULL,
method VARCHAR(16) NULL,
source_ip VARCHAR(64) NULL,
customer_number INT NULL,
user_id INT NULL,
subuser_id INT NULL,
metadata_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_security_policy_events_window (rule_key, subject_type, subject_key, created_at),
INDEX idx_security_policy_events_created (created_at),
INDEX idx_security_policy_events_customer (customer_number, created_at),
INDEX idx_security_policy_events_ip (source_ip, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS security_incidents (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
incident_key VARCHAR(191) NOT NULL,
type VARCHAR(64) NOT NULL,
severity VARCHAR(16) NOT NULL DEFAULT 'medium',
status VARCHAR(32) NOT NULL DEFAULT 'open',
title VARCHAR(255) NOT NULL,
source_ip VARCHAR(64) NULL,
customer_number INT NULL,
user_id INT NULL,
subuser_id INT NULL,
route_path VARCHAR(255) NULL,
route_template VARCHAR(255) NULL,
method VARCHAR(16) NULL,
related_rule_id BIGINT UNSIGNED NULL,
related_firewall_rule_id BIGINT UNSIGNED NULL,
occurrence_count INT NOT NULL DEFAULT 1,
metadata_json LONGTEXT NULL,
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
resolved_by INT NULL,
resolved_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_security_incidents_key (incident_key),
INDEX idx_security_incidents_status_seen (status, last_seen_at),
INDEX idx_security_incidents_type_seen (type, last_seen_at),
INDEX idx_security_incidents_customer_seen (customer_number, last_seen_at),
INDEX idx_security_incidents_ip_seen (source_ip, last_seen_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS security_incident_notes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
incident_id BIGINT UNSIGNED NOT NULL,
note TEXT NOT NULL,
created_by INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_security_incident_notes_incident (incident_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($queries as $query) {
$db->query($query);
}
self::$initialized = true;
}
public static function tablesExist(): bool
{
global $db;
$database = $db->escape_string($db->getDatabase());
$result = $db->query(
"SELECT COUNT(*) AS count
FROM information_schema.tables
WHERE table_schema = '{$database}'
AND table_name IN (
'security_firewall_rules',
'security_policy_rules',
'security_policy_events',
'security_incidents',
'security_incident_notes'
)"
);
$row = $result ? $result->fetch_assoc() : ['count' => 0];
return (int)($row['count'] ?? 0) === 5;
}
}
@@ -54,6 +54,7 @@ class selfserve_schema_bootstrap
department_id INT NOT NULL,
machine_type_id INT NULL,
customer_number INT NULL,
subuser_id INT NULL,
vehicle_id INT NULL,
vehicle_type_id INT NULL,
reg VARCHAR(255) NOT NULL,
@@ -73,7 +74,11 @@ class selfserve_schema_bootstrap
INDEX idx_selfserve_wash_sessions_lane_reg (lane_id, reg),
INDEX idx_selfserve_wash_sessions_status (status),
INDEX idx_selfserve_wash_sessions_customer (customer_number),
INDEX idx_selfserve_wash_sessions_created_at (created_at)
INDEX idx_selfserve_wash_sessions_subuser_active (subuser_id, completed_at),
INDEX idx_selfserve_wash_sessions_customer_subuser_active (customer_number, subuser_id, completed_at),
INDEX idx_selfserve_wash_sessions_created_at (created_at),
INDEX idx_selfserve_wash_sessions_department_completed (department_id, completed_at),
INDEX idx_selfserve_wash_sessions_order (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS selfserve_wash_session_answers (
@@ -95,7 +100,7 @@ class selfserve_schema_bootstrap
session_id INT NOT NULL,
task_id INT NULL,
task_text VARCHAR(255) NOT NULL,
description VARCHAR(255) NULL,
description TEXT NULL,
services JSON NULL,
buttons JSON NULL,
dynamic_image_id INT NULL,
@@ -197,16 +202,63 @@ class selfserve_schema_bootstrap
'gate_ref_id',
'ALTER TABLE department_selfserve_tasks ADD COLUMN gate_ref_id INT NULL AFTER gate_type'
);
self::ensureColumnDataType(
'department_selfserve_tasks',
'description',
['text', 'mediumtext', 'longtext'],
'ALTER TABLE department_selfserve_tasks MODIFY COLUMN description TEXT NULL AFTER task'
);
self::ensureColumnDataType(
'selfserve_wash_session_tasks',
'description',
['text', 'mediumtext', 'longtext'],
'ALTER TABLE selfserve_wash_session_tasks MODIFY COLUMN description TEXT NULL AFTER task_text'
);
self::ensureColumn(
'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',
'customer_number',
'ALTER TABLE selfserve_wash_sessions ADD COLUMN customer_number INT NULL AFTER department_id'
);
self::ensureColumn(
'selfserve_wash_sessions',
'subuser_id',
'ALTER TABLE selfserve_wash_sessions ADD COLUMN subuser_id INT NULL AFTER customer_number'
);
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::ensureIndex(
'selfserve_wash_sessions',
'idx_selfserve_wash_sessions_customer',
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_customer (customer_number)'
);
self::ensureIndex(
'selfserve_wash_sessions',
'idx_selfserve_wash_sessions_subuser_active',
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_subuser_active (subuser_id, completed_at)'
);
self::ensureIndex(
'selfserve_wash_sessions',
'idx_selfserve_wash_sessions_customer_subuser_active',
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_customer_subuser_active (customer_number, subuser_id, completed_at)'
);
self::ensureIndex(
'selfserve_wash_sessions',
'idx_selfserve_wash_sessions_department_completed',
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_department_completed (department_id, completed_at)'
);
self::ensureIndex(
'selfserve_wash_sessions',
'idx_selfserve_wash_sessions_order',
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_order (order_id)'
);
self::$initialized = true;
}
@@ -239,4 +291,78 @@ class selfserve_schema_bootstrap
}
$db->query($alterSql);
}
public static function tableHasIndex(string $table, string $index): bool
{
global $db;
$table = $db->escape_string($table);
$index = $db->escape_string($index);
$database = $db->escape_string($db->getDatabase());
$sql = "SELECT COUNT(*) AS c
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = '$database'
AND TABLE_NAME = '$table'
AND INDEX_NAME = '$index'";
$result = $db->query($sql);
if (!$result) {
return false;
}
$row = $result->fetch_assoc();
return ((int)($row['c'] ?? 0)) > 0;
}
public static function ensureIndex(string $table, string $index, string $alterSql): void
{
global $db;
if (self::tableHasIndex($table, $index)) {
return;
}
$db->query($alterSql);
}
/**
* @param array<int,string> $acceptedDataTypes
*/
public static function ensureColumnDataType(string $table, string $column, array $acceptedDataTypes, string $alterSql): void
{
global $db;
$columnInfo = self::columnInfo($table, $column);
if ($columnInfo === null) {
return;
}
$dataType = strtolower((string)($columnInfo['DATA_TYPE'] ?? ''));
$acceptedDataTypes = array_map(static fn(string $type): string => strtolower($type), $acceptedDataTypes);
if (in_array($dataType, $acceptedDataTypes, true)) {
return;
}
$db->query($alterSql);
}
/**
* @return array<string,mixed>|null
*/
public static function columnInfo(string $table, string $column): ?array
{
global $db;
$table = $db->escape_string($table);
$column = $db->escape_string($column);
$database = $db->escape_string($db->getDatabase());
$sql = "SELECT DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, CHARACTER_MAXIMUM_LENGTH
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = '$database'
AND TABLE_NAME = '$table'
AND COLUMN_NAME = '$column'
LIMIT 1";
$result = $db->query($sql);
if (!$result) {
return null;
}
$row = $result->fetch_assoc();
return is_array($row) ? $row : null;
}
}
+8
View File
@@ -16,6 +16,8 @@ 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';
private const SHELLY_CONNECT_TIMEOUT_SECONDS = 2;
private const SHELLY_REQUEST_TIMEOUT_SECONDS = 5;
/**
* @var array<int,array<string,mixed>>
*/
@@ -178,6 +180,9 @@ class shelly implements shelly_i
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
]);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::SHELLY_CONNECT_TIMEOUT_SECONDS);
curl_setopt($ch, CURLOPT_TIMEOUT, self::SHELLY_REQUEST_TIMEOUT_SECONDS);
curl_setopt($ch, CURLOPT_NOSIGNAL, true);
// Execute the request
$response = curl_exec($ch);
// Get the status code
@@ -224,6 +229,9 @@ class shelly implements shelly_i
);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPGET, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::SHELLY_CONNECT_TIMEOUT_SECONDS);
curl_setopt($ch, CURLOPT_TIMEOUT, self::SHELLY_REQUEST_TIMEOUT_SECONDS);
curl_setopt($ch, CURLOPT_NOSIGNAL, true);
$response = curl_exec($ch);
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+198 -1
View File
@@ -6,13 +6,25 @@ use GuzzleHttp\Client;
use interfaces\notification_i;
use objects\departments_o;
use objects\users_o;
use slack\slack_c;
use traits\notification_t;
require_once WD . '/modules/slack/slack_c.php';
class slack implements notification_i
{
use notification_t;
private ?slack_c $config = null;
public function getConfig(): slack_c
{
if ($this->config === null) {
$this->config = new slack_c();
}
return $this->config;
}
/**
* @inheritdoc
@@ -122,7 +134,7 @@ class slack implements notification_i
. "Status: $status";
}
public function send_message(string $string, string $module = null): void
public function send_message(string $string, ?string $module = null): void
{
global $SLACK_DEFAULT_WEBHOOK;
// Format the message if a module is provided
@@ -132,4 +144,189 @@ class slack implements notification_i
// Send the message to the slack webhook
self::add_log(self::send_webhook_message($string, $SLACK_DEFAULT_WEBHOOK));
}
public function send_customer_registration_notification(int $customer_number): self
{
$webhook = $this->get_customer_registration_webhook_url();
if ($webhook === '') {
return $this;
}
self::add_log(self::send_webhook_message(
$this->format_customer_registration($customer_number),
$webhook
));
return $this;
}
/**
* Send a sanitized customer-registration test notification to the saved Slack webhook.
*
* @return array{configured:bool,sent:bool,message:string}
*/
public function test_customer_registration_webhook(): array
{
$webhook = $this->get_customer_registration_webhook_url();
if ($webhook === '') {
return [
'configured' => false,
'sent' => false,
'message' => 'Slack customer registration webhook URL is not configured.',
];
}
$result = $this->send_webhook_message(
$this->format_customer_registration_test(),
$webhook
);
$sent = $this->is_webhook_send_successful($result);
self::add_log($sent
? 'Slack customer registration test webhook sent successfully.'
: 'Slack customer registration test webhook failed.'
);
return [
'configured' => true,
'sent' => $sent,
'message' => $sent
? 'Slack test message sent successfully.'
: 'Slack test message failed.',
];
}
/**
* Send a sanitized internal department goal progress test notification to the saved Slack webhook.
*
* @return array{configured:bool,sent:bool,message:string}
*/
public function test_internal_department_goal_progress_webhook(): array
{
$webhook = $this->get_internal_department_goal_progress_webhook_url();
if ($webhook === '') {
return [
'configured' => false,
'sent' => false,
'message' => 'Slack internal department goal progress webhook URL is not configured.',
];
}
$result = $this->send_webhook_message(
$this->format_internal_department_goal_progress_test(),
$webhook
);
$sent = $this->is_webhook_send_successful($result);
self::add_log($sent
? 'Slack internal department goal progress test webhook sent successfully.'
: 'Slack internal department goal progress test webhook failed.'
);
return [
'configured' => true,
'sent' => $sent,
'message' => $sent
? 'Slack test message sent successfully.'
: 'Slack test message failed.',
];
}
protected function get_customer_registration_webhook_url(): string
{
return trim((string)$this->getConfig()->customer_registration_webhook_url->getVariableValue());
}
public function get_internal_department_goal_progress_webhook_url(): string
{
return trim((string)$this->getConfig()->internal_department_goal_progress_webhook_url->getVariableValue());
}
/**
* @return int[]
*/
public function get_internal_department_ids(): array
{
return $this->getConfig()->internal_department_ids->getDepartmentIds();
}
/**
* @param int[] $department_ids
* @throws \Exception
*/
public function set_internal_department_goal_progress_config(string $webhook_url, array $department_ids): array
{
$this->getConfig()->internal_department_goal_progress_webhook_url->setVariableValue(trim($webhook_url));
$this->getConfig()->internal_department_ids->setVariableValue($department_ids);
return $this->get_internal_department_goal_progress_config();
}
public function get_internal_department_goal_progress_config(): array
{
$departments = (new departments_o())->getFieldsWhere(
[
'visible' => 1,
'archived' => 0,
],
[
'id',
'name',
'order_priority',
]
);
usort($departments, static function (array $a, array $b): int {
return (int)($a['order_priority'] ?? 0) <=> (int)($b['order_priority'] ?? 0)
?: (int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0);
});
return [
'internal_department_goal_progress_webhook_url' => $this->get_internal_department_goal_progress_webhook_url(),
'internal_department_ids' => $this->get_internal_department_ids(),
'departments' => array_map(static function (array $department): array {
return [
'id' => (int)$department['id'],
'name' => (string)$department['name'],
'order_priority' => (int)$department['order_priority'],
];
}, $departments),
];
}
public function is_webhook_send_successful(string $result): bool
{
return !str_starts_with($result, 'Failed to send message:');
}
public function format_customer_registration(int $customer_number): string
{
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
$customerName = $customer->exists()
? $customer->getCustomerName((int)$customer->customer_number->value())
: '';
$customerName = trim((string)$customerName);
if ($customerName === '') {
$customerName = 'Unknown customer';
}
$safeCustomerNumber = (int)$customer_number;
$customerUrl = 'https://truckwash.io/superuser/users?search=' . $safeCustomerNumber;
return "*New customer registered on Truck Wash*\n"
. "Customer: $customerName ($safeCustomerNumber)\n"
. "Open in Superuser: $customerUrl";
}
public function format_customer_registration_test(): string
{
return "*Truck Wash Slack test*\n"
. "Customer registration notifications are configured correctly.";
}
public function format_internal_department_goal_progress_test(): string
{
return "*Truck Wash Slack test*\n"
. "Internal department goal progress notifications are configured correctly.";
}
}
@@ -0,0 +1,488 @@
<?php
namespace classes;
use Exception;
use objects\logs_o;
use objects\subusers_o;
class subuser_contact_verification_service
{
public const CHANNEL_EMAIL = 'email';
public const CHANNEL_PHONE = 'phone';
public const CODE_TTL_SECONDS = 600;
public const RESEND_COOLDOWN_SECONDS = 60;
public const MAX_ATTEMPTS = 5;
private ?object $redis;
private $codeGenerator;
private $timeProvider;
private $smsSender;
private $emailSender;
public function __construct(
?object $redis = null,
?callable $codeGenerator = null,
?callable $timeProvider = null,
?callable $smsSender = null,
?callable $emailSender = null
) {
$this->redis = $redis ?? (defined('redis') ? constant('redis') : null);
$this->codeGenerator = $codeGenerator;
$this->timeProvider = $timeProvider;
$this->smsSender = $smsSender;
$this->emailSender = $emailSender;
}
/**
* @return array{state:string,email:array<string,mixed>,phone:array<string,mixed>}
*/
public function status(subusers_o $subuser): array
{
$email = $this->emailDestination($subuser);
$phone = $this->phoneDestination($subuser);
$emailVerifiedAt = $this->verifiedAtValue($subuser, self::CHANNEL_EMAIL);
$phoneVerifiedAt = $this->verifiedAtValue($subuser, self::CHANNEL_PHONE);
$emailStatus = [
'channel' => self::CHANNEL_EMAIL,
'value' => $email,
'masked_value' => $email !== null ? $this->maskEmail($email) : null,
'available' => $email !== null,
'verified' => $email !== null && $emailVerifiedAt !== null,
'verified_at' => $emailVerifiedAt,
];
$phoneStatus = [
'channel' => self::CHANNEL_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,
'value' => $phone,
'masked_value' => $phone !== null ? $this->maskPhone($phone) : null,
'available' => $phone !== null,
'verified' => $phone !== null && $phoneVerifiedAt !== null,
'verified_at' => $phoneVerifiedAt,
];
return [
'state' => $this->verificationState($emailStatus, $phoneStatus),
'email' => $emailStatus,
'phone' => $phoneStatus,
];
}
/**
* @return array<string,mixed>
*/
public function sendCode(subusers_o $subuser, string $channel, array $actor = []): array
{
$channel = $this->normalizeChannel($channel);
$destination = $this->destinationFor($subuser, $channel);
if ($destination === null) {
return $this->delivery($channel, 'missing_destination', 'No contact value is available for verification.');
}
if ($this->redis === null) {
return $this->delivery($channel, 'unavailable', 'Verification delivery is not available.');
}
$now = $this->now();
$existing = $this->readChallenge($subuser, $channel);
if ($existing !== null) {
$sentAt = (int)($existing['sent_at'] ?? 0);
$retryAfter = self::RESEND_COOLDOWN_SECONDS - ($now - $sentAt);
if ($retryAfter > 0) {
return $this->delivery($channel, 'throttled', 'Please wait before requesting another code.', [
'retry_after' => $retryAfter,
]);
}
}
$code = $this->generateCode();
$destinationKey = $this->destinationKey($subuser, $channel);
$nonce = bin2hex(random_bytes(16));
$payload = [
'hash' => $this->hashCode($code, $destinationKey, $nonce),
'nonce' => $nonce,
'destination' => $destinationKey,
'attempts' => 0,
'sent_at' => $now,
'expires_at' => $now + self::CODE_TTL_SECONDS,
];
try {
$this->deliverCode($subuser, $channel, $destination, $code);
$this->writeChallenge($subuser, $channel, $payload);
$this->logEvent('SUBUSER_CONTACT_VERIFICATION_SENT', $subuser, $channel, $actor);
} catch (Exception $exception) {
$this->logEvent('SUBUSER_CONTACT_VERIFICATION_FAILED', $subuser, $channel, $actor);
return $this->delivery($channel, 'failed', 'Verification delivery failed.');
}
return $this->delivery($channel, 'sent', 'Verification code sent.', [
'masked_destination' => $channel === self::CHANNEL_EMAIL
? $this->maskEmail($destination)
: $this->maskPhone($destination),
'expires_in' => self::CODE_TTL_SECONDS,
]);
}
/**
* @return array<string,mixed>
*/
public function verifyCode(subusers_o $subuser, string $channel, string $code, array $actor = []): array
{
$channel = $this->normalizeChannel($channel);
$code = trim($code);
if (!preg_match('/^[0-9]{6}$/', $code)) {
return $this->verificationResult($channel, 'invalid_code', 'Invalid verification code.');
}
$challenge = $this->readChallenge($subuser, $channel);
if ($challenge === null || (int)($challenge['expires_at'] ?? 0) < $this->now()) {
$this->deleteChallenge($subuser, $channel);
return $this->verificationResult($channel, 'expired', 'Verification code expired.');
}
if (($challenge['destination'] ?? null) !== $this->destinationKey($subuser, $channel)) {
$this->deleteChallenge($subuser, $channel);
return $this->verificationResult($channel, 'destination_changed', 'Contact value changed. Request a new code.');
}
$attempts = (int)($challenge['attempts'] ?? 0);
if ($attempts >= self::MAX_ATTEMPTS) {
$this->deleteChallenge($subuser, $channel);
return $this->verificationResult($channel, 'too_many_attempts', 'Too many verification attempts.');
}
$expectedHash = (string)($challenge['hash'] ?? '');
$nonce = (string)($challenge['nonce'] ?? '');
if (!hash_equals($expectedHash, $this->hashCode($code, (string)$challenge['destination'], $nonce))) {
$nextAttempts = $attempts + 1;
if ($nextAttempts >= self::MAX_ATTEMPTS) {
$this->deleteChallenge($subuser, $channel);
return $this->verificationResult($channel, 'too_many_attempts', 'Too many verification attempts.');
}
$challenge['attempts'] = $nextAttempts;
$remainingTtl = max(1, (int)($challenge['expires_at'] ?? $this->now()) - $this->now());
$this->writeChallenge($subuser, $channel, $challenge, $remainingTtl);
return $this->verificationResult($channel, 'invalid_code', 'Invalid verification code.');
}
$this->markVerified($subuser, $channel);
$this->deleteChallenge($subuser, $channel);
$this->logEvent('SUBUSER_CONTACT_VERIFICATION_VERIFIED', $subuser, $channel, $actor);
return $this->verificationResult($channel, 'verified', 'Contact value verified.', [
'verified_at' => $this->verifiedAtValue($subuser, $channel),
]);
}
/**
* @return array<string,mixed>
*/
public function setVerificationState(subusers_o $subuser, string $channel, bool $verified, array $actor = []): array
{
$channel = $this->normalizeChannel($channel);
if ($verified && $this->destinationFor($subuser, $channel) === null) {
return $this->verificationResult($channel, 'missing_destination', 'No contact value is available for verification.');
}
if ($verified) {
$this->markVerified($subuser, $channel);
} else {
$this->markUnverified($subuser, $channel);
}
$this->deleteChallenge($subuser, $channel);
$this->logEvent(
$verified
? 'SUBUSER_CONTACT_VERIFICATION_MARKED_VERIFIED'
: 'SUBUSER_CONTACT_VERIFICATION_MARKED_UNVERIFIED',
$subuser,
$channel,
$actor
);
return $this->verificationResult($channel, $verified ? 'verified' : 'unverified', 'Contact verification state updated.', [
'verified_at' => $this->verifiedAtValue($subuser, $channel),
]);
}
/**
* @param array<string,mixed> $updates
* @return array<string,mixed>
*/
public function clearVerificationForChangedContacts(subusers_o $subuser, array $updates): array
{
if (array_key_exists('email', $updates)) {
$current = $this->normalizeNullableString($subuser->email->value());
$next = $this->normalizeNullableString($updates['email']);
if ($current !== $next) {
$updates['email_verified_at'] = null;
$this->deleteChallenge($subuser, self::CHANNEL_EMAIL);
}
}
$phoneChanged = false;
if (array_key_exists('phone_country_code', $updates)) {
$phoneChanged = (int)$subuser->phone_country_code->value() !== (int)$updates['phone_country_code'];
}
if (array_key_exists('phone', $updates)) {
$phoneChanged = $phoneChanged || (int)$subuser->phone->value() !== (int)$updates['phone'];
}
if ($phoneChanged) {
$updates['phone_verified_at'] = null;
$this->deleteChallenge($subuser, self::CHANNEL_PHONE);
}
return $updates;
}
public function normalizeChannel(string $channel): string
{
$channel = strtolower(trim($channel));
if (!in_array($channel, [self::CHANNEL_EMAIL, self::CHANNEL_PHONE], true)) {
throw new Exception('Invalid verification channel');
}
return $channel;
}
private function now(): int
{
if ($this->timeProvider !== null) {
return (int)call_user_func($this->timeProvider);
}
return time();
}
private function generateCode(): string
{
if ($this->codeGenerator !== null) {
$code = (string)call_user_func($this->codeGenerator);
if (preg_match('/^[0-9]{6}$/', $code)) {
return $code;
}
}
return (string)random_int(100000, 999999);
}
private function hashCode(string $code, string $destination, string $nonce): string
{
$secret = (string)(getenv('APP_KEY') ?: getenv('JWT_SECRET') ?: __FILE__);
return hash('sha256', $secret . ':' . $nonce . ':' . $destination . ':' . $code);
}
private function destinationFor(subusers_o $subuser, string $channel): ?string
{
return $channel === self::CHANNEL_EMAIL
? $this->emailDestination($subuser)
: $this->phoneDestination($subuser);
}
private function emailDestination(subusers_o $subuser): ?string
{
$email = $this->normalizeNullableString($subuser->email->value());
return $email !== null && filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null;
}
private function phoneDestination(subusers_o $subuser): ?string
{
$countryCode = $subuser->phone_country_code->value();
$phone = $subuser->phone->value();
if ($countryCode === null || $phone === null) {
return null;
}
$normalized = preg_replace('/[^0-9]/', '', (string)$countryCode . (string)$phone);
return $normalized !== '' ? $normalized : null;
}
private function destinationKey(subusers_o $subuser, string $channel): string
{
return $channel . ':' . (string)($this->destinationFor($subuser, $channel) ?? '');
}
private function key(subusers_o $subuser, string $channel): string
{
return 'subuser_contact_verification:' . (int)$subuser->id . ':' . $channel;
}
private function readChallenge(subusers_o $subuser, string $channel): ?array
{
if ($this->redis === null) {
return null;
}
$raw = $this->redis->get($this->key($subuser, $channel));
if (!is_string($raw) || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
private function writeChallenge(subusers_o $subuser, string $channel, array $payload, ?int $ttl = null): void
{
if ($this->redis === null) {
throw new Exception('Verification cache is unavailable');
}
$encoded = json_encode($payload, JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
throw new Exception('Failed to encode verification challenge');
}
$this->redis->setEx($this->key($subuser, $channel), $encoded, $ttl ?? self::CODE_TTL_SECONDS);
}
private function deleteChallenge(subusers_o $subuser, string $channel): void
{
if ($this->redis !== null) {
$this->redis->delete($this->key($subuser, $channel));
}
}
private function deliverCode(subusers_o $subuser, string $channel, string $destination, string $code): void
{
if ($channel === self::CHANNEL_PHONE) {
if ($this->smsSender !== null) {
call_user_func($this->smsSender, $destination, $code, $subuser);
return;
}
$gateway = new gatewayapi();
if (!$gateway->isEnabled()) {
throw new Exception('SMS delivery is not configured.');
}
$gateway->send([$destination], 'Truck Wash verifikationskode: ' . $code . '. Den udløber om 10 minutter.');
return;
}
if ($this->emailSender !== null) {
call_user_func($this->emailSender, $destination, $code, $subuser);
return;
}
$recipientName = $this->normalizeNullableString($subuser->name->value()) ?? 'Chauffør';
$message = '<p>Din verifikationskode til Truck Wash er <strong>' . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '</strong>.</p>'
. '<p>Koden udløber om 10 minutter.</p>';
(new email())->sendEmail($destination, $recipientName, 'Truck Wash verifikationskode', $message);
}
private function markVerified(subusers_o $subuser, string $channel): void
{
$timestamp = date('Y-m-d H:i:s', $this->now());
if ($channel === self::CHANNEL_EMAIL) {
$subuser->email_verified_at->set($timestamp);
return;
}
$subuser->phone_verified_at->set($timestamp);
}
private function markUnverified(subusers_o $subuser, string $channel): void
{
if ($channel === self::CHANNEL_EMAIL) {
$subuser->email_verified_at->set(null);
return;
}
$subuser->phone_verified_at->set(null);
}
private function verifiedAtValue(subusers_o $subuser, string $channel): ?string
{
$value = $channel === self::CHANNEL_EMAIL
? $subuser->email_verified_at->value()
: $subuser->phone_verified_at->value();
$value = $this->normalizeNullableString($value);
return $value;
}
private function verificationState(array $emailStatus, array $phoneStatus): string
{
if (!$phoneStatus['available'] && !$emailStatus['available']) {
return 'missing_contacts';
}
if (!$phoneStatus['available']) {
return 'missing_phone';
}
if (!$emailStatus['available']) {
return 'missing_email';
}
if ($phoneStatus['verified'] && $emailStatus['verified']) {
return 'verified';
}
if ($phoneStatus['verified'] || $emailStatus['verified']) {
return 'partial';
}
return 'unverified';
}
private function normalizeNullableString(mixed $value): ?string
{
if ($value === null) {
return null;
}
$normalized = trim((string)$value);
return $normalized === '' ? null : $normalized;
}
private function maskEmail(string $email): string
{
[$local, $domain] = array_pad(explode('@', $email, 2), 2, '');
$prefix = substr($local, 0, 2);
return $prefix . str_repeat('*', max(2, strlen($local) - 2)) . '@' . $domain;
}
private function maskPhone(string $phone): string
{
$suffix = substr($phone, -4);
return str_repeat('*', max(0, strlen($phone) - 4)) . $suffix;
}
private function delivery(string $channel, string $status, string $message, array $extra = []): array
{
return [
'channel' => $channel,
'status' => $status,
'message' => $message,
...$extra,
];
}
private function verificationResult(string $channel, string $status, string $message, array $extra = []): array
{
return [
'channel' => $channel,
'status' => $status,
'message' => $message,
...$extra,
];
}
private function logEvent(string $action, subusers_o $subuser, string $channel, array $actor): void
{
try {
if (!defined('redis')) {
return;
}
$actorId = isset($actor['id']) ? (int)$actor['id'] : 0;
(new logs_o())->add(
'subusers',
'global',
1,
$actorId,
$action,
'Chauffeur contact verification ' . $channel . ': ' . (int)$subuser->id
);
} catch (Exception) {
}
}
}
@@ -0,0 +1,293 @@
<?php
namespace classes;
use modules\subusers\helpers\subusers_permission_node_key;
class subuser_permission_templates_service
{
public const TEMPLATE_DEACTIVATED = 'deactivated';
public const TEMPLATE_DRIVER = 'driver';
public const TEMPLATE_BOOKING_COORDINATOR = 'booking_coordinator';
public const TEMPLATE_FLEET_ADMIN = 'fleet_admin';
public const TEMPLATE_CUSTOM = 'custom';
/**
* @var array<string, array{label:string,description:string,enabled:bool,permissions:array<int,string>}>
*/
private const TEMPLATES = [
self::TEMPLATE_DEACTIVATED => [
'label' => 'Deactivated',
'description' => 'Keeps the driver linked to the customer without active access.',
'enabled' => false,
'permissions' => [],
],
self::TEMPLATE_DRIVER => [
'label' => 'Driver',
'description' => 'Can use self-service, manage own bookings, see vehicles, and view orders.',
'enabled' => true,
'permissions' => [
'VEHICLES_LIST',
'SELFSERVE_LIST',
'SELFSERVE_ADD',
'BOOKINGS_LIST',
'BOOKINGS_ADD',
'ORDERS_LIST',
],
],
self::TEMPLATE_BOOKING_COORDINATOR => [
'label' => 'Booking coordinator',
'description' => 'Can coordinate bookings and see the related vehicles and orders.',
'enabled' => true,
'permissions' => [
'VEHICLES_LIST',
'BOOKINGS_LIST',
'BOOKINGS_ADD',
'BOOKINGS_EDIT',
'ORDERS_LIST',
],
],
self::TEMPLATE_FLEET_ADMIN => [
'label' => 'Fleet admin',
'description' => 'Can manage drivers, vehicles, bookings, self-service, and orders for the customer.',
'enabled' => true,
'permissions' => [
'VEHICLES_LIST',
'VEHICLES_EDIT',
'VEHICLES_DELETE',
'VEHICLES_ADD',
'SELFSERVE_LIST',
'SELFSERVE_EDIT',
'SELFSERVE_DELETE',
'SELFSERVE_ADD',
'BOOKINGS_LIST',
'BOOKINGS_EDIT',
'BOOKINGS_DELETE',
'BOOKINGS_ADD',
'ORDERS_LIST',
'ORDERS_EDIT',
'SUBUSERS_LIST',
'SUBUSERS_EDIT',
'SUBUSERS_DELETE',
'SUBUSERS_ADD',
],
],
];
/**
* @var array<string, array{group:string,capability:string}>
*/
private const PERMISSION_CAPABILITIES = [
'VEHICLES_LIST' => ['group' => 'vehicles', 'capability' => 'view_vehicles'],
'VEHICLES_EDIT' => ['group' => 'vehicles', 'capability' => 'edit_vehicles'],
'VEHICLES_DELETE' => ['group' => 'vehicles', 'capability' => 'delete_vehicles'],
'VEHICLES_ADD' => ['group' => 'vehicles', 'capability' => 'add_vehicles'],
'SELFSERVE_LIST' => ['group' => 'selfserve', 'capability' => 'view_selfserve'],
'SELFSERVE_EDIT' => ['group' => 'selfserve', 'capability' => 'edit_selfserve'],
'SELFSERVE_DELETE' => ['group' => 'selfserve', 'capability' => 'delete_selfserve'],
'SELFSERVE_ADD' => ['group' => 'selfserve', 'capability' => 'start_selfserve'],
'BOOKINGS_LIST' => ['group' => 'bookings', 'capability' => 'view_bookings'],
'BOOKINGS_EDIT' => ['group' => 'bookings', 'capability' => 'edit_bookings'],
'BOOKINGS_DELETE' => ['group' => 'bookings', 'capability' => 'delete_bookings'],
'BOOKINGS_ADD' => ['group' => 'bookings', 'capability' => 'add_bookings'],
'ORDERS_LIST' => ['group' => 'orders', 'capability' => 'view_orders'],
'ORDERS_EDIT' => ['group' => 'orders', 'capability' => 'edit_orders'],
'SUBUSERS_LIST' => ['group' => 'driver_management', 'capability' => 'view_drivers'],
'SUBUSERS_EDIT' => ['group' => 'driver_management', 'capability' => 'edit_driver_access'],
'SUBUSERS_DELETE' => ['group' => 'driver_management', 'capability' => 'disable_driver_access'],
'SUBUSERS_ADD' => ['group' => 'driver_management', 'capability' => 'invite_drivers'],
];
/**
* @var array<int, string>
*/
private const GROUP_ORDER = [
'vehicles',
'selfserve',
'bookings',
'orders',
'driver_management',
];
/**
* @return array<string, mixed>
*/
public function accessModel(): array
{
return [
'templates' => $this->templates(),
'groups' => $this->groups(),
];
}
/**
* @return array<int, array<string, mixed>>
*/
public function templates(): array
{
$templates = [];
foreach (self::TEMPLATES as $key => $template) {
$templates[] = [
'key' => $key,
'label' => $template['label'],
'description' => $template['description'],
'enabled' => $template['enabled'],
'permissions' => array_values($template['permissions']),
'permission_groups' => $this->permissionGroups($template['permissions']),
];
}
return $templates;
}
/**
* @return array<int, array{key:string,capabilities:array<int,string>}>
*/
public function groups(): array
{
$groups = [];
foreach (self::GROUP_ORDER as $group) {
$capabilities = [];
foreach (self::PERMISSION_CAPABILITIES as $capability) {
if ($capability['group'] === $group) {
$capabilities[] = $capability['capability'];
}
}
$groups[] = [
'key' => $group,
'capabilities' => array_values(array_unique($capabilities)),
];
}
return $groups;
}
/**
* @return array{enabled:bool,permissions:array<int,string>}
*/
public function expandTemplate(string $templateKey): array
{
$key = $this->normalizeTemplateKey($templateKey);
if ($key === null || $key === self::TEMPLATE_CUSTOM) {
throw new \InvalidArgumentException('Unknown driver access template.');
}
return [
'enabled' => self::TEMPLATES[$key]['enabled'],
'permissions' => array_values(self::TEMPLATES[$key]['permissions']),
];
}
/**
* @return array{enabled:bool,permissions:array<int,string>}|null
*/
public function expandTemplateForWritePayload(?string $templateKey): ?array
{
$key = $this->normalizeTemplateKey($templateKey);
if ($key === self::TEMPLATE_CUSTOM) {
return null;
}
if ($key === null) {
throw new \InvalidArgumentException('Unknown driver access template.');
}
return $this->expandTemplate($key);
}
public function normalizeTemplateKey(?string $templateKey): ?string
{
if ($templateKey === null) {
return null;
}
$key = strtolower(trim($templateKey));
if ($key === self::TEMPLATE_CUSTOM) {
return self::TEMPLATE_CUSTOM;
}
return array_key_exists($key, self::TEMPLATES) ? $key : null;
}
/**
* @param array<int, string> $permissions
*/
public function classify(array $permissions, bool $enabled = true): string
{
$normalized = $this->normalizePermissions($permissions);
if (!$enabled || $normalized === []) {
return self::TEMPLATE_DEACTIVATED;
}
foreach (self::TEMPLATES as $key => $template) {
if (!$template['enabled']) {
continue;
}
if ($normalized === $this->normalizePermissions($template['permissions'])) {
return $key;
}
}
return self::TEMPLATE_CUSTOM;
}
/**
* @param array<int, string> $permissions
* @return array<int, array{key:string,capabilities:array<int,string>}>
*/
public function permissionGroups(array $permissions): array
{
$permissions = $this->normalizePermissions($permissions);
$groups = [];
foreach ($permissions as $permission) {
$capability = self::PERMISSION_CAPABILITIES[$permission] ?? null;
if ($capability === null) {
continue;
}
$group = $capability['group'];
$groups[$group] ??= [];
$groups[$group][] = $capability['capability'];
}
$payload = [];
foreach (self::GROUP_ORDER as $group) {
if (!isset($groups[$group])) {
continue;
}
$payload[] = [
'key' => $group,
'capabilities' => array_values(array_unique($groups[$group])),
];
}
return $payload;
}
/**
* @param array<int, string> $permissions
* @return array<int, string>
*/
private function normalizePermissions(array $permissions): array
{
$normalized = [];
foreach ($permissions 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) {
$normalized[] = $permission;
}
}
$normalized = array_values(array_unique($normalized));
sort($normalized);
return $normalized;
}
}
@@ -0,0 +1,82 @@
<?php
namespace classes;
class subusers_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
self::ensureColumn(
'subuser_grants',
'assigned_vehicle_id',
'INT NULL AFTER `subuser`'
);
self::ensureIndex(
'subuser_grants',
'idx_subuser_grants_assigned_vehicle_id',
'`assigned_vehicle_id`'
);
self::ensureColumn(
'subusers',
'phone_verified_at',
'DATETIME NULL AFTER `phone`'
);
self::ensureColumn(
'subusers',
'email_verified_at',
'DATETIME NULL AFTER `email`'
);
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;
}
$columnSql = $db->escape_string($column);
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$columnSql'");
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)");
}
}
@@ -27,9 +27,6 @@ class superuser_system_status_service
$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) {
@@ -298,16 +295,6 @@ class superuser_system_status_service
$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(
@@ -333,19 +320,6 @@ class superuser_system_status_service
];
}
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');
@@ -563,6 +537,7 @@ class superuser_system_status_service
$result['configured'] = false;
$result['status'] = 'disabled';
$result = array_merge($result, $this->moduleReason('module_disabled', [], 'Module is disabled.'));
$result = $this->attachModuleUsageMetrics($result);
$results[] = $result;
continue;
}
@@ -574,6 +549,7 @@ class superuser_system_status_service
$result['status_reason_params'] = isset($configuration['reason_params']) && is_array($configuration['reason_params'])
? $configuration['reason_params']
: ['variables' => implode(', ', $missingRequired), 'variables_list' => $missingRequired];
$result = $this->attachModuleUsageMetrics($result);
$results[] = $result;
continue;
}
@@ -589,6 +565,7 @@ class superuser_system_status_service
'Configuration is present, but no safe read-only probe is available.'
)
);
$result = $this->attachModuleUsageMetrics($result);
$results[] = $result;
continue;
}
@@ -601,6 +578,10 @@ class superuser_system_status_service
? $probeResult['status_reason_params']
: [];
$result['checked_at'] = (string)($probeResult['checked_at'] ?? $result['checked_at']);
if (isset($probeResult['usage']) && is_array($probeResult['usage'])) {
$result['usage'] = $probeResult['usage'];
}
$result = $this->attachModuleUsageMetrics($result, isset($probeResult['usage']) && is_array($probeResult['usage']) ? $probeResult['usage'] : null);
$results[] = $result;
}
@@ -619,6 +600,60 @@ class superuser_system_status_service
return $results;
}
protected function attachModuleUsageMetrics(array $moduleResult, ?array $probeUsage = null): array
{
try {
$service = new module_usage_service();
$metrics = $service->metricsForModule((string)($moduleResult['key'] ?? ''));
if ($probeUsage !== null) {
$probeMetric = $service->recordProviderSnapshotFromLegacyUsage(
(string)($moduleResult['key'] ?? ''),
$probeUsage,
['source' => 'system_status_probe']
);
if ($probeMetric !== null) {
$metrics = $this->replaceModuleUsageMetric($metrics, $probeMetric);
}
}
if ($metrics !== []) {
$moduleResult['usage_metrics'] = $metrics;
if (!isset($moduleResult['usage'])) {
$primaryUsage = $service->primarySystemUsage($metrics);
if ($primaryUsage !== null) {
$moduleResult['usage'] = $primaryUsage;
}
}
}
} catch (Throwable $throwable) {
$moduleResult['usage_metrics_error'] = $throwable->getMessage();
}
return $moduleResult;
}
protected function replaceModuleUsageMetric(array $metrics, array $replacement): array
{
$replaced = false;
foreach ($metrics as $index => $metric) {
if (
($metric['module_key'] ?? null) === ($replacement['module_key'] ?? null)
&& ($metric['metric_key'] ?? null) === ($replacement['metric_key'] ?? null)
) {
$metrics[$index] = $replacement;
$replaced = true;
break;
}
}
if (!$replaced) {
$metrics[] = $replacement;
}
return $metrics;
}
protected function resolveModuleProbeResult(array $descriptor, array $moduleConfig, bool $force, array &$warnings): array
{
$cacheKey = self::MODULE_PROBE_CACHE_KEY_PREFIX . $descriptor['key'];
@@ -824,7 +859,7 @@ class superuser_system_status_service
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' => 'economic', 'module' => 'economic', 'always_enabled' => true, 'required' => ['invoiceLayoutNumber', 'invoiceDiscountLayoutNumber', '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)],
@@ -979,7 +1014,11 @@ class superuser_system_status_service
'Authorization: Bearer ' . $apiKey,
'Accept: application/json',
],
'MailerSend API'
'MailerSend API',
null,
'GET',
null,
fn(array $httpResponse, string $label): array => $this->evaluateProviderQuotaProbeResponse($httpResponse, $label, 'email')
);
}
@@ -990,12 +1029,50 @@ class superuser_system_status_service
try {
$this->validateBackupsStore();
$health = $this->backupHealthSummary();
if (empty($health['encryption']['available'])) {
$error = (string)($health['encryption']['error'] ?? 'Backup encryption key is missing.');
return [
'status' => 'down',
'status_reason' => 'Backup encryption is not ready: ' . $error,
'status_reason_key' => 'backup_encryption_key_missing',
'status_reason_params' => ['error' => $error],
'checked_at' => $checkedAt,
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
}
if (empty($health['latest_verified_backup'])) {
return [
'status' => 'degraded',
'status_reason' => 'No verified backup is available for restore.',
'status_reason_key' => 'backup_no_verified_backup',
'status_reason_params' => [],
'checked_at' => $checkedAt,
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
}
if (empty($health['fresh'])) {
return [
'status' => 'degraded',
'status_reason' => 'Latest verified backup is stale.',
'status_reason_key' => 'backup_latest_verified_stale',
'status_reason_params' => ['age_seconds' => (string)($health['latest_verified_age_seconds'] ?? '')],
'checked_at' => $checkedAt,
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
}
return [
'status' => 'ok',
'status_reason' => 'Backup store connectivity confirmed.',
'status_reason_key' => 'backup_connectivity_confirmed',
'status_reason_params' => [],
'status_reason_params' => [
'latest_verified_age_seconds' => (string)($health['latest_verified_age_seconds'] ?? ''),
'encryption_key_id' => (string)($health['encryption']['key_id'] ?? ''),
],
'checked_at' => $checkedAt,
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
@@ -1018,7 +1095,11 @@ class superuser_system_status_service
return $this->performHttpProbe(
$this->buildUrlWithQuery('https://v1.motorapi.dk', '/usage'),
['X-AUTH-TOKEN: ' . $secretKey],
'MotorAPI'
'MotorAPI',
null,
'GET',
null,
fn(array $httpResponse, string $label): array => $this->evaluateProviderQuotaProbeResponse($httpResponse, $label, 'motorapi')
);
}
@@ -1151,12 +1232,16 @@ class superuser_system_status_service
$apiKey = trim((string)($config['api_key']['parsed'] ?? ''));
return $this->performHttpProbe(
$this->buildUrlWithQuery('https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk', '/info/'),
$this->buildUrlWithQuery(licenseplaterecognizer::configuredApiBaseUrl(), '/info/'),
[
'Authorization: Token ' . $apiKey,
'Accept: application/json',
],
'License Plate Recognizer'
'License Plate Recognizer',
null,
'GET',
null,
fn(array $httpResponse, string $label): array => $this->evaluateLicensePlateRecognizerProbeResponse($httpResponse, $label)
);
}
@@ -1474,6 +1559,270 @@ class superuser_system_status_service
];
}
protected function evaluateLicensePlateRecognizerProbeResponse(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 $this->providerQuotaUnavailableProbeResult(
$httpResponse,
$label,
'licenseplaterecognizer',
'unreadable_payload',
[]
);
}
$usage = is_array($decoded['usage'] ?? null) ? $decoded['usage'] : [];
$callsUsedRaw = $usage['calls'] ?? null;
$quotaCallsRaw = $decoded['total_calls'] ?? null;
if (!is_numeric($callsUsedRaw) || !is_numeric($quotaCallsRaw) || (int)$quotaCallsRaw <= 0) {
$reason = !is_numeric($quotaCallsRaw) ? 'missing_limit' : 'missing_usage';
if (is_numeric($quotaCallsRaw) && (int)$quotaCallsRaw <= 0) {
$reason = 'invalid_limit';
}
return $this->providerQuotaUnavailableProbeResult(
$httpResponse,
$label,
'licenseplaterecognizer',
$reason,
$this->payloadKeyPaths($decoded)
);
}
$callsUsed = max(0, (int)$callsUsedRaw);
$quotaCalls = max(1, (int)$quotaCallsRaw);
$callsRemaining = max(0, $quotaCalls - $callsUsed);
$usagePercent = round(($callsUsed / $quotaCalls) * 100, 2);
$usagePayload = [
'provider' => 'licenseplaterecognizer',
'calls_used' => $callsUsed,
'quota_calls' => $quotaCalls,
'calls_remaining' => $callsRemaining,
'usage_percent' => $usagePercent,
'version' => trim((string)($decoded['version'] ?? '')),
];
$reasonParams = [
'label' => $label,
'used' => (string)$callsUsed,
'quota' => (string)$quotaCalls,
'remaining' => (string)$callsRemaining,
'percent' => number_format($usagePercent, 1, '.', ''),
];
if ($callsRemaining <= 0 || $usagePercent >= 100.0) {
return [
'status' => 'down',
'status_reason' => $label . ' quota is exhausted.',
'status_reason_key' => 'licenseplaterecognizer_quota_exhausted',
'status_reason_params' => $reasonParams,
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
'latency_ms' => $httpResponse['latency_ms'] ?? null,
'http_status' => $httpResponse['http_status'] ?? null,
'usage' => $usagePayload,
];
}
if ($usagePercent >= 90.0) {
return [
'status' => 'degraded',
'status_reason' => $label . ' quota usage is near the limit.',
'status_reason_key' => 'licenseplaterecognizer_quota_near_limit',
'status_reason_params' => $reasonParams,
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
'latency_ms' => $httpResponse['latency_ms'] ?? null,
'http_status' => $httpResponse['http_status'] ?? null,
'usage' => $usagePayload,
];
}
return [
'status' => 'ok',
'status_reason' => $label . ' usage and quota are available.',
'status_reason_key' => 'licenseplaterecognizer_usage_available',
'status_reason_params' => $reasonParams,
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
'latency_ms' => $httpResponse['latency_ms'] ?? null,
'http_status' => $httpResponse['http_status'] ?? null,
'usage' => $usagePayload,
];
}
protected function evaluateProviderQuotaProbeResponse(array $httpResponse, string $label, string $moduleKey): 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 $this->providerQuotaUnavailableProbeResult(
$httpResponse,
$label,
$moduleKey,
'unreadable_payload',
[]
);
}
$used = $this->findFirstNumericPayloadValue($decoded, ['used', 'usage', 'calls', 'messages_used', 'used_messages', 'sent', 'total_used']);
$limit = $this->findFirstNumericPayloadValue($decoded, ['limit', 'quota', 'total', 'total_calls', 'messages_limit', 'max', 'allowed']);
$remaining = $this->findFirstNumericPayloadValue($decoded, ['remaining', 'left', 'available', 'calls_remaining', 'messages_remaining']);
if ($used === null && $limit !== null && $remaining !== null) {
$used = max(0.0, $limit - $remaining);
}
if ($remaining === null && $used !== null && $limit !== null) {
$remaining = max(0.0, $limit - $used);
}
if ($limit === null) {
return $this->providerQuotaUnavailableProbeResult(
$httpResponse,
$label,
$moduleKey,
'missing_limit',
$this->payloadKeyPaths($decoded)
);
}
if ($limit <= 0) {
return $this->providerQuotaUnavailableProbeResult(
$httpResponse,
$label,
$moduleKey,
'invalid_limit',
$this->payloadKeyPaths($decoded)
);
}
if ($used === null) {
return $this->providerQuotaUnavailableProbeResult(
$httpResponse,
$label,
$moduleKey,
'missing_usage',
$this->payloadKeyPaths($decoded)
);
}
$usagePercent = round(($used / $limit) * 100, 2);
$usagePayload = [
'provider' => $moduleKey,
'calls_used' => $used,
'quota_calls' => $limit,
'calls_remaining' => $remaining,
'usage_percent' => $usagePercent,
];
if (($remaining !== null && $remaining <= 0) || $usagePercent >= 100.0) {
return [
'status' => 'down',
'status_reason' => $label . ' quota is exhausted.',
'status_reason_key' => 'provider_quota_exhausted',
'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')],
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
'latency_ms' => $httpResponse['latency_ms'] ?? null,
'http_status' => $httpResponse['http_status'] ?? null,
'usage' => $usagePayload,
];
}
if ($usagePercent >= 90.0) {
return [
'status' => 'degraded',
'status_reason' => $label . ' quota usage is near the limit.',
'status_reason_key' => 'provider_quota_near_limit',
'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')],
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
'latency_ms' => $httpResponse['latency_ms'] ?? null,
'http_status' => $httpResponse['http_status'] ?? null,
'usage' => $usagePayload,
];
}
return [
'status' => 'ok',
'status_reason' => $label . ' quota usage is available.',
'status_reason_key' => 'provider_quota_available',
'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')],
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
'latency_ms' => $httpResponse['latency_ms'] ?? null,
'http_status' => $httpResponse['http_status'] ?? null,
'usage' => $usagePayload,
];
}
protected function providerQuotaUnavailableProbeResult(array $httpResponse, string $label, string $moduleKey, string $reason, array $detectedKeys = []): array
{
$usagePayload = [
'provider' => $moduleKey,
'usage_available' => false,
'status' => 'unknown',
'calls_used' => null,
'quota_calls' => null,
'calls_remaining' => null,
'usage_percent' => null,
'unavailable_reason' => $reason,
'detected_keys' => $detectedKeys,
];
return [
'status' => 'degraded',
'status_reason' => $label . ' responded, but quota usage could not be read.',
'status_reason_key' => 'provider_quota_unavailable',
'status_reason_params' => [
'label' => $label,
'reason' => $reason,
'detected_keys' => implode(', ', array_slice($detectedKeys, 0, 12)),
],
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
'latency_ms' => $httpResponse['latency_ms'] ?? null,
'http_status' => $httpResponse['http_status'] ?? null,
'usage' => $usagePayload,
];
}
protected function findFirstNumericPayloadValue(array $payload, array $keys): ?float
{
foreach ($payload as $key => $value) {
$normalizedKey = strtolower((string)$key);
if (in_array($normalizedKey, $keys, true) && is_numeric($value)) {
return (float)$value;
}
if (is_array($value)) {
$nested = $this->findFirstNumericPayloadValue($value, $keys);
if ($nested !== null) {
return $nested;
}
}
}
return null;
}
protected function payloadKeyPaths(array $payload, string $prefix = ''): array
{
$paths = [];
foreach ($payload as $key => $value) {
$path = $prefix === '' ? (string)$key : $prefix . '.' . (string)$key;
$paths[] = $path;
if (is_array($value)) {
array_push($paths, ...$this->payloadKeyPaths($value, $path));
}
}
return array_values(array_unique($paths));
}
protected function evaluateRecaptchaProbeResponse(array $httpResponse, string $label): array
{
$classified = $this->classifyHttpProbeResult($httpResponse, $label);
@@ -1539,6 +1888,11 @@ class superuser_system_status_service
new backup_store();
}
protected function backupHealthSummary(): array
{
return (new backup_store())->healthSummary();
}
protected function bootstrapSelfserveSchema(): void
{
selfserve_schema_bootstrap::ensureTables();
@@ -6,9 +6,8 @@ use Throwable;
class system_search_cache
{
public const PREFIX = 'system_search:v1:';
public const PREFIX = 'system_search:v2:';
public const QUERY_PREFIX = self::PREFIX . 'query:';
public const INTENT_PREFIX = self::PREFIX . 'intent:';
public const DIRTY_TABLES_KEY = self::PREFIX . 'dirty_tables';
public const REBUILD_REQUEST_KEY = self::PREFIX . 'rebuild_request';
public const TABLE_VERSION_PREFIX = self::PREFIX . 'table_version:';
@@ -40,24 +39,6 @@ class system_search_cache
self::redisSetEx(self::QUERY_PREFIX . $hash, json_encode($payload, JSON_UNESCAPED_UNICODE), $ttlSeconds);
}
public static function getIntent(string $hash): ?array
{
$raw = self::redisGet(self::INTENT_PREFIX . $hash);
if ($raw === null) {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return null;
}
return $decoded;
}
public static function setIntent(string $hash, array $payload, int $ttlSeconds = 3600): void
{
self::redisSetEx(self::INTENT_PREFIX . $hash, json_encode($payload, JSON_UNESCAPED_UNICODE), $ttlSeconds);
}
public static function clearAll(): void
{
self::clearPattern(self::PREFIX . '*');
@@ -68,11 +49,6 @@ class system_search_cache
self::clearPattern(self::QUERY_PREFIX . '*');
}
public static function clearIntentCaches(): void
{
self::clearPattern(self::INTENT_PREFIX . '*');
}
public static function markDirtyTable(string $table): void
{
$table = trim($table, " `\t\n\r\0\x0B");
@@ -499,6 +499,7 @@ class system_search_document_index
*/
private function buildCustomerDiscountDocuments(): array
{
price_overrides_schema_bootstrap::ensureColumns();
$fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id';
$selectFields = [
'po.id AS entity_id',
@@ -506,6 +507,7 @@ class system_search_document_index
'po.is_category',
'po.product_or_category_id',
'po.percentage',
'po.fixed_price',
'u.customer_number',
'u.display_name',
...$this->joinTemporalSelectFields('price_overrides', 'po'),
@@ -554,6 +556,7 @@ class system_search_document_index
$row['search_text'] ?? null,
$row['product_or_category_id'] ?? null,
$row['percentage'] ?? null,
$row['fixed_price'] ?? null,
$row['user_id'] ?? null,
]),
$this->toIntOrNull($row['customer_number'] ?? null),
@@ -564,6 +567,7 @@ class system_search_document_index
'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
'product_or_category_id' => $row['product_or_category_id'] ?? null,
'percentage' => $this->toIntOrNull($row['percentage'] ?? null),
'fixed_price' => $this->toIntOrNull($row['fixed_price'] ?? null),
'economic_name' => $row['economic_name'] ?? null,
'economic_cvr' => $row['economic_cvr'] ?? null,
'is_category' => $row['is_category'] ?? null,
@@ -1,317 +0,0 @@
<?php
namespace classes;
use Exception;
use interfaces\system_search_intent_parser_i;
use Throwable;
class system_search_openai_intent_parser implements system_search_intent_parser_i
{
private string $apiUrl = 'https://api.openai.com/v1/responses';
private string $model = 'gpt-4.1-mini';
private float $temperature = 0.1;
private int $timeoutSeconds = 10;
private int $maxAliases = 12;
private int $maxEntityHints = 8;
private int $maxAliasLength = 64;
private int $maxHintLength = 32;
private int $maxNormalizedQueryLength = 256;
private int $maxFallbackReasonLength = 160;
/**
* @var callable|null
*/
private $transport;
private ?bool $forcedEnabled;
private ?string $forcedApiKey;
public function __construct(?callable $transport = null, ?bool $forcedEnabled = null, ?string $forcedApiKey = null)
{
$this->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,
];
}
}
@@ -2,18 +2,18 @@
namespace classes;
use interfaces\system_search_intent_parser_i;
use Throwable;
class system_search_service
{
private system_search_intent_parser_i $intentParser;
private int $lowConfidenceResultThreshold = 5;
private int $lowConfidenceTopScoreThreshold = 60;
private int $defaultEntityFetchLimit = 200;
private int $defaultMaxResults = 50;
private int $maxExpandedTerms = 24;
private int $maxTermLength = 64;
private int $recencyScoreTolerance = 12;
private int $minimumEffectiveScore = 45;
private int $minimumExplicitTypeScore = 20;
private int $associationSeedScoreThreshold = 80;
private array $rankingBoostByType = [
'invoices' => 35,
'orders' => 35,
@@ -35,9 +35,8 @@ class system_search_service
private array $tableColumnsCache = [];
private array $customerContextCache = [];
public function __construct(?system_search_intent_parser_i $intentParser = null)
public function __construct()
{
$this->intentParser = $intentParser ?? new system_search_openai_intent_parser();
try {
system_search_economic_customer_index::ensureTable();
system_search_document_index::ensureTable();
@@ -59,18 +58,13 @@ class system_search_service
$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;
$maxResults = (int)($options['max_results'] ?? $this->defaultMaxResults);
if ($maxResults < 1) {
$maxResults = $this->defaultMaxResults;
}
if ($limit > 200) {
$limit = 200;
}
if ($offset < 0) {
$offset = 0;
if ($maxResults > $this->defaultMaxResults) {
$maxResults = $this->defaultMaxResults;
}
$allTypes = $this->allEntityTypes();
@@ -79,11 +73,12 @@ class system_search_service
$activeTypes = array_values(array_diff($activeTypes, $excludeTypes));
}
$activeTypes = array_values(array_intersect($activeTypes, $allowedTypes));
$terms = $this->buildExpandedTerms($this->tokenize($query));
$activeTypes = $this->selectSearchTypes($activeTypes, $includeTypes, $terms, $query);
$baseMeta = [
'query' => $query,
'limit' => $limit,
'offset' => $offset,
'max_results' => $maxResults,
'allowed_types' => $activeTypes,
'cache' => ['hit' => false],
];
@@ -94,7 +89,8 @@ class system_search_service
'grouped_results' => $this->groupResultsByType([]),
'meta' => [
...$baseMeta,
'total' => 0,
'returned' => 0,
'truncated' => false,
],
];
}
@@ -104,16 +100,14 @@ class system_search_service
'include' => $includeTypes,
'exclude' => $excludeTypes,
'active' => $activeTypes,
'limit' => $limit,
'offset' => $offset,
'max' => $maxResults,
'own' => $ownCustomerNumber,
'own_only' => $ownOnlyTypes,
'dept' => $allowedDepartmentIds,
'assoc' => $includeAssociations,
'dbg' => $debugIntent,
'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility),
'table_versions' => system_search_cache::tableVersionFingerprint($this->relevantSourceTables($activeTypes)),
'v' => 12,
'v' => 13,
], JSON_UNESCAPED_UNICODE));
$cached = system_search_cache::getQuery($queryCacheHash);
@@ -122,7 +116,6 @@ class system_search_service
return $cached;
}
$terms = $this->buildExpandedTerms($this->tokenize($query));
$entityBoost = [];
$initialResults = $this->executeLexicalSearch(
$activeTypes,
@@ -135,76 +128,23 @@ class system_search_service
$moduleConfigVisibility,
$allowedDepartmentIds
);
$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,
$allowedDepartmentIds
);
} else {
$intentMeta['status'] = 'fallback';
}
}
$initialResults = $this->filterRelevantResults($initialResults, $includeTypes);
if ($includeAssociations) {
$customerNumbers = [];
foreach ($initialResults as $result) {
if (!isset($result['customer_number'])) {
$customerNumber = $this->toIntOrNull($result['customer_number'] ?? null);
if ($customerNumber === null || $customerNumber <= 0) {
continue;
}
if ($result['entity_type'] !== 'customers' && !$intentAssociationHint) {
if (!$this->shouldExpandAssociationsFromResult($result, $includeTypes)) {
continue;
}
$customerNumbers[] = (int)$result['customer_number'];
$customerNumbers[] = $customerNumber;
}
$customerNumbers = array_values(array_unique(array_filter($customerNumbers)));
if (count($customerNumbers) > 15) {
$customerNumbers = array_slice($customerNumbers, 0, 15);
if (count($customerNumbers) > 5) {
$customerNumbers = array_slice($customerNumbers, 0, 5);
}
if (!empty($customerNumbers)) {
$associationTypes = array_values(array_intersect(
@@ -232,18 +172,21 @@ class system_search_service
}
$item['score'] = max((int)$item['score'], 35);
}
unset($item);
$associated = $this->filterRelevantResults($associated, $includeTypes);
$initialResults = $this->mergeResults($initialResults, $associated);
}
}
}
}
$initialResults = $this->filterRelevantResults($initialResults, $includeTypes);
$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);
$effectiveScoreA = $this->effectiveResultScore($a);
$effectiveScoreB = $this->effectiveResultScore($b);
$recencyA = $this->resultRecencyTimestamp($a);
$recencyB = $this->resultRecencyTimestamp($b);
$cancelledA = $this->isCancelledBookingResult($a);
@@ -272,20 +215,17 @@ class system_search_service
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);
$limited = array_slice($initialResults, 0, $maxResults);
$grouped = $this->groupResultsByType($limited);
$meta = [
...$baseMeta,
'total' => $total,
'returned' => count($limited),
'truncated' => count($initialResults) > count($limited),
];
if ($debugIntent) {
$meta['intent_parser'] = $intentMeta;
}
$payload = [
'results' => $paged,
'results' => $limited,
'grouped_results' => $grouped,
'meta' => $meta,
];
@@ -294,13 +234,127 @@ class system_search_service
return $payload;
}
protected function shouldInvokeIntentParser(array $results): bool
/**
* @param array<int, string> $activeTypes
* @param array<int, string> $includeTypes
* @param array<int, string> $terms
* @return array<int, string>
*/
private function selectSearchTypes(array $activeTypes, array $includeTypes, array $terms, string $query): array
{
if (count($results) < $this->lowConfidenceResultThreshold) {
return true;
if (empty($activeTypes) || !empty($includeTypes)) {
return $activeTypes;
}
$topScore = (int)($results[0]['score'] ?? 0);
return $topScore < $this->lowConfidenceTopScoreThreshold;
$selected = array_values(array_intersect($activeTypes, $this->defaultSearchEntityTypes()));
foreach ($activeTypes as $entityType) {
if (in_array($entityType, $selected, true)) {
continue;
}
if ($this->entityTypeMatchesQuery($entityType, $terms, $query)) {
$selected[] = $entityType;
}
}
return array_values(array_intersect($activeTypes, array_values(array_unique($selected))));
}
/**
* @return array<int, string>
*/
private function defaultSearchEntityTypes(): array
{
return [
'customers',
'users',
'employees',
'orders',
'order_bookings',
'bookings',
'bookings_new',
'invoices',
'vehicles',
'departments',
'products',
'objects',
];
}
/**
* @param array<int, string> $terms
*/
private function entityTypeMatchesQuery(string $entityType, array $terms, string $query): bool
{
$normalizedQuery = ' ' . trim(mb_strtolower($query)) . ' ';
if (trim($normalizedQuery) === '') {
return false;
}
$aliases = system_search_registry::taxonomyAliases()[$entityType] ?? [];
$human = str_replace('_', ' ', $entityType);
$aliases[] = $entityType;
$aliases[] = $human;
$aliases[] = rtrim($human, 's');
$aliases = array_values(array_unique(array_filter($aliases, static fn($alias) => is_string($alias) && trim($alias) !== '')));
foreach ($aliases as $alias) {
$aliasTerms = $this->tokenize($alias);
if (empty($aliasTerms)) {
continue;
}
if (count($aliasTerms) === 1 && in_array($aliasTerms[0], $terms, true)) {
return true;
}
if (count($aliasTerms) > 1 && empty(array_diff($aliasTerms, $terms))) {
return true;
}
$aliasText = trim(mb_strtolower($alias));
if ($aliasText !== '' && str_contains($normalizedQuery, ' ' . $aliasText . ' ')) {
return true;
}
}
return false;
}
/**
* @param array<int, array<string, mixed>> $results
* @param array<int, string> $includeTypes
* @return array<int, array<string, mixed>>
*/
private function filterRelevantResults(array $results, array $includeTypes): array
{
$explicitTypes = array_flip($includeTypes);
$filtered = [];
foreach ($results as $result) {
$entityType = (string)($result['entity_type'] ?? '');
$score = (int)($result['score'] ?? 0);
if ($score <= 0) {
continue;
}
if (isset($explicitTypes[$entityType])) {
if ($score >= $this->minimumExplicitTypeScore) {
$filtered[] = $result;
}
continue;
}
if ($this->effectiveResultScore($result) >= $this->minimumEffectiveScore) {
$filtered[] = $result;
}
}
return $filtered;
}
private function shouldExpandAssociationsFromResult(array $result, array $includeTypes): bool
{
$entityType = trim(mb_strtolower((string)($result['entity_type'] ?? '')));
if (!in_array($entityType, ['customers', 'users'], true)) {
return false;
}
if (!empty($includeTypes) && !in_array($entityType, $includeTypes, true)) {
return false;
}
return $this->effectiveResultScore($result) >= $this->associationSeedScoreThreshold;
}
/**
@@ -347,20 +401,6 @@ class system_search_service
$allowedDepartmentIds,
$forcedCustomerNumbers
);
if (empty($rows)) {
$rows = $this->searchEntity(
$entityType,
$terms,
$boost,
$ownOnly,
$ownCustomerNumber,
$permissionsCatalogAll,
$permissionsCatalogOwn,
$moduleConfigVisibility,
$allowedDepartmentIds,
$forcedCustomerNumbers
);
}
} else {
$rows = $this->searchEntity(
$entityType,
@@ -1086,6 +1126,7 @@ class system_search_service
private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
{
price_overrides_schema_bootstrap::ensureColumns();
$customerFilter = '';
if (!empty($forcedCustomerNumbers)) {
$customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')';
@@ -1100,11 +1141,12 @@ class system_search_service
'po.is_category',
'po.product_or_category_id',
'po.percentage',
'po.fixed_price',
'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'];
$searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'po.fixed_price', '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';
@@ -1166,6 +1208,7 @@ class system_search_service
'search_text',
'product_or_category_id',
'percentage',
'fixed_price',
'user_id',
], $terms) + $entityBoost,
'payload' => $this->augmentPayloadWithTemporal([
@@ -1173,6 +1216,7 @@ class system_search_service
'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,
'fixed_price' => isset($row['fixed_price']) ? (int)$row['fixed_price'] : null,
'economic_name' => $row['economic_name'] ?? null,
'economic_cvr' => $row['economic_cvr'] ?? null,
], $row),
@@ -2643,6 +2687,11 @@ class system_search_service
return (int)($this->rankingPenaltyByType[$entityType] ?? 0);
}
private function effectiveResultScore(array $result): int
{
return (int)($result['score'] ?? 0) + $this->rankingBoost($result) - $this->rankingPenalty($result);
}
private function boolishTrue(mixed $value): bool
{
if (is_bool($value)) {
@@ -2702,7 +2751,7 @@ class system_search_service
return false;
}
// Explicit identifiers (order numbers, customer numbers, emails, etc.) imply exact intent.
// Explicit identifiers (order numbers, customer numbers, emails, etc.) imply exact matches.
if ($this->queryHasExplicitIdentifier($normalized)) {
return false;
}
@@ -2725,68 +2774,6 @@ class system_search_service
]);
}
/**
* @param array<int, string> $boostedTypes
* @param array<string, array<int, string>> $taxonomy
* @return array<int, string>
*/
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<int, string> $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 === '') {
@@ -2981,22 +2968,6 @@ class system_search_service
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 = [];
+14
View File
@@ -72,6 +72,8 @@ class virkdata implements virkdata_i
self::requireModuleEnabled();
// Validate the secret key
self::requireValidSecretKey();
// VirkData has a monthly limit config, but defaults to observe mode until explicitly switched to block.
$this->reserveCompanySearchQuota($search, $endpoint, $method);
// Send the request
$response = match ($method) {
'GET' => self::sendGetRequest($search, $endpoint, $data),
@@ -128,6 +130,18 @@ class virkdata implements virkdata_i
}
}
/**
* @throws Exception
*/
private function reserveCompanySearchQuota(string $search, string $endpoint, string $method): void
{
(new module_usage_service())->reserveOrFail('virkdata', 'company_search_calls', 1, [
'search' => $search,
'endpoint' => $endpoint,
'method' => strtoupper($method),
]);
}
/**
* @throws Exception
*/
@@ -291,6 +291,12 @@ class xlvask_automation_service
return array_reduce($items, fn(int $total, array $item): int => $total + ((int)($item['price'] ?? 0) * (int)($item['quantity'] ?? 0)), 0);
}
public static function isExactItemMatchForAutomation(array $usageItems, array $orderItems): bool
{
return self::itemSignaturePartsForAutomation($usageItems) === self::itemSignaturePartsForAutomation($orderItems)
&& self::itemsTotalForAutomation($usageItems) === self::itemsTotalForAutomation($orderItems);
}
public static function productOverlapForAutomation(array $usageItems, array $orderItems): float
{
$usageBag = self::productBagForAutomation($usageItems);
@@ -600,18 +606,52 @@ class xlvask_automation_service
if ($action === self::ACTION_ATTACH) {
return $xlvask->config->automatic_order_attachment_enabled->isTrue()
&& $confidence >= self::AUTO_ATTACH_CONFIDENCE;
&& $confidence >= self::AUTO_ATTACH_CONFIDENCE
&& $this->isExactAttachSuggestionForContext($suggestion, $context);
}
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;
}
return false;
}
private function isExactAttachSuggestionForContext(array $suggestion, array $context): bool
{
if ((string)($suggestion['action'] ?? '') !== self::ACTION_ATTACH) {
return false;
}
$matchedOrderId = (int)($suggestion['matched_order_id'] ?? 0);
$candidateOrder = $this->candidateOrderFromSuggestion($suggestion);
if ($matchedOrderId < 1 || !is_array($candidateOrder) || (int)($candidateOrder['id'] ?? 0) !== $matchedOrderId) {
return false;
}
$usageItems = $context['items'] ?? [];
$orderItems = $candidateOrder['order_items'] ?? [];
return is_array($usageItems)
&& is_array($orderItems)
&& self::isExactItemMatchForAutomation($usageItems, $orderItems);
}
private function candidateOrderFromSuggestion(array $suggestion): ?array
{
$candidateOrder = $suggestion['candidate_order'] ?? null;
if (is_array($candidateOrder)) {
return $candidateOrder;
}
$candidateOrderJson = $suggestion['candidate_order_json'] ?? null;
if (!is_string($candidateOrderJson) || trim($candidateOrderJson) === '') {
return null;
}
$decoded = json_decode($candidateOrderJson, true);
return is_array($decoded) ? $decoded : null;
}
private function executeSuggestion(array $suggestion, array $context, ?int $actorId, bool $automatic): array
{
try {
@@ -646,7 +686,7 @@ class xlvask_automation_service
$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.');
$this->persistFeedback($context, $action, 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, $this->automaticFeedbackReason($suggestion, $context));
}
return $this->formatSuggestion($latest);
@@ -660,6 +700,15 @@ class xlvask_automation_service
}
}
private function automaticFeedbackReason(array $suggestion, array $context): string
{
if ($this->isExactAttachSuggestionForContext($suggestion, $context)) {
return 'Automatisk accepteret: Prisoverensstemmelse.';
}
return 'Automatisk accepteret.';
}
private function createOrderFromContext(array $context): orders_o
{
$orderData = $context['proposed_order'];
@@ -1246,19 +1295,20 @@ class xlvask_automation_service
{
global $db;
(new xlvask_usage_logs_o())->structure();
$startTimeExpression = "STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')";
$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))) . "'";
$where[] = "{$startTimeExpression} >= '" . $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'))) . "'";
$where[] = "{$startTimeExpression} >= '" . $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))) . "'";
$where[] = "{$startTimeExpression} <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
}
$limit = max(1, min(500, $limit));
+4
View File
@@ -102,6 +102,10 @@ if ($args[1] === 'run') {
echo "[" . date('Y-m-d H:i:s') . "][CRON] Running the cron script\n";
require_once 'cron/Cron.php';
break;
case 'cron-worker':
echo "[" . date('Y-m-d H:i:s') . "][CRON_WORKER] Starting cron worker\n";
(new \classes\cron_worker())->run();
break;
default:
echo "Invalid script name";
break;
+4
View File
@@ -1,6 +1,10 @@
{
"scripts": {
"test": "composer test:unit",
"analyse": "vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress",
"static": "@analyse",
"rector:dry-run": "@php -d error_reporting=0 -d display_errors=0 -d log_errors=0 vendor/bin/rector process --dry-run --config rector.php",
"rector:fix": "@php -d error_reporting=0 -d display_errors=0 -d log_errors=0 vendor/bin/rector process --config rector.php",
"test:unit": "vendor/bin/pest --testsuite=Unit --colors=always",
"test:integration": "vendor/bin/pest --testsuite=Integration --colors=always",
"test:api": [
+5 -2
View File
@@ -39,8 +39,11 @@ $htaccess = "
# php -- END cPanel-generated handler, do not edit
";
// Write the .htaccess file (This is not really ideal, but it works for now. This is because the .htaccess file is changed by cPanel, and it's not going to be kept there anyway.)
file_put_contents(__DIR__ . '/.htaccess', $htaccess);
// Keep the compatibility rewrite file stable without rewriting it every minute.
$htaccessPath = __DIR__ . '/.htaccess';
if (!is_file($htaccessPath) || file_get_contents($htaccessPath) !== $htaccess) {
file_put_contents($htaccessPath, $htaccess);
}
// Log the time of the cron job
file_put_contents(__DIR__ . '/cron.log', date('Y-m-d H:i:s', $now) . ' - Cron job executed in ' . (time() - $now) . ' seconds ( ' . (microtime(true) - $now) . 'ms )' . PHP_EOL, FILE_APPEND);
+340 -23
View File
@@ -8,6 +8,7 @@ use classes\invoice_period_flag_service;
use classes\coolify_manager;
use classes\replication_manager;
use classes\redis;
use classes\selfserve;
use classes\system_search_cache;
use classes\system_search_document_index;
use classes\system_search_economic_customer_index;
@@ -19,6 +20,8 @@ use classes\slack as Slack;
use classes\email as Email;
use classes\gatewayapi as GatewayAPI;
use dynamicimages\images\machine_1;
use modules\selfserve\config\selfserve_dynamic_image_size_c;
use modules\selfserve\selfserve_c;
use goals\classes\goals_criteria;
use goals\services\goals_progress_alert_renderer;
use goals\helpers\goals_criteria_progress_alert_destination as Dest;
@@ -33,9 +36,18 @@ use objects\users_o;
use routes\moduleWeatherAPIRoute;
require_once __DIR__ . '/../classes/economic_transfer_executor.php';
const DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH = 1600;
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
require_once __DIR__ . '/../classes/backup_schema_bootstrap.php';
require_once __DIR__ . '/../classes/backup_store.php';
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
require_once __DIR__ . '/../classes/cron_schedule.php';
require_once __DIR__ . '/../classes/cron_task_definition.php';
require_once __DIR__ . '/../classes/cron_task_registry.php';
require_once __DIR__ . '/../classes/cron_schema_bootstrap.php';
require_once __DIR__ . '/../classes/cron_scheduler.php';
if (!defined('WD')) {
exit;
@@ -528,12 +540,34 @@ function backup(): void
{
try {
$backup = new backup_store();
$backup->createBackup();
$backup->enqueueCreateBackup(null, null, null, 'scheduled');
} catch (Exception $e) {
warn('Backup failed: ' . $e->getMessage());
}
}
function processBackupJobs(): array
{
try {
return (new backup_store())->processPendingJobs(3);
} catch (Exception $e) {
warn('Backup job processing failed: ' . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
function pruneBackupRetention(): array
{
try {
$backup = new backup_store();
$queued = $backup->enqueueRetentionPrune();
return $backup->runJobById((int)$queued['job_id']);
} catch (Exception $e) {
warn('Backup retention prune failed: ' . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
function SyncUserEconomicCustomerDiscounts(): void
{
$users_o = new users_o();
@@ -648,7 +682,6 @@ function SystemSearchCacheMaintenanceCron(): void
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)) {
@@ -930,6 +963,7 @@ function buildDynamicImageCacheKey(array $variant): string
'current_step' => (int)($variant['current_step'] ?? 0),
'only_current_step' => (bool)($variant['only_current_step'] ?? false),
'vehicle_type' => $variant['vehicle_type'] ?? null,
'dynamic_image_size' => getSelfServeDynamicImageSizeModeForCron(),
];
$json = json_encode($cacheParams);
@@ -962,16 +996,11 @@ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $cu
$image->current_step = max(0, $currentStep);
$image->only_generate_current_step = $onlyCurrentStep;
$image->setup();
if (getSelfServeDynamicImageSizeModeForCron() === selfserve_dynamic_image_size_c::SIZE_RELEVANT) {
$image->resizeToMaxWidth(DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH);
}
$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;
return $image->exportBinary('png');
} catch (Throwable $e) {
warn('PreRenderDynamicImagesCron: render failed for dynamic_image_id=' . $dynamicImageId . ': ' . $e->getMessage());
return null;
@@ -985,6 +1014,19 @@ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $cu
}
}
function getSelfServeDynamicImageSizeModeForCron(): string
{
try {
$mode = (string)(new selfserve_c())->dynamic_image_size->getVariableValue();
} catch (Throwable) {
return selfserve_dynamic_image_size_c::SIZE_ORIGINAL;
}
return in_array($mode, [selfserve_dynamic_image_size_c::SIZE_ORIGINAL, selfserve_dynamic_image_size_c::SIZE_RELEVANT], true)
? $mode
: selfserve_dynamic_image_size_c::SIZE_ORIGINAL;
}
/**
* @param mixed $value
* @return array<int|string>
@@ -1457,17 +1499,292 @@ function goalsProgressAlertDue(goals_criteria $criteria, DateTimeImmutable $nowU
}
}
foreach ( $cron_tasks as $task => $data ) {
$lastRun = redis->get_last_crond_run($task) === null ? 0 : redis->get_last_crond_run($task);
$nextRun = $lastRun + $data['interval'];
$cron_tasks[$task]['last_run'] = $lastRun;
$cron_tasks[$task]['next_run'] = $nextRun;
if ($nextRun <= time()) {
echo "[" . date('Y-m-d H:i:s') . "][CRON] Running task: " . $task . "\n";
$data['function']();
redis->set_last_crond_run($task, time());
echo "[" . date('Y-m-d H:i:s') . "][CRON] Completed task: " . $task . "\n";
} 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)';
function SelfserveOpeningRelayActivationCron(): array
{
global $db;
if (!($db instanceof \classes\db)) {
warn('SelfserveOpeningRelayActivationCron skipped: database connection is unavailable.');
return [
'skipped' => true,
'reason' => 'database_unavailable',
];
}
$now = new DateTimeImmutable('now', new DateTimeZone('Europe/Copenhagen'));
$candidates = selfserveOpeningCleanerRelayActivationCandidates($now);
$summary = [
'checked_departments' => count($candidates),
'activated_departments' => 0,
'skipped_departments' => 0,
'failed_departments' => 0,
'activated_relays' => 0,
'skipped_relays' => 0,
'failed_relays' => 0,
];
foreach ($candidates as $candidate) {
$departmentId = (int)($candidate['department_id'] ?? 0);
$opensAt = (string)($candidate['opens_at'] ?? '');
if ($departmentId <= 0 || $opensAt === '') {
$summary['skipped_departments']++;
continue;
}
$cacheKey = selfserveOpeningCleanerRelayActivationKey($departmentId, $opensAt, $now);
if (selfserveOpeningCleanerRelayActivationAlreadyHandled($cacheKey)) {
$summary['skipped_departments']++;
continue;
}
$departmentSummary = selfserveActivateOpeningCleanerRelaysForDepartment($departmentId);
$summary['activated_relays'] += (int)$departmentSummary['activated'];
$summary['skipped_relays'] += (int)$departmentSummary['skipped'];
$summary['failed_relays'] += (int)$departmentSummary['failed'];
if ((int)$departmentSummary['failed'] > 0) {
$summary['failed_departments']++;
continue;
}
selfserveMarkOpeningCleanerRelayActivationHandled($cacheKey);
if ((int)$departmentSummary['activated'] > 0) {
$summary['activated_departments']++;
} else {
$summary['skipped_departments']++;
}
}
echo "[" . date('Y-m-d H:i:s') . "][CRON] SelfserveOpeningRelayActivationCron: "
. $summary['activated_relays'] . " cleaner relays activated across "
. $summary['activated_departments'] . " departments.\n";
return $summary;
}
function selfserveOpeningCleanerRelayActivationCandidates(DateTimeImmutable $now): array
{
global $db;
$weekday = strtolower($now->format('l'));
$allowedWeekdays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
if (!in_array($weekday, $allowedWeekdays, true)) {
return [];
}
$startColumn = $weekday . '_start';
$endColumn = $weekday . '_end';
$sql = "
SELECT
oh.department AS department_id,
TIME_FORMAT(oh.`$startColumn`, '%H:%i:%s') AS opens_at,
TIME_FORMAT(oh.`$endColumn`, '%H:%i:%s') AS closes_at
FROM department_time_bookings_opening_hours oh
INNER JOIN department_variables dv ON dv.department_id = oh.department
INNER JOIN department_lanes dl ON dl.department = oh.department
WHERE dv.variable = 'selfserve_enabled'
AND LOWER(TRIM(COALESCE(dv.value, ''))) IN ('true', '1', 'yes', 'on')
AND oh.`$startColumn` IS NOT NULL
AND oh.`$endColumn` IS NOT NULL
AND dl.deleted_at IS NULL
AND COALESCE(dl.selfserve_enabled, 1) = 1
AND dl.relay_machine_cleaner_id IS NOT NULL
AND TRIM(dl.relay_machine_cleaner_id) <> ''
GROUP BY oh.department, oh.`$startColumn`, oh.`$endColumn`
";
$result = $db->query($sql);
if (!$result instanceof mysqli_result) {
return [];
}
return array_values(array_filter(
$db->fetch_all($result),
static fn(array $row): bool => selfserveOpeningCleanerRelayWindowActive(
$now,
(string)($row['opens_at'] ?? ''),
(string)($row['closes_at'] ?? '')
)
));
}
function selfserveOpeningCleanerRelayWindowActive(
DateTimeImmutable $now,
?string $opensAt,
?string $closesAt
): bool {
$opensAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($opensAt);
$closesAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($closesAt);
if ($opensAtSeconds === null || $closesAtSeconds === null || $opensAtSeconds === $closesAtSeconds) {
return false;
}
$nowSeconds = ((int)$now->format('G') * 3600)
+ ((int)$now->format('i') * 60)
+ (int)$now->format('s');
if ($opensAtSeconds < $closesAtSeconds) {
return $nowSeconds >= $opensAtSeconds && $nowSeconds < $closesAtSeconds;
}
return $nowSeconds >= $opensAtSeconds;
}
function selfserveOpeningCleanerRelayTimeToSeconds(?string $time): ?int
{
$time = trim((string)$time);
if ($time === '') {
return null;
}
if (!preg_match('/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $time, $matches)) {
return null;
}
$hours = (int)$matches[1];
$minutes = (int)$matches[2];
$seconds = isset($matches[3]) ? (int)$matches[3] : 0;
if ($hours > 23 || $minutes > 59 || $seconds > 59) {
return null;
}
return ($hours * 3600) + ($minutes * 60) + $seconds;
}
function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId): array
{
$summary = [
'activated' => 0,
'skipped' => 0,
'failed' => 0,
];
$selfserve = new selfserve();
$lanes = (new department_lanes_o())->getDepartmentLanes($departmentId);
foreach ($lanes as $departmentLane) {
if (!($departmentLane instanceof department_lanes_o)) {
$summary['skipped']++;
continue;
}
try {
if (!$departmentLane->isSelfServeEnabled()) {
$summary['skipped']++;
continue;
}
} catch (Throwable) {
$summary['skipped']++;
continue;
}
if (!selfserveLaneHasConfiguredCleanerRelay($departmentLane)) {
$summary['skipped']++;
continue;
}
$laneId = (int)$departmentLane->id;
if ($laneId <= 0) {
$summary['skipped']++;
continue;
}
try {
$lane = $selfserve->lane($laneId);
if (!selfserveLaneHasConfiguredCleanerRelay($lane->department_lane)) {
$summary['skipped']++;
continue;
}
$lane->setMachineCleanerRelayStatusHard(true);
$summary['activated']++;
} catch (Throwable $throwable) {
$summary['failed']++;
warn(
'SelfserveOpeningRelayActivationCron failed for department '
. $departmentId
. ', lane '
. $laneId
. ': '
. $throwable->getMessage()
);
}
}
return $summary;
}
function selfserveLaneHasConfiguredCleanerRelay(?object $departmentLane): bool
{
if (
$departmentLane === null
|| !isset($departmentLane->relay_machine_cleaner_id)
|| !is_object($departmentLane->relay_machine_cleaner_id)
|| !method_exists($departmentLane->relay_machine_cleaner_id, 'value')
) {
return false;
}
try {
$relayId = trim((string)$departmentLane->relay_machine_cleaner_id->value());
} catch (Throwable) {
return false;
}
return $relayId !== '' && $relayId !== '0' && strtolower($relayId) !== 'null';
}
function selfserveOpeningCleanerRelayActivationKey(
int $departmentId,
string $opensAt,
DateTimeImmutable $now
): string {
$normalizedOpensAt = preg_replace('/[^0-9]/', '', $opensAt) ?: 'unknown';
return 'selfserve:opening-cleaner-relays:'
. $now->format('Y-m-d')
. ':'
. $departmentId
. ':'
. $normalizedOpensAt;
}
function selfserveOpeningCleanerRelayActivationAlreadyHandled(string $cacheKey): bool
{
if (!defined('redis')) {
return false;
}
try {
return redis->exists($cacheKey);
} catch (Throwable) {
return false;
}
}
function selfserveMarkOpeningCleanerRelayActivationHandled(string $cacheKey): void
{
if (!defined('redis')) {
return;
}
try {
redis->setEx($cacheKey, '1', 36 * 3600);
} catch (Throwable) {
// Redis idempotency should not block relay activation.
}
}
if (defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY') && CRON_LOAD_LEGACY_FUNCTIONS_ONLY) {
return;
}
try {
$response_cron = (new \classes\cron_scheduler())->runDue('automatic');
} catch (Throwable $throwable) {
warn('Cron scheduler failed: ' . $throwable->getMessage());
$response_cron = [
'ran' => [],
'count' => 0,
'error' => $throwable->getMessage(),
];
}
@@ -12,6 +12,31 @@ interface licenseplaterecognizer_i extends universal_module_i
* @return array An array containing the plate number and other relevant information.
*/
public function licenseplaterecognizer(string $base64_image): array;
/**
* Get the plate number from raw uploaded image bytes.
* @param string $image_data Raw uploaded image bytes.
* @param string $mime_type The image MIME type.
* @return array An array containing the plate number and other relevant information.
*/
public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array;
/**
* Get the plate number from raw uploaded image bytes without building an exact-result cache key.
* @param string $image_data Raw uploaded image bytes.
* @param string $mime_type The image MIME type.
* @return array An array containing the plate number and other relevant information.
*/
public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array;
/**
* Get the plate number from a PHP upload temp file without copying it into memory.
* @param string $image_path The uploaded image temp-file path.
* @param string $mime_type The image MIME type.
* @return array An array containing the plate number and other relevant information.
*/
public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array;
/**
* Get usage information about the license plate recognizer module.
* @returns licenseplaterecognizer_info An object containing usage statistics and information.
@@ -31,7 +31,7 @@ interface minio_backups_i
* Create a backup, upload it to the Minio bucket and return the UUID
* @return string The UUID of the backup
*/
public function createBackup(): string;
public function createBackup($backup_name = null, $backup_description = null): string;
/**
* Create the metadata for a backup
@@ -1,26 +0,0 @@
<?php
namespace interfaces;
interface system_search_intent_parser_i
{
/**
* Parse a natural-language search query into structured search hints.
*
* @param string $query The raw user query.
* @param array $allowedEntityTypes Entity types the caller is allowed to search.
* @param array $taxonomy Public taxonomy/aliases to improve intent parsing.
* @return array{
* success: bool,
* normalized_query: string,
* aliases: array<int, string>,
* entity_hints: array<int, string>,
* confidence: float,
* association_hint: bool,
* fallback_reason: string|null,
* source: string
* }
*/
public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array;
}
@@ -2,8 +2,22 @@
namespace backups;
require_once WD . '/modules/backups/config/backups_enabled_c.php';
require_once WD . '/modules/backups/config/backups_retention_recent_hours_c.php';
require_once WD . '/modules/backups/config/backups_retention_daily_days_c.php';
require_once WD . '/modules/backups/config/backups_retention_weekly_weeks_c.php';
require_once WD . '/modules/backups/config/backups_retention_monthly_months_c.php';
require_once WD . '/modules/backups/config/backups_app_data_enabled_c.php';
require_once WD . '/modules/backups/config/backups_verification_required_c.php';
require_once WD . '/modules/backups/config/backups_restore_enabled_c.php';
use backups\config\backups_app_data_enabled_c;
use backups\config\backups_enabled_c;
use backups\config\backups_restore_enabled_c;
use backups\config\backups_retention_daily_days_c;
use backups\config\backups_retention_monthly_months_c;
use backups\config\backups_retention_recent_hours_c;
use backups\config\backups_retention_weekly_weeks_c;
use backups\config\backups_verification_required_c;
use traits\module_config_t;
class backups_c
@@ -15,6 +29,13 @@ class backups_c
* @var backups_enabled_c
*/
public backups_enabled_c $enabled;
public backups_retention_recent_hours_c $retention_recent_hours;
public backups_retention_daily_days_c $retention_daily_days;
public backups_retention_weekly_weeks_c $retention_weekly_weeks;
public backups_retention_monthly_months_c $retention_monthly_months;
public backups_app_data_enabled_c $app_data_enabled;
public backups_verification_required_c $verification_required;
public backups_restore_enabled_c $restore_enabled;
public function __construct()
@@ -22,8 +43,22 @@ class backups_c
$this->setupConfig('Backups');
$this->allowUpdate([
backups_enabled_c::class,
backups_retention_recent_hours_c::class,
backups_retention_daily_days_c::class,
backups_retention_weekly_weeks_c::class,
backups_retention_monthly_months_c::class,
backups_app_data_enabled_c::class,
backups_verification_required_c::class,
backups_restore_enabled_c::class,
]);
$this->enabled = new backups_enabled_c();
$this->retention_recent_hours = new backups_retention_recent_hours_c();
$this->retention_daily_days = new backups_retention_daily_days_c();
$this->retention_weekly_weeks = new backups_retention_weekly_weeks_c();
$this->retention_monthly_months = new backups_retention_monthly_months_c();
$this->app_data_enabled = new backups_app_data_enabled_c();
$this->verification_required = new backups_verification_required_c();
$this->restore_enabled = new backups_restore_enabled_c();
}
}
@@ -0,0 +1,29 @@
<?php
namespace backups\config;
use Exception;
use traits\module_config_variable;
class backups_app_data_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Backups',
'app_data_enabled',
'bool',
true,
null,
'Whether app-owned object storage buckets are included in backups.',
'1',
false,
'true'
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace backups\config;
use Exception;
use traits\module_config_variable;
class backups_restore_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Backups',
'restore_enabled',
'bool',
true,
null,
'Whether direct production restore execution is enabled for superusers.',
'0',
false,
'false'
);
}
}

Some files were not shown because too many files have changed in this diff Show More