Compare commits

...
Author SHA1 Message Date
Jeppe B 0d2e906763 Guard wash subscription distribution SQL 2026-07-09 10:22:33 +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
Jeppe Bundgaard 6d739cfebc Add configuration for GitHub self-hosted runners 2026-06-02 11:25:00 +02:00
Jeppe Bundgaard 20071166f8 Switch CI to self-hosted runners
Updated all GitHub Actions workflows to use self-hosted runners instead of `ubuntu-latest`. This change ensures better control over the CI environment and aligns with internal infrastructure requirements.
2026-06-02 10:36:22 +02:00
Jeppe Bundgaard c23168afc5 Merge remote-tracking branch 'origin/master' 2026-06-02 10:29:25 +02:00
Jeppe Bundgaard 72704b7806 Add "Get My Active Self-Serve Wash" endpoint and corresponding tests
- Introduced a new `/modules/self-serve/lane/wash/my-active-wash` endpoint to retrieve the authenticated customer's active self-serve wash.
- Implemented authentication and permission checks for secure access.
- Added detailed response handling for various scenarios, including 401, 403, and 404 statuses.
- Extended API documentation and OpenAPI spec to support the new endpoint.
- Updated unit and API tests to validate endpoint functionality and route wiring.
2026-06-02 10:29:15 +02:00
Jeppe B f7485f0767 Merge pull request #273 from copenhagentruckwash/update-self-serve-lane-command-access-logic
Allow customer self-serve lane commands
2026-06-02 10:22:21 +02:00
292 changed files with 168139 additions and 3829 deletions
+8
View File
@@ -11,6 +11,8 @@ services:
edge-broker: edge-broker:
container_name: "${COMPOSE_PROJECT_NAME:-api}-edge-broker" container_name: "${COMPOSE_PROJECT_NAME:-api}-edge-broker"
ports:
- "127.0.0.1:${EDGE_BROKER_CI_PORT:-14300}:4300"
labels: labels:
- "traefik.http.routers.edge-broker-local-ci.rule=PathPrefix(`/api/edge-broker`)" - "traefik.http.routers.edge-broker-local-ci.rule=PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local-ci.entrypoints=web" - "traefik.http.routers.edge-broker-local-ci.entrypoints=web"
@@ -80,3 +82,9 @@ services:
volumes: volumes:
ci_php_app: ci_php_app:
networks:
default:
ipam:
config:
- subnet: "${CI_DOCKER_SUBNET:-10.240.0.0/24}"
+2 -2
View File
@@ -9,8 +9,8 @@ on:
jobs: jobs:
qodana: qodana:
# Use GitHub-hosted runners for PR scans so untrusted code never runs on persistent internal infrastructure. # CI runs on the repository's self-hosted runner pool.
runs-on: ubuntu-latest runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
permissions: permissions:
contents: read contents: read
pull-requests: read pull-requests: read
+122 -42
View File
@@ -7,7 +7,7 @@ on:
jobs: jobs:
php: php:
name: PHP ${{ matrix.suite }} (required) name: PHP ${{ matrix.suite }} (required)
runs-on: ubuntu-latest runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -19,6 +19,20 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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 - name: Setup Node.js
if: ${{ matrix.suite == 'unit' }} if: ${{ matrix.suite == 'unit' }}
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -44,7 +58,7 @@ jobs:
edge-agent: edge-agent:
name: Edge Agent (required) name: Edge Agent (required)
runs-on: ubuntu-latest runs-on: [self-hosted, Linux, X64, pleno, backend]
steps: steps:
- name: Checkout - name: Checkout
@@ -54,8 +68,6 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: npm
cache-dependency-path: services/edge-agent/package-lock.json
- name: Install native build tools - name: Install native build tools
run: | run: |
@@ -91,12 +103,26 @@ jobs:
edge-broker: edge-broker:
name: Edge Broker (required) name: Edge Broker (required)
runs-on: ubuntu-latest runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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 - name: Materialize CI compose env files
run: | run: |
set -euo pipefail set -euo pipefail
@@ -112,8 +138,6 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: npm
cache-dependency-path: services/edge-broker/package-lock.json
- name: Install dependencies - name: Install dependencies
working-directory: services/edge-broker working-directory: services/edge-broker
@@ -125,26 +149,83 @@ jobs:
edge-gateway-backend: edge-gateway-backend:
name: Edge Gateway Backend (required) name: Edge Gateway Backend (required)
runs-on: ubuntu-latest runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
env: env:
COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml
COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }} COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }}
COMPOSE_PROFILES: dev
TRAEFIK_WEB_PORT: "18080" TRAEFIK_WEB_PORT: "18080"
TRAEFIK_WEBSECURE_PORT: "18443" TRAEFIK_WEBSECURE_PORT: "18443"
TRAEFIK_WEBSECURE_STAGING_PORT: "18433" TRAEFIK_WEBSECURE_STAGING_PORT: "18433"
TRAEFIK_METRICS_PORT: "19100" TRAEFIK_METRICS_PORT: "19100"
EDGE_BROKER_CI_PORT: "14300"
EDGE_GATEWAY_E2E_BASE_URL: "http://localhost:18080/api" EDGE_GATEWAY_E2E_BASE_URL: "http://localhost:18080/api"
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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 - name: Materialize CI compose env files
run: | run: |
set -euo pipefail set -euo pipefail
cp .github/ci.env .env cp .github/ci.env .env
cp .github/ci.env.staging .env.staging 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 - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -152,7 +233,7 @@ jobs:
node-version: 22 node-version: 22
- name: Boot local stack - 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 - name: Sync PHP app checkout
run: > run: >
@@ -161,10 +242,33 @@ jobs:
--exclude='./.phpunit.cache' --exclude='./.phpunit.cache'
--exclude='./build/logs' --exclude='./build/logs'
-C services/nginx/app -cf - . -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 - 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 - name: Verify edge gateway test files
run: > run: >
@@ -223,34 +327,10 @@ jobs:
vendor/bin/pest tests/Integration/EdgeGateway --colors=always" vendor/bin/pest tests/Integration/EdgeGateway --colors=always"
- name: Run edge gateway E2E smoke - name: Run edge gateway E2E smoke
run: | env:
set -euo pipefail EDGE_GATEWAY_E2E_COPY_CONFIG: "true"
compose_project="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}" EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP: "true"
runner="edge-e2e-runner-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" run: node scripts/edge-gateway-e2e.mjs
docker rm -f "$runner" >/dev/null 2>&1 || true
trap 'docker rm -f "$runner" >/dev/null 2>&1 || true' EXIT
docker create \
--name "$runner" \
--network "${compose_project}_default" \
-e COMPOSE_FILE="$COMPOSE_FILE" \
-e COMPOSE_PROJECT_NAME="$compose_project" \
-e TRAEFIK_WEB_PORT="${TRAEFIK_WEB_PORT:-18080}" \
-e TRAEFIK_WEBSECURE_PORT="${TRAEFIK_WEBSECURE_PORT:-18443}" \
-e TRAEFIK_WEBSECURE_STAGING_PORT="${TRAEFIK_WEBSECURE_STAGING_PORT:-18433}" \
-e TRAEFIK_METRICS_PORT="${TRAEFIK_METRICS_PORT:-19100}" \
-e EDGE_GATEWAY_E2E_BASE_URL="http://caddy" \
-e EDGE_GATEWAY_E2E_COMPOSE_PROJECT="$compose_project" \
-e EDGE_GATEWAY_E2E_COPY_CONFIG="true" \
-e EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP="true" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace \
node:22-alpine \
sh -lc "apk add --no-cache docker-cli docker-cli-compose >/dev/null && node scripts/edge-gateway-e2e.mjs"
docker cp . "$runner:/workspace"
docker start "$runner" >/dev/null
docker logs -f "$runner"
exit_code="$(docker wait "$runner")"
exit "$exit_code"
- name: Tear down local stack - name: Tear down local stack
if: always() if: always()
@@ -258,7 +338,7 @@ jobs:
release-manager-gate: release-manager-gate:
name: Release Manager gate name: Release Manager gate
runs-on: ubuntu-latest runs-on: [self-hosted, Linux, X64, pleno, backend]
needs: [php, edge-agent, edge-broker, edge-gateway-backend] needs: [php, edge-agent, edge-broker, edge-gateway-backend]
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
@@ -279,7 +359,7 @@ jobs:
-X POST "$RELEASE_MANAGER_GATE_URL" \ -X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \ -H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
--data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[]}")" --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")" response_body="$(cat "$response_file")"
rm -f "$response_file" rm -f "$response_file"
+3
View File
@@ -14,3 +14,6 @@
/.tmp/ /.tmp/
/.env.staging /.env.staging
/services/nginx/app/storage/replication-bootstrap.json /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 configuration file
COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.conf /etc/nginx/nginx.conf
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
# Install Composer # Install Composer
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
+1
View File
@@ -47,6 +47,7 @@ RUN set -eux; \
COPY services/nginx/app/ /var/www/html/ COPY services/nginx/app/ /var/www/html/
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
BIN
View File
Binary file not shown.
+3
View File
@@ -3,6 +3,8 @@ services:
traefik: traefik:
image: traefik:2.11 image: traefik:2.11
container_name: traefik container_name: traefik
group_add:
- "${DOCKER_SOCKET_GID:-65534}"
ports: ports:
- "${TRAEFIK_WEB_PORT:-80}:80" - "${TRAEFIK_WEB_PORT:-80}:80"
- "${TRAEFIK_WEBSECURE_PORT:-443}:443" - "${TRAEFIK_WEBSECURE_PORT:-443}:443"
@@ -102,6 +104,7 @@ services:
image: mysql:8.4 image: mysql:8.4
container_name: mysql-debug container_name: mysql-debug
profiles: [dev] profiles: [dev]
command: ["mysqld", "--innodb-use-native-aio=0"]
environment: environment:
MYSQL_ROOT_PASSWORD: ${CONFIG_DB_DEBUG_PASSWORD:?CONFIG_DB_DEBUG_PASSWORD is required for mysql-debug} MYSQL_ROOT_PASSWORD: ${CONFIG_DB_DEBUG_PASSWORD:?CONFIG_DB_DEBUG_PASSWORD is required for mysql-debug}
MYSQL_DATABASE: ${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug} MYSQL_DATABASE: ${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}
+105
View File
@@ -3357,6 +3357,9 @@
}, },
"email_notifications_enabled": { "email_notifications_enabled": {
"type": "boolean" "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": { "/backups/config": {
"get": { "get": {
"tags": [ "tags": [
@@ -15496,6 +15547,39 @@
"value" "value"
] ]
}, },
"SlackConfigEntry": {
"type": "object",
"properties": {
"module": {
"type": "string",
"enum": [
"Slack"
]
},
"variable": {
"type": "string",
"enum": [
"customer_registration_webhook_url"
]
},
"type": {
"type": "string",
"enum": [
"string"
]
},
"value": {
"type": "string",
"example": "https://hooks.slack.com/services/..."
}
},
"required": [
"module",
"variable",
"type",
"value"
]
},
"BackupsConfigEntry": { "BackupsConfigEntry": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -16240,6 +16324,27 @@
} }
] ]
}, },
"SlackConfigListResponse": {
"allOf": [
{
"$ref": "#/components/schemas/ModuleConfigEnvelopeBase"
},
{
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SlackConfigEntry"
}
}
},
"required": [
"data"
]
}
]
},
"BackupsConfigListResponse": { "BackupsConfigListResponse": {
"allOf": [ "allOf": [
{ {
+4023 -278
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);
});
}
+104 -33
View File
@@ -94,9 +94,23 @@ function directCaddyBaseUrl(baseUrl) {
return normalizeBaseUrl(url.toString()); 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) { function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
const websocketUrl = new URL(String(rawUrl)); const websocketUrl = new URL(String(rawUrl));
const apiUrl = new URL(normalizeBaseUrl(apiBaseUrl)); 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") { if (apiUrl.hostname === "caddy" && websocketUrl.hostname === "caddy") {
websocketUrl.hostname = "edge-broker"; websocketUrl.hostname = "edge-broker";
@@ -104,6 +118,18 @@ function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/"; 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(); return websocketUrl.toString();
} }
@@ -555,6 +581,38 @@ function summarizeStreamMessages(messages, limit = 12) {
.filter(Boolean); .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() { async function main() {
const scriptPath = fileURLToPath(import.meta.url); const scriptPath = fileURLToPath(import.meta.url);
const rootDir = await resolveRootDir(scriptPath); const rootDir = await resolveRootDir(scriptPath);
@@ -669,6 +727,7 @@ async function main() {
} }
); );
try {
await waitForCondition( await waitForCondition(
async () => { async () => {
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, { const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
@@ -685,6 +744,18 @@ async function main() {
message: "Gateway never established a live broker connection after install.", 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 WebSocketImpl = await loadWebSocketImplementation();
const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, { 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." } { timeoutMs: 15_000, message: "Gateway stream never became ready." }
); );
const readyMessage = streamMessages.find((message) => message?.type === "gateway.stream.ready"); await waitForSocketMessage(
assert.equal( streamMessages,
Boolean(readyMessage?.connected), (message) => (
true, message?.type === "gateway.stream.ready" && message?.connected === true
"Gateway stream became ready before the broker reported the gateway as connected." ) || (
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`, { 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."); assert.ok(operationId > 0, "Operation creation did not return an operation id.");
try { try {
await waitForSocketMessage( await waitForCondition(
streamMessages, async () => {
(message) => message?.type === "task.updated" && Number(message?.operationId || 0) === operationId, const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
{ timeoutMs: 180_000, message: "Live gateway stream never emitted task.updated for the queued operation." } 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) { } catch (error) {
let operationSnapshot = null; let operationSnapshot = null;
@@ -765,21 +853,6 @@ async function main() {
throw new Error(diagnostic); 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( await waitForSocketMessage(
streamMessages, streamMessages,
(message) => message?.type === "gateway.telemetry" || message?.type === "stats.updated", (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." } { timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
); );
await waitForCondition(
async () => {
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, { const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
token: authToken, token: authToken,
}); });
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions) const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || "")) ? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
: []; : [];
assert.ok(
shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")),
"Gateway logs page did not persist the shell transcript."
);
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []); const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
assert.ok(
timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"), return shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell"))
"Gateway logs page did not include the shell close audit event." && 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"); 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" compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
project_suffix="$(date +%s)-$$" project_suffix="$(date +%s)-$$"
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}" export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
export COMPOSE_PROFILES="${COMPOSE_PROFILES:-dev}"
log_dir=".tmp/ci-logs/$suite" log_dir=".tmp/ci-logs/$suite"
mkdir -p "$log_dir" mkdir -p "$log_dir"
@@ -68,6 +69,33 @@ retry_command() {
done done
} }
composer_install() {
dist_attempts="${PHP_CI_COMPOSER_RETRIES:-3}"
source_attempts="${PHP_CI_COMPOSER_SOURCE_RETRIES:-2}"
if retry_command "$dist_attempts" \
docker compose $compose_files exec -T php1 sh -lc \
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'; then
return 0
fi
echo "Composer dist install failed after ${dist_attempts} attempts; retrying with --prefer-source." >&2
retry_command "$source_attempts" \
docker compose $compose_files exec -T php1 sh -lc \
'cd /var/www/html && composer install --no-interaction --prefer-source --no-progress'
}
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() { cleanup() {
status="$?" status="$?"
collect_logs "$status" collect_logs "$status"
@@ -87,7 +115,8 @@ cleanup() {
} }
trap cleanup EXIT INT TERM 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 ' docker compose $compose_files exec -T php1 sh -lc '
set -eu set -eu
@@ -110,10 +139,18 @@ tar \
--exclude='./.phpunit.cache' \ --exclude='./.phpunit.cache' \
--exclude='./build/logs' \ --exclude='./build/logs' \
-C services/nginx/app -cf - . \ -C services/nginx/app -cf - . \
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf - | docker compose $compose_files exec -T php1 tar --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 \ docker compose $compose_files exec -T php1 sh -lc \
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress' "cd /var/www/html && PLENO_REPO_ROOT_FOR_TESTS=/var/www/repo-root composer test:ci:$suite"
docker compose $compose_files exec -T php1 sh -lc \
"cd /var/www/html && composer test:ci:$suite"
+73 -3
View File
@@ -1,14 +1,32 @@
import process from "node:process"; import process from "node:process";
import path from "node:path"; import path from "node:path";
import { createHash } from "node:crypto";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
export const DEFAULT_STAGING_BASE_URL = "https://api.truckwash.io:4433"; 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 = [ export const INSTALLER_SCRIPT_REQUIRED_SNIPPETS = [
"/edge-agent/install-token/status", "/edge-agent/install-token/status",
"/edge-agent/artifacts/manifest.json",
"report_install_status", "report_install_status",
'begin_install_phase "VERIFY_TOKEN"', 'begin_install_phase "VERIFY_TOKEN"',
'begin_install_phase "VERIFY_ARTIFACTS"',
'begin_install_phase "WAIT_FOR_CLAIM"', 'begin_install_phase "WAIT_FOR_CLAIM"',
'report_install_status "FAILED"', 'report_install_status "FAILED"',
"verify_manifest_artifact",
EXPECTED_INSTALL_VERSION,
]; ];
export function normalizeBaseUrl(url) { export function normalizeBaseUrl(url) {
@@ -55,13 +73,20 @@ export function buildChecks(baseUrl, installToken) {
name: "Ping", name: "Ping",
url: `${normalizedBaseUrl}/ping`, url: `${normalizedBaseUrl}/ping`,
}, },
{
name: "Artifact manifest",
url: `${normalizedBaseUrl}/edge-agent/artifacts/manifest.json`,
artifactName: "manifest.json",
},
{ {
name: "Agent PHP artifact", name: "Agent PHP artifact",
url: `${normalizedBaseUrl}/edge-agent/artifacts/agent.php`, url: `${normalizedBaseUrl}/edge-agent/artifacts/agent.php`,
artifactName: "agent.php",
}, },
{ {
name: "Service unit artifact", name: "Service unit artifact",
url: `${normalizedBaseUrl}/edge-agent/artifacts/truckwash-edge-agent.service`, url: `${normalizedBaseUrl}/edge-agent/artifacts/truckwash-edge-agent.service`,
artifactName: "truckwash-edge-agent.service",
}, },
{ {
name: "Installer script", 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) { export function validateInstallerScriptBody(body) {
const source = String(body || ""); const source = String(body || "");
const missingSnippets = INSTALLER_SCRIPT_REQUIRED_SNIPPETS.filter((snippet) => !source.includes(snippet)); 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 checks = buildChecks(baseUrl, installToken);
const results = []; const results = [];
let artifactManifest = null;
for (const check of checks) { for (const check of checks) {
process.stdout.write(`[staging-smoke] GET ${check.url}\n`); process.stdout.write(`[staging-smoke] GET ${check.url}\n`);
const response = await fetch(check.url); const response = await fetch(check.url);
const body = await response.text(); const body = Buffer.from(await response.arrayBuffer());
const result = { const result = {
...check, ...check,
status: response.status, status: response.status,
ok: response.ok, ok: response.ok,
bodyPreview: previewBody(body), bodyPreview: previewBody(body.toString("utf8")),
}; };
results.push(result); 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") { 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 { import {
DEFAULT_STAGING_BASE_URL, DEFAULT_STAGING_BASE_URL,
EXPECTED_INSTALL_VERSION,
INSTALLER_SCRIPT_REQUIRED_SNIPPETS, INSTALLER_SCRIPT_REQUIRED_SNIPPETS,
buildChecks, buildChecks,
normalizeBaseUrl, normalizeBaseUrl,
parseArgs, parseArgs,
validateArtifactBodyAgainstManifest,
validateArtifactManifestBody,
validateInstallerScriptBody, validateInstallerScriptBody,
} from "./staging-edge-gateway-smoke.mjs"; } from "./staging-edge-gateway-smoke.mjs";
@@ -32,18 +35,58 @@ test("buildChecks targets the public staging endpoints", () => {
assert.deepEqual(checks.map((check) => check.url), [ assert.deepEqual(checks.map((check) => check.url), [
"https://api.truckwash.io:4433/ping", "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/agent.php",
"https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service", "https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service",
"https://api.truckwash.io:4433/edge-agent/install.sh?token=abc%20123", "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", () => { test("validateInstallerScriptBody requires install-session reporting wiring", () => {
const script = ` const script = `
INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status" 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" report_install_status "FAILED"
begin_install_phase "VERIFY_TOKEN" "Verifying install token" 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" 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); assert.deepEqual(validateInstallerScriptBody(script), INSTALLER_SCRIPT_REQUIRED_SNIPPETS);
+8 -2
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_CONFIG_FILE_NAME = "test-gateway.json";
export const DEFAULT_HOST_API_URL = "http://localhost/api"; export const DEFAULT_HOST_API_URL = "http://localhost/api";
export const DEFAULT_CONTAINER_API_URL = "http://caddy"; 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_INSTALL_DIR = "/opt/truckwash-edge-agent";
export const DEFAULT_RUNTIME_DIR = `${DEFAULT_INSTALL_DIR}/runtime`; export const DEFAULT_RUNTIME_DIR = `${DEFAULT_INSTALL_DIR}/runtime`;
export const DEFAULT_STATE_DATABASE_PATH = `${DEFAULT_RUNTIME_DIR}/gateway-state.sqlite`; export const DEFAULT_STATE_DATABASE_PATH = `${DEFAULT_RUNTIME_DIR}/gateway-state.sqlite`;
@@ -409,14 +409,20 @@ async function startContainer({
}); });
if (copyConfig) { if (copyConfig) {
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
await fs.chmod(configFilePath, 0o666).catch(() => {});
try {
await runCommand("docker", [ await runCommand("docker", [
"cp", "cp",
path.join(configDir, DEFAULT_CONFIG_FILE_NAME), configFilePath,
`${containerName}:${containerConfigPath}`, `${containerName}:${containerConfigPath}`,
], { ], {
cwd: rootDir, cwd: rootDir,
stdio: "inherit", stdio: "inherit",
}); });
} finally {
await fs.chmod(configFilePath, 0o600).catch(() => {});
}
await runCommand("docker", ["start", containerName], { await runCommand("docker", ["start", containerName], {
cwd: rootDir, 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) { async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch) {
if (!url) { if (!url) {
return null; return null;
@@ -1416,6 +1470,10 @@ export async function handleAgentCommand(command, deps = {}) {
return await getRelayStatus(command.payload || {}, fetchImpl); return await getRelayStatus(command.payload || {}, fetchImpl);
case "SET_RELAY_STATE": case "SET_RELAY_STATE":
return await setRelayState(command.payload || {}, fetchImpl); 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": case "RUN_UPDATE":
return await runUpdate(command.payload || {}, fetchImpl, deps); return await runUpdate(command.payload || {}, fetchImpl, deps);
case "UNINSTALL_AGENT": 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); 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 () => { 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) => { const inventory = await discoverShellyDevices({ candidateIps: ["192.168.1.2"] }, async (url) => {
assert.equal(String(url), "http://192.168.1.2/shelly"); 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 = {}) => payload
: async (gatewayId, payload = {}) => : async (gatewayId, payload = {}) =>
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/logs`, 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 broadcastGatewayEvent = (gatewayId, message) => {
const sessionIds = gatewayStreamSessions.get(String(gatewayId)); const sessionIds = gatewayStreamSessions.get(String(gatewayId));
@@ -853,6 +859,11 @@ export function createBrokerServer(options = {}) {
return; return;
} }
if (message.type === "MACHINE_SIGNAL") {
await ingestMachineSignal(String(ws.gatewayId), message.payload || {});
return;
}
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) { if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
const sessionRecord = browserShellSessions.get(String(message.sessionId)); const sessionRecord = browserShellSessions.get(String(message.sessionId));
if (!sessionRecord) { 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(); 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 () => { test("broker survives telemetry ingestion failures for stale gateways", async () => {
const broker = createBrokerServer({ const broker = createBrokerServer({
authMode: "stub", authMode: "stub",
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -133,8 +133,14 @@ class attachments implements attachments_i
protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array
{ {
$options = $this->normalizeAttachmentOptions($options); $options = $this->normalizeAttachmentOptions($options);
$rawType = trim($type, '`');
$objectTypes = array_values(array_unique([
$rawType,
'`' . $rawType . '`',
]));
return (new object_attachments_o())->getFieldsWhereIn([ return (new object_attachments_o())->getFieldsWhereIn([
'object_type' => $type, 'object_type' => $objectTypes,
'object_id' => $object_ids, 'object_id' => $object_ids,
'deleted_at' => null 'deleted_at' => null
], $options); ], $options);
@@ -121,6 +121,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 private static function bulkEnvData(array $env): array
{ {
$data = []; $data = [];
@@ -159,6 +169,11 @@ class coolify_api_client
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart'); return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
} }
public function stopApplication(string $uuid): array
{
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/stop');
}
public function deleteService(string $uuid): array public function deleteService(string $uuid): array
{ {
return $this->request('DELETE', '/services/' . rawurlencode($uuid)); return $this->request('DELETE', '/services/' . rawurlencode($uuid));
@@ -1371,6 +1371,129 @@ class coolify_manager
]; ];
} }
public function deployGithubRunners(array $input, ?int $actorUserId = null): array
{
$this->ensureSchema();
$dryRun = $this->toBool($input['dry_run'] ?? null, false);
$instanceId = (int)($input['instance_id'] ?? 0);
if ($instanceId <= 0) {
$instanceId = $this->defaultInstanceId();
}
$instance = $this->getInstance($instanceId);
$repositories = $this->githubRunnerRepositories($input);
$labels = $this->githubRunnerLabels($input['labels'] ?? null);
$countPerRepo = $this->githubRunnerCount($input['count_per_repo'] ?? $input['runner_count_per_repo'] ?? null);
$serviceName = $this->githubRunnerServiceName($input['service_name'] ?? null);
$resourceUuid = $this->nullableString($input['service_uuid'] ?? null)
?? $this->nullableString($this->coolifyConfigValue('github_runner_service_uuid', ''));
$token = $this->githubRunnerToken($input);
$template = $this->githubRunnerComposeTemplate($repositories, $labels, $countPerRepo);
$hash = $this->composeHash($template);
$plan = [
'type' => 'deploy_github_runners',
'instance_id' => $instanceId,
'service_uuid' => $resourceUuid,
'service_name' => $serviceName,
'repositories' => $repositories,
'labels' => $labels,
'count_per_repo' => $countPerRepo,
'compose_hash' => $hash,
'action' => $resourceUuid === null ? 'create' : 'update',
'token_set' => $token !== '',
'token_source' => trim((string)($input['github_token'] ?? $input['token'] ?? '')) !== '' ? 'request' : 'config',
];
if ($dryRun) {
$this->audit(null, $instanceId, null, 'github_runners_planned', $actorUserId, 'info', $plan);
return [
'ok' => true,
'dry_run' => true,
'mutated' => false,
'planned' => [$plan],
'applied' => [],
'errors' => [],
'service_uuid' => $resourceUuid,
'service_name' => $serviceName,
'compose_hash' => $hash,
'repositories' => $repositories,
'labels' => $labels,
'count_per_repo' => $countPerRepo,
];
}
if ($token === '') {
throw new RuntimeException('GitHub runner token is required to deploy self-hosted runners.');
}
$client = $this->clientForInstance($instance);
$apiResult = [];
$action = $resourceUuid === null ? 'created' : 'updated';
if ($resourceUuid === null) {
$apiResult = $client->createService($this->githubRunnerServicePayload($instance, $input, $serviceName, $template, false));
$resourceUuid = trim((string)($apiResult['uuid'] ?? ''));
if ($resourceUuid === '') {
throw new RuntimeException('Coolify did not return a GitHub runner service UUID.');
}
} else {
try {
$apiResult = $client->updateService($resourceUuid, $this->githubRunnerServicePayload($instance, $input, $serviceName, $template, true));
} catch (Throwable $throwable) {
if (!str_contains(strtolower($throwable->getMessage()), '404')
&& !str_contains(strtolower($throwable->getMessage()), 'not found')) {
throw $throwable;
}
$apiResult = $client->createService($this->githubRunnerServicePayload($instance, $input, $serviceName, $template, false));
$resourceUuid = trim((string)($apiResult['uuid'] ?? ''));
if ($resourceUuid === '') {
throw new RuntimeException('Coolify did not return a GitHub runner service UUID.');
}
$action = 'created';
}
}
$client->updateServiceEnvsBulk($resourceUuid, ['GITHUB_RUNNER_TOKEN' => $token]);
$start = $this->startOrRestartService($client, $resourceUuid, $action === 'updated');
$deployment = $client->deployResource($resourceUuid, false);
$this->setModuleConfigValue('Coolify', 'github_runner_service_uuid', $resourceUuid, 'string');
$this->setModuleConfigValue('Coolify', 'github_runner_frontend_repository', $repositories['frontend'], 'string');
$this->setModuleConfigValue('Coolify', 'github_runner_backend_repository', $repositories['backend'], 'string');
$this->setModuleConfigValue('Coolify', 'github_runner_labels', implode(',', $labels), 'string');
$this->setModuleConfigValue('Coolify', 'github_runner_count_per_repo', (string)$countPerRepo, 'int');
if ($this->toBool($input['persist_token'] ?? null, false)) {
$this->setModuleConfigValue('Coolify', 'github_runner_token', replication_secret_box::encrypt($token), 'string');
}
$applied = array_replace($plan, [
'action' => $action,
'service_uuid' => $resourceUuid,
'coolify' => self::redactCoolifyResponse($apiResult),
'start' => self::redactCoolifyResponse(is_array($start) ? $start : []),
'deployment' => self::redactCoolifyResponse($deployment),
]);
$this->audit(null, $instanceId, null, 'github_runners_deployed', $actorUserId, 'info', $applied);
return [
'ok' => true,
'dry_run' => false,
'mutated' => true,
'planned' => [$plan],
'applied' => [$applied],
'errors' => [],
'action' => $action,
'service_uuid' => $resourceUuid,
'service_name' => $serviceName,
'compose_hash' => $hash,
'repositories' => $repositories,
'labels' => $labels,
'count_per_repo' => $countPerRepo,
'deployment' => self::redactCoolifyResponse($deployment),
];
}
private function gatewayApiCodeVersionLabel(array $target): string private function gatewayApiCodeVersionLabel(array $target): string
{ {
$channelSlug = trim((string)($target['channel_slug'] ?? 'gateway')); $channelSlug = trim((string)($target['channel_slug'] ?? 'gateway'));
@@ -3266,6 +3389,141 @@ class coolify_manager
]; ];
} }
private function githubRunnerRepositories(array $input): array
{
return [
'frontend' => $this->normalizeGithubRepository(
$input['frontend_repository'] ?? $input['frontend_repo'] ?? $this->coolifyConfigValue('github_runner_frontend_repository', 'copenhagentruckwash/pleno-vue'),
'frontend'
),
'backend' => $this->normalizeGithubRepository(
$input['backend_repository'] ?? $input['backend_repo'] ?? $this->coolifyConfigValue('github_runner_backend_repository', 'copenhagentruckwash/api'),
'backend'
),
];
}
private function normalizeGithubRepository(mixed $value, string $label): string
{
$repository = trim((string)$value);
$repository = preg_replace('#^https://github\.com/#i', '', $repository) ?? $repository;
$repository = preg_replace('#^git@github\.com:#i', '', $repository) ?? $repository;
$repository = preg_replace('#\.git$#i', '', $repository) ?? $repository;
$repository = trim($repository, " \t\n\r\0\x0B/");
if (!preg_match('#^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$#', $repository)) {
throw new RuntimeException('GitHub ' . $label . ' repository must be in owner/repo format.');
}
return $repository;
}
private function githubRunnerLabels(mixed $value): array
{
$raw = trim((string)($value ?? ''));
if ($raw === '') {
$raw = $this->coolifyConfigValue('github_runner_labels', 'self-hosted,Linux,X64,default');
}
$labels = array_values(array_unique(array_filter(array_map(
static fn(string $label): string => trim($label),
preg_split('/[,\s]+/', $raw) ?: []
))));
return $labels !== [] ? $labels : ['self-hosted', 'Linux', 'X64', 'default'];
}
private function githubRunnerCount(mixed $value): int
{
$count = (int)($value ?? 0);
if ($count <= 0) {
$count = (int)$this->coolifyConfigValue('github_runner_count_per_repo', '1');
}
return max(1, min(10, $count));
}
private function githubRunnerServiceName(mixed $value): string
{
$name = strtolower(trim((string)($value ?? 'truckwash-github-runners')));
$name = preg_replace('/[^a-z0-9-]+/', '-', $name) ?: '';
$name = trim($name, '-') ?: 'truckwash-github-runners';
return substr($name, 0, 120);
}
private function githubRunnerToken(array $input): string
{
$token = trim((string)($input['github_token'] ?? $input['token'] ?? ''));
if ($token !== '') {
return $token;
}
$envToken = trim((string)(getenv('GITHUB_RUNNER_TOKEN') ?: getenv('GITHUB_TOKEN') ?: ''));
if ($envToken !== '') {
return $envToken;
}
return replication_secret_box::decrypt($this->coolifyConfigValue('github_runner_token', ''));
}
private function githubRunnerComposeTemplate(array $repositories, array $labels, int $countPerRepo): array
{
$lines = ['services:'];
foreach ($repositories as $key => $repository) {
for ($index = 1; $index <= $countPerRepo; $index++) {
$service = 'github-runner-' . $key . '-' . $index;
$runnerName = 'truckwash-' . $key . '-' . $index;
$runnerLabels = array_values(array_unique(array_merge($labels, [$key])));
$lines = array_merge($lines, [
' ' . $service . ':',
' image: myoung34/github-runner:latest',
' restart: unless-stopped',
' environment:',
' REPO_URL: ' . self::yamlScalar('https://github.com/' . $repository),
' RUNNER_NAME: ' . self::yamlScalar($runnerName),
' RUNNER_SCOPE: repo',
' RUNNER_WORKDIR: /tmp/runner/work',
' LABELS: ' . self::yamlScalar(implode(',', $runnerLabels)),
' EPHEMERAL: "false"',
' RUN_AS_ROOT: "true"',
' ACCESS_TOKEN: ${GITHUB_RUNNER_TOKEN}',
' volumes:',
' - /var/run/docker.sock:/var/run/docker.sock',
]);
}
}
return [
'compose' => implode("\n", $lines) . "\n",
'env' => 'GITHUB_RUNNER_TOKEN=${GITHUB_RUNNER_TOKEN}',
];
}
private function githubRunnerServicePayload(array $instance, array $input, string $serviceName, array $template, bool $update): array
{
$payload = [
'name' => $serviceName,
'description' => 'Truckwash GitHub self-hosted runners for frontend and backend workflows.',
'instant_deploy' => false,
'docker_compose_raw' => $this->encodedDockerCompose($template),
'force_domain_override' => false,
];
if (!$update) {
$payload = array_replace($payload, [
'project_uuid' => $this->targetMapping($input, $instance, 'project_uuid'),
'environment_name' => $this->targetMapping($input, $instance, 'environment_name') ?: 'production',
'environment_uuid' => $this->targetMapping($input, $instance, 'environment_uuid'),
'server_uuid' => $this->targetMapping($input, $instance, 'server_uuid'),
'destination_uuid' => $this->targetMapping($input, $instance, 'destination_uuid'),
]);
}
return array_filter($payload, static fn($value): bool => $value !== null && $value !== '');
}
private static function yamlScalar(string $value): string
{
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"';
}
private function publicLoadBalancerConfig(array $config): array private function publicLoadBalancerConfig(array $config): array
{ {
unset($config['token']); unset($config['token']);
@@ -154,6 +154,12 @@ class coolify_schema_bootstrap
self::ensureModuleConfigDefault('Coolify', 'hetzner_cloud_api_token', '', 'string'); self::ensureModuleConfigDefault('Coolify', 'hetzner_cloud_api_token', '', 'string');
self::ensureModuleConfigDefault('Coolify', 'public_gateway_host', 'api-v2.truckwash.io', 'string'); self::ensureModuleConfigDefault('Coolify', 'public_gateway_host', 'api-v2.truckwash.io', 'string');
self::ensureModuleConfigDefault('Coolify', 'public_gateway_probe_path', '', 'string'); self::ensureModuleConfigDefault('Coolify', 'public_gateway_probe_path', '', 'string');
self::ensureModuleConfigDefault('Coolify', 'github_runner_token', '', 'string');
self::ensureModuleConfigDefault('Coolify', 'github_runner_service_uuid', '', 'string');
self::ensureModuleConfigDefault('Coolify', 'github_runner_frontend_repository', 'copenhagentruckwash/pleno-vue', 'string');
self::ensureModuleConfigDefault('Coolify', 'github_runner_backend_repository', 'copenhagentruckwash/api', 'string');
self::ensureModuleConfigDefault('Coolify', 'github_runner_labels', 'self-hosted,Linux,X64,default', 'string');
self::ensureModuleConfigDefault('Coolify', 'github_runner_count_per_repo', '1', 'int');
self::ensureDefaultGateway('node1.truckwash.io', '94.130.142.41', 10); self::ensureDefaultGateway('node1.truckwash.io', '94.130.142.41', 10);
self::ensureDefaultGateway('node2.truckwash.io', '65.21.214.30', 20); self::ensureDefaultGateway('node2.truckwash.io', '65.21.214.30', 20);
@@ -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_HEADERS = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
public const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'; public const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
public const EXPOSED_HEADERS = 'Server-Timing';
public const MAX_AGE_SECONDS = '86400'; public const MAX_AGE_SECONDS = '86400';
private const REQUIRED_ALLOWED_ORIGINS = [ private const REQUIRED_ALLOWED_ORIGINS = [
@@ -25,6 +26,9 @@ class cors_policy
'https://localhost:4433', 'https://localhost:4433',
'https://twdev.jeppeb.dk', 'https://twdev.jeppeb.dk',
'http://localhost:5173', 'http://localhost:5173',
'http://localhost:5174',
'http://127.0.0.1:5173',
'http://127.0.0.1:5174',
]; ];
public static function normalizeOrigin(?string $value): string public static function normalizeOrigin(?string $value): string
@@ -125,7 +129,9 @@ class cors_policy
'Access-Control-Allow-Credentials' => 'true', 'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS, 'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS, 'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
'Access-Control-Expose-Headers' => self::EXPOSED_HEADERS,
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS, 'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
'Timing-Allow-Origin' => $origin,
'Vary' => 'Origin', 'Vary' => 'Origin',
]; ];
} }
@@ -137,6 +137,10 @@ class customer_mass_import_service
if ($cvrLength < 8 || $cvrLength > 20) { if ($cvrLength < 8 || $cvrLength > 20) {
throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400); 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 protected function normalizePositiveInt(mixed $value): ?int
@@ -0,0 +1,98 @@
<?php
namespace classes;
use RuntimeException;
class customer_order_product_policy
{
public const ONLY_TANKCLEANING_ATTRIBUTE = 'onlyTankCleaning';
public const ONLY_TANKCLEANING_MESSAGE = 'Only tankcleaning customers can only have tankcleaning products in their orders.';
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
{
$context = self::loadOrderProductContext($orderId, $productId);
if ($context === null) {
return null;
}
if ((int)($context['product_id'] ?? 0) < 1) {
return null;
}
return self::onlyTankCleaningViolation((bool)((int)($context['has_only_tank_cleaning'] ?? 0)), $context)
? self::ONLY_TANKCLEANING_MESSAGE
: null;
}
public static function onlyTankCleaningViolation(bool $customerHasOnlyTankCleaning, array $productRow): bool
{
return $customerHasOnlyTankCleaning && !self::isTankCleaningProductRow($productRow);
}
public static function isTankCleaningProductRow(array $row): bool
{
return (int)($row['product_category'] ?? $row['category'] ?? 0) === 5
|| self::rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
}
private static function loadOrderProductContext(int $orderId, int $productId): ?array
{
global $db;
if ($orderId < 1 || $productId < 1) {
return null;
}
$sql = "
SELECT
o.id AS order_id,
o.customer_id AS customer_number,
p.id AS product_id,
p.name AS product_name,
p.category AS product_category,
c.name AS category_name,
MAX(CASE WHEN ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "' THEN 1 ELSE 0 END) AS has_only_tank_cleaning
FROM orders o
LEFT JOIN products p ON p.id = {$productId}
LEFT JOIN categories c ON c.id = p.category
LEFT JOIN users u ON u.customer_number = o.customer_id
LEFT JOIN customer_attributes ca ON ca.user_id = u.id
AND ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "'
WHERE o.id = {$orderId}
GROUP BY o.id, o.customer_id, p.id, p.name, p.category, c.name
LIMIT 1
";
$result = $db->query($sql);
if (!$result || $result->num_rows < 1) {
return null;
}
$row = $result->fetch_assoc();
return is_array($row) ? $row : null;
}
private static function rowMatchesProductTerms(array $row, array $terms): bool
{
$haystack = strtolower(trim(
(string)($row['product_name'] ?? $row['name'] ?? '') . ' ' .
(string)($row['category_name'] ?? '')
));
foreach ($terms as $term) {
if ($term !== '' && str_contains($haystack, strtolower($term))) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,168 @@
<?php
namespace classes;
use objects\orders_o;
use objects\products_o;
use objects\users_o;
class customer_product_rule_service
{
public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer';
private const ADDON_CATEGORY_ID = 4;
private const TANK_CLEANING_CATEGORY_ID = 5;
private const SPOT_FREE_PRODUCT_IDS = [23, 24];
/**
* @return array{rule: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;
}
$product = (new products_o())->getProductById($productId);
if (!$product->exists()) {
return null;
}
$customer = (new users_o())->getUserByCustomerNumber((int)$order->customer_id->value());
if (!$customer->exists()) {
return null;
}
$categoryId = (int)$product->category->value();
$categoryName = $this->categoryName($categoryId);
$searchableProduct = $this->searchableProductText($product, $categoryName);
$isTankCleaningProduct = $this->isTankCleaningProduct($categoryId, $searchableProduct);
if ($customer->doesUserHaveAttribute('restrictAdditionalServices')
&& $this->isAdditionalServiceProduct($orderId, $relatedItemId, $categoryId, $searchableProduct)) {
return $this->violation('restrictAdditionalServices');
}
if ($customer->doesUserHaveAttribute('restrictTankCleaning') && $isTankCleaningProduct) {
return $this->violation('restrictTankCleaning');
}
if ($customer->doesUserHaveAttribute('onlyTankCleaning') && !$isTankCleaningProduct) {
return $this->violation('onlyTankCleaning');
}
if ($customer->doesUserHaveAttribute('restrictSpotFree')
&& $this->isSpotFreeProduct((int)$product->id, $searchableProduct)) {
return $this->violation('restrictSpotFree');
}
if ($customer->doesUserHaveAttribute('restrictInteriorCleaning')
&& $this->containsAny($searchableProduct, ['interior', 'indvendig'])) {
return $this->violation('restrictInteriorCleaning');
}
return null;
}
/**
* @return array{rule:string,message:string}
*/
private function violation(string $rule): array
{
return [
'rule' => $rule,
'message' => self::BLOCK_MESSAGE,
];
}
private function isAdditionalServiceProduct(int $orderId, ?int $relatedItemId, int $categoryId, string $searchableProduct): bool
{
if ($relatedItemId !== null && $relatedItemId > 0) {
return true;
}
if ($categoryId === self::ADDON_CATEGORY_ID) {
return true;
}
if ($this->containsAny($searchableProduct, ['add-on', 'add on', 'addon', 'tilvalg'])) {
return true;
}
return $this->countStandaloneOrderItems($orderId) > 0;
}
private function isTankCleaningProduct(int $categoryId, string $searchableProduct): bool
{
if ($categoryId === self::TANK_CLEANING_CATEGORY_ID) {
return true;
}
return $this->containsAny($searchableProduct, ['tank cleaning', 'tankcleaning', 'tankrens', 'tank rens']);
}
private function isSpotFreeProduct(int $productId, string $searchableProduct): bool
{
if (in_array($productId, self::SPOT_FREE_PRODUCT_IDS, true)) {
return true;
}
return $this->containsAny($searchableProduct, ['spot free', 'spotfree', 'skylning med ro']);
}
private function searchableProductText(products_o $product, string $categoryName): string
{
return strtolower(trim((string)$product->name->value() . ' ' . $categoryName));
}
/**
* @param array<int, string> $terms
*/
private function containsAny(string $value, array $terms): bool
{
foreach ($terms as $term) {
if ($term !== '' && str_contains($value, $term)) {
return true;
}
}
return false;
}
private function categoryName(int $categoryId): string
{
global $db;
if ($categoryId <= 0) {
return '';
}
$result = $db->query('SELECT name FROM categories WHERE id = ' . $categoryId . ' LIMIT 1');
if (!$result || $result->num_rows === 0) {
return '';
}
$row = $result->fetch_assoc();
return strtolower((string)($row['name'] ?? ''));
}
private function countStandaloneOrderItems(int $orderId): int
{
global $db;
$result = $db->query(
'SELECT COUNT(*) AS item_count
FROM order_items
WHERE order_id = ' . $orderId . '
AND deleted_at IS NULL
AND (related_item_id IS NULL OR related_item_id = 0)'
);
if (!$result) {
return 0;
}
$row = $result->fetch_assoc();
return (int)($row['item_count'] ?? 0);
}
}
@@ -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)) { if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
$db->query( $db->query(
"ALTER TABLE departments "ALTER TABLE departments
+29 -1
View File
@@ -172,7 +172,8 @@ class economic implements economic_i
string $email, string $email,
int $phone, int $phone,
?int $mobile_phone = null, ?int $mobile_phone = null,
object|array|null $company_information = null object|array|null $company_information = null,
?string $ean = null
): object ): object
{ {
$payload = [ $payload = [
@@ -196,10 +197,37 @@ class economic implements economic_i
]; ];
$payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information)); $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); 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 private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array
{ {
if ($company_information === null) { if ($company_information === null) {
@@ -240,7 +240,13 @@ class economic_transfer_executor
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id '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) 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->versioning = $versioning ?? new economic_v2_versioning_service();
$this->economic = $economic; $this->economic = $economic;
} }
@@ -388,7 +389,16 @@ class economic_v2_distribution_service
continue; 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); $discount_percentage = (float)($discount_row['discount'] ?? 0);
if ($discount_percentage <= 0) { if ($discount_percentage <= 0) {
continue; continue;
@@ -1519,7 +1529,13 @@ class economic_v2_distribution_service
$line_price = ((float)$this->getProductDepartmentPrice($product_id, $department_id)) * $quantity; $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); $discount_percentage = (float)($discount_row['discount'] ?? 0);
if ($discount_percentage > 0) { if ($discount_percentage > 0) {
$line_price *= (1 - ($discount_percentage / 100)); $line_price *= (1 - ($discount_percentage / 100));
@@ -1529,15 +1545,22 @@ class economic_v2_distribution_service
return $total; 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)) { if (array_key_exists($cache_key, $this->discount_resolution_cache)) {
return $this->discount_resolution_cache[$cache_key]; return $this->discount_resolution_cache[$cache_key];
} }
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp); $scopedDepartmentId = $department_id !== null && (new \objects\departments_o())->isCustomPricingOnly((int)$department_id)
if ($direct !== null && (int)($direct['discount'] ?? 0) > 0) { ? (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; return $this->discount_resolution_cache[$cache_key] = $direct;
} }
@@ -1545,13 +1568,20 @@ class economic_v2_distribution_service
if ($product !== null) { if ($product !== null) {
$category = (string)$product->category->value(); $category = (string)$product->category->value();
if ($category !== '') { 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) { if ($category_discount !== null && (int)($category_discount['discount'] ?? 0) > 0) {
return $this->discount_resolution_cache[$cache_key] = $category_discount; return $this->discount_resolution_cache[$cache_key] = $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; 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 ( "CREATE TABLE IF NOT EXISTS customer_discount_override_versions (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NULL DEFAULT NULL,
user_id INT NOT NULL, user_id INT NOT NULL,
customer_number INT NOT NULL, customer_number INT NOT NULL,
is_category TINYINT(1) NOT NULL, is_category TINYINT(1) NOT NULL,
object_id VARCHAR(64) NOT NULL, object_id VARCHAR(64) NOT NULL,
discount INT NOT NULL, discount INT NOT NULL,
fixed_price INT NULL DEFAULT NULL,
effective_from DATETIME NOT NULL, effective_from DATETIME NOT NULL,
effective_to DATETIME NULL, effective_to DATETIME NULL,
source VARCHAR(64) NOT NULL DEFAULT 'live', source VARCHAR(64) NOT NULL DEFAULT 'live',
@@ -74,6 +76,7 @@ class economic_v2_schema_bootstrap
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE 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_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_user (user_id),
INDEX idx_discount_override_versions_source (source) INDEX idx_discount_override_versions_source (source)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
@@ -83,6 +86,22 @@ class economic_v2_schema_bootstrap
$db->query($sql); $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; self::$initialized = true;
} }
@@ -106,4 +125,3 @@ class economic_v2_schema_bootstrap
return ((int)($row['c'] ?? 0)) > 0; return ((int)($row['c'] ?? 0)) > 0;
} }
} }
@@ -135,7 +135,9 @@ class economic_v2_versioning_service
string $source = 'live.discount_override', string $source = 'live.discount_override',
float $confidence = 1.0, float $confidence = 1.0,
bool $inferred = false, bool $inferred = false,
array $metadata = [] array $metadata = [],
?int $fixed_price = null,
?int $department_id = null
): array { ): array {
$identity = [ $identity = [
'user_id' => $user_id, 'user_id' => $user_id,
@@ -143,8 +145,11 @@ class economic_v2_versioning_service
'is_category' => (int)$is_category, 'is_category' => (int)$is_category,
'object_id' => (string)$object_id, '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( return $this->closeActiveVersion(
'customer_discount_override_versions', 'customer_discount_override_versions',
$identity, $identity,
@@ -161,6 +166,7 @@ class economic_v2_versioning_service
$identity, $identity,
[ [
'discount' => (int)$discount, 'discount' => (int)$discount,
'fixed_price' => $is_category ? null : $fixed_price,
], ],
$this->normalizeDatetime($effective_from), $this->normalizeDatetime($effective_from),
$source, $source,
@@ -238,15 +244,21 @@ class economic_v2_versioning_service
int $customer_number, int $customer_number,
bool $is_category, bool $is_category,
int|string $object_id, int|string $object_id,
string $timestamp string $timestamp,
?int $department_id = null
): ?array { ): ?array {
$rows = $this->resolveActiveVersions( $identity = [
'customer_discount_override_versions',
[
'customer_number' => $customer_number, 'customer_number' => $customer_number,
'is_category' => (int)$is_category, 'is_category' => (int)$is_category,
'object_id' => (string)$object_id, 'object_id' => (string)$object_id,
], ];
if ($department_id !== null) {
$identity['department_id'] = (int)$department_id;
}
$rows = $this->resolveActiveVersions(
'customer_discount_override_versions',
$identity,
$timestamp, $timestamp,
'effective_from DESC, id DESC', 'effective_from DESC, id DESC',
1 1
@@ -411,8 +423,11 @@ class economic_v2_versioning_service
} }
// Discount overrides current state. // Discount overrides current state.
price_overrides_schema_bootstrap::ensureColumns();
$has_override_created_at = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'created_at'); $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' . $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' : ''); ($has_override_created_at ? ', po.created_at' : '');
$discount_rows = $this->fetchAll( $discount_rows = $this->fetchAll(
"SELECT $discount_cols "SELECT $discount_cols
@@ -434,7 +449,8 @@ class economic_v2_versioning_service
'backfill.current_discount_override', 'backfill.current_discount_override',
$confidence, $confidence,
true, 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'); $this->incrementReportAction($report['discount_overrides'], $result['action'] ?? 'noop');
} }
@@ -707,4 +723,3 @@ class economic_v2_versioning_service
$bucket[$action]++; $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 private function sendEmailMailerSend(string $to, string $recipient_name, string $subject, string $message, string $html = null, string $references = null, array $attachments = []): void
{ {
if (self::isFakeDeliveryEnabled()) { if (self::isFakeDeliveryEnabled()) {
self::$fake_deliveries[] = [ self::recordFakeDelivery([
'to' => $to, 'to' => $to,
'recipient_name' => $recipient_name, 'recipient_name' => $recipient_name,
'subject' => $subject, 'subject' => $subject,
'message' => $message, 'message' => $message,
'html' => $html, 'html' => $html,
]; ]);
return; return;
} }
@@ -225,6 +225,72 @@ use Psr\Http\Client\ClientExceptionInterface;
public static function resetFakeDeliveries(): void public static function resetFakeDeliveries(): void
{ {
self::$fake_deliveries = []; self::$fake_deliveries = [];
$path = self::getFakeDeliveriesPath();
if ($path !== null && is_file($path)) {
unlink($path);
}
}
public static function syncFakeDeliveries(): void
{
$path = self::getFakeDeliveriesPath();
if ($path === null || !is_file($path)) {
self::$fake_deliveries = [];
return;
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
self::$fake_deliveries = [];
return;
}
$deliveries = [];
foreach ($lines as $line) {
$delivery = json_decode($line, true);
if (is_array($delivery)) {
$deliveries[] = $delivery;
}
}
self::$fake_deliveries = $deliveries;
}
private static function recordFakeDelivery(array $delivery): void
{
self::$fake_deliveries[] = $delivery;
$path = self::getFakeDeliveriesPath();
if ($path === null) {
return;
}
$directory = dirname($path);
if (!is_dir($directory)) {
mkdir($directory, 0777, true);
}
file_put_contents($path, json_encode($delivery, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL, FILE_APPEND | LOCK_EX);
}
private static function getFakeDeliveriesPath(): ?string
{
if (!self::isFakeDeliveryEnabled()) {
return null;
}
$configuredPath = trim((string)(getenv('EMAIL_FAKE_DELIVERIES_PATH') ?: ''));
if ($configuredPath !== '') {
return $configuredPath;
}
if (getenv('RUN_API_TESTS') !== '1') {
return null;
}
return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
. DIRECTORY_SEPARATOR
. 'truckwash-email-fake-deliveries-' . md5((string)getcwd()) . '.jsonl';
} }
private static function isFakeDeliveryEnabled(): bool private static function isFakeDeliveryEnabled(): bool
@@ -511,4 +577,47 @@ use Psr\Http\Client\ClientExceptionInterface;
$this->attachments $this->attachments
); );
} }
/**
* @throws Exception
*/
public function sendNewCustomerRegistrationNotifications(int $customer_number): void
{
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
if (!$customer->exists()) {
throw new Exception('Customer not found with customer number: ' . $customer_number);
}
$customerName = $customer->getCustomerName((int)$customer->customer_number->value()) ?: 'Unknown customer';
$safeCustomerName = htmlspecialchars($customerName, ENT_QUOTES, 'UTF-8');
$safeCustomerNumber = (int)$customer->customer_number->value();
$customerUrl = 'https://truckwash.io/superuser/users?search=' . $safeCustomerNumber;
$message = "
<p>A new customer has registered on truckwash.io.</p>
<p>
<strong>Customer number:</strong> $safeCustomerNumber<br>
<strong>Customer name:</strong> $safeCustomerName
</p>
<p><a href='$customerUrl'>Open customer in Superuser</a></p>
";
foreach ((new users_o())->getSuperuserNewCustomerEmailNotificationRecipients() as $recipient) {
$recipientEmail = trim((string)($recipient['email'] ?? ''));
if ($recipientEmail === '') {
continue;
}
$recipientName = trim((string)($recipient['display_name'] ?? ''));
if ($recipientName === '') {
$recipientName = $recipientEmail;
}
$this->sendEmail(
$recipientEmail,
$recipientName,
'New customer registered on Truck Wash',
$message,
);
}
}
} }
@@ -92,12 +92,17 @@ class error_report_service
throw new RuntimeException('Data collection acceptance is required.'); 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'] : []; $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); $requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25);
$vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25); $vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25);
$runtimeContext = $this->runtimeContext($payload, $context); $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( $this->execute(
"INSERT INTO error_reports ( "INSERT INTO error_reports (
@@ -295,6 +300,67 @@ class error_report_service
return $value === true || $value === 1 || $value === '1' || $value === 'true'; 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 private function runtimeContext(array $payload, array $context): array
{ {
return [ return [
@@ -432,6 +498,10 @@ class error_report_service
private function publicReport(array $row, bool $includeDetail): array 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 = [ $report = [
'id' => (int)$row['id'], 'id' => (int)$row['id'],
'status' => (string)$row['status'], 'status' => (string)$row['status'],
@@ -449,10 +519,10 @@ class error_report_service
'release_trace_id' => $row['release_trace_id'] ?? null, 'release_trace_id' => $row['release_trace_id'] ?? null,
'frontend_version' => $row['frontend_version'] ?? null, 'frontend_version' => $row['frontend_version'] ?? null,
'api_version' => $row['api_version'] ?? null, 'api_version' => $row['api_version'] ?? null,
'screenshot' => [ 'screenshot' => $hasScreenshot ? [
'mime_type' => $row['screenshot_mime_type'] ?? null, 'mime_type' => $screenshotMimeType,
'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0, 'size_bytes' => $screenshotSizeBytes,
], ] : null,
'answers' => [ 'answers' => [
'before_error' => $row['before_error'] ?? '', 'before_error' => $row['before_error'] ?? '',
'expected' => $row['expected'] ?? '', 'expected' => $row['expected'] ?? '',
@@ -467,8 +537,11 @@ class error_report_service
]; ];
if ($includeDetail) { if ($includeDetail) {
$report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? '')); if ($hasScreenshot) {
$report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null; $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['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null); $report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null); $report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
@@ -38,6 +38,8 @@ class gateway_shelly_transport implements shelly_transport_i
return match ($endpoint) { return match ($endpoint) {
'/v2/devices/api/get' => $this->handleGetStates($department_id, $data), '/v2/devices/api/get' => $this->handleGetStates($department_id, $data),
'/v2/devices/api/set/switch' => $this->handleSetSwitch($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), 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)]; 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 * @param array<string,mixed> $status
* @return array<string,mixed> * @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));
}
}
@@ -22,6 +22,7 @@ class invoice_period_flag_service
private const ORDER_FIELDS = ['customer', 'reference', 'po', 'notes']; private const ORDER_FIELDS = ['customer', 'reference', 'po', 'notes'];
private const ORDER_ITEM_FIELDS = ['notes', 'quantity', 'reference', 'price']; private const ORDER_ITEM_FIELDS = ['notes', 'quantity', 'reference', 'price'];
private const WASH_CERTIFICATE_PRODUCT_ID = 41; private const WASH_CERTIFICATE_PRODUCT_ID = 41;
private const SPOT_FREE_PRODUCT_IDS = [23, 24];
private array $economicCustomerDiscountCache = []; private array $economicCustomerDiscountCache = [];
private array $userDisplayNameCache = []; private array $userDisplayNameCache = [];
private array $orderItemsPreviewCache = []; private array $orderItemsPreviewCache = [];
@@ -30,6 +31,8 @@ class invoice_period_flag_service
public function __construct() public function __construct()
{ {
invoice_period_flag_schema_bootstrap::ensureTables(); 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 public function createManualFlag(array $payload, int $userId): array
@@ -688,6 +691,7 @@ class invoice_period_flag_service
o.po AS order_po, o.po AS order_po,
o.notes AS order_notes, o.notes AS order_notes,
o.department_id, o.department_id,
d.custom_pricing_only AS department_custom_pricing_only,
o.reg_1, o.reg_1,
o.invoice_collection_id, o.invoice_collection_id,
o.wash_id, o.wash_id,
@@ -710,8 +714,21 @@ class invoice_period_flag_service
p.max_quantity_per_order, p.max_quantity_per_order,
c.name AS category_name, c.name AS category_name,
pdp.price AS department_price, pdp.price AS department_price,
product_discount.percentage AS product_discount_percentage, CASE
category_discount.percentage AS category_discount_percentage 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 FROM orders o
LEFT JOIN ( LEFT JOIN (
SELECT customer_number, MIN(id) AS id, MAX(display_name) AS display_name SELECT customer_number, MIN(id) AS id, MAX(display_name) AS display_name
@@ -720,11 +737,12 @@ class invoice_period_flag_service
GROUP BY customer_number GROUP BY customer_number
) u ON u.customer_number = o.customer_id ) 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 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 products p ON p.id = oi.product_id
LEFT JOIN categories c ON c.id = p.category 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 product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id
LEFT JOIN ( 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 FROM price_overrides po
INNER JOIN users discount_user ON discount_user.id = po.user_id INNER JOIN users discount_user ON discount_user.id = po.user_id
WHERE po.is_category = 0 WHERE po.is_category = 0
@@ -741,6 +759,32 @@ class invoice_period_flag_service
) category_discount ) category_discount
ON category_discount.customer_number = o.customer_id ON category_discount.customer_number = o.customer_id
AND category_discount.product_or_category_id = p.category 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}' WHERE o.created_at BETWEEN '{$escapedDateFrom}' AND '{$escapedDateTo}'
AND o.deleted_at IS NULL AND o.deleted_at IS NULL
ORDER BY o.customer_id, o.id, oi.id"; ORDER BY o.customer_id, o.id, oi.id";
@@ -896,7 +940,7 @@ class invoice_period_flag_service
} }
$restrictedProducts = [ $restrictedProducts = [
'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree']], 'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree', 'skylning med ro']],
'restrictInteriorCleaning' => ['customer_rule_restrict_interior_cleaning', ['interior', 'indvendig']], 'restrictInteriorCleaning' => ['customer_rule_restrict_interior_cleaning', ['interior', 'indvendig']],
'exemptFromAdministrationFee' => ['customer_rule_exempt_from_administration_fees', ['administration fee', 'administrationsgebyr', 'administration']], 'exemptFromAdministrationFee' => ['customer_rule_exempt_from_administration_fees', ['administration fee', 'administrationsgebyr', 'administration']],
]; ];
@@ -1921,7 +1965,19 @@ class invoice_period_flag_service
private function calculateExpectedPrice(array $row): int 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']; $discount = $this->discountBreakdown($row)['applied_discount_percentage'];
return (int)round($base * (1 - ($discount / 100))); return (int)round($base * (1 - ($discount / 100)));
} }
@@ -1929,13 +1985,18 @@ class invoice_period_flag_service
private function priceBreakdown(array $row, int $expected): array private function priceBreakdown(array $row, int $expected): array
{ {
$departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null; $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); $discount = $this->discountBreakdown($row);
if ($customMissingPrice) {
$discount['applied_discount_percentage'] = 0;
}
return [ 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, 'department_price' => $departmentPrice,
'effective_base_price' => $base, 'effective_base_price' => $base,
'product_fixed_price' => $this->rowProductFixedPrice($row),
'product_discount_percentage' => $discount['product_discount_percentage'], 'product_discount_percentage' => $discount['product_discount_percentage'],
'category_discount_percentage' => $discount['category_discount_percentage'], 'category_discount_percentage' => $discount['category_discount_percentage'],
'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'], 'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'],
@@ -1944,21 +2005,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 private function discountBreakdown(array $row): array
{ {
$productDiscount = (int)($row['product_discount_percentage'] ?? 0); $productDiscount = (int)($row['product_discount_percentage'] ?? 0);
$categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1; $categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1;
$categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0; $categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0;
$economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0; $economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0;
$appliedDiscount = $this->rowProductFixedPrice($row) !== null
? 0
: max($productDiscount, $categoryDiscount, $economicDiscount);
return [ return [
'product_discount_percentage' => $productDiscount, 'product_discount_percentage' => $productDiscount,
'category_discount_percentage' => $categoryDiscount, 'category_discount_percentage' => $categoryDiscount,
'economic_customer_discount_percentage' => $economicDiscount, '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 private function economicCustomerDiscountPercentage(array $row): int
{ {
$customerNumber = (int)($row['customer_number'] ?? 0); $customerNumber = (int)($row['customer_number'] ?? 0);
@@ -2022,6 +2098,11 @@ class invoice_period_flag_service
private function rowMatchesProductTerms(array $row, array $terms): bool private function rowMatchesProductTerms(array $row, array $terms): bool
{ {
if (in_array((int)($row['product_id'] ?? 0), self::SPOT_FREE_PRODUCT_IDS, true)
&& in_array('spotfree', array_map('strtolower', $terms), true)) {
return true;
}
$haystack = strtolower(trim( $haystack = strtolower(trim(
(string)($row['product_name'] ?? '') . ' ' . (string)($row['product_name'] ?? '') . ' ' .
(string)($row['category_name'] ?? '') (string)($row['category_name'] ?? '')
@@ -2036,8 +2117,7 @@ class invoice_period_flag_service
private function rowIsTankCleaningProduct(array $row): bool private function rowIsTankCleaningProduct(array $row): bool
{ {
return (int)($row['product_category'] ?? 0) === 5 return customer_order_product_policy::isTankCleaningProductRow($row);
|| $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
} }
private function isIncludedOrderItem(array $row): bool private function isIncludedOrderItem(array $row): bool
@@ -10,6 +10,22 @@ use licenseplaterecognizer\licenseplaterecognizer_c;
class licenseplaterecognizer implements licenseplaterecognizer_i class licenseplaterecognizer implements licenseplaterecognizer_i
{ {
private const DEFAULT_API_URL = 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk';
private const PLATE_READER_CONFIG_JSON = '{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0}';
private const RESULT_CACHE_CONTEXT = '{"config":{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0},"regions":"dk,de,se,no"}';
private const PLATE_READER_REGIONS = 'dk,de,se,no';
private const DEFAULT_UPLOAD_FILE_NAME = 'license-plate.jpg';
private const RUNTIME_CONFIG_CACHE_TTL_SECONDS = 15;
private const RUNTIME_CONFIG_REDIS_CACHE_KEY = 'licenseplaterecognizer:runtime_config:v1';
private const RESULT_CACHE_TTL_SECONDS = 10;
private const RESULT_CACHE_REDIS_KEY_PREFIX = 'licenseplaterecognizer:result:v1:';
private const PLATE_READER_CONNECT_TIMEOUT_MS = 1000;
private const PLATE_READER_TOTAL_TIMEOUT_MS = 4500;
/**
* @var array<string, float>
*/
private array $last_timings = [];
/** /**
* The configuration of the module * The configuration of the module
* @var licenseplaterecognizer_c * @var licenseplaterecognizer_c
@@ -19,13 +35,26 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
* API URL * API URL
* @var string * @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->api_url = self::normalizeApiUrl($api_url ?? self::configuredApiUrl());
if ($load_config) {
$this->config = new licenseplaterecognizer_c(); $this->config = new licenseplaterecognizer_c();
} }
}
/** /**
@@ -33,7 +62,7 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
*/ */
public function requireModuleEnabled(): void public function requireModuleEnabled(): void
{ {
if (!(bool)$this->config->enabled->getVariableValue()) { if (!$this->runtimeConfig()['enabled']) {
throw new Exception('licenseplaterecognizer module is not enabled.'); throw new Exception('licenseplaterecognizer module is not enabled.');
} }
} }
@@ -45,52 +74,514 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
*/ */
public function licenseplaterecognizer(string $base64_image): array public function licenseplaterecognizer(string $base64_image): array
{ {
$image_processor = new image_processor(); return $this->recognizePlate(
fn () => $this->buildPlateReaderPayload($base64_image),
//ADD PARAMETER IN REQUEST LIKE regions fn () => $this->buildResultCacheKeyFromUploadString($base64_image)
$data = array(
'upload' => $base64_image,
//'regions' => 'dk' // Optional
); );
}
// Prepare new cURL resource public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array
//$ch = curl_init('https://api.platerecognizer.com/v1/plate-reader/'); {
$ch = curl_init($this->api_url . '/v1/plate-reader/'); return $this->recognizePlate(
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); fn () => $this->buildPlateReaderPayloadFromUpload(
curl_setopt($ch, CURLINFO_HEADER_OUT, true); $this->buildUploadValueFromBytes($image_data, $mime_type)
curl_setopt($ch, CURLOPT_POST, true); ),
curl_setopt($ch, CURLOPT_POSTFIELDS, $data); fn () => $this->buildResultCacheKeyFromBytes($image_data)
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2TLS); );
}
// Set HTTP Header for POST request public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array
curl_setopt($ch, CURLOPT_HTTPHEADER, array( {
"Authorization: Token " . $this->config->api_key->getVariableValue() return $this->recognizePlate(
fn () => $this->buildPlateReaderPayloadFromUpload(
$this->buildUploadValueFromBytes($image_data, $mime_type)
) )
); );
}
public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array
{
return $this->recognizePlate(
fn () => $this->buildPlateReaderPayloadFromUpload(
$this->buildUploadValueFromFile($image_path, $mime_type)
)
);
}
/**
* @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:',
],
];
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 // Submit the POST request and close cURL session handle
$upstream_started_at = microtime(true);
$result = curl_exec($ch); $result = curl_exec($ch);
$this->last_timings['upstream'] = $this->elapsedMs($upstream_started_at);
$this->recordCurlTimings($ch);
curl_close($ch); curl_close($ch);
// Print the response from the server // Print the response from the server
if ($result === false) { if ($result === false) {
throw new Exception('Error in API request.'); throw new Exception('Error in API request.');
} }
$parse_started_at = microtime(true);
$response_data = json_decode($result, 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) { if (isset($response_data['results']) && count($response_data['results']) > 0) {
return [ $recognized_result = [
'success' => true, 'success' => true,
'license_plate_number' => $response_data['results'][0]['plate'] ?? null, 'license_plate_number' => $response_data['results'][0]['plate'] ?? null,
'confidence' => $response_data['results'][0]['score'] ?? null, 'confidence' => $response_data['results'][0]['score'] ?? null,
'raw_response' => $response_data,
]; ];
} else {
return [ $this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
return $recognized_result;
}
$recognized_result = [
'success' => false, 'success' => false,
'message' => 'No license plate detected.', 'message' => 'No license plate detected.',
'raw_response' => $response_data, ];
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
return $recognized_result;
} finally {
$this->last_timings['total'] = $this->elapsedMs($started_at);
}
}
private static function clientDisconnectAbortCallback(): callable
{
return static function (): int {
return connection_aborted() ? 1 : 0;
};
}
public function getLastTimings(): array
{
return $this->last_timings;
}
private function elapsedMs(float $started_at): float
{
return (microtime(true) - $started_at) * 1000;
}
private static function configuredApiUrl(): string
{
$configured = getenv('PLATE_RECOGNIZER_API_URL');
if ($configured === false || trim((string)$configured) === '') {
$configured = $_ENV['PLATE_RECOGNIZER_API_URL'] ?? $_SERVER['PLATE_RECOGNIZER_API_URL'] ?? self::DEFAULT_API_URL;
}
return (string)$configured;
}
private static function normalizeApiUrl(string $api_url): string
{
$api_url = trim($api_url);
if ($api_url === '') {
return self::DEFAULT_API_URL;
}
return rtrim($api_url, '/');
}
private function recordCurlTimings(\CurlHandle $curl_handle): void
{
$mapping = [
CURLINFO_NAMELOOKUP_TIME => 'upstream_dns',
CURLINFO_CONNECT_TIME => 'upstream_connect',
CURLINFO_APPCONNECT_TIME => 'upstream_tls',
CURLINFO_PRETRANSFER_TIME => 'upstream_pretransfer',
CURLINFO_STARTTRANSFER_TIME => 'upstream_ttfb',
CURLINFO_TOTAL_TIME => 'upstream_total',
];
foreach ($mapping as $curl_info_option => $timing_key) {
$value = curl_getinfo($curl_handle, $curl_info_option);
if (!is_numeric($value)) {
continue;
}
$this->last_timings[$timing_key] = max(0, (float)$value * 1000);
}
}
private function recordResponseTimings(mixed $response_data): void
{
if (!is_array($response_data) || !isset($response_data['processing_time']) || !is_numeric($response_data['processing_time'])) {
return;
}
$this->last_timings['upstream_processing'] = max(0, (float)$response_data['processing_time']);
}
private function buildResultCacheKeyFromUploadString(string $base64_image): string
{
$base64_image = trim($base64_image);
if (preg_match('/^data:image\/[a-zA-Z0-9.+-]+;base64,(.*)$/s', $base64_image, $matches) === 1) {
$image_data = base64_decode((string)$matches[1], true);
if (is_string($image_data)) {
return $this->buildResultCacheKeyFromBytes($image_data);
}
}
return $this->buildResultCacheKeyFromBytes($base64_image);
}
private function buildResultCacheKeyFromBytes(string $image_data): string
{
$context = hash_init('sha256');
hash_update($context, $this->resultCacheContext());
hash_update($context, "\0");
hash_update($context, $image_data);
return self::RESULT_CACHE_REDIS_KEY_PREFIX . hash_final($context);
}
private function resultCacheContext(): string
{
return self::RESULT_CACHE_CONTEXT;
}
protected function resultCacheStore(): ?object
{
return $this->runtimeConfigCacheStore();
}
private function readRecognitionResultCache(?object $cache, ?string $key): ?array
{
if ($cache === null || $key === null || !method_exists($cache, 'get')) {
return null;
}
try {
$cached = $cache->get($key);
} catch (\Throwable) {
return null;
}
if (!is_string($cached) || trim($cached) === '') {
return null;
}
$decoded = json_decode($cached, true);
if (!is_array($decoded) || !array_key_exists('success', $decoded)) {
return null;
}
return $decoded;
}
private function writeRecognitionResultCache(?object $cache, ?string $key, array $result): void
{
if ($cache === null || $key === null || !method_exists($cache, 'setEx')) {
return;
}
try {
$encoded = json_encode($result, JSON_UNESCAPED_SLASHES);
if (is_string($encoded)) {
$cache->setEx($key, $encoded, self::RESULT_CACHE_TTL_SECONDS);
}
} catch (\Throwable) {
// Scanner result cache is best-effort; Plate Recognizer remains the source of truth.
}
}
protected function buildPlateReaderPayload(string $base64_image): array
{
return $this->buildPlateReaderPayloadFromUpload($this->buildUploadValue($base64_image));
}
protected function buildPlateReaderPayloadFromUpload(string|\CURLFile|\CURLStringFile $upload): array
{
return [
'upload' => $upload,
'config' => self::PLATE_READER_CONFIG_JSON,
'regions' => self::PLATE_READER_REGIONS,
]; ];
} }
private function buildUploadValue(string $base64_image): string|\CURLStringFile
{
$base64_image = trim($base64_image);
if (preg_match('/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.*)$/s', $base64_image, $matches) !== 1) {
return $base64_image;
}
$image_data = base64_decode((string)$matches[2], true);
if ($image_data === false || !class_exists(\CURLStringFile::class)) {
return (string)$matches[2];
}
return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, (string)$matches[1]);
}
private function buildUploadValueFromBytes(string $image_data, string $mime_type): string|\CURLStringFile
{
$mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg';
if (!str_starts_with($mime_type, 'image/')) {
$mime_type = 'image/jpeg';
}
if (!class_exists(\CURLStringFile::class)) {
return $image_data;
}
return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, $mime_type);
}
/**
* @throws Exception
*/
private function buildUploadValueFromFile(string $image_path, string $mime_type): \CURLFile
{
$image_path = trim($image_path);
$mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg';
if (!str_starts_with($mime_type, 'image/')) {
$mime_type = 'image/jpeg';
}
if ($image_path === '' || !is_file($image_path) || !class_exists(\CURLFile::class)) {
throw new Exception('Image upload file is invalid.');
}
return new \CURLFile($image_path, $mime_type, self::DEFAULT_UPLOAD_FILE_NAME);
}
protected function runtimeConfig(): array
{
if ($this->runtime_config !== null) {
return $this->runtime_config;
}
if ($this->shouldUseSharedRuntimeConfigCache()) {
$cached_config = self::getSharedRuntimeConfigCache();
if ($cached_config !== null) {
$this->runtime_config = $cached_config;
return $this->runtime_config;
}
$cached_config = $this->readRuntimeConfigCacheStore();
if ($cached_config !== null) {
self::$runtime_config_cache = [
'values' => $cached_config,
'cached_at' => microtime(true),
];
$this->runtime_config = $cached_config;
return $this->runtime_config;
}
}
$values = $this->readRuntimeModuleConfig();
$this->runtime_config = [
'enabled' => $this->parseModuleConfigBool($values['enabled'] ?? false),
'api_key' => (string)($values['api_key'] ?? ''),
];
if ($this->shouldUseSharedRuntimeConfigCache()) {
self::$runtime_config_cache = [
'values' => $this->runtime_config,
'cached_at' => microtime(true),
];
$this->writeRuntimeConfigCacheStore($this->runtime_config);
}
return $this->runtime_config;
}
protected function shouldUseSharedRuntimeConfigCache(): bool
{
return static::class === self::class;
}
private static function getSharedRuntimeConfigCache(): ?array
{
if (self::$runtime_config_cache === null) {
return null;
}
$cache_age_seconds = microtime(true) - self::$runtime_config_cache['cached_at'];
if ($cache_age_seconds > self::RUNTIME_CONFIG_CACHE_TTL_SECONDS) {
self::$runtime_config_cache = null;
return null;
}
return self::$runtime_config_cache['values'];
}
protected function runtimeConfigCacheStore(): ?object
{
return defined('redis') ? constant('redis') : null;
}
private function readRuntimeConfigCacheStore(): ?array
{
$cache = $this->runtimeConfigCacheStore();
if ($cache === null || !method_exists($cache, 'get')) {
return null;
}
try {
$cached = $cache->get(self::RUNTIME_CONFIG_REDIS_CACHE_KEY);
} catch (\Throwable) {
return null;
}
if (!is_string($cached) || trim($cached) === '') {
return null;
}
$decoded = json_decode($cached, true);
if (!is_array($decoded)) {
return null;
}
if (!array_key_exists('enabled', $decoded) || !array_key_exists('api_key', $decoded)) {
return null;
}
return [
'enabled' => $this->parseModuleConfigBool($decoded['enabled']),
'api_key' => (string)$decoded['api_key'],
];
}
/**
* @param array{enabled: bool, api_key: string} $config
*/
private function writeRuntimeConfigCacheStore(array $config): void
{
$cache = $this->runtimeConfigCacheStore();
if ($cache === null || !method_exists($cache, 'setEx')) {
return;
}
try {
$encoded = json_encode($config, JSON_UNESCAPED_SLASHES);
if (is_string($encoded)) {
$cache->setEx(self::RUNTIME_CONFIG_REDIS_CACHE_KEY, $encoded, self::RUNTIME_CONFIG_CACHE_TTL_SECONDS);
}
} catch (\Throwable) {
// Scanner config cache is best-effort; DB remains the source of truth.
}
}
private function parseModuleConfigBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_numeric($value)) {
return (int)$value === 1;
}
return strtolower(trim((string)$value)) === 'true';
}
protected function readRuntimeModuleConfig(): array
{
global $db;
if ($db instanceof db) {
$module = $db->escape_string('licenseplaterecognizer');
$result = $db->query("SELECT variable, value FROM module_config WHERE module = '$module' AND variable IN ('enabled', 'api_key')");
$values = [];
if ($result instanceof \mysqli_result) {
while ($row = $result->fetch_assoc()) {
$variable = (string)($row['variable'] ?? '');
if ($variable !== '') {
$values[$variable] = (string)($row['value'] ?? '');
}
}
}
return $values;
}
if (!isset($this->config)) {
$this->config = new licenseplaterecognizer_c();
}
return [
'enabled' => (string)$this->config->enabled->getVariableValue(),
'api_key' => (string)$this->config->api_key->getVariableValue(),
];
} }
/** /**
@@ -100,8 +591,8 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
*/ */
public function get_usage(): licenseplaterecognizer_info public function get_usage(): licenseplaterecognizer_info
{ {
// Require the module to be enabled
$this->requireModuleEnabled(); $this->requireModuleEnabled();
$api_key = $this->runtimeConfig()['api_key'];
$curl = curl_init(); $curl = curl_init();
curl_setopt_array($curl, array( curl_setopt_array($curl, array(
CURLOPT_URL => $this->api_url . '/info/', CURLOPT_URL => $this->api_url . '/info/',
@@ -112,9 +603,9 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
CURLOPT_FOLLOWLOCATION => true, CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
CURLOPT_CUSTOMREQUEST => 'GET', CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => array( CURLOPT_HTTPHEADER => [
'Authorization: Token ' . $this->config->api_key->getVariableValue() 'Authorization: Token ' . $api_key,
), ],
)); ));
$response = curl_exec($curl); $response = curl_exec($curl);
curl_close($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
+5 -1
View File
@@ -47,8 +47,12 @@ class pdf_store implements minio_pdfs_i
*/ */
public function download(string $file): string public function download(string $file): string
{ {
if ($this->shouldUseLocalTestStorage()) {
return $this->getLocalTestObjectPath($file);
}
$path = '/tmp/' . $file; $path = '/tmp/' . $file;
$result = self::getS3Client()->getObject([ self::getS3Client()->getObject([
'Bucket' => self::getBucket(), 'Bucket' => self::getBucket(),
'Key' => $file, 'Key' => $file,
'SaveAs' => $path 'SaveAs' => $path
@@ -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);
}
}
+223 -15
View File
@@ -1434,6 +1434,8 @@ class release_manager
]; ];
} }
$expectedCommit = self::normalizeCommitSha((string)($gateInput['expected_commit'] ?? ''));
$enforceExpectedCommit = $expectedCommit !== '' && !$this->releaseGateAutoSyncRequested($gateInput);
$checked = []; $checked = [];
try { try {
foreach ($gateInput['api_ping_paths'] as $path) { foreach ($gateInput['api_ping_paths'] as $path) {
@@ -1442,9 +1444,19 @@ class release_manager
if (array_key_exists('success', $payload) && $payload['success'] !== true) { if (array_key_exists('success', $payload) && $payload['success'] !== true) {
throw new RuntimeException(sprintf('%s returned success=false.', $path)); throw new RuntimeException(sprintf('%s returned success=false.', $path));
} }
$actualCommit = $this->releaseGateApiPayloadCommitSha($payload);
if ($enforceExpectedCommit && !$this->releaseGateCommitMatches($actualCommit, $expectedCommit)) {
throw new RuntimeException(sprintf(
'%s returned commit %s, expected %s.',
$path,
$actualCommit !== '' ? $actualCommit : 'unknown',
$expectedCommit
));
}
$checked[] = [ $checked[] = [
'path' => $path, 'path' => $path,
'status' => $json['status'], 'status' => $json['status'],
'commit_sha' => $actualCommit !== '' ? $actualCommit : null,
]; ];
} }
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
@@ -1471,10 +1483,24 @@ class release_manager
'context' => [ 'context' => [
'api_base_url' => $apiBaseUrl, 'api_base_url' => $apiBaseUrl,
'checked' => $checked, 'checked' => $checked,
'expected_commit' => $expectedCommit !== '' ? $expectedCommit : null,
], ],
]; ];
} }
private function releaseGateApiPayloadCommitSha(array $payload): string
{
$data = is_array($payload['data'] ?? null) ? $payload['data'] : $payload;
foreach (['api_commit_sha', 'backend_version', 'commit_sha', 'version'] as $key) {
$commit = self::normalizeCommitSha((string)($data[$key] ?? ''));
if ($commit !== '') {
return $commit;
}
}
return '';
}
private function releaseGateFetchJson(string $baseUrl, string $path): array private function releaseGateFetchJson(string $baseUrl, string $path): array
{ {
$result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $path)); $result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $path));
@@ -2066,11 +2092,7 @@ class release_manager
$eventId = (int)$event['id']; $eventId = (int)$event['id'];
if (!$this->acquireReleaseAutoSyncLock($eventId)) { if (!$this->acquireReleaseAutoSyncLock($eventId)) {
return [ return $this->waitForReleaseAutoSyncEventResult($eventId, $channelId, $app, $commitSha, $gateInput);
'step_status' => 'passed',
'message' => 'Automatic container update is already being processed for this commit.',
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
];
} }
try { try {
@@ -2152,6 +2174,67 @@ class release_manager
} }
} }
private function waitForReleaseAutoSyncEventResult(int $eventId, int $channelId, string $app, string $commitSha, array $gateInput): array
{
$timeout = max(0, min(300, (int)($gateInput['wait_timeout_seconds'] ?? 300)));
$pollInterval = max(1, min(60, (int)($gateInput['poll_interval_seconds'] ?? 10)));
$deadline = time() + $timeout;
$attempts = 0;
$lastStatus = 'unknown';
do {
$attempts++;
$event = $this->releaseAutoSyncEventById($eventId);
if ($event === null) {
throw new RuntimeException('Automatic container update disappeared while another request was processing it.');
}
$lastStatus = (string)($event['status'] ?? 'unknown');
if (in_array($lastStatus, ['promoted', 'deployed'], true)) {
$deployment = null;
$deploymentId = $this->nullablePositiveInt($event['deployment_id'] ?? null);
if ($deploymentId !== null) {
$deployment = $this->getDeployment($deploymentId);
}
$deployment ??= $this->currentDeploymentForChannelApp($channelId, $app);
if ($deployment !== null && $this->releaseGateCommitMatches((string)($deployment['commit_sha'] ?? ''), $commitSha)) {
return [
'step_status' => 'passed',
'message' => sprintf('%s container update completed by an in-flight request at %s.', strtoupper($app), substr($commitSha, 0, 12)),
'deployment' => $this->publicDeployment($deployment),
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
'attempts' => $attempts,
];
}
throw new RuntimeException(sprintf(
'Automatic container update completed for event %d but the active %s deployment does not match %s.',
$eventId,
strtoupper($app),
$commitSha
));
}
if ($lastStatus === 'failed') {
$message = trim((string)($event['error_message'] ?? 'Automatic container update failed in another request.'));
throw new RuntimeException($message !== '' ? $message : 'Automatic container update failed in another request.');
}
if (time() >= $deadline) {
break;
}
sleep($pollInterval);
} while (true);
throw new RuntimeException(sprintf(
'Automatic container update is already being processed for event %d but did not finish within %d seconds; last status was %s.',
$eventId,
$timeout,
$lastStatus
));
}
private function upsertReleaseAutoSyncEvent(array $input): array private function upsertReleaseAutoSyncEvent(array $input): array
{ {
$channelId = (int)$input['channel_id']; $channelId = (int)$input['channel_id'];
@@ -6232,6 +6315,7 @@ class release_manager
} }
$deployment = $client->deployResource($serviceUuid, $this->releaseCoolifyForceRebuild($context)); $deployment = $client->deployResource($serviceUuid, $this->releaseCoolifyForceRebuild($context));
$previousApplications = $this->stopCoolifyPreviousApplications($client, $context, $serviceUuid);
return [ return [
'service_uuid' => $serviceUuid, 'service_uuid' => $serviceUuid,
'resource_type' => $resourceType, 'resource_type' => $resourceType,
@@ -6241,9 +6325,76 @@ class release_manager
'updated' => self::redactPayload($update ?? []), 'updated' => self::redactPayload($update ?? []),
'runtime_env' => $runtimeEnvUpdate, 'runtime_env' => $runtimeEnvUpdate,
'deployment' => self::redactPayload($deployment), 'deployment' => self::redactPayload($deployment),
'previous_applications' => self::redactPayload($previousApplications),
]; ];
} }
private function stopCoolifyPreviousApplications(coolify_api_client $client, array $context, string $activeUuid): array
{
$stopped = [];
foreach ($this->releaseCoolifyPreviousApplicationUuids($context, $activeUuid) as $uuid) {
try {
$stopped[] = [
'uuid' => $uuid,
'status' => 'stop_requested',
'result' => $client->stopApplication($uuid),
];
} catch (Throwable $throwable) {
$stopped[] = [
'uuid' => $uuid,
'status' => 'warning',
'error' => $throwable->getMessage(),
];
}
}
return $stopped;
}
private function releaseCoolifyPreviousApplicationUuids(array $context, string $activeUuid): array
{
$values = [];
foreach ([
'coolify_previous_application_uuid',
'coolify_previous_artifact_app_uuid',
'coolify_previous_artifact_application_uuid',
'previous_application_uuid',
'previous_app_uuid',
] as $key) {
if (is_scalar($context[$key] ?? null)) {
$values[] = (string)$context[$key];
}
}
foreach ([
'coolify_previous_application_uuids',
'coolify_previous_artifact_app_uuids',
'previous_application_uuids',
'previous_app_uuids',
] as $key) {
if (!is_array($context[$key] ?? null)) {
continue;
}
foreach ($context[$key] as $value) {
if (is_scalar($value)) {
$values[] = (string)$value;
}
}
}
$activeUuid = trim($activeUuid);
$uuids = [];
foreach ($values as $value) {
$uuid = trim((string)$value);
if ($uuid === '' || $uuid === $activeUuid || in_array($uuid, $uuids, true)) {
continue;
}
$uuids[] = $uuid;
}
return $uuids;
}
private function updateCoolifyReleaseRuntimeEnv(coolify_api_client $client, string $resourceUuid, string $resourceType, array $target, array $context): ?array private function updateCoolifyReleaseRuntimeEnv(coolify_api_client $client, string $resourceUuid, string $resourceType, array $target, array $context): ?array
{ {
$env = $this->releaseCoolifyRuntimeEnv($target, $context); $env = $this->releaseCoolifyRuntimeEnv($target, $context);
@@ -6252,6 +6403,7 @@ class release_manager
} }
if ($resourceType === 'application') { if ($resourceType === 'application') {
$this->deleteCoolifyGeneratedCommitEnvs($client, $resourceUuid, $target, $context);
$client->updateApplicationEnvsBulk($resourceUuid, $env); $client->updateApplicationEnvsBulk($resourceUuid, $env);
} else { } else {
$client->updateServiceEnvsBulk($resourceUuid, $env); $client->updateServiceEnvsBulk($resourceUuid, $env);
@@ -6264,12 +6416,45 @@ class release_manager
]; ];
} }
private function deleteCoolifyGeneratedCommitEnvs(coolify_api_client $client, string $resourceUuid, array $target, array $context): void
{
$keys = $this->releaseCoolifyGeneratedCommitEnvKeys($target, $context);
if ($keys === []) {
return;
}
try {
$rows = $this->payloadRows($client->listApplicationEnvs($resourceUuid));
} catch (Throwable) {
return;
}
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$key = trim((string)($row['key'] ?? $row['name'] ?? ''));
$uuid = trim((string)($row['uuid'] ?? $row['id'] ?? ''));
if ($key === '' || $uuid === '' || !in_array($key, $keys, true)) {
continue;
}
try {
$client->deleteApplicationEnv($resourceUuid, $uuid);
} catch (Throwable) {
}
}
}
private function releaseCoolifyRuntimeEnv(array $target, array $context): array private function releaseCoolifyRuntimeEnv(array $target, array $context): array
{ {
$contextEnv = $this->releaseCoolifyContextEnv($context); $contextEnv = $this->releaseCoolifyContextEnv($context);
$env = $contextEnv; $env = $contextEnv;
$app = strtolower(trim((string)($target['app'] ?? ''))); $app = strtolower(trim((string)($target['app'] ?? '')));
$deploymentCommitSha = self::normalizeCommitSha($this->releaseCoolifyGitCommitSha($target, $context));
if ($app !== 'api') { if ($app !== 'api') {
$this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
return $env; return $env;
} }
@@ -6291,21 +6476,37 @@ class release_manager
$this->appendRuntimeEnvValue($env, $key, $value); $this->appendRuntimeEnvValue($env, $key, $value);
} }
$deploymentCommitSha = self::normalizeCommitSha($this->releaseCoolifyGitCommitSha($target, $context));
if ($deploymentCommitSha !== '') {
foreach (['API_COMMIT_SHA', 'COMMIT_SHA'] as $key) {
if (!array_key_exists($key, $contextEnv)) {
$env[$key] = $deploymentCommitSha;
}
}
}
$env = array_replace($env, $contextEnv); $env = array_replace($env, $contextEnv);
$env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true'; $env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true';
$env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? '')); $env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? ''));
$this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
return $this->normalizeCoolifyRuntimeEnv($env); return $this->normalizeCoolifyRuntimeEnv($env);
} }
private function applyReleaseCoolifyCommitRuntimeEnv(array &$env, string $app, string $deploymentCommitSha): void
{
if ($deploymentCommitSha === '') {
return;
}
foreach ($this->releaseCoolifyGeneratedCommitEnvKeys(['app' => $app], []) as $key) {
$env[$key] = $deploymentCommitSha;
}
}
private function releaseCoolifyGeneratedCommitEnvKeys(array $target, array $context): array
{
$app = strtolower(trim((string)($target['app'] ?? $context['app'] ?? '')));
if ($app === 'frontend') {
return ['SOURCE_COMMIT', 'RELEASE_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'VITE_COMMIT_HASH'];
}
if ($app === 'api') {
return ['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA'];
}
return [];
}
private function releaseCoolifyContextEnv(array $context): array private function releaseCoolifyContextEnv(array $context): array
{ {
$env = []; $env = [];
@@ -7013,6 +7214,13 @@ class release_manager
private function releaseCoolifyGitCommitSha(array $target, array $context): string private function releaseCoolifyGitCommitSha(array $target, array $context): string
{ {
foreach (['commit_sha', 'commit'] as $key) {
$value = trim((string)($target[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
foreach ([ foreach ([
'coolify_git_commit_sha', 'coolify_git_commit_sha',
'git_commit_sha', 'git_commit_sha',
@@ -8928,7 +9136,7 @@ class release_manager
if (array_keys($payload) === range(0, count($payload) - 1)) { if (array_keys($payload) === range(0, count($payload) - 1)) {
return $payload; return $payload;
} }
foreach (['data', 'services', 'projects', 'servers', 'github_apps', 'results'] as $key) { foreach (['data', 'services', 'projects', 'servers', 'github_apps', 'envs', 'environment_variables', 'results'] as $key) {
if (is_array($payload[$key] ?? null)) { if (is_array($payload[$key] ?? null)) {
return $this->payloadRows($payload[$key]); return $this->payloadRows($payload[$key]);
} }
+1 -1
View File
@@ -89,7 +89,7 @@ class router
} }
// Regex // Regex
$route = str_replace('/', '\/', $route); $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)) { if (preg_match('/^' . $route . '$/', $this->url)) {
return true; return true;
} }
@@ -73,7 +73,9 @@ class selfserve_schema_bootstrap
INDEX idx_selfserve_wash_sessions_lane_reg (lane_id, reg), INDEX idx_selfserve_wash_sessions_lane_reg (lane_id, reg),
INDEX idx_selfserve_wash_sessions_status (status), INDEX idx_selfserve_wash_sessions_status (status),
INDEX idx_selfserve_wash_sessions_customer (customer_number), INDEX idx_selfserve_wash_sessions_customer (customer_number),
INDEX idx_selfserve_wash_sessions_created_at (created_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", ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS selfserve_wash_session_answers ( "CREATE TABLE IF NOT EXISTS selfserve_wash_session_answers (
@@ -95,7 +97,7 @@ class selfserve_schema_bootstrap
session_id INT NOT NULL, session_id INT NOT NULL,
task_id INT NULL, task_id INT NULL,
task_text VARCHAR(255) NOT NULL, task_text VARCHAR(255) NOT NULL,
description VARCHAR(255) NULL, description TEXT NULL,
services JSON NULL, services JSON NULL,
buttons JSON NULL, buttons JSON NULL,
dynamic_image_id INT NULL, dynamic_image_id INT NULL,
@@ -197,6 +199,18 @@ class selfserve_schema_bootstrap
'gate_ref_id', 'gate_ref_id',
'ALTER TABLE department_selfserve_tasks ADD COLUMN gate_ref_id INT NULL AFTER gate_type' 'ALTER TABLE department_selfserve_tasks ADD COLUMN gate_ref_id INT NULL AFTER gate_type'
); );
self::ensureColumnDataType(
'department_selfserve_tasks',
'description',
['text', 'mediumtext', 'longtext'],
'ALTER TABLE department_selfserve_tasks MODIFY COLUMN description TEXT NULL AFTER task'
);
self::ensureColumnDataType(
'selfserve_wash_session_tasks',
'description',
['text', 'mediumtext', 'longtext'],
'ALTER TABLE selfserve_wash_session_tasks MODIFY COLUMN description TEXT NULL AFTER task_text'
);
self::ensureColumn( self::ensureColumn(
'selfserve_wash_session_tasks', 'selfserve_wash_session_tasks',
'dynamic_images_vehicle_type', 'dynamic_images_vehicle_type',
@@ -207,6 +221,16 @@ class selfserve_schema_bootstrap
'wash_started_at', 'wash_started_at',
'ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_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_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; self::$initialized = true;
} }
@@ -239,4 +263,78 @@ class selfserve_schema_bootstrap
} }
$db->query($alterSql); $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_WAIT_TIMEOUT_SECONDS = 20;
private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000; private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;
private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate'; private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate';
private const SHELLY_CONNECT_TIMEOUT_SECONDS = 2;
private const SHELLY_REQUEST_TIMEOUT_SECONDS = 5;
/** /**
* @var array<int,array<string,mixed>> * @var array<int,array<string,mixed>>
*/ */
@@ -178,6 +180,9 @@ class shelly implements shelly_i
curl_setopt($ch, CURLOPT_HTTPHEADER, [ curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json', 'Content-Type: application/json',
]); ]);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::SHELLY_CONNECT_TIMEOUT_SECONDS);
curl_setopt($ch, CURLOPT_TIMEOUT, self::SHELLY_REQUEST_TIMEOUT_SECONDS);
curl_setopt($ch, CURLOPT_NOSIGNAL, true);
// Execute the request // Execute the request
$response = curl_exec($ch); $response = curl_exec($ch);
// Get the status code // Get the status code
@@ -224,6 +229,9 @@ class shelly implements shelly_i
); );
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPGET, true); curl_setopt($ch, CURLOPT_HTTPGET, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::SHELLY_CONNECT_TIMEOUT_SECONDS);
curl_setopt($ch, CURLOPT_TIMEOUT, self::SHELLY_REQUEST_TIMEOUT_SECONDS);
curl_setopt($ch, CURLOPT_NOSIGNAL, true);
$response = curl_exec($ch); $response = curl_exec($ch);
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+198 -1
View File
@@ -6,13 +6,25 @@ use GuzzleHttp\Client;
use interfaces\notification_i; use interfaces\notification_i;
use objects\departments_o; use objects\departments_o;
use objects\users_o; use objects\users_o;
use slack\slack_c;
use traits\notification_t; use traits\notification_t;
require_once WD . '/modules/slack/slack_c.php';
class slack implements notification_i class slack implements notification_i
{ {
use notification_t; use notification_t;
private ?slack_c $config = null;
public function getConfig(): slack_c
{
if ($this->config === null) {
$this->config = new slack_c();
}
return $this->config;
}
/** /**
* @inheritdoc * @inheritdoc
@@ -122,7 +134,7 @@ class slack implements notification_i
. "Status: $status"; . "Status: $status";
} }
public function send_message(string $string, string $module = null): void public function send_message(string $string, ?string $module = null): void
{ {
global $SLACK_DEFAULT_WEBHOOK; global $SLACK_DEFAULT_WEBHOOK;
// Format the message if a module is provided // Format the message if a module is provided
@@ -132,4 +144,189 @@ class slack implements notification_i
// Send the message to the slack webhook // Send the message to the slack webhook
self::add_log(self::send_webhook_message($string, $SLACK_DEFAULT_WEBHOOK)); self::add_log(self::send_webhook_message($string, $SLACK_DEFAULT_WEBHOOK));
} }
public function send_customer_registration_notification(int $customer_number): self
{
$webhook = $this->get_customer_registration_webhook_url();
if ($webhook === '') {
return $this;
}
self::add_log(self::send_webhook_message(
$this->format_customer_registration($customer_number),
$webhook
));
return $this;
}
/**
* Send a sanitized customer-registration test notification to the saved Slack webhook.
*
* @return array{configured:bool,sent:bool,message:string}
*/
public function test_customer_registration_webhook(): array
{
$webhook = $this->get_customer_registration_webhook_url();
if ($webhook === '') {
return [
'configured' => false,
'sent' => false,
'message' => 'Slack customer registration webhook URL is not configured.',
];
}
$result = $this->send_webhook_message(
$this->format_customer_registration_test(),
$webhook
);
$sent = $this->is_webhook_send_successful($result);
self::add_log($sent
? 'Slack customer registration test webhook sent successfully.'
: 'Slack customer registration test webhook failed.'
);
return [
'configured' => true,
'sent' => $sent,
'message' => $sent
? 'Slack test message sent successfully.'
: 'Slack test message failed.',
];
}
/**
* 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,276 @@
<?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']),
];
}
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;
}
}
@@ -6,9 +6,8 @@ use Throwable;
class system_search_cache 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 QUERY_PREFIX = self::PREFIX . 'query:';
public const INTENT_PREFIX = self::PREFIX . 'intent:';
public const DIRTY_TABLES_KEY = self::PREFIX . 'dirty_tables'; public const DIRTY_TABLES_KEY = self::PREFIX . 'dirty_tables';
public const REBUILD_REQUEST_KEY = self::PREFIX . 'rebuild_request'; public const REBUILD_REQUEST_KEY = self::PREFIX . 'rebuild_request';
public const TABLE_VERSION_PREFIX = self::PREFIX . 'table_version:'; 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); 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 public static function clearAll(): void
{ {
self::clearPattern(self::PREFIX . '*'); self::clearPattern(self::PREFIX . '*');
@@ -68,11 +49,6 @@ class system_search_cache
self::clearPattern(self::QUERY_PREFIX . '*'); self::clearPattern(self::QUERY_PREFIX . '*');
} }
public static function clearIntentCaches(): void
{
self::clearPattern(self::INTENT_PREFIX . '*');
}
public static function markDirtyTable(string $table): void public static function markDirtyTable(string $table): void
{ {
$table = trim($table, " `\t\n\r\0\x0B"); $table = trim($table, " `\t\n\r\0\x0B");
@@ -499,6 +499,7 @@ class system_search_document_index
*/ */
private function buildCustomerDiscountDocuments(): array private function buildCustomerDiscountDocuments(): array
{ {
price_overrides_schema_bootstrap::ensureColumns();
$fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id'; $fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id';
$selectFields = [ $selectFields = [
'po.id AS entity_id', 'po.id AS entity_id',
@@ -506,6 +507,7 @@ class system_search_document_index
'po.is_category', 'po.is_category',
'po.product_or_category_id', 'po.product_or_category_id',
'po.percentage', 'po.percentage',
'po.fixed_price',
'u.customer_number', 'u.customer_number',
'u.display_name', 'u.display_name',
...$this->joinTemporalSelectFields('price_overrides', 'po'), ...$this->joinTemporalSelectFields('price_overrides', 'po'),
@@ -554,6 +556,7 @@ class system_search_document_index
$row['search_text'] ?? null, $row['search_text'] ?? null,
$row['product_or_category_id'] ?? null, $row['product_or_category_id'] ?? null,
$row['percentage'] ?? null, $row['percentage'] ?? null,
$row['fixed_price'] ?? null,
$row['user_id'] ?? null, $row['user_id'] ?? null,
]), ]),
$this->toIntOrNull($row['customer_number'] ?? null), $this->toIntOrNull($row['customer_number'] ?? null),
@@ -564,6 +567,7 @@ class system_search_document_index
'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null), 'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
'product_or_category_id' => $row['product_or_category_id'] ?? null, 'product_or_category_id' => $row['product_or_category_id'] ?? null,
'percentage' => $this->toIntOrNull($row['percentage'] ?? null), 'percentage' => $this->toIntOrNull($row['percentage'] ?? null),
'fixed_price' => $this->toIntOrNull($row['fixed_price'] ?? null),
'economic_name' => $row['economic_name'] ?? null, 'economic_name' => $row['economic_name'] ?? null,
'economic_cvr' => $row['economic_cvr'] ?? null, 'economic_cvr' => $row['economic_cvr'] ?? null,
'is_category' => $row['is_category'] ?? 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; namespace classes;
use interfaces\system_search_intent_parser_i;
use Throwable; use Throwable;
class system_search_service 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 $defaultEntityFetchLimit = 200;
private int $defaultMaxResults = 50;
private int $maxExpandedTerms = 24; private int $maxExpandedTerms = 24;
private int $maxTermLength = 64; private int $maxTermLength = 64;
private int $recencyScoreTolerance = 12; private int $recencyScoreTolerance = 12;
private int $minimumEffectiveScore = 45;
private int $minimumExplicitTypeScore = 20;
private int $associationSeedScoreThreshold = 80;
private array $rankingBoostByType = [ private array $rankingBoostByType = [
'invoices' => 35, 'invoices' => 35,
'orders' => 35, 'orders' => 35,
@@ -35,9 +35,8 @@ class system_search_service
private array $tableColumnsCache = []; private array $tableColumnsCache = [];
private array $customerContextCache = []; 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 { try {
system_search_economic_customer_index::ensureTable(); system_search_economic_customer_index::ensureTable();
system_search_document_index::ensureTable(); system_search_document_index::ensureTable();
@@ -59,18 +58,13 @@ class system_search_service
$permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []); $permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []);
$moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []); $moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []);
$includeAssociations = (bool)($options['include_associations'] ?? true); $includeAssociations = (bool)($options['include_associations'] ?? true);
$debugIntent = (bool)($options['debug_intent'] ?? false);
$limit = (int)($options['limit'] ?? 50); $maxResults = (int)($options['max_results'] ?? $this->defaultMaxResults);
$offset = (int)($options['offset'] ?? 0); if ($maxResults < 1) {
if ($limit < 1) { $maxResults = $this->defaultMaxResults;
$limit = 50;
} }
if ($limit > 200) { if ($maxResults > $this->defaultMaxResults) {
$limit = 200; $maxResults = $this->defaultMaxResults;
}
if ($offset < 0) {
$offset = 0;
} }
$allTypes = $this->allEntityTypes(); $allTypes = $this->allEntityTypes();
@@ -79,11 +73,12 @@ class system_search_service
$activeTypes = array_values(array_diff($activeTypes, $excludeTypes)); $activeTypes = array_values(array_diff($activeTypes, $excludeTypes));
} }
$activeTypes = array_values(array_intersect($activeTypes, $allowedTypes)); $activeTypes = array_values(array_intersect($activeTypes, $allowedTypes));
$terms = $this->buildExpandedTerms($this->tokenize($query));
$activeTypes = $this->selectSearchTypes($activeTypes, $includeTypes, $terms, $query);
$baseMeta = [ $baseMeta = [
'query' => $query, 'query' => $query,
'limit' => $limit, 'max_results' => $maxResults,
'offset' => $offset,
'allowed_types' => $activeTypes, 'allowed_types' => $activeTypes,
'cache' => ['hit' => false], 'cache' => ['hit' => false],
]; ];
@@ -94,7 +89,8 @@ class system_search_service
'grouped_results' => $this->groupResultsByType([]), 'grouped_results' => $this->groupResultsByType([]),
'meta' => [ 'meta' => [
...$baseMeta, ...$baseMeta,
'total' => 0, 'returned' => 0,
'truncated' => false,
], ],
]; ];
} }
@@ -104,16 +100,14 @@ class system_search_service
'include' => $includeTypes, 'include' => $includeTypes,
'exclude' => $excludeTypes, 'exclude' => $excludeTypes,
'active' => $activeTypes, 'active' => $activeTypes,
'limit' => $limit, 'max' => $maxResults,
'offset' => $offset,
'own' => $ownCustomerNumber, 'own' => $ownCustomerNumber,
'own_only' => $ownOnlyTypes, 'own_only' => $ownOnlyTypes,
'dept' => $allowedDepartmentIds, 'dept' => $allowedDepartmentIds,
'assoc' => $includeAssociations, 'assoc' => $includeAssociations,
'dbg' => $debugIntent,
'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility), 'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility),
'table_versions' => system_search_cache::tableVersionFingerprint($this->relevantSourceTables($activeTypes)), 'table_versions' => system_search_cache::tableVersionFingerprint($this->relevantSourceTables($activeTypes)),
'v' => 12, 'v' => 13,
], JSON_UNESCAPED_UNICODE)); ], JSON_UNESCAPED_UNICODE));
$cached = system_search_cache::getQuery($queryCacheHash); $cached = system_search_cache::getQuery($queryCacheHash);
@@ -122,7 +116,6 @@ class system_search_service
return $cached; return $cached;
} }
$terms = $this->buildExpandedTerms($this->tokenize($query));
$entityBoost = []; $entityBoost = [];
$initialResults = $this->executeLexicalSearch( $initialResults = $this->executeLexicalSearch(
$activeTypes, $activeTypes,
@@ -135,76 +128,23 @@ class system_search_service
$moduleConfigVisibility, $moduleConfigVisibility,
$allowedDepartmentIds $allowedDepartmentIds
); );
$initialResults = $this->filterRelevantResults($initialResults, $includeTypes);
$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';
}
}
if ($includeAssociations) { if ($includeAssociations) {
$customerNumbers = []; $customerNumbers = [];
foreach ($initialResults as $result) { foreach ($initialResults as $result) {
if (!isset($result['customer_number'])) { $customerNumber = $this->toIntOrNull($result['customer_number'] ?? null);
if ($customerNumber === null || $customerNumber <= 0) {
continue; continue;
} }
if ($result['entity_type'] !== 'customers' && !$intentAssociationHint) { if (!$this->shouldExpandAssociationsFromResult($result, $includeTypes)) {
continue; continue;
} }
$customerNumbers[] = (int)$result['customer_number']; $customerNumbers[] = $customerNumber;
} }
$customerNumbers = array_values(array_unique(array_filter($customerNumbers))); $customerNumbers = array_values(array_unique(array_filter($customerNumbers)));
if (count($customerNumbers) > 15) { if (count($customerNumbers) > 5) {
$customerNumbers = array_slice($customerNumbers, 0, 15); $customerNumbers = array_slice($customerNumbers, 0, 5);
} }
if (!empty($customerNumbers)) { if (!empty($customerNumbers)) {
$associationTypes = array_values(array_intersect( $associationTypes = array_values(array_intersect(
@@ -232,18 +172,21 @@ class system_search_service
} }
$item['score'] = max((int)$item['score'], 35); $item['score'] = max((int)$item['score'], 35);
} }
unset($item);
$associated = $this->filterRelevantResults($associated, $includeTypes);
$initialResults = $this->mergeResults($initialResults, $associated); $initialResults = $this->mergeResults($initialResults, $associated);
} }
} }
} }
} }
$initialResults = $this->filterRelevantResults($initialResults, $includeTypes);
$preferRecency = $this->shouldPreferRecencySort($query, $terms); $preferRecency = $this->shouldPreferRecencySort($query, $terms);
usort($initialResults, function (array $a, array $b) use ($preferRecency): int { usort($initialResults, function (array $a, array $b) use ($preferRecency): int {
$scoreA = (int)($a['score'] ?? 0); $scoreA = (int)($a['score'] ?? 0);
$scoreB = (int)($b['score'] ?? 0); $scoreB = (int)($b['score'] ?? 0);
$effectiveScoreA = $scoreA + $this->rankingBoost($a) - $this->rankingPenalty($a); $effectiveScoreA = $this->effectiveResultScore($a);
$effectiveScoreB = $scoreB + $this->rankingBoost($b) - $this->rankingPenalty($b); $effectiveScoreB = $this->effectiveResultScore($b);
$recencyA = $this->resultRecencyTimestamp($a); $recencyA = $this->resultRecencyTimestamp($a);
$recencyB = $this->resultRecencyTimestamp($b); $recencyB = $this->resultRecencyTimestamp($b);
$cancelledA = $this->isCancelledBookingResult($a); $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']); return strcmp((string)$a['entity_type'] . ':' . (string)$a['entity_id'], (string)$b['entity_type'] . ':' . (string)$b['entity_id']);
}); });
$total = count($initialResults); $limited = array_slice($initialResults, 0, $maxResults);
$paged = array_slice($initialResults, $offset, $limit); $grouped = $this->groupResultsByType($limited);
$grouped = $this->groupResultsByType($paged);
$meta = [ $meta = [
...$baseMeta, ...$baseMeta,
'total' => $total, 'returned' => count($limited),
'truncated' => count($initialResults) > count($limited),
]; ];
if ($debugIntent) {
$meta['intent_parser'] = $intentMeta;
}
$payload = [ $payload = [
'results' => $paged, 'results' => $limited,
'grouped_results' => $grouped, 'grouped_results' => $grouped,
'meta' => $meta, 'meta' => $meta,
]; ];
@@ -294,13 +234,127 @@ class system_search_service
return $payload; 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) { if (empty($activeTypes) || !empty($includeTypes)) {
return $activeTypes;
}
$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; return true;
} }
$topScore = (int)($results[0]['score'] ?? 0); if (count($aliasTerms) > 1 && empty(array_diff($aliasTerms, $terms))) {
return $topScore < $this->lowConfidenceTopScoreThreshold; 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, $allowedDepartmentIds,
$forcedCustomerNumbers $forcedCustomerNumbers
); );
if (empty($rows)) {
$rows = $this->searchEntity(
$entityType,
$terms,
$boost,
$ownOnly,
$ownCustomerNumber,
$permissionsCatalogAll,
$permissionsCatalogOwn,
$moduleConfigVisibility,
$allowedDepartmentIds,
$forcedCustomerNumbers
);
}
} else { } else {
$rows = $this->searchEntity( $rows = $this->searchEntity(
$entityType, $entityType,
@@ -1086,6 +1126,7 @@ class system_search_service
private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
{ {
price_overrides_schema_bootstrap::ensureColumns();
$customerFilter = ''; $customerFilter = '';
if (!empty($forcedCustomerNumbers)) { if (!empty($forcedCustomerNumbers)) {
$customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')'; $customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')';
@@ -1100,11 +1141,12 @@ class system_search_service
'po.is_category', 'po.is_category',
'po.product_or_category_id', 'po.product_or_category_id',
'po.percentage', 'po.percentage',
'po.fixed_price',
'u.customer_number', 'u.customer_number',
'u.display_name', 'u.display_name',
...$this->joinTemporalSelectFields('price_overrides', 'po'), ...$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()) { if ($this->isEconomicCustomerIndexAvailable()) {
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number'; $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', 'search_text',
'product_or_category_id', 'product_or_category_id',
'percentage', 'percentage',
'fixed_price',
'user_id', 'user_id',
], $terms) + $entityBoost, ], $terms) + $entityBoost,
'payload' => $this->augmentPayloadWithTemporal([ 'payload' => $this->augmentPayloadWithTemporal([
@@ -1173,6 +1216,7 @@ class system_search_service
'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null, 'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
'product_or_category_id' => $row['product_or_category_id'] ?? null, 'product_or_category_id' => $row['product_or_category_id'] ?? null,
'percentage' => isset($row['percentage']) ? (int)$row['percentage'] : 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_name' => $row['economic_name'] ?? null,
'economic_cvr' => $row['economic_cvr'] ?? null, 'economic_cvr' => $row['economic_cvr'] ?? null,
], $row), ], $row),
@@ -2643,6 +2687,11 @@ class system_search_service
return (int)($this->rankingPenaltyByType[$entityType] ?? 0); 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 private function boolishTrue(mixed $value): bool
{ {
if (is_bool($value)) { if (is_bool($value)) {
@@ -2702,7 +2751,7 @@ class system_search_service
return false; 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)) { if ($this->queryHasExplicitIdentifier($normalized)) {
return false; 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 private function queryHasExplicitIdentifier(string $normalizedQuery): bool
{ {
if ($normalizedQuery === '') { if ($normalizedQuery === '') {
@@ -2981,22 +2968,6 @@ class system_search_service
return system_search_registry::allEntityTypes(); 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 private function groupResultsByType(array $results): array
{ {
$grouped = []; $grouped = [];
@@ -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); 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 public static function productOverlapForAutomation(array $usageItems, array $orderItems): float
{ {
$usageBag = self::productBagForAutomation($usageItems); $usageBag = self::productBagForAutomation($usageItems);
@@ -600,18 +606,52 @@ class xlvask_automation_service
if ($action === self::ACTION_ATTACH) { if ($action === self::ACTION_ATTACH) {
return $xlvask->config->automatic_order_attachment_enabled->isTrue() 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) { if ($action === self::ACTION_CREATE) {
return $xlvask->config->automatic_order_creation_enabled->isTrue() return false;
&& $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 private function executeSuggestion(array $suggestion, array $context, ?int $actorId, bool $automatic): array
{ {
try { try {
@@ -646,7 +686,7 @@ class xlvask_automation_service
$latest = $this->loadSuggestion((int)$suggestion['id']) ?? $suggestion; $latest = $this->loadSuggestion((int)$suggestion['id']) ?? $suggestion;
if ($automatic) { 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); 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 private function createOrderFromContext(array $context): orders_o
{ {
$orderData = $context['proposed_order']; $orderData = $context['proposed_order'];
@@ -1246,19 +1295,20 @@ class xlvask_automation_service
{ {
global $db; global $db;
(new xlvask_usage_logs_o())->structure(); (new xlvask_usage_logs_o())->structure();
$startTimeExpression = "STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')";
$where = [ $where = [
'FinishStatus = 1', 'FinishStatus = 1',
'(ignored_at IS NULL OR ignored_at = "")', '(ignored_at IS NULL OR ignored_at = "")',
]; ];
if ($dateFrom !== null && strtotime($dateFrom) !== false) { 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 { } 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) { 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)); $limit = max(1, min(500, $limit));
+4
View File
@@ -1,6 +1,10 @@
{ {
"scripts": { "scripts": {
"test": "composer test:unit", "test": "composer test:unit",
"analyse": "vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress",
"static": "@analyse",
"rector:dry-run": "@php -d error_reporting=0 -d display_errors=0 -d log_errors=0 vendor/bin/rector process --dry-run --config rector.php",
"rector:fix": "@php -d error_reporting=0 -d display_errors=0 -d log_errors=0 vendor/bin/rector process --config rector.php",
"test:unit": "vendor/bin/pest --testsuite=Unit --colors=always", "test:unit": "vendor/bin/pest --testsuite=Unit --colors=always",
"test:integration": "vendor/bin/pest --testsuite=Integration --colors=always", "test:integration": "vendor/bin/pest --testsuite=Integration --colors=always",
"test:api": [ "test:api": [
+22 -10
View File
@@ -19,6 +19,8 @@ use classes\slack as Slack;
use classes\email as Email; use classes\email as Email;
use classes\gatewayapi as GatewayAPI; use classes\gatewayapi as GatewayAPI;
use dynamicimages\images\machine_1; use dynamicimages\images\machine_1;
use modules\selfserve\config\selfserve_dynamic_image_size_c;
use modules\selfserve\selfserve_c;
use goals\classes\goals_criteria; use goals\classes\goals_criteria;
use goals\services\goals_progress_alert_renderer; use goals\services\goals_progress_alert_renderer;
use goals\helpers\goals_criteria_progress_alert_destination as Dest; use goals\helpers\goals_criteria_progress_alert_destination as Dest;
@@ -33,6 +35,8 @@ use objects\users_o;
use routes\moduleWeatherAPIRoute; use routes\moduleWeatherAPIRoute;
require_once __DIR__ . '/../classes/economic_transfer_executor.php'; require_once __DIR__ . '/../classes/economic_transfer_executor.php';
const DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH = 1600;
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php'; require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
require_once __DIR__ . '/../classes/economic_transfer_queue.php'; require_once __DIR__ . '/../classes/economic_transfer_queue.php';
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php'; require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
@@ -648,7 +652,6 @@ function SystemSearchCacheMaintenanceCron(): void
if ($rebuildRequest !== null) { if ($rebuildRequest !== null) {
system_search_cache::clearQueryCaches(); system_search_cache::clearQueryCaches();
system_search_cache::clearIntentCaches();
$scope = (string)($rebuildRequest['scope'] ?? 'all'); $scope = (string)($rebuildRequest['scope'] ?? 'all');
$types = array_values(array_filter(array_map('strval', (array)($rebuildRequest['types'] ?? [])))); $types = array_values(array_filter(array_map('strval', (array)($rebuildRequest['types'] ?? []))));
if ($scope === 'types' && !empty($types)) { if ($scope === 'types' && !empty($types)) {
@@ -930,6 +933,7 @@ function buildDynamicImageCacheKey(array $variant): string
'current_step' => (int)($variant['current_step'] ?? 0), 'current_step' => (int)($variant['current_step'] ?? 0),
'only_current_step' => (bool)($variant['only_current_step'] ?? false), 'only_current_step' => (bool)($variant['only_current_step'] ?? false),
'vehicle_type' => $variant['vehicle_type'] ?? null, 'vehicle_type' => $variant['vehicle_type'] ?? null,
'dynamic_image_size' => getSelfServeDynamicImageSizeModeForCron(),
]; ];
$json = json_encode($cacheParams); $json = json_encode($cacheParams);
@@ -962,16 +966,11 @@ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $cu
$image->current_step = max(0, $currentStep); $image->current_step = max(0, $currentStep);
$image->only_generate_current_step = $onlyCurrentStep; $image->only_generate_current_step = $onlyCurrentStep;
$image->setup(); $image->setup();
if (getSelfServeDynamicImageSizeModeForCron() === selfserve_dynamic_image_size_c::SIZE_RELEVANT) {
$image->resizeToMaxWidth(DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH);
}
$dataUri = $image->exportAsBase64('png'); return $image->exportBinary('png');
if (!preg_match('/^data:image\/png;base64,(.*)$/', $dataUri, $matches)) {
return null;
}
$imageData = base64_decode($matches[1], true);
if ($imageData === false) {
return null;
}
return $imageData;
} catch (Throwable $e) { } catch (Throwable $e) {
warn('PreRenderDynamicImagesCron: render failed for dynamic_image_id=' . $dynamicImageId . ': ' . $e->getMessage()); warn('PreRenderDynamicImagesCron: render failed for dynamic_image_id=' . $dynamicImageId . ': ' . $e->getMessage());
return null; return null;
@@ -985,6 +984,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 * @param mixed $value
* @return array<int|string> * @return array<int|string>
@@ -12,6 +12,31 @@ interface licenseplaterecognizer_i extends universal_module_i
* @return array An array containing the plate number and other relevant information. * @return array An array containing the plate number and other relevant information.
*/ */
public function licenseplaterecognizer(string $base64_image): array; public function licenseplaterecognizer(string $base64_image): array;
/**
* Get the plate number from raw uploaded image bytes.
* @param string $image_data Raw uploaded image bytes.
* @param string $mime_type The image MIME type.
* @return array An array containing the plate number and other relevant information.
*/
public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array;
/**
* Get the plate number from raw uploaded image bytes without building an exact-result cache key.
* @param string $image_data Raw uploaded image bytes.
* @param string $mime_type The image MIME type.
* @return array An array containing the plate number and other relevant information.
*/
public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array;
/**
* Get the plate number from a PHP upload temp file without copying it into memory.
* @param string $image_path The uploaded image temp-file path.
* @param string $mime_type The image MIME type.
* @return array An array containing the plate number and other relevant information.
*/
public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array;
/** /**
* Get usage information about the license plate recognizer module. * Get usage information about the license plate recognizer module.
* @returns licenseplaterecognizer_info An object containing usage statistics and information. * @returns licenseplaterecognizer_info An object containing usage statistics and information.
@@ -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;
}
@@ -131,7 +131,7 @@
"post": { "post": {
"tags": ["User Bookings"], "tags": ["User Bookings"],
"summary": "Get download link for a booking's wash certificate", "summary": "Get download link for a booking's wash certificate",
"description": "Requires permission `download_own_wash_certificate`. Returns a presigned download link if certificate exists and user has access.", "description": "Requires permission `download_own_wash_certificate`, except authenticated customer accounts may download certificates for their own bookings. Returns a presigned download link if certificate exists and user has access.",
"parameters": [ { "$ref": "#/components/parameters/id" } ], "parameters": [ { "$ref": "#/components/parameters/id" } ],
"responses": { "responses": {
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } }, "200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } },
@@ -146,7 +146,7 @@
"get": { "get": {
"tags": ["User Bookings"], "tags": ["User Bookings"],
"summary": "Get download link for a booking's wash certificate PDF", "summary": "Get download link for a booking's wash certificate PDF",
"description": "Requires permission `download_own_wash_certificate`. Checks both legacy and current storage buckets.", "description": "Requires permission `download_own_wash_certificate`, except authenticated customer accounts may download certificates for their own bookings. Checks both legacy and current storage buckets.",
"parameters": [ { "$ref": "#/components/parameters/id" } ], "parameters": [ { "$ref": "#/components/parameters/id" } ],
"responses": { "responses": {
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } }, "200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } },
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_github_runner_backend_repository_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'github_runner_backend_repository',
'string',
false,
null,
'GitHub backend repository that receives Coolify-managed self-hosted runners.',
'copenhagentruckwash/api',
false,
'copenhagentruckwash/api'
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_github_runner_count_per_repo_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'github_runner_count_per_repo',
'int',
false,
null,
'Number of self-hosted GitHub runner containers to deploy per repository.',
'1',
false,
'1'
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_github_runner_frontend_repository_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'github_runner_frontend_repository',
'string',
false,
null,
'GitHub frontend repository that receives Coolify-managed self-hosted runners.',
'copenhagentruckwash/pleno-vue',
false,
'copenhagentruckwash/pleno-vue'
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_github_runner_labels_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'github_runner_labels',
'string',
false,
null,
'Comma-separated GitHub Actions runner labels registered on each Coolify-managed runner.',
'self-hosted,Linux,X64,default',
false,
'self-hosted,Linux,X64,default'
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_github_runner_service_uuid_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'github_runner_service_uuid',
'string',
false,
null,
'Coolify service UUID for the managed GitHub self-hosted runner stack.',
'abc123...',
false,
''
);
}
}
@@ -0,0 +1,38 @@
<?php
namespace modules\coolify\config;
use classes\replication_secret_box;
use traits\module_config_variable;
class coolify_github_runner_token_c
{
use module_config_variable {
setVariableValue as private traitSetVariableValue;
}
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'github_runner_token',
'string',
false,
null,
'GitHub PAT used to register Coolify-managed self-hosted repository runners.',
'github_pat_...',
true,
''
);
}
public function setVariableValue(mixed $value): void
{
$value = trim((string)($value ?? ''));
if ($value !== '' && !str_starts_with($value, 'twsec:v1:')) {
$value = replication_secret_box::encrypt($value);
}
$this->traitSetVariableValue($value);
}
}
@@ -86,6 +86,14 @@ interface dynamicimages_image_i
*/ */
public function exportAsBase64(?string $format = null, int $quality = 90): string; public function exportAsBase64(?string $format = null, int $quality = 90): string;
/**
* Export the composed image as binary image data.
* @param string|null $format Optional target format (e.g. 'png', 'jpeg')
* @param int $quality Quality for lossy formats (0-100)
* @return string binary image data
*/
public function exportBinary(?string $format = null, int $quality = 90): string;
/** /**
* Directly serve the composed image to the client with proper headers. * Directly serve the composed image to the client with proper headers.
* Convenience wrapper for outputting binary image data. * Convenience wrapper for outputting binary image data.
@@ -226,6 +226,20 @@ trait dynamicimages_image_t
return $this; return $this;
} }
public function resizeToMaxWidth(int $maxWidth): dynamicimages_image_i
{
$this->assertCanvasInitialized();
if ($maxWidth <= 0) {
throw new \InvalidArgumentException('Resize max width must be a positive integer.');
}
if ($this->canvasWidth === null || $this->canvasHeight === null || $this->canvasWidth <= $maxWidth) {
return $this;
}
$height = (int)round($this->canvasHeight * ($maxWidth / $this->canvasWidth));
return $this->resize($maxWidth, max(1, $height));
}
public function crop(int $width, int $height, int $x, int $y): dynamicimages_image_i public function crop(int $width, int $height, int $x, int $y): dynamicimages_image_i
{ {
$this->assertCanvasInitialized(); $this->assertCanvasInitialized();
@@ -307,20 +321,8 @@ trait dynamicimages_image_t
*/ */
public function exportAsBase64(?string $format = null, int $quality = 90): string public function exportAsBase64(?string $format = null, int $quality = 90): string
{ {
// If a canvas is initialized, export that as PNG by default
if ($this->image instanceof \Imagick) { if ($this->image instanceof \Imagick) {
$img = clone $this->image; return 'data:image/png;base64,' . base64_encode($this->exportBinary($format, $quality));
$img->setImageFormat('png');
// Quality influences compression for PNG differently; keep as hint
if ($format !== null && strtolower($format) !== 'png') {
// For now we only support PNG for composed images as requested
}
// Strip metadata to reduce size
$img->stripImage();
$blob = $img->getImageBlob();
$img->clear();
$img->destroy();
return 'data:image/png;base64,' . base64_encode($blob);
} }
// Fallback: export first asset as-is // Fallback: export first asset as-is
@@ -341,6 +343,40 @@ trait dynamicimages_image_t
return 'data:' . $mime . ';base64,' . base64_encode($data); return 'data:' . $mime . ';base64,' . base64_encode($data);
} }
public function exportBinary(?string $format = null, int $quality = 90): string
{
// If a canvas is initialized, export that as PNG by default
if ($this->image instanceof \Imagick) {
$img = clone $this->image;
$img->setImageFormat('png');
// Quality influences compression for PNG differently; keep as hint
if ($format !== null && strtolower($format) !== 'png') {
// For now we only support PNG for composed images as requested
}
// Strip metadata to reduce size
$img->stripImage();
$blob = $img->getImageBlob();
$img->clear();
$img->destroy();
return $blob;
}
// Fallback: export first asset as-is
if (empty($this->assets)) {
throw new \RuntimeException('No assets available to export.');
}
$asset = $this->assets[0];
$path = $asset->getPath();
if (!is_readable($path)) {
throw new \RuntimeException('Asset is not readable: ' . $path);
}
$data = file_get_contents($path);
if ($data === false) {
throw new \RuntimeException('Failed to read asset: ' . $path);
}
return $data;
}
public function getAsset(string $asset_name): ?dynamicimages_asset public function getAsset(string $asset_name): ?dynamicimages_asset
{ {
foreach ($this->assets as $asset) { foreach ($this->assets as $asset) {
@@ -357,23 +393,19 @@ trait dynamicimages_image_t
*/ */
public function outputImage(?string $format = null, int $quality = 90): void public function outputImage(?string $format = null, int $quality = 90): void
{ {
$dataUri = $this->exportAsBase64($format, $quality); $mimeType = 'image/png';
// Extract mime type and base64 data if (!$this->image instanceof \Imagick && !empty($this->assets)) {
if (preg_match('/^data:(image\/[a-zA-Z0-9+.-]+);base64,(.*)$/', $dataUri, $matches)) { $asset = $this->assets[0];
$mimeType = $matches[1]; $path = $asset->getPath();
$base64Data = $matches[2]; $imgInfo = is_readable($path) ? @getimagesize($path) : false;
// Decode base64 data $mimeType = is_array($imgInfo) && isset($imgInfo['mime']) ? $imgInfo['mime'] : 'application/octet-stream';
$imageData = base64_decode($base64Data); }
if ($imageData !== false) { $imageData = $this->exportBinary($format, $quality);
// Send appropriate headers
header('Content-Type: ' . $mimeType); header('Content-Type: ' . $mimeType);
header('Content-Length: ' . strlen($imageData)); header('Content-Length: ' . strlen($imageData));
// Output the image data
echo $imageData; echo $imageData;
exit; exit;
} }
}
}
/** /**
* Serve the composed picture directly to the client. Wrapper for outputImage. * Serve the composed picture directly to the client. Wrapper for outputImage.
@@ -14,6 +14,8 @@ class economic_customer_mo
public null|string $message; public null|string $message;
public null|string $corporateIdentificationNumber; public null|string $corporateIdentificationNumber;
public null|string $email; public null|string $email;
public null|string $ean;
public null|string $publicEntryNumber;
public null|string $mobilePhone; public null|string $mobilePhone;
public null|string $currency; public null|string $currency;
public null|string $country; public null|string $country;
@@ -46,6 +48,8 @@ class economic_customer_mo
$this->zip = ($customer->zip ?? null); $this->zip = ($customer->zip ?? null);
$this->corporateIdentificationNumber = ($customer->corporateIdentificationNumber ?? null); $this->corporateIdentificationNumber = ($customer->corporateIdentificationNumber ?? null);
$this->email = ($customer->email ?? null); $this->email = ($customer->email ?? null);
$this->ean = ($customer->ean ?? null);
$this->publicEntryNumber = ($customer->publicEntryNumber ?? $customer->public_entry_number ?? null);
$this->mobilePhone = ($customer->mobilePhone ?? null); $this->mobilePhone = ($customer->mobilePhone ?? null);
$this->currency = ($customer->currency ?? null); $this->currency = ($customer->currency ?? null);
$this->country = ($customer->country ?? null); $this->country = ($customer->country ?? null);
@@ -100,6 +104,8 @@ class economic_customer_mo
'zip' => $this->zip, 'zip' => $this->zip,
'corporateIdentificationNumber' => $this->corporateIdentificationNumber, 'corporateIdentificationNumber' => $this->corporateIdentificationNumber,
'email' => $this->email, 'email' => $this->email,
'ean' => $this->ean,
'publicEntryNumber' => $this->publicEntryNumber,
'mobilePhone' => $this->mobilePhone, 'mobilePhone' => $this->mobilePhone,
'currency' => $this->currency, 'currency' => $this->currency,
'country' => $this->country, 'country' => $this->country,
@@ -61,19 +61,47 @@ class economic_invoices_draft_endpoint
* @throws Exception If the request fails * @throws Exception If the request fails
*/ */
public function add_order(int $invoiceDraftId, orders_o $order, string $currency = 'DKK'): void public function add_order(int $invoiceDraftId, orders_o $order, string $currency = 'DKK'): void
{
$this->add_orders($invoiceDraftId, [$order], $currency);
}
/**
* Add many orders to a draft invoice and flush their lines in batches.
*
* @param orders_o[] $orders
* @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array<int,int>}
* @throws Exception If the request fails
*/
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500): array
{ {
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true); $draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
$orders_with_invoice_lines = 0;
foreach ( $orders as $order ) {
if (!$order instanceof orders_o) {
throw new Exception('Order payload must contain orders_o instances');
}
// Check if the order includes any items that should be included in the invoice // Check if the order includes any items that should be included in the invoice
if ($order->getIncludeInInvoiceCount() > 0) { if ($order->getIncludeInInvoiceCount() <= 0) {
continue;
}
$orders_with_invoice_lines++;
// Add the transaction header (Timestamp, department, etc.) // Add the transaction header (Timestamp, department, etc.)
$draftInvoice->addNewTransactionHeader($order); $draftInvoice->addNewTransactionHeader($order);
// Add the order lines // Add the order lines
$draftInvoice->addOrderItemLines($order); $draftInvoice->addOrderItemLines($order);
// Add an empty line, so the invoice is not empty // Add an empty line, so the invoice is not empty
$draftInvoice->addTextLine(''); $draftInvoice->addTextLine('');
// Save the draft invoice lines
$draftInvoice->addLines();
} }
$metrics = $draftInvoice->flushLinesInBatches($line_batch_size);
return [
'order_count' => count($orders),
'orders_with_invoice_lines' => $orders_with_invoice_lines,
...$metrics,
];
} }
/** /**
@@ -127,6 +127,23 @@ class economic_invoices_drafts_endpoint
$customer_address = $customer->getAddress() ?? 'Ukendt'; $customer_address = $customer->getAddress() ?? 'Ukendt';
$customer_zip = $customer->getZipCode() ?? 'Ukendt'; $customer_zip = $customer->getZipCode() ?? 'Ukendt';
$customer_city = $customer->getCity() ?? 'Ukendt'; $customer_city = $customer->getCity() ?? 'Ukendt';
$recipient = [
'name' => $customer_name,
'address' => $customer_address,
'zip' => $customer_zip,
'city' => $customer_city,
'vatZone' => [
'vatZoneNumber' => (int)$customer->getVatZoneNumber(),
],
];
$customer_ean = $customer->getEan();
if ($customer_ean !== null) {
$recipient['ean'] = $customer_ean;
}
$public_entry_number = $customer->getPublicEntryNumber();
if ($public_entry_number !== null) {
$recipient['publicEntryNumber'] = $public_entry_number;
}
// Send the request // Send the request
$response = $this->send_request( $response = $this->send_request(
@@ -165,15 +182,7 @@ class economic_invoices_drafts_endpoint
'currency' => $customer->getCurrency() ?? 'DKK', 'currency' => $customer->getCurrency() ?? 'DKK',
// Set the recipient details // Set the recipient details
'recipient' => [ 'recipient' => $recipient,
'name' => $customer_name,
'address' => $customer_address,
'zip' => $customer_zip,
'city' => $customer_city,
'vatZone' => [
'vatZoneNumber' => (int)$customer->getVatZoneNumber(),
],
],
]) ])
); );
// Return the response as an object // Return the response as an object
@@ -149,6 +149,29 @@ class economic_customer
return $this->customer_data_object->email; return $this->customer_data_object->email;
} }
public function getEan(): ?string
{
self::requireSelected();
return $this->nullableStringField('ean');
}
public function getPublicEntryNumber(): ?string
{
self::requireSelected();
return $this->nullableStringField('publicEntryNumber');
}
protected function nullableStringField(string $field): ?string
{
$value = $this->customer_data_object->{$field} ?? null;
if ($value === null) {
return null;
}
$normalized = trim((string)$value);
return $normalized !== '' ? $normalized : null;
}
/** /**
* Get the customer address * Get the customer address
* @return string The customer address * @return string The customer address
@@ -10,6 +10,8 @@ use objects\orders_o;
class economic_invoice_draft class economic_invoice_draft
{ {
public const DEFAULT_LINE_BATCH_SIZE = 500;
/** /**
* The Economic draftInvoiceNumber * The Economic draftInvoiceNumber
* @var int $draft_invoice_number * @var int $draft_invoice_number
@@ -110,13 +112,55 @@ class economic_invoice_draft
} }
/** /**
* Add the lines to the draft invoice * Add the lines to the draft invoice.
* @return void
*/ */
public function addLines(): void public function addLines(): void
{
$this->flushLinesInBatches();
}
/**
* Add queued draft lines using chunked requests.
*
* @return array{line_count:int,batch_count:int,batch_sizes:array<int,int>}
*/
public function flushLinesInBatches(int $batch_size = self::DEFAULT_LINE_BATCH_SIZE): array
{
$lines = array_values($this->draft_lines);
$line_count = count($lines);
if ($line_count === 0) {
return [
'line_count' => 0,
'batch_count' => 0,
'batch_sizes' => [],
];
}
$batch_size = max(1, $batch_size);
$batch_sizes = [];
foreach (array_chunk($lines, $batch_size) as $batch) {
$this->sendDraftLines($batch);
$batch_sizes[] = count($batch);
}
$this->draft_lines = [];
return [
'line_count' => $line_count,
'batch_count' => count($batch_sizes),
'batch_sizes' => $batch_sizes,
];
}
public function pendingLineCount(): int
{
return count($this->draft_lines);
}
protected function sendDraftLines(array $draft_lines): object
{ {
$economic = new economic(); $economic = new economic();
$economic->invoices->draft->add_lines($this->draft_invoice_number, $this->draft_lines); return $economic->invoices->draft->add_lines($this->draft_invoice_number, $draft_lines);
} }
/** /**
@@ -6,6 +6,7 @@ use Exception;
class edge_gateway_agent_artifact_locator class edge_gateway_agent_artifact_locator
{ {
private const EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY = 'build/install';
private const ROUTER_ARTIFACT_DIRECTORY = 'resources/edge-gateway-agent'; private const ROUTER_ARTIFACT_DIRECTORY = 'resources/edge-gateway-agent';
private const DEFAULT_MOUNTED_ARTIFACT_DIRECTORY = '/services/edge-agent/php-agent'; private const DEFAULT_MOUNTED_ARTIFACT_DIRECTORY = '/services/edge-agent/php-agent';
private const DEFAULT_BAKED_ARTIFACT_DIRECTORY = '/opt/truckwash-edge-agent-artifacts'; private const DEFAULT_BAKED_ARTIFACT_DIRECTORY = '/opt/truckwash-edge-agent-artifacts';
@@ -30,6 +31,10 @@ class edge_gateway_agent_artifact_locator
$candidateDirectories[] = self::routerArtifactDirectory($basePath); $candidateDirectories[] = self::routerArtifactDirectory($basePath);
foreach (self::edgeAgentBuildDirectories($basePath) as $directory) {
$candidateDirectories[] = $directory;
}
$mountedArtifactDirectory = $mountedArtifactDirectory ?? self::mountedArtifactDirectory(); $mountedArtifactDirectory = $mountedArtifactDirectory ?? self::mountedArtifactDirectory();
if ($mountedArtifactDirectory !== null) { if ($mountedArtifactDirectory !== null) {
$candidateDirectories[] = self::normalizePath($mountedArtifactDirectory); $candidateDirectories[] = self::normalizePath($mountedArtifactDirectory);
@@ -40,9 +45,9 @@ class edge_gateway_agent_artifact_locator
$candidateDirectories[] = self::normalizePath($bakedArtifactDirectory); $candidateDirectories[] = self::normalizePath($bakedArtifactDirectory);
} }
$candidateDirectories[] = self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'); foreach (self::legacyPhpAgentDirectories($basePath) as $directory) {
$candidateDirectories[] = self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'); $candidateDirectories[] = $directory;
$candidateDirectories[] = self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'); }
$paths = []; $paths = [];
foreach (array_values(array_unique($candidateDirectories)) as $directory) { foreach (array_values(array_unique($candidateDirectories)) as $directory) {
@@ -102,6 +107,42 @@ class edge_gateway_agent_artifact_locator
return self::normalizePath($basePath . DIRECTORY_SEPARATOR . self::ROUTER_ARTIFACT_DIRECTORY); return self::normalizePath($basePath . DIRECTORY_SEPARATOR . self::ROUTER_ARTIFACT_DIRECTORY);
} }
/**
* @return array<int,string>
*/
private static function edgeAgentBuildDirectories(string $basePath): array
{
$relative = str_replace('/', DIRECTORY_SEPARATOR, self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY);
$directories = [
dirname($basePath, 4) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
];
if (DIRECTORY_SEPARATOR === '/') {
array_unshift($directories, '/edge-agent/' . self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY);
$directories[] = '/services/edge-agent/' . self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY;
}
return array_values(array_unique(array_map(
static fn(string $directory): string => self::normalizePath($directory),
$directories
)));
}
/**
* @return array<int,string>
*/
private static function legacyPhpAgentDirectories(string $basePath): array
{
return [
self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
];
}
private static function mountedArtifactDirectory(): ?string private static function mountedArtifactDirectory(): ?string
{ {
if (DIRECTORY_SEPARATOR !== '/') { if (DIRECTORY_SEPARATOR !== '/') {
@@ -7,6 +7,7 @@ use Exception;
class edge_gateway_install_service class edge_gateway_install_service
{ {
private const ARTIFACTS = [ private const ARTIFACTS = [
'manifest.json' => 'application/json; charset=utf-8',
'agent.php' => 'application/x-httpd-php; charset=utf-8', 'agent.php' => 'application/x-httpd-php; charset=utf-8',
'lan-worker.php' => 'application/x-httpd-php; charset=utf-8', 'lan-worker.php' => 'application/x-httpd-php; charset=utf-8',
'auto-updater.php' => 'application/x-httpd-php; charset=utf-8', 'auto-updater.php' => 'application/x-httpd-php; charset=utf-8',
@@ -26,7 +27,7 @@ class edge_gateway_install_service
public function buildInstallScript(string $plainToken): string public function buildInstallScript(string $plainToken): string
{ {
return $this->manager()->buildInstallScript($plainToken); return $this->normalizeLineEndings($this->manager()->buildInstallScript($plainToken));
} }
/** /**
@@ -42,13 +43,17 @@ class edge_gateway_install_service
*/ */
public function readArtifact(string $fileName): string public function readArtifact(string $fileName): string
{ {
if ($fileName === 'manifest.json') {
return $this->buildManifest();
}
$path = $this->artifactPath($fileName); $path = $this->artifactPath($fileName);
$contents = file_get_contents($path); $contents = file_get_contents($path);
if ($contents === false) { if ($contents === false) {
throw new Exception('Unable to read edge agent artifact'); throw new Exception('Unable to read edge agent artifact');
} }
return $contents; return $this->normalizeLineEndings($contents);
} }
public function contentType(string $fileName): string public function contentType(string $fileName): string
@@ -66,15 +71,55 @@ class edge_gateway_install_service
*/ */
public function artifactPath(string $fileName): string public function artifactPath(string $fileName): string
{ {
if (!array_key_exists($fileName, self::ARTIFACTS)) { if (!array_key_exists($fileName, self::ARTIFACTS) || $fileName === 'manifest.json') {
throw new Exception('Unknown edge agent artifact'); throw new Exception('Unknown edge agent artifact');
} }
return edge_gateway_agent_artifact_locator::resolve($fileName); return edge_gateway_agent_artifact_locator::resolve($fileName);
} }
/**
* @throws Exception
*/
private function buildManifest(): string
{
$artifacts = [];
foreach (self::ARTIFACTS as $fileName => $contentType) {
if ($fileName === 'manifest.json') {
continue;
}
$path = $this->artifactPath($fileName);
$sha256 = hash_file('sha256', $path);
$bytes = filesize($path);
if ($sha256 === false || $bytes === false) {
throw new Exception('Unable to inspect edge agent artifact: ' . $fileName);
}
$artifacts[] = [
'name' => $fileName,
'sha256' => $sha256,
'bytes' => $bytes,
'content_type' => $contentType,
];
}
return json_encode([
'schema_version' => 1,
'package' => 'truckwash-edge-agent',
'version' => edge_gateway_manager::DEFAULT_INSTALL_VERSION,
'generated_at' => gmdate('c'),
'artifacts' => $artifacts,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
}
private function manager(): edge_gateway_manager private function manager(): edge_gateway_manager
{ {
return $this->manager ?? new edge_gateway_manager(); return $this->manager ?? new edge_gateway_manager();
} }
private function normalizeLineEndings(string $contents): string
{
return str_replace(["\r\n", "\r"], "\n", $contents);
}
} }
File diff suppressed because it is too large Load Diff
@@ -18,8 +18,7 @@ class edge_gateway_schema_bootstrap
return; return;
} }
global $db; $pdo = self::pdo();
$queries = [ $queries = [
"CREATE TABLE IF NOT EXISTS edge_gateways ( "CREATE TABLE IF NOT EXISTS edge_gateways (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -124,6 +123,31 @@ class edge_gateway_schema_bootstrap
INDEX idx_edge_gateway_command_type (command_type) INDEX idx_edge_gateway_command_type (command_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS edge_gateway_expected_relay_states (
id INT AUTO_INCREMENT PRIMARY KEY,
gateway_id INT NOT NULL,
department_id INT NOT NULL,
lane_id INT NOT NULL,
role VARCHAR(32) NOT NULL,
relay_id VARCHAR(255) NOT NULL,
device_id VARCHAR(255) NULL,
local_ip VARCHAR(64) NULL,
device_type VARCHAR(64) NOT NULL DEFAULT 'UNKNOWN',
expected_state TINYINT(1) NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL,
completed_at DATETIME NULL,
last_attempted_at DATETIME NULL,
last_error TEXT NULL,
metadata_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
row_updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
UNIQUE KEY uniq_edge_gateway_expected_relay_state (gateway_id, lane_id, role),
INDEX idx_edge_gateway_expected_relay_gateway (gateway_id),
INDEX idx_edge_gateway_expected_relay_department (department_id),
INDEX idx_edge_gateway_expected_relay_pending (gateway_id, completed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS edge_gateway_operations ( "CREATE TABLE IF NOT EXISTS edge_gateway_operations (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
gateway_id INT NOT NULL, gateway_id INT NOT NULL,
@@ -233,11 +257,18 @@ class edge_gateway_schema_bootstrap
]; ];
foreach ($queries as $sql) { foreach ($queries as $sql) {
$db->query($sql); $pdo->exec($sql);
} }
self::ensureColumn('edge_gateway_command_jobs', 'delivery_json', 'JSON NULL AFTER response_json'); self::ensureColumn('edge_gateway_command_jobs', 'delivery_json', 'JSON NULL AFTER response_json');
self::ensureColumn('edge_gateway_expected_relay_states', 'device_type', "VARCHAR(64) NOT NULL DEFAULT 'UNKNOWN' AFTER local_ip");
self::ensureColumn('edge_gateway_expected_relay_states', 'last_attempted_at', 'DATETIME NULL AFTER completed_at');
self::ensureColumn('edge_gateway_expected_relay_states', 'last_error', 'TEXT NULL AFTER last_attempted_at');
self::ensureColumn('edge_gateway_expected_relay_states', 'metadata_json', 'JSON NULL AFTER last_error');
self::ensureColumn('edge_gateway_expected_relay_states', 'row_updated_at', 'TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
self::ensureColumn('edge_gateway_expected_relay_states', 'deleted_at', 'TIMESTAMP NULL DEFAULT NULL AFTER row_updated_at');
self::ensureColumn('edge_gateway_relay_bindings', 'fallback_mode', "VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL' AFTER channel"); self::ensureColumn('edge_gateway_relay_bindings', 'fallback_mode', "VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL' AFTER channel");
self::ensureColumn('edge_gateway_operations', 'type', "VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER gateway_id"); self::ensureColumn('edge_gateway_operations', 'type', "VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER gateway_id");
@@ -304,8 +335,6 @@ class edge_gateway_schema_bootstrap
private static function ensureColumn(string $table, string $column, string $definition): void private static function ensureColumn(string $table, string $column, string $definition): void
{ {
global $db;
if (self::tableHasColumn($table, $column)) { if (self::tableHasColumn($table, $column)) {
return; return;
} }
@@ -314,7 +343,7 @@ class edge_gateway_schema_bootstrap
throw new \RuntimeException('Invalid schema bootstrap identifier'); throw new \RuntimeException('Invalid schema bootstrap identifier');
} }
$db->query( self::pdo()->exec(
"ALTER TABLE `$table` "ALTER TABLE `$table`
ADD COLUMN `$column` $definition" ADD COLUMN `$column` $definition"
); );
@@ -327,8 +356,6 @@ class edge_gateway_schema_bootstrap
string $definition, string $definition,
?string $afterColumn = null ?string $afterColumn = null
): void { ): void {
global $db;
if (!self::tableHasColumn($table, $from) || self::tableHasColumn($table, $to)) { if (!self::tableHasColumn($table, $from) || self::tableHasColumn($table, $to)) {
return; return;
} }
@@ -342,7 +369,7 @@ class edge_gateway_schema_bootstrap
$positionClause = $afterColumn === null ? '' : " AFTER `$afterColumn`"; $positionClause = $afterColumn === null ? '' : " AFTER `$afterColumn`";
$db->query( self::pdo()->exec(
"ALTER TABLE `$table` "ALTER TABLE `$table`
CHANGE COLUMN `$from` `$to` $definition$positionClause" CHANGE COLUMN `$from` `$to` $definition$positionClause"
); );
@@ -350,38 +377,35 @@ class edge_gateway_schema_bootstrap
private static function tableHasColumn(string $table, string $column): bool private static function tableHasColumn(string $table, string $column): bool
{ {
global $db; if (!preg_match('/^[A-Za-z0-9_]+$/', $table) || !preg_match('/^[A-Za-z0-9_]+$/', $column)) {
throw new \RuntimeException('Invalid schema bootstrap identifier');
$table = $db->escape_string($table);
$column = $db->escape_string($column);
$database = $db->escape_string($db->getDatabase());
$result = $db->query(
"SELECT COUNT(*) AS c
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = '$database'
AND TABLE_NAME = '$table'
AND COLUMN_NAME = '$column'"
);
if (!$result) {
return false;
} }
$row = $result->fetch_assoc(); $statement = self::pdo()->prepare(
"SELECT COUNT(*) AS c
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table
AND COLUMN_NAME = :column"
);
$statement->execute([
':table' => $table,
':column' => $column,
]);
$row = $statement->fetch();
return ((int)($row['c'] ?? 0)) > 0; return ((int)($row['c'] ?? 0)) > 0;
} }
private static function syncOperationTypeColumns(): void private static function syncOperationTypeColumns(): void
{ {
global $db;
if (!self::tableHasColumn('edge_gateway_operations', 'type') if (!self::tableHasColumn('edge_gateway_operations', 'type')
|| !self::tableHasColumn('edge_gateway_operations', 'operation_type')) { || !self::tableHasColumn('edge_gateway_operations', 'operation_type')) {
return; return;
} }
$db->query( $pdo = self::pdo();
$pdo->exec(
"UPDATE edge_gateway_operations "UPDATE edge_gateway_operations
SET type = operation_type SET type = operation_type
WHERE operation_type IS NOT NULL WHERE operation_type IS NOT NULL
@@ -389,7 +413,7 @@ class edge_gateway_schema_bootstrap
AND (type IS NULL OR type = '' OR type <> operation_type)" AND (type IS NULL OR type = '' OR type <> operation_type)"
); );
$db->query( $pdo->exec(
"UPDATE edge_gateway_operations "UPDATE edge_gateway_operations
SET operation_type = type SET operation_type = type
WHERE type IS NOT NULL WHERE type IS NOT NULL
@@ -397,4 +421,88 @@ class edge_gateway_schema_bootstrap
AND (operation_type IS NULL OR operation_type = '')" AND (operation_type IS NULL OR operation_type = '')"
); );
} }
private static function pdo(): \PDO
{
$envConfig = self::databaseConfigFromEnvironment();
if ($envConfig !== null) {
return self::connectPdo($envConfig);
}
if (!class_exists(db::class, false)) {
$dbClassPath = __DIR__ . '/../../../classes/db.php';
if (is_file($dbClassPath)) {
require_once $dbClassPath;
}
}
if (!class_exists(db::class)) {
throw new \RuntimeException('Database connection helper is not available.');
}
return db::getPDO();
}
/**
* @return array{host:string,user:string,password:string,database:string,port:int}|null
*/
private static function databaseConfigFromEnvironment(): ?array
{
$target = strtolower(self::envString('CONFIG_DB_TARGET') ?: 'live');
if ($target !== 'debug') {
$target = 'live';
}
$host = self::databaseEnvValue('HOST', $target);
$user = self::databaseEnvValue('USER', $target);
$database = self::databaseEnvValue('DATABASE', $target);
if ($host === '' || $user === '' || $database === '') {
return null;
}
return [
'host' => $host,
'user' => $user,
'password' => self::databaseEnvValue('PASSWORD', $target),
'database' => $database,
'port' => (int)(self::databaseEnvValue('PORT', $target) ?: 3306),
];
}
private static function databaseEnvValue(string $key, string $target): string
{
$liveValue = self::envString('CONFIG_DB_' . $key);
$debugValue = self::envString('CONFIG_DB_DEBUG_' . $key);
if ($target === 'debug' && $debugValue !== '') {
return $debugValue;
}
return $liveValue;
}
private static function envString(string $key): string
{
$value = getenv($key);
if ($value === false || $value === null) {
$value = $_ENV[$key] ?? $_SERVER[$key] ?? '';
}
return trim((string)$value);
}
/**
* @param array{host:string,user:string,password:string,database:string,port:int} $config
*/
private static function connectPdo(array $config): \PDO
{
$port = $config['port'] > 0 ? $config['port'] : 3306;
$dsn = "mysql:host={$config['host']};port={$port};dbname={$config['database']};charset=utf8mb4";
return new \PDO($dsn, $config['user'], $config['password'], [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
\PDO::ATTR_EMULATE_PREPARES => false,
]);
}
} }
@@ -81,6 +81,7 @@ class edgeGatewaysRoute
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify()); $this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
$this->post('/edge-agent/install-token/status', fn() => $this->handleAgentInstallTokenStatus()); $this->post('/edge-agent/install-token/status', fn() => $this->handleAgentInstallTokenStatus());
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript()); $this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
$this->get('/edge-agent/artifacts/manifest.json', fn() => $this->renderArtifact('manifest.json'));
$this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php')); $this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php'));
$this->get('/edge-agent/artifacts/lan-worker.php', fn() => $this->renderArtifact('lan-worker.php')); $this->get('/edge-agent/artifacts/lan-worker.php', fn() => $this->renderArtifact('lan-worker.php'));
$this->get('/edge-agent/artifacts/auto-updater.php', fn() => $this->renderArtifact('auto-updater.php')); $this->get('/edge-agent/artifacts/auto-updater.php', fn() => $this->renderArtifact('auto-updater.php'));
@@ -98,6 +99,8 @@ class edgeGatewaysRoute
$this->post('/edge-agent/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleAgentOperationComplete()); $this->post('/edge-agent/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleAgentOperationComplete());
$this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll()); $this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll());
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult()); $this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
$this->post('/edge-agent/gateways/{id}/expected-relay-states', fn() => $this->handleAgentExpectedRelayStates());
$this->post('/edge-agent/gateways/{id}/relay-state-results', fn() => $this->handleAgentRelayStateResults());
$this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence()); $this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence());
$this->post('/edge-agent/gateways/{id}/selfserve/machine-signal-bindings', fn() => $this->handleAgentSelfserveMachineSignalBindings()); $this->post('/edge-agent/gateways/{id}/selfserve/machine-signal-bindings', fn() => $this->handleAgentSelfserveMachineSignalBindings());
$this->post('/edge-agent/gateways/{id}/selfserve/machine-signal', fn() => $this->handleAgentSelfserveMachineSignal()); $this->post('/edge-agent/gateways/{id}/selfserve/machine-signal', fn() => $this->handleAgentSelfserveMachineSignal());
@@ -109,6 +112,7 @@ class edgeGatewaysRoute
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/events', fn() => $this->handleBrokerOperationEvent()); $this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/events', fn() => $this->handleBrokerOperationEvent());
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleBrokerOperationComplete()); $this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleBrokerOperationComplete());
$this->post('/edge-agent/internal/gateways/{id}/logs', fn() => $this->handleBrokerGatewayLogEntry()); $this->post('/edge-agent/internal/gateways/{id}/logs', fn() => $this->handleBrokerGatewayLogEntry());
$this->post('/edge-agent/internal/gateways/{id}/selfserve/machine-signal', fn() => $this->handleBrokerSelfserveMachineSignal());
$this->post('/edge-agent/internal/browser-streams/validate', fn() => $this->handleBrokerBrowserStreamValidate()); $this->post('/edge-agent/internal/browser-streams/validate', fn() => $this->handleBrokerBrowserStreamValidate());
$this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellSessionValidate()); $this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellSessionValidate());
$this->post('/edge-agent/internal/shell-sessions/opened', fn() => $this->handleBrokerShellSessionOpened()); $this->post('/edge-agent/internal/shell-sessions/opened', fn() => $this->handleBrokerShellSessionOpened());
@@ -398,7 +402,11 @@ class edgeGatewaysRoute
$response->error('Missing token', 400); $response->error('Missing token', 400);
} }
try {
$response->success($this->install()->verifyInstallToken($token)); $response->success($this->install()->verifyInstallToken($token));
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
} }
private function handleAgentInstallTokenStatus(): void private function handleAgentInstallTokenStatus(): void
@@ -407,6 +415,7 @@ class edgeGatewaysRoute
self::requireParameters(['token', 'status']); self::requireParameters(['token', 'status']);
$payload = self::getParametersAsArray(); $payload = self::getParametersAsArray();
try {
$response->success($this->registry()->reportInstallTokenStatus( $response->success($this->registry()->reportInstallTokenStatus(
(string)$payload['token'], (string)$payload['token'],
[ [
@@ -418,6 +427,9 @@ class edgeGatewaysRoute
'last_error' => isset($payload['last_error']) ? (string)$payload['last_error'] : null, 'last_error' => isset($payload['last_error']) ? (string)$payload['last_error'] : null,
] ]
)); ));
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
} }
private function renderArtifact(string $fileName): void private function renderArtifact(string $fileName): void
@@ -439,6 +451,7 @@ class edgeGatewaysRoute
self::requireParameters(['token']); self::requireParameters(['token']);
$payload = self::getParametersAsArray(); $payload = self::getParametersAsArray();
try {
$response->success( $response->success(
$this->registry()->claimGateway( $this->registry()->claimGateway(
(string)$payload['token'], (string)$payload['token'],
@@ -448,6 +461,9 @@ class edgeGatewaysRoute
), ),
201 201
); );
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
} }
private function handleAgentHeartbeat(): void private function handleAgentHeartbeat(): void
@@ -555,6 +571,31 @@ class edgeGatewaysRoute
)); ));
} }
private function handleAgentExpectedRelayStates(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
$payload = self::getParametersAsArray();
$response->success($this->manager()->buildExpectedRelayStatesForAgent(
$gatewayId,
$this->requireAgentToken($payload),
isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : 0
));
}
private function handleAgentRelayStateResults(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
$payload = self::getParametersAsArray();
$results = isset($payload['results']) && is_array($payload['results']) ? (array)$payload['results'] : [];
$response->success($this->manager()->recordExpectedRelayStateResults(
$gatewayId,
$this->requireAgentToken($payload),
$results
));
}
private function handleAgentPresence(): void private function handleAgentPresence(): void
{ {
global /** @var response $response */ $response; global /** @var response $response */ $response;
@@ -706,6 +747,21 @@ class edgeGatewaysRoute
)); ));
} }
private function handleBrokerSelfserveMachineSignal(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSecret();
$gatewayId = (int)$this->fromRoute('id');
$payload = self::getParametersAsArray();
try {
$result = (new selfserve_machine_signal())->recordBrokerEdgeGatewaySignal($gatewayId, $payload);
$response->success($result, !empty($result['recorded']) ? 201 : 202);
} catch (\Throwable $exception) {
$response->error($exception->getMessage(), 400);
}
}
private function handleBrokerBrowserStreamValidate(): void private function handleBrokerBrowserStreamValidate(): void
{ {
global /** @var response $response */ $response; global /** @var response $response */ $response;
@@ -2,7 +2,8 @@
namespace email\templates; namespace email\templates;
use email\helpers\email_template;use objects\users_o; use email\helpers\email_template;
use objects\users_o;
class email_template_new_customer class email_template_new_customer
{ {
@@ -52,6 +53,7 @@ class email_template_new_customer
*/ */
public function generate_html(): string public function generate_html(): string
{ {
$customer_label = htmlspecialchars($this->getCustomerRegistrationLabel(), ENT_QUOTES, 'UTF-8');
ob_start(); ob_start();
# Start of the html # Start of the html
?> ?>
@@ -73,7 +75,7 @@ class email_template_new_customer
<!-- Intro --> <!-- Intro -->
<p class="container-text-md" style="color:#000000;font-size:16px;line-height:1.5;margin:0 0 18px 0;mso-line-height-rule:exactly;"> <p class="container-text-md" style="color:#000000;font-size:16px;line-height:1.5;margin:0 0 18px 0;mso-line-height-rule:exactly;">
Tak for din registrering af <?=((new users_o())->getCustomerName((int)$this->customer_number))?><?=(((new users_o())->getCustomerEcocomicData((int)$this->customer_number)->economic_customer->corporateIdentificationNumber) ? ' (' . (new users_o())->getCustomerEcocomicData((int)$this->customer_number)->economic_customer->corporateIdentificationNumber . ')' : '')?> som kunde hos Truck Wash. Tak for din registrering af <?=$customer_label?> som kunde hos Truck Wash.
</p> </p>
<!-- You can now wash your trucks --> <!-- You can now wash your trucks -->
@@ -185,4 +187,19 @@ class email_template_new_customer
# End of the html # End of the html
return ob_get_clean(); return ob_get_clean();
} }
private function getCustomerRegistrationLabel(): string
{
$customer = (new users_o())->getUserByCustomerNumber($this->customer_number);
$customer_name = trim((string)($customer->getCustomerName($this->customer_number) ?? ''));
$customer_label = $customer_name === '' ? 'virksomhed (CVR)' : $customer_name;
$customer->getCustomerEcocomicData($this->customer_number);
$corporate_identification_number = trim((string)($customer->economic_customer->corporateIdentificationNumber ?? ''));
if ($corporate_identification_number !== '') {
$customer_label .= ' (' . $corporate_identification_number . ')';
}
return $customer_label;
}
} }
@@ -7,6 +7,7 @@ class selfserve_lane_command_arguments
public ?string $license_plate = null; public ?string $license_plate = null;
public ?int $customer_number = null; public ?int $customer_number = null;
public ?int $subuser_id = null; public ?int $subuser_id = null;
public ?string $wash_mode = null;
public bool $defer_relay_side_effects = false; public bool $defer_relay_side_effects = false;
/** /**
@@ -32,6 +33,22 @@ class selfserve_lane_command_arguments
return $this; return $this;
} }
public function setWashMode(?string $wash_mode): self
{
$normalized = strtolower(trim((string)$wash_mode));
if ($wash_mode === null || $normalized === '') {
$this->wash_mode = null;
return $this;
}
if (!in_array($normalized, ['manual', 'machine'], true)) {
throw new \InvalidArgumentException('Invalid wash type: ' . $wash_mode);
}
$this->wash_mode = $normalized;
return $this;
}
public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self
{ {
$this->defer_relay_side_effects = $defer_relay_side_effects; $this->defer_relay_side_effects = $defer_relay_side_effects;
@@ -50,6 +67,12 @@ class selfserve_lane_command_arguments
if (array_key_exists('subuser_id', $params)) { if (array_key_exists('subuser_id', $params)) {
$this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']); $this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']);
} }
if (array_key_exists('wash_type', $params)) {
$this->setWashMode($params['wash_type'] === null ? null : (string)$params['wash_type']);
}
if (array_key_exists('wash_mode', $params)) {
$this->setWashMode($params['wash_mode'] === null ? null : (string)$params['wash_mode']);
}
if (array_key_exists('defer_relay_side_effects', $params)) { if (array_key_exists('defer_relay_side_effects', $params)) {
$this->setDeferRelaySideEffects(filter_var( $this->setDeferRelaySideEffects(filter_var(
$params['defer_relay_side_effects'], $params['defer_relay_side_effects'],
@@ -148,6 +148,25 @@ class selfserve_machine_signal
$gateway = (new edge_gateway_manager())->authenticateGateway($gatewayId, $agentToken); $gateway = (new edge_gateway_manager())->authenticateGateway($gatewayId, $agentToken);
$departmentId = (int)$gateway->department_id->value(); $departmentId = (int)$gateway->department_id->value();
return $this->recordEdgeGatewaySignalForDepartment($gatewayId, $departmentId, $payload);
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
public function recordBrokerEdgeGatewaySignal(int $gatewayId, array $payload): array
{
$gateway = (new edge_gateway_manager())->getGateway($gatewayId);
return $this->recordEdgeGatewaySignalForDepartment($gatewayId, (int)$gateway['department_id'], $payload);
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
private function recordEdgeGatewaySignalForDepartment(int $gatewayId, int $departmentId, array $payload): array
{
return $this->recordCloudShellySignal( return $this->recordCloudShellySignal(
$departmentId, $departmentId,
isset($payload['lane_id']) ? (int)$payload['lane_id'] : null, isset($payload['lane_id']) ? (int)$payload['lane_id'] : null,
@@ -48,8 +48,8 @@ class selfserve_studio_graph
{ {
private const DEFAULT_PATH_MAX_STATES = 2048; private const DEFAULT_PATH_MAX_STATES = 2048;
private const MAX_PATH_MAX_STATES = 2048; private const MAX_PATH_MAX_STATES = 2048;
private const DEFAULT_PATH_SAMPLE_LIMIT = 200; private const DEFAULT_PATH_SAMPLE_LIMIT = 2048;
private const MAX_PATH_SAMPLE_LIMIT = 200; private const MAX_PATH_SAMPLE_LIMIT = 2048;
/** @var array<string,array<int,string>> */ /** @var array<string,array<int,string>> */
private array $columnCache = []; private array $columnCache = [];
@@ -103,12 +103,28 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null, bool $syncRelayState = true, array $options = []): array public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null, bool $syncRelayState = true, array $options = []): array
{ {
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options); $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
$mutationResult = $this->withSessionMutationLock(
$laneId,
$snapshot['reg'],
$snapshot['customer_number'],
function () use ($laneId, $snapshot, $options): array {
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']); $session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
$createSession = (bool)($options['create_session'] ?? true);
if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) { if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
return $session->exists() return [
'session' => $session,
'response' => $session->exists()
? $this->getSessionSummary((int)$session->id) ? $this->getSessionSummary((int)$session->id)
: $this->formatBlockedSessionSummary($snapshot); : $this->formatBlockedSessionSummary($snapshot),
];
}
if (!$session->exists() && !$createSession) {
return [
'session' => $session,
'response' => $this->formatSnapshotResponse($snapshot, null),
];
} }
if (!$session->exists()) { if (!$session->exists()) {
@@ -144,9 +160,23 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']), 'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
]); ]);
return [
'session' => $session,
'response' => null,
];
}
);
$session = $mutationResult['session'];
if ($mutationResult['response'] !== null) {
return $mutationResult['response'];
}
if ($syncRelayState) { if ($syncRelayState) {
if ($session->exists()) {
$this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine); $this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine);
} }
}
return $this->getSessionSummary((int)$session->id); return $this->getSessionSummary((int)$session->id);
} }
@@ -330,9 +360,13 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$allowedServices = $this->normalizeServiceNames( $allowedServices = $this->normalizeServiceNames(
is_array($metadata['allowed_services'] ?? null) ? (array)$metadata['allowed_services'] : [] is_array($metadata['allowed_services'] ?? null) ? (array)$metadata['allowed_services'] : []
); );
$machineAvailable = array_key_exists('machine_available', $metadata) $machineWashEnabled = $this->isMachineWashEnabled();
if (!$machineWashEnabled) {
$allowedServices = $this->withoutMachineService($allowedServices);
}
$machineAvailable = $machineWashEnabled && (array_key_exists('machine_available', $metadata)
? (bool)$metadata['machine_available'] ? (bool)$metadata['machine_available']
: ($lane->exists() && !empty($lane->relay_machine_id->value())); : ($lane->exists() && !empty($lane->relay_machine_id->value())));
$allVisibleQuestionsAnswered = array_key_exists('all_visible_questions_answered', $metadata) $allVisibleQuestionsAnswered = array_key_exists('all_visible_questions_answered', $metadata)
? (bool)$metadata['all_visible_questions_answered'] ? (bool)$metadata['all_visible_questions_answered']
: true; : true;
@@ -378,6 +412,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'events' => $events, 'events' => $events,
'allowed_services' => $allowedServices, 'allowed_services' => $allowedServices,
'machine_available' => $machineAvailable, 'machine_available' => $machineAvailable,
'machine_wash_enabled' => $machineWashEnabled,
'all_visible_questions_answered' => $allVisibleQuestionsAnswered, 'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
'allowed' => (bool)$session->allowed->value(), 'allowed' => (bool)$session->allowed->value(),
'config_version_id' => $metadata['config_version_id'] ?? null, 'config_version_id' => $metadata['config_version_id'] ?? null,
@@ -395,7 +430,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $this->getSessionSummary((int)$session->id); return $this->getSessionSummary((int)$session->id);
} }
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array
{ {
$session = $reg !== null $session = $reg !== null
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber) ? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber)
@@ -405,8 +440,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return null; return null;
} }
$session->markCompleted($orderId); $this->fillMissingWashStartedAtFromLaneRuntime($session, $laneId);
if (!$session->markCompletedIfOpen($orderId)) {
return $this->getSessionSummary((int)$session->id);
}
if ($disableRelays) {
$this->disableMachineRelayForCompletedWash($laneId); $this->disableMachineRelayForCompletedWash($laneId);
}
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [ $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [
'lane_id' => $laneId, 'lane_id' => $laneId,
'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg), 'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg),
@@ -417,6 +458,25 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $this->getSessionSummary((int)$session->id); return $this->getSessionSummary((int)$session->id);
} }
protected function fillMissingWashStartedAtFromLaneRuntime(selfserve_wash_sessions_o $session, int $laneId): void
{
try {
if ($session->wash_started_at->value() !== null) {
return;
}
$lane = (new selfserve())->lane($laneId);
$washStartedAt = (int)$lane->getWashStartTime();
if ($washStartedAt <= 0) {
return;
}
$session->wash_started_at->set(date('Y-m-d H:i:s', $washStartedAt));
} catch (\Throwable) {
// Session timestamp enrichment must not block STOP completion.
}
}
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array
{ {
$lane = (new selfserve())->lane($laneId); $lane = (new selfserve())->lane($laneId);
@@ -450,10 +510,13 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'runtime_before_reset' => $runtimeSnapshot, 'runtime_before_reset' => $runtimeSnapshot,
'forced_at' => date('Y-m-d H:i:s'), 'forced_at' => date('Y-m-d H:i:s'),
]; ];
$session->markForceStopped($orderId, $eventPayload); if (!$session->markForceStoppedIfOpen($orderId, $eventPayload)) {
$summary = $this->getSessionSummary((int)$session->id);
} else {
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_FORCE_STOPPED, $eventPayload); $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_FORCE_STOPPED, $eventPayload);
$summary = $this->getSessionSummary((int)$session->id); $summary = $this->getSessionSummary((int)$session->id);
} }
}
$lane->execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments()); $lane->execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments());
@@ -650,7 +713,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
} }
} }
$machineAvailable = !empty($lane->relay_machine_id->value()); $machineWashEnabled = $this->isMachineWashEnabled();
if (!$machineWashEnabled) {
$allowedServices = $this->withoutMachineService($allowedServices);
}
$machineAvailable = $machineWashEnabled && !empty($lane->relay_machine_id->value());
$allVisibleQuestionsAnswered = true; $allVisibleQuestionsAnswered = true;
foreach ($visibleQuestions as $question) { foreach ($visibleQuestions as $question) {
if ($question['answer'] === null) { if ($question['answer'] === null) {
@@ -689,8 +757,10 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'tasks' => $visibleTasks, 'tasks' => $visibleTasks,
'allowed_services' => $allowedServices, 'allowed_services' => $allowedServices,
'machine_available' => $machineAvailable, 'machine_available' => $machineAvailable,
'machine_wash_enabled' => $machineWashEnabled,
'all_visible_questions_answered' => $allVisibleQuestionsAnswered, 'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
'allowed' => $machineAllowed, 'allowed' => $machineAllowed,
'blocked_reason' => !$machineWashEnabled ? 'Machine wash is disabled globally.' : null,
'config_version_id' => $publishedConfigVersionId === null ? null : (int)$publishedConfigVersionId, 'config_version_id' => $publishedConfigVersionId === null ? null : (int)$publishedConfigVersionId,
'config_source' => $configSource, 'config_source' => $configSource,
'evaluation_trace' => [ 'evaluation_trace' => [
@@ -789,6 +859,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'tasks' => $snapshot['tasks'], 'tasks' => $snapshot['tasks'],
'allowed_services' => $snapshot['allowed_services'], 'allowed_services' => $snapshot['allowed_services'],
'machine_available' => $snapshot['machine_available'], 'machine_available' => $snapshot['machine_available'],
'machine_wash_enabled' => $snapshot['machine_wash_enabled'] ?? true,
'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'], 'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'],
'allowed' => $snapshot['allowed'], 'allowed' => $snapshot['allowed'],
'blocked_reason' => $snapshot['blocked_reason'] ?? null, 'blocked_reason' => $snapshot['blocked_reason'] ?? null,
@@ -2962,7 +3033,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return array_values(array_filter($questions, static function (array $question) use ($departmentId, $laneId, $vehicleTypeId): bool { return array_values(array_filter($questions, static function (array $question) use ($departmentId, $laneId, $vehicleTypeId): bool {
return (int)($question['department'] ?? 0) === $departmentId return (int)($question['department'] ?? 0) === $departmentId
&& (int)($question['lane'] ?? 0) === $laneId && ((int)($question['lane'] ?? 0) === 0 || (int)($question['lane'] ?? 0) === $laneId)
&& (int)($question['product'] ?? 0) === $vehicleTypeId; && (int)($question['product'] ?? 0) === $vehicleTypeId;
})); }));
} }
@@ -3005,7 +3076,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return array_values(array_filter($conditions, static function (array $condition) use ($departmentId, $laneId, $vehicleTypeId): bool { return array_values(array_filter($conditions, static function (array $condition) use ($departmentId, $laneId, $vehicleTypeId): bool {
return (int)($condition['machine_type_id'] ?? 0) === 0 return (int)($condition['machine_type_id'] ?? 0) === 0
&& (int)($condition['department'] ?? 0) === $departmentId && (int)($condition['department'] ?? 0) === $departmentId
&& (int)($condition['lane'] ?? 0) === $laneId && ((int)($condition['lane'] ?? 0) === 0 || (int)($condition['lane'] ?? 0) === $laneId)
&& (int)($condition['product'] ?? 0) === $vehicleTypeId; && (int)($condition['product'] ?? 0) === $vehicleTypeId;
})); }));
} }
@@ -3049,7 +3120,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return array_values(array_filter($tasks, static function (array $task) use ($departmentId, $laneId, $vehicleTypeId): bool { return array_values(array_filter($tasks, static function (array $task) use ($departmentId, $laneId, $vehicleTypeId): bool {
return (int)($task['machine_type_id'] ?? 0) === 0 return (int)($task['machine_type_id'] ?? 0) === 0
&& (int)($task['department'] ?? 0) === $departmentId && (int)($task['department'] ?? 0) === $departmentId
&& (int)($task['lane'] ?? 0) === $laneId && ((int)($task['lane'] ?? 0) === 0 || (int)($task['lane'] ?? 0) === $laneId)
&& (int)($task['product'] ?? 0) === $vehicleTypeId; && (int)($task['product'] ?? 0) === $vehicleTypeId;
})); }));
} }
@@ -3188,11 +3259,92 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
(new selfserve_wash_session_events_o())->add($sessionId, $eventType, $payload); (new selfserve_wash_session_events_o())->add($sessionId, $eventType, $payload);
} }
/**
* @param callable():array<string,mixed> $callback
* @return array<string,mixed>
*/
protected function withSessionMutationLock(int $laneId, string $reg, ?int $customerNumber, callable $callback): array
{
$lockKey = $this->sessionMutationLockKey($laneId, $reg, $customerNumber);
$lock = $this->acquireSessionMutationLock($lockKey);
try {
return $callback();
} finally {
$this->releaseSessionMutationLock($lock);
}
}
/**
* @return array{driver:string,key:string,token:?string}
*/
protected function acquireSessionMutationLock(string $lockKey): array
{
if (defined('redis') && method_exists(redis, 'set_if_absent_with_expiration')) {
$token = bin2hex(random_bytes(16));
if (!redis->set_if_absent_with_expiration($lockKey, $token, 15)) {
throw new \RuntimeException('Self-serve wash session is busy. Try again.');
}
return [
'driver' => 'redis',
'key' => $lockKey,
'token' => $token,
];
}
global $db;
$result = $db->query("SELECT GET_LOCK('" . $db->escape_string($lockKey) . "', 5) AS acquired");
$row = $db->fetch_assoc($result);
if ((int)($row['acquired'] ?? 0) !== 1) {
throw new \RuntimeException('Self-serve wash session is busy. Try again.');
}
return [
'driver' => 'mysql',
'key' => $lockKey,
'token' => null,
];
}
/**
* @param array{driver:string,key:string,token:?string} $lock
*/
protected function releaseSessionMutationLock(array $lock): void
{
try {
if ($lock['driver'] === 'redis' && defined('redis')) {
if (method_exists(redis, 'get') && redis->get($lock['key']) !== $lock['token']) {
return;
}
if (method_exists(redis, 'delete')) {
redis->delete($lock['key']);
}
return;
}
if ($lock['driver'] === 'mysql') {
global $db;
$db->query("SELECT RELEASE_LOCK('" . $db->escape_string($lock['key']) . "')");
}
} catch (\Throwable) {
// Locks have TTLs or connection scope; release failures must not mask API results.
}
}
protected function sessionMutationLockKey(int $laneId, string $reg, ?int $customerNumber): string
{
return 'selfserve_session_mutation:' . (int)$laneId . ':' . sha1(
selfserve::standardize_registration($reg) . ':' . ($customerNumber === null ? 'anon' : (string)(int)$customerNumber)
);
}
protected function buildSessionMetadata(array $snapshot): array protected function buildSessionMetadata(array $snapshot): array
{ {
return [ return [
'allowed_services' => $snapshot['allowed_services'], 'allowed_services' => $snapshot['allowed_services'],
'machine_available' => (bool)$snapshot['machine_available'], 'machine_available' => (bool)$snapshot['machine_available'],
'machine_wash_enabled' => (bool)($snapshot['machine_wash_enabled'] ?? true),
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'], 'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
'config_version_id' => $snapshot['config_version_id'] ?? null, 'config_version_id' => $snapshot['config_version_id'] ?? null,
'evaluation_trace' => $snapshot['evaluation_trace'] ?? null, 'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
@@ -3491,6 +3643,27 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
)); ));
} }
protected function isMachineWashEnabled(): bool
{
try {
return (new selfserve())->config->machine_wash_enabled->isTrue();
} catch (\Throwable) {
return true;
}
}
/**
* @param array<int,string> $services
* @return array<int,string>
*/
protected function withoutMachineService(array $services): array
{
return array_values(array_filter(
$this->normalizeServiceNames($services),
static fn(string $service): bool => $service !== selfserve_lane_services::MACHINE->name
));
}
protected function taskUsesMachineControls(array $task): bool protected function taskUsesMachineControls(array $task): bool
{ {
if (in_array(selfserve_lane_services::MACHINE->name, $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), true)) { if (in_array(selfserve_lane_services::MACHINE->name, $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), true)) {
@@ -0,0 +1,32 @@
<?php
namespace modules\selfserve\config;
use Exception;
use traits\module_config_variable;
class selfserve_dynamic_image_size_c
{
use module_config_variable;
public const SIZE_ORIGINAL = 'original';
public const SIZE_RELEVANT = 'relevant';
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'selfserve',
'dynamic_image_size',
'string',
true,
[self::SIZE_ORIGINAL, self::SIZE_RELEVANT],
'Whether self-serve dynamic images are served in the original rendered size or resized to the relevant terminal size',
self::SIZE_RELEVANT,
false,
self::SIZE_ORIGINAL
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace modules\selfserve\config;
use Exception;
use traits\module_config_variable;
class selfserve_machine_wash_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'selfserve',
'machine_wash_enabled',
'bool',
true,
null,
'Whether machine wash is available in customer-facing self-serve flows',
'1',
false,
true
);
}
}
@@ -14,7 +14,7 @@ interface selfserve_wash_flow_i
public function getLatestSessionSummary(int $laneId, string $reg): array; public function getLatestSessionSummary(int $laneId, string $reg): array;
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array; public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array;
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array; public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array;
} }
@@ -439,7 +439,7 @@ Public methods:
| `recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = [])` | The machine button or hardware event fired. | Full session summary after the machine-start event. | | `recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = [])` | The machine button or hardware event fired. | Full session summary after the machine-start event. |
| `getSessionSummary(int $sessionId)` | You have a session id already. | Full session summary. | | `getSessionSummary(int $sessionId)` | You have a session id already. | Full session summary. |
| `getLatestSessionSummary(int $laneId, string $reg)` | You want the latest session for a lane and vehicle. | Full session summary. | | `getLatestSessionSummary(int $laneId, string $reg)` | You want the latest session for a lane and vehicle. | Full session summary. |
| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null)` | STOP has finished and you want to close the latest open session. | Full summary, or `null` if no open session exists. | | `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true)` | STOP has finished and you want to close the latest open session. Normal STOP passes `false` because it already disabled relays before opening the exit port. | Full summary, or `null` if no open session exists. |
Key implementation details: Key implementation details:
@@ -2,10 +2,14 @@
namespace modules\selfserve; namespace modules\selfserve;
require_once WD . '/modules/selfserve/config/selfserve_enabled_c.php'; require_once WD . '/modules/selfserve/config/selfserve_enabled_c.php';
require_once WD . '/modules/selfserve/config/selfserve_machine_wash_enabled_c.php';
require_once WD . '/modules/selfserve/config/selfserve_minute_product_c.php'; require_once WD . '/modules/selfserve/config/selfserve_minute_product_c.php';
require_once WD . '/modules/selfserve/config/selfserve_machine_wash_minutes_included_c.php'; require_once WD . '/modules/selfserve/config/selfserve_machine_wash_minutes_included_c.php';
require_once WD . '/modules/selfserve/config/selfserve_dynamic_image_size_c.php';
use modules\selfserve\config\selfserve_dynamic_image_size_c;
use modules\selfserve\config\selfserve_enabled_c; use modules\selfserve\config\selfserve_enabled_c;
use modules\selfserve\config\selfserve_machine_wash_enabled_c;
use modules\selfserve\config\selfserve_machine_wash_minutes_included_c; use modules\selfserve\config\selfserve_machine_wash_minutes_included_c;
use modules\selfserve\config\selfserve_minute_product_c; use modules\selfserve\config\selfserve_minute_product_c;
use traits\module_config_t; use traits\module_config_t;
@@ -19,6 +23,11 @@ class selfserve_c
* @var selfserve_enabled_c $enabled * @var selfserve_enabled_c $enabled
*/ */
public selfserve_enabled_c $enabled; public selfserve_enabled_c $enabled;
/**
* Whether machine wash is available in customer-facing self-serve flows
* @var selfserve_machine_wash_enabled_c $machine_wash_enabled
*/
public selfserve_machine_wash_enabled_c $machine_wash_enabled;
/** /**
* The product ID used for minute-based self-serve billing * The product ID used for minute-based self-serve billing
* @var selfserve_minute_product_c $minute_product * @var selfserve_minute_product_c $minute_product
@@ -29,17 +38,26 @@ class selfserve_c
* @var selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included * @var selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included
*/ */
public selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included; public selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included;
/**
* Dynamic image output size mode for self-serve terminals
* @var selfserve_dynamic_image_size_c $dynamic_image_size
*/
public selfserve_dynamic_image_size_c $dynamic_image_size;
public function __construct() public function __construct()
{ {
$this->setupConfig('selfserve'); $this->setupConfig('selfserve');
$this->allowUpdate([ $this->allowUpdate([
selfserve_enabled_c::class, selfserve_enabled_c::class,
selfserve_machine_wash_enabled_c::class,
selfserve_minute_product_c::class, selfserve_minute_product_c::class,
selfserve_machine_wash_minutes_included_c::class selfserve_machine_wash_minutes_included_c::class,
selfserve_dynamic_image_size_c::class
]); ]);
$this->enabled = new selfserve_enabled_c(); $this->enabled = new selfserve_enabled_c();
$this->machine_wash_enabled = new selfserve_machine_wash_enabled_c();
$this->minute_product = new selfserve_minute_product_c(); $this->minute_product = new selfserve_minute_product_c();
$this->machine_wash_minutes_included = new selfserve_machine_wash_minutes_included_c(); $this->machine_wash_minutes_included = new selfserve_machine_wash_minutes_included_c();
$this->dynamic_image_size = new selfserve_dynamic_image_size_c();
} }
} }
@@ -171,13 +171,12 @@ trait selfserve_lane_command_t
* Ensure machine relay is ON when a wash starts, when it is allowed by configuration. * Ensure machine relay is ON when a wash starts, when it is allowed by configuration.
* If machine relay is not configured, this is a no-op. * If machine relay is not configured, this is a no-op.
*/ */
protected function setMachineRelayStatusForWashStart(): void protected function setMachineRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
{ {
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE)) { if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE)) {
return; return;
} }
$active_wash = new selfserve_wash_flow(); if ($this->isMachineWashSelectedAndAvailableForStart($arguments)) {
if ($active_wash->isMachineAllowedToStartWash($this->id)) {
try { try {
$this->setMachineRelayStatusHard(true); $this->setMachineRelayStatusHard(true);
} catch (\Throwable) { } catch (\Throwable) {
@@ -193,6 +192,116 @@ trait selfserve_lane_command_t
} }
} }
/**
* Keep the program picker relay aligned with the selected wash mode at START.
* It is ON only when the customer explicitly selected machine wash.
*/
protected function setProgramPickerRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
{
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) {
return;
}
try {
$shouldEnable = $this->isExplicitMachineWashModeSelectedForStart($arguments)
&& $this->isMachineWashSelectedAndAvailableForStart($arguments);
$this->setMachineProgramPickerRelayStatusHard($shouldEnable);
} catch (\Throwable) {
// Best effort only; wash start must continue.
}
}
protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): void
{
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) {
return;
}
try {
$this->setMachineProgramPickerRelayStatusHard($this->shouldEnableProgramPickerRelayForWashStart($arguments));
} catch (\Throwable) {
// Best effort only; wash start must continue.
}
}
protected function isMachineWashSelectedAndAvailableForStart(?selfserve_lane_command_arguments $arguments = null): bool
{
if (!$this->shouldEnableSelectedMachineServiceForWashStart($arguments)) {
return false;
}
try {
$reg = method_exists($this, 'getLicensePlate') ? trim((string)$this->getLicensePlate()) : '';
if ($reg === '') {
return false;
}
$customerNumber = method_exists($this, 'getCustomerNumber') ? (int)$this->getCustomerNumber() : null;
$snapshot = (new selfserve_wash_flow())->previewVehicleEligibility(
(int)$this->id,
$reg,
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null
);
return (bool)($snapshot['allowed'] ?? false);
} catch (\Throwable) {
return false;
}
}
protected function shouldEnableSelectedMachineServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): bool
{
if (!$this->isMachineWashModeSelectedForStart($arguments)) {
return false;
}
return $this->isMachineServiceSelectedForWashStart();
}
protected function shouldEnableProgramPickerRelayForWashStart(?selfserve_lane_command_arguments $arguments = null): bool
{
return $this->isExplicitMachineWashModeSelectedForStart($arguments)
&& $this->isMachineServiceSelectedForWashStart();
}
protected function isExplicitMachineWashModeSelectedForStart(?selfserve_lane_command_arguments $arguments = null): bool
{
return $arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MACHINE;
}
protected function isMachineWashModeSelectedForStart(?selfserve_lane_command_arguments $arguments = null): bool
{
if ($arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MANUAL) {
return false;
}
if ($arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MACHINE) {
return true;
}
return $this->isMachineServiceSelectedForWashStart();
}
protected function isMachineServiceSelectedForWashStart(): bool
{
try {
if (method_exists($this, 'getLaneCache') && defined(self::class . '::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES')) {
$services = $this->getLaneCache((int)$this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES);
if (is_array($services)) {
foreach ($services as $service) {
if (strtoupper((string)$service) === 'MACHINE') {
return true;
}
}
}
}
} catch (\Throwable) {
// Fall through to fail-closed when the selected service cache is unavailable.
}
return false;
}
protected function openEntrancePortForWashStart(): void protected function openEntrancePortForWashStart(): void
{ {
try { try {
@@ -274,31 +383,27 @@ trait selfserve_lane_command_t
protected function runRelaySideEffectsForWashStart(selfserve_lane_command_arguments $arguments): void protected function runRelaySideEffectsForWashStart(selfserve_lane_command_arguments $arguments): void
{ {
if ($arguments->defer_relay_side_effects) { if ($arguments->defer_relay_side_effects) {
$this->setProgramPickerRelayStatusFromSelectedServiceForWashStart($arguments);
return; return;
} }
// Ensure cleaner relay is enabled whenever wash starts.
$this->turnOnCleanerRelayForWashStart(); $this->turnOnCleanerRelayForWashStart();
// Ensure the machine relay is ON when a wash starts, when it is allowed. $this->setProgramPickerRelayStatusForWashStart($arguments);
$this->setMachineRelayStatusForWashStart(); $this->setMachineRelayStatusForWashStart($arguments);
} }
protected function resolveSelfServeActionWashModeForStart(): string protected function resolveSelfServeActionWashModeForStart(?selfserve_lane_command_arguments $arguments = null): string
{ {
try { if ($arguments !== null && in_array($arguments->wash_mode, [
if (method_exists($this, 'getLaneCache') && defined(self::class . '::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES')) { selfserve_studio_actions::MODE_MANUAL,
$services = $this->getLaneCache((int)$this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES); selfserve_studio_actions::MODE_MACHINE,
if (is_array($services)) { ], true)) {
foreach ($services as $service) { return $arguments->wash_mode;
if (strtoupper((string)$service) === 'MACHINE') { }
if ($this->isMachineServiceSelectedForWashStart()) {
return selfserve_studio_actions::MODE_MACHINE; return selfserve_studio_actions::MODE_MACHINE;
} }
}
}
}
} catch (\Throwable) {
// Fall through to manual mode when the cached service set is unavailable.
}
return selfserve_studio_actions::MODE_MANUAL; return selfserve_studio_actions::MODE_MANUAL;
} }
@@ -382,14 +487,16 @@ trait selfserve_lane_command_t
} }
/** /**
* Disable relays after STOP in deterministic order: * Disable relays before opening exit gates in deterministic order:
* 1. Cleaner relay * 1. Cleaner relay
* 2. Machine relay * 2. Program picker relay
* 3. Machine relay
*/ */
protected function turnOffRelaysAfterStop(): void protected function turnOffRelaysAfterStop(): void
{ {
$relays = [ $relays = [
selfserve_lane_relay::MACHINE_CLEANER, selfserve_lane_relay::MACHINE_CLEANER,
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
selfserve_lane_relay::MACHINE, selfserve_lane_relay::MACHINE,
]; ];
@@ -431,7 +538,8 @@ trait selfserve_lane_command_t
$this->id, $this->id,
$this->getLicensePlate() ?: null, $this->getLicensePlate() ?: null,
$this->getCustomerNumber() ?: null, $this->getCustomerNumber() ?: null,
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null,
false
); );
} catch (\Throwable) { } catch (\Throwable) {
// Session completion must not block STOP flow. // Session completion must not block STOP flow.
@@ -563,8 +671,17 @@ trait selfserve_lane_command_t
// Set the customer number and license plate // Set the customer number and license plate
$this->setCustomerNumber($customer_number); $this->setCustomerNumber($customer_number);
$this->setLicensePlate($license_plate); $this->setLicensePlate($license_plate);
// Mark the lane occupied before any physical entrance relay side effects. // Mark the lane occupied before any physical relay or gate side effects.
$this->setLaneStatus(selfserve_lane_status::OCCUPIED); $this->setLaneStatus(selfserve_lane_status::OCCUPIED);
$this->runRelaySideEffectsForWashStart($arguments);
$this->runPublishedStudioActions(
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
$this->resolveSelfServeActionWashModeForStart($arguments),
[
'customer_number' => (int)$customer_number,
'reg' => $license_plate,
]
);
try { try {
// Gateway timeouts are ambiguous because the relay may already have received // Gateway timeouts are ambiguous because the relay may already have received
// the pulse, so openEntrancePortForWashStart() reports them and continues. // the pulse, so openEntrancePortForWashStart() reports them and continues.
@@ -580,15 +697,6 @@ trait selfserve_lane_command_t
$this->setLaneState(selfserve_lane_state::IN_WASH); $this->setLaneState(selfserve_lane_state::IN_WASH);
// Start the wash timer // Start the wash timer
$this->setWashStartTime(time()); $this->setWashStartTime(time());
$this->runPublishedStudioActions(
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
$this->resolveSelfServeActionWashModeForStart(),
[
'customer_number' => (int)$customer_number,
'reg' => $license_plate,
]
);
$this->runRelaySideEffectsForWashStart($arguments);
// Log the lane start event // Log the lane start event
$this->logLaneAction(selfserve_lane_log_action::START_WASH); $this->logLaneAction(selfserve_lane_log_action::START_WASH);
} finally { } finally {
@@ -604,6 +712,8 @@ trait selfserve_lane_command_t
} }
// Snapshot the physical machine ON signal before session completion/reset. // Snapshot the physical machine ON signal before session completion/reset.
$machine_start_triggered = $this->hasMachineStartSignalForStop(); $machine_start_triggered = $this->hasMachineStartSignalForStop();
// Turn off relays before any configured or default exit gate opens.
$this->turnOffRelaysAfterStop();
$this->runPublishedStudioActions( $this->runPublishedStudioActions(
selfserve_studio_actions::EVENT_WASH_STOP_COMMAND, selfserve_studio_actions::EVENT_WASH_STOP_COMMAND,
$machine_start_triggered ? selfserve_studio_actions::MODE_MACHINE : selfserve_studio_actions::MODE_MANUAL, $machine_start_triggered ? selfserve_studio_actions::MODE_MACHINE : selfserve_studio_actions::MODE_MANUAL,
@@ -616,8 +726,6 @@ trait selfserve_lane_command_t
// Open the exit port. Gateway timeouts are ambiguous because // Open the exit port. Gateway timeouts are ambiguous because
// the relay may already have received the pulse. // the relay may already have received the pulse.
$this->openExitPortForWashStop(); $this->openExitPortForWashStop();
// Turn off relays in deterministic order after STOP
$this->turnOffRelaysAfterStop();
// Log the lane stop event // Log the lane stop event
$this->logLaneAction(selfserve_lane_log_action::STOP_WASH); $this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
// Invoice the customer // Invoice the customer
@@ -12,6 +12,7 @@ use modules\selfserve\helpers\selfserve_lane_relay;
use modules\selfserve\helpers\selfserve_lane_services; use modules\selfserve\helpers\selfserve_lane_services;
use modules\selfserve\helpers\selfserve_lane_status; use modules\selfserve\helpers\selfserve_lane_status;
use modules\shelly\helpers\shelly_request_body_get_states; use modules\shelly\helpers\shelly_request_body_get_states;
use objects\selfserve_wash_sessions_o;
trait selfserve_lane_relay_controller_t trait selfserve_lane_relay_controller_t
{ {
@@ -917,7 +918,38 @@ trait selfserve_lane_relay_controller_t
} }
} }
return $this->sendRelaySwitchCommand($relay, true, $duration); $result = $this->sendRelaySwitchCommand($relay, true, $duration);
if ($result && $relay === selfserve_lane_relay::MACHINE) {
$this->markLatestSelfServeSessionRelayEnabledForLane();
}
return $result;
}
protected function markLatestSelfServeSessionRelayEnabledForLane(): void
{
try {
$customerNumber = (int)$this->getCustomerNumber();
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane(
(int)$this->id,
$customerNumber > 0 ? $customerNumber : null
);
if (!$session->exists() && $customerNumber > 0) {
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane((int)$this->id);
}
if (!$session->exists() || (bool)$session->machine_relay_enabled->value() === true) {
return;
}
$session->markRelayEnabled();
} catch (\Throwable $e) {
error_log(
'Failed to synchronize self-serve machine relay session state for lane '
. (int)$this->id
. ': '
. $e->getMessage()
);
}
} }
/** /**
@@ -0,0 +1,29 @@
<?php
namespace slack\config;
use Exception;
use traits\module_config_variable;
class slack_customer_registration_webhook_url_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Slack',
'customer_registration_webhook_url',
'string',
false,
null,
'Slack webhook URL used for successful customer registration notifications',
'https://hooks.slack.com/services/...',
true,
''
);
}
}

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