Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10814e68ae | ||
|
|
fefe18a719 | ||
|
|
9b2d5d5291 | ||
|
|
e1fb79d9b6 | ||
|
|
879dfcf79a | ||
|
|
0feb705059 | ||
|
|
69b3bf83c4 | ||
|
|
5bac316e4b | ||
|
|
233133365d | ||
|
|
026492c3bd | ||
|
|
403f93e62c | ||
|
|
582edd3e6c | ||
|
|
a4fafaf7fb | ||
|
|
327e9cf817 | ||
|
|
012e5366ba | ||
|
|
fa1ade555f | ||
|
|
7a1c444df0 | ||
|
|
6a00f023b1 | ||
|
|
8aefbd8fb3 | ||
|
|
a7181a4ab2 | ||
|
|
fc6c76ad1b | ||
|
|
6a694f92cc | ||
|
|
b7a2dc04d7 | ||
|
|
870b88e707 | ||
|
|
e14cddc1fb | ||
|
|
31887fa8c9 | ||
|
|
eac83b18a0 | ||
|
|
3817a37021 | ||
|
|
940a3e5e9b | ||
|
|
3221223865 | ||
|
|
b51006d9d1 | ||
|
|
6b7592921d | ||
|
|
f26a427510 | ||
|
|
6de747252f | ||
|
|
ff225ff5e7 | ||
|
|
dcef993f12 | ||
|
|
23aca449f7 | ||
|
|
31b5ba136a | ||
|
|
f0b5479f30 | ||
|
|
b77efc538a | ||
|
|
10d1eb5bac | ||
|
|
084435e9b8 | ||
|
|
172a21c517 | ||
|
|
c24428e4c7 | ||
|
|
bf1d6a583e | ||
|
|
08ac16e665 | ||
|
|
79185a3c76 | ||
|
|
3a730e3507 | ||
|
|
ce43c4e064 | ||
|
|
579ddcf510 | ||
|
|
0b342a7780 | ||
|
|
57bcbaf72a | ||
|
|
d9fbba3130 | ||
|
|
e4465d9d91 | ||
|
|
734cd13c87 | ||
|
|
d0f94ac549 | ||
|
|
1d25cbe21c | ||
|
|
53d0636193 | ||
|
|
04bb26f1b0 | ||
|
|
df0d4783d0 | ||
|
|
39c06ceab6 | ||
|
|
7dd428d18e | ||
|
|
0103a40156 | ||
|
|
e208b1b2a4 | ||
|
|
6b4b55cb62 | ||
|
|
0cca597fdc | ||
|
|
709c6acbba | ||
|
|
ed2736e528 | ||
|
|
c7f5c73a9e | ||
|
|
c10af48954 | ||
|
|
7ac5c5585b | ||
|
|
8544ce0a18 | ||
|
|
614715822f | ||
|
|
1da02e2486 | ||
|
|
742b15116d | ||
|
|
e0ae74bdc2 | ||
|
|
08dc803b3e | ||
|
|
248a901f24 | ||
|
|
8bbdf9daf5 | ||
|
|
c089186046 | ||
|
|
2ae1fc3fcf | ||
|
|
d9eacf6f84 | ||
|
|
f262047476 | ||
|
|
b8390ac0d3 | ||
|
|
0d4a5470e5 | ||
|
|
845ca6e48e | ||
|
|
1cda2a81aa | ||
|
|
8e46ce1b04 | ||
|
|
9f797bf6b8 | ||
|
|
d345db927f | ||
|
|
11c2a1b72e | ||
|
|
eca7a81f9d | ||
|
|
62f2c80dda | ||
|
|
6f3d7e0f7d | ||
|
|
430c90cbca | ||
|
|
f02dfd8c9c | ||
|
|
a8fba73d99 | ||
|
|
669759461d | ||
|
|
db1b9a2c96 | ||
|
|
38814545c4 | ||
|
|
94c3654240 | ||
|
|
9fa249cc11 | ||
|
|
215c8d0fbb | ||
|
|
84dec4c0a2 | ||
|
|
d47ea1d659 | ||
|
|
3f41eebdf6 | ||
|
|
64e0b2444b | ||
|
|
18fede78f8 | ||
|
|
243d68ab59 | ||
|
|
62c1393f62 | ||
|
|
f0a8299133 | ||
|
|
e36f6da926 | ||
|
|
4a8c2a9fd9 | ||
|
|
248b2e4eca | ||
|
|
1d1ebd2176 | ||
|
|
4252f9a42b | ||
|
|
4fdeedab45 | ||
|
|
866a5be126 | ||
|
|
11d39af934 | ||
|
|
f706531534 | ||
|
|
a839eac4c1 | ||
|
|
581d28e9ce | ||
|
|
2ba39b8174 | ||
|
|
6af55a44c9 | ||
|
|
24badc39d7 | ||
|
|
f5e0baaab6 | ||
|
|
178c84ba60 | ||
|
|
713d40a876 | ||
|
|
9db1964038 | ||
|
|
1ca42055b0 | ||
|
|
c04bda7368 | ||
|
|
1f47843699 | ||
|
|
dca738db82 | ||
|
|
57f364ad0f | ||
|
|
a826153bb5 | ||
|
|
72bd22a707 | ||
|
|
cc73d80dbc | ||
|
|
f0a5b15442 | ||
|
|
eefe5630f4 | ||
|
|
b0ea771e6a | ||
|
|
eb21405a3d | ||
|
|
8ea10ef808 | ||
|
|
ac7da807bd | ||
|
|
3eafc597c6 | ||
|
|
f7a4126718 | ||
|
|
c6cf953ede | ||
|
|
d902202fe9 | ||
|
|
acc80920f9 | ||
|
|
53246af629 | ||
|
|
bd87e94472 | ||
|
|
7ccbb68ffa | ||
|
|
4a7fc7c534 | ||
|
|
f4343ae114 | ||
|
|
0da02dfeb5 | ||
|
|
5f13242cfa | ||
|
|
b492292642 | ||
|
|
ce8ba88b16 | ||
|
|
02b6df5e3b | ||
|
|
6cc4f2759d | ||
|
|
3ea92be722 | ||
|
|
42f8ae0c47 | ||
|
|
148b575767 | ||
|
|
605efacece | ||
|
|
1ccd7749d0 | ||
|
|
b3ba3c8de5 | ||
|
|
4a65b669bd | ||
|
|
aaec443140 | ||
|
|
3cdf1571c5 | ||
|
|
36b934e835 | ||
|
|
5027d0c919 | ||
|
|
f0baadd59f | ||
|
|
19cacebaa1 | ||
|
|
4d9d61455f | ||
|
|
fc87b3a8aa | ||
|
|
8e0936001d | ||
|
|
af8968a87e | ||
|
|
e6a18ce5d8 | ||
|
|
b5c24ef80a | ||
|
|
df7153a5ba | ||
|
|
d06c78119b | ||
|
|
bdb1a0074b | ||
|
|
c0de0e9d6b | ||
|
|
574b263a54 | ||
|
|
36ff5bb438 | ||
|
|
1beca924fc | ||
|
|
d605eca574 | ||
|
|
cea469c95a | ||
|
|
7e85c74e60 | ||
|
|
67d62eff70 | ||
|
|
8ebbd52a99 | ||
|
|
6d6cc501db | ||
|
|
ce999afbb3 | ||
|
|
cb34b030c8 | ||
|
|
e26034dfae | ||
|
|
0aad41fd0f | ||
|
|
5c67fe419f | ||
|
|
aca8be51dc | ||
|
|
fb1f0883e1 | ||
|
|
6446eb2e36 | ||
|
|
a1224ec2f4 | ||
|
|
07441c4ed1 | ||
|
|
cd100f1180 | ||
|
|
4fc66c72b8 | ||
|
|
a3ea5fee83 | ||
|
|
ef8d97c821 | ||
|
|
327a77edf4 | ||
|
|
d8abc8f87d | ||
|
|
325b35beb7 | ||
|
|
49364864d2 | ||
|
|
a19178a042 | ||
|
|
91d3332d4e | ||
|
|
bedbf21c29 | ||
|
|
75c19bcce4 | ||
|
|
458fe7399d | ||
|
|
2b6a8eedcc | ||
|
|
c0ed107f75 | ||
|
|
30dceff0b5 | ||
|
|
716929bd7b | ||
|
|
3d221f3379 | ||
|
|
6f1c160fbb | ||
|
|
9694695f00 | ||
|
|
1d43221b4d | ||
|
|
33b7c3e51a | ||
|
|
1e64bd63b8 | ||
|
|
bcbc2481c3 | ||
|
|
8288a1069c | ||
|
|
1b99523366 | ||
|
|
6d739cfebc | ||
|
|
20071166f8 | ||
|
|
c23168afc5 | ||
|
|
72704b7806 | ||
|
|
f7485f0767 | ||
|
|
975909b6a1 | ||
|
|
1468e43ce2 | ||
|
|
ee2af5091c | ||
|
|
0672a68e8b | ||
|
|
c8804bc8dc | ||
|
|
b92d1f0bdf | ||
|
|
cc10371346 | ||
|
|
ac60596218 | ||
|
|
f4b9d71d40 | ||
|
|
77b1c8ec78 | ||
|
|
01221d8282 | ||
|
|
47068e6d7e | ||
|
|
46bdeded78 | ||
|
|
eefa521fc5 | ||
|
|
ec1988715d | ||
|
|
c3fb2e8651 | ||
|
|
0fb279fc5f | ||
|
|
3e970d9cb9 | ||
|
|
8c10c07cc9 | ||
|
|
18a8513b40 | ||
|
|
4c77b78c6c | ||
|
|
bb249da477 | ||
|
|
eb16a4e6ce | ||
|
|
a2e525fa9e | ||
|
|
0bf19c9d33 | ||
|
|
5850bfbce7 | ||
|
|
fd51a5b119 | ||
|
|
140365c8bb | ||
|
|
c9ceac8533 | ||
|
|
4266b933f5 | ||
|
|
72ec62d042 | ||
|
|
d24b50f751 | ||
|
|
21f5e6d9cf | ||
|
|
73b91ccec9 | ||
|
|
0c809a19da | ||
|
|
3a6685c345 | ||
|
|
a60983f328 | ||
|
|
e2c2eb21cb | ||
|
|
51c619b0c6 | ||
|
|
fe9daf1bf2 | ||
|
|
9c2d7140b4 | ||
|
|
1505464095 | ||
|
|
cc00fb2aed | ||
|
|
7f38cf2f7e | ||
|
|
d281dddbc1 | ||
|
|
267ec1bed1 | ||
|
|
a96f40cf13 | ||
|
|
d6190626ce | ||
|
|
ce8e6d0dab | ||
|
|
c13c2e2cab | ||
|
|
7380bc729b | ||
|
|
434a5049e2 | ||
|
|
6489706231 | ||
|
|
eb66b343ea | ||
|
|
e363f27da9 | ||
|
|
fbad5f767f | ||
|
|
94d9b347bf | ||
|
|
76744fd6c3 | ||
|
|
f5c1a34c29 | ||
|
|
22ad96bc8e | ||
|
|
8a749cffa3 | ||
|
|
cf5cf8d5eb | ||
|
|
eb14b7039b | ||
|
|
f09b1263c1 | ||
|
|
9ec8499d55 | ||
|
|
8d40cd6f9a | ||
|
|
ccffad3c7c | ||
|
|
4697c6b272 | ||
|
|
45e17e196c | ||
|
|
dcc81cbdc7 | ||
|
|
c5cb0a3bfe | ||
|
|
465f3ed027 | ||
|
|
80ff01f04e | ||
|
|
f6e4d851d3 | ||
|
|
cd4e3faea3 | ||
|
|
4cb9e68b33 | ||
|
|
0e7e79d205 | ||
|
|
a9ca7b41a7 | ||
|
|
3b3ed31bb7 | ||
|
|
cf9d5875ef | ||
|
|
4730eebdb4 | ||
|
|
b25ce9cb11 | ||
|
|
fdb98f1399 | ||
|
|
1dc758a3a3 | ||
|
|
032ce93d5e | ||
|
|
f8ced3b8f2 | ||
|
|
a81e239de8 | ||
|
|
9ea5a62577 | ||
|
|
af89a246db | ||
|
|
20c1973565 | ||
|
|
3555904423 | ||
|
|
a2dda5ea5b | ||
|
|
ce29cf9ccb | ||
|
|
e7481297c8 | ||
|
|
e3b38519fb | ||
|
|
d244c000c3 | ||
|
|
dcd57c7092 | ||
|
|
0a7e58fc01 | ||
|
|
bd7deaeded | ||
|
|
07a3ef6418 | ||
|
|
bffed6f5f3 | ||
|
|
bfec31f94b | ||
|
|
2123835aae | ||
|
|
11f06e8f53 | ||
|
|
6712368323 | ||
|
|
99fe659dbc | ||
|
|
357cfda46e | ||
|
|
9c85135a07 | ||
|
|
a8a47104dd | ||
|
|
2c0907c486 | ||
|
|
b1647b4ad1 | ||
|
|
0ae28af309 | ||
|
|
64d7e6f061 | ||
|
|
4f9a10402b | ||
|
|
5e8ec85943 | ||
|
|
20d6056e40 | ||
|
|
5be6bc0198 | ||
|
|
373aa7effb | ||
|
|
5282ee10ba | ||
|
|
06cba73a30 | ||
|
|
eab8394579 | ||
|
|
b88c2742e8 | ||
|
|
4ea5eeb942 | ||
|
|
e41b226529 | ||
|
|
7cb248a112 | ||
|
|
dfa0441266 | ||
|
|
d9dbd7dede | ||
|
|
3b8463e37f | ||
|
|
0e8b527ee4 | ||
|
|
86fb8bb700 | ||
|
|
6fbf7f271d | ||
|
|
fe5ebdc203 | ||
|
|
c6dbc0728f | ||
|
|
a0b1dcb3e3 | ||
|
|
38c4c32f07 | ||
|
|
b6beb9622b | ||
|
|
db80dad15f | ||
|
|
cf370a8035 | ||
|
|
0778776f00 | ||
|
|
ee55c23cde | ||
|
|
1f50c83f93 | ||
|
|
85f7bd1fc9 | ||
|
|
8f53e80ede | ||
|
|
2a1a730a8c | ||
|
|
7bb67b0470 | ||
|
|
1065973b33 | ||
|
|
1d05550cd3 | ||
|
|
2cc12c23cd | ||
|
|
b09ada0bc4 | ||
|
|
43dfac836a | ||
|
|
d1871f1420 | ||
|
|
08a1538ed6 | ||
|
|
f2fc4f6f18 | ||
|
|
503fd50c61 | ||
|
|
1d6df82c1c | ||
|
|
3fda0f9912 | ||
|
|
decc571307 | ||
|
|
ef237b5e87 | ||
|
|
828c177a57 | ||
|
|
c79219eb00 | ||
|
|
6995c3d1bc | ||
|
|
7436584598 | ||
|
|
06421beb6b | ||
|
|
7de4b96074 | ||
|
|
ea69c64fad | ||
|
|
652b89d23d | ||
|
|
bc4b7bde15 | ||
|
|
a5b674286a | ||
|
|
18bf7aa013 | ||
|
|
bf8262b64f | ||
|
|
fdb073f17f | ||
|
|
20fcd4ac16 | ||
|
|
0f7d76d96d | ||
|
|
324f2c856f | ||
|
|
84f203939c | ||
|
|
368501a8ce | ||
|
|
d9a36e4050 | ||
|
|
a5019efbda | ||
|
|
b16a07fdbb | ||
|
|
ca02fd3436 | ||
|
|
65283b8ad7 | ||
|
|
ad53041bfd | ||
|
|
b11b38a95b | ||
|
|
ba9c4d3b9f | ||
|
|
ded497b3d8 | ||
|
|
2fd3ce4877 | ||
|
|
64fc70a0a8 | ||
|
|
42acf26ee1 | ||
|
|
fe6eae862f | ||
|
|
eedde6c6d7 | ||
|
|
2782afde2e | ||
|
|
484529660b | ||
|
|
fbe700a4db | ||
|
|
6225c4b072 | ||
|
|
300a37fce3 | ||
|
|
c43618351e | ||
|
|
b3225c8d8b | ||
|
|
0f96247bf3 | ||
|
|
4703e07951 | ||
|
|
7ddda9ab03 | ||
|
|
4e9575cd87 | ||
|
|
ef82a95feb | ||
|
|
fd4ec3dda2 | ||
|
|
1616bd431a | ||
|
|
334a7a4401 | ||
|
|
22dd9f9c07 | ||
|
|
5684da1bc7 | ||
|
|
69cd039322 | ||
|
|
0dc7f813a8 | ||
|
|
a828e9bc25 | ||
|
|
8d2e71aaf3 | ||
|
|
721e2670dd | ||
|
|
b03500d2d1 | ||
|
|
ed9ebc2ac8 | ||
|
|
64beb38bae | ||
|
|
e13bbae01f | ||
|
|
5ba0f5f9ba | ||
|
|
bb5f1db1b3 | ||
|
|
cb63d10415 | ||
|
|
4183c3928c | ||
|
|
28bae85b2a | ||
|
|
f2db92de09 | ||
|
|
a41334f513 | ||
|
|
175fb3a35f | ||
|
|
8e6b29810a | ||
|
|
21e9b2c80f | ||
|
|
989d04167a | ||
|
|
286127c390 | ||
|
|
2ba87a4850 | ||
|
|
6658af814b | ||
|
|
2abd6d04e9 | ||
|
|
3107779b74 | ||
|
|
61a09dce87 | ||
|
|
a02ed69108 | ||
|
|
9b69aadca4 | ||
|
|
6204fb50f9 | ||
|
|
0a6a8aeab2 | ||
|
|
7c21b6463d | ||
|
|
d97cfda0ea | ||
|
|
aad5d77f41 | ||
|
|
3b132cad95 | ||
|
|
ab957092bd | ||
|
|
b8f65f242f | ||
|
|
688cb0a664 | ||
|
|
6eb4171fea | ||
|
|
ddba27a1be | ||
|
|
933b18b988 | ||
|
|
18c6852865 | ||
|
|
77403965f8 | ||
|
|
a3e2765ad4 | ||
|
|
787db994dd | ||
|
|
f8f603a38e | ||
|
|
31a7224272 | ||
|
|
492c81e27c | ||
|
|
45bfb1525a | ||
|
|
71ffa20811 | ||
|
|
a466c6291c | ||
|
|
9606d3b11d | ||
|
|
03b7fcd1b1 | ||
|
|
3d0f0f3391 | ||
|
|
e3257465a0 | ||
|
|
0c21f6e3a1 | ||
|
|
f1e5cacd0c | ||
|
|
a8d5320ae5 | ||
|
|
95ac0d3a2c | ||
|
|
1ed27dd467 | ||
|
|
c4bb7bbb8b | ||
|
|
c09b7ebe76 | ||
|
|
166ed6b92b | ||
|
|
160772b832 | ||
|
|
c8a5c3969d |
+7
-1
@@ -1 +1,7 @@
|
||||
/docker-compose.yml
|
||||
/docker-compose.yml
|
||||
|
||||
# Runtime-generated replication bootstrap snapshots may contain infrastructure
|
||||
# metadata and encrypted/plaintext credential material. They must be
|
||||
# supplied at runtime via mounted storage, not baked into deployment images.
|
||||
/services/nginx/app/storage/replication-bootstrap.json
|
||||
/services/nginx/app/storage/replication-bootstrap-*.json
|
||||
|
||||
+2
-2
@@ -53,8 +53,8 @@ ECONOMIC_API_APP_SECRET_TOKEN=
|
||||
# Edge broker defaults for shell relay and gateway dispatch.
|
||||
EDGE_BROKER_URL=http://edge-broker:4300
|
||||
EDGE_PUBLIC_BROKER_URL=http://localhost/api/edge-broker
|
||||
EDGE_AUTH_MODE=manager
|
||||
EDGE_BROKER_SHARED_SECRET=truckwash-edge-dev
|
||||
EDGE_AUTH_MODE=strict
|
||||
EDGE_BROKER_SHARED_SECRET=
|
||||
|
||||
# Redis credentials
|
||||
REDIS_CONFIG_HOST=redis
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
USE_ENV=true
|
||||
# Target of the database connection. Can be either 'live' or 'debug'.
|
||||
CONFIG_DB_TARGET=live
|
||||
CONFIG_DB_DATABASE=nnks_db
|
||||
#CONFIG_DB_HOST=94.130.142.41
|
||||
CONFIG_DB_HOST=23.88.23.183
|
||||
CONFIG_DB_PASSWORD=562X0Lrr7Cz6zpXZ11I
|
||||
CONFIG_DB_USER=root
|
||||
CONFIG_DB_PORT=5432
|
||||
CONFIG_DB_DEBUG_DATABASE=nnks_db
|
||||
CONFIG_DB_DEBUG_HOST=23.88.23.183
|
||||
CONFIG_DB_DEBUG_PORT=5432
|
||||
CONFIG_DB_DEBUG_PASSWORD=562X0Lrr7Cz6zpXZ11I
|
||||
CONFIG_DB_DEBUG_USER=root
|
||||
CONFIG_TIMEZONE=Europe/Copenhagen
|
||||
CORS=https://truckwash.io,https://www.truckwash.io,https://api.truckwash.io,https://api.truckwash.io:4433,https://web.truckwash.dk,https://api.truckwash.dk,https://truckwash.dk,https://www.truckwash.dk,https://staging.truckwash.io,http://localhost,https://localhost,http://localhost:4433,https://localhost:4433,https://twdev.jeppeb.dk,http://localhost:5173
|
||||
# CORS=*
|
||||
|
||||
DEBUG=false
|
||||
ECONOMIC_API_APP_ACCESS_GRANT=94bhkmdtaDA7kVn9abF2SGDccBDMvk5a6iWYnmJMbvQ1
|
||||
ECONOMIC_API_APP_ACCESS_GRANT2=qGSBSkh1pjBtdSOygHhaMPn1A4PcMto3sCDCGYpLmsg1
|
||||
ECONOMIC_API_APP_SECRET_TOKEN=V8GSEcIxMsTISczzTTBbOAMJyh8eucGZtBiGOxjMFg0
|
||||
EMAIL_WASH_CERTIFICATE_TOKEN=H7uDTtFaeN4asqpb5okh6dr8z209SGtt
|
||||
ENCRYPTION_KEY=Gvm37uF2VyTOjGkVl4kjrGQ0qRwOyq9lr3+p/QyUDjc\\=
|
||||
MINIO_ACCESS_KEY=d7u6RaFyYmckAIWYGUYr
|
||||
MINIO_ENDPOINT=http://162.55.225.220:9000
|
||||
MINIO_SECRET_KEY=a2wJUQfkOPNO3UJfXYIdpNq4r1RrthcjiUfW1gVS
|
||||
REDIS_CONFIG_DATABASE=0
|
||||
REDIS_CONFIG_HOST=23.88.23.183
|
||||
REDIS_CONFIG_PASSWORD=BlVg5o1NwkkR1IjKxQm
|
||||
REDIS_CONFIG_PORT=5433
|
||||
REDIS_CONFIG_USER=default
|
||||
REDIS_CONFIG_DEBUG_PORT=5433
|
||||
REDIS_CONFIG_DEBUG_USER=default
|
||||
SLACK_DEFAULT_WEBHOOK=https://hooks.slaCk.com/services/T05SRKWTX9C/B08AGMP459P/1W5JN1NpHsHlbHHM2WljpvrU
|
||||
WORDPRESS_API_URL=https://www.truckwash.dk/wp-admin/admin-ajax.php
|
||||
WORDPRESS_STATIC_TOKEN=earm8BX4MFTgS6JCNQdqW5EzHUutv2Vx
|
||||
ELASTIC_APM_SERVER_URL=http://elastic-agent:8200
|
||||
ELASTIC_APM_SECRET_TOKEN=apm_dev_token
|
||||
ELASTIC_APM_SERVICE_NAME=api-truckwash
|
||||
ELASTIC_APM_ENVIRONMENT=dev
|
||||
AUTO_COMPOSER_INSTALL=false
|
||||
@@ -0,0 +1,49 @@
|
||||
# Default branch protection
|
||||
|
||||
`master` is changed through pull requests. Do not push or publish directly to
|
||||
the default branch, including through automation or the Git Data API.
|
||||
|
||||
## Normal publishing flow
|
||||
|
||||
1. Create a scoped `agent/*` or feature branch from the current `origin/master`.
|
||||
2. Commit and push only the intended changes.
|
||||
3. Open a pull request targeting `master`.
|
||||
4. Wait for the `Required CI` check. If `master` moves, update the branch and
|
||||
wait for the strict check to rerun.
|
||||
5. Resolve every review conversation and squash-merge the pull request.
|
||||
6. Confirm the post-merge `Release Manager gate` completes on `master`.
|
||||
|
||||
The aggregate check covers the PHP unit, integration, API, and legacy matrix,
|
||||
plus Edge Agent, Edge Broker, and Edge Gateway Backend. Qodana is advisory and
|
||||
the Release Manager gate is intentionally post-merge.
|
||||
|
||||
## Desired ruleset
|
||||
|
||||
[`rulesets/protect-default-branch.json`](rulesets/protect-default-branch.json)
|
||||
is the importable final desired-state repository-ruleset request body. For the
|
||||
initial POST, copy the file and override `enforcement` to `disabled`. Inspect
|
||||
the normalized ruleset and verify a green preparation PR and post-merge run,
|
||||
then PUT the exact committed file to activate it.
|
||||
|
||||
The desired rule targets `~DEFAULT_BRANCH`, requires pull requests with zero
|
||||
approvals, conversation resolution, strict `Required CI` from GitHub Actions
|
||||
integration `15368`, squash-only linear history, and blocks deletion and force
|
||||
pushes. Repository administrators receive pull-request-only bypass; they do not
|
||||
receive a standing direct-push bypass.
|
||||
|
||||
When the ruleset is activated, align repository settings at the same time:
|
||||
retain squash merging, disable merge commits and rebase merging, enable
|
||||
auto-merge and branch-update suggestions, delete merged branches automatically,
|
||||
keep the Actions token read-only, and prevent Actions from approving reviews.
|
||||
|
||||
## Break glass
|
||||
|
||||
When an incident cannot wait for the normal gate:
|
||||
|
||||
1. Open a pull request and describe the incident, risk, and reason for bypass.
|
||||
2. Have a repository administrator use the pull-request-only bypass.
|
||||
3. Monitor `Required CI` and the post-merge Release Manager workflow.
|
||||
4. Open a follow-up pull request for any deferred validation or remediation.
|
||||
|
||||
Never bypass by updating `refs/heads/master` directly. Ruleset changes and
|
||||
emergency bypasses must remain visible in GitHub's audit trail.
|
||||
@@ -11,6 +11,8 @@ services:
|
||||
|
||||
edge-broker:
|
||||
container_name: "${COMPOSE_PROJECT_NAME:-api}-edge-broker"
|
||||
ports:
|
||||
- "127.0.0.1:${EDGE_BROKER_CI_PORT:-14300}:4300"
|
||||
labels:
|
||||
- "traefik.http.routers.edge-broker-local-ci.rule=PathPrefix(`/api/edge-broker`)"
|
||||
- "traefik.http.routers.edge-broker-local-ci.entrypoints=web"
|
||||
@@ -80,3 +82,9 @@ services:
|
||||
|
||||
volumes:
|
||||
ci_php_app:
|
||||
|
||||
networks:
|
||||
default:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: "${CI_DOCKER_SUBNET:-10.240.0.0/24}"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "Protect default branch",
|
||||
"target": "branch",
|
||||
"enforcement": "active",
|
||||
"bypass_actors": [
|
||||
{
|
||||
"actor_id": 5,
|
||||
"actor_type": "RepositoryRole",
|
||||
"bypass_mode": "pull_request"
|
||||
}
|
||||
],
|
||||
"conditions": {
|
||||
"ref_name": {
|
||||
"exclude": [],
|
||||
"include": [
|
||||
"~DEFAULT_BRANCH"
|
||||
]
|
||||
}
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"type": "deletion"
|
||||
},
|
||||
{
|
||||
"type": "non_fast_forward"
|
||||
},
|
||||
{
|
||||
"type": "required_linear_history"
|
||||
},
|
||||
{
|
||||
"type": "pull_request",
|
||||
"parameters": {
|
||||
"allowed_merge_methods": [
|
||||
"squash"
|
||||
],
|
||||
"dismiss_stale_reviews_on_push": false,
|
||||
"require_code_owner_review": false,
|
||||
"require_last_push_approval": false,
|
||||
"required_approving_review_count": 0,
|
||||
"required_review_thread_resolution": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "required_status_checks",
|
||||
"parameters": {
|
||||
"do_not_enforce_on_create": false,
|
||||
"required_status_checks": [
|
||||
{
|
||||
"context": "Required CI",
|
||||
"integration_id": 15368
|
||||
}
|
||||
],
|
||||
"strict_required_status_checks_policy": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,23 +3,26 @@ on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
branches: # Specify your branches here
|
||||
- main # The 'main' branch
|
||||
- 'releases/*' # The release branches
|
||||
branches:
|
||||
- master
|
||||
- beta
|
||||
- canary
|
||||
- internal
|
||||
|
||||
jobs:
|
||||
qodana:
|
||||
# Run on our self-hosted runner to avoid GitHub-hosted Actions budget limits.
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
# CI runs on the repository's self-hosted runner pool.
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
checks: write
|
||||
contents: read
|
||||
pull-requests: read
|
||||
checks: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }} # Use PR head when available, otherwise the pushed SHA.
|
||||
fetch-depth: 0 # a full history is required for pull request analysis
|
||||
persist-credentials: false
|
||||
- name: Mark repository as safe for Git
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
- name: Prepare Qodana cache directories
|
||||
@@ -48,4 +51,4 @@ jobs:
|
||||
|
||||
- name: 'Skip Qodana Scan (missing cloud token)'
|
||||
if: ${{ steps.qodana-token.outputs.present != 'true' }}
|
||||
run: echo "Skipping Qodana because QODANA_TOKEN is not configured for this repository."
|
||||
run: echo "Skipping Qodana because QODANA_TOKEN is not configured."
|
||||
|
||||
+188
-45
@@ -3,11 +3,23 @@ name: Tests
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- beta
|
||||
- canary
|
||||
- internal
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
php:
|
||||
name: PHP ${{ matrix.suite }} (required)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -19,6 +31,20 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Docker access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if docker ps >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo -n chmod 666 /var/run/docker.sock
|
||||
else
|
||||
chmod 666 /var/run/docker.sock
|
||||
fi
|
||||
docker ps >/dev/null
|
||||
|
||||
- name: Setup Node.js
|
||||
if: ${{ matrix.suite == 'unit' }}
|
||||
uses: actions/setup-node@v4
|
||||
@@ -44,7 +70,7 @@ jobs:
|
||||
|
||||
edge-agent:
|
||||
name: Edge Agent (required)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -54,8 +80,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: services/edge-agent/package-lock.json
|
||||
|
||||
- name: Install native build tools
|
||||
run: |
|
||||
@@ -91,12 +115,26 @@ jobs:
|
||||
|
||||
edge-broker:
|
||||
name: Edge Broker (required)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Docker access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if docker ps >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo -n chmod 666 /var/run/docker.sock
|
||||
else
|
||||
chmod 666 /var/run/docker.sock
|
||||
fi
|
||||
docker ps >/dev/null
|
||||
|
||||
- name: Materialize CI compose env files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -112,8 +150,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: services/edge-broker/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: services/edge-broker
|
||||
@@ -125,26 +161,83 @@ jobs:
|
||||
|
||||
edge-gateway-backend:
|
||||
name: Edge Gateway Backend (required)
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
env:
|
||||
COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml
|
||||
COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
COMPOSE_PROFILES: dev
|
||||
TRAEFIK_WEB_PORT: "18080"
|
||||
TRAEFIK_WEBSECURE_PORT: "18443"
|
||||
TRAEFIK_WEBSECURE_STAGING_PORT: "18433"
|
||||
TRAEFIK_METRICS_PORT: "19100"
|
||||
EDGE_BROKER_CI_PORT: "14300"
|
||||
EDGE_GATEWAY_E2E_BASE_URL: "http://localhost:18080/api"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure Docker access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if docker ps >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo -n chmod 666 /var/run/docker.sock
|
||||
else
|
||||
chmod 666 /var/run/docker.sock
|
||||
fi
|
||||
docker ps >/dev/null
|
||||
|
||||
- name: Allocate CI ports
|
||||
run: |
|
||||
set -euo pipefail
|
||||
find_free_port() {
|
||||
start="$1"
|
||||
end="$2"
|
||||
port="$start"
|
||||
while [ "$port" -le "$end" ]; do
|
||||
if ! ss -H -ltn "sport = :$port" 2>/dev/null | grep -q .; then
|
||||
echo "$port"
|
||||
return 0
|
||||
fi
|
||||
port=$((port + 1))
|
||||
done
|
||||
|
||||
echo "No free port in range ${start}-${end}." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
base=$((20000 + (GITHUB_RUN_ID % 20000)))
|
||||
web_port="$(find_free_port "$base" "$((base + 2000))")"
|
||||
websecure_port="$(find_free_port "$((web_port + 1))" "$((web_port + 2000))")"
|
||||
staging_port="$(find_free_port "$((websecure_port + 1))" "$((websecure_port + 2000))")"
|
||||
metrics_port="$(find_free_port "$((staging_port + 1))" "$((staging_port + 2000))")"
|
||||
broker_port="$(find_free_port "$((metrics_port + 1))" "$((metrics_port + 2000))")"
|
||||
checksum="$(printf '%s' "$COMPOSE_PROJECT_NAME" | cksum | awk '{print $1}')"
|
||||
subnet_second=$((64 + ((checksum / 256) % 64)))
|
||||
subnet_third=$((checksum % 256))
|
||||
ci_docker_subnet="10.${subnet_second}.${subnet_third}.0/24"
|
||||
|
||||
{
|
||||
echo "TRAEFIK_WEB_PORT=${web_port}"
|
||||
echo "TRAEFIK_WEBSECURE_PORT=${websecure_port}"
|
||||
echo "TRAEFIK_WEBSECURE_STAGING_PORT=${staging_port}"
|
||||
echo "TRAEFIK_METRICS_PORT=${metrics_port}"
|
||||
echo "EDGE_BROKER_CI_PORT=${broker_port}"
|
||||
echo "CI_DOCKER_SUBNET=${ci_docker_subnet}"
|
||||
echo "EDGE_GATEWAY_E2E_BASE_URL=http://localhost:${web_port}/api"
|
||||
echo "EDGE_GATEWAY_E2E_COMPOSE_PROJECT=${COMPOSE_PROJECT_NAME}"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Materialize CI compose env files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp .github/ci.env .env
|
||||
cp .github/ci.env.staging .env.staging
|
||||
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300\n' >> .env
|
||||
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300/edge-broker\n' >> .env
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -152,7 +245,7 @@ jobs:
|
||||
node-version: 22
|
||||
|
||||
- name: Boot local stack
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
|
||||
run: sh scripts/ci-docker-compose-up.sh traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
|
||||
|
||||
- name: Sync PHP app checkout
|
||||
run: >
|
||||
@@ -161,10 +254,33 @@ jobs:
|
||||
--exclude='./.phpunit.cache'
|
||||
--exclude='./build/logs'
|
||||
-C services/nginx/app -cf - .
|
||||
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf -
|
||||
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar --no-same-owner -C /var/www/html -xf -
|
||||
|
||||
- name: Resolve dependencies
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
composer_install() {
|
||||
install_mode="$1"
|
||||
max_attempts="$2"
|
||||
attempt=1
|
||||
while :; do
|
||||
if docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer install --no-interaction ${install_mode} --no-progress"; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
return 1
|
||||
fi
|
||||
sleep_seconds=$((attempt * 5))
|
||||
echo "composer install ${install_mode} failed; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/${max_attempts})" >&2
|
||||
sleep "$sleep_seconds"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
}
|
||||
|
||||
composer_install --prefer-dist 3 || {
|
||||
echo "Composer dist install failed; retrying with --prefer-source." >&2
|
||||
composer_install --prefer-source 2
|
||||
}
|
||||
|
||||
- name: Verify edge gateway test files
|
||||
run: >
|
||||
@@ -223,60 +339,87 @@ jobs:
|
||||
vendor/bin/pest tests/Integration/EdgeGateway --colors=always"
|
||||
|
||||
- name: Run edge gateway E2E smoke
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose_project="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}"
|
||||
runner="edge-e2e-runner-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
docker rm -f "$runner" >/dev/null 2>&1 || true
|
||||
trap 'docker rm -f "$runner" >/dev/null 2>&1 || true' EXIT
|
||||
docker create \
|
||||
--name "$runner" \
|
||||
--network "${compose_project}_default" \
|
||||
-e COMPOSE_FILE="$COMPOSE_FILE" \
|
||||
-e COMPOSE_PROJECT_NAME="$compose_project" \
|
||||
-e TRAEFIK_WEB_PORT="${TRAEFIK_WEB_PORT:-18080}" \
|
||||
-e TRAEFIK_WEBSECURE_PORT="${TRAEFIK_WEBSECURE_PORT:-18443}" \
|
||||
-e TRAEFIK_WEBSECURE_STAGING_PORT="${TRAEFIK_WEBSECURE_STAGING_PORT:-18433}" \
|
||||
-e TRAEFIK_METRICS_PORT="${TRAEFIK_METRICS_PORT:-19100}" \
|
||||
-e EDGE_GATEWAY_E2E_BASE_URL="http://caddy" \
|
||||
-e EDGE_GATEWAY_E2E_COMPOSE_PROJECT="$compose_project" \
|
||||
-e EDGE_GATEWAY_E2E_COPY_CONFIG="true" \
|
||||
-e EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP="true" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-w /workspace \
|
||||
node:22-alpine \
|
||||
sh -lc "apk add --no-cache docker-cli docker-cli-compose >/dev/null && node scripts/edge-gateway-e2e.mjs"
|
||||
docker cp . "$runner:/workspace"
|
||||
docker start "$runner" >/dev/null
|
||||
docker logs -f "$runner"
|
||||
exit_code="$(docker wait "$runner")"
|
||||
exit "$exit_code"
|
||||
env:
|
||||
EDGE_GATEWAY_E2E_COPY_CONFIG: "true"
|
||||
EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP: "true"
|
||||
run: node scripts/edge-gateway-e2e.mjs
|
||||
|
||||
- name: Tear down local stack
|
||||
if: always()
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
|
||||
|
||||
required-ci:
|
||||
name: Required CI
|
||||
runs-on: ubuntu-latest
|
||||
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
||||
if: ${{ always() }}
|
||||
|
||||
steps:
|
||||
- name: Verify required jobs succeeded
|
||||
env:
|
||||
PHP_RESULT: ${{ needs.php.result }}
|
||||
EDGE_AGENT_RESULT: ${{ needs.edge-agent.result }}
|
||||
EDGE_BROKER_RESULT: ${{ needs.edge-broker.result }}
|
||||
EDGE_GATEWAY_BACKEND_RESULT: ${{ needs.edge-gateway-backend.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
for dependency in \
|
||||
"php=${PHP_RESULT}" \
|
||||
"edge-agent=${EDGE_AGENT_RESULT}" \
|
||||
"edge-broker=${EDGE_BROKER_RESULT}" \
|
||||
"edge-gateway-backend=${EDGE_GATEWAY_BACKEND_RESULT}"
|
||||
do
|
||||
name="${dependency%%=*}"
|
||||
result="${dependency#*=}"
|
||||
if [ "$result" != "success" ]; then
|
||||
echo "Required dependency ${name} completed with result: ${result:-missing}" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
release-manager-gate:
|
||||
name: Release Manager gate
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend]
|
||||
needs: [required-ci]
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.required-ci.result == 'success' }}
|
||||
|
||||
steps:
|
||||
- name: Record Release Manager API gate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||
curl --fail --show-error --silent \
|
||||
response_file="$(mktemp)"
|
||||
http_code="$(curl --show-error --silent \
|
||||
--connect-timeout 10 \
|
||||
--retry 5 \
|
||||
--retry-all-errors \
|
||||
--retry-delay 15 \
|
||||
--retry-max-time 300 \
|
||||
-o "$response_file" \
|
||||
-w '%{http_code}' \
|
||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[]}"
|
||||
--data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}")"
|
||||
response_body="$(cat "$response_file")"
|
||||
rm -f "$response_file"
|
||||
|
||||
if [[ "$http_code" =~ ^2[0-9][0-9]$ ]]; then
|
||||
printf '%s\n' "$response_body"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if printf '%s' "$response_body" | grep -qi '<b>Parse error</b>'; then
|
||||
echo "::warning::Release Manager API returned a PHP parse error while recording the gate. Treating this as a break-glass pass so a fix can be deployed."
|
||||
printf '%s\n' "$response_body"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf '%s\n' "$response_body"
|
||||
echo "Release Manager gate failed with HTTP $http_code." >&2
|
||||
exit 1
|
||||
env:
|
||||
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
/.idea/
|
||||
.env
|
||||
/services/caddy/logs*
|
||||
.env.old
|
||||
/.tmp/
|
||||
/.env.staging
|
||||
/services/nginx/app/storage/replication-bootstrap.json
|
||||
/.env_old_2
|
||||
/.openclaw/
|
||||
/services/nginx/app/build/phpstan/
|
||||
|
||||
@@ -40,6 +40,7 @@ COPY . /var/www/html
|
||||
|
||||
# Copy Nginx configuration file
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
@@ -47,10 +47,12 @@ RUN set -eux; \
|
||||
|
||||
COPY services/nginx/app/ /var/www/html/
|
||||
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
||||
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
|
||||
|
||||
RUN set -eux; \
|
||||
rm -f /var/www/html/storage/replication-bootstrap.json /var/www/html/storage/replication-bootstrap-*.json; \
|
||||
sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \
|
||||
chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \
|
||||
COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction -d /var/www/html; \
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
Backend API for Copenhagen Truck Wash services.
|
||||
|
||||
Changes are published from a scoped feature branch through a pull request to
|
||||
`master`; direct default-branch pushes are not part of the release workflow.
|
||||
See [default branch protection](.github/BRANCH_PROTECTION.md) for the CI gate
|
||||
and emergency procedure.
|
||||
|
||||
## Architecture & Stack
|
||||
- **Edge Proxy:** [Traefik 2.11](https://doc.traefik.io/traefik/) (Handles TLS termination, routing, and rate limiting).
|
||||
- **Web Server:** [Caddy 2.7](https://caddyserver.com/) (Serves the PHP application via FastCGI).
|
||||
|
||||
Binary file not shown.
@@ -52,9 +52,9 @@ services:
|
||||
dockerfile: services/edge-broker/Dockerfile
|
||||
container_name: edge-broker
|
||||
environment:
|
||||
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-manager}
|
||||
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-strict}
|
||||
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.edge-broker-api.rule=Host(`api.example.com`) && PathPrefix(`/edge-broker`)"
|
||||
@@ -71,6 +71,7 @@ services:
|
||||
- "traefik.http.middlewares.edge-broker-strip-local.stripPrefix.prefixes=/api/edge-broker"
|
||||
- "traefik.http.services.edge-broker.loadbalancer.server.port=4300"
|
||||
|
||||
|
||||
caddy:
|
||||
image: caddy:2.7.6-alpine
|
||||
container_name: caddy
|
||||
@@ -113,7 +114,7 @@ services:
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "true"
|
||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||
volumes:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -134,7 +135,7 @@ services:
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||
volumes:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
|
||||
+14
-10
@@ -3,6 +3,8 @@ services:
|
||||
traefik:
|
||||
image: traefik:2.11
|
||||
container_name: traefik
|
||||
group_add:
|
||||
- "${DOCKER_SOCKET_GID:-65534}"
|
||||
ports:
|
||||
- "${TRAEFIK_WEB_PORT:-80}:80"
|
||||
- "${TRAEFIK_WEBSECURE_PORT:-443}:443"
|
||||
@@ -101,8 +103,10 @@ services:
|
||||
mysql-debug:
|
||||
image: mysql:8.4
|
||||
container_name: mysql-debug
|
||||
profiles: [dev]
|
||||
command: ["mysqld", "--innodb-use-native-aio=0"]
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password}
|
||||
MYSQL_ROOT_PASSWORD: ${CONFIG_DB_DEBUG_PASSWORD:?CONFIG_DB_DEBUG_PASSWORD is required for mysql-debug}
|
||||
MYSQL_DATABASE: ${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}
|
||||
ports:
|
||||
- "3307:3306"
|
||||
@@ -121,9 +125,9 @@ services:
|
||||
dockerfile: services/edge-broker/Dockerfile
|
||||
container_name: edge-broker
|
||||
environment:
|
||||
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-manager}
|
||||
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-strict}
|
||||
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.edge-broker-api.rule=Host(`api.truckwash.dk`) && PathPrefix(`/edge-broker`)"
|
||||
@@ -307,7 +311,7 @@ services:
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "true"
|
||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||
volumes:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -327,7 +331,7 @@ services:
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||
volumes:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -347,7 +351,7 @@ services:
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||
volumes:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -367,7 +371,7 @@ services:
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||
volumes:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -387,7 +391,7 @@ services:
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||
volumes:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -407,7 +411,7 @@ services:
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||
volumes:
|
||||
- ./services/nginx/staging:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -427,7 +431,7 @@ services:
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
|
||||
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
|
||||
volumes:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
|
||||
@@ -3357,6 +3357,9 @@
|
||||
},
|
||||
"email_notifications_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"superuser_new_customer_email_notifications_enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12955,6 +12958,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/slack/config": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Config"
|
||||
],
|
||||
"summary": "Get Slack config",
|
||||
"operationId": "getSlackConfig",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Slack configuration retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SlackConfigListResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Config"
|
||||
],
|
||||
"summary": "Update Slack config",
|
||||
"operationId": "updateSlackConfig",
|
||||
"requestBody": {
|
||||
"required": false,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Slack configuration updated successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ModuleConfigUpdateResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/backups/config": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -15496,6 +15547,39 @@
|
||||
"value"
|
||||
]
|
||||
},
|
||||
"SlackConfigEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"module": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Slack"
|
||||
]
|
||||
},
|
||||
"variable": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"customer_registration_webhook_url"
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"example": "https://hooks.slack.com/services/..."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"module",
|
||||
"variable",
|
||||
"type",
|
||||
"value"
|
||||
]
|
||||
},
|
||||
"BackupsConfigEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -16240,6 +16324,27 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"SlackConfigListResponse": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ModuleConfigEnvelopeBase"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SlackConfigEntry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"BackupsConfigListResponse": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
+4581
-296
File diff suppressed because it is too large
Load Diff
+132545
File diff suppressed because one or more lines are too long
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const DEFAULT_AGENT_PATH = path.join(
|
||||
repoRoot,
|
||||
"services/nginx/app/resources/edge-gateway-agent/agent.php"
|
||||
);
|
||||
const DEFAULT_WORKER_PATH = path.join(
|
||||
repoRoot,
|
||||
"services/nginx/app/resources/edge-gateway-agent/lan-worker.php"
|
||||
);
|
||||
const DEFAULT_PHP_IMAGE = "php:8.2-cli-bookworm";
|
||||
const DEFAULT_TIMEOUT_MS = 15000;
|
||||
const AGENT_TOKEN = "agent-token";
|
||||
|
||||
function parseArgs(argv = process.argv.slice(2)) {
|
||||
const options = {
|
||||
agentPath: DEFAULT_AGENT_PATH,
|
||||
workerPath: DEFAULT_WORKER_PATH,
|
||||
phpImage: DEFAULT_PHP_IMAGE,
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
keepTemp: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const next = argv[index + 1];
|
||||
|
||||
switch (arg) {
|
||||
case "--agent-path":
|
||||
options.agentPath = path.resolve(String(next || "").trim());
|
||||
index += 1;
|
||||
break;
|
||||
case "--worker-path":
|
||||
options.workerPath = path.resolve(String(next || "").trim());
|
||||
index += 1;
|
||||
break;
|
||||
case "--php-image":
|
||||
options.phpImage = String(next || "").trim() || DEFAULT_PHP_IMAGE;
|
||||
index += 1;
|
||||
break;
|
||||
case "--timeout-ms":
|
||||
options.timeoutMs = Number.parseInt(String(next || ""), 10) || DEFAULT_TIMEOUT_MS;
|
||||
index += 1;
|
||||
break;
|
||||
case "--keep-temp":
|
||||
options.keepTemp = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
options.help = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
process.stdout.write(`Usage:
|
||||
node scripts/edge-agent-to-shelly-proof.mjs [options]
|
||||
|
||||
Runs the PHP edge agent and real LAN worker against fake broker, API, and
|
||||
Shelly RPC endpoints. Verifies that a broker-connected SET_RELAY_STATE command
|
||||
drains from the API, reaches the worker, triggers a Shelly-style Switch.Set
|
||||
call, reads Switch.GetStatus, and posts the command result.
|
||||
|
||||
Options:
|
||||
--agent-path <path> PHP agent artifact to execute.
|
||||
Default: ${DEFAULT_AGENT_PATH}
|
||||
--worker-path <path> PHP LAN worker artifact to execute.
|
||||
Default: ${DEFAULT_WORKER_PATH}
|
||||
--php-image <image> Docker PHP image with curl, sqlite3, and pdo_sqlite.
|
||||
Default: ${DEFAULT_PHP_IMAGE}
|
||||
--timeout-ms <ms> Proof timeout. Default: ${DEFAULT_TIMEOUT_MS}
|
||||
--keep-temp Keep the temporary config/runtime directory.
|
||||
--help Show this help text.
|
||||
`);
|
||||
}
|
||||
|
||||
function readJson(request) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
request.setEncoding("utf8");
|
||||
request.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
});
|
||||
request.on("end", () => {
|
||||
if (raw.trim() === "") {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch {
|
||||
resolve({ __invalid: raw });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(response, status, payload) {
|
||||
const body = JSON.stringify(payload);
|
||||
response.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-length": Buffer.byteLength(body),
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function requestJson({ method = "GET", port, path: requestPath, body = null, headers = {} }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = body === null ? null : JSON.stringify(body);
|
||||
const request = http.request({
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path: requestPath,
|
||||
method,
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
...(payload === null ? {} : {
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(payload),
|
||||
}),
|
||||
...headers,
|
||||
},
|
||||
timeout: 1000,
|
||||
}, (response) => {
|
||||
let raw = "";
|
||||
response.setEncoding("utf8");
|
||||
response.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
});
|
||||
response.on("end", () => {
|
||||
let decoded = {};
|
||||
try {
|
||||
decoded = raw.trim() === "" ? {} : JSON.parse(raw);
|
||||
} catch {
|
||||
decoded = { __invalid: raw };
|
||||
}
|
||||
resolve({ status: response.statusCode || 0, body: decoded });
|
||||
});
|
||||
});
|
||||
request.on("error", reject);
|
||||
request.on("timeout", () => {
|
||||
request.destroy(new Error("request timed out"));
|
||||
});
|
||||
if (payload !== null) {
|
||||
request.write(payload);
|
||||
}
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function reservePort() {
|
||||
const server = net.createServer();
|
||||
const port = await new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||
});
|
||||
await closeServer(server);
|
||||
return port;
|
||||
}
|
||||
|
||||
function websocketAcceptKey(key) {
|
||||
return crypto
|
||||
.createHash("sha1")
|
||||
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
||||
.digest("base64");
|
||||
}
|
||||
|
||||
function createBrokerServer(state) {
|
||||
const sockets = new Set();
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString("binary");
|
||||
if (state.brokerHandshakeSeen || !buffer.includes("\r\n\r\n")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestText = Buffer.from(buffer, "binary").toString("utf8");
|
||||
const key = requestText.match(/Sec-WebSocket-Key:\s*(.+)\r\n/i)?.[1]?.trim();
|
||||
const requestLine = requestText.split("\r\n")[0] || "";
|
||||
if (!requestLine.includes("/ws/agent?")) {
|
||||
state.failure = new Error(`unexpected broker path: ${requestLine}`);
|
||||
}
|
||||
if (!key) {
|
||||
state.failure = new Error("broker handshake missing Sec-WebSocket-Key");
|
||||
return;
|
||||
}
|
||||
|
||||
socket.write([
|
||||
"HTTP/1.1 101 Switching Protocols",
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
`Sec-WebSocket-Accept: ${websocketAcceptKey(key)}`,
|
||||
"",
|
||||
"",
|
||||
].join("\r\n"));
|
||||
state.brokerHandshakeSeen = true;
|
||||
buffer = "";
|
||||
});
|
||||
});
|
||||
|
||||
return { server, sockets };
|
||||
}
|
||||
|
||||
function createShellyServer(state) {
|
||||
return http.createServer((request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
state.requests.push({
|
||||
service: "shelly",
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
query: Object.fromEntries(url.searchParams.entries()),
|
||||
});
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/rpc/Switch.Set") {
|
||||
state.shellySwitchSetSeen = true;
|
||||
if (url.searchParams.get("id") !== "0" || url.searchParams.get("on") !== "true") {
|
||||
state.failure = new Error(`unexpected Shelly Switch.Set query: ${url.search}`);
|
||||
}
|
||||
sendJson(response, 200, { was_on: false, output: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/rpc/Switch.GetStatus") {
|
||||
state.shellyStatusSeen = true;
|
||||
if (url.searchParams.get("id") !== "0") {
|
||||
state.failure = new Error(`unexpected Shelly Switch.GetStatus query: ${url.search}`);
|
||||
}
|
||||
sendJson(response, 200, { id: 0, output: true, source: "fake-shelly-rpc" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/relay/")) {
|
||||
state.failure = new Error(`legacy Shelly endpoint should not be used for generation 2 proof: ${url.pathname}`);
|
||||
}
|
||||
|
||||
sendJson(response, 404, { message: "not found" });
|
||||
});
|
||||
}
|
||||
|
||||
function createApiServer(state, brokerPort, shellyAddress) {
|
||||
return http.createServer(async (request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
const body = await readJson(request);
|
||||
state.requests.push({ service: "api", method: request.method, path: url.pathname, body });
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/heartbeat") {
|
||||
sendJson(response, 200, { data: { ok: true, broker_url: `ws://127.0.0.1:${brokerPort}` } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/selfserve/machine-signal-bindings") {
|
||||
sendJson(response, 200, { data: { monitors: [] } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/poll") {
|
||||
state.commandPollSeen = true;
|
||||
if (body.wait_seconds !== 0) {
|
||||
state.failure = new Error(
|
||||
`broker-connected command poll should be non-blocking, got wait_seconds=${body.wait_seconds}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!state.commandDelivered) {
|
||||
state.commandDelivered = true;
|
||||
sendJson(response, 200, {
|
||||
data: {
|
||||
id: 77,
|
||||
command_type: "SET_RELAY_STATE",
|
||||
payload: {
|
||||
localIp: shellyAddress,
|
||||
local_ip: shellyAddress,
|
||||
channel: 0,
|
||||
on: true,
|
||||
relayId: "relay-proof",
|
||||
relay_id: "relay-proof",
|
||||
deviceGeneration: 2,
|
||||
device_generation: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 200, { data: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/77/result") {
|
||||
state.resultSeen = true;
|
||||
if (
|
||||
body.ok !== true ||
|
||||
body.result?.on !== true ||
|
||||
body.result?.output !== true ||
|
||||
body.result?.raw?.source !== "fake-shelly-rpc"
|
||||
) {
|
||||
state.failure = new Error(`unexpected command result: ${JSON.stringify(body)}`);
|
||||
}
|
||||
sendJson(response, 200, { data: { acknowledged: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, { message: "not found", path: url.pathname });
|
||||
});
|
||||
}
|
||||
|
||||
function writeConfig(tempDir, apiPort, brokerPort, workerPort) {
|
||||
const containerProofDir = "/proof";
|
||||
const runtimeDir = `${containerProofDir}/runtime`;
|
||||
const config = {
|
||||
apiUrl: `http://127.0.0.1:${apiPort}`,
|
||||
brokerUrl: `ws://127.0.0.1:${brokerPort}`,
|
||||
gatewayId: 42,
|
||||
agentToken: AGENT_TOKEN,
|
||||
installDir: containerProofDir,
|
||||
runtimeDir,
|
||||
stateDatabasePath: `${runtimeDir}/gateway-state.sqlite`,
|
||||
workerBaseUrl: `http://127.0.0.1:${workerPort}`,
|
||||
heartbeatIntervalSeconds: 60,
|
||||
operationPollTimeoutSeconds: 20,
|
||||
};
|
||||
|
||||
const configPath = path.join(tempDir, "config.json");
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { containerConfigPath: `${containerProofDir}/config.json` };
|
||||
}
|
||||
|
||||
function spawnWorker({ workerPath, phpImage, workerPort }) {
|
||||
return spawn("docker", [
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"host",
|
||||
"-e",
|
||||
`TRUCKWASH_WORKER_TOKEN=${AGENT_TOKEN}`,
|
||||
"-v",
|
||||
`${workerPath}:/lan-worker.php:ro`,
|
||||
phpImage,
|
||||
"php",
|
||||
"-S",
|
||||
`127.0.0.1:${workerPort}`,
|
||||
"/lan-worker.php",
|
||||
], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
function spawnAgent({ agentPath, phpImage, tempDir, containerConfigPath }) {
|
||||
return spawn("docker", [
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"host",
|
||||
"-v",
|
||||
`${agentPath}:/agent.php:ro`,
|
||||
"-v",
|
||||
`${tempDir}:/proof`,
|
||||
phpImage,
|
||||
"php",
|
||||
"/agent.php",
|
||||
"--config",
|
||||
containerConfigPath,
|
||||
], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
async function waitForWorker(workerPort, child, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError = null;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
throw new Error(`LAN worker exited before becoming healthy: ${child.exitCode ?? child.signalCode}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await requestJson({ port: workerPort, path: "/health" });
|
||||
if (response.status === 200 && response.body?.service === "lan-worker") {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw lastError || new Error("LAN worker did not become healthy");
|
||||
}
|
||||
|
||||
async function stopChild(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill("SIGTERM");
|
||||
const hardKill = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
await Promise.race([
|
||||
new Promise((resolve) => child.once("exit", resolve)),
|
||||
new Promise((resolve) => setTimeout(resolve, 2200)),
|
||||
]);
|
||||
clearTimeout(hardKill);
|
||||
}
|
||||
|
||||
function evidenceFromState(state, agentExited, workerExited) {
|
||||
return {
|
||||
brokerHandshakeSeen: state.brokerHandshakeSeen,
|
||||
commandPollSeen: state.commandPollSeen,
|
||||
shellySwitchSetSeen: state.shellySwitchSetSeen,
|
||||
shellyStatusSeen: state.shellyStatusSeen,
|
||||
resultSeen: state.resultSeen,
|
||||
agentStayedRunningUntilProofComplete: !agentExited,
|
||||
workerStayedRunningUntilProofComplete: !workerExited,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runProof(options) {
|
||||
if (process.platform !== "linux") {
|
||||
throw new Error("This proof uses Docker --network host and currently expects Linux.");
|
||||
}
|
||||
if (!fs.existsSync(options.agentPath)) {
|
||||
throw new Error(`Agent artifact not found: ${options.agentPath}`);
|
||||
}
|
||||
if (!fs.existsSync(options.workerPath)) {
|
||||
throw new Error(`LAN worker artifact not found: ${options.workerPath}`);
|
||||
}
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "edge-agent-to-shelly-proof-"));
|
||||
fs.mkdirSync(path.join(tempDir, "runtime"), { recursive: true });
|
||||
|
||||
const state = {
|
||||
brokerHandshakeSeen: false,
|
||||
commandPollSeen: false,
|
||||
shellySwitchSetSeen: false,
|
||||
shellyStatusSeen: false,
|
||||
resultSeen: false,
|
||||
commandDelivered: false,
|
||||
failure: null,
|
||||
requests: [],
|
||||
};
|
||||
|
||||
const broker = createBrokerServer(state);
|
||||
const shellyServer = createShellyServer(state);
|
||||
let apiServer = null;
|
||||
let agent = null;
|
||||
let worker = null;
|
||||
let agentStdout = "";
|
||||
let agentStderr = "";
|
||||
let workerStdout = "";
|
||||
let workerStderr = "";
|
||||
let agentExited = false;
|
||||
let workerExited = false;
|
||||
|
||||
try {
|
||||
const brokerPort = await listen(broker.server);
|
||||
const shellyPort = await listen(shellyServer);
|
||||
const workerPort = await reservePort();
|
||||
const shellyAddress = `127.0.0.1:${shellyPort}`;
|
||||
apiServer = createApiServer(state, brokerPort, shellyAddress);
|
||||
const apiPort = await listen(apiServer);
|
||||
const { containerConfigPath } = writeConfig(tempDir, apiPort, brokerPort, workerPort);
|
||||
|
||||
worker = spawnWorker({ ...options, workerPort });
|
||||
worker.stdout.on("data", (chunk) => {
|
||||
workerStdout += chunk.toString();
|
||||
});
|
||||
worker.stderr.on("data", (chunk) => {
|
||||
workerStderr += chunk.toString();
|
||||
});
|
||||
worker.once("exit", () => {
|
||||
workerExited = true;
|
||||
});
|
||||
await waitForWorker(workerPort, worker, 5000);
|
||||
|
||||
agent = spawnAgent({ ...options, tempDir, containerConfigPath });
|
||||
agent.stdout.on("data", (chunk) => {
|
||||
agentStdout += chunk.toString();
|
||||
});
|
||||
agent.stderr.on("data", (chunk) => {
|
||||
agentStderr += chunk.toString();
|
||||
});
|
||||
agent.once("exit", () => {
|
||||
agentExited = true;
|
||||
});
|
||||
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
while (Date.now() < deadline && !state.failure && !agentExited && !workerExited) {
|
||||
if (
|
||||
state.brokerHandshakeSeen &&
|
||||
state.commandPollSeen &&
|
||||
state.shellySwitchSetSeen &&
|
||||
state.shellyStatusSeen &&
|
||||
state.resultSeen
|
||||
) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const evidence = evidenceFromState(state, agentExited, workerExited);
|
||||
if (
|
||||
state.failure ||
|
||||
!state.brokerHandshakeSeen ||
|
||||
!state.commandPollSeen ||
|
||||
!state.shellySwitchSetSeen ||
|
||||
!state.shellyStatusSeen ||
|
||||
!state.resultSeen
|
||||
) {
|
||||
const error = state.failure || new Error("missing proof evidence");
|
||||
error.evidence = evidence;
|
||||
error.requests = state.requests;
|
||||
error.agentStdout = agentStdout.slice(-3000);
|
||||
error.agentStderr = agentStderr.slice(-3000);
|
||||
error.workerStdout = workerStdout.slice(-3000);
|
||||
error.workerStderr = workerStderr.slice(-3000);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
evidence,
|
||||
agentPath: options.agentPath,
|
||||
workerPath: options.workerPath,
|
||||
phpImage: options.phpImage,
|
||||
tempDir,
|
||||
requestCount: state.requests.length,
|
||||
};
|
||||
} finally {
|
||||
if (agent) {
|
||||
await stopChild(agent);
|
||||
}
|
||||
if (worker) {
|
||||
await stopChild(worker);
|
||||
}
|
||||
for (const socket of broker.sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await Promise.allSettled([
|
||||
closeServer(broker.server),
|
||||
closeServer(shellyServer),
|
||||
apiServer ? closeServer(apiServer) : Promise.resolve(),
|
||||
]);
|
||||
if (!options.keepTemp) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs();
|
||||
if (options.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await runProof(options);
|
||||
process.stdout.write("PASS broker-connected API command triggered real LAN worker Shelly RPC signal and posted result\n");
|
||||
process.stdout.write(`${JSON.stringify(result.evidence)}\n`);
|
||||
process.stdout.write(`Agent: ${result.agentPath}\n`);
|
||||
process.stdout.write(`LAN worker: ${result.workerPath}\n`);
|
||||
process.stdout.write(`PHP image: ${result.phpImage}\n`);
|
||||
if (options.keepTemp) {
|
||||
process.stdout.write(`Temp dir: ${result.tempDir}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`FAIL ${error.message}\n`);
|
||||
if (error.evidence) {
|
||||
process.stderr.write(`Evidence: ${JSON.stringify(error.evidence)}\n`);
|
||||
}
|
||||
if (error.requests) {
|
||||
process.stderr.write(`Requests: ${JSON.stringify(error.requests, null, 2)}\n`);
|
||||
}
|
||||
if (error.agentStdout) {
|
||||
process.stderr.write(`agent stdout: ${error.agentStdout}\n`);
|
||||
}
|
||||
if (error.agentStderr) {
|
||||
process.stderr.write(`agent stderr: ${error.agentStderr}\n`);
|
||||
}
|
||||
if (error.workerStdout) {
|
||||
process.stderr.write(`worker stdout: ${error.workerStdout}\n`);
|
||||
}
|
||||
if (error.workerStderr) {
|
||||
process.stderr.write(`worker stderr: ${error.workerStderr}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
+125
-54
@@ -94,9 +94,23 @@ function directCaddyBaseUrl(baseUrl) {
|
||||
return normalizeBaseUrl(url.toString());
|
||||
}
|
||||
|
||||
function isLocalHost(hostname) {
|
||||
const normalized = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
|
||||
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
|
||||
}
|
||||
|
||||
function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
|
||||
const websocketUrl = new URL(String(rawUrl));
|
||||
const apiUrl = new URL(normalizeBaseUrl(apiBaseUrl));
|
||||
const ciBrokerPort = String(process.env.EDGE_BROKER_CI_PORT || "").trim();
|
||||
|
||||
if (isLocalHost(apiUrl.hostname) && websocketUrl.hostname === "edge-broker" && ciBrokerPort !== "") {
|
||||
websocketUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
|
||||
websocketUrl.hostname = apiUrl.hostname;
|
||||
websocketUrl.port = ciBrokerPort;
|
||||
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||
return websocketUrl.toString();
|
||||
}
|
||||
|
||||
if (apiUrl.hostname === "caddy" && websocketUrl.hostname === "caddy") {
|
||||
websocketUrl.hostname = "edge-broker";
|
||||
@@ -104,6 +118,18 @@ function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
|
||||
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||
}
|
||||
|
||||
if (isLocalHost(apiUrl.hostname) && ["caddy", "edge-broker"].includes(websocketUrl.hostname)) {
|
||||
const brokerPath = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||
websocketUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
|
||||
websocketUrl.hostname = apiUrl.hostname;
|
||||
websocketUrl.port = apiUrl.port;
|
||||
websocketUrl.pathname = `/api/edge-broker${brokerPath}`;
|
||||
}
|
||||
|
||||
if (isLocalHost(websocketUrl.hostname)) {
|
||||
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
|
||||
}
|
||||
|
||||
return websocketUrl.toString();
|
||||
}
|
||||
|
||||
@@ -555,6 +581,38 @@ function summarizeStreamMessages(messages, limit = 12) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function readGatewayDiagnostics({ baseUrl, authToken, gatewayId, containerName }) {
|
||||
const diagnostics = {};
|
||||
|
||||
if (gatewayId !== null && gatewayId > 0 && authToken) {
|
||||
try {
|
||||
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
||||
token: authToken,
|
||||
});
|
||||
diagnostics.gateway = {
|
||||
status: detail?.data?.status ?? null,
|
||||
channelStatus: detail?.data?.channel_status ?? null,
|
||||
brokerPresence: detail?.data?.metadata?.broker_presence ?? null,
|
||||
brokerConnected: detail?.data?.metadata?.broker_connected ?? null,
|
||||
brokerLastError: detail?.data?.metadata?.broker_last_error ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
diagnostics.gatewayError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const logs = await runCommand("docker", ["logs", "--tail", "120", containerName], {
|
||||
allowFailure: true,
|
||||
});
|
||||
diagnostics.containerLogs = String(`${logs.stdout || ""}${logs.stderr || ""}`).trim().split(/\r?\n/).slice(-120);
|
||||
} catch (error) {
|
||||
diagnostics.containerLogError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const rootDir = await resolveRootDir(scriptPath);
|
||||
@@ -669,22 +727,35 @@ async function main() {
|
||||
}
|
||||
);
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
||||
token: authToken,
|
||||
});
|
||||
try {
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
return Boolean(
|
||||
detail?.data?.channel_status?.broker?.connected
|
||||
|| detail?.data?.metadata?.broker_connected
|
||||
);
|
||||
},
|
||||
{
|
||||
timeoutMs: 90_000,
|
||||
message: "Gateway never established a live broker connection after install.",
|
||||
}
|
||||
);
|
||||
return Boolean(
|
||||
detail?.data?.channel_status?.broker?.connected
|
||||
|| detail?.data?.metadata?.broker_connected
|
||||
);
|
||||
},
|
||||
{
|
||||
timeoutMs: 90_000,
|
||||
message: "Gateway never established a live broker connection after install.",
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
const diagnostics = await readGatewayDiagnostics({
|
||||
baseUrl,
|
||||
authToken,
|
||||
gatewayId,
|
||||
containerName,
|
||||
});
|
||||
throw new Error([
|
||||
error instanceof Error ? error.message : String(error),
|
||||
`Broker diagnostics: ${JSON.stringify(diagnostics, null, 2)}`,
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
const WebSocketImpl = await loadWebSocketImplementation();
|
||||
const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, {
|
||||
@@ -707,11 +778,14 @@ async function main() {
|
||||
{ timeoutMs: 15_000, message: "Gateway stream never became ready." }
|
||||
);
|
||||
|
||||
const readyMessage = streamMessages.find((message) => message?.type === "gateway.stream.ready");
|
||||
assert.equal(
|
||||
Boolean(readyMessage?.connected),
|
||||
true,
|
||||
"Gateway stream became ready before the broker reported the gateway as connected."
|
||||
await waitForSocketMessage(
|
||||
streamMessages,
|
||||
(message) => (
|
||||
message?.type === "gateway.stream.ready" && message?.connected === true
|
||||
) || (
|
||||
message?.type === "presence.changed" && message?.status === "connected"
|
||||
),
|
||||
{ timeoutMs: 45_000, message: "Gateway stream never observed a connected broker presence." }
|
||||
);
|
||||
|
||||
const operationResponse = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/operations`, {
|
||||
@@ -739,10 +813,24 @@ async function main() {
|
||||
assert.ok(operationId > 0, "Operation creation did not return an operation id.");
|
||||
|
||||
try {
|
||||
await waitForSocketMessage(
|
||||
streamMessages,
|
||||
(message) => message?.type === "task.updated" && Number(message?.operationId || 0) === operationId,
|
||||
{ timeoutMs: 180_000, message: "Live gateway stream never emitted task.updated for the queued operation." }
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
const operation = Array.isArray(operations?.data)
|
||||
? operations.data.find((item) => Number(item?.id || 0) === operationId)
|
||||
: null;
|
||||
|
||||
return operation?.status === "COMPLETED"
|
||||
|| streamMessages.some((message) => (
|
||||
message?.type === "task.updated"
|
||||
&& Number(message?.operationId || 0) === operationId
|
||||
&& message?.operation?.status === "COMPLETED"
|
||||
));
|
||||
},
|
||||
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
|
||||
);
|
||||
} catch (error) {
|
||||
let operationSnapshot = null;
|
||||
@@ -765,21 +853,6 @@ async function main() {
|
||||
throw new Error(diagnostic);
|
||||
}
|
||||
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
const operation = Array.isArray(operations?.data)
|
||||
? operations.data.find((item) => Number(item?.id || 0) === operationId)
|
||||
: null;
|
||||
|
||||
return operation?.status === "COMPLETED";
|
||||
},
|
||||
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
|
||||
);
|
||||
|
||||
await waitForSocketMessage(
|
||||
streamMessages,
|
||||
(message) => message?.type === "gateway.telemetry" || message?.type === "stats.updated",
|
||||
@@ -855,22 +928,20 @@ async function main() {
|
||||
{ timeoutMs: 20_000, message: "Browser shell never closed cleanly." }
|
||||
);
|
||||
|
||||
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
|
||||
token: authToken,
|
||||
});
|
||||
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
|
||||
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
|
||||
: [];
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, {
|
||||
token: authToken,
|
||||
});
|
||||
const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions)
|
||||
? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || ""))
|
||||
: [];
|
||||
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
|
||||
|
||||
assert.ok(
|
||||
shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")),
|
||||
"Gateway logs page did not persist the shell transcript."
|
||||
);
|
||||
|
||||
const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []);
|
||||
assert.ok(
|
||||
timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"),
|
||||
"Gateway logs page did not include the shell close audit event."
|
||||
return shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell"))
|
||||
&& timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED");
|
||||
},
|
||||
{ timeoutMs: 30_000, message: "Gateway logs page did not persist the shell transcript and close audit event." }
|
||||
);
|
||||
|
||||
process.stdout.write("Edge gateway E2E smoke completed successfully.\n");
|
||||
|
||||
+60
-6
@@ -18,6 +18,7 @@ cd "$repo_root"
|
||||
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
|
||||
project_suffix="$(date +%s)-$$"
|
||||
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
|
||||
export COMPOSE_PROFILES="${COMPOSE_PROFILES:-dev}"
|
||||
|
||||
log_dir=".tmp/ci-logs/$suite"
|
||||
mkdir -p "$log_dir"
|
||||
@@ -51,6 +52,50 @@ collect_logs() {
|
||||
docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
retry_command() {
|
||||
max_attempts="$1"
|
||||
shift
|
||||
attempt=1
|
||||
while :; do
|
||||
"$@" && return 0
|
||||
status="$?"
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
return "$status"
|
||||
fi
|
||||
sleep_seconds=$((attempt * 5))
|
||||
echo "Command failed with status $status; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/$max_attempts): $*" >&2
|
||||
sleep "$sleep_seconds"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
}
|
||||
|
||||
composer_install() {
|
||||
dist_attempts="${PHP_CI_COMPOSER_RETRIES:-3}"
|
||||
source_attempts="${PHP_CI_COMPOSER_SOURCE_RETRIES:-2}"
|
||||
|
||||
if retry_command "$dist_attempts" \
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Composer dist install failed after ${dist_attempts} attempts; retrying with --prefer-source." >&2
|
||||
retry_command "$source_attempts" \
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
'cd /var/www/html && composer install --no-interaction --prefer-source --no-progress'
|
||||
}
|
||||
|
||||
configure_ci_docker_subnet() {
|
||||
if [ -n "${CI_DOCKER_SUBNET:-}" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
checksum="$(printf '%s' "$COMPOSE_PROJECT_NAME" | cksum | awk '{print $1}')"
|
||||
subnet_second=$((64 + ((checksum / 256) % 64)))
|
||||
subnet_third=$((checksum % 256))
|
||||
export CI_DOCKER_SUBNET="10.${subnet_second}.${subnet_third}.0/24"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
status="$?"
|
||||
collect_logs "$status"
|
||||
@@ -70,7 +115,8 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
docker compose $compose_files up -d redis mysql-debug php1
|
||||
configure_ci_docker_subnet
|
||||
sh scripts/ci-docker-compose-up.sh redis mysql-debug php1
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc '
|
||||
set -eu
|
||||
@@ -93,10 +139,18 @@ tar \
|
||||
--exclude='./.phpunit.cache' \
|
||||
--exclude='./build/logs' \
|
||||
-C services/nginx/app -cf - . \
|
||||
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
|
||||
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/html -xf -
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc 'rm -rf /var/www/repo-root && mkdir -p /var/www/repo-root'
|
||||
tar \
|
||||
-cf - \
|
||||
Dockerfile \
|
||||
Dockerfile.coolify-api \
|
||||
services/php/Dockerfile \
|
||||
services/php/php-fpm-pool.conf \
|
||||
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/repo-root -xf -
|
||||
|
||||
composer_install
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
"cd /var/www/html && composer test:ci:$suite"
|
||||
"cd /var/www/html && PLENO_REPO_ROOT_FOR_TESTS=/var/www/repo-root composer test:ci:$suite"
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import process from "node:process";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export const DEFAULT_STAGING_BASE_URL = "https://api.truckwash.io:4433";
|
||||
export const EXPECTED_INSTALL_VERSION = "compose-php-agent-v3";
|
||||
export const REQUIRED_MANIFEST_ARTIFACTS = [
|
||||
"agent.php",
|
||||
"lan-worker.php",
|
||||
"auto-updater.php",
|
||||
"docker-compose.gateway.yml",
|
||||
"Dockerfile.edge-agent",
|
||||
"Dockerfile.lan-worker",
|
||||
"Dockerfile.auto-updater",
|
||||
"gateway-launcher.sh",
|
||||
"truckwash-edge-gateway-stack.service",
|
||||
"truckwash-edge-agent.service",
|
||||
];
|
||||
export const INSTALLER_SCRIPT_REQUIRED_SNIPPETS = [
|
||||
"/edge-agent/install-token/status",
|
||||
"/edge-agent/artifacts/manifest.json",
|
||||
"report_install_status",
|
||||
'begin_install_phase "VERIFY_TOKEN"',
|
||||
'begin_install_phase "VERIFY_ARTIFACTS"',
|
||||
'begin_install_phase "WAIT_FOR_CLAIM"',
|
||||
'report_install_status "FAILED"',
|
||||
"verify_manifest_artifact",
|
||||
EXPECTED_INSTALL_VERSION,
|
||||
];
|
||||
|
||||
export function normalizeBaseUrl(url) {
|
||||
@@ -55,13 +73,20 @@ export function buildChecks(baseUrl, installToken) {
|
||||
name: "Ping",
|
||||
url: `${normalizedBaseUrl}/ping`,
|
||||
},
|
||||
{
|
||||
name: "Artifact manifest",
|
||||
url: `${normalizedBaseUrl}/edge-agent/artifacts/manifest.json`,
|
||||
artifactName: "manifest.json",
|
||||
},
|
||||
{
|
||||
name: "Agent PHP artifact",
|
||||
url: `${normalizedBaseUrl}/edge-agent/artifacts/agent.php`,
|
||||
artifactName: "agent.php",
|
||||
},
|
||||
{
|
||||
name: "Service unit artifact",
|
||||
url: `${normalizedBaseUrl}/edge-agent/artifacts/truckwash-edge-agent.service`,
|
||||
artifactName: "truckwash-edge-agent.service",
|
||||
},
|
||||
{
|
||||
name: "Installer script",
|
||||
@@ -70,6 +95,42 @@ export function buildChecks(baseUrl, installToken) {
|
||||
];
|
||||
}
|
||||
|
||||
export function validateArtifactManifestBody(body) {
|
||||
const manifest = JSON.parse(String(body || ""));
|
||||
if (manifest.version !== EXPECTED_INSTALL_VERSION) {
|
||||
throw new Error(`Artifact manifest version mismatch: expected ${EXPECTED_INSTALL_VERSION}, got ${manifest.version}`);
|
||||
}
|
||||
if (!Array.isArray(manifest.artifacts)) {
|
||||
throw new Error("Artifact manifest is missing artifacts.");
|
||||
}
|
||||
|
||||
const byName = new Map(manifest.artifacts.map((artifact) => [artifact?.name, artifact]));
|
||||
const missingArtifacts = REQUIRED_MANIFEST_ARTIFACTS.filter((artifactName) => !byName.has(artifactName));
|
||||
if (missingArtifacts.length) {
|
||||
throw new Error(`Artifact manifest is missing required artifacts: ${missingArtifacts.join(", ")}`);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function validateArtifactBodyAgainstManifest(manifest, artifactName, body) {
|
||||
const artifact = manifest?.artifacts?.find((entry) => entry?.name === artifactName);
|
||||
if (!artifact) {
|
||||
throw new Error(`Artifact ${artifactName} is missing from manifest.`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.isBuffer(body) ? body : Buffer.from(String(body || ""));
|
||||
const sha256 = createHash("sha256").update(buffer).digest("hex");
|
||||
if (sha256 !== artifact.sha256) {
|
||||
throw new Error(`Artifact ${artifactName} hash mismatch: ${sha256} !== ${artifact.sha256}`);
|
||||
}
|
||||
if (buffer.length !== artifact.bytes) {
|
||||
throw new Error(`Artifact ${artifactName} size mismatch: ${buffer.length} !== ${artifact.bytes}`);
|
||||
}
|
||||
|
||||
return artifact;
|
||||
}
|
||||
|
||||
export function validateInstallerScriptBody(body) {
|
||||
const source = String(body || "");
|
||||
const missingSnippets = INSTALLER_SCRIPT_REQUIRED_SNIPPETS.filter((snippet) => !source.includes(snippet));
|
||||
@@ -107,16 +168,17 @@ export async function runSmoke({ baseUrl, installToken }) {
|
||||
|
||||
const checks = buildChecks(baseUrl, installToken);
|
||||
const results = [];
|
||||
let artifactManifest = null;
|
||||
|
||||
for (const check of checks) {
|
||||
process.stdout.write(`[staging-smoke] GET ${check.url}\n`);
|
||||
const response = await fetch(check.url);
|
||||
const body = await response.text();
|
||||
const body = Buffer.from(await response.arrayBuffer());
|
||||
const result = {
|
||||
...check,
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
bodyPreview: previewBody(body),
|
||||
bodyPreview: previewBody(body.toString("utf8")),
|
||||
};
|
||||
results.push(result);
|
||||
|
||||
@@ -127,8 +189,16 @@ export async function runSmoke({ baseUrl, installToken }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (check.name === "Artifact manifest") {
|
||||
artifactManifest = validateArtifactManifestBody(body.toString("utf8"));
|
||||
result.version = artifactManifest.version;
|
||||
result.artifactCount = artifactManifest.artifacts.length;
|
||||
}
|
||||
if (artifactManifest && check.artifactName && check.artifactName !== "manifest.json") {
|
||||
result.verifiedArtifact = validateArtifactBodyAgainstManifest(artifactManifest, check.artifactName, body);
|
||||
}
|
||||
if (check.name === "Installer script") {
|
||||
result.verifiedSnippets = validateInstallerScriptBody(body);
|
||||
result.verifiedSnippets = validateInstallerScriptBody(body.toString("utf8"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DEFAULT_STAGING_BASE_URL,
|
||||
EXPECTED_INSTALL_VERSION,
|
||||
INSTALLER_SCRIPT_REQUIRED_SNIPPETS,
|
||||
buildChecks,
|
||||
normalizeBaseUrl,
|
||||
parseArgs,
|
||||
validateArtifactBodyAgainstManifest,
|
||||
validateArtifactManifestBody,
|
||||
validateInstallerScriptBody,
|
||||
} from "./staging-edge-gateway-smoke.mjs";
|
||||
|
||||
@@ -32,18 +35,58 @@ test("buildChecks targets the public staging endpoints", () => {
|
||||
|
||||
assert.deepEqual(checks.map((check) => check.url), [
|
||||
"https://api.truckwash.io:4433/ping",
|
||||
"https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json",
|
||||
"https://api.truckwash.io:4433/edge-agent/artifacts/agent.php",
|
||||
"https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service",
|
||||
"https://api.truckwash.io:4433/edge-agent/install.sh?token=abc%20123",
|
||||
]);
|
||||
});
|
||||
|
||||
test("validateArtifactManifestBody requires v3 install artifacts", () => {
|
||||
const artifacts = [
|
||||
"agent.php",
|
||||
"lan-worker.php",
|
||||
"auto-updater.php",
|
||||
"docker-compose.gateway.yml",
|
||||
"Dockerfile.edge-agent",
|
||||
"Dockerfile.lan-worker",
|
||||
"Dockerfile.auto-updater",
|
||||
"gateway-launcher.sh",
|
||||
"truckwash-edge-gateway-stack.service",
|
||||
"truckwash-edge-agent.service",
|
||||
].map((name) => ({ name, sha256: "abc", bytes: 1 }));
|
||||
|
||||
const manifest = validateArtifactManifestBody(JSON.stringify({
|
||||
version: EXPECTED_INSTALL_VERSION,
|
||||
artifacts,
|
||||
}));
|
||||
|
||||
assert.equal(manifest.version, EXPECTED_INSTALL_VERSION);
|
||||
});
|
||||
|
||||
test("validateArtifactBodyAgainstManifest verifies size and hash", () => {
|
||||
const body = Buffer.from("hello");
|
||||
const manifest = {
|
||||
artifacts: [{
|
||||
name: "agent.php",
|
||||
sha256: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
|
||||
bytes: body.length,
|
||||
}],
|
||||
};
|
||||
|
||||
assert.equal(validateArtifactBodyAgainstManifest(manifest, "agent.php", body).name, "agent.php");
|
||||
});
|
||||
|
||||
test("validateInstallerScriptBody requires install-session reporting wiring", () => {
|
||||
const script = `
|
||||
INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"
|
||||
fetch_http "Download artifact manifest" "https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json"
|
||||
report_install_status "FAILED"
|
||||
begin_install_phase "VERIFY_TOKEN" "Verifying install token"
|
||||
begin_install_phase "VERIFY_ARTIFACTS" "Verifying edge gateway artifacts"
|
||||
begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"
|
||||
verify_manifest_artifact
|
||||
${EXPECTED_INSTALL_VERSION}
|
||||
`;
|
||||
|
||||
assert.deepEqual(validateInstallerScriptBody(script), INSTALLER_SCRIPT_REQUIRED_SNIPPETS);
|
||||
|
||||
@@ -12,7 +12,7 @@ export const DEFAULT_IMAGE_TAG = "truckwash-edge-agent:test-gateway";
|
||||
export const DEFAULT_CONFIG_FILE_NAME = "test-gateway.json";
|
||||
export const DEFAULT_HOST_API_URL = "http://localhost/api";
|
||||
export const DEFAULT_CONTAINER_API_URL = "http://caddy";
|
||||
export const DEFAULT_CONTAINER_BROKER_URL = "http://edge-broker:4300";
|
||||
export const DEFAULT_CONTAINER_BROKER_URL = "ws://edge-broker:4300";
|
||||
export const DEFAULT_INSTALL_DIR = "/opt/truckwash-edge-agent";
|
||||
export const DEFAULT_RUNTIME_DIR = `${DEFAULT_INSTALL_DIR}/runtime`;
|
||||
export const DEFAULT_STATE_DATABASE_PATH = `${DEFAULT_RUNTIME_DIR}/gateway-state.sqlite`;
|
||||
@@ -409,14 +409,20 @@ async function startContainer({
|
||||
});
|
||||
|
||||
if (copyConfig) {
|
||||
await runCommand("docker", [
|
||||
"cp",
|
||||
path.join(configDir, DEFAULT_CONFIG_FILE_NAME),
|
||||
`${containerName}:${containerConfigPath}`,
|
||||
], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
|
||||
await fs.chmod(configFilePath, 0o666).catch(() => {});
|
||||
try {
|
||||
await runCommand("docker", [
|
||||
"cp",
|
||||
configFilePath,
|
||||
`${containerName}:${containerConfigPath}`,
|
||||
], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
} finally {
|
||||
await fs.chmod(configFilePath, 0o600).catch(() => {});
|
||||
}
|
||||
|
||||
await runCommand("docker", ["start", containerName], {
|
||||
cwd: rootDir,
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
# CORS is handled at the edge by Traefik's headers middleware.
|
||||
# Do not set or strip Access-Control-* headers here to avoid conflicts.
|
||||
|
||||
# Do not expose local replication bootstrap material from the public web root.
|
||||
# Bootstrap snapshots contain sensitive failover credentials.
|
||||
@replicationBootstrap path /storage/replication-bootstrap.json /storage/replication-bootstrap-*
|
||||
respond @replicationBootstrap 404
|
||||
|
||||
# PHP handling via FastCGI to php-fpm pool
|
||||
php_fastcgi php1:9000 php2:9000 php3:9000 php4:9000 php5:9000
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
# CORS is handled at the edge by Traefik's headers middleware.
|
||||
# Do not set or strip Access-Control-* headers here to avoid conflicts.
|
||||
|
||||
# Do not expose local replication bootstrap material from the public web root.
|
||||
# Bootstrap snapshots contain sensitive failover credentials.
|
||||
@replicationBootstrap path /storage/replication-bootstrap.json /storage/replication-bootstrap-*
|
||||
respond @replicationBootstrap 404
|
||||
|
||||
# PHP handling via FastCGI to php-fpm pool
|
||||
php_fastcgi php-staging:9000
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+90
-2
@@ -18,6 +18,7 @@ const DEFAULT_UPDATE_VERIFY_INTERVAL_MS = 500;
|
||||
const DEFAULT_UPDATE_RESTART_GRACE_MS = 150;
|
||||
const DEFAULT_BROKER_RECONNECT_DELAY_MS = 1500;
|
||||
const DEFAULT_SHELLY_LOCAL_HTTP_TIMEOUT_MS = 1200;
|
||||
const MAX_RELAY_TOGGLE_AFTER_SECONDS = 5;
|
||||
const UPDATE_VERIFY_COMMAND = "post-update-verify";
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
@@ -151,6 +152,10 @@ function buildTransportHeartbeatState(brokerState = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function isShellAccessEnabled(config = {}) {
|
||||
return config.enableShellAccess === true;
|
||||
}
|
||||
|
||||
function normalizeBrokerBaseUrl(value) {
|
||||
const trimmed = String(value || "").trim().replace(/\/+$/, "");
|
||||
if (trimmed === "") {
|
||||
@@ -478,7 +483,7 @@ function resolveRelayToggleAfterSeconds(payload = {}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.floor(configured);
|
||||
return Math.min(Math.floor(configured), MAX_RELAY_TOGGLE_AFTER_SECONDS);
|
||||
}
|
||||
|
||||
async function fetchJson(url, fetchImpl = fetch, options = {}) {
|
||||
@@ -749,11 +754,74 @@ export async function setRelayState(payload, fetchImpl = fetch) {
|
||||
}
|
||||
}
|
||||
|
||||
async function mapWithConcurrency(items, limit, mapper) {
|
||||
const results = new Array(items.length);
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.max(1, Math.min(Number(limit) || 1, items.length || 1));
|
||||
|
||||
await Promise.all(Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[index] = await mapper(items[index], index);
|
||||
}
|
||||
}));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function executeRelayBatch(command, handler, fetchImpl = fetch) {
|
||||
const commands = Array.isArray(command?.payload?.commands)
|
||||
? command.payload.commands
|
||||
: Array.isArray(command?.commands)
|
||||
? command.commands
|
||||
: [];
|
||||
const concurrency = Math.max(1, Math.min(Number(command?.payload?.concurrency || command?.concurrency || 5), 5));
|
||||
|
||||
const results = await mapWithConcurrency(commands, concurrency, async (entry = {}) => {
|
||||
const target = String(entry.target || entry.relay || "");
|
||||
const relayId = String(entry.relayId || entry.relay_id || "");
|
||||
try {
|
||||
const payload = await handler(entry, fetchImpl);
|
||||
return {
|
||||
target,
|
||||
relayId,
|
||||
relay_id: relayId,
|
||||
ok: true,
|
||||
payload,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
target,
|
||||
relayId,
|
||||
relay_id: relayId,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
batchId: command?.payload?.batchId || command?.payload?.batch_id || command?.batchId || command?.batch_id || null,
|
||||
batch_id: command?.payload?.batch_id || command?.payload?.batchId || command?.batch_id || command?.batchId || null,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch) {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!expectedSha256) {
|
||||
throw new Error(`${label} checksum is required`);
|
||||
}
|
||||
|
||||
const normalizedExpectedSha256 = String(expectedSha256).toLowerCase();
|
||||
if (!/^[a-f0-9]{64}$/.test(normalizedExpectedSha256)) {
|
||||
throw new Error(`${label} checksum must be a valid sha256 hex digest`);
|
||||
}
|
||||
|
||||
const response = await fetchImpl(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${label} download failed: HTTP ${response.status}`);
|
||||
@@ -761,7 +829,7 @@ async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const sha256 = createHash("sha256").update(buffer).digest("hex");
|
||||
if (expectedSha256 && String(expectedSha256).toLowerCase() !== sha256.toLowerCase()) {
|
||||
if (normalizedExpectedSha256 !== sha256.toLowerCase()) {
|
||||
throw new Error(`${label} checksum mismatch`);
|
||||
}
|
||||
|
||||
@@ -1402,6 +1470,10 @@ export async function handleAgentCommand(command, deps = {}) {
|
||||
return await getRelayStatus(command.payload || {}, fetchImpl);
|
||||
case "SET_RELAY_STATE":
|
||||
return await setRelayState(command.payload || {}, fetchImpl);
|
||||
case "BATCH_RELAY_STATUS":
|
||||
return await executeRelayBatch(command, getRelayStatus, fetchImpl);
|
||||
case "BATCH_SET_RELAY_STATE":
|
||||
return await executeRelayBatch(command, setRelayState, fetchImpl);
|
||||
case "RUN_UPDATE":
|
||||
return await runUpdate(command.payload || {}, fetchImpl, deps);
|
||||
case "UNINSTALL_AGENT":
|
||||
@@ -1785,6 +1857,10 @@ export async function processPolledShellAction(config, action, shell, fetchImpl
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isShellAccessEnabled(config)) {
|
||||
throw new Error("Shell access is disabled by local configuration");
|
||||
}
|
||||
|
||||
if (actionType === "OPEN") {
|
||||
await shell.open(payload);
|
||||
} else if (actionType === "INPUT") {
|
||||
@@ -1919,18 +1995,30 @@ function createBrokerBridge({
|
||||
}
|
||||
|
||||
if (message.type === "OPEN_ROOT_SHELL") {
|
||||
if (!isShellAccessEnabled(config)) {
|
||||
throw new Error("Shell access is disabled by local configuration");
|
||||
}
|
||||
await shell.open(message.payload || {});
|
||||
return;
|
||||
}
|
||||
if (message.type === "SHELL_INPUT") {
|
||||
if (!isShellAccessEnabled(config)) {
|
||||
throw new Error("Shell access is disabled by local configuration");
|
||||
}
|
||||
shell.input(message.payload || {});
|
||||
return;
|
||||
}
|
||||
if (message.type === "RESIZE_ROOT_SHELL") {
|
||||
if (!isShellAccessEnabled(config)) {
|
||||
throw new Error("Shell access is disabled by local configuration");
|
||||
}
|
||||
shell.resize(message.payload || {});
|
||||
return;
|
||||
}
|
||||
if (message.type === "CLOSE_ROOT_SHELL") {
|
||||
if (!isShellAccessEnabled(config)) {
|
||||
throw new Error("Shell access is disabled by local configuration");
|
||||
}
|
||||
shell.close(message.payload || {});
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
getRelayStatus,
|
||||
loadConfig,
|
||||
parseCliArgs,
|
||||
processPolledShellAction,
|
||||
processPolledCommand,
|
||||
runCli,
|
||||
runUpdate,
|
||||
@@ -39,6 +41,10 @@ function makeFetchResponse(body) {
|
||||
};
|
||||
}
|
||||
|
||||
function sha256Hex(body) {
|
||||
return createHash("sha256").update(body).digest("hex");
|
||||
}
|
||||
|
||||
async function waitFor(predicate, { timeoutMs = 1000, intervalMs = 10, description = "condition" } = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
@@ -121,6 +127,39 @@ test("relay status and switch commands support both Shelly RPC and legacy endpoi
|
||||
assert.equal(switched.on, false);
|
||||
});
|
||||
|
||||
test("batch relay commands return per-relay results without failing the whole batch", async () => {
|
||||
const fakeFetch = async (url) => {
|
||||
const value = String(url);
|
||||
if (value.includes("10.1.0.31")) {
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { output: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error("relay offline");
|
||||
};
|
||||
|
||||
const result = await handleAgentCommand({
|
||||
commandType: "BATCH_SET_RELAY_STATE",
|
||||
payload: {
|
||||
batch_id: "batch-1",
|
||||
commands: [
|
||||
{ target: "MACHINE", relayId: "relay-machine", localIp: "10.1.0.31", channel: 0, on: true },
|
||||
{ target: "EXIT", relayId: "relay-out", localIp: "10.1.0.32", channel: 0, on: true },
|
||||
],
|
||||
},
|
||||
}, { fetchImpl: fakeFetch });
|
||||
|
||||
assert.equal(result.batch_id, "batch-1");
|
||||
assert.equal(result.results.length, 2);
|
||||
assert.equal(result.results[0].ok, true);
|
||||
assert.equal(result.results[0].target, "MACHINE");
|
||||
assert.equal(result.results[1].ok, false);
|
||||
assert.match(result.results[1].error, /relay offline/);
|
||||
});
|
||||
|
||||
test("Shelly discovery infers Gen3 from S3 relay model codes when generation is omitted", async () => {
|
||||
const inventory = await discoverShellyDevices({ candidateIps: ["192.168.1.2"] }, async (url) => {
|
||||
assert.equal(String(url), "http://192.168.1.2/shelly");
|
||||
@@ -270,6 +309,31 @@ test("relay switch commands pass timer values to local Shelly APIs", async () =>
|
||||
"http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=3",
|
||||
"http://10.1.0.31/relay/0?turn=on&timer=3",
|
||||
]);
|
||||
|
||||
const cappedUrls = [];
|
||||
const cappedFetch = async (url) => {
|
||||
cappedUrls.push(String(url));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { output: true };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
await setRelayState({
|
||||
localIp: "10.1.0.31",
|
||||
channel: 0,
|
||||
on: true,
|
||||
toggle_after: 999999999,
|
||||
device_generation: 3,
|
||||
}, cappedFetch);
|
||||
|
||||
assert.equal(
|
||||
cappedUrls[0],
|
||||
"http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=5"
|
||||
);
|
||||
});
|
||||
|
||||
test("runUpdate stages a pending verification restart after installing new artifacts", async () => {
|
||||
@@ -294,19 +358,23 @@ test("runUpdate stages a pending verification restart after installing new artif
|
||||
execCalls.push({ command, args, options });
|
||||
return { stdout: "{}" };
|
||||
};
|
||||
const agentBody = "// new agent\n";
|
||||
const packageBody = JSON.stringify({ name: "new-edge-agent" }, null, 2);
|
||||
const fakeFetch = async (url) => {
|
||||
if (String(url).endsWith("/agent.mjs")) {
|
||||
return makeFetchResponse("// new agent\n");
|
||||
return makeFetchResponse(agentBody);
|
||||
}
|
||||
if (String(url).endsWith("/package.json")) {
|
||||
return makeFetchResponse(JSON.stringify({ name: "new-edge-agent" }, null, 2));
|
||||
return makeFetchResponse(packageBody);
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
};
|
||||
|
||||
const result = await runUpdate({
|
||||
artifactUrl: "https://api.example.test/edge-agent/artifacts/agent.mjs",
|
||||
sha256: sha256Hex(agentBody),
|
||||
packageUrl: "https://api.example.test/edge-agent/artifacts/package.json",
|
||||
packageSha256: sha256Hex(packageBody),
|
||||
targetVersion: "1.1.0",
|
||||
releaseChannel: "stable",
|
||||
restartMode: "spawn",
|
||||
@@ -334,6 +402,45 @@ test("runUpdate stages a pending verification restart after installing new artif
|
||||
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
test("runUpdate rejects artifacts without required checksums", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-update-checksum-"));
|
||||
const configPath = path.join(tempDir, "config.json");
|
||||
const liveConfig = {
|
||||
apiUrl: "https://api.example.test",
|
||||
gatewayId: 42,
|
||||
agentToken: "agent-token",
|
||||
installDir: tempDir,
|
||||
restartMode: "spawn",
|
||||
installedVersion: "1.0.0",
|
||||
targetVersion: "1.0.0",
|
||||
};
|
||||
|
||||
await writeFile(configPath, JSON.stringify(liveConfig, null, 2));
|
||||
await writeFile(path.join(tempDir, "agent.mjs"), "// old agent\n");
|
||||
|
||||
let fetchCalled = false;
|
||||
await assert.rejects(
|
||||
runUpdate({
|
||||
artifactUrl: "https://api.example.test/edge-agent/artifacts/agent.mjs",
|
||||
targetVersion: "1.1.0",
|
||||
}, async () => {
|
||||
fetchCalled = true;
|
||||
return makeFetchResponse("// new agent\n");
|
||||
}, {
|
||||
configPath,
|
||||
config: liveConfig,
|
||||
liveConfig,
|
||||
execFileImpl: async () => ({ stdout: "{}" }),
|
||||
}),
|
||||
/Agent artifact checksum is required/
|
||||
);
|
||||
|
||||
assert.equal(fetchCalled, false);
|
||||
assert.equal(await readFile(path.join(tempDir, "agent.mjs"), "utf8"), "// old agent\n");
|
||||
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
test("handleAgentCommand returns an uninstall follow-up envelope for gateway removal", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-uninstall-envelope-"));
|
||||
@@ -670,6 +777,7 @@ test("startAgent reports API polling metadata, executes polled commands, and upl
|
||||
commandPollRetryDelayMs: 5,
|
||||
shellActionPollTimeoutSeconds: 0,
|
||||
shellActionPollRetryDelayMs: 5,
|
||||
enableShellAccess: true,
|
||||
}));
|
||||
|
||||
const heartbeats = [];
|
||||
@@ -928,6 +1036,61 @@ test("startAgent reports API polling metadata, executes polled commands, and upl
|
||||
}
|
||||
});
|
||||
|
||||
test("processPolledShellAction denies shell access when locally disabled", async () => {
|
||||
const submissions = [];
|
||||
const fakeFetch = async (url, options = {}) => {
|
||||
if (/\/shell-actions\/\d+\/result$/.test(String(url))) {
|
||||
submissions.push({ url, body: JSON.parse(options.body) });
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { data: { acknowledged: true } };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
};
|
||||
|
||||
const shell = {
|
||||
async open() {
|
||||
throw new Error("should not run");
|
||||
},
|
||||
input() {
|
||||
throw new Error("should not run");
|
||||
},
|
||||
resize() {
|
||||
throw new Error("should not run");
|
||||
},
|
||||
close() {
|
||||
throw new Error("should not run");
|
||||
},
|
||||
};
|
||||
|
||||
const result = await processPolledShellAction(
|
||||
{
|
||||
apiUrl: "https://api.example.test",
|
||||
gatewayId: 42,
|
||||
agentToken: "agent-token",
|
||||
enableShellAccess: false,
|
||||
},
|
||||
{
|
||||
id: 501,
|
||||
actionType: "OPEN",
|
||||
payload: {
|
||||
sessionId: 44,
|
||||
},
|
||||
},
|
||||
shell,
|
||||
fakeFetch
|
||||
);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /Shell access is disabled/);
|
||||
assert.equal(submissions.length, 1);
|
||||
assert.equal(submissions[0].body.ok, false);
|
||||
});
|
||||
|
||||
test("status helpers report config without exposing the agent token", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-status-"));
|
||||
const configPath = path.join(tempDir, "config.json");
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
const DEFAULT_SHELL_OPEN_TIMEOUT_MS = 15000;
|
||||
const TELEMETRY_INGEST_ERROR_MESSAGE = "Telemetry ingestion failed";
|
||||
|
||||
function parseJsonBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -55,7 +56,33 @@ function resolveAuthMode(options = {}, managerUrl = "") {
|
||||
if (process.env.EDGE_AUTH_MODE) {
|
||||
return process.env.EDGE_AUTH_MODE;
|
||||
}
|
||||
return "manager";
|
||||
return "strict";
|
||||
}
|
||||
|
||||
function resolveSharedSecret(options = {}) {
|
||||
return String(options.sharedSecret ?? process.env.EDGE_BROKER_SHARED_SECRET ?? "").trim();
|
||||
}
|
||||
|
||||
function requireSharedSecret(req, res, sharedSecret) {
|
||||
if (sharedSecret === "") {
|
||||
jsonResponse(res, 503, {
|
||||
ok: false,
|
||||
error: "Edge broker shared secret is not configured",
|
||||
shared_secret_required: true,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.headers["x-edge-broker-secret"] !== sharedSecret) {
|
||||
jsonResponse(res, 403, {
|
||||
ok: false,
|
||||
error: "Forbidden",
|
||||
shared_secret_required: true,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseScopes(value) {
|
||||
@@ -150,7 +177,7 @@ function rejectUpgrade(socket, statusCode, errorCode, message, details = {}) {
|
||||
}
|
||||
|
||||
export function createBrokerServer(options = {}) {
|
||||
const sharedSecret = options.sharedSecret ?? process.env.EDGE_BROKER_SHARED_SECRET ?? "";
|
||||
const sharedSecret = resolveSharedSecret(options);
|
||||
const managerUrl = resolveManagerUrl(options);
|
||||
const authMode = resolveAuthMode(options, managerUrl);
|
||||
const commandTimeoutMs = options.commandTimeoutMs ?? 10000;
|
||||
@@ -269,6 +296,12 @@ export function createBrokerServer(options = {}) {
|
||||
? async (_gatewayId, payload = {}) => payload
|
||||
: async (gatewayId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/logs`, payload));
|
||||
const ingestMachineSignal =
|
||||
options.ingestMachineSignal ||
|
||||
(authMode === "stub"
|
||||
? async (_gatewayId, payload = {}) => payload
|
||||
: async (gatewayId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/selfserve/machine-signal`, payload));
|
||||
|
||||
const broadcastGatewayEvent = (gatewayId, message) => {
|
||||
const sessionIds = gatewayStreamSessions.get(String(gatewayId));
|
||||
@@ -460,25 +493,19 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/diagnostics/shared-secret") {
|
||||
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
|
||||
jsonResponse(res, 403, {
|
||||
ok: false,
|
||||
error: "Forbidden",
|
||||
shared_secret_required: true,
|
||||
});
|
||||
if (!requireSharedSecret(req, res, sharedSecret)) {
|
||||
return;
|
||||
}
|
||||
|
||||
jsonResponse(res, 200, {
|
||||
ok: true,
|
||||
shared_secret_required: Boolean(sharedSecret),
|
||||
shared_secret_required: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && /^\/api\/gateways\/\d+\/commands$/.test(url.pathname)) {
|
||||
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
|
||||
jsonResponse(res, 403, { error: "Forbidden" });
|
||||
if (!requireSharedSecret(req, res, sharedSecret)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -521,8 +548,7 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
|
||||
if (req.method === "POST" && /^\/api\/gateways\/\d+\/sync$/.test(url.pathname)) {
|
||||
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
|
||||
jsonResponse(res, 403, { error: "Forbidden" });
|
||||
if (!requireSharedSecret(req, res, sharedSecret)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -556,12 +582,13 @@ export function createBrokerServer(options = {}) {
|
||||
gatewayInfo = await validateAgent({ gatewayId, token, headers: req.headers });
|
||||
} catch (error) {
|
||||
const status = Number(error?.status) === 403 ? 403 : Number(error?.status) === 401 ? 401 : 503;
|
||||
rejectUpgrade(socket, status, error?.code || "agent_validation_failed", normalizeErrorMessage(error, "Gateway agent could not be validated."), {
|
||||
rejectUpgrade(socket, status, error?.code || "agent_validation_failed", "Gateway agent could not be validated.", {
|
||||
stage: "agent_validate",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
const existing = agents.get(gatewayId);
|
||||
if (existing && existing.readyState < 2) {
|
||||
@@ -622,12 +649,13 @@ export function createBrokerServer(options = {}) {
|
||||
try {
|
||||
session = await validateShellSession({ token, headers: req.headers });
|
||||
} catch (error) {
|
||||
rejectUpgrade(socket, Number(error?.status) === 403 ? 403 : 401, error?.code || "shell_session_invalid", normalizeErrorMessage(error, "Shell session could not be validated."), {
|
||||
rejectUpgrade(socket, Number(error?.status) === 403 ? 403 : 401, error?.code || "shell_session_invalid", "Shell session could not be validated.", {
|
||||
stage: "shell_session_validate",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
ws.sessionToken = token;
|
||||
ws.sessionInfo = session;
|
||||
@@ -722,7 +750,7 @@ export function createBrokerServer(options = {}) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
rejectUpgrade(socket, 500, "websocket_upgrade_failed", normalizeErrorMessage(error, "WebSocket upgrade failed."));
|
||||
rejectUpgrade(socket, 500, "websocket_upgrade_failed", "WebSocket upgrade failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -765,8 +793,8 @@ export function createBrokerServer(options = {}) {
|
||||
let ingestError = null;
|
||||
try {
|
||||
ingested = await ingestTelemetry(String(ws.gatewayId), payload);
|
||||
} catch (error) {
|
||||
ingestError = error instanceof Error ? error.message : String(error);
|
||||
} catch {
|
||||
ingestError = TELEMETRY_INGEST_ERROR_MESSAGE;
|
||||
}
|
||||
const fallbackStatistics = {
|
||||
system_metrics: payload?.metadata?.system_metrics || {},
|
||||
@@ -831,6 +859,11 @@ export function createBrokerServer(options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "MACHINE_SIGNAL") {
|
||||
await ingestMachineSignal(String(ws.gatewayId), message.payload || {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
|
||||
const sessionRecord = browserShellSessions.get(String(message.sessionId));
|
||||
if (!sessionRecord) {
|
||||
|
||||
@@ -19,6 +19,18 @@ function waitForClose(socket) {
|
||||
});
|
||||
}
|
||||
|
||||
function waitForCloseOrError(socket) {
|
||||
return new Promise((resolve) => {
|
||||
const onDone = () => {
|
||||
socket.off("error", onDone);
|
||||
socket.off("close", onDone);
|
||||
resolve();
|
||||
};
|
||||
socket.once("error", onDone);
|
||||
socket.once("close", onDone);
|
||||
});
|
||||
}
|
||||
|
||||
function rawUpgradeRequest(port, path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port }, () => {
|
||||
@@ -59,7 +71,7 @@ async function waitFor(predicate, { timeoutMs = 1000, intervalMs = 10, descripti
|
||||
throw new Error(`Timed out waiting for ${description}`);
|
||||
}
|
||||
|
||||
test("broker defaults to manager auth and fails closed when manager URL is missing", async () => {
|
||||
test("broker defaults to strict auth and fails closed when manager URL is missing", async () => {
|
||||
const previousEnv = {
|
||||
EDGE_AUTH_MODE: process.env.EDGE_AUTH_MODE,
|
||||
EDGE_MANAGER_URL: process.env.EDGE_MANAGER_URL,
|
||||
@@ -72,7 +84,7 @@ test("broker defaults to manager auth and fails closed when manager URL is missi
|
||||
let broker;
|
||||
try {
|
||||
broker = createBrokerServer({ sharedSecret: "secret" });
|
||||
assert.equal(broker.state.authMode, "manager");
|
||||
assert.equal(broker.state.authMode, "strict");
|
||||
assert.equal(broker.state.managerUrl, "");
|
||||
|
||||
const address = await broker.listen(0);
|
||||
@@ -83,13 +95,15 @@ test("broker defaults to manager auth and fails closed when manager URL is missi
|
||||
assert.doesNotMatch(shellResponse, /101 Switching Protocols/);
|
||||
assert.match(shellResponse, /^HTTP\/1\.1 401 Unauthorized/m);
|
||||
assert.match(shellResponse, /"error_code":"shell_session_invalid"/);
|
||||
assert.match(shellResponse, /Edge manager URL is not configured/);
|
||||
assert.match(shellResponse, /"message":"Shell session could not be validated\."/);
|
||||
assert.doesNotMatch(shellResponse, /Edge manager URL is not configured/);
|
||||
|
||||
assert.doesNotMatch(agentResponse, /101 Switching Protocols/);
|
||||
assert.match(agentResponse, /^HTTP\/1\.1 503 Service Unavailable/m);
|
||||
assert.match(agentResponse, /"error_code":"agent_validation_failed"/);
|
||||
assert.match(agentResponse, /"stage":"agent_validate"/);
|
||||
assert.match(agentResponse, /Edge manager URL is not configured/);
|
||||
assert.match(agentResponse, /"message":"Gateway agent could not be validated\."/);
|
||||
assert.doesNotMatch(agentResponse, /Edge manager URL is not configured/);
|
||||
} finally {
|
||||
if (broker) {
|
||||
await broker.close();
|
||||
@@ -104,6 +118,53 @@ test("broker defaults to manager auth and fails closed when manager URL is missi
|
||||
}
|
||||
});
|
||||
|
||||
test("broker rejects protected HTTP endpoints when shared secret is missing", async () => {
|
||||
const broker = createBrokerServer({ authMode: "stub", sharedSecret: "", commandTimeoutMs: 2000 });
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
||||
|
||||
await new Promise((resolve) => agent.once("open", resolve));
|
||||
const agentMessages = collectMessages(agent);
|
||||
|
||||
const commandResponse = await fetch(`http://127.0.0.1:${port}/api/gateways/701/commands`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
commandType: "SET_RELAY_STATE",
|
||||
payload: { relayId: "M-7", on: true },
|
||||
}),
|
||||
});
|
||||
const commandJson = await commandResponse.json();
|
||||
|
||||
assert.equal(commandResponse.status, 503);
|
||||
assert.equal(commandJson.ok, false);
|
||||
assert.equal(commandJson.shared_secret_required, true);
|
||||
assert.match(commandJson.error, /shared secret is not configured/);
|
||||
assert.equal(agentMessages.some((message) => message.type === "COMMAND"), false);
|
||||
|
||||
const diagnosticsResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
|
||||
method: "POST",
|
||||
});
|
||||
const diagnosticsJson = await diagnosticsResponse.json();
|
||||
|
||||
assert.equal(diagnosticsResponse.status, 503);
|
||||
assert.equal(diagnosticsJson.shared_secret_required, true);
|
||||
|
||||
const syncResponse = await fetch(`http://127.0.0.1:${port}/api/gateways/701/sync`, {
|
||||
method: "POST",
|
||||
});
|
||||
const syncJson = await syncResponse.json();
|
||||
|
||||
assert.equal(syncResponse.status, 503);
|
||||
assert.equal(syncJson.shared_secret_required, true);
|
||||
|
||||
agent.terminate();
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker dispatches commands to connected agents", async () => {
|
||||
const broker = createBrokerServer({ authMode: "stub", sharedSecret: "secret", commandTimeoutMs: 2000 });
|
||||
const address = await broker.listen(0);
|
||||
@@ -338,11 +399,34 @@ test("broker rejects invalid browser shell upgrades without leaking the token",
|
||||
|
||||
assert.match(response, /^HTTP\/1\.1 401 Unauthorized/m);
|
||||
assert.match(response, /"error_code":"shell_session_expired"/);
|
||||
assert.match(response, /"message":"Shell session could not be validated\."/);
|
||||
assert.doesNotMatch(response, /Shell session expired/);
|
||||
assert.doesNotMatch(response, new RegExp(rawToken));
|
||||
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker rejects websocket upgrade errors without exposing exception text", async () => {
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
validateBrowserStream: async () => {
|
||||
throw new Error("UPSTREAM-SENSITIVE: redis://cache.internal:6379 timeout");
|
||||
},
|
||||
});
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
const response = await rawUpgradeRequest(port, "/ws/browser-gateway-stream?token=session-token");
|
||||
|
||||
assert.match(response, /^HTTP\/1\.1 500 Internal Server Error/m);
|
||||
assert.match(response, /"error_code":"websocket_upgrade_failed"/);
|
||||
assert.match(response, /"message":"WebSocket upgrade failed\."/);
|
||||
assert.doesNotMatch(response, /UPSTREAM-SENSITIVE/);
|
||||
assert.doesNotMatch(response, /redis:\/\/cache\.internal/);
|
||||
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker closes browser shell sessions when the agent never reports shell opened", async () => {
|
||||
const closedSessions = [];
|
||||
const broker = createBrokerServer({
|
||||
@@ -418,6 +502,17 @@ test("broker closes browser shell sessions when the agent disconnects before she
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker defaults to strict auth when no validators are configured", async () => {
|
||||
const broker = createBrokerServer();
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
||||
await waitForCloseOrError(agent);
|
||||
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker sends an agent welcome before connection progress and backlog dispatch", async () => {
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
@@ -671,6 +766,51 @@ test("broker fans out telemetry, task, log, and presence updates to browser gate
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker ingests self-serve machine signals from connected agents", async () => {
|
||||
const machineSignals = [];
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
|
||||
ingestMachineSignal: async (gatewayId, payload) => {
|
||||
machineSignals.push({ gatewayId, payload });
|
||||
return { recorded: true, lane_id: payload.lane_id };
|
||||
},
|
||||
});
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
||||
await new Promise((resolve) => agent.once("open", resolve));
|
||||
|
||||
agent.send(
|
||||
JSON.stringify({
|
||||
type: "MACHINE_SIGNAL",
|
||||
payload: {
|
||||
lane_id: 3,
|
||||
relay_id: "machine-relay",
|
||||
component: "input",
|
||||
channel: 0,
|
||||
event: "input.toggle_on",
|
||||
state: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => machineSignals.length === 1, { description: "machine signal ingestion" });
|
||||
assert.equal(machineSignals[0].gatewayId, "701");
|
||||
assert.deepEqual(machineSignals[0].payload, {
|
||||
lane_id: 3,
|
||||
relay_id: "machine-relay",
|
||||
component: "input",
|
||||
channel: 0,
|
||||
event: "input.toggle_on",
|
||||
state: true,
|
||||
});
|
||||
|
||||
agent.terminate();
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker survives telemetry ingestion failures for stale gateways", async () => {
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
@@ -743,9 +883,16 @@ test("broker still fans out telemetry when manager ingestion fails", async () =>
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
() => browserMessages.some((message) => message.type === "gateway.telemetry" && message.error === "manager unavailable"),
|
||||
() =>
|
||||
browserMessages.some(
|
||||
(message) => message.type === "gateway.telemetry" && message.error === "Telemetry ingestion failed"
|
||||
),
|
||||
{ description: "telemetry fanout after ingest failure" }
|
||||
);
|
||||
assert.ok(
|
||||
browserMessages.every((message) => message.error !== "manager unavailable"),
|
||||
"raw manager errors must not be sent to browser streams"
|
||||
);
|
||||
assert.ok(
|
||||
browserMessages.some(
|
||||
(message) => message.type === "stats.updated" && message.statistics?.system_metrics?.cpu_usage_pct === 31
|
||||
|
||||
@@ -39,7 +39,7 @@ test("traefik does not expose a dedicated public edge broker port", () => {
|
||||
test("base docker compose routes edge broker traffic through traefik", () => {
|
||||
const serviceBlock = readComposeServiceBlock(baseComposeSource, "edge-broker");
|
||||
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
|
||||
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-manager\}/);
|
||||
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-strict\}/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.priority=200/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
|
||||
@@ -55,7 +55,7 @@ test("base docker compose routes edge broker traffic through traefik", () => {
|
||||
test("example docker compose routes edge broker traffic through traefik", () => {
|
||||
const serviceBlock = readComposeServiceBlock(exampleComposeSource, "edge-broker");
|
||||
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
|
||||
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-manager\}/);
|
||||
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-strict\}/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.example\.com`\) && PathPrefix\(`\/edge-broker`\)/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
|
||||
@@ -76,10 +76,10 @@ test("standalone production compose routes edge broker traffic through traefik",
|
||||
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
|
||||
});
|
||||
|
||||
test("php services receive broker websocket environment defaults", () => {
|
||||
test("compose config does not provide insecure broker secret defaults", () => {
|
||||
for (const composeSource of [baseComposeSource, exampleComposeSource]) {
|
||||
assert.match(composeSource, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/);
|
||||
assert.match(composeSource, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev\}/);
|
||||
assert.match(composeSource, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -88,6 +88,6 @@ test("base docker compose wires the broker into each php worker", () => {
|
||||
const serviceBlock = readComposeServiceBlock(baseComposeSource, serviceName);
|
||||
assert.match(serviceBlock, /\n\s+depends_on:\s*\n[\s\S]*?\n\s+- edge-broker/);
|
||||
assert.match(serviceBlock, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/);
|
||||
assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev\}/);
|
||||
assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/);
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -37,8 +37,21 @@ class attachment_store implements minio_uploads_i
|
||||
*/
|
||||
public function isValidFilePath(string $filePath): bool
|
||||
{
|
||||
// Check if the file path is valid
|
||||
return preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $filePath) === 1;
|
||||
if (
|
||||
$filePath === ''
|
||||
|| str_starts_with($filePath, '/')
|
||||
|| preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $filePath) !== 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (explode('/', $filePath) as $segment) {
|
||||
if ($segment === '' || $segment === '.' || $segment === '..') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +95,10 @@ class attachment_store implements minio_uploads_i
|
||||
{
|
||||
$host = 'https://api.truckwash.io';
|
||||
|
||||
$this->requireValidFilePath($fileName);
|
||||
$encodedPath = implode('/', array_map('rawurlencode', explode('/', $fileName)));
|
||||
|
||||
// Generate a direct download URL for the given file name
|
||||
return $host . '/files/' . $fileName;
|
||||
return $host . '/files/' . $encodedPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,8 +133,14 @@ class attachments implements attachments_i
|
||||
protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array
|
||||
{
|
||||
$options = $this->normalizeAttachmentOptions($options);
|
||||
$rawType = trim($type, '`');
|
||||
$objectTypes = array_values(array_unique([
|
||||
$rawType,
|
||||
'`' . $rawType . '`',
|
||||
]));
|
||||
|
||||
return (new object_attachments_o())->getFieldsWhereIn([
|
||||
'object_type' => $type,
|
||||
'object_type' => $objectTypes,
|
||||
'object_id' => $object_ids,
|
||||
'deleted_at' => null
|
||||
], $options);
|
||||
|
||||
@@ -6,6 +6,7 @@ use classes\totp;
|
||||
use Exception;
|
||||
use interfaces\authentication_i;
|
||||
use objects\plate_scanners_o;
|
||||
use objects\subuser_grants_o;
|
||||
use objects\tokens_o;
|
||||
use objects\users_o;
|
||||
use objects\subusers_o;
|
||||
@@ -125,9 +126,13 @@ class authentication implements authentication_i
|
||||
public function validate_token(string $token): bool
|
||||
{
|
||||
// First: try validating as a classic user auth token
|
||||
$dbToken = (new tokens_o())->getToken($token);
|
||||
if ($dbToken && $dbToken->id) {
|
||||
return true;
|
||||
try {
|
||||
$dbToken = (new tokens_o())->getToken($token);
|
||||
if ($dbToken && $dbToken->id && $dbToken->type->value() === 'AUTH_TOKEN') {
|
||||
return true;
|
||||
}
|
||||
} catch (Exception) {
|
||||
// Ignore and continue to subuser session validation
|
||||
}
|
||||
// Fallback: try validating as a subuser session token
|
||||
$subuser = (new subusers_o())->getSubuserBySessionToken($token);
|
||||
@@ -154,19 +159,17 @@ class authentication implements authentication_i
|
||||
// Strip the Bearer prefix
|
||||
$rawToken = str_replace('Bearer ', '', $rawToken);
|
||||
// Get the token from the database
|
||||
$token = (new tokens_o())->getToken($rawToken);
|
||||
try {
|
||||
$token = (new tokens_o())->getToken($rawToken);
|
||||
} catch (Exception) {
|
||||
return false;
|
||||
}
|
||||
// Check if the token exists
|
||||
if (!$token->id) {
|
||||
return false;
|
||||
}
|
||||
if ($token->type->value() === "AUTH_TOKEN_SUBUSER") {
|
||||
// Get the customer number from the headers
|
||||
if (!isset($headers['X-Customer-Number'])) {
|
||||
return false;
|
||||
}
|
||||
$customer_number = (int)$headers['X-Customer-Number'];
|
||||
// Get the user by the customer number
|
||||
return (new users_o())->getUserByCustomerNumber($customer_number);
|
||||
if ($token->type->value() !== 'AUTH_TOKEN') {
|
||||
return false;
|
||||
}
|
||||
// Get the user from the database
|
||||
$user = (new users_o())->getUserById($token->user_id->value());
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class backup_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS backup_records (
|
||||
backup_uuid VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(191) NOT NULL,
|
||||
description TEXT NULL,
|
||||
source VARCHAR(32) NOT NULL DEFAULT 'manual',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
schema_version INT UNSIGNED NOT NULL DEFAULT 2,
|
||||
storage_bucket VARCHAR(191) NOT NULL DEFAULT 'backups',
|
||||
storage_prefix VARCHAR(255) NOT NULL,
|
||||
manifest_key VARCHAR(255) NULL,
|
||||
manifest_sha256 CHAR(64) NULL,
|
||||
encryption_key_id VARCHAR(191) NULL,
|
||||
component_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
object_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
total_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
requested_by_user_id INT NULL,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
verified_at DATETIME NULL,
|
||||
expires_at DATETIME NULL,
|
||||
last_error TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_backup_records_status_created (status, created_at),
|
||||
KEY idx_backup_records_verified (verified_at),
|
||||
KEY idx_backup_records_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS backup_components (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
backup_uuid VARCHAR(64) NOT NULL,
|
||||
component_type VARCHAR(32) NOT NULL,
|
||||
logical_name VARCHAR(191) NOT NULL,
|
||||
source_bucket VARCHAR(191) NULL,
|
||||
source_prefix VARCHAR(255) NULL,
|
||||
storage_key VARCHAR(255) NULL,
|
||||
manifest_key VARCHAR(255) NULL,
|
||||
object_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
byte_size BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
content_sha256 CHAR(64) NULL,
|
||||
encrypted_sha256 CHAR(64) NULL,
|
||||
encryption_key_id VARCHAR(191) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
error_message TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_backup_components_backup (backup_uuid),
|
||||
KEY idx_backup_components_status (status),
|
||||
KEY idx_backup_components_type_name (component_type, logical_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
job_type VARCHAR(32) NOT NULL,
|
||||
backup_uuid VARCHAR(64) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
progress_percent TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
progress_message VARCHAR(255) NULL,
|
||||
payload_json LONGTEXT NULL,
|
||||
result_json LONGTEXT NULL,
|
||||
actor_user_id INT NULL,
|
||||
locked_at DATETIME NULL,
|
||||
lock_owner VARCHAR(191) NULL,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
error_message TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_backup_jobs_status_created (status, created_at),
|
||||
KEY idx_backup_jobs_backup (backup_uuid),
|
||||
KEY idx_backup_jobs_type_status (job_type, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS backup_restore_audit (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
restore_job_id BIGINT UNSIGNED NULL,
|
||||
preview_job_id BIGINT UNSIGNED NULL,
|
||||
backup_uuid VARCHAR(64) NOT NULL,
|
||||
actor_user_id INT NULL,
|
||||
target_environment VARCHAR(64) NOT NULL DEFAULT 'production',
|
||||
confirmation_fingerprint CHAR(64) NULL,
|
||||
reason TEXT NULL,
|
||||
ip_address VARCHAR(64) NULL,
|
||||
user_agent VARCHAR(255) NULL,
|
||||
pre_restore_backup_uuid VARCHAR(64) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
error_message TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_backup_restore_audit_backup (backup_uuid),
|
||||
KEY idx_backup_restore_audit_job (restore_job_id),
|
||||
KEY idx_backup_restore_audit_created (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,11 @@ class coolify_api_client
|
||||
return $this->request('GET', '/services');
|
||||
}
|
||||
|
||||
public function listApplications(): array
|
||||
{
|
||||
return $this->request('GET', '/applications');
|
||||
}
|
||||
|
||||
public function listGithubApps(): array
|
||||
{
|
||||
return $this->request('GET', '/github-apps');
|
||||
@@ -121,6 +126,16 @@ class coolify_api_client
|
||||
]);
|
||||
}
|
||||
|
||||
public function listApplicationEnvs(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/envs');
|
||||
}
|
||||
|
||||
public function deleteApplicationEnv(string $uuid, string $envUuid): array
|
||||
{
|
||||
return $this->request('DELETE', '/applications/' . rawurlencode($uuid) . '/envs/' . rawurlencode($envUuid));
|
||||
}
|
||||
|
||||
private static function bulkEnvData(array $env): array
|
||||
{
|
||||
$data = [];
|
||||
@@ -159,11 +174,26 @@ class coolify_api_client
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
|
||||
}
|
||||
|
||||
public function stopService(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/services/' . rawurlencode($uuid) . '/stop');
|
||||
}
|
||||
|
||||
public function stopApplication(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/stop');
|
||||
}
|
||||
|
||||
public function deleteService(string $uuid): array
|
||||
{
|
||||
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
|
||||
}
|
||||
|
||||
public function deleteApplication(string $uuid): array
|
||||
{
|
||||
return $this->request('DELETE', '/applications/' . rawurlencode($uuid));
|
||||
}
|
||||
|
||||
public function listDeployments(): array
|
||||
{
|
||||
return $this->request('GET', '/deployments');
|
||||
|
||||
@@ -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
|
||||
{
|
||||
$channelSlug = trim((string)($target['channel_slug'] ?? 'gateway'));
|
||||
@@ -1926,13 +2049,14 @@ class coolify_manager
|
||||
], $actorUserId);
|
||||
}
|
||||
|
||||
$deployment = $releaseManager->startDeployment([
|
||||
$sourceCommitSha = trim((string)($sourceTarget['latest_deployment_commit_sha'] ?? ''));
|
||||
$deploymentInput = [
|
||||
'target_id' => (int)($deploymentTarget['id'] ?? 0),
|
||||
'channel_id' => (int)$sourceTarget['channel_id'],
|
||||
'app' => $app,
|
||||
'repository' => (string)($sourceTarget['repository'] ?? ''),
|
||||
'branch' => (string)($sourceTarget['branch'] ?? 'master'),
|
||||
'commit_mode' => 'latest',
|
||||
'commit_mode' => $sourceCommitSha === '' ? 'latest' : 'specific',
|
||||
'version_label' => $this->gatewayRouteProvisionVersionLabel($sourceTarget),
|
||||
'deployed_url' => $sourcePublicUrl,
|
||||
'metadata' => [
|
||||
@@ -1942,7 +2066,11 @@ class coolify_manager
|
||||
'server_uuid' => $serverUuid,
|
||||
'app' => $app,
|
||||
],
|
||||
], $actorUserId);
|
||||
];
|
||||
if ($sourceCommitSha !== '') {
|
||||
$deploymentInput['commit_sha'] = $sourceCommitSha;
|
||||
}
|
||||
$deployment = $releaseManager->startDeployment($deploymentInput, $actorUserId);
|
||||
|
||||
if ((string)($deployment['status'] ?? '') !== 'deployed') {
|
||||
$errors[] = array_replace($action, [
|
||||
@@ -3261,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
|
||||
{
|
||||
unset($config['token']);
|
||||
|
||||
@@ -154,6 +154,12 @@ class coolify_schema_bootstrap
|
||||
self::ensureModuleConfigDefault('Coolify', 'hetzner_cloud_api_token', '', 'string');
|
||||
self::ensureModuleConfigDefault('Coolify', 'public_gateway_host', 'api-v2.truckwash.io', 'string');
|
||||
self::ensureModuleConfigDefault('Coolify', 'public_gateway_probe_path', '', 'string');
|
||||
self::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('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_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
|
||||
public const EXPOSED_HEADERS = 'Server-Timing';
|
||||
public const MAX_AGE_SECONDS = '86400';
|
||||
|
||||
private const REQUIRED_ALLOWED_ORIGINS = [
|
||||
@@ -25,6 +26,9 @@ class cors_policy
|
||||
'https://localhost:4433',
|
||||
'https://twdev.jeppeb.dk',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5174',
|
||||
'http://127.0.0.1:5173',
|
||||
'http://127.0.0.1:5174',
|
||||
];
|
||||
|
||||
public static function normalizeOrigin(?string $value): string
|
||||
@@ -125,7 +129,9 @@ class cors_policy
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
|
||||
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
|
||||
'Access-Control-Expose-Headers' => self::EXPOSED_HEADERS,
|
||||
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
|
||||
'Timing-Allow-Origin' => $origin,
|
||||
'Vary' => 'Origin',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class cron_schedule
|
||||
{
|
||||
public static function normalize(array $schedule): array
|
||||
{
|
||||
$type = strtolower(trim((string)($schedule['type'] ?? 'interval')));
|
||||
if ($type !== 'interval') {
|
||||
throw new InvalidArgumentException('Unsupported cron schedule type: ' . $type);
|
||||
}
|
||||
|
||||
$seconds = (int)($schedule['seconds'] ?? $schedule['interval'] ?? 0);
|
||||
if ($seconds < 30 || $seconds > 2678400) {
|
||||
throw new InvalidArgumentException('Cron interval must be between 30 seconds and 31 days.');
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'interval',
|
||||
'seconds' => $seconds,
|
||||
];
|
||||
}
|
||||
|
||||
public static function nextRunAt(array $schedule, ?string $anchorDateTime, int $now): string
|
||||
{
|
||||
$normalized = self::normalize($schedule);
|
||||
$anchor = $anchorDateTime !== null && trim($anchorDateTime) !== ''
|
||||
? strtotime($anchorDateTime)
|
||||
: false;
|
||||
$base = $anchor !== false ? (int)$anchor : $now;
|
||||
$next = $base + (int)$normalized['seconds'];
|
||||
|
||||
if ($next <= $now) {
|
||||
$missed = (int)floor(($now - $next) / (int)$normalized['seconds']) + 1;
|
||||
$next += $missed * (int)$normalized['seconds'];
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', $next);
|
||||
}
|
||||
|
||||
public static function dueAt(array $schedule, ?string $lastRunAt, int $now, ?int $legacyLastRun = null): string
|
||||
{
|
||||
$normalized = self::normalize($schedule);
|
||||
|
||||
if ($lastRunAt !== null && trim($lastRunAt) !== '') {
|
||||
return self::nextRunAt($normalized, $lastRunAt, $now);
|
||||
}
|
||||
|
||||
if ($legacyLastRun !== null && $legacyLastRun > 0) {
|
||||
return date('Y-m-d H:i:s', $legacyLastRun + (int)$normalized['seconds']);
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i:s', $now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class cron_scheduler
|
||||
{
|
||||
private cron_task_registry $registry;
|
||||
private string $lock_owner;
|
||||
|
||||
public function __construct(?cron_task_registry $registry = null)
|
||||
{
|
||||
$this->registry = $registry ?? new cron_task_registry();
|
||||
$this->lock_owner = gethostname() . ':' . getmypid() . ':' . bin2hex(random_bytes(4));
|
||||
}
|
||||
|
||||
public function listTasks(): array
|
||||
{
|
||||
$this->ensureReady();
|
||||
$this->syncDefinitions();
|
||||
|
||||
$states = $this->stateRows();
|
||||
$estimates = $this->durationEstimates();
|
||||
$tasks = [];
|
||||
$now = time();
|
||||
|
||||
foreach ($this->registry->definitions() as $definition) {
|
||||
$state = $states[$definition->id] ?? [];
|
||||
$schedule = is_array($state['schedule'] ?? null) && $state['schedule'] !== []
|
||||
? $state['schedule']
|
||||
: $definition->schedule;
|
||||
$nextRunAt = $state['next_run_at'] ?? null;
|
||||
if ($nextRunAt === null || trim((string)$nextRunAt) === '') {
|
||||
$nextRunAt = cron_schedule::dueAt($schedule, $state['last_run_at'] ?? null, $now);
|
||||
}
|
||||
|
||||
$task = $definition->asArray($state + ['next_run_at' => $nextRunAt], $estimates[$definition->id] ?? null);
|
||||
$task['due'] = strtotime($nextRunAt) !== false && strtotime($nextRunAt) <= $now;
|
||||
$task['seconds_until_due'] = max(0, (int)strtotime($nextRunAt) - $now);
|
||||
$tasks[] = $task;
|
||||
}
|
||||
|
||||
return [
|
||||
'tasks' => $tasks,
|
||||
'summary' => [
|
||||
'total' => count($tasks),
|
||||
'enabled' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['enabled'])),
|
||||
'due' => count(array_filter($tasks, static fn(array $task): bool => (bool)$task['due'] && (bool)$task['enabled'])),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function listRuns(?string $task_id = null, int $limit = 50): array
|
||||
{
|
||||
$this->ensureReady();
|
||||
$limit = max(1, min(200, $limit));
|
||||
|
||||
$where = '';
|
||||
if ($task_id !== null && trim($task_id) !== '') {
|
||||
$where = "WHERE task_id = " . $this->sql($task_id);
|
||||
}
|
||||
|
||||
return $this->fetchAll(
|
||||
"SELECT * FROM cron_task_runs $where ORDER BY id DESC LIMIT $limit"
|
||||
);
|
||||
}
|
||||
|
||||
public function queueTaskRun(string $task_id_or_legacy_name, ?int $actor_user_id = null, bool $force = false): array
|
||||
{
|
||||
$this->ensureReady();
|
||||
$this->syncDefinitions();
|
||||
|
||||
$definition = $this->registry->get($task_id_or_legacy_name);
|
||||
if ($definition === null) {
|
||||
throw new RuntimeException('Cron task not found.');
|
||||
}
|
||||
|
||||
$state = $this->stateRows()[$definition->id] ?? [];
|
||||
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
|
||||
if (!$enabled && !$force) {
|
||||
throw new RuntimeException('Cron task is disabled.');
|
||||
}
|
||||
if ($this->taskIsLocked($state)) {
|
||||
throw new RuntimeException('Cron task is already running.');
|
||||
}
|
||||
|
||||
$existing = $this->fetchOne(
|
||||
"SELECT * FROM cron_task_runs
|
||||
WHERE task_id = " . $this->sql($definition->id) . " AND status = 'queued'
|
||||
ORDER BY id DESC LIMIT 1"
|
||||
);
|
||||
if ($existing !== null) {
|
||||
$this->markTaskQueued($definition);
|
||||
return $this->publicRun($existing);
|
||||
}
|
||||
|
||||
$scheduled_for = date('Y-m-d H:i:s');
|
||||
$this->query(
|
||||
"INSERT INTO cron_task_runs
|
||||
(task_id, module, source, status, actor_user_id, scheduled_for, force_run)
|
||||
VALUES ("
|
||||
. $this->sql($definition->id) . ', '
|
||||
. $this->sql($definition->module) . ", 'manual', 'queued', "
|
||||
. ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', '
|
||||
. $this->sql($scheduled_for) . ', '
|
||||
. ($force ? '1' : '0')
|
||||
. ")"
|
||||
);
|
||||
|
||||
$run_id = (int)$this->insertId();
|
||||
$this->markTaskQueued($definition);
|
||||
|
||||
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
|
||||
}
|
||||
|
||||
public function runDue(string $source = 'automatic'): array
|
||||
{
|
||||
$this->ensureReady();
|
||||
$this->syncDefinitions();
|
||||
|
||||
$ran = [];
|
||||
foreach ($this->queuedRuns() as $queuedRun) {
|
||||
try {
|
||||
$run = $this->runQueuedRun($queuedRun);
|
||||
if ($run !== null) {
|
||||
$ran[] = $run;
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
$ran[] = [
|
||||
'task_id' => (string)($queuedRun['task_id'] ?? ''),
|
||||
'module' => (string)($queuedRun['module'] ?? ''),
|
||||
'source' => (string)($queuedRun['source'] ?? 'manual'),
|
||||
'status' => 'skipped',
|
||||
'error_message' => $throwable->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$states = $this->stateRows();
|
||||
foreach ($this->registry->definitions() as $definition) {
|
||||
$state = $states[$definition->id] ?? [];
|
||||
if (!(bool)($state['enabled'] ?? $definition->enabled)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$nextRunAt = (string)($state['next_run_at'] ?? '');
|
||||
if ($nextRunAt === '' || strtotime($nextRunAt) === false || strtotime($nextRunAt) > $now) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$ran[] = $this->runTask($definition->id, $source, null, false, $nextRunAt);
|
||||
} catch (Throwable $throwable) {
|
||||
$ran[] = [
|
||||
'task_id' => $definition->id,
|
||||
'module' => $definition->module,
|
||||
'source' => $source,
|
||||
'status' => 'skipped',
|
||||
'error_message' => $throwable->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ran' => $ran,
|
||||
'count' => count($ran),
|
||||
];
|
||||
}
|
||||
|
||||
public function markExpiredRunningRuns(): int
|
||||
{
|
||||
$this->ensureReady();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$message = 'Task lock expired before completion.';
|
||||
|
||||
$this->query(
|
||||
"UPDATE cron_task_runs r
|
||||
INNER JOIN cron_task_state s ON s.task_id = r.task_id AND s.current_run_id = r.id
|
||||
SET r.status = 'timed_out',
|
||||
r.completed_at = COALESCE(s.locked_until, " . $this->sql($now) . "),
|
||||
r.error_message = COALESCE(r.error_message, " . $this->sql($message) . "),
|
||||
s.current_run_id = NULL,
|
||||
s.locked_until = NULL,
|
||||
s.lock_owner = NULL,
|
||||
s.last_status = 'timed_out',
|
||||
s.last_error = " . $this->sql($message) . "
|
||||
WHERE r.status = 'running'
|
||||
AND s.locked_until IS NOT NULL
|
||||
AND s.locked_until < " . $this->sql($now)
|
||||
);
|
||||
|
||||
return $this->affectedRows();
|
||||
}
|
||||
|
||||
public function runTask(
|
||||
string $task_id_or_legacy_name,
|
||||
string $source = 'manual',
|
||||
?int $actor_user_id = null,
|
||||
bool $force = false,
|
||||
?string $scheduled_for = null
|
||||
): array {
|
||||
$this->ensureReady();
|
||||
$this->syncDefinitions();
|
||||
|
||||
$definition = $this->registry->get($task_id_or_legacy_name);
|
||||
if ($definition === null) {
|
||||
throw new RuntimeException('Cron task not found.');
|
||||
}
|
||||
|
||||
$state = $this->stateRows()[$definition->id] ?? [];
|
||||
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
|
||||
if (!$enabled && !$force) {
|
||||
throw new RuntimeException('Cron task is disabled.');
|
||||
}
|
||||
|
||||
if (!$this->claimLock($definition)) {
|
||||
throw new RuntimeException('Cron task is already running.');
|
||||
}
|
||||
|
||||
$started = microtime(true);
|
||||
$started_at = date('Y-m-d H:i:s', (int)$started);
|
||||
$run_id = $this->createRun($definition, $source, $actor_user_id, $scheduled_for, $started_at, $force);
|
||||
$this->query(
|
||||
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
|
||||
return $this->executeClaimedRun($definition, $run_id, $started);
|
||||
}
|
||||
|
||||
private function executeClaimedRun(cron_task_definition $definition, int $run_id, float $started): array
|
||||
{
|
||||
$status = 'succeeded';
|
||||
$summary = [];
|
||||
$error_message = null;
|
||||
$output = '';
|
||||
|
||||
try {
|
||||
if (function_exists('set_time_limit')) {
|
||||
@set_time_limit($definition->timeout_seconds + 30);
|
||||
}
|
||||
|
||||
$this->ensureLegacyFunctionsLoaded($definition);
|
||||
if (!is_callable($definition->handler)) {
|
||||
throw new RuntimeException('Cron task handler is not callable: ' . $definition->handler);
|
||||
}
|
||||
|
||||
ob_start();
|
||||
$result = call_user_func($definition->handler);
|
||||
$output = (string)ob_get_clean();
|
||||
$summary = is_array($result) ? $result : [];
|
||||
} catch (Throwable $throwable) {
|
||||
if (ob_get_level() > 0) {
|
||||
$output .= (string)ob_get_clean();
|
||||
}
|
||||
$status = 'failed';
|
||||
$error_message = $throwable->getMessage();
|
||||
}
|
||||
|
||||
$completed = microtime(true);
|
||||
$duration_ms = (int)round(($completed - $started) * 1000);
|
||||
if ($duration_ms > ($definition->timeout_seconds * 1000) && $status === 'succeeded') {
|
||||
$status = 'timed_out';
|
||||
$error_message = 'Task exceeded its configured timeout window.';
|
||||
}
|
||||
|
||||
if ($output !== '') {
|
||||
$summary['output'] = substr($output, 0, 8000);
|
||||
}
|
||||
|
||||
$completed_at = date('Y-m-d H:i:s', (int)$completed);
|
||||
$this->completeRun($run_id, $status, $completed_at, $duration_ms, $summary, $error_message);
|
||||
$this->releaseLock($definition, $status, $error_message, $completed_at);
|
||||
|
||||
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
|
||||
}
|
||||
|
||||
public function updateTaskConfig(string $task_id, array $config): array
|
||||
{
|
||||
$this->ensureReady();
|
||||
$this->syncDefinitions();
|
||||
|
||||
$definition = $this->registry->get($task_id);
|
||||
if ($definition === null) {
|
||||
throw new RuntimeException('Cron task not found.');
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
if (array_key_exists('enabled', $config)) {
|
||||
$updates[] = 'enabled = ' . ((bool)$config['enabled'] ? '1' : '0');
|
||||
}
|
||||
|
||||
if (array_key_exists('schedule', $config)) {
|
||||
$schedule = $config['schedule'] === null ? null : cron_schedule::normalize((array)$config['schedule']);
|
||||
$updates[] = 'schedule_json = ' . ($schedule === null ? 'NULL' : $this->sql(json_encode($schedule)));
|
||||
$anchor = (string)($this->fetchOne("SELECT last_run_at FROM cron_task_state WHERE task_id = " . $this->sql($definition->id))['last_run_at'] ?? '');
|
||||
$updates[] = 'next_run_at = ' . $this->sql(cron_schedule::dueAt($schedule ?? $definition->schedule, $anchor !== '' ? $anchor : null, time()));
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
$this->query(
|
||||
"UPDATE cron_task_state SET " . implode(', ', $updates) . " WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
}
|
||||
|
||||
return $this->listTasks();
|
||||
}
|
||||
|
||||
private function ensureReady(): void
|
||||
{
|
||||
cron_schema_bootstrap::ensureTables();
|
||||
}
|
||||
|
||||
private function syncDefinitions(): void
|
||||
{
|
||||
$now = time();
|
||||
foreach ($this->registry->definitions() as $definition) {
|
||||
$row = $this->fetchOne(
|
||||
"SELECT * FROM cron_task_state WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
if ($row !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$legacyLastRun = $this->legacyLastRun($definition);
|
||||
$nextRunAt = cron_schedule::dueAt($definition->schedule, null, $now, $legacyLastRun);
|
||||
$this->query(
|
||||
"INSERT INTO cron_task_state (task_id, module, enabled, schedule_json, next_run_at)
|
||||
VALUES ("
|
||||
. $this->sql($definition->id) . ', '
|
||||
. $this->sql($definition->module) . ', '
|
||||
. ($definition->enabled ? '1' : '0') . ', NULL, '
|
||||
. $this->sql($nextRunAt)
|
||||
. ")"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function legacyLastRun(cron_task_definition $definition): ?int
|
||||
{
|
||||
if ($definition->legacy_name === null || !defined('redis')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$last_run = redis->get_last_crond_run($definition->legacy_name);
|
||||
return $last_run !== null ? (int)$last_run : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function claimLock(cron_task_definition $definition): bool
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$locked_until = date('Y-m-d H:i:s', time() + $definition->timeout_seconds + 60);
|
||||
$this->query(
|
||||
"UPDATE cron_task_state
|
||||
SET locked_until = " . $this->sql($locked_until) . ",
|
||||
lock_owner = " . $this->sql($this->lock_owner) . "
|
||||
WHERE task_id = " . $this->sql($definition->id) . "
|
||||
AND (locked_until IS NULL OR locked_until < " . $this->sql($now) . ")"
|
||||
);
|
||||
|
||||
return $this->affectedRows() === 1;
|
||||
}
|
||||
|
||||
private function taskIsLocked(array $state): bool
|
||||
{
|
||||
$lockedUntil = (string)($state['locked_until'] ?? '');
|
||||
return $lockedUntil !== ''
|
||||
&& strtotime($lockedUntil) !== false
|
||||
&& strtotime($lockedUntil) >= time();
|
||||
}
|
||||
|
||||
private function markTaskQueued(cron_task_definition $definition): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$this->query(
|
||||
"UPDATE cron_task_state
|
||||
SET last_status = 'queued',
|
||||
last_error = NULL,
|
||||
next_run_at = CASE
|
||||
WHEN next_run_at IS NULL OR next_run_at > " . $this->sql($now) . " THEN " . $this->sql($now) . "
|
||||
ELSE next_run_at
|
||||
END
|
||||
WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function queuedRuns(): array
|
||||
{
|
||||
return $this->fetchAll("SELECT * FROM cron_task_runs WHERE status = 'queued' ORDER BY id ASC LIMIT 50");
|
||||
}
|
||||
|
||||
private function runQueuedRun(array $queuedRun): ?array
|
||||
{
|
||||
$run_id = (int)($queuedRun['id'] ?? 0);
|
||||
$definition = $this->registry->get((string)($queuedRun['task_id'] ?? ''));
|
||||
if ($run_id < 1 || $definition === null) {
|
||||
if ($run_id > 0) {
|
||||
$this->skipQueuedRun($run_id, 'Cron task not found.');
|
||||
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$force = (bool)($queuedRun['force_run'] ?? false);
|
||||
$state = $this->stateRows()[$definition->id] ?? [];
|
||||
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
|
||||
if (!$enabled && !$force) {
|
||||
$this->skipQueuedRun($run_id, 'Cron task is disabled.', $definition);
|
||||
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
|
||||
}
|
||||
|
||||
if (!$this->claimLock($definition)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$started = microtime(true);
|
||||
$started_at = date('Y-m-d H:i:s', (int)$started);
|
||||
$this->query(
|
||||
"UPDATE cron_task_runs
|
||||
SET status = 'running',
|
||||
started_at = " . $this->sql($started_at) . ",
|
||||
lock_owner = " . $this->sql($this->lock_owner) . "
|
||||
WHERE id = $run_id AND status = 'queued'"
|
||||
);
|
||||
|
||||
if ($this->affectedRows() !== 1) {
|
||||
$this->clearClaimedLock($definition);
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->query(
|
||||
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
|
||||
return $this->executeClaimedRun($definition, $run_id, $started);
|
||||
}
|
||||
|
||||
private function skipQueuedRun(int $run_id, string $message, ?cron_task_definition $definition = null): void
|
||||
{
|
||||
$completed_at = date('Y-m-d H:i:s');
|
||||
$this->query(
|
||||
"UPDATE cron_task_runs
|
||||
SET status = 'skipped',
|
||||
completed_at = " . $this->sql($completed_at) . ",
|
||||
duration_ms = 0,
|
||||
error_message = " . $this->sql($message) . "
|
||||
WHERE id = $run_id AND status = 'queued'"
|
||||
);
|
||||
$updated = $this->affectedRows() === 1;
|
||||
if (!$updated || $definition === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->query(
|
||||
"UPDATE cron_task_state
|
||||
SET last_status = 'skipped',
|
||||
last_error = " . $this->sql($message) . "
|
||||
WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
}
|
||||
|
||||
private function clearClaimedLock(cron_task_definition $definition): void
|
||||
{
|
||||
$this->query(
|
||||
"UPDATE cron_task_state
|
||||
SET locked_until = NULL,
|
||||
lock_owner = NULL
|
||||
WHERE task_id = " . $this->sql($definition->id) . "
|
||||
AND lock_owner = " . $this->sql($this->lock_owner)
|
||||
);
|
||||
}
|
||||
|
||||
private function releaseLock(cron_task_definition $definition, string $status, ?string $error_message, string $completed_at): void
|
||||
{
|
||||
$state = $this->fetchOne("SELECT schedule_json FROM cron_task_state WHERE task_id = " . $this->sql($definition->id));
|
||||
$schedule = $this->decodeJson($state['schedule_json'] ?? null);
|
||||
if ($schedule === []) {
|
||||
$schedule = $definition->schedule;
|
||||
}
|
||||
|
||||
$nextRunAt = cron_schedule::nextRunAt($schedule, $completed_at, time());
|
||||
if ($status !== 'succeeded') {
|
||||
$retrySeconds = min(300, max(60, (int)$schedule['seconds']));
|
||||
$nextRunAt = date('Y-m-d H:i:s', time() + $retrySeconds);
|
||||
}
|
||||
|
||||
$this->query(
|
||||
"UPDATE cron_task_state
|
||||
SET last_run_at = " . $this->sql($completed_at) . ",
|
||||
next_run_at = " . $this->sql($nextRunAt) . ",
|
||||
locked_until = NULL,
|
||||
lock_owner = NULL,
|
||||
current_run_id = NULL,
|
||||
last_status = " . $this->sql($status) . ",
|
||||
last_error = " . $this->nullableSql($error_message) . "
|
||||
WHERE task_id = " . $this->sql($definition->id) . "
|
||||
AND lock_owner = " . $this->sql($this->lock_owner)
|
||||
);
|
||||
|
||||
if ($definition->legacy_name !== null && defined('redis')) {
|
||||
try {
|
||||
redis->set_last_crond_run($definition->legacy_name, time());
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function createRun(
|
||||
cron_task_definition $definition,
|
||||
string $source,
|
||||
?int $actor_user_id,
|
||||
?string $scheduled_for,
|
||||
string $started_at,
|
||||
bool $force = false
|
||||
): int {
|
||||
$this->query(
|
||||
"INSERT INTO cron_task_runs
|
||||
(task_id, module, source, status, actor_user_id, scheduled_for, started_at, lock_owner, force_run)
|
||||
VALUES ("
|
||||
. $this->sql($definition->id) . ', '
|
||||
. $this->sql($definition->module) . ', '
|
||||
. $this->sql($source) . ", 'running', "
|
||||
. ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', '
|
||||
. $this->nullableSql($scheduled_for) . ', '
|
||||
. $this->sql($started_at) . ', '
|
||||
. $this->sql($this->lock_owner) . ', '
|
||||
. ($force ? '1' : '0')
|
||||
. ")"
|
||||
);
|
||||
|
||||
return $this->insertId();
|
||||
}
|
||||
|
||||
private function completeRun(
|
||||
int $run_id,
|
||||
string $status,
|
||||
string $completed_at,
|
||||
int $duration_ms,
|
||||
array $summary,
|
||||
?string $error_message
|
||||
): void {
|
||||
$this->query(
|
||||
"UPDATE cron_task_runs
|
||||
SET status = " . $this->sql($status) . ",
|
||||
completed_at = " . $this->sql($completed_at) . ",
|
||||
duration_ms = " . (string)$duration_ms . ",
|
||||
summary_json = " . $this->sql(json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) . ",
|
||||
error_message = " . $this->nullableSql($error_message) . "
|
||||
WHERE id = " . (string)$run_id
|
||||
);
|
||||
}
|
||||
|
||||
private function ensureLegacyFunctionsLoaded(cron_task_definition $definition): void
|
||||
{
|
||||
if (function_exists($definition->handler)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!defined('WD')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY')) {
|
||||
define('CRON_LOAD_LEGACY_FUNCTIONS_ONLY', true);
|
||||
}
|
||||
|
||||
require_once WD . '/cron/Cron.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function stateRows(): array
|
||||
{
|
||||
$rows = $this->fetchAll("SELECT * FROM cron_task_state");
|
||||
$states = [];
|
||||
foreach ($rows as $row) {
|
||||
$row['enabled'] = (bool)$row['enabled'];
|
||||
$row['schedule'] = $this->decodeJson($row['schedule_json'] ?? null);
|
||||
$states[(string)$row['task_id']] = $row;
|
||||
}
|
||||
return $states;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function durationEstimates(): array
|
||||
{
|
||||
$rows = $this->fetchAll(
|
||||
"SELECT task_id, AVG(duration_ms) AS avg_duration_ms
|
||||
FROM (
|
||||
SELECT task_id, duration_ms
|
||||
FROM cron_task_runs
|
||||
WHERE status = 'succeeded' AND duration_ms IS NOT NULL
|
||||
ORDER BY id DESC
|
||||
LIMIT 500
|
||||
) recent_runs
|
||||
GROUP BY task_id"
|
||||
);
|
||||
|
||||
$estimates = [];
|
||||
foreach ($rows as $row) {
|
||||
$estimates[(string)$row['task_id']] = (int)round((float)$row['avg_duration_ms']);
|
||||
}
|
||||
return $estimates;
|
||||
}
|
||||
|
||||
private function decodeJson(mixed $json): array
|
||||
{
|
||||
if (!is_string($json) || trim($json) === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($json, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function publicRun(array $run): array
|
||||
{
|
||||
if ($run === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$run['force_run'] = (bool)($run['force_run'] ?? false);
|
||||
$run['summary'] = $this->decodeJson($run['summary_json'] ?? null);
|
||||
return $run;
|
||||
}
|
||||
|
||||
private function fetchOne(string $sql): ?array
|
||||
{
|
||||
$rows = $this->fetchAll($sql);
|
||||
return $rows[0] ?? null;
|
||||
}
|
||||
|
||||
private function fetchAll(string $sql): array
|
||||
{
|
||||
$result = $this->query($sql);
|
||||
if ($result === false || $result === true) {
|
||||
return [];
|
||||
}
|
||||
return $result->fetch_all(MYSQLI_ASSOC);
|
||||
}
|
||||
|
||||
private function query(string $sql): \mysqli_result|bool
|
||||
{
|
||||
global $db;
|
||||
return $db->query($sql);
|
||||
}
|
||||
|
||||
private function sql(string $value): string
|
||||
{
|
||||
global $db;
|
||||
return "'" . $db->escape_string($value) . "'";
|
||||
}
|
||||
|
||||
private function nullableSql(?string $value): string
|
||||
{
|
||||
return $value === null ? 'NULL' : $this->sql($value);
|
||||
}
|
||||
|
||||
private function affectedRows(): int
|
||||
{
|
||||
global $db;
|
||||
return (int)$db->conn()->affected_rows;
|
||||
}
|
||||
|
||||
private function insertId(): int
|
||||
{
|
||||
global $db;
|
||||
return (int)$db->insert_id();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class cron_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS cron_task_state (
|
||||
task_id VARCHAR(191) NOT NULL PRIMARY KEY,
|
||||
module VARCHAR(64) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
schedule_json LONGTEXT NULL,
|
||||
last_run_at DATETIME NULL,
|
||||
next_run_at DATETIME NULL,
|
||||
locked_until DATETIME NULL,
|
||||
lock_owner VARCHAR(191) NULL,
|
||||
current_run_id BIGINT UNSIGNED NULL,
|
||||
last_status VARCHAR(32) NULL,
|
||||
last_error TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_cron_task_state_next_run (enabled, next_run_at),
|
||||
KEY idx_cron_task_state_lock (locked_until)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS cron_task_runs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
task_id VARCHAR(191) NOT NULL,
|
||||
module VARCHAR(64) NOT NULL,
|
||||
source VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'running',
|
||||
actor_user_id INT NULL,
|
||||
scheduled_for DATETIME NULL,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
duration_ms INT UNSIGNED NULL,
|
||||
summary_json LONGTEXT NULL,
|
||||
error_message TEXT NULL,
|
||||
lock_owner VARCHAR(191) NULL,
|
||||
force_run TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_cron_task_runs_task_created (task_id, created_at),
|
||||
KEY idx_cron_task_runs_status_created (status, created_at),
|
||||
KEY idx_cron_task_runs_module_created (module, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
self::ensureColumn('cron_task_runs', 'force_run', 'TINYINT(1) NOT NULL DEFAULT 0 AFTER lock_owner');
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS cron_worker_state (
|
||||
worker_id VARCHAR(191) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(191) NOT NULL,
|
||||
hostname VARCHAR(191) NULL,
|
||||
pid INT UNSIGNED NULL,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'coolify_worker',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'starting',
|
||||
release_channel_id BIGINT UNSIGNED NULL,
|
||||
release_target_id BIGINT UNSIGNED NULL,
|
||||
coolify_resource_uuid VARCHAR(128) NULL,
|
||||
coolify_resource_type VARCHAR(32) NULL,
|
||||
commit_sha VARCHAR(64) NULL,
|
||||
poll_seconds INT UNSIGNED NOT NULL DEFAULT 15,
|
||||
last_run_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_stale_run_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_error TEXT NULL,
|
||||
started_at DATETIME NULL,
|
||||
last_heartbeat_at DATETIME NULL,
|
||||
last_loop_started_at DATETIME NULL,
|
||||
last_loop_finished_at DATETIME NULL,
|
||||
stopped_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_cron_worker_state_heartbeat (last_heartbeat_at),
|
||||
KEY idx_cron_worker_state_status (status),
|
||||
KEY idx_cron_worker_state_release_target (release_target_id),
|
||||
KEY idx_cron_worker_state_coolify_resource (coolify_resource_uuid)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||
if ($table === '' || $column === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
||||
if ($result && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class cron_task_definition
|
||||
{
|
||||
public string $id;
|
||||
public string $name;
|
||||
public string $description;
|
||||
public string $module;
|
||||
public string $handler;
|
||||
public array $schedule;
|
||||
public bool $enabled;
|
||||
public int $timeout_seconds;
|
||||
public int $estimated_duration_ms;
|
||||
public int $priority;
|
||||
public ?string $legacy_name;
|
||||
|
||||
public function __construct(array $definition)
|
||||
{
|
||||
$this->id = self::requiredString($definition, 'id');
|
||||
$this->name = self::requiredString($definition, 'name');
|
||||
$this->description = (string)($definition['description'] ?? '');
|
||||
$this->module = self::requiredString($definition, 'module');
|
||||
$this->handler = self::requiredString($definition, 'handler');
|
||||
$this->schedule = cron_schedule::normalize($definition['schedule'] ?? []);
|
||||
$this->enabled = (bool)($definition['enabled'] ?? true);
|
||||
$this->timeout_seconds = max(30, (int)($definition['timeout_seconds'] ?? 600));
|
||||
$this->estimated_duration_ms = max(0, (int)($definition['estimated_duration_ms'] ?? 0));
|
||||
$this->priority = (int)($definition['priority'] ?? 100);
|
||||
$legacy_name = trim((string)($definition['legacy_name'] ?? ''));
|
||||
$this->legacy_name = $legacy_name !== '' ? $legacy_name : null;
|
||||
|
||||
if (!preg_match('/^[a-z0-9][a-z0-9_.-]{1,190}$/', $this->id)) {
|
||||
throw new InvalidArgumentException('Invalid cron task id: ' . $this->id);
|
||||
}
|
||||
if (!preg_match('/^[a-z0-9][a-z0-9_-]{1,63}$/', $this->module)) {
|
||||
throw new InvalidArgumentException('Invalid cron task module: ' . $this->module);
|
||||
}
|
||||
}
|
||||
|
||||
public function asArray(?array $state = null, ?int $estimatedDurationMs = null): array
|
||||
{
|
||||
$schedule = is_array($state['schedule'] ?? null) && ($state['schedule'] ?? []) !== []
|
||||
? $state['schedule']
|
||||
: $this->schedule;
|
||||
$enabled = array_key_exists('enabled', $state ?? [])
|
||||
? (bool)$state['enabled']
|
||||
: $this->enabled;
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'module' => $this->module,
|
||||
'handler' => $this->handler,
|
||||
'schedule' => $schedule,
|
||||
'default_schedule' => $this->schedule,
|
||||
'enabled' => $enabled,
|
||||
'default_enabled' => $this->enabled,
|
||||
'timeout_seconds' => $this->timeout_seconds,
|
||||
'estimated_duration_ms' => $estimatedDurationMs ?? $this->estimated_duration_ms,
|
||||
'priority' => $this->priority,
|
||||
'legacy_name' => $this->legacy_name,
|
||||
'last_run_at' => $state['last_run_at'] ?? null,
|
||||
'next_run_at' => $state['next_run_at'] ?? null,
|
||||
'locked_until' => $state['locked_until'] ?? null,
|
||||
'lock_owner' => $state['lock_owner'] ?? null,
|
||||
'current_run_id' => $state['current_run_id'] ?? null,
|
||||
'last_status' => $state['last_status'] ?? null,
|
||||
'last_error' => $state['last_error'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private static function requiredString(array $definition, string $key): string
|
||||
{
|
||||
$value = trim((string)($definition[$key] ?? ''));
|
||||
if ($value === '') {
|
||||
throw new InvalidArgumentException('Missing cron task definition field: ' . $key);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class cron_task_registry
|
||||
{
|
||||
private string $modules_root;
|
||||
|
||||
/** @var array<string, cron_task_definition>|null */
|
||||
private ?array $definitions = null;
|
||||
|
||||
public function __construct(?string $modules_root = null)
|
||||
{
|
||||
$this->modules_root = $modules_root ?? (defined('WD') ? WD . '/modules' : dirname(__DIR__) . '/modules');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, cron_task_definition>
|
||||
*/
|
||||
public function definitions(): array
|
||||
{
|
||||
if ($this->definitions !== null) {
|
||||
return $this->definitions;
|
||||
}
|
||||
|
||||
$definitions = [];
|
||||
foreach ($this->definitionFiles() as $file) {
|
||||
$module_definitions = require $file;
|
||||
if (!is_array($module_definitions)) {
|
||||
throw new InvalidArgumentException('Cron definition file must return an array: ' . $file);
|
||||
}
|
||||
|
||||
foreach ($module_definitions as $definition) {
|
||||
$task = new cron_task_definition($definition);
|
||||
if (isset($definitions[$task->id])) {
|
||||
throw new InvalidArgumentException('Duplicate cron task id: ' . $task->id);
|
||||
}
|
||||
$definitions[$task->id] = $task;
|
||||
}
|
||||
}
|
||||
|
||||
uasort($definitions, static function (cron_task_definition $left, cron_task_definition $right): int {
|
||||
if ($left->priority !== $right->priority) {
|
||||
return $left->priority <=> $right->priority;
|
||||
}
|
||||
return strcmp($left->id, $right->id);
|
||||
});
|
||||
|
||||
$this->definitions = $definitions;
|
||||
return $definitions;
|
||||
}
|
||||
|
||||
public function get(string $id_or_legacy_name): ?cron_task_definition
|
||||
{
|
||||
$normalized = trim($id_or_legacy_name);
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$definitions = $this->definitions();
|
||||
if (isset($definitions[$normalized])) {
|
||||
return $definitions[$normalized];
|
||||
}
|
||||
|
||||
foreach ($definitions as $definition) {
|
||||
if ($definition->legacy_name !== null && hash_equals($definition->legacy_name, $normalized)) {
|
||||
return $definition;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function definitionFiles(): array
|
||||
{
|
||||
$files = glob($this->modules_root . '/*/cron/tasks.php') ?: [];
|
||||
sort($files, SORT_STRING);
|
||||
return $files;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Throwable;
|
||||
|
||||
class cron_worker
|
||||
{
|
||||
private cron_scheduler $scheduler;
|
||||
private string $worker_id;
|
||||
private string $name;
|
||||
private string $source;
|
||||
private int $poll_seconds;
|
||||
private int $heartbeat_seconds;
|
||||
private int $max_runtime_seconds;
|
||||
private bool $should_stop = false;
|
||||
private int $last_heartbeat = 0;
|
||||
|
||||
public function __construct(?cron_scheduler $scheduler = null, array $options = [])
|
||||
{
|
||||
$this->scheduler = $scheduler ?? new cron_scheduler();
|
||||
$this->name = $this->stringOption($options, 'name', 'CRON_WORKER_NAME', 'cron-worker');
|
||||
$this->worker_id = $this->stringOption($options, 'worker_id', 'CRON_WORKER_ID', $this->name);
|
||||
$this->source = $this->stringOption($options, 'source', 'CRON_WORKER_SOURCE', 'coolify_worker');
|
||||
$this->poll_seconds = $this->intOption($options, 'poll_seconds', 'CRON_WORKER_POLL_SECONDS', 15, 1, 300);
|
||||
$this->heartbeat_seconds = $this->intOption($options, 'heartbeat_seconds', 'CRON_WORKER_HEARTBEAT_SECONDS', 30, 5, 300);
|
||||
$this->max_runtime_seconds = $this->intOption($options, 'max_runtime_seconds', 'CRON_WORKER_MAX_RUNTIME_SECONDS', 0, 0, 86400);
|
||||
}
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
if (!$this->boolOption('CRON_WORKER_ENABLED', true)) {
|
||||
$this->heartbeat('disabled', 0, 0, null, true);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->registerSignalHandlers();
|
||||
$started = time();
|
||||
$this->heartbeat('starting', 0, 0, null, true);
|
||||
|
||||
while (!$this->should_stop) {
|
||||
$result = $this->tick();
|
||||
$this->writeStatusLine($result);
|
||||
|
||||
if ($this->max_runtime_seconds > 0 && time() - $started >= $this->max_runtime_seconds) {
|
||||
$this->should_stop = true;
|
||||
break;
|
||||
}
|
||||
|
||||
$this->sleepUntilNextPoll();
|
||||
}
|
||||
|
||||
$this->heartbeat('stopped', 0, 0, null, true, true);
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function tick(): array
|
||||
{
|
||||
$this->heartbeat('running');
|
||||
$loopStartedAt = date('Y-m-d H:i:s');
|
||||
$staleRuns = 0;
|
||||
$ran = ['count' => 0, 'ran' => []];
|
||||
$error = null;
|
||||
$status = 'running';
|
||||
|
||||
try {
|
||||
$staleRuns = $this->scheduler->markExpiredRunningRuns();
|
||||
$ran = $this->scheduler->runDue($this->source);
|
||||
} catch (Throwable $throwable) {
|
||||
$status = 'failed';
|
||||
$error = $throwable->getMessage();
|
||||
}
|
||||
|
||||
$this->heartbeat($status, (int)($ran['count'] ?? 0), $staleRuns, $error, true, false, $loopStartedAt);
|
||||
|
||||
return [
|
||||
'worker_id' => $this->worker_id,
|
||||
'status' => $status,
|
||||
'ran' => (int)($ran['count'] ?? 0),
|
||||
'stale_runs' => $staleRuns,
|
||||
'error' => $error,
|
||||
];
|
||||
}
|
||||
|
||||
public function listWorkers(): array
|
||||
{
|
||||
cron_schema_bootstrap::ensureTables();
|
||||
$rows = $this->fetchAll('SELECT * FROM cron_worker_state ORDER BY last_heartbeat_at DESC, worker_id');
|
||||
$workers = [];
|
||||
foreach ($rows as $row) {
|
||||
$workers[] = $this->publicWorker($row);
|
||||
}
|
||||
|
||||
return [
|
||||
'workers' => $workers,
|
||||
'summary' => [
|
||||
'total' => count($workers),
|
||||
'running' => count(array_filter($workers, static fn(array $worker): bool => ($worker['status'] ?? '') === 'running')),
|
||||
'stale' => count(array_filter($workers, static fn(array $worker): bool => (bool)($worker['stale'] ?? false))),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function registerSignalHandlers(): void
|
||||
{
|
||||
if (!function_exists('pcntl_signal')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (function_exists('pcntl_async_signals')) {
|
||||
pcntl_async_signals(true);
|
||||
}
|
||||
|
||||
pcntl_signal(SIGTERM, function (): void {
|
||||
$this->should_stop = true;
|
||||
});
|
||||
pcntl_signal(SIGINT, function (): void {
|
||||
$this->should_stop = true;
|
||||
});
|
||||
}
|
||||
|
||||
private function sleepUntilNextPoll(): void
|
||||
{
|
||||
$remaining = $this->poll_seconds;
|
||||
while ($remaining > 0 && !$this->should_stop) {
|
||||
$sleep = min(1, $remaining);
|
||||
sleep($sleep);
|
||||
$remaining -= $sleep;
|
||||
if (time() - $this->last_heartbeat >= $this->heartbeat_seconds) {
|
||||
$this->heartbeat('running');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function heartbeat(
|
||||
string $status,
|
||||
int $runCount = 0,
|
||||
int $staleRunCount = 0,
|
||||
?string $error = null,
|
||||
bool $force = false,
|
||||
bool $stopped = false,
|
||||
?string $loopStartedAt = null
|
||||
): void {
|
||||
if (!$force && time() - $this->last_heartbeat < $this->heartbeat_seconds) {
|
||||
return;
|
||||
}
|
||||
|
||||
cron_schema_bootstrap::ensureTables();
|
||||
$this->last_heartbeat = time();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$workerId = $this->sql($this->worker_id);
|
||||
$name = $this->sql($this->name);
|
||||
$hostname = $this->nullableSql(gethostname() ?: null);
|
||||
$pid = getmypid() ?: 0;
|
||||
$source = $this->sql($this->source);
|
||||
$statusSql = $this->sql($status);
|
||||
$releaseChannelId = $this->nullableInt($this->env('CRON_WORKER_RELEASE_CHANNEL_ID'));
|
||||
$releaseTargetId = $this->nullableInt($this->env('CRON_WORKER_RELEASE_TARGET_ID'));
|
||||
$resourceUuid = $this->nullableSql($this->env('COOLIFY_RESOURCE_UUID') ?: $this->env('CRON_WORKER_COOLIFY_RESOURCE_UUID'));
|
||||
$resourceType = $this->nullableSql($this->env('COOLIFY_RESOURCE_TYPE') ?: $this->env('CRON_WORKER_COOLIFY_RESOURCE_TYPE') ?: 'application');
|
||||
$commitSha = $this->nullableSql($this->commitSha());
|
||||
$errorSql = $this->nullableSql($error);
|
||||
$loopStarted = $this->nullableSql($loopStartedAt);
|
||||
$stoppedAt = $stopped ? $this->sql($now) : 'NULL';
|
||||
|
||||
$this->query(
|
||||
"INSERT INTO cron_worker_state (
|
||||
worker_id, name, hostname, pid, source, status, release_channel_id, release_target_id,
|
||||
coolify_resource_uuid, coolify_resource_type, commit_sha, poll_seconds, last_run_count,
|
||||
last_stale_run_count, last_error, started_at, last_heartbeat_at, last_loop_started_at,
|
||||
last_loop_finished_at, stopped_at
|
||||
) VALUES (
|
||||
$workerId, $name, $hostname, $pid, $source, $statusSql, $releaseChannelId, $releaseTargetId,
|
||||
$resourceUuid, $resourceType, $commitSha, $this->poll_seconds, $runCount,
|
||||
$staleRunCount, $errorSql, $this->sql($now), $this->sql($now), $loopStarted,
|
||||
$this->sql($now), $stoppedAt
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
hostname = VALUES(hostname),
|
||||
pid = VALUES(pid),
|
||||
source = VALUES(source),
|
||||
status = VALUES(status),
|
||||
release_channel_id = VALUES(release_channel_id),
|
||||
release_target_id = VALUES(release_target_id),
|
||||
coolify_resource_uuid = VALUES(coolify_resource_uuid),
|
||||
coolify_resource_type = VALUES(coolify_resource_type),
|
||||
commit_sha = VALUES(commit_sha),
|
||||
poll_seconds = VALUES(poll_seconds),
|
||||
last_run_count = VALUES(last_run_count),
|
||||
last_stale_run_count = VALUES(last_stale_run_count),
|
||||
last_error = VALUES(last_error),
|
||||
last_heartbeat_at = VALUES(last_heartbeat_at),
|
||||
last_loop_started_at = COALESCE(VALUES(last_loop_started_at), last_loop_started_at),
|
||||
last_loop_finished_at = VALUES(last_loop_finished_at),
|
||||
stopped_at = VALUES(stopped_at)"
|
||||
);
|
||||
}
|
||||
|
||||
private function publicWorker(array $row): array
|
||||
{
|
||||
$heartbeatAt = (string)($row['last_heartbeat_at'] ?? '');
|
||||
$heartbeatTs = strtotime($heartbeatAt);
|
||||
$threshold = max(60, ((int)($row['poll_seconds'] ?? 15) * 4) + 30);
|
||||
$age = $heartbeatTs !== false ? max(0, time() - $heartbeatTs) : null;
|
||||
|
||||
return [
|
||||
'worker_id' => (string)($row['worker_id'] ?? ''),
|
||||
'name' => (string)($row['name'] ?? ''),
|
||||
'hostname' => $row['hostname'] ?? null,
|
||||
'pid' => isset($row['pid']) ? (int)$row['pid'] : null,
|
||||
'source' => (string)($row['source'] ?? ''),
|
||||
'status' => (string)($row['status'] ?? 'unknown'),
|
||||
'release_channel_id' => isset($row['release_channel_id']) ? (int)$row['release_channel_id'] : null,
|
||||
'release_target_id' => isset($row['release_target_id']) ? (int)$row['release_target_id'] : null,
|
||||
'coolify_resource_uuid' => $row['coolify_resource_uuid'] ?? null,
|
||||
'coolify_resource_type' => $row['coolify_resource_type'] ?? null,
|
||||
'commit_sha' => $row['commit_sha'] ?? null,
|
||||
'poll_seconds' => (int)($row['poll_seconds'] ?? 0),
|
||||
'last_run_count' => (int)($row['last_run_count'] ?? 0),
|
||||
'last_stale_run_count' => (int)($row['last_stale_run_count'] ?? 0),
|
||||
'last_error' => $row['last_error'] ?? null,
|
||||
'started_at' => $row['started_at'] ?? null,
|
||||
'last_heartbeat_at' => $heartbeatAt !== '' ? $heartbeatAt : null,
|
||||
'last_heartbeat_age_seconds' => $age,
|
||||
'last_loop_started_at' => $row['last_loop_started_at'] ?? null,
|
||||
'last_loop_finished_at' => $row['last_loop_finished_at'] ?? null,
|
||||
'stopped_at' => $row['stopped_at'] ?? null,
|
||||
'stale' => $age === null || $age > $threshold,
|
||||
'stale_after_seconds' => $threshold,
|
||||
];
|
||||
}
|
||||
|
||||
private function writeStatusLine(array $result): void
|
||||
{
|
||||
echo '[' . date('Y-m-d H:i:s') . '][CRON_WORKER] '
|
||||
. json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
. PHP_EOL;
|
||||
}
|
||||
|
||||
private function stringOption(array $options, string $key, string $env, string $default): string
|
||||
{
|
||||
$value = trim((string)($options[$key] ?? $this->env($env) ?? ''));
|
||||
return $value !== '' ? $value : $default;
|
||||
}
|
||||
|
||||
private function intOption(array $options, string $key, string $env, int $default, int $min, int $max): int
|
||||
{
|
||||
$value = (int)($options[$key] ?? $this->env($env) ?? $default);
|
||||
return max($min, min($max, $value));
|
||||
}
|
||||
|
||||
private function boolOption(string $env, bool $default): bool
|
||||
{
|
||||
$value = $this->env($env);
|
||||
if ($value === null || trim($value) === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private function commitSha(): string
|
||||
{
|
||||
foreach (['CRON_WORKER_COMMIT_SHA', 'API_COMMIT_SHA', 'RELEASE_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA'] as $key) {
|
||||
$value = trim((string)($this->env($key) ?? ''));
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function env(string $key): ?string
|
||||
{
|
||||
$value = getenv($key);
|
||||
if ($value !== false) {
|
||||
return (string)$value;
|
||||
}
|
||||
|
||||
return isset($_SERVER[$key]) ? (string)$_SERVER[$key] : null;
|
||||
}
|
||||
|
||||
private function nullableInt(?string $value): string
|
||||
{
|
||||
$value = trim((string)$value);
|
||||
if ($value === '' || filter_var($value, FILTER_VALIDATE_INT) === false) {
|
||||
return 'NULL';
|
||||
}
|
||||
|
||||
return (string)max(0, (int)$value);
|
||||
}
|
||||
|
||||
private function nullableSql(?string $value): string
|
||||
{
|
||||
$value = $value !== null ? trim($value) : '';
|
||||
return $value === '' ? 'NULL' : $this->sql($value);
|
||||
}
|
||||
|
||||
private function fetchAll(string $sql): array
|
||||
{
|
||||
$result = $this->query($sql);
|
||||
if ($result === false || $result === true) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $result->fetch_all(MYSQLI_ASSOC);
|
||||
}
|
||||
|
||||
private function query(string $sql): \mysqli_result|bool
|
||||
{
|
||||
global $db;
|
||||
return $db->query($sql);
|
||||
}
|
||||
|
||||
private function sql(string $value): string
|
||||
{
|
||||
global $db;
|
||||
return "'" . $db->escape_string($value) . "'";
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,10 @@ class customer_mass_import_service
|
||||
if ($cvrLength < 8 || $cvrLength > 20) {
|
||||
throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400);
|
||||
}
|
||||
|
||||
if ($normalized['ean'] !== null && strlen((string)$normalized['ean']) > 13) {
|
||||
throw new \RuntimeException('EAN must be at most 13 digits.', 400);
|
||||
}
|
||||
}
|
||||
|
||||
protected function normalizePositiveInt(mixed $value): ?int
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class customer_order_product_policy
|
||||
{
|
||||
public static function assertOrderAllowsProduct(int $orderId, int $productId): void
|
||||
{
|
||||
$message = self::orderProductViolationMessage($orderId, $productId);
|
||||
if ($message !== null) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
|
||||
{
|
||||
$customerNumber = self::loadOrderCustomerNumber($orderId);
|
||||
if ($customerNumber === null) {
|
||||
return null;
|
||||
}
|
||||
$violation = (new customer_rule_product_restriction_service())
|
||||
->violationForCustomerProduct($customerNumber, $productId);
|
||||
return $violation === null ? null : (string)$violation['message'];
|
||||
}
|
||||
|
||||
private static function loadOrderCustomerNumber(int $orderId): ?int
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($orderId < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = $db->query("SELECT customer_id FROM orders WHERE id = {$orderId} LIMIT 1");
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
$customerNumber = (int)($row['customer_id'] ?? 0);
|
||||
return $customerNumber > 0 ? $customerNumber : null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use objects\orders_o;
|
||||
|
||||
class customer_product_rule_service
|
||||
{
|
||||
public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer';
|
||||
|
||||
/**
|
||||
* @return array{rule:string,rules:list<string>,collections:list<int>,product_id:int,code:string,message:string}|null
|
||||
*/
|
||||
public function firstViolationForOrderItem(int $orderId, int $productId, ?int $relatedItemId): ?array
|
||||
{
|
||||
$order = (new orders_o())->getOrderById($orderId);
|
||||
if (!$order->exists()) {
|
||||
return null;
|
||||
}
|
||||
$violation = (new customer_rule_product_restriction_service())->violationForCustomerProduct(
|
||||
(int)$order->customer_id->value(),
|
||||
$productId
|
||||
);
|
||||
if ($violation === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Keep the singular key during the API migration for existing invoice
|
||||
// and logging consumers while also returning every matching rule.
|
||||
return ['rule' => (string)$violation['rules'][0]] + $violation;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Additive schema and the one-time legacy-to-exact-product migration for
|
||||
* customer-rule product restrictions.
|
||||
*/
|
||||
class customer_rule_product_restriction_schema_bootstrap
|
||||
{
|
||||
public const LEGACY_SEED_KEY = 'legacy_exact_product_sets_v1';
|
||||
|
||||
private static bool $initialized = false;
|
||||
|
||||
/** @var array<string, string> */
|
||||
private const RULES = [
|
||||
'restrictAdditionalServices' => 'Additional services',
|
||||
'restrictTankCleaning' => 'Tank cleaning',
|
||||
'restrictSpotFree' => 'SpotFree',
|
||||
'restrictInteriorCleaning' => 'Interior cleaning',
|
||||
'onlyTankCleaning' => 'Non-tank products',
|
||||
];
|
||||
|
||||
public static function ensureSchema(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::createTables($db);
|
||||
self::deduplicateCustomerAttributes($db);
|
||||
self::seedLegacyProductSets($db);
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function createTables(object $db): void
|
||||
{
|
||||
$statements = [
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_restrictions (
|
||||
attribute VARCHAR(191) NOT NULL,
|
||||
version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (attribute)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_collections (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
attribute VARCHAR(191) NOT NULL,
|
||||
name VARCHAR(191) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_customer_rule_collection_name (attribute, name),
|
||||
KEY idx_customer_rule_collection_attribute_order (attribute, sort_order, id),
|
||||
CONSTRAINT fk_customer_rule_collection_attribute
|
||||
FOREIGN KEY (attribute) REFERENCES customer_rule_product_restrictions(attribute)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_collection_products (
|
||||
collection_id INT UNSIGNED NOT NULL,
|
||||
product_id INT UNSIGNED NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (collection_id, product_id),
|
||||
KEY idx_customer_rule_collection_product (product_id, collection_id),
|
||||
CONSTRAINT fk_customer_rule_collection_product_collection
|
||||
FOREIGN KEY (collection_id) REFERENCES customer_rule_product_collections(id)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_migrations (
|
||||
migration_key VARCHAR(191) NOT NULL,
|
||||
details_json LONGTEXT NULL,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (migration_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_audit_logs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
actor_user_id INT UNSIGNED NULL,
|
||||
attribute VARCHAR(191) NOT NULL,
|
||||
old_version INT UNSIGNED NOT NULL,
|
||||
new_version INT UNSIGNED NOT NULL,
|
||||
changes_json LONGTEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_customer_rule_product_audit_attribute (attribute, created_at),
|
||||
KEY idx_customer_rule_product_audit_actor (actor_user_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($statements as $statement) {
|
||||
if ($db->query($statement) === false) {
|
||||
throw new RuntimeException('Unable to initialize customer-rule product restriction schema');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function deduplicateCustomerAttributes(object $db): void
|
||||
{
|
||||
if (!self::tableExists($db, 'customer_attributes')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (self::indexExists($db, 'customer_attributes', 'uniq_customer_attributes_user_attribute')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($db->query(
|
||||
'DELETE duplicate_row FROM customer_attributes duplicate_row
|
||||
INNER JOIN customer_attributes keep_row
|
||||
ON keep_row.user_id = duplicate_row.user_id
|
||||
AND keep_row.attribute = duplicate_row.attribute
|
||||
AND keep_row.id < duplicate_row.id'
|
||||
) === false) {
|
||||
throw new RuntimeException('Unable to deduplicate customer attributes');
|
||||
}
|
||||
|
||||
if ($db->query(
|
||||
'ALTER TABLE customer_attributes
|
||||
ADD UNIQUE KEY uniq_customer_attributes_user_attribute (user_id, attribute)'
|
||||
) === false) {
|
||||
throw new RuntimeException('Unable to enforce unique customer attributes');
|
||||
}
|
||||
}
|
||||
|
||||
private static function seedLegacyProductSets(object $db): void
|
||||
{
|
||||
if (!self::tableExists($db, 'products') || !self::tableExists($db, 'categories')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$migrationKey = self::escape($db, self::LEGACY_SEED_KEY);
|
||||
$existing = $db->query(
|
||||
"SELECT migration_key FROM customer_rule_product_migrations WHERE migration_key = '{$migrationKey}' LIMIT 1"
|
||||
);
|
||||
if ($existing && (int)$existing->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($db->query('START TRANSACTION') === false) {
|
||||
throw new RuntimeException('Unable to start customer-rule product migration');
|
||||
}
|
||||
try {
|
||||
if ($db->query(
|
||||
"INSERT IGNORE INTO customer_rule_product_migrations (migration_key, details_json)
|
||||
VALUES ('{$migrationKey}', '{\"status\":\"in_progress\"}')"
|
||||
) === false) {
|
||||
throw new RuntimeException('Unable to claim customer-rule product migration');
|
||||
}
|
||||
if (self::affectedRows($db) === 0) {
|
||||
$db->query('ROLLBACK');
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (array_keys(self::RULES) as $attribute) {
|
||||
$safeAttribute = self::escape($db, $attribute);
|
||||
if ($db->query(
|
||||
"INSERT IGNORE INTO customer_rule_product_restrictions (attribute, version)
|
||||
VALUES ('{$safeAttribute}', 1)"
|
||||
) === false) {
|
||||
throw new RuntimeException("Unable to initialize restriction {$attribute}");
|
||||
}
|
||||
}
|
||||
|
||||
$counts = [];
|
||||
$seededProductIds = [];
|
||||
foreach (self::RULES as $attribute => $collectionName) {
|
||||
$safeAttribute = self::escape($db, $attribute);
|
||||
$safeName = self::escape($db, 'Legacy migration: ' . $collectionName);
|
||||
if ($db->query(
|
||||
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
|
||||
VALUES ('{$safeAttribute}', '{$safeName}', 0)"
|
||||
) === false) {
|
||||
throw new RuntimeException("Unable to create seed collection for {$attribute}");
|
||||
}
|
||||
$collectionId = (int)$db->insert_id();
|
||||
if ($collectionId < 1) {
|
||||
throw new RuntimeException("Unable to create seed collection for {$attribute}");
|
||||
}
|
||||
|
||||
$predicate = self::legacyPredicate($db, $attribute);
|
||||
$activePredicate = self::columnExists($db, 'products', 'deleted_at')
|
||||
? 'p.deleted_at IS NULL'
|
||||
: '1 = 1';
|
||||
$insert = $db->query(
|
||||
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
|
||||
SELECT {$collectionId}, p.id
|
||||
FROM products p
|
||||
LEFT JOIN categories c ON c.id = p.category
|
||||
WHERE ({$activePredicate}) AND ({$predicate})"
|
||||
);
|
||||
if ($insert === false) {
|
||||
throw new RuntimeException("Unable to seed products for {$attribute}");
|
||||
}
|
||||
$counts[$attribute] = self::affectedRows($db);
|
||||
$seeded = $db->query(
|
||||
"SELECT product_id FROM customer_rule_product_collection_products
|
||||
WHERE collection_id = {$collectionId} ORDER BY product_id"
|
||||
);
|
||||
$seededProductIds[$attribute] = [];
|
||||
if ($seeded) {
|
||||
while ($row = $seeded->fetch_assoc()) {
|
||||
$seededProductIds[$attribute][] = (int)$row['product_id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$details = self::escape($db, (string)json_encode([
|
||||
'counts' => $counts,
|
||||
'product_ids' => $seededProductIds,
|
||||
'seeded_at' => gmdate(DATE_ATOM),
|
||||
], JSON_UNESCAPED_SLASHES));
|
||||
if ($db->query(
|
||||
"UPDATE customer_rule_product_migrations
|
||||
SET details_json = '{$details}', applied_at = NOW()
|
||||
WHERE migration_key = '{$migrationKey}'"
|
||||
) === false) {
|
||||
throw new RuntimeException('Unable to record customer-rule product migration');
|
||||
}
|
||||
if ($db->query('COMMIT') === false) {
|
||||
throw new RuntimeException('Unable to commit customer-rule product migration');
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
$db->query('ROLLBACK');
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
private static function legacyPredicate(object $db, string $attribute): string
|
||||
{
|
||||
$text = "LOWER(CONCAT(COALESCE(p.name, ''), ' ', COALESCE(c.name, '')))";
|
||||
|
||||
return match ($attribute) {
|
||||
'restrictAdditionalServices' => "p.category = 8 OR LOWER(COALESCE(c.name, '')) IN ('tillægsydelser', 'tillaegsydelser')" .
|
||||
(self::tableExists($db, 'products_options') && self::columnExists($db, 'products_options', 'option_id')
|
||||
? ' OR EXISTS (SELECT 1 FROM products_options po WHERE po.option_id = p.id)'
|
||||
: ''),
|
||||
'restrictTankCleaning' => "p.category = 5 OR {$text} LIKE '%tank cleaning%' OR {$text} LIKE '%tankcleaning%' OR {$text} LIKE '%tankrens%' OR {$text} LIKE '%tank rens%'",
|
||||
'restrictSpotFree' => "p.id IN (23, 24) OR {$text} LIKE '%spot free%' OR {$text} LIKE '%spotfree%' OR {$text} LIKE '%skylning med ro%'",
|
||||
'restrictInteriorCleaning' => "{$text} LIKE '%interior%' OR {$text} LIKE '%indvendig%'",
|
||||
'onlyTankCleaning' => "NOT (p.category = 5 OR {$text} LIKE '%tank cleaning%' OR {$text} LIKE '%tankcleaning%' OR {$text} LIKE '%tankrens%' OR {$text} LIKE '%tank rens%')",
|
||||
default => '0 = 1',
|
||||
};
|
||||
}
|
||||
|
||||
private static function tableExists(object $db, string $table): bool
|
||||
{
|
||||
$safeTable = self::escape($db, $table);
|
||||
$result = $db->query("SHOW TABLES LIKE '{$safeTable}'");
|
||||
return $result && (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function indexExists(object $db, string $table, string $index): bool
|
||||
{
|
||||
$safeTable = str_replace('`', '', $table);
|
||||
$safeIndex = self::escape($db, $index);
|
||||
$result = $db->query("SHOW INDEX FROM `{$safeTable}` WHERE Key_name = '{$safeIndex}'");
|
||||
return $result && (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function columnExists(object $db, string $table, string $column): bool
|
||||
{
|
||||
$safeTable = str_replace('`', '', $table);
|
||||
$safeColumn = self::escape($db, $column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
|
||||
return $result && (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function escape(object $db, string $value): string
|
||||
{
|
||||
return method_exists($db, 'escape_string')
|
||||
? $db->escape_string($value)
|
||||
: addslashes($value);
|
||||
}
|
||||
|
||||
private static function affectedRows(object $db): int
|
||||
{
|
||||
if (method_exists($db, 'conn')) {
|
||||
$connection = $db->conn();
|
||||
return (int)($connection->affected_rows ?? 0);
|
||||
}
|
||||
return (int)($db->affected_rows ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class customer_rule_product_restriction_exception extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, private readonly int $httpStatus = 422, string $code = 'INVALID_CUSTOMER_RULE_CONFIGURATION')
|
||||
{
|
||||
parent::__construct($message);
|
||||
$this->restrictionCode = $code;
|
||||
}
|
||||
|
||||
private string $restrictionCode;
|
||||
|
||||
public function httpStatus(): int
|
||||
{
|
||||
return $this->httpStatus;
|
||||
}
|
||||
|
||||
public function restrictionCode(): string
|
||||
{
|
||||
return $this->restrictionCode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Source of truth for globally configured customer-rule product collections.
|
||||
*/
|
||||
class customer_rule_product_restriction_service
|
||||
{
|
||||
/** @var list<string> */
|
||||
public const PRODUCT_IMPACT_ATTRIBUTES = [
|
||||
'restrictAdditionalServices',
|
||||
'restrictTankCleaning',
|
||||
'restrictSpotFree',
|
||||
'restrictInteriorCleaning',
|
||||
'onlyTankCleaning',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
public const SUPPORTED_ATTRIBUTES = [
|
||||
'restrictAdditionalServices',
|
||||
'restrictTankCleaning',
|
||||
'restrictSpotFree',
|
||||
'restrictInteriorCleaning',
|
||||
'onlyTankCleaning',
|
||||
'requiresReferenceNumber',
|
||||
'requiresRegistrationNumbersInvoice',
|
||||
'invoiceAllOrdersIndividually',
|
||||
'invoiceWithStripe',
|
||||
'showPricesOnBookingPage',
|
||||
'usePONumbers',
|
||||
'exemptFromAdministrationFee',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
|
||||
}
|
||||
|
||||
/** @return array{rules:list<array<string,mixed>>,products:list<array<string,mixed>>} */
|
||||
public function listConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'rules' => array_map(fn(string $attribute): array => $this->ruleConfiguration($attribute), self::PRODUCT_IMPACT_ATTRIBUTES),
|
||||
'products' => $this->productCatalog(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function ruleConfiguration(string $attribute): array
|
||||
{
|
||||
$this->assertSupportedAttribute($attribute);
|
||||
global $db;
|
||||
|
||||
$safeAttribute = $this->escape($attribute);
|
||||
$versionResult = $db->query(
|
||||
"SELECT version FROM customer_rule_product_restrictions WHERE attribute = '{$safeAttribute}' LIMIT 1"
|
||||
);
|
||||
if (!$versionResult || $versionResult->num_rows < 1) {
|
||||
throw new RuntimeException("Unable to load customer-rule restriction version for {$attribute}");
|
||||
}
|
||||
$versionRow = $versionResult->fetch_assoc();
|
||||
|
||||
$result = $db->query(
|
||||
"SELECT c.id AS collection_id, c.name, c.sort_order, cp.product_id
|
||||
FROM customer_rule_product_collections c
|
||||
LEFT JOIN customer_rule_product_collection_products cp ON cp.collection_id = c.id
|
||||
WHERE c.attribute = '{$safeAttribute}'
|
||||
ORDER BY c.sort_order ASC, c.id ASC, cp.product_id ASC"
|
||||
);
|
||||
|
||||
if (!$result) {
|
||||
throw new RuntimeException("Unable to load customer-rule restriction collections for {$attribute}");
|
||||
}
|
||||
|
||||
$collections = [];
|
||||
$disabled = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$collectionId = (int)$row['collection_id'];
|
||||
if (!isset($collections[$collectionId])) {
|
||||
$collections[$collectionId] = [
|
||||
'id' => $collectionId,
|
||||
'name' => (string)$row['name'],
|
||||
'sort_order' => (int)$row['sort_order'],
|
||||
'product_ids' => [],
|
||||
];
|
||||
}
|
||||
if ($row['product_id'] !== null) {
|
||||
$productId = (int)$row['product_id'];
|
||||
$collections[$collectionId]['product_ids'][] = $productId;
|
||||
$disabled[$productId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'attribute' => $attribute,
|
||||
'version' => max(1, (int)($versionRow['version'] ?? 1)),
|
||||
'collections' => array_values($collections),
|
||||
'disabled_product_ids' => array_map('intval', array_keys($disabled)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function replaceRuleConfiguration(string $attribute, array $payload, int $actorUserId): array
|
||||
{
|
||||
$this->assertSupportedAttribute($attribute);
|
||||
$expectedVersion = $this->positiveInt($payload['version'] ?? null, 'version');
|
||||
$collections = $this->validateCollections($attribute, $payload['collections'] ?? null);
|
||||
|
||||
global $db;
|
||||
$safeAttribute = $this->escape($attribute);
|
||||
if ($db->query('START TRANSACTION') === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to start configuration transaction', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
try {
|
||||
$versionResult = $db->query(
|
||||
"SELECT version FROM customer_rule_product_restrictions
|
||||
WHERE attribute = '{$safeAttribute}' FOR UPDATE"
|
||||
);
|
||||
if (!$versionResult || $versionResult->num_rows < 1) {
|
||||
throw new customer_rule_product_restriction_exception('Customer rule configuration was not found', 404, 'CUSTOMER_RULE_CONFIGURATION_NOT_FOUND');
|
||||
}
|
||||
$versionRow = $versionResult->fetch_assoc();
|
||||
$currentVersion = (int)$versionRow['version'];
|
||||
if ($currentVersion !== $expectedVersion) {
|
||||
throw new customer_rule_product_restriction_exception(
|
||||
'Customer rule configuration has changed; reload before saving',
|
||||
409,
|
||||
'CUSTOMER_RULE_CONFIGURATION_CONFLICT'
|
||||
);
|
||||
}
|
||||
|
||||
$old = $this->ruleConfiguration($attribute);
|
||||
$existingIds = $this->existingCollectionIds($attribute);
|
||||
foreach ($collections as $collection) {
|
||||
if ($collection['id'] !== null && !isset($existingIds[$collection['id']])) {
|
||||
throw new customer_rule_product_restriction_exception('A collection does not belong to this customer rule');
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid temporary unique-name collisions while two collections swap names.
|
||||
foreach ($existingIds as $collectionId => $_) {
|
||||
$temporaryName = $this->escape('__pending_' . $collectionId . '_' . bin2hex(random_bytes(6)));
|
||||
if ($db->query(
|
||||
"UPDATE customer_rule_product_collections
|
||||
SET name = '{$temporaryName}'
|
||||
WHERE id = {$collectionId} AND attribute = '{$safeAttribute}'"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to prepare collection update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
$keptIds = [];
|
||||
foreach ($collections as $collection) {
|
||||
$name = $this->escape($collection['name']);
|
||||
$sortOrder = (int)$collection['sort_order'];
|
||||
$collectionId = $collection['id'];
|
||||
if ($collectionId === null) {
|
||||
if ($db->query(
|
||||
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
|
||||
VALUES ('{$safeAttribute}', '{$name}', {$sortOrder})"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to create collection');
|
||||
}
|
||||
$collectionId = (int)$db->insert_id();
|
||||
} else {
|
||||
if ($db->query(
|
||||
"UPDATE customer_rule_product_collections
|
||||
SET name = '{$name}', sort_order = {$sortOrder}
|
||||
WHERE id = {$collectionId} AND attribute = '{$safeAttribute}'"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to update collection');
|
||||
}
|
||||
}
|
||||
|
||||
$keptIds[$collectionId] = true;
|
||||
if ($db->query("DELETE FROM customer_rule_product_collection_products WHERE collection_id = {$collectionId}") === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to replace collection products', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
foreach ($collection['product_ids'] as $productId) {
|
||||
if ($db->query(
|
||||
"INSERT INTO customer_rule_product_collection_products (collection_id, product_id)
|
||||
VALUES ({$collectionId}, {$productId})"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to save collection products');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$removeIds = array_values(array_diff(array_keys($existingIds), array_keys($keptIds)));
|
||||
if ($removeIds !== []) {
|
||||
if ($db->query(
|
||||
'DELETE FROM customer_rule_product_collections WHERE attribute = \'' . $safeAttribute . '\' AND id IN (' .
|
||||
implode(',', array_map('intval', $removeIds)) . ')'
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to remove collections', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
$newVersion = $currentVersion + 1;
|
||||
if ($db->query(
|
||||
"UPDATE customer_rule_product_restrictions
|
||||
SET version = {$newVersion}, updated_at = NOW()
|
||||
WHERE attribute = '{$safeAttribute}'"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to update configuration version', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
$new = $this->ruleConfiguration($attribute);
|
||||
$changes = $this->escape((string)json_encode([
|
||||
'before' => $old,
|
||||
'after' => $new,
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
if ($db->query(
|
||||
"INSERT INTO customer_rule_product_audit_logs
|
||||
(actor_user_id, attribute, old_version, new_version, changes_json)
|
||||
VALUES ({$actorUserId}, '{$safeAttribute}', {$currentVersion}, {$newVersion}, '{$changes}')"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to audit configuration update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
if ($db->query('COMMIT') === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to commit configuration update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
return $new;
|
||||
} catch (Throwable $throwable) {
|
||||
$db->query('ROLLBACK');
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return configured product restrictions for all active product-impact
|
||||
* attributes belonging to any account with the customer number.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function restrictionsForCustomerNumber(int $customerNumber): array
|
||||
{
|
||||
if ($customerNumber < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
"SELECT DISTINCT ca.attribute
|
||||
FROM users u
|
||||
INNER JOIN customer_attributes ca ON ca.user_id = u.id
|
||||
WHERE u.customer_number = {$customerNumber}"
|
||||
);
|
||||
if (!$result) {
|
||||
throw new RuntimeException('Unable to load active customer-rule product restrictions');
|
||||
}
|
||||
$activeAttributes = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$attribute = (string)$row['attribute'];
|
||||
if (in_array($attribute, self::PRODUCT_IMPACT_ATTRIBUTES, true)) {
|
||||
$activeAttributes[$attribute] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$active = [];
|
||||
foreach (self::PRODUCT_IMPACT_ATTRIBUTES as $attribute) {
|
||||
if (isset($activeAttributes[$attribute])) {
|
||||
$active[] = $this->ruleConfiguration($attribute);
|
||||
}
|
||||
}
|
||||
return $active;
|
||||
}
|
||||
|
||||
/** @return array{rules:list<string>,collections:list<int>,message:string,code:string,product_id:int}|null */
|
||||
public function violationForCustomerProduct(int $customerNumber, int $productId): ?array
|
||||
{
|
||||
if ($productId < 1) {
|
||||
return null;
|
||||
}
|
||||
$rules = [];
|
||||
$collections = [];
|
||||
foreach ($this->restrictionsForCustomerNumber($customerNumber) as $restriction) {
|
||||
if (!in_array($productId, $restriction['disabled_product_ids'], true)) {
|
||||
continue;
|
||||
}
|
||||
$rules[] = (string)$restriction['attribute'];
|
||||
foreach ($restriction['collections'] as $collection) {
|
||||
if (in_array($productId, $collection['product_ids'], true)) {
|
||||
$collections[] = (int)$collection['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($rules === []) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'code' => 'CUSTOMER_RULE_PRODUCT_RESTRICTED',
|
||||
'message' => customer_product_rule_service::BLOCK_MESSAGE,
|
||||
'product_id' => $productId,
|
||||
'rules' => array_values(array_unique($rules)),
|
||||
'collections' => array_values(array_unique($collections)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $attributes
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function enrichAttributes(int $customerNumber, array $attributes): array
|
||||
{
|
||||
$restrictions = [];
|
||||
foreach ($this->restrictionsForCustomerNumber($customerNumber) as $restriction) {
|
||||
$restrictions[(string)$restriction['attribute']] = [
|
||||
'attribute' => (string)$restriction['attribute'],
|
||||
'version' => (int)$restriction['version'],
|
||||
'collections' => $restriction['collections'],
|
||||
'disabled_product_ids' => $restriction['disabled_product_ids'],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($attributes as &$attribute) {
|
||||
$key = (string)($attribute['attribute'] ?? '');
|
||||
$attribute['product_restriction'] = $restrictions[$key] ?? null;
|
||||
}
|
||||
unset($attribute);
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function productCatalog(): array
|
||||
{
|
||||
global $db;
|
||||
$activeExpression = $this->columnExists('products', 'deleted_at')
|
||||
? 'CASE WHEN p.deleted_at IS NULL THEN 1 ELSE 0 END'
|
||||
: '1';
|
||||
$result = $db->query(
|
||||
"SELECT p.id, p.name, p.category AS category_id, c.name AS category_name,
|
||||
{$activeExpression} AS active
|
||||
FROM products p
|
||||
LEFT JOIN categories c ON c.id = p.category
|
||||
ORDER BY c.name ASC, p.name ASC, p.id ASC"
|
||||
);
|
||||
if (!$result) {
|
||||
throw new RuntimeException('Unable to load the customer-rule product catalog');
|
||||
}
|
||||
$products = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$products[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'name' => (string)$row['name'],
|
||||
'category_id' => (int)$row['category_id'],
|
||||
'category_name' => (string)($row['category_name'] ?? ''),
|
||||
'active' => (bool)$row['active'],
|
||||
];
|
||||
}
|
||||
return $products;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,true>
|
||||
*/
|
||||
private function existingCollectionIds(string $attribute): array
|
||||
{
|
||||
global $db;
|
||||
$safeAttribute = $this->escape($attribute);
|
||||
$result = $db->query("SELECT id FROM customer_rule_product_collections WHERE attribute = '{$safeAttribute}'");
|
||||
if (!$result) {
|
||||
throw new RuntimeException("Unable to load existing collections for {$attribute}");
|
||||
}
|
||||
$ids = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$ids[(int)$row['id']] = true;
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/** @return list<array{id:?int,name:string,sort_order:int,product_ids:list<int>}> */
|
||||
private function validateCollections(string $attribute, mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
throw new customer_rule_product_restriction_exception('collections must be an array');
|
||||
}
|
||||
$normalized = [];
|
||||
$names = [];
|
||||
$collectionIds = [];
|
||||
$allProductIds = [];
|
||||
foreach (array_values($value) as $index => $collection) {
|
||||
if (!is_array($collection)) {
|
||||
throw new customer_rule_product_restriction_exception("Collection {$index} must be an object");
|
||||
}
|
||||
$name = trim((string)($collection['name'] ?? ''));
|
||||
if ($name === '' || mb_strlen($name) > 191) {
|
||||
throw new customer_rule_product_restriction_exception('Collection names must be between 1 and 191 characters');
|
||||
}
|
||||
$nameKey = mb_strtolower($name);
|
||||
if (isset($names[$nameKey])) {
|
||||
throw new customer_rule_product_restriction_exception('Collection names must be unique within a rule');
|
||||
}
|
||||
$names[$nameKey] = true;
|
||||
if (!isset($collection['product_ids']) || !is_array($collection['product_ids'])) {
|
||||
throw new customer_rule_product_restriction_exception('product_ids must be an array');
|
||||
}
|
||||
$productIds = [];
|
||||
foreach ($collection['product_ids'] as $productId) {
|
||||
$id = $this->positiveInt($productId, 'product_id');
|
||||
$productIds[$id] = true;
|
||||
$allProductIds[$id] = true;
|
||||
}
|
||||
$id = isset($collection['id']) && $collection['id'] !== null
|
||||
? $this->positiveInt($collection['id'], 'collection id')
|
||||
: null;
|
||||
if ($id !== null && isset($collectionIds[$id])) {
|
||||
throw new customer_rule_product_restriction_exception('Collection IDs must be unique within a rule');
|
||||
}
|
||||
if ($id !== null) {
|
||||
$collectionIds[$id] = true;
|
||||
}
|
||||
$normalized[] = [
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'sort_order' => isset($collection['sort_order']) && is_numeric($collection['sort_order'])
|
||||
? (int)$collection['sort_order']
|
||||
: $index,
|
||||
'product_ids' => array_map('intval', array_keys($productIds)),
|
||||
];
|
||||
}
|
||||
|
||||
$this->assertProductsExist(array_map('intval', array_keys($allProductIds)));
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/** @param list<int> $productIds */
|
||||
private function assertProductsExist(array $productIds): void
|
||||
{
|
||||
if ($productIds === []) {
|
||||
return;
|
||||
}
|
||||
global $db;
|
||||
$result = $db->query('SELECT id FROM products WHERE id IN (' . implode(',', $productIds) . ')');
|
||||
if (!$result) {
|
||||
throw new customer_rule_product_restriction_exception(
|
||||
'Unable to validate collection products',
|
||||
500,
|
||||
'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED'
|
||||
);
|
||||
}
|
||||
$found = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$found[(int)$row['id']] = true;
|
||||
}
|
||||
$missing = array_values(array_diff($productIds, array_keys($found)));
|
||||
if ($missing !== []) {
|
||||
throw new customer_rule_product_restriction_exception('Unknown product IDs: ' . implode(', ', $missing));
|
||||
}
|
||||
}
|
||||
|
||||
private function assertSupportedAttribute(string $attribute): void
|
||||
{
|
||||
if (!in_array($attribute, self::PRODUCT_IMPACT_ATTRIBUTES, true)) {
|
||||
throw new customer_rule_product_restriction_exception('Unsupported product-impact customer rule');
|
||||
}
|
||||
}
|
||||
|
||||
private function positiveInt(mixed $value, string $field): int
|
||||
{
|
||||
if (!is_numeric($value) || (int)$value < 1 || (string)(int)$value !== trim((string)$value)) {
|
||||
throw new customer_rule_product_restriction_exception("{$field} must be a positive integer");
|
||||
}
|
||||
return (int)$value;
|
||||
}
|
||||
|
||||
private function escape(string $value): string
|
||||
{
|
||||
global $db;
|
||||
return method_exists($db, 'escape_string') ? $db->escape_string($value) : addslashes($value);
|
||||
}
|
||||
|
||||
private function columnExists(string $table, string $column): bool
|
||||
{
|
||||
global $db;
|
||||
$safeTable = str_replace('`', '', $table);
|
||||
$safeColumn = $this->escape($column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
|
||||
return $result && (int)$result->num_rows > 0;
|
||||
}
|
||||
}
|
||||
@@ -177,15 +177,19 @@ class db
|
||||
return $this->database;
|
||||
}
|
||||
|
||||
public function getPort(): int
|
||||
{
|
||||
return $this->port;
|
||||
}
|
||||
|
||||
public function getSslMode(): string
|
||||
{
|
||||
return $this->ssl_mode;
|
||||
}
|
||||
|
||||
public function backupDatabase(string $path): bool
|
||||
{
|
||||
// Save the database to the path
|
||||
// Build a safe mysqldump command with configurable SSL (MariaDB-compatible flags)
|
||||
$mode = strtoupper(trim($this->ssl_mode));
|
||||
// Map ssl_mode to MariaDB client flags
|
||||
// DISABLED => --skip-ssl (no TLS)
|
||||
// PREFERRED => (no flag; client decides)
|
||||
// REQUIRED/VERIFY_* => --ssl (enable TLS without strict verification unless CA materials provided)
|
||||
$sslFlag = '';
|
||||
switch ($mode) {
|
||||
case 'DISABLED':
|
||||
@@ -201,17 +205,42 @@ class db
|
||||
$sslFlag = '--ssl';
|
||||
break;
|
||||
}
|
||||
|
||||
$host = escapeshellarg($this->host);
|
||||
$user = escapeshellarg($this->user);
|
||||
$pass = escapeshellarg($this->password);
|
||||
$db = escapeshellarg($this->database);
|
||||
$port = (int)$this->port;
|
||||
$outfile = escapeshellarg($path);
|
||||
$sslPart = $sslFlag !== '' ? ($sslFlag . ' ') : '';
|
||||
$command = "mysqldump {$sslPart}-h $host -P $port -u $user --password=$pass $db > $outfile 2>&1";
|
||||
exec($command, $output, $return);
|
||||
// Check if the command was successful
|
||||
return $return === 0;
|
||||
$command = "mysqldump {$sslPart}--single-transaction --quick --routines --triggers --events --hex-blob -h $host -P $port -u $user $db";
|
||||
|
||||
$directory = dirname($path);
|
||||
if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$environment = array_merge(getenv() ?: [], $_ENV);
|
||||
$environment['MYSQL_PWD'] = $this->password;
|
||||
$descriptors = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['file', $path, 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
|
||||
$process = proc_open($command, $descriptors, $pipes, null, $environment);
|
||||
if (!is_resource($process)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
fclose($pipes[0]);
|
||||
$stderr = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[2]);
|
||||
$return = proc_close($process);
|
||||
|
||||
if ($return !== 0 && is_string($stderr) && $stderr !== '') {
|
||||
@file_put_contents($path . '.error.log', $stderr);
|
||||
}
|
||||
|
||||
return $return === 0 && is_file($path) && filesize($path) !== false;
|
||||
}
|
||||
|
||||
public function getView(string $view): array
|
||||
@@ -238,4 +267,4 @@ class db
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Ensures additive schema for department-scoped customer price overrides.
|
||||
*/
|
||||
class department_customer_price_overrides_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `department_customer_price_overrides` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`department_id` INT NOT NULL,
|
||||
`user_id` INT NOT NULL,
|
||||
`is_category` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`product_or_category_id` VARCHAR(191) NOT NULL,
|
||||
`percentage` INT NOT NULL DEFAULT 0,
|
||||
`fixed_price` INT NULL DEFAULT NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_department_customer_price_overrides_lookup` (`department_id`, `user_id`, `is_category`, `product_or_category_id`),
|
||||
KEY `idx_department_customer_price_overrides_department` (`department_id`),
|
||||
KEY `idx_department_customer_price_overrides_user` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use objects\department_customer_price_overrides_o;
|
||||
use objects\departments_o;
|
||||
use objects\products_o;
|
||||
use objects\users_o;
|
||||
|
||||
class department_customer_pricing_service
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getPricing(int $departmentId, int $userId): array
|
||||
{
|
||||
$department = $this->department($departmentId);
|
||||
$customer = $this->customer($userId);
|
||||
$this->assertEnabled($department);
|
||||
|
||||
$overrides = (new department_customer_price_overrides_o())->getAllPrices($departmentId, $userId);
|
||||
|
||||
return [
|
||||
'department' => $department,
|
||||
'customer' => $customer,
|
||||
'overrides' => $overrides,
|
||||
'categories' => $this->catalog($departmentId, $customer['id']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function updatePricing(int $departmentId, int $userId, array $payload): array
|
||||
{
|
||||
$department = $this->department($departmentId);
|
||||
$customer = $this->customer($userId);
|
||||
$this->assertEnabled($department);
|
||||
|
||||
if (array_key_exists('department_id', $payload) && (int)$payload['department_id'] !== $departmentId) {
|
||||
throw new limited_backoffice_exception('Department ID in body does not match the route.', 400);
|
||||
}
|
||||
if (array_key_exists('user_id', $payload) && (int)$payload['user_id'] !== $userId) {
|
||||
throw new limited_backoffice_exception('User ID in body does not match the route.', 400);
|
||||
}
|
||||
|
||||
$overrides = $payload['overrides'] ?? null;
|
||||
if (!is_array($overrides)) {
|
||||
throw new limited_backoffice_exception('Overrides are required.', 400);
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeOverrides($departmentId, $overrides);
|
||||
$overrideObject = new department_customer_price_overrides_o();
|
||||
$existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']);
|
||||
$normalizedKeys = [];
|
||||
foreach ($normalized as $override) {
|
||||
$normalizedKeys[$this->overrideKey((bool)$override['is_category'], $override['product_or_category_id'])] = true;
|
||||
}
|
||||
|
||||
global $db;
|
||||
$db->conn()->begin_transaction();
|
||||
try {
|
||||
$db->query(
|
||||
'DELETE FROM `department_customer_price_overrides` WHERE `department_id` = '
|
||||
. (int)$departmentId . ' AND `user_id` = ' . (int)$customer['id']
|
||||
);
|
||||
|
||||
foreach ($normalized as $override) {
|
||||
$overrideObject->setPrice(
|
||||
$departmentId,
|
||||
$customer['id'],
|
||||
(bool)$override['is_category'],
|
||||
$override['product_or_category_id'],
|
||||
(int)$override['percentage'],
|
||||
$override['fixed_price']
|
||||
);
|
||||
}
|
||||
|
||||
$db->conn()->commit();
|
||||
} catch (\Throwable) {
|
||||
$db->conn()->rollback();
|
||||
throw new limited_backoffice_exception('Unable to update department customer pricing.', 500);
|
||||
}
|
||||
|
||||
foreach ($normalized as $override) {
|
||||
$this->recordVersion($customer, $departmentId, $override);
|
||||
}
|
||||
|
||||
foreach ($existingOverrides as $existingOverride) {
|
||||
$key = $this->overrideKey((bool)$existingOverride['is_category'], $existingOverride['product_or_category_id']);
|
||||
if (isset($normalizedKeys[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->recordVersion($customer, $departmentId, [
|
||||
'is_category' => (bool)$existingOverride['is_category'],
|
||||
'product_or_category_id' => $existingOverride['product_or_category_id'],
|
||||
'percentage' => 0,
|
||||
'fixed_price' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->getPricing($departmentId, $customer['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id:int,name:string,description:string,custom_pricing_only:bool}
|
||||
*/
|
||||
private function department(int $departmentId): array
|
||||
{
|
||||
$department = (new departments_o())->getDepartmentById($departmentId);
|
||||
if (!is_array($department) || empty($department)) {
|
||||
throw new limited_backoffice_exception('Department not found', 404);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)$department['id'],
|
||||
'name' => (string)$department['name'],
|
||||
'description' => (string)($department['description'] ?? ''),
|
||||
'custom_pricing_only' => (bool)(int)($department['custom_pricing_only'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id:int,customer_number:int,display_name:string}
|
||||
*/
|
||||
private function customer(int $userId): array
|
||||
{
|
||||
$customer = (new users_o())->getUserById($userId);
|
||||
if (!$customer->exists()) {
|
||||
throw new limited_backoffice_exception('Customer not found', 404);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)$customer->id,
|
||||
'customer_number' => (int)$customer->customer_number->value(),
|
||||
'display_name' => (string)($customer->display_name->value() ?: ('Customer #' . $customer->customer_number->value())),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $department
|
||||
*/
|
||||
private function assertEnabled(array $department): void
|
||||
{
|
||||
if (!($department['custom_pricing_only'] ?? false)) {
|
||||
throw new limited_backoffice_exception('Department customer pricing is disabled.', 409, [
|
||||
'message' => 'Department customer pricing is disabled.',
|
||||
'code' => 'department_customer_pricing_disabled',
|
||||
'department' => $department,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function catalog(int $departmentId, int $userId): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
c.`id` AS `category_id`,
|
||||
c.`name` AS `category_name`,
|
||||
c.`description` AS `category_description`,
|
||||
p.*,
|
||||
pdp.`price` AS `department_price`
|
||||
FROM `department_categories` dc
|
||||
INNER JOIN `categories` c ON c.`id` = dc.`category_id`
|
||||
INNER JOIN `products` p ON p.`category` = dc.`category_id`
|
||||
LEFT JOIN `product_department_prices` pdp
|
||||
ON pdp.`department_id` = dc.`department_id`
|
||||
AND pdp.`product_id` = p.`id`
|
||||
WHERE dc.`department_id` = " . (int)$departmentId . "
|
||||
AND dc.`deleted_at` IS NULL
|
||||
ORDER BY c.`name` ASC, c.`id` ASC, p.`order_priority` ASC, p.`name` ASC, p.`id` ASC";
|
||||
|
||||
$result = $db->query($sql);
|
||||
$rows = $result ? $db->fetch_all($result) : [];
|
||||
$customer = (new users_o())->getUserById($userId);
|
||||
$categories = [];
|
||||
$seen = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$productId = (int)$row['id'];
|
||||
if (isset($seen[$productId])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$productId] = true;
|
||||
|
||||
$categoryId = (int)$row['category_id'];
|
||||
if (!isset($categories[$categoryId])) {
|
||||
$categories[$categoryId] = [
|
||||
'id' => $categoryId,
|
||||
'name' => (string)$row['category_name'],
|
||||
'description' => (string)($row['category_description'] ?? ''),
|
||||
'products' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$departmentPrice = $row['department_price'] === null ? null : (int)$row['department_price'];
|
||||
$effectivePrice = products_o::CUSTOM_PRICING_MISSING_PRICE;
|
||||
if ($departmentPrice !== null) {
|
||||
$effectivePrice = $customer->applyProductCustomerPricing($productId, $departmentPrice, true, $departmentId);
|
||||
}
|
||||
|
||||
$categories[$categoryId]['products'][] = [
|
||||
'id' => $productId,
|
||||
'name' => (string)$row['name'],
|
||||
'description' => (string)($row['description'] ?? ''),
|
||||
'category' => $categoryId,
|
||||
'apply_category_discount' => (bool)$row['apply_category_discount'],
|
||||
'base_price' => (int)$row['price'],
|
||||
'department_price' => $departmentPrice,
|
||||
'effective_price' => $effectivePrice,
|
||||
'missing_department_price' => $departmentPrice === null,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($categories);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $overrides
|
||||
* @return array<int, array{is_category:bool,product_or_category_id:int|string,percentage:int,fixed_price:int|null}>
|
||||
*/
|
||||
private function normalizeOverrides(int $departmentId, array $overrides): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($overrides as $override) {
|
||||
if (!is_array($override)) {
|
||||
throw new limited_backoffice_exception('Invalid override payload.', 400);
|
||||
}
|
||||
|
||||
$isCategory = (bool)($override['is_category'] ?? false);
|
||||
$objectId = $override['product_or_category_id'] ?? $override['object_id'] ?? null;
|
||||
if ($objectId === null || $objectId === '') {
|
||||
throw new limited_backoffice_exception('Override object is required.', 400);
|
||||
}
|
||||
|
||||
$percentage = filter_var($override['discount'] ?? $override['percentage'] ?? 0, FILTER_VALIDATE_INT);
|
||||
if ($percentage === false || $percentage < 0 || $percentage > 100) {
|
||||
throw new limited_backoffice_exception('Discount must be between 0 and 100.', 400);
|
||||
}
|
||||
|
||||
$fixedPrice = null;
|
||||
if (array_key_exists('fixed_price', $override) && $override['fixed_price'] !== null && $override['fixed_price'] !== '') {
|
||||
$fixedPrice = filter_var($override['fixed_price'], FILTER_VALIDATE_INT);
|
||||
if ($fixedPrice === false || $fixedPrice < 0) {
|
||||
throw new limited_backoffice_exception('Fixed price must be zero or more.', 400);
|
||||
}
|
||||
}
|
||||
|
||||
if ($isCategory) {
|
||||
$fixedPrice = null;
|
||||
$objectId = (string)$objectId;
|
||||
if ($objectId !== 'global') {
|
||||
$this->assertDepartmentCategory($departmentId, $objectId);
|
||||
}
|
||||
} else {
|
||||
$objectId = (int)$objectId;
|
||||
$this->assertDepartmentProduct($departmentId, $objectId);
|
||||
}
|
||||
|
||||
if ($percentage <= 0 && $fixedPrice === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $this->overrideKey($isCategory, $objectId);
|
||||
$normalized[$key] = [
|
||||
'is_category' => $isCategory,
|
||||
'product_or_category_id' => $objectId,
|
||||
'percentage' => (int)$percentage,
|
||||
'fixed_price' => $fixedPrice === null ? null : (int)$fixedPrice,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id:int,customer_number:int,display_name:string} $customer
|
||||
* @param array{is_category:bool,product_or_category_id:int|string,percentage:int,fixed_price:int|null} $override
|
||||
*/
|
||||
private function recordVersion(array $customer, int $departmentId, array $override): void
|
||||
{
|
||||
try {
|
||||
(new economic_v2_versioning_service())->recordDiscountOverrideVersion(
|
||||
(int)$customer['id'],
|
||||
(int)$customer['customer_number'],
|
||||
(bool)$override['is_category'],
|
||||
(string)$override['product_or_category_id'],
|
||||
(int)$override['percentage'],
|
||||
date('Y-m-d H:i:s'),
|
||||
'live.department_discount_override.route',
|
||||
1.0,
|
||||
false,
|
||||
[
|
||||
'route' => 'department_customer_pricing',
|
||||
'department_id' => $departmentId,
|
||||
],
|
||||
$override['fixed_price'],
|
||||
$departmentId
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private function overrideKey(bool $isCategory, int|string $objectId): string
|
||||
{
|
||||
return ((int)$isCategory) . ':' . (string)$objectId;
|
||||
}
|
||||
|
||||
private function assertDepartmentProduct(int $departmentId, int $productId): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$result = $db->query(
|
||||
'SELECT p.`id`
|
||||
FROM `department_categories` dc
|
||||
INNER JOIN `products` p ON p.`category` = dc.`category_id`
|
||||
WHERE dc.`department_id` = ' . (int)$departmentId . '
|
||||
AND dc.`deleted_at` IS NULL
|
||||
AND p.`id` = ' . (int)$productId . '
|
||||
LIMIT 1'
|
||||
);
|
||||
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
throw new limited_backoffice_exception('Product is not available for this department.', 400);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertDepartmentCategory(int $departmentId, string $categoryId): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$categoryId = $db->escape_string($categoryId);
|
||||
$result = $db->query(
|
||||
"SELECT `id`
|
||||
FROM `department_categories`
|
||||
WHERE `department_id` = " . (int)$departmentId . "
|
||||
AND `deleted_at` IS NULL
|
||||
AND `category_id` = '{$categoryId}'
|
||||
LIMIT 1"
|
||||
);
|
||||
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
throw new limited_backoffice_exception('Category is not available for this department.', 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
require_once WD . '/classes/selfserve_schema_bootstrap.php';
|
||||
|
||||
use Exception;
|
||||
|
||||
class department_wash_count_service
|
||||
{
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function countInDateRange(string $date_start, string $date_end, int $department_id): int
|
||||
{
|
||||
$rows = $this->countByHourForDepartments($date_start, $date_end, [$department_id]);
|
||||
$total = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$total += (int)($row['wash_count'] ?? 0);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $department_ids
|
||||
* @return array<int,array{department_id:int,hour_bucket:string,wash_count:int}>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function countByHourForDepartments(string $date_start, string $date_end, array $department_ids): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$this->validateDateRange($date_start, $date_end);
|
||||
$normalized_department_ids = $this->normalizeIds($department_ids);
|
||||
if ($normalized_department_ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
|
||||
$department_ids_sql = implode(',', $normalized_department_ids);
|
||||
$escaped_start = $db->escape_string($date_start);
|
||||
$escaped_end = $db->escape_string($date_end);
|
||||
$candidate_sql = $this->candidateUnionSql($department_ids_sql, $escaped_start, $escaped_end);
|
||||
|
||||
$sql = "SELECT deduped.department_id,
|
||||
DATE_FORMAT(deduped.counted_at, '%Y-%m-%d %H:00:00') AS hour_bucket,
|
||||
COUNT(*) AS wash_count
|
||||
FROM (
|
||||
SELECT dedupe_key,
|
||||
department_id,
|
||||
MIN(counted_at) AS counted_at
|
||||
FROM ($candidate_sql) candidates
|
||||
GROUP BY dedupe_key, department_id
|
||||
) deduped
|
||||
GROUP BY deduped.department_id, DATE_FORMAT(deduped.counted_at, '%Y-%m-%d %H:00:00')
|
||||
ORDER BY deduped.department_id ASC, hour_bucket ASC";
|
||||
|
||||
$result = $db->query($sql);
|
||||
if (!is_object($result) || $result->num_rows === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$rows[] = [
|
||||
'department_id' => (int)($row['department_id'] ?? 0),
|
||||
'hour_bucket' => (string)($row['hour_bucket'] ?? ''),
|
||||
'wash_count' => (int)($row['wash_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $department_ids
|
||||
* @return array{quantity:int,products:int,earnings:int,washes:int}
|
||||
* @throws Exception
|
||||
*/
|
||||
public function transactionSummary(string $date_start, string $date_end, array $department_ids): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$this->validateDateRange($date_start, $date_end);
|
||||
$normalized_department_ids = $this->normalizeIds($department_ids);
|
||||
if ($normalized_department_ids === []) {
|
||||
return [
|
||||
'quantity' => 0,
|
||||
'products' => 0,
|
||||
'earnings' => 0,
|
||||
'washes' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$department_ids_sql = implode(',', $normalized_department_ids);
|
||||
$escaped_start = $db->escape_string($date_start);
|
||||
$escaped_end = $db->escape_string($date_end);
|
||||
|
||||
$sql = "SELECT COUNT(DISTINCT o.id) AS quantity,
|
||||
COALESCE(SUM(oi.quantity), 0) AS products,
|
||||
COALESCE(SUM(oi.price * oi.quantity), 0) AS earnings
|
||||
FROM orders o
|
||||
JOIN order_items oi ON oi.order_id = o.id
|
||||
WHERE o.department_id IN ($department_ids_sql)
|
||||
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
|
||||
AND o.deleted_at IS NULL
|
||||
AND oi.deleted_at IS NULL";
|
||||
|
||||
$result = $db->query($sql);
|
||||
$row = is_object($result) ? $result->fetch_assoc() : null;
|
||||
|
||||
return [
|
||||
'quantity' => (int)($row['quantity'] ?? 0),
|
||||
'products' => (int)($row['products'] ?? 0),
|
||||
'earnings' => (int)round((float)($row['earnings'] ?? 0)),
|
||||
'washes' => $this->countRows($date_start, $date_end, $normalized_department_ids),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $department_ids
|
||||
* @return array<int,array{id:int,department_id:int,created_at:string}>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function listTransactions(string $date_start, string $date_end, array $department_ids): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$this->validateDateRange($date_start, $date_end);
|
||||
$normalized_department_ids = $this->normalizeIds($department_ids);
|
||||
if ($normalized_department_ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
|
||||
$department_ids_sql = implode(',', $normalized_department_ids);
|
||||
$escaped_start = $db->escape_string($date_start);
|
||||
$escaped_end = $db->escape_string($date_end);
|
||||
$candidate_sql = $this->candidateUnionSql($department_ids_sql, $escaped_start, $escaped_end);
|
||||
|
||||
$sql = "SELECT CAST(SUBSTRING_INDEX(GROUP_CONCAT(entity_id ORDER BY source_priority ASC, entity_id ASC), ',', 1) AS UNSIGNED) AS id,
|
||||
department_id,
|
||||
MIN(counted_at) AS created_at
|
||||
FROM ($candidate_sql) candidates
|
||||
GROUP BY dedupe_key, department_id
|
||||
ORDER BY created_at ASC";
|
||||
|
||||
$result = $db->query($sql);
|
||||
if (!is_object($result) || $result->num_rows === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$rows[] = [
|
||||
'id' => (int)($row['id'] ?? 0),
|
||||
'department_id' => (int)($row['department_id'] ?? 0),
|
||||
'created_at' => (string)($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int> $department_ids
|
||||
* @throws Exception
|
||||
*/
|
||||
private function countRows(string $date_start, string $date_end, array $department_ids): int
|
||||
{
|
||||
$rows = $this->countByHourForDepartments($date_start, $date_end, $department_ids);
|
||||
$total = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$total += (int)($row['wash_count'] ?? 0);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function candidateUnionSql(string $department_ids_sql, string $escaped_start, string $escaped_end): string
|
||||
{
|
||||
return "SELECT CONCAT('order:', o.id) AS dedupe_key,
|
||||
o.id AS entity_id,
|
||||
o.department_id,
|
||||
o.created_at AS counted_at,
|
||||
0 AS source_priority
|
||||
FROM orders o
|
||||
JOIN order_items oi ON oi.order_id = o.id
|
||||
JOIN products p ON p.id = oi.product_id
|
||||
WHERE o.department_id IN ($department_ids_sql)
|
||||
AND o.created_at BETWEEN '$escaped_start' AND '$escaped_end'
|
||||
AND o.deleted_at IS NULL
|
||||
AND oi.deleted_at IS NULL
|
||||
AND p.is_wash = 1
|
||||
UNION ALL
|
||||
SELECT CASE
|
||||
WHEN linked_o.id IS NOT NULL THEN CONCAT('order:', linked_o.id)
|
||||
ELSE CONCAT('selfserve:', s.id)
|
||||
END AS dedupe_key,
|
||||
CASE
|
||||
WHEN linked_o.id IS NOT NULL THEN linked_o.id
|
||||
ELSE s.id
|
||||
END AS entity_id,
|
||||
COALESCE(linked_o.department_id, s.department_id) AS department_id,
|
||||
COALESCE(linked_o.created_at, s.completed_at) AS counted_at,
|
||||
1 AS source_priority
|
||||
FROM selfserve_wash_sessions s
|
||||
LEFT JOIN orders linked_o
|
||||
ON linked_o.id = s.order_id
|
||||
AND linked_o.deleted_at IS NULL
|
||||
WHERE COALESCE(linked_o.department_id, s.department_id) IN ($department_ids_sql)
|
||||
AND COALESCE(linked_o.created_at, s.completed_at) BETWEEN '$escaped_start' AND '$escaped_end'
|
||||
AND s.deleted_at IS NULL
|
||||
AND s.completed_at IS NOT NULL
|
||||
AND UPPER(TRIM(s.status)) = 'COMPLETED'";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string> $ids
|
||||
* @return array<int>
|
||||
*/
|
||||
private function normalizeIds(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($ids as $id) {
|
||||
$value = (int)$id;
|
||||
if ($value > 0) {
|
||||
$normalized[$value] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function validateDateRange(string $date_start, string $date_end): void
|
||||
{
|
||||
if (strtotime($date_start) === false || strtotime($date_end) === false) {
|
||||
throw new Exception('Invalid date range provided');
|
||||
}
|
||||
if (strtotime($date_start) > strtotime($date_end)) {
|
||||
throw new Exception('The start date cannot be after the end date');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,14 @@ class departments_schema_bootstrap
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::columnExists($db, 'departments', 'custom_pricing_only')) {
|
||||
$db->query(
|
||||
"ALTER TABLE departments
|
||||
ADD COLUMN custom_pricing_only TINYINT(1) NOT NULL DEFAULT 0
|
||||
AFTER archived"
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
|
||||
$db->query(
|
||||
"ALTER TABLE departments
|
||||
|
||||
@@ -172,7 +172,8 @@ class economic implements economic_i
|
||||
string $email,
|
||||
int $phone,
|
||||
?int $mobile_phone = null,
|
||||
object|array|null $company_information = null
|
||||
object|array|null $company_information = null,
|
||||
?string $ean = null
|
||||
): object
|
||||
{
|
||||
$payload = [
|
||||
@@ -196,10 +197,37 @@ class economic implements economic_i
|
||||
];
|
||||
|
||||
$payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information));
|
||||
$normalized_ean = self::normalizeCustomerEan($ean);
|
||||
if ($normalized_ean !== null) {
|
||||
$payload['ean'] = $normalized_ean;
|
||||
}
|
||||
|
||||
return $this->customers->customers->create($payload);
|
||||
}
|
||||
|
||||
public static function normalizeCustomerEan(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = preg_replace('/\D+/', '', (string)$value);
|
||||
if (!is_string($digits)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$digits = trim($digits);
|
||||
if ($digits === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (strlen($digits) > 13) {
|
||||
throw new \InvalidArgumentException('EAN must be at most 13 digits.');
|
||||
}
|
||||
|
||||
return $digits;
|
||||
}
|
||||
|
||||
private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array
|
||||
{
|
||||
if ($company_information === null) {
|
||||
|
||||
@@ -240,7 +240,13 @@ class economic_transfer_executor
|
||||
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
|
||||
);
|
||||
|
||||
return $collected_order_invoices->asArray();
|
||||
$result = $collected_order_invoices->asArray();
|
||||
$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();
|
||||
if ($transfer_metrics !== null) {
|
||||
$result['economic_transfer_metrics'] = $transfer_metrics;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,7 +41,7 @@ class economic_transfer_queue
|
||||
$transfer_type = $this->validateTransferType($transfer_type);
|
||||
$payload = $this->normalizePayloadForTransferType($transfer_type, $payload, $created_by);
|
||||
|
||||
$active_job = $this->findActiveJobByTarget($transfer_type, $payload);
|
||||
$active_job = $this->findActiveJobByTarget($transfer_type, $payload, $created_by);
|
||||
if ($active_job !== null) {
|
||||
$target_label = $this->buildTargetLabel($transfer_type, $payload);
|
||||
$this->logQueueEvent(
|
||||
@@ -135,6 +135,89 @@ class economic_transfer_queue
|
||||
return $jobs;
|
||||
}
|
||||
|
||||
public function getJobByIdForUser(int $job_id, int $created_by): ?array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$job_id = max(0, $job_id);
|
||||
$created_by = max(0, $created_by);
|
||||
if ($job_id < 1 || $created_by < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("SELECT * FROM economic_transfer_queue_jobs WHERE id = ? AND created_by = ? LIMIT 1");
|
||||
if (!$stmt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt->bind_param('ii', $job_id, $created_by);
|
||||
if (!$stmt->execute()) {
|
||||
$stmt->close();
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = $stmt->get_result();
|
||||
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||
$stmt->close();
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $this->normalizeJobRow($row);
|
||||
}
|
||||
|
||||
public function listJobsForCreatedBy(array $statuses = [], int $limit = 50, int $offset = 0, ?string $transfer_type = null, int $created_by = 0): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$created_by = max(0, $created_by);
|
||||
if ($created_by < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$limit = max(1, min(500, $limit));
|
||||
$offset = max(0, $offset);
|
||||
|
||||
$where = $this->buildListJobsWhereClause($statuses, $transfer_type);
|
||||
$where .= $where === '' ? 'WHERE created_by = ' . $created_by : ' AND created_by = ' . $created_by;
|
||||
$sql = "SELECT * FROM economic_transfer_queue_jobs $where ORDER BY id DESC LIMIT $limit OFFSET $offset";
|
||||
$result = $db->query($sql);
|
||||
if (!$result instanceof mysqli_result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$jobs = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$jobs[] = $this->normalizeJobRow($row);
|
||||
}
|
||||
return $jobs;
|
||||
}
|
||||
|
||||
public function countJobsForCreatedBy(array $statuses = [], ?string $transfer_type = null, int $created_by = 0): int
|
||||
{
|
||||
global $db;
|
||||
|
||||
$created_by = max(0, $created_by);
|
||||
if ($created_by < 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$where = $this->buildListJobsWhereClause($statuses, $transfer_type);
|
||||
$where .= $where === '' ? 'WHERE created_by = ' . $created_by : ' AND created_by = ' . $created_by;
|
||||
$sql = "SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs $where";
|
||||
$result = $db->query($sql);
|
||||
if (!$result instanceof mysqli_result) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
if (!is_array($row) || !isset($row['total'])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return max(0, (int)$row['total']);
|
||||
}
|
||||
|
||||
public function countJobs(array $statuses = [], ?string $transfer_type = null): int
|
||||
{
|
||||
global $db;
|
||||
@@ -179,7 +262,7 @@ class economic_transfer_queue
|
||||
ON d.queue_job_id = q.id
|
||||
AND d.user_id = $user_id
|
||||
AND d.dismissed_status = q.status
|
||||
WHERE 1 = 1
|
||||
WHERE q.created_by = $user_id
|
||||
$transfer_condition
|
||||
AND (
|
||||
q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "')
|
||||
@@ -214,7 +297,7 @@ class economic_transfer_queue
|
||||
throw new Exception('Queue job and user are required');
|
||||
}
|
||||
|
||||
$job = $this->getJobById($job_id);
|
||||
$job = $this->getJobByIdForUser($job_id, $user_id);
|
||||
if ($job === null) {
|
||||
throw new Exception('Queue job not found');
|
||||
}
|
||||
@@ -272,7 +355,8 @@ class economic_transfer_queue
|
||||
ON d.queue_job_id = q.id
|
||||
AND d.user_id = $user_id
|
||||
AND d.dismissed_status = q.status
|
||||
WHERE q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "')
|
||||
WHERE q.created_by = $user_id
|
||||
AND q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "')
|
||||
$transfer_condition
|
||||
AND d.queue_job_id IS NULL
|
||||
ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()";
|
||||
@@ -284,10 +368,24 @@ class economic_transfer_queue
|
||||
* @throws Exception
|
||||
*/
|
||||
public function retryJob(int $job_id): array
|
||||
{
|
||||
return $this->retryJobInternal($job_id);
|
||||
}
|
||||
|
||||
public function retryJobForUser(int $job_id, int $created_by): array
|
||||
{
|
||||
return $this->retryJobInternal($job_id, $created_by);
|
||||
}
|
||||
|
||||
private function retryJobInternal(int $job_id, ?int $created_by = null): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$existing_job = $this->getJobById($job_id);
|
||||
$job_id = max(0, $job_id);
|
||||
$created_by = $created_by === null ? null : max(0, $created_by);
|
||||
$existing_job = $created_by === null
|
||||
? $this->getJobById($job_id)
|
||||
: $this->getJobByIdForUser($job_id, $created_by);
|
||||
if ($existing_job === null) {
|
||||
throw new Exception('Queue job not found');
|
||||
}
|
||||
@@ -298,19 +396,26 @@ class economic_transfer_queue
|
||||
throw new Exception('Queue job reached max retry attempts');
|
||||
}
|
||||
|
||||
$stmt = $db->prepare(
|
||||
"UPDATE economic_transfer_queue_jobs
|
||||
$sql = "UPDATE economic_transfer_queue_jobs
|
||||
SET status = ?, progress_percent = 0, progress_message = 'Queued for retry',
|
||||
error_message = NULL, result_json = NULL, started_at = NULL, completed_at = NULL, locked_at = NULL
|
||||
WHERE id = ? AND status = ?"
|
||||
);
|
||||
WHERE id = ? AND status = ?";
|
||||
if ($created_by !== null) {
|
||||
$sql .= " AND created_by = ?";
|
||||
}
|
||||
|
||||
$stmt = $db->prepare($sql);
|
||||
if (!$stmt) {
|
||||
throw new Exception('Failed to prepare retry statement');
|
||||
}
|
||||
|
||||
$queued = self::STATUS_QUEUED;
|
||||
$failed = self::STATUS_FAILED;
|
||||
$stmt->bind_param('sis', $queued, $job_id, $failed);
|
||||
if ($created_by !== null) {
|
||||
$stmt->bind_param('sisi', $queued, $job_id, $failed, $created_by);
|
||||
} else {
|
||||
$stmt->bind_param('sis', $queued, $job_id, $failed);
|
||||
}
|
||||
$stmt->execute();
|
||||
$affected = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
@@ -321,7 +426,9 @@ class economic_transfer_queue
|
||||
|
||||
$this->clearDismissalsForJob($job_id);
|
||||
|
||||
$job = $this->getJobById($job_id);
|
||||
$job = $created_by === null
|
||||
? $this->getJobById($job_id)
|
||||
: $this->getJobByIdForUser($job_id, $created_by);
|
||||
if ($job === null) {
|
||||
throw new Exception('Retry updated job could not be loaded');
|
||||
}
|
||||
@@ -710,28 +817,31 @@ class economic_transfer_queue
|
||||
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
|
||||
}
|
||||
|
||||
private function findActiveJobByTarget(string $transfer_type, array $payload): ?array
|
||||
private function findActiveJobByTarget(string $transfer_type, array $payload, int $created_by): ?array
|
||||
{
|
||||
return match ($transfer_type) {
|
||||
self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget(
|
||||
$transfer_type,
|
||||
'$.order_id',
|
||||
(int)($payload['order_id'] ?? 0)
|
||||
(int)($payload['order_id'] ?? 0),
|
||||
$created_by
|
||||
),
|
||||
self::TYPE_COLLECTED_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget(
|
||||
$transfer_type,
|
||||
'$.collected_invoice_id',
|
||||
(int)($payload['collected_invoice_id'] ?? 0)
|
||||
(int)($payload['collected_invoice_id'] ?? 0),
|
||||
$created_by
|
||||
),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function findActiveJobByJsonNumericTarget(string $transfer_type, string $json_path, int $target_value): ?array
|
||||
private function findActiveJobByJsonNumericTarget(string $transfer_type, string $json_path, int $target_value, int $created_by): ?array
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($target_value < 1) {
|
||||
$created_by = max(0, $created_by);
|
||||
if ($target_value < 1 || $created_by < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -741,6 +851,7 @@ class economic_transfer_queue
|
||||
WHERE transfer_type = ?
|
||||
AND status IN (?, ?)
|
||||
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$json_path')) AS UNSIGNED) = ?
|
||||
AND created_by = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
@@ -750,7 +861,7 @@ class economic_transfer_queue
|
||||
|
||||
$queued = self::STATUS_QUEUED;
|
||||
$processing = self::STATUS_PROCESSING;
|
||||
$stmt->bind_param('sssi', $transfer_type, $queued, $processing, $target_value);
|
||||
$stmt->bind_param('sssii', $transfer_type, $queued, $processing, $target_value, $created_by);
|
||||
if (!$stmt->execute()) {
|
||||
$stmt->close();
|
||||
return null;
|
||||
|
||||
@@ -40,6 +40,7 @@ class economic_v2_distribution_service
|
||||
|
||||
public function __construct(?economic_v2_versioning_service $versioning = null, ?economic $economic = null)
|
||||
{
|
||||
department_customer_price_overrides_schema_bootstrap::ensureTables();
|
||||
$this->versioning = $versioning ?? new economic_v2_versioning_service();
|
||||
$this->economic = $economic;
|
||||
}
|
||||
@@ -388,7 +389,16 @@ class economic_v2_distribution_service
|
||||
continue;
|
||||
}
|
||||
|
||||
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at);
|
||||
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $created_at, $department_id);
|
||||
if ($discount_row === null) {
|
||||
continue;
|
||||
}
|
||||
if (array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
|
||||
$fixed_price = (float)$discount_row['fixed_price'];
|
||||
$order_discount_total += (($base_price - $fixed_price) * $quantity);
|
||||
continue;
|
||||
}
|
||||
|
||||
$discount_percentage = (float)($discount_row['discount'] ?? 0);
|
||||
if ($discount_percentage <= 0) {
|
||||
continue;
|
||||
@@ -1519,7 +1529,13 @@ class economic_v2_distribution_service
|
||||
$line_price = ((float)$this->getProductDepartmentPrice($product_id, $department_id)) * $quantity;
|
||||
}
|
||||
|
||||
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp);
|
||||
$discount_row = $this->resolveDiscountForProduct($customer_number, $product_id, $timestamp, $department_id);
|
||||
if ($discount_row !== null && array_key_exists('fixed_price', $discount_row) && $discount_row['fixed_price'] !== null) {
|
||||
$line_price = ((float)$discount_row['fixed_price']) * $quantity;
|
||||
$total += $line_price;
|
||||
continue;
|
||||
}
|
||||
|
||||
$discount_percentage = (float)($discount_row['discount'] ?? 0);
|
||||
if ($discount_percentage > 0) {
|
||||
$line_price *= (1 - ($discount_percentage / 100));
|
||||
@@ -1529,15 +1545,22 @@ class economic_v2_distribution_service
|
||||
return $total;
|
||||
}
|
||||
|
||||
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp): ?array
|
||||
protected function resolveDiscountForProduct(int $customer_number, int $product_id, string $timestamp, ?int $department_id = null): ?array
|
||||
{
|
||||
$cache_key = $customer_number . '|' . $product_id . '|' . substr($timestamp, 0, 19);
|
||||
$cache_key = $customer_number . '|' . $product_id . '|' . (int)($department_id ?? 0) . '|' . substr($timestamp, 0, 19);
|
||||
if (array_key_exists($cache_key, $this->discount_resolution_cache)) {
|
||||
return $this->discount_resolution_cache[$cache_key];
|
||||
}
|
||||
|
||||
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp);
|
||||
if ($direct !== null && (int)($direct['discount'] ?? 0) > 0) {
|
||||
$scopedDepartmentId = $department_id !== null && (new \objects\departments_o())->isCustomPricingOnly((int)$department_id)
|
||||
? (int)$department_id
|
||||
: null;
|
||||
|
||||
$direct = $this->versioning->resolveDiscountOverrideAt($customer_number, false, (string)$product_id, $timestamp, $scopedDepartmentId);
|
||||
if ($direct !== null && (
|
||||
(array_key_exists('fixed_price', $direct) && $direct['fixed_price'] !== null)
|
||||
|| (int)($direct['discount'] ?? 0) > 0
|
||||
)) {
|
||||
return $this->discount_resolution_cache[$cache_key] = $direct;
|
||||
}
|
||||
|
||||
@@ -1545,13 +1568,20 @@ class economic_v2_distribution_service
|
||||
if ($product !== null) {
|
||||
$category = (string)$product->category->value();
|
||||
if ($category !== '') {
|
||||
$category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp);
|
||||
$category_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, $category, $timestamp, $scopedDepartmentId);
|
||||
if ($category_discount !== null && (int)($category_discount['discount'] ?? 0) > 0) {
|
||||
return $this->discount_resolution_cache[$cache_key] = $category_discount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($scopedDepartmentId !== null) {
|
||||
$global_discount = $this->versioning->resolveDiscountOverrideAt($customer_number, true, 'global', $timestamp, $scopedDepartmentId);
|
||||
if ($global_discount !== null && (int)($global_discount['discount'] ?? 0) > 0) {
|
||||
return $this->discount_resolution_cache[$cache_key] = $global_discount;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->discount_resolution_cache[$cache_key] = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,11 +60,13 @@ class economic_v2_schema_bootstrap
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS customer_discount_override_versions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
department_id INT NULL DEFAULT NULL,
|
||||
user_id INT NOT NULL,
|
||||
customer_number INT NOT NULL,
|
||||
is_category TINYINT(1) NOT NULL,
|
||||
object_id VARCHAR(64) NOT NULL,
|
||||
discount INT NOT NULL,
|
||||
fixed_price INT NULL DEFAULT NULL,
|
||||
effective_from DATETIME NOT NULL,
|
||||
effective_to DATETIME NULL,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'live',
|
||||
@@ -74,6 +76,7 @@ class economic_v2_schema_bootstrap
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_discount_override_versions_lookup (customer_number, is_category, object_id, effective_from, effective_to),
|
||||
INDEX idx_discount_override_versions_department_lookup (department_id, customer_number, is_category, object_id, effective_from, effective_to),
|
||||
INDEX idx_discount_override_versions_user (user_id),
|
||||
INDEX idx_discount_override_versions_source (source)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
@@ -83,6 +86,22 @@ class economic_v2_schema_bootstrap
|
||||
$db->query($sql);
|
||||
}
|
||||
|
||||
if (!self::tableHasColumn('customer_discount_override_versions', 'fixed_price')) {
|
||||
$db->query(
|
||||
"ALTER TABLE customer_discount_override_versions
|
||||
ADD COLUMN fixed_price INT NULL DEFAULT NULL
|
||||
AFTER discount"
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::tableHasColumn('customer_discount_override_versions', 'department_id')) {
|
||||
$db->query(
|
||||
"ALTER TABLE customer_discount_override_versions
|
||||
ADD COLUMN department_id INT NULL DEFAULT NULL
|
||||
AFTER id"
|
||||
);
|
||||
}
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
@@ -106,4 +125,3 @@ class economic_v2_schema_bootstrap
|
||||
return ((int)($row['c'] ?? 0)) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +135,9 @@ class economic_v2_versioning_service
|
||||
string $source = 'live.discount_override',
|
||||
float $confidence = 1.0,
|
||||
bool $inferred = false,
|
||||
array $metadata = []
|
||||
array $metadata = [],
|
||||
?int $fixed_price = null,
|
||||
?int $department_id = null
|
||||
): array {
|
||||
$identity = [
|
||||
'user_id' => $user_id,
|
||||
@@ -143,8 +145,11 @@ class economic_v2_versioning_service
|
||||
'is_category' => (int)$is_category,
|
||||
'object_id' => (string)$object_id,
|
||||
];
|
||||
if ($department_id !== null) {
|
||||
$identity['department_id'] = (int)$department_id;
|
||||
}
|
||||
|
||||
if ($discount === null || (int)$discount === 0) {
|
||||
if (($discount === null || (int)$discount === 0) && $fixed_price === null) {
|
||||
return $this->closeActiveVersion(
|
||||
'customer_discount_override_versions',
|
||||
$identity,
|
||||
@@ -161,6 +166,7 @@ class economic_v2_versioning_service
|
||||
$identity,
|
||||
[
|
||||
'discount' => (int)$discount,
|
||||
'fixed_price' => $is_category ? null : $fixed_price,
|
||||
],
|
||||
$this->normalizeDatetime($effective_from),
|
||||
$source,
|
||||
@@ -238,15 +244,21 @@ class economic_v2_versioning_service
|
||||
int $customer_number,
|
||||
bool $is_category,
|
||||
int|string $object_id,
|
||||
string $timestamp
|
||||
string $timestamp,
|
||||
?int $department_id = null
|
||||
): ?array {
|
||||
$identity = [
|
||||
'customer_number' => $customer_number,
|
||||
'is_category' => (int)$is_category,
|
||||
'object_id' => (string)$object_id,
|
||||
];
|
||||
if ($department_id !== null) {
|
||||
$identity['department_id'] = (int)$department_id;
|
||||
}
|
||||
|
||||
$rows = $this->resolveActiveVersions(
|
||||
'customer_discount_override_versions',
|
||||
[
|
||||
'customer_number' => $customer_number,
|
||||
'is_category' => (int)$is_category,
|
||||
'object_id' => (string)$object_id,
|
||||
],
|
||||
$identity,
|
||||
$timestamp,
|
||||
'effective_from DESC, id DESC',
|
||||
1
|
||||
@@ -411,8 +423,11 @@ class economic_v2_versioning_service
|
||||
}
|
||||
|
||||
// Discount overrides current state.
|
||||
price_overrides_schema_bootstrap::ensureColumns();
|
||||
$has_override_created_at = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'created_at');
|
||||
$has_override_fixed_price = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'fixed_price');
|
||||
$discount_cols = 'po.user_id, u.customer_number, po.is_category, po.product_or_category_id, po.percentage' .
|
||||
($has_override_fixed_price ? ', po.fixed_price' : '') .
|
||||
($has_override_created_at ? ', po.created_at' : '');
|
||||
$discount_rows = $this->fetchAll(
|
||||
"SELECT $discount_cols
|
||||
@@ -434,7 +449,8 @@ class economic_v2_versioning_service
|
||||
'backfill.current_discount_override',
|
||||
$confidence,
|
||||
true,
|
||||
['table' => 'price_overrides']
|
||||
['table' => 'price_overrides'],
|
||||
$has_override_fixed_price && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null
|
||||
);
|
||||
$this->incrementReportAction($report['discount_overrides'], $result['action'] ?? 'noop');
|
||||
}
|
||||
@@ -707,4 +723,3 @@ class economic_v2_versioning_service
|
||||
$bucket[$action]++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
|
||||
class edge_broker_transport_exception extends Exception
|
||||
{
|
||||
public function __construct(string $message, private readonly int $curlErrno = 0, int $code = 0, ?Exception $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
public function curlErrno(): int
|
||||
{
|
||||
return $this->curlErrno;
|
||||
}
|
||||
}
|
||||
|
||||
class edge_broker_http_exception extends Exception
|
||||
{
|
||||
public function __construct(string $message, private readonly int $statusCode, int $code = 0, ?Exception $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
public function statusCode(): int
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
class edge_broker_client
|
||||
{
|
||||
private const DEFAULT_BROKER_URL = 'http://edge-broker:4300';
|
||||
|
||||
public function __construct(
|
||||
private readonly ?string $baseUrl = null,
|
||||
private readonly ?string $sharedSecret = null,
|
||||
private readonly int $timeoutSeconds = 10
|
||||
) {
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return trim((string)$this->resolveBaseUrl()) !== '';
|
||||
}
|
||||
|
||||
public function dispatchCommand(int $gatewayId, string $commandType, array $payload): array
|
||||
{
|
||||
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/gateways/' . $gatewayId . '/commands';
|
||||
$response = $this->request('POST', $url, [
|
||||
'commandType' => $commandType,
|
||||
'payload' => $payload,
|
||||
]);
|
||||
|
||||
return is_array($response) ? $response : ['ok' => false, 'response' => $response];
|
||||
}
|
||||
|
||||
public function validateAgent(int $gatewayId, string $agentToken): array
|
||||
{
|
||||
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/agent/auth';
|
||||
$response = $this->request('POST', $url, [
|
||||
'gatewayId' => $gatewayId,
|
||||
'agentToken' => $agentToken,
|
||||
]);
|
||||
|
||||
return is_array($response) ? $response : [];
|
||||
}
|
||||
|
||||
public function validateShellSession(string $sessionToken): array
|
||||
{
|
||||
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/shell/auth';
|
||||
$response = $this->request('POST', $url, [
|
||||
'sessionToken' => $sessionToken,
|
||||
]);
|
||||
|
||||
return is_array($response) ? $response : [];
|
||||
}
|
||||
|
||||
public function closeShellSession(int $sessionId, string $sessionToken, string $transcript, string $closedReason): array
|
||||
{
|
||||
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/shell-sessions/' . $sessionId . '/close';
|
||||
$response = $this->request('POST', $url, [
|
||||
'sessionToken' => $sessionToken,
|
||||
'transcript' => $transcript,
|
||||
'closedReason' => $closedReason,
|
||||
]);
|
||||
|
||||
return is_array($response) ? $response : [];
|
||||
}
|
||||
|
||||
private function resolveBaseUrl(): string
|
||||
{
|
||||
return trim((string)($this->baseUrl ?? getenv('EDGE_BROKER_URL') ?: self::DEFAULT_BROKER_URL));
|
||||
}
|
||||
|
||||
private function resolveSharedSecret(): string
|
||||
{
|
||||
return trim((string)($this->sharedSecret
|
||||
?? getenv('EDGE_BROKER_SHARED_SECRET')
|
||||
?: getenv('EDGE_INTERNAL_SECRET')
|
||||
?: ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function request(string $method, string $url, array $payload): array|object|null
|
||||
{
|
||||
if (trim($url) === '') {
|
||||
throw new Exception('Edge broker URL is not configured');
|
||||
}
|
||||
|
||||
$sharedSecret = $this->resolveSharedSecret();
|
||||
if ($sharedSecret === '') {
|
||||
throw new Exception('Edge broker shared secret is not configured');
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeoutSeconds);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'X-Edge-Broker-Secret: ' . $sharedSecret,
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$rawResponse = curl_exec($ch);
|
||||
$statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErrno = curl_errno($ch);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($rawResponse === false) {
|
||||
throw new edge_broker_transport_exception('Edge broker request failed: ' . $curlError, $curlErrno);
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)$rawResponse, true);
|
||||
if ($statusCode >= 400) {
|
||||
$message = is_array($decoded)
|
||||
? (string)($decoded['error'] ?? $decoded['message'] ?? 'Edge broker request failed')
|
||||
: 'Edge broker request failed';
|
||||
throw new edge_broker_http_exception($message, $statusCode);
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
@@ -128,13 +128,13 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
private function sendEmailMailerSend(string $to, string $recipient_name, string $subject, string $message, string $html = null, string $references = null, array $attachments = []): void
|
||||
{
|
||||
if (self::isFakeDeliveryEnabled()) {
|
||||
self::$fake_deliveries[] = [
|
||||
self::recordFakeDelivery([
|
||||
'to' => $to,
|
||||
'recipient_name' => $recipient_name,
|
||||
'subject' => $subject,
|
||||
'message' => $message,
|
||||
'html' => $html,
|
||||
];
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -225,6 +225,72 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
public static function resetFakeDeliveries(): void
|
||||
{
|
||||
self::$fake_deliveries = [];
|
||||
$path = self::getFakeDeliveriesPath();
|
||||
if ($path !== null && is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public static function syncFakeDeliveries(): void
|
||||
{
|
||||
$path = self::getFakeDeliveriesPath();
|
||||
if ($path === null || !is_file($path)) {
|
||||
self::$fake_deliveries = [];
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if ($lines === false) {
|
||||
self::$fake_deliveries = [];
|
||||
return;
|
||||
}
|
||||
|
||||
$deliveries = [];
|
||||
foreach ($lines as $line) {
|
||||
$delivery = json_decode($line, true);
|
||||
if (is_array($delivery)) {
|
||||
$deliveries[] = $delivery;
|
||||
}
|
||||
}
|
||||
|
||||
self::$fake_deliveries = $deliveries;
|
||||
}
|
||||
|
||||
private static function recordFakeDelivery(array $delivery): void
|
||||
{
|
||||
self::$fake_deliveries[] = $delivery;
|
||||
|
||||
$path = self::getFakeDeliveriesPath();
|
||||
if ($path === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$directory = dirname($path);
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0777, true);
|
||||
}
|
||||
|
||||
file_put_contents($path, json_encode($delivery, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
private static function getFakeDeliveriesPath(): ?string
|
||||
{
|
||||
if (!self::isFakeDeliveryEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$configuredPath = trim((string)(getenv('EMAIL_FAKE_DELIVERIES_PATH') ?: ''));
|
||||
if ($configuredPath !== '') {
|
||||
return $configuredPath;
|
||||
}
|
||||
|
||||
if (getenv('RUN_API_TESTS') !== '1') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
|
||||
. DIRECTORY_SEPARATOR
|
||||
. 'truckwash-email-fake-deliveries-' . md5((string)getcwd()) . '.jsonl';
|
||||
}
|
||||
|
||||
private static function isFakeDeliveryEnabled(): bool
|
||||
@@ -511,4 +577,47 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
$this->attachments
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function sendNewCustomerRegistrationNotifications(int $customer_number): void
|
||||
{
|
||||
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
|
||||
if (!$customer->exists()) {
|
||||
throw new Exception('Customer not found with customer number: ' . $customer_number);
|
||||
}
|
||||
|
||||
$customerName = $customer->getCustomerName((int)$customer->customer_number->value()) ?: 'Unknown customer';
|
||||
$safeCustomerName = htmlspecialchars($customerName, ENT_QUOTES, 'UTF-8');
|
||||
$safeCustomerNumber = (int)$customer->customer_number->value();
|
||||
$customerUrl = 'https://truckwash.io/superuser/users?search=' . $safeCustomerNumber;
|
||||
$message = "
|
||||
<p>A new customer has registered on truckwash.io.</p>
|
||||
<p>
|
||||
<strong>Customer number:</strong> $safeCustomerNumber<br>
|
||||
<strong>Customer name:</strong> $safeCustomerName
|
||||
</p>
|
||||
<p><a href='$customerUrl'>Open customer in Superuser</a></p>
|
||||
";
|
||||
|
||||
foreach ((new users_o())->getSuperuserNewCustomerEmailNotificationRecipients() as $recipient) {
|
||||
$recipientEmail = trim((string)($recipient['email'] ?? ''));
|
||||
if ($recipientEmail === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$recipientName = trim((string)($recipient['display_name'] ?? ''));
|
||||
if ($recipientName === '') {
|
||||
$recipientName = $recipientEmail;
|
||||
}
|
||||
|
||||
$this->sendEmail(
|
||||
$recipientEmail,
|
||||
$recipientName,
|
||||
'New customer registered on Truck Wash',
|
||||
$message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,12 +92,17 @@ class error_report_service
|
||||
throw new RuntimeException('Data collection acceptance is required.');
|
||||
}
|
||||
|
||||
$screenshot = self::decodeScreenshotDataUri((string)($payload['screenshot'] ?? ''));
|
||||
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
|
||||
$context = is_array($payload['context'] ?? null) ? $payload['context'] : [];
|
||||
$storedScreenshot = $this->storeOptionalScreenshot($payload['screenshot'] ?? null, $context);
|
||||
$requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25);
|
||||
$vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25);
|
||||
$runtimeContext = $this->runtimeContext($payload, $context);
|
||||
$runtimeContext['screenshot_attachment'] = [
|
||||
'status' => $storedScreenshot['status'],
|
||||
'attached' => $storedScreenshot['key'] !== '',
|
||||
'mime_type' => $storedScreenshot['mime_type'] !== '' ? $storedScreenshot['mime_type'] : null,
|
||||
'size_bytes' => (int)$storedScreenshot['size_bytes'],
|
||||
];
|
||||
|
||||
$this->execute(
|
||||
"INSERT INTO error_reports (
|
||||
@@ -295,6 +300,67 @@ class error_report_service
|
||||
return $value === true || $value === 1 || $value === '1' || $value === 'true';
|
||||
}
|
||||
|
||||
private function storeOptionalScreenshot(mixed $value, array $context): array
|
||||
{
|
||||
if (!is_scalar($value) && !$value instanceof \Stringable && $value !== null) {
|
||||
return $this->emptyScreenshotAttachment('invalid');
|
||||
}
|
||||
|
||||
$dataUri = trim((string)($value ?? ''));
|
||||
if ($dataUri === '') {
|
||||
return $this->emptyScreenshotAttachment($this->contextScreenshotStatus($context) ?? 'not_provided');
|
||||
}
|
||||
|
||||
try {
|
||||
$screenshot = self::decodeScreenshotDataUri($dataUri);
|
||||
} catch (RuntimeException $exception) {
|
||||
$message = strtolower($exception->getMessage());
|
||||
return $this->emptyScreenshotAttachment(str_contains($message, 'too large') ? 'too_large' : 'invalid');
|
||||
}
|
||||
|
||||
try {
|
||||
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
|
||||
} catch (Throwable) {
|
||||
return $this->emptyScreenshotAttachment('storage_failed');
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => (string)($storedScreenshot['key'] ?? ''),
|
||||
'mime_type' => (string)($storedScreenshot['mime_type'] ?? $screenshot['mime_type']),
|
||||
'size_bytes' => (int)($storedScreenshot['size_bytes'] ?? $screenshot['size_bytes']),
|
||||
'status' => 'stored',
|
||||
];
|
||||
}
|
||||
|
||||
private function emptyScreenshotAttachment(string $status): array
|
||||
{
|
||||
return [
|
||||
'key' => '',
|
||||
'mime_type' => '',
|
||||
'size_bytes' => 0,
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
|
||||
private function contextScreenshotStatus(array $context): ?string
|
||||
{
|
||||
$attachment = $context['screenshot_attachment'] ?? null;
|
||||
$status = is_array($attachment) ? ($attachment['status'] ?? null) : null;
|
||||
$status ??= $context['screenshot_capture_status'] ?? $context['screenshot_status'] ?? null;
|
||||
|
||||
return $this->normalizeEmptyScreenshotStatus($status);
|
||||
}
|
||||
|
||||
private function normalizeEmptyScreenshotStatus(mixed $status): ?string
|
||||
{
|
||||
$status = strtolower(trim((string)$status));
|
||||
if (in_array($status, ['capture_failed', 'not_provided'], true)) {
|
||||
return $status;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function runtimeContext(array $payload, array $context): array
|
||||
{
|
||||
return [
|
||||
@@ -432,6 +498,10 @@ class error_report_service
|
||||
|
||||
private function publicReport(array $row, bool $includeDetail): array
|
||||
{
|
||||
$screenshotMimeType = trim((string)($row['screenshot_mime_type'] ?? ''));
|
||||
$screenshotSizeBytes = isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0;
|
||||
$hasScreenshot = $screenshotMimeType !== '' && $screenshotSizeBytes > 0;
|
||||
|
||||
$report = [
|
||||
'id' => (int)$row['id'],
|
||||
'status' => (string)$row['status'],
|
||||
@@ -449,10 +519,10 @@ class error_report_service
|
||||
'release_trace_id' => $row['release_trace_id'] ?? null,
|
||||
'frontend_version' => $row['frontend_version'] ?? null,
|
||||
'api_version' => $row['api_version'] ?? null,
|
||||
'screenshot' => [
|
||||
'mime_type' => $row['screenshot_mime_type'] ?? null,
|
||||
'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0,
|
||||
],
|
||||
'screenshot' => $hasScreenshot ? [
|
||||
'mime_type' => $screenshotMimeType,
|
||||
'size_bytes' => $screenshotSizeBytes,
|
||||
] : null,
|
||||
'answers' => [
|
||||
'before_error' => $row['before_error'] ?? '',
|
||||
'expected' => $row['expected'] ?? '',
|
||||
@@ -467,8 +537,11 @@ class error_report_service
|
||||
];
|
||||
|
||||
if ($includeDetail) {
|
||||
$report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? ''));
|
||||
$report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null;
|
||||
if ($hasScreenshot) {
|
||||
$objectKey = trim((string)($row['screenshot_object_key'] ?? ''));
|
||||
$report['screenshot']['url'] = $this->store->screenshotUrl($objectKey);
|
||||
$report['screenshot']['object_key'] = $objectKey !== '' ? $objectKey : null;
|
||||
}
|
||||
$report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
|
||||
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
|
||||
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
|
||||
|
||||
@@ -11,6 +11,7 @@ use fxratesapi\actions\convert_rate_a;
|
||||
use fxratesapi\fxratesapi_c;
|
||||
use interfaces\fxratesapi_i;
|
||||
use objects\fxratesapi_conversion_rates_o;
|
||||
use Throwable;
|
||||
|
||||
class fxratesapi implements fxratesapi_i
|
||||
{
|
||||
@@ -117,10 +118,10 @@ class fxratesapi implements fxratesapi_i
|
||||
// Validate the base and target currencies
|
||||
self::requireValidCurrency($base);
|
||||
self::requireValidCurrency($target);
|
||||
// Validate the daily limit
|
||||
self::requireDailyLimitNotExceeded();
|
||||
// Validate the secret key
|
||||
self::requireValidSecretKey();
|
||||
// Reserve quota for the outbound provider call. Cached conversion reads return before this point.
|
||||
$this->reserveRateFetchQuota($base, $target, $endpoint, $method);
|
||||
// Send the request
|
||||
$response = match ($method) {
|
||||
'GET' => self::sendGetRequest($base, $target, $endpoint, $data),
|
||||
@@ -167,7 +168,7 @@ class fxratesapi implements fxratesapi_i
|
||||
function requireDailyLimitNotExceeded(): void
|
||||
{
|
||||
// Check if the daily limit is exceeded
|
||||
if ($this->getDailyRequestCounter() >= $this->config->daily_limit->getVariableValue()) {
|
||||
if ($this->getDailyRequestCounter() >= (int)$this->config->daily_limit->getVariableValue()) {
|
||||
throw new Exception('Daily limit exceeded');
|
||||
}
|
||||
}
|
||||
@@ -177,12 +178,30 @@ class fxratesapi implements fxratesapi_i
|
||||
*/
|
||||
function getDailyRequestCounter(): int
|
||||
{
|
||||
try {
|
||||
return (new module_usage_service())->currentUsedQuantity('fxratesapi', 'rate_fetch_calls');
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
// Count the rows from the fxratesapi request log that was made today
|
||||
$fxratesapi_lookups = new fxratesapi_conversion_rates_o();
|
||||
$fxratesapi_lookups->getTodayCount();
|
||||
return $fxratesapi_lookups->getTodayCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function reserveRateFetchQuota(string $base, string $target, string $endpoint, string $method): void
|
||||
{
|
||||
(new module_usage_service())->reserveOrFail('fxratesapi', 'rate_fetch_calls', 1, [
|
||||
'base' => $base,
|
||||
'target' => $target,
|
||||
'endpoint' => $endpoint,
|
||||
'method' => strtoupper($method),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
@@ -515,4 +534,4 @@ class fxratesapi implements fxratesapi_i
|
||||
throw new Exception('Failed to add request to log');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ class gateway_shelly_transport implements shelly_transport_i
|
||||
return match ($endpoint) {
|
||||
'/v2/devices/api/get' => $this->handleGetStates($department_id, $data),
|
||||
'/v2/devices/api/set/switch' => $this->handleSetSwitch($department_id, $data),
|
||||
'/v2/devices/api/batch/get' => $this->handleBatchGetStates($department_id, $data),
|
||||
'/v2/devices/api/batch/set/switch' => $this->handleBatchSetSwitch($department_id, $data),
|
||||
default => throw new Exception('Unsupported gateway Shelly transport endpoint: ' . $endpoint),
|
||||
};
|
||||
}
|
||||
@@ -95,6 +97,52 @@ class gateway_shelly_transport implements shelly_transport_i
|
||||
return [$this->normalizeRelayPayload($logicalRelayId, $status)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function handleBatchGetStates(int $departmentId, array $data): array
|
||||
{
|
||||
$requests = [];
|
||||
foreach ((array)($data['commands'] ?? $data['targets'] ?? []) as $entry) {
|
||||
$command = is_array($entry) ? $entry : ['relay_id' => $entry];
|
||||
$relayId = trim((string)($command['relay_id'] ?? $command['relayId'] ?? $command['id'] ?? ''));
|
||||
if ($relayId === '') {
|
||||
continue;
|
||||
}
|
||||
$requests[] = [
|
||||
'target' => strtoupper(trim((string)($command['target'] ?? $relayId))),
|
||||
'relay_id' => $relayId,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->manager()->queueRelayStatusBatch($departmentId, $requests, null, $this->localOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function handleBatchSetSwitch(int $departmentId, array $data): array
|
||||
{
|
||||
$requests = [];
|
||||
foreach ((array)($data['commands'] ?? []) as $entry) {
|
||||
if (!is_array($entry)) {
|
||||
continue;
|
||||
}
|
||||
$relayId = trim((string)($entry['relay_id'] ?? $entry['relayId'] ?? $entry['id'] ?? ''));
|
||||
if ($relayId === '') {
|
||||
continue;
|
||||
}
|
||||
$requests[] = [
|
||||
'target' => strtoupper(trim((string)($entry['target'] ?? $relayId))),
|
||||
'relay_id' => $relayId,
|
||||
'on' => (bool)($entry['on'] ?? false),
|
||||
'toggle_after' => $entry['toggle_after'] ?? $entry['toggleAfter'] ?? $entry['timer'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->manager()->queueRelaySwitchBatch($departmentId, $requests, null, $this->localOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $status
|
||||
* @return array<string,mixed>
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
use objects\collected_order_invoices_o;
|
||||
use objects\logs_o;
|
||||
use objects\order_items_o;
|
||||
use objects\orders_o;
|
||||
use objects\products_o;
|
||||
use objects\users_o;
|
||||
|
||||
class invoice_collection_bulk_action_service
|
||||
{
|
||||
public const ACTION_CLEAN_CUSTOMER_RULES = 'remove_customer_rule_violations';
|
||||
public const ACTION_MERGE = 'merge_collections';
|
||||
public const ACTION_SPLIT_BY_MONTH = 'split_by_month';
|
||||
public const ACTION_RESET_HIDDEN_PRICES = 'reset_hidden_item_prices';
|
||||
public const ACTION_QUEUE_ECONOMIC = 'queue_economic';
|
||||
|
||||
private const PREVIEW_TTL_SECONDS = 600;
|
||||
private const MAX_COLLECTIONS = 100;
|
||||
private const CONFIRMATION_PHRASES = [
|
||||
'da' => 'Bekræft',
|
||||
'en' => 'Confirm',
|
||||
'sv' => 'Bekräfta',
|
||||
'no' => 'Bekreft',
|
||||
'de' => 'Bestätigen',
|
||||
];
|
||||
|
||||
public function preview(string $action, array $invoiceCollectionIds, array $options = [], string $locale = 'da'): array
|
||||
{
|
||||
$action = $this->normalizeAction($action);
|
||||
$invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
|
||||
$options = $this->normalizeOptions($options);
|
||||
|
||||
$preview = $this->buildPreview($action, $invoiceCollectionIds, $options, $locale);
|
||||
$previewId = $this->previewId();
|
||||
$preview['preview_id'] = $previewId;
|
||||
$preview['selection_hash'] = $this->selectionHash($action, $invoiceCollectionIds, $options);
|
||||
$preview['confirmation_phrase'] = $this->confirmationPhrase($locale);
|
||||
|
||||
$this->cachePreview($previewId, [
|
||||
'action' => $action,
|
||||
'invoice_collection_ids' => $invoiceCollectionIds,
|
||||
'options' => $options,
|
||||
'locale' => $locale,
|
||||
'selection_hash' => $preview['selection_hash'],
|
||||
'preview' => $preview,
|
||||
]);
|
||||
|
||||
return $preview;
|
||||
}
|
||||
|
||||
public function apply(
|
||||
string $previewId,
|
||||
string $action,
|
||||
array $invoiceCollectionIds,
|
||||
array $options,
|
||||
string $confirmationText,
|
||||
int $actorUserId,
|
||||
string $locale = 'da'
|
||||
): array {
|
||||
global $db;
|
||||
|
||||
$action = $this->normalizeAction($action);
|
||||
$invoiceCollectionIds = $this->normalizeInvoiceCollectionIds($invoiceCollectionIds);
|
||||
$options = $this->normalizeOptions($options);
|
||||
$cached = $this->getCachedPreview($previewId);
|
||||
$selectionHash = $this->selectionHash($action, $invoiceCollectionIds, $options);
|
||||
|
||||
if (!$cached || ($cached['selection_hash'] ?? '') !== $selectionHash) {
|
||||
throw new Exception('Preview is missing, expired, or no longer matches the selected invoice collections.');
|
||||
}
|
||||
|
||||
$expectedConfirmation = (string)($cached['preview']['confirmation_phrase'] ?? $this->confirmationPhrase($locale));
|
||||
if (trim($confirmationText) !== $expectedConfirmation) {
|
||||
throw new Exception('Confirmation text does not match.');
|
||||
}
|
||||
|
||||
$freshPreview = $this->buildPreview($action, $invoiceCollectionIds, $options, (string)($cached['locale'] ?? $locale));
|
||||
if (!empty($freshPreview['blockers'])) {
|
||||
throw new Exception('Action cannot be applied while blockers are present.');
|
||||
}
|
||||
|
||||
$db->conn()->begin_transaction();
|
||||
try {
|
||||
$result = match ($action) {
|
||||
self::ACTION_CLEAN_CUSTOMER_RULES => $this->applyCleanCustomerRules($freshPreview),
|
||||
self::ACTION_MERGE => $this->applyMerge($freshPreview, $options),
|
||||
self::ACTION_SPLIT_BY_MONTH => $this->applySplitByMonth($freshPreview),
|
||||
self::ACTION_RESET_HIDDEN_PRICES => $this->applyResetHiddenPrices($freshPreview),
|
||||
self::ACTION_QUEUE_ECONOMIC => [
|
||||
'queued_invoice_collection_ids' => $invoiceCollectionIds,
|
||||
'changed_count' => count($invoiceCollectionIds),
|
||||
],
|
||||
default => throw new Exception('Unsupported action'),
|
||||
};
|
||||
(new logs_o())->add(
|
||||
'orderInvoices',
|
||||
'global',
|
||||
1,
|
||||
$actorUserId,
|
||||
'APPLY_COLLECTED_INVOICE_BULK_ACTION',
|
||||
'Applied collected invoice bulk action ' . $action . ' to ' . count($invoiceCollectionIds) . ' invoice collections'
|
||||
);
|
||||
$db->conn()->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$db->conn()->rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$this->deleteCachedPreview($previewId);
|
||||
|
||||
return [
|
||||
...$freshPreview,
|
||||
'preview' => false,
|
||||
'result' => $result,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildPreview(string $action, array $invoiceCollectionIds, array $options, string $locale): array
|
||||
{
|
||||
$collections = $this->loadCollections($invoiceCollectionIds);
|
||||
$base = [
|
||||
'action' => $action,
|
||||
'preview' => true,
|
||||
'confirmation_phrase' => $this->confirmationPhrase($locale),
|
||||
'invoice_collection_ids' => $invoiceCollectionIds,
|
||||
'collections' => array_map(fn(collected_order_invoices_o $collection): array => $this->collectionSummary($collection), $collections),
|
||||
'warnings' => [],
|
||||
'blockers' => [],
|
||||
];
|
||||
|
||||
return match ($action) {
|
||||
self::ACTION_CLEAN_CUSTOMER_RULES => $this->previewCleanCustomerRules($base, $collections),
|
||||
self::ACTION_MERGE => $this->previewMerge($base, $collections, $options),
|
||||
self::ACTION_SPLIT_BY_MONTH => $this->previewSplitByMonth($base, $collections),
|
||||
self::ACTION_RESET_HIDDEN_PRICES => $this->previewResetHiddenPrices($base, $collections),
|
||||
self::ACTION_QUEUE_ECONOMIC => $this->previewQueueEconomic($base, $collections),
|
||||
default => throw new Exception('Unsupported action'),
|
||||
};
|
||||
}
|
||||
|
||||
private function previewCleanCustomerRules(array $preview, array $collections): array
|
||||
{
|
||||
$items = [];
|
||||
$blockers = [];
|
||||
foreach ($collections as $collection) {
|
||||
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
|
||||
$rows = $this->orderItemRows((int)$collection->id);
|
||||
$violatingItemIds = [];
|
||||
$includedItemIds = [];
|
||||
foreach ($rows as $row) {
|
||||
$violation = (new customer_product_rule_service())->firstViolationForOrderItem(
|
||||
(int)$row['order_id'],
|
||||
(int)$row['product_id'],
|
||||
empty($row['related_item_id']) ? null : (int)$row['related_item_id']
|
||||
);
|
||||
if ($violation === null) {
|
||||
continue;
|
||||
}
|
||||
$violatingItemIds[] = (int)$row['order_item_id'];
|
||||
$includedItemIds[] = (int)$row['order_item_id'];
|
||||
$items[] = [
|
||||
'invoice_collection_id' => (int)$collection->id,
|
||||
'order_id' => (int)$row['order_id'],
|
||||
'order_item_id' => (int)$row['order_item_id'],
|
||||
'product_id' => (int)$row['product_id'],
|
||||
'product_name' => (string)$row['product_name'],
|
||||
'rule' => (string)$violation['rule'],
|
||||
'price' => (int)$row['price'],
|
||||
'quantity' => (int)$row['quantity'],
|
||||
'will_soft_delete' => true,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$orderItemId = (int)$row['order_item_id'];
|
||||
$relatedItemId = empty($row['related_item_id']) ? null : (int)$row['related_item_id'];
|
||||
if ($relatedItemId === null || !in_array($relatedItemId, $violatingItemIds, true) || in_array($orderItemId, $includedItemIds, true)) {
|
||||
continue;
|
||||
}
|
||||
$includedItemIds[] = $orderItemId;
|
||||
$items[] = [
|
||||
'invoice_collection_id' => (int)$collection->id,
|
||||
'order_id' => (int)$row['order_id'],
|
||||
'order_item_id' => $orderItemId,
|
||||
'product_id' => (int)$row['product_id'],
|
||||
'product_name' => (string)$row['product_name'],
|
||||
'rule' => 'related_to_removed_item',
|
||||
'price' => (int)$row['price'],
|
||||
'quantity' => (int)$row['quantity'],
|
||||
'will_soft_delete' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...$preview,
|
||||
'order_items' => $items,
|
||||
'summary' => [
|
||||
'collections' => count($collections),
|
||||
'order_items' => count($items),
|
||||
'changed_count' => count($items),
|
||||
],
|
||||
'blockers' => $blockers,
|
||||
];
|
||||
}
|
||||
|
||||
private function previewMerge(array $preview, array $collections, array $options): array
|
||||
{
|
||||
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
|
||||
$blockers = [];
|
||||
if (count($collections) < 2) {
|
||||
$blockers[] = ['code' => 'merge_requires_multiple_collections', 'message' => 'Merge requires at least two invoice collections.'];
|
||||
}
|
||||
if ($targetId < 1 || !in_array($targetId, array_map(static fn($collection): int => (int)$collection->id, $collections), true)) {
|
||||
$blockers[] = ['code' => 'invalid_merge_target', 'message' => 'A selected invoice collection must be chosen as merge target.'];
|
||||
}
|
||||
$customerNumbers = array_values(array_unique(array_map(static fn($collection): int => (int)$collection->customer_number->value(), $collections)));
|
||||
if (count($customerNumbers) !== 1) {
|
||||
$blockers[] = ['code' => 'merge_cross_customer', 'message' => 'Only invoice collections for the same customer can be merged.'];
|
||||
}
|
||||
foreach ($collections as $collection) {
|
||||
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
|
||||
}
|
||||
|
||||
$ordersToMove = [];
|
||||
foreach ($collections as $collection) {
|
||||
if ((int)$collection->id === $targetId) {
|
||||
continue;
|
||||
}
|
||||
foreach ($collection->getOrderIds() as $orderIdRow) {
|
||||
$ordersToMove[] = [
|
||||
'order_id' => (int)$orderIdRow['id'],
|
||||
'source_invoice_collection_id' => (int)$collection->id,
|
||||
'target_invoice_collection_id' => $targetId,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...$preview,
|
||||
'target_invoice_collection_id' => $targetId,
|
||||
'orders' => $ordersToMove,
|
||||
'summary' => [
|
||||
'collections' => count($collections),
|
||||
'orders_to_move' => count($ordersToMove),
|
||||
'changed_count' => count($ordersToMove),
|
||||
],
|
||||
'blockers' => $blockers,
|
||||
];
|
||||
}
|
||||
|
||||
private function previewSplitByMonth(array $preview, array $collections): array
|
||||
{
|
||||
$items = [];
|
||||
$changed = [];
|
||||
$skipped = [];
|
||||
foreach ($collections as $collection) {
|
||||
try {
|
||||
$item = [
|
||||
'invoice_collection_id' => (int)$collection->id,
|
||||
...$collection->previewSplitByOrderMonth(),
|
||||
];
|
||||
if (($item['status'] ?? '') === 'changed') {
|
||||
$changed[] = $item;
|
||||
} else {
|
||||
$skipped[] = $item;
|
||||
}
|
||||
$items[] = $item;
|
||||
} catch (\Throwable $e) {
|
||||
$item = [
|
||||
'status' => 'skipped',
|
||||
'invoice_collection_id' => (int)$collection->id,
|
||||
'reason' => 'not_splittable',
|
||||
'message' => $e->getMessage(),
|
||||
];
|
||||
$skipped[] = $item;
|
||||
$items[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...$preview,
|
||||
'items' => $items,
|
||||
'changed' => $changed,
|
||||
'skipped' => $skipped,
|
||||
'summary' => [
|
||||
'collections' => count($collections),
|
||||
'changed_count' => count($changed),
|
||||
'skipped_count' => count($skipped),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function previewResetHiddenPrices(array $preview, array $collections): array
|
||||
{
|
||||
$items = [];
|
||||
$blockers = [];
|
||||
foreach ($collections as $collection) {
|
||||
$blockers = [...$blockers, ...$this->contentMutationBlockers($collection)];
|
||||
foreach ($this->orderItemRows((int)$collection->id, true) as $row) {
|
||||
if ((int)$row['include_in_invoice'] !== 0) {
|
||||
continue;
|
||||
}
|
||||
$order = (new orders_o())->select((int)$row['order_id']);
|
||||
$product = (new products_o())->select((int)$row['product_id']);
|
||||
if (!$order->exists() || !$product->exists()) {
|
||||
continue;
|
||||
}
|
||||
$newPrice = (int)$order->getCustomerProductPrice($product);
|
||||
if ((int)$row['price'] === $newPrice) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'invoice_collection_id' => (int)$collection->id,
|
||||
'order_id' => (int)$row['order_id'],
|
||||
'order_item_id' => (int)$row['order_item_id'],
|
||||
'product_id' => (int)$row['product_id'],
|
||||
'product_name' => (string)$row['product_name'],
|
||||
'current_price' => (int)$row['price'],
|
||||
'new_price' => $newPrice,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...$preview,
|
||||
'order_items' => $items,
|
||||
'summary' => [
|
||||
'collections' => count($collections),
|
||||
'order_items' => count($items),
|
||||
'changed_count' => count($items),
|
||||
],
|
||||
'blockers' => $blockers,
|
||||
];
|
||||
}
|
||||
|
||||
private function previewQueueEconomic(array $preview, array $collections): array
|
||||
{
|
||||
$blockers = [];
|
||||
foreach ($collections as $collection) {
|
||||
if (!empty($collection->booked_invoice_id->value())) {
|
||||
$blockers[] = [
|
||||
'code' => 'collection_booked',
|
||||
'invoice_collection_id' => (int)$collection->id,
|
||||
'message' => 'Invoice collection is already booked.',
|
||||
];
|
||||
}
|
||||
}
|
||||
return [
|
||||
...$preview,
|
||||
'summary' => [
|
||||
'collections' => count($collections),
|
||||
'changed_count' => count($collections),
|
||||
],
|
||||
'blockers' => $blockers,
|
||||
];
|
||||
}
|
||||
|
||||
private function applyCleanCustomerRules(array $preview): array
|
||||
{
|
||||
global $db;
|
||||
$itemIds = array_values(array_unique(array_map(static fn(array $item): int => (int)$item['order_item_id'], $preview['order_items'] ?? [])));
|
||||
if ($itemIds === []) {
|
||||
return ['changed_count' => 0, 'order_item_ids' => []];
|
||||
}
|
||||
$ids = implode(',', array_map('intval', $itemIds));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$safeNow = $db->escape_string($now);
|
||||
$db->query("UPDATE order_items SET deleted_at = '$safeNow' WHERE deleted_at IS NULL AND id IN ($ids)");
|
||||
$this->touchOrdersForItems($itemIds);
|
||||
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
|
||||
return ['changed_count' => count($itemIds), 'order_item_ids' => $itemIds];
|
||||
}
|
||||
|
||||
private function applyMerge(array $preview, array $options): array
|
||||
{
|
||||
$targetId = (int)($options['target_invoice_collection_id'] ?? 0);
|
||||
$moved = [];
|
||||
foreach ($preview['orders'] ?? [] as $row) {
|
||||
$order = (new orders_o())->select((int)$row['order_id']);
|
||||
if (!$order->exists()) {
|
||||
continue;
|
||||
}
|
||||
$order->assignToInvoiceCollection($targetId);
|
||||
$moved[] = (int)$order->id;
|
||||
}
|
||||
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
|
||||
return ['changed_count' => count($moved), 'moved_order_ids' => $moved, 'target_invoice_collection_id' => $targetId];
|
||||
}
|
||||
|
||||
private function applySplitByMonth(array $preview): array
|
||||
{
|
||||
$changed = [];
|
||||
$skipped = [];
|
||||
foreach ($preview['items'] ?? [] as $item) {
|
||||
$invoiceCollectionId = (int)($item['invoice_collection_id'] ?? 0);
|
||||
if (($item['status'] ?? '') !== 'changed' || $invoiceCollectionId < 1) {
|
||||
$skipped[] = $item;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$changed[] = (new collected_order_invoices_o())->select($invoiceCollectionId)->splitByOrderMonth();
|
||||
} catch (\Throwable $e) {
|
||||
$skipped[] = [
|
||||
'invoice_collection_id' => $invoiceCollectionId,
|
||||
'status' => 'skipped',
|
||||
'message' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
return ['changed_count' => count($changed), 'skipped_count' => count($skipped), 'changed' => $changed, 'skipped' => $skipped];
|
||||
}
|
||||
|
||||
private function applyResetHiddenPrices(array $preview): array
|
||||
{
|
||||
$changed = [];
|
||||
foreach ($preview['order_items'] ?? [] as $item) {
|
||||
$orderItem = (new order_items_o())->select((int)$item['order_item_id']);
|
||||
if (!$orderItem->exists()) {
|
||||
continue;
|
||||
}
|
||||
$orderItem->price->set((int)$item['new_price']);
|
||||
$orderItem->objectChanged();
|
||||
$changed[] = (int)$orderItem->id;
|
||||
}
|
||||
$this->touchCollections($preview['invoice_collection_ids'] ?? []);
|
||||
return ['changed_count' => count($changed), 'order_item_ids' => $changed];
|
||||
}
|
||||
|
||||
private function contentMutationBlockers(collected_order_invoices_o $collection): array
|
||||
{
|
||||
$blockers = [];
|
||||
if (!empty($collection->booked_invoice_id->value())) {
|
||||
$blockers[] = ['code' => 'collection_booked', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection is already booked.'];
|
||||
}
|
||||
if (!empty($collection->external_id->value())) {
|
||||
$blockers[] = ['code' => 'collection_exported', 'invoice_collection_id' => (int)$collection->id, 'message' => 'Invoice collection already has an external invoice reference.'];
|
||||
}
|
||||
return $blockers;
|
||||
}
|
||||
|
||||
private function orderItemRows(int $invoiceCollectionId, bool $includeHidden = false): array
|
||||
{
|
||||
global $db;
|
||||
$hiddenCondition = $includeHidden ? '' : 'AND oi.include_in_invoice = 1';
|
||||
$sql = "
|
||||
SELECT
|
||||
oi.id AS order_item_id,
|
||||
oi.order_id,
|
||||
oi.product_id,
|
||||
oi.related_item_id,
|
||||
oi.include_in_invoice,
|
||||
oi.price,
|
||||
oi.quantity,
|
||||
p.name AS product_name
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN products p ON p.id = oi.product_id
|
||||
WHERE o.invoice_collection_id = {$invoiceCollectionId}
|
||||
AND o.deleted_at IS NULL
|
||||
AND oi.deleted_at IS NULL
|
||||
{$hiddenCondition}
|
||||
ORDER BY o.id ASC, oi.id ASC
|
||||
";
|
||||
$result = $db->query($sql);
|
||||
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
}
|
||||
|
||||
private function touchOrdersForItems(array $itemIds): void
|
||||
{
|
||||
global $db;
|
||||
if ($itemIds === []) {
|
||||
return;
|
||||
}
|
||||
$ids = implode(',', array_map('intval', $itemIds));
|
||||
$result = $db->query("SELECT DISTINCT order_id FROM order_items WHERE id IN ($ids)");
|
||||
if (!$result) {
|
||||
return;
|
||||
}
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$order = (new orders_o())->select((int)$row['order_id']);
|
||||
if ($order->exists()) {
|
||||
$order->objectChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function touchCollections(array $invoiceCollectionIds): void
|
||||
{
|
||||
foreach ($invoiceCollectionIds as $invoiceCollectionId) {
|
||||
$collection = (new collected_order_invoices_o())->select((int)$invoiceCollectionId);
|
||||
if ($collection->exists()) {
|
||||
$collection->objectChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function loadCollections(array $invoiceCollectionIds): array
|
||||
{
|
||||
return array_map(static function (int $invoiceCollectionId): collected_order_invoices_o {
|
||||
$collection = (new collected_order_invoices_o())->select($invoiceCollectionId);
|
||||
$collection->requireSelected();
|
||||
return $collection;
|
||||
}, $invoiceCollectionIds);
|
||||
}
|
||||
|
||||
private function collectionSummary(collected_order_invoices_o $collection): array
|
||||
{
|
||||
return [
|
||||
'id' => (int)$collection->id,
|
||||
'customer_number' => (int)$collection->customer_number->value(),
|
||||
'name' => (string)$collection->name->value(),
|
||||
'created_at' => (string)$collection->created_at->value(),
|
||||
'closed_at' => $collection->closed_at->value(),
|
||||
'booked_invoice_id' => $collection->booked_invoice_id->value(),
|
||||
'external_id' => $collection->external_id->value(),
|
||||
'order_count' => (int)$collection->getOrders(true),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeAction(string $action): string
|
||||
{
|
||||
$action = trim($action);
|
||||
if (!in_array($action, [
|
||||
self::ACTION_CLEAN_CUSTOMER_RULES,
|
||||
self::ACTION_MERGE,
|
||||
self::ACTION_SPLIT_BY_MONTH,
|
||||
self::ACTION_RESET_HIDDEN_PRICES,
|
||||
self::ACTION_QUEUE_ECONOMIC,
|
||||
], true)) {
|
||||
throw new Exception('Invalid invoice collection bulk action.');
|
||||
}
|
||||
return $action;
|
||||
}
|
||||
|
||||
private function normalizeInvoiceCollectionIds(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($ids as $id) {
|
||||
if (is_array($id) || is_object($id) || !is_numeric($id)) {
|
||||
throw new Exception('invoice_collection_ids must contain only positive integer ids.');
|
||||
}
|
||||
$parsed = (int)$id;
|
||||
if ($parsed < 1 || $parsed > 999999999) {
|
||||
throw new Exception('invoice_collection_ids must contain only positive integer ids.');
|
||||
}
|
||||
$normalized[$parsed] = $parsed;
|
||||
}
|
||||
$normalized = array_values($normalized);
|
||||
sort($normalized);
|
||||
if ($normalized === []) {
|
||||
throw new Exception('invoice_collection_ids must contain at least one id.');
|
||||
}
|
||||
if (count($normalized) > self::MAX_COLLECTIONS) {
|
||||
throw new Exception('Too many invoice collections selected.');
|
||||
}
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function normalizeOptions(array $options): array
|
||||
{
|
||||
if (isset($options['target_invoice_collection_id'])) {
|
||||
$options['target_invoice_collection_id'] = (int)$options['target_invoice_collection_id'];
|
||||
}
|
||||
ksort($options);
|
||||
return $options;
|
||||
}
|
||||
|
||||
private function selectionHash(string $action, array $invoiceCollectionIds, array $options): string
|
||||
{
|
||||
return hash('sha256', json_encode([
|
||||
'action' => $action,
|
||||
'invoice_collection_ids' => $invoiceCollectionIds,
|
||||
'options' => $options,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
private function confirmationPhrase(string $locale): string
|
||||
{
|
||||
$language = strtolower(substr(trim($locale), 0, 2));
|
||||
return self::CONFIRMATION_PHRASES[$language] ?? self::CONFIRMATION_PHRASES['en'];
|
||||
}
|
||||
|
||||
private function previewId(): string
|
||||
{
|
||||
return bin2hex(random_bytes(16));
|
||||
}
|
||||
|
||||
private function previewCacheKey(string $previewId): string
|
||||
{
|
||||
return 'collected_invoice_bulk_action_preview:' . preg_replace('/[^a-f0-9]/', '', strtolower($previewId));
|
||||
}
|
||||
|
||||
private function cachePreview(string $previewId, array $payload): void
|
||||
{
|
||||
(new redis())->setEx($this->previewCacheKey($previewId), json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), self::PREVIEW_TTL_SECONDS);
|
||||
}
|
||||
|
||||
private function getCachedPreview(string $previewId): ?array
|
||||
{
|
||||
$raw = (new redis())->get($this->previewCacheKey($previewId));
|
||||
if (!$raw) {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode($raw, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
private function deleteCachedPreview(string $previewId): void
|
||||
{
|
||||
(new redis())->delete($this->previewCacheKey($previewId));
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ class invoice_period_flag_service
|
||||
public function __construct()
|
||||
{
|
||||
invoice_period_flag_schema_bootstrap::ensureTables();
|
||||
price_overrides_schema_bootstrap::ensureColumns();
|
||||
department_customer_price_overrides_schema_bootstrap::ensureTables();
|
||||
}
|
||||
|
||||
public function createManualFlag(array $payload, int $userId): array
|
||||
@@ -320,6 +322,16 @@ class invoice_period_flag_service
|
||||
}
|
||||
|
||||
public function warmManualFlagsCache(): void
|
||||
{
|
||||
$flags = $this->fetchActiveManualFlagsFromDb();
|
||||
|
||||
try {
|
||||
(new redis())->cache_invoice_period_manual_flags($flags);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private function fetchActiveManualFlagsFromDb(): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
@@ -337,10 +349,7 @@ class invoice_period_flag_service
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
(new redis())->cache_invoice_period_manual_flags($flags);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
return $flags;
|
||||
}
|
||||
|
||||
private function formatStoredFlag(array $row): array
|
||||
@@ -388,15 +397,11 @@ class invoice_period_flag_service
|
||||
}
|
||||
|
||||
if (!is_array($flags)) {
|
||||
// Cache miss — warm on demand and re-fetch
|
||||
$this->warmManualFlagsCache();
|
||||
// Cache miss — read from the database and refresh Redis without hiding active flags.
|
||||
$flags = $this->fetchActiveManualFlagsFromDb();
|
||||
try {
|
||||
$flags = (new redis())->get_invoice_period_manual_flags();
|
||||
(new redis())->cache_invoice_period_manual_flags($flags);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
if (!is_array($flags)) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,16 +530,12 @@ class invoice_period_flag_service
|
||||
try {
|
||||
$flags = (new redis())->get_invoice_period_automatic_flags($dateFrom, $dateTo);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
$flags = null;
|
||||
}
|
||||
|
||||
if (!is_array($flags)) {
|
||||
// Cache miss — enqueue for warming on the next cron run
|
||||
try {
|
||||
(new redis())->enqueue_invoice_period_warming($dateFrom, $dateTo);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
return [];
|
||||
$flags = $this->calculateAutomaticFlagsForPeriod($dateFrom, $dateTo);
|
||||
$this->cacheAutomaticFlagsForPeriod($dateFrom, $dateTo, $flags);
|
||||
}
|
||||
|
||||
if ($onlyCustomerNumbers === null) {
|
||||
@@ -549,17 +550,31 @@ class invoice_period_flag_service
|
||||
|
||||
public function warmAutomaticFlagsForPeriod(string $dateFrom, string $dateTo): void
|
||||
{
|
||||
[$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo);
|
||||
$this->cacheAutomaticFlagsForPeriod(
|
||||
$dateFrom,
|
||||
$dateTo,
|
||||
$this->calculateAutomaticFlagsForPeriod($dateFrom, $dateTo)
|
||||
);
|
||||
}
|
||||
|
||||
private function calculateAutomaticFlagsForPeriod(string $dateFrom, string $dateTo): array
|
||||
{
|
||||
[$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo);
|
||||
$rows = $this->getPeriodOrderItemRows($dateFrom, $dateTo, null);
|
||||
$attributes = $this->getCustomerAttributes(null);
|
||||
|
||||
$flags = array_merge(
|
||||
return array_merge(
|
||||
$this->detectCustomerRuleViolations($rows, $attributes),
|
||||
$this->detectPriceMismatches($rows),
|
||||
$this->detectAbnormalQuantities($rows, $dateFrom, $dateTo),
|
||||
$this->detectVehicleTypeMismatches($rows, $dateFrom),
|
||||
$this->detectMissingXlVaskLinks($dateFrom, $dateTo, null)
|
||||
);
|
||||
}
|
||||
|
||||
private function cacheAutomaticFlagsForPeriod(string $dateFrom, string $dateTo, array $flags): void
|
||||
{
|
||||
try {
|
||||
(new redis())->cache_invoice_period_automatic_flags($dateFrom, $dateTo, $flags);
|
||||
} catch (Throwable) {
|
||||
@@ -603,14 +618,19 @@ class invoice_period_flag_service
|
||||
|
||||
private function getPeriodOrderItemRows(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array
|
||||
{
|
||||
[$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo);
|
||||
try {
|
||||
$rows = (new redis())->get_invoice_period_order_item_rows($dateFrom, $dateTo);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
$rows = null;
|
||||
}
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
$rows = $this->fetchOrderItemRowsFromDb($dateFrom, $dateTo);
|
||||
try {
|
||||
(new redis())->cache_invoice_period_order_item_rows($dateFrom, $dateTo, $rows);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
$this->seedOrderItemsPreviewCacheFromRows($rows);
|
||||
@@ -627,6 +647,7 @@ class invoice_period_flag_service
|
||||
|
||||
public function warmOrderItemRowsForPeriod(string $dateFrom, string $dateTo): void
|
||||
{
|
||||
[$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo);
|
||||
$rows = $this->fetchOrderItemRowsFromDb($dateFrom, $dateTo);
|
||||
try {
|
||||
(new redis())->cache_invoice_period_order_item_rows($dateFrom, $dateTo, $rows);
|
||||
@@ -634,6 +655,24 @@ class invoice_period_flag_service
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizePeriodDateRange(string $dateFrom, string $dateTo): array
|
||||
{
|
||||
return [
|
||||
$this->normalizePeriodDate($dateFrom, true),
|
||||
$this->normalizePeriodDate($dateTo, false),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizePeriodDate(string $date, bool $startOfDay): string
|
||||
{
|
||||
$timestamp = strtotime($date);
|
||||
if ($timestamp === false) {
|
||||
return $date;
|
||||
}
|
||||
|
||||
return date($startOfDay ? 'Y-m-d 00:00:00' : 'Y-m-d 23:59:59', $timestamp);
|
||||
}
|
||||
|
||||
private function fetchOrderItemRowsFromDb(string $dateFrom, string $dateTo): array
|
||||
{
|
||||
global $db;
|
||||
@@ -651,6 +690,7 @@ class invoice_period_flag_service
|
||||
o.po AS order_po,
|
||||
o.notes AS order_notes,
|
||||
o.department_id,
|
||||
d.custom_pricing_only AS department_custom_pricing_only,
|
||||
o.reg_1,
|
||||
o.invoice_collection_id,
|
||||
o.wash_id,
|
||||
@@ -673,8 +713,21 @@ class invoice_period_flag_service
|
||||
p.max_quantity_per_order,
|
||||
c.name AS category_name,
|
||||
pdp.price AS department_price,
|
||||
product_discount.percentage AS product_discount_percentage,
|
||||
category_discount.percentage AS category_discount_percentage
|
||||
CASE
|
||||
WHEN d.custom_pricing_only = 1 THEN department_product_discount.percentage
|
||||
ELSE product_discount.percentage
|
||||
END AS product_discount_percentage,
|
||||
CASE
|
||||
WHEN d.custom_pricing_only = 1 THEN department_product_discount.fixed_price
|
||||
ELSE product_discount.fixed_price
|
||||
END AS product_fixed_price,
|
||||
CASE
|
||||
WHEN d.custom_pricing_only = 1 THEN GREATEST(
|
||||
COALESCE(department_category_discount.percentage, 0),
|
||||
COALESCE(department_global_discount.percentage, 0)
|
||||
)
|
||||
ELSE category_discount.percentage
|
||||
END AS category_discount_percentage
|
||||
FROM orders o
|
||||
LEFT JOIN (
|
||||
SELECT customer_number, MIN(id) AS id, MAX(display_name) AS display_name
|
||||
@@ -683,11 +736,12 @@ class invoice_period_flag_service
|
||||
GROUP BY customer_number
|
||||
) u ON u.customer_number = o.customer_id
|
||||
LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '')
|
||||
LEFT JOIN departments d ON d.id = o.department_id
|
||||
LEFT JOIN products p ON p.id = oi.product_id
|
||||
LEFT JOIN categories c ON c.id = p.category
|
||||
LEFT JOIN product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id
|
||||
LEFT JOIN (
|
||||
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage
|
||||
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage, MAX(po.fixed_price) AS fixed_price
|
||||
FROM price_overrides po
|
||||
INNER JOIN users discount_user ON discount_user.id = po.user_id
|
||||
WHERE po.is_category = 0
|
||||
@@ -704,6 +758,32 @@ class invoice_period_flag_service
|
||||
) category_discount
|
||||
ON category_discount.customer_number = o.customer_id
|
||||
AND category_discount.product_or_category_id = p.category
|
||||
LEFT JOIN (
|
||||
SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage, MAX(fixed_price) AS fixed_price
|
||||
FROM department_customer_price_overrides
|
||||
WHERE is_category = 0
|
||||
GROUP BY department_id, user_id, product_or_category_id
|
||||
) department_product_discount
|
||||
ON department_product_discount.department_id = o.department_id
|
||||
AND department_product_discount.user_id = u.id
|
||||
AND department_product_discount.product_or_category_id = p.id
|
||||
LEFT JOIN (
|
||||
SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage
|
||||
FROM department_customer_price_overrides
|
||||
WHERE is_category = 1 AND product_or_category_id <> 'global'
|
||||
GROUP BY department_id, user_id, product_or_category_id
|
||||
) department_category_discount
|
||||
ON department_category_discount.department_id = o.department_id
|
||||
AND department_category_discount.user_id = u.id
|
||||
AND department_category_discount.product_or_category_id = p.category
|
||||
LEFT JOIN (
|
||||
SELECT department_id, user_id, MAX(percentage) AS percentage
|
||||
FROM department_customer_price_overrides
|
||||
WHERE is_category = 1 AND product_or_category_id = 'global'
|
||||
GROUP BY department_id, user_id
|
||||
) department_global_discount
|
||||
ON department_global_discount.department_id = o.department_id
|
||||
AND department_global_discount.user_id = u.id
|
||||
WHERE o.created_at BETWEEN '{$escapedDateFrom}' AND '{$escapedDateTo}'
|
||||
AND o.deleted_at IS NULL
|
||||
ORDER BY o.customer_id, o.id, oi.id";
|
||||
@@ -767,11 +847,16 @@ class invoice_period_flag_service
|
||||
{
|
||||
global $db;
|
||||
|
||||
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
|
||||
|
||||
$customerFilter = $this->customerFilterSql('u.customer_number', $onlyCustomerNumbers);
|
||||
$result = $db->query(
|
||||
"SELECT u.customer_number, ca.attribute
|
||||
"SELECT u.customer_number, ca.attribute, cp.product_id
|
||||
FROM customer_attributes ca
|
||||
JOIN users u ON u.id = ca.user_id
|
||||
LEFT JOIN customer_rule_product_restrictions r ON r.attribute = ca.attribute
|
||||
LEFT JOIN customer_rule_product_collections c ON c.attribute = r.attribute
|
||||
LEFT JOIN customer_rule_product_collection_products cp ON cp.collection_id = c.id
|
||||
WHERE 1=1 {$customerFilter}"
|
||||
);
|
||||
|
||||
@@ -782,7 +867,11 @@ class invoice_period_flag_service
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$customerNumber = (int)$row['customer_number'];
|
||||
$attributes[$customerNumber][(string)$row['attribute']] = true;
|
||||
$attribute = (string)$row['attribute'];
|
||||
$attributes[$customerNumber][$attribute] = true;
|
||||
if ($row['product_id'] !== null && in_array($attribute, customer_rule_product_restriction_service::PRODUCT_IMPACT_ATTRIBUTES, true)) {
|
||||
$attributes[$customerNumber]['__disabled_products'][(int)$row['product_id']][$attribute] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
@@ -793,6 +882,8 @@ class invoice_period_flag_service
|
||||
$flags = [];
|
||||
$orders = [];
|
||||
$collectionOrders = [];
|
||||
$matchingRules = static fn(int $customerNumber, int $productId): array =>
|
||||
array_keys($attributes[$customerNumber]['__disabled_products'][$productId] ?? []);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$customerNumber = (int)$row['customer_number'];
|
||||
@@ -818,57 +909,17 @@ class invoice_period_flag_service
|
||||
continue;
|
||||
}
|
||||
|
||||
$isTankCleaningProduct = $this->rowIsTankCleaningProduct($row);
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'restrictAdditionalServices')
|
||||
&& (int)($row['related_item_id'] ?? 0) > 0
|
||||
&& (int)($row['item_price'] ?? 0) > 0) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'customer_rule_restrict_addon_services',
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
$row,
|
||||
['product' => $this->productLabel($row)],
|
||||
$this->orderItemContext($row)
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'restrictTankCleaning') && $isTankCleaningProduct) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'customer_rule_restrict_tank_cleaning',
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
$row,
|
||||
['product' => $this->productLabel($row)],
|
||||
$this->orderItemContext($row)
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'onlyTankCleaning') && !$isTankCleaningProduct) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'customer_rule_only_tank_cleaning',
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
$row,
|
||||
['product' => $this->productLabel($row)],
|
||||
$this->orderItemContext($row)
|
||||
);
|
||||
}
|
||||
|
||||
$restrictedProducts = [
|
||||
'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree']],
|
||||
'restrictInteriorCleaning' => ['customer_rule_restrict_interior_cleaning', ['interior', 'indvendig']],
|
||||
'exemptFromAdministrationFee' => ['customer_rule_exempt_from_administration_fees', ['administration fee', 'administrationsgebyr', 'administration']],
|
||||
$productRuleDefinitions = [
|
||||
'restrictAdditionalServices' => 'customer_rule_restrict_addon_services',
|
||||
'restrictTankCleaning' => 'customer_rule_restrict_tank_cleaning',
|
||||
'onlyTankCleaning' => 'customer_rule_only_tank_cleaning',
|
||||
'restrictSpotFree' => 'customer_rule_restrict_spot_free',
|
||||
'restrictInteriorCleaning' => 'customer_rule_restrict_interior_cleaning',
|
||||
];
|
||||
|
||||
foreach ($restrictedProducts as $attribute => [$definitionKey, $terms]) {
|
||||
if ($this->hasAttribute($attributes, $customerNumber, $attribute)
|
||||
&& $this->rowMatchesProductTerms($row, $terms)) {
|
||||
foreach ($matchingRules($customerNumber, (int)($row['product_id'] ?? 0)) as $attribute) {
|
||||
if (isset($productRuleDefinitions[$attribute])) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
$definitionKey,
|
||||
$productRuleDefinitions[$attribute],
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
@@ -878,6 +929,19 @@ class invoice_period_flag_service
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'exemptFromAdministrationFee')
|
||||
&& $this->rowMatchesProductTerms($row, ['administration fee', 'administrationsgebyr', 'administration'])) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'customer_rule_exempt_from_administration_fees',
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
$row,
|
||||
['product' => $this->productLabel($row)],
|
||||
$this->orderItemContext($row)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($orders as $orderId => $row) {
|
||||
@@ -1727,18 +1791,7 @@ class invoice_period_flag_service
|
||||
if ($currentVehicleType === '' || $expectedVehicleType === '') {
|
||||
return false;
|
||||
}
|
||||
if ($currentVehicleType === $expectedVehicleType) {
|
||||
return true;
|
||||
}
|
||||
// Allow a match if one normalized name's tokens are a subset of the other.
|
||||
// E.g. "Indvendig vask Kassevogn" → "kassevogn" is a subset of
|
||||
// "Kassevogn/varevogn" → "kassevogn varevogn", meaning the same vehicle type.
|
||||
$currentTokens = explode(' ', $currentVehicleType);
|
||||
$expectedTokens = explode(' ', $expectedVehicleType);
|
||||
if (count($currentTokens) <= count($expectedTokens)) {
|
||||
return array_diff($currentTokens, $expectedTokens) === [];
|
||||
}
|
||||
return array_diff($expectedTokens, $currentTokens) === [];
|
||||
return $currentVehicleType === $expectedVehicleType;
|
||||
}
|
||||
|
||||
private function normalizePrimaryVehicleProductName(string $productName): string
|
||||
@@ -1895,7 +1948,19 @@ class invoice_period_flag_service
|
||||
|
||||
private function calculateExpectedPrice(array $row): int
|
||||
{
|
||||
$base = $row['department_price'] !== null ? (int)$row['department_price'] : (int)($row['product_base_price'] ?? 0);
|
||||
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
|
||||
if ($customMissingPrice) {
|
||||
return \objects\products_o::CUSTOM_PRICING_MISSING_PRICE;
|
||||
}
|
||||
|
||||
$fixedPrice = $this->rowProductFixedPrice($row);
|
||||
if ($fixedPrice !== null) {
|
||||
return $fixedPrice;
|
||||
}
|
||||
|
||||
$base = $row['department_price'] !== null
|
||||
? (int)$row['department_price']
|
||||
: (int)($row['product_base_price'] ?? 0);
|
||||
$discount = $this->discountBreakdown($row)['applied_discount_percentage'];
|
||||
return (int)round($base * (1 - ($discount / 100)));
|
||||
}
|
||||
@@ -1903,13 +1968,18 @@ class invoice_period_flag_service
|
||||
private function priceBreakdown(array $row, int $expected): array
|
||||
{
|
||||
$departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null;
|
||||
$base = $departmentPrice ?? (int)($row['product_base_price'] ?? 0);
|
||||
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
|
||||
$base = $departmentPrice ?? ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0));
|
||||
$discount = $this->discountBreakdown($row);
|
||||
if ($customMissingPrice) {
|
||||
$discount['applied_discount_percentage'] = 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'product_price' => (int)($row['product_base_price'] ?? 0),
|
||||
'product_price' => $customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0),
|
||||
'department_price' => $departmentPrice,
|
||||
'effective_base_price' => $base,
|
||||
'product_fixed_price' => $this->rowProductFixedPrice($row),
|
||||
'product_discount_percentage' => $discount['product_discount_percentage'],
|
||||
'category_discount_percentage' => $discount['category_discount_percentage'],
|
||||
'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'],
|
||||
@@ -1918,21 +1988,36 @@ class invoice_period_flag_service
|
||||
];
|
||||
}
|
||||
|
||||
private function isCustomMissingDepartmentPrice(array $row): bool
|
||||
{
|
||||
return $row['department_price'] === null && (bool)(int)($row['department_custom_pricing_only'] ?? 0);
|
||||
}
|
||||
|
||||
private function discountBreakdown(array $row): array
|
||||
{
|
||||
$productDiscount = (int)($row['product_discount_percentage'] ?? 0);
|
||||
$categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1;
|
||||
$categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0;
|
||||
$economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0;
|
||||
$appliedDiscount = $this->rowProductFixedPrice($row) !== null
|
||||
? 0
|
||||
: max($productDiscount, $categoryDiscount, $economicDiscount);
|
||||
|
||||
return [
|
||||
'product_discount_percentage' => $productDiscount,
|
||||
'category_discount_percentage' => $categoryDiscount,
|
||||
'economic_customer_discount_percentage' => $economicDiscount,
|
||||
'applied_discount_percentage' => max($productDiscount, $categoryDiscount, $economicDiscount),
|
||||
'applied_discount_percentage' => $appliedDiscount,
|
||||
];
|
||||
}
|
||||
|
||||
private function rowProductFixedPrice(array $row): ?int
|
||||
{
|
||||
return array_key_exists('product_fixed_price', $row) && $row['product_fixed_price'] !== null
|
||||
? (int)$row['product_fixed_price']
|
||||
: null;
|
||||
}
|
||||
|
||||
private function economicCustomerDiscountPercentage(array $row): int
|
||||
{
|
||||
$customerNumber = (int)($row['customer_number'] ?? 0);
|
||||
@@ -2008,12 +2093,6 @@ class invoice_period_flag_service
|
||||
return false;
|
||||
}
|
||||
|
||||
private function rowIsTankCleaningProduct(array $row): bool
|
||||
{
|
||||
return (int)($row['product_category'] ?? 0) === 5
|
||||
|| $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
||||
}
|
||||
|
||||
private function isIncludedOrderItem(array $row): bool
|
||||
{
|
||||
$value = $row['item_include_in_invoice'] ?? 1;
|
||||
|
||||
@@ -27,7 +27,7 @@ class invoice_store implements minio_invoices_i
|
||||
return count($objects['Contents'] ?? []) > 0;
|
||||
}
|
||||
|
||||
public function getInvoiceDownloadUrl(int $id): string
|
||||
public function getInvoiceDownloadUrl(int|string $id): string
|
||||
{
|
||||
return self::getPresignedUrl('invoice_' . $id . '.pdf');
|
||||
}
|
||||
@@ -51,4 +51,4 @@ class invoice_store implements minio_invoices_i
|
||||
{
|
||||
return self::getS3Client()->doesObjectExist(self::getBucket(), $file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,22 @@ use licenseplaterecognizer\licenseplaterecognizer_c;
|
||||
|
||||
class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
{
|
||||
private const DEFAULT_API_URL = 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk';
|
||||
private const PLATE_READER_CONFIG_JSON = '{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0}';
|
||||
private const RESULT_CACHE_CONTEXT = '{"config":{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0},"regions":"dk,de,se,no"}';
|
||||
private const PLATE_READER_REGIONS = 'dk,de,se,no';
|
||||
private const DEFAULT_UPLOAD_FILE_NAME = 'license-plate.jpg';
|
||||
private const RUNTIME_CONFIG_CACHE_TTL_SECONDS = 15;
|
||||
private const RUNTIME_CONFIG_REDIS_CACHE_KEY = 'licenseplaterecognizer:runtime_config:v1';
|
||||
private const RESULT_CACHE_TTL_SECONDS = 10;
|
||||
private const RESULT_CACHE_REDIS_KEY_PREFIX = 'licenseplaterecognizer:result:v1:';
|
||||
private const PLATE_READER_CONNECT_TIMEOUT_MS = 1000;
|
||||
private const PLATE_READER_TOTAL_TIMEOUT_MS = 4500;
|
||||
/**
|
||||
* @var array<string, float>
|
||||
*/
|
||||
private array $last_timings = [];
|
||||
|
||||
/**
|
||||
* The configuration of the module
|
||||
* @var licenseplaterecognizer_c
|
||||
@@ -19,12 +35,25 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
* API URL
|
||||
* @var string
|
||||
*/
|
||||
private string $api_url = 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk'; // Default (cloud): 'https://api.platerecognizer.com'; (without /v1/plate-reader/)';
|
||||
private string $api_url;
|
||||
|
||||
/**
|
||||
* @var array{enabled: bool, api_key: string}|null
|
||||
*/
|
||||
private ?array $runtime_config = null;
|
||||
|
||||
public function __construct()
|
||||
/**
|
||||
* @var array{values: array{enabled: bool, api_key: string}, cached_at: float}|null
|
||||
*/
|
||||
private static ?array $runtime_config_cache = null;
|
||||
|
||||
public function __construct(bool $load_config = true, ?string $api_url = null)
|
||||
{
|
||||
$this->config = new licenseplaterecognizer_c();
|
||||
$this->api_url = self::normalizeApiUrl($api_url ?? self::configuredApiUrl());
|
||||
|
||||
if ($load_config) {
|
||||
$this->config = new licenseplaterecognizer_c();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +62,7 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
*/
|
||||
public function requireModuleEnabled(): void
|
||||
{
|
||||
if (!(bool)$this->config->enabled->getVariableValue()) {
|
||||
if (!$this->runtimeConfig()['enabled']) {
|
||||
throw new Exception('licenseplaterecognizer module is not enabled.');
|
||||
}
|
||||
}
|
||||
@@ -45,54 +74,521 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
*/
|
||||
public function licenseplaterecognizer(string $base64_image): array
|
||||
{
|
||||
$image_processor = new image_processor();
|
||||
|
||||
//ADD PARAMETER IN REQUEST LIKE regions
|
||||
$data = array(
|
||||
'upload' => $base64_image,
|
||||
//'regions' => 'dk' // Optional
|
||||
return $this->recognizePlate(
|
||||
fn () => $this->buildPlateReaderPayload($base64_image),
|
||||
fn () => $this->buildResultCacheKeyFromUploadString($base64_image)
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare new cURL resource
|
||||
//$ch = curl_init('https://api.platerecognizer.com/v1/plate-reader/');
|
||||
$ch = curl_init($this->api_url . '/v1/plate-reader/');
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLINFO_HEADER_OUT, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
|
||||
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2TLS);
|
||||
public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array
|
||||
{
|
||||
return $this->recognizePlate(
|
||||
fn () => $this->buildPlateReaderPayloadFromUpload(
|
||||
$this->buildUploadValueFromBytes($image_data, $mime_type)
|
||||
),
|
||||
fn () => $this->buildResultCacheKeyFromBytes($image_data)
|
||||
);
|
||||
}
|
||||
|
||||
// Set HTTP Header for POST request
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
|
||||
"Authorization: Token " . $this->config->api_key->getVariableValue()
|
||||
public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array
|
||||
{
|
||||
return $this->recognizePlate(
|
||||
fn () => $this->buildPlateReaderPayloadFromUpload(
|
||||
$this->buildUploadValueFromBytes($image_data, $mime_type)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Submit the POST request and close cURL session handle
|
||||
$result = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
// Print the response from the server
|
||||
if ($result === false) {
|
||||
throw new Exception('Error in API request.');
|
||||
}
|
||||
public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array
|
||||
{
|
||||
return $this->recognizePlate(
|
||||
fn () => $this->buildPlateReaderPayloadFromUpload(
|
||||
$this->buildUploadValueFromFile($image_path, $mime_type)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$response_data = json_decode($result, true);
|
||||
if (isset($response_data['results']) && count($response_data['results']) > 0) {
|
||||
return [
|
||||
'success' => true,
|
||||
'license_plate_number' => $response_data['results'][0]['plate'] ?? null,
|
||||
'confidence' => $response_data['results'][0]['score'] ?? null,
|
||||
'raw_response' => $response_data,
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function recognizePlate(callable $payload_factory, ?callable $result_cache_key_factory = null): array
|
||||
{
|
||||
$started_at = microtime(true);
|
||||
$this->last_timings = [];
|
||||
$result_cache = null;
|
||||
$result_cache_key = null;
|
||||
|
||||
try {
|
||||
$config_started_at = microtime(true);
|
||||
$runtime_config = $this->runtimeConfig();
|
||||
if (!$runtime_config['enabled']) {
|
||||
throw new Exception('licenseplaterecognizer module is not enabled.');
|
||||
}
|
||||
$api_key = $runtime_config['api_key'];
|
||||
$this->last_timings['config'] = $this->elapsedMs($config_started_at);
|
||||
|
||||
if ($result_cache_key_factory !== null) {
|
||||
$cache_started_at = microtime(true);
|
||||
try {
|
||||
$result_cache = $this->resultCacheStore();
|
||||
if ($result_cache !== null) {
|
||||
$result_cache_key = $result_cache_key_factory();
|
||||
if ($result_cache_key !== null) {
|
||||
$cached_result = $this->readRecognitionResultCache($result_cache, $result_cache_key);
|
||||
if ($cached_result !== null) {
|
||||
$this->last_timings['cache_hit'] = 1;
|
||||
return $cached_result;
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->last_timings['cache_miss'] = 1;
|
||||
} finally {
|
||||
$this->last_timings['cache'] = $this->elapsedMs($cache_started_at);
|
||||
}
|
||||
}
|
||||
|
||||
$payload_started_at = microtime(true);
|
||||
$data = $payload_factory();
|
||||
$this->last_timings['payload'] = $this->elapsedMs($payload_started_at);
|
||||
|
||||
$ch = curl_init($this->api_url . '/v1/plate-reader/');
|
||||
if (!$ch instanceof \CurlHandle) {
|
||||
throw new Exception('Error initializing API request.');
|
||||
}
|
||||
|
||||
$curl_options = [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $data,
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
|
||||
CURLOPT_CONNECTTIMEOUT_MS => self::PLATE_READER_CONNECT_TIMEOUT_MS,
|
||||
CURLOPT_TIMEOUT_MS => self::PLATE_READER_TOTAL_TIMEOUT_MS,
|
||||
CURLOPT_NOSIGNAL => true,
|
||||
CURLOPT_NOPROGRESS => false,
|
||||
CURLOPT_XFERINFOFUNCTION => self::clientDisconnectAbortCallback(),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: Token " . $api_key,
|
||||
'Expect:',
|
||||
],
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
if (defined('CURLOPT_TCP_NODELAY')) {
|
||||
$curl_options[(int)constant('CURLOPT_TCP_NODELAY')] = true;
|
||||
}
|
||||
curl_setopt_array($ch, $curl_options);
|
||||
|
||||
// Submit the POST request and close cURL session handle
|
||||
$upstream_started_at = microtime(true);
|
||||
$result = curl_exec($ch);
|
||||
$this->last_timings['upstream'] = $this->elapsedMs($upstream_started_at);
|
||||
$this->recordCurlTimings($ch);
|
||||
curl_close($ch);
|
||||
|
||||
// Print the response from the server
|
||||
if ($result === false) {
|
||||
throw new Exception('Error in API request.');
|
||||
}
|
||||
|
||||
$parse_started_at = microtime(true);
|
||||
$response_data = json_decode($result, true);
|
||||
$this->last_timings['parse'] = $this->elapsedMs($parse_started_at);
|
||||
$this->recordResponseTimings($response_data);
|
||||
|
||||
if (isset($response_data['results']) && count($response_data['results']) > 0) {
|
||||
$recognized_result = [
|
||||
'success' => true,
|
||||
'license_plate_number' => $response_data['results'][0]['plate'] ?? null,
|
||||
'confidence' => $response_data['results'][0]['score'] ?? null,
|
||||
];
|
||||
|
||||
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
|
||||
|
||||
return $recognized_result;
|
||||
}
|
||||
|
||||
$recognized_result = [
|
||||
'success' => false,
|
||||
'message' => 'No license plate detected.',
|
||||
'raw_response' => $response_data,
|
||||
];
|
||||
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
|
||||
|
||||
return $recognized_result;
|
||||
} finally {
|
||||
$this->last_timings['total'] = $this->elapsedMs($started_at);
|
||||
}
|
||||
}
|
||||
|
||||
private static function clientDisconnectAbortCallback(): callable
|
||||
{
|
||||
return static function (): int {
|
||||
return connection_aborted() ? 1 : 0;
|
||||
};
|
||||
}
|
||||
|
||||
public function getLastTimings(): array
|
||||
{
|
||||
return $this->last_timings;
|
||||
}
|
||||
|
||||
private function elapsedMs(float $started_at): float
|
||||
{
|
||||
return (microtime(true) - $started_at) * 1000;
|
||||
}
|
||||
|
||||
private static function configuredApiUrl(): string
|
||||
{
|
||||
$configured = getenv('PLATE_RECOGNIZER_API_URL');
|
||||
if ($configured === false || trim((string)$configured) === '') {
|
||||
$configured = $_ENV['PLATE_RECOGNIZER_API_URL'] ?? $_SERVER['PLATE_RECOGNIZER_API_URL'] ?? self::DEFAULT_API_URL;
|
||||
}
|
||||
|
||||
return (string)$configured;
|
||||
}
|
||||
|
||||
public static function configuredApiBaseUrl(): string
|
||||
{
|
||||
return self::normalizeApiUrl(self::configuredApiUrl());
|
||||
}
|
||||
|
||||
private static function normalizeApiUrl(string $api_url): string
|
||||
{
|
||||
$api_url = trim($api_url);
|
||||
if ($api_url === '') {
|
||||
return self::DEFAULT_API_URL;
|
||||
}
|
||||
|
||||
return rtrim($api_url, '/');
|
||||
}
|
||||
|
||||
private function recordCurlTimings(\CurlHandle $curl_handle): void
|
||||
{
|
||||
$mapping = [
|
||||
CURLINFO_NAMELOOKUP_TIME => 'upstream_dns',
|
||||
CURLINFO_CONNECT_TIME => 'upstream_connect',
|
||||
CURLINFO_APPCONNECT_TIME => 'upstream_tls',
|
||||
CURLINFO_PRETRANSFER_TIME => 'upstream_pretransfer',
|
||||
CURLINFO_STARTTRANSFER_TIME => 'upstream_ttfb',
|
||||
CURLINFO_TOTAL_TIME => 'upstream_total',
|
||||
];
|
||||
|
||||
foreach ($mapping as $curl_info_option => $timing_key) {
|
||||
$value = curl_getinfo($curl_handle, $curl_info_option);
|
||||
if (!is_numeric($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->last_timings[$timing_key] = max(0, (float)$value * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private function recordResponseTimings(mixed $response_data): void
|
||||
{
|
||||
if (!is_array($response_data) || !isset($response_data['processing_time']) || !is_numeric($response_data['processing_time'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->last_timings['upstream_processing'] = max(0, (float)$response_data['processing_time']);
|
||||
}
|
||||
|
||||
private function buildResultCacheKeyFromUploadString(string $base64_image): string
|
||||
{
|
||||
$base64_image = trim($base64_image);
|
||||
if (preg_match('/^data:image\/[a-zA-Z0-9.+-]+;base64,(.*)$/s', $base64_image, $matches) === 1) {
|
||||
$image_data = base64_decode((string)$matches[1], true);
|
||||
if (is_string($image_data)) {
|
||||
return $this->buildResultCacheKeyFromBytes($image_data);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->buildResultCacheKeyFromBytes($base64_image);
|
||||
}
|
||||
|
||||
private function buildResultCacheKeyFromBytes(string $image_data): string
|
||||
{
|
||||
$context = hash_init('sha256');
|
||||
hash_update($context, $this->resultCacheContext());
|
||||
hash_update($context, "\0");
|
||||
hash_update($context, $image_data);
|
||||
|
||||
return self::RESULT_CACHE_REDIS_KEY_PREFIX . hash_final($context);
|
||||
}
|
||||
|
||||
private function resultCacheContext(): string
|
||||
{
|
||||
return self::RESULT_CACHE_CONTEXT;
|
||||
}
|
||||
|
||||
protected function resultCacheStore(): ?object
|
||||
{
|
||||
return $this->runtimeConfigCacheStore();
|
||||
}
|
||||
|
||||
private function readRecognitionResultCache(?object $cache, ?string $key): ?array
|
||||
{
|
||||
if ($cache === null || $key === null || !method_exists($cache, 'get')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$cached = $cache->get($key);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_string($cached) || trim($cached) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($cached, true);
|
||||
if (!is_array($decoded) || !array_key_exists('success', $decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function writeRecognitionResultCache(?object $cache, ?string $key, array $result): void
|
||||
{
|
||||
if ($cache === null || $key === null || !method_exists($cache, 'setEx')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$encoded = json_encode($result, JSON_UNESCAPED_SLASHES);
|
||||
if (is_string($encoded)) {
|
||||
$cache->setEx($key, $encoded, self::RESULT_CACHE_TTL_SECONDS);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Scanner result cache is best-effort; Plate Recognizer remains the source of truth.
|
||||
}
|
||||
}
|
||||
|
||||
protected function buildPlateReaderPayload(string $base64_image): array
|
||||
{
|
||||
return $this->buildPlateReaderPayloadFromUpload($this->buildUploadValue($base64_image));
|
||||
}
|
||||
|
||||
protected function buildPlateReaderPayloadFromUpload(string|\CURLFile|\CURLStringFile $upload): array
|
||||
{
|
||||
return [
|
||||
'upload' => $upload,
|
||||
'config' => self::PLATE_READER_CONFIG_JSON,
|
||||
'regions' => self::PLATE_READER_REGIONS,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildUploadValue(string $base64_image): string|\CURLStringFile
|
||||
{
|
||||
$base64_image = trim($base64_image);
|
||||
if (preg_match('/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.*)$/s', $base64_image, $matches) !== 1) {
|
||||
return $base64_image;
|
||||
}
|
||||
|
||||
$image_data = base64_decode((string)$matches[2], true);
|
||||
if ($image_data === false || !class_exists(\CURLStringFile::class)) {
|
||||
return (string)$matches[2];
|
||||
}
|
||||
|
||||
return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, (string)$matches[1]);
|
||||
}
|
||||
|
||||
private function buildUploadValueFromBytes(string $image_data, string $mime_type): string|\CURLStringFile
|
||||
{
|
||||
$mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg';
|
||||
if (!str_starts_with($mime_type, 'image/')) {
|
||||
$mime_type = 'image/jpeg';
|
||||
}
|
||||
|
||||
if (!class_exists(\CURLStringFile::class)) {
|
||||
return $image_data;
|
||||
}
|
||||
|
||||
return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, $mime_type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function buildUploadValueFromFile(string $image_path, string $mime_type): \CURLFile
|
||||
{
|
||||
$image_path = trim($image_path);
|
||||
$mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg';
|
||||
if (!str_starts_with($mime_type, 'image/')) {
|
||||
$mime_type = 'image/jpeg';
|
||||
}
|
||||
|
||||
if ($image_path === '' || !is_file($image_path) || !class_exists(\CURLFile::class)) {
|
||||
throw new Exception('Image upload file is invalid.');
|
||||
}
|
||||
|
||||
return new \CURLFile($image_path, $mime_type, self::DEFAULT_UPLOAD_FILE_NAME);
|
||||
}
|
||||
|
||||
protected function runtimeConfig(): array
|
||||
{
|
||||
if ($this->runtime_config !== null) {
|
||||
return $this->runtime_config;
|
||||
}
|
||||
|
||||
if ($this->shouldUseSharedRuntimeConfigCache()) {
|
||||
$cached_config = self::getSharedRuntimeConfigCache();
|
||||
if ($cached_config !== null) {
|
||||
$this->runtime_config = $cached_config;
|
||||
|
||||
return $this->runtime_config;
|
||||
}
|
||||
|
||||
$cached_config = $this->readRuntimeConfigCacheStore();
|
||||
if ($cached_config !== null) {
|
||||
self::$runtime_config_cache = [
|
||||
'values' => $cached_config,
|
||||
'cached_at' => microtime(true),
|
||||
];
|
||||
$this->runtime_config = $cached_config;
|
||||
|
||||
return $this->runtime_config;
|
||||
}
|
||||
}
|
||||
|
||||
$values = $this->readRuntimeModuleConfig();
|
||||
|
||||
$this->runtime_config = [
|
||||
'enabled' => $this->parseModuleConfigBool($values['enabled'] ?? false),
|
||||
'api_key' => (string)($values['api_key'] ?? ''),
|
||||
];
|
||||
|
||||
if ($this->shouldUseSharedRuntimeConfigCache()) {
|
||||
self::$runtime_config_cache = [
|
||||
'values' => $this->runtime_config,
|
||||
'cached_at' => microtime(true),
|
||||
];
|
||||
$this->writeRuntimeConfigCacheStore($this->runtime_config);
|
||||
}
|
||||
|
||||
return $this->runtime_config;
|
||||
}
|
||||
|
||||
protected function shouldUseSharedRuntimeConfigCache(): bool
|
||||
{
|
||||
return static::class === self::class;
|
||||
}
|
||||
|
||||
private static function getSharedRuntimeConfigCache(): ?array
|
||||
{
|
||||
if (self::$runtime_config_cache === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cache_age_seconds = microtime(true) - self::$runtime_config_cache['cached_at'];
|
||||
if ($cache_age_seconds > self::RUNTIME_CONFIG_CACHE_TTL_SECONDS) {
|
||||
self::$runtime_config_cache = null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::$runtime_config_cache['values'];
|
||||
}
|
||||
|
||||
protected function runtimeConfigCacheStore(): ?object
|
||||
{
|
||||
return defined('redis') ? constant('redis') : null;
|
||||
}
|
||||
|
||||
private function readRuntimeConfigCacheStore(): ?array
|
||||
{
|
||||
$cache = $this->runtimeConfigCacheStore();
|
||||
if ($cache === null || !method_exists($cache, 'get')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$cached = $cache->get(self::RUNTIME_CONFIG_REDIS_CACHE_KEY);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_string($cached) || trim($cached) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($cached, true);
|
||||
if (!is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!array_key_exists('enabled', $decoded) || !array_key_exists('api_key', $decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => $this->parseModuleConfigBool($decoded['enabled']),
|
||||
'api_key' => (string)$decoded['api_key'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{enabled: bool, api_key: string} $config
|
||||
*/
|
||||
private function writeRuntimeConfigCacheStore(array $config): void
|
||||
{
|
||||
$cache = $this->runtimeConfigCacheStore();
|
||||
if ($cache === null || !method_exists($cache, 'setEx')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$encoded = json_encode($config, JSON_UNESCAPED_SLASHES);
|
||||
if (is_string($encoded)) {
|
||||
$cache->setEx(self::RUNTIME_CONFIG_REDIS_CACHE_KEY, $encoded, self::RUNTIME_CONFIG_CACHE_TTL_SECONDS);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Scanner config cache is best-effort; DB remains the source of truth.
|
||||
}
|
||||
}
|
||||
|
||||
private function parseModuleConfigBool(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int)$value === 1;
|
||||
}
|
||||
|
||||
return strtolower(trim((string)$value)) === 'true';
|
||||
}
|
||||
|
||||
protected function readRuntimeModuleConfig(): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($db instanceof db) {
|
||||
$module = $db->escape_string('licenseplaterecognizer');
|
||||
$result = $db->query("SELECT variable, value FROM module_config WHERE module = '$module' AND variable IN ('enabled', 'api_key')");
|
||||
$values = [];
|
||||
|
||||
if ($result instanceof \mysqli_result) {
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$variable = (string)($row['variable'] ?? '');
|
||||
if ($variable !== '') {
|
||||
$values[$variable] = (string)($row['value'] ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
if (!isset($this->config)) {
|
||||
$this->config = new licenseplaterecognizer_c();
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => (string)$this->config->enabled->getVariableValue(),
|
||||
'api_key' => (string)$this->config->api_key->getVariableValue(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
* @throws Exception If the module is not enabled or if there is an error in the API request
|
||||
@@ -100,8 +596,8 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
*/
|
||||
public function get_usage(): licenseplaterecognizer_info
|
||||
{
|
||||
// Require the module to be enabled
|
||||
$this->requireModuleEnabled();
|
||||
$api_key = $this->runtimeConfig()['api_key'];
|
||||
$curl = curl_init();
|
||||
curl_setopt_array($curl, array(
|
||||
CURLOPT_URL => $this->api_url . '/info/',
|
||||
@@ -112,9 +608,9 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
|
||||
CURLOPT_CUSTOMREQUEST => 'GET',
|
||||
CURLOPT_HTTPHEADER => array(
|
||||
'Authorization: Token ' . $this->config->api_key->getVariableValue()
|
||||
),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Token ' . $api_key,
|
||||
],
|
||||
));
|
||||
$response = curl_exec($curl);
|
||||
curl_close($curl);
|
||||
@@ -124,4 +620,4 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
}
|
||||
return new licenseplaterecognizer_info($response_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class limited_backoffice_exception extends \RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
private readonly int $statusCode = 400,
|
||||
private readonly ?array $payload = null
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
|
||||
public function statusCode(): int
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
|
||||
public function payload(): array|string
|
||||
{
|
||||
return $this->payload ?? $this->getMessage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class limited_backoffice_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `limited_backoffice_employees` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL,
|
||||
`managed_group_id` INT NOT NULL,
|
||||
`role_key` VARCHAR(64) NOT NULL,
|
||||
`department_ids` LONGTEXT NOT NULL,
|
||||
`created_by_user_id` INT NOT NULL,
|
||||
`updated_by_user_id` INT NULL,
|
||||
`deactivated_at` DATETIME NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_limited_backoffice_employees_user_id` (`user_id`),
|
||||
KEY `idx_limited_backoffice_employees_group_id` (`managed_group_id`),
|
||||
KEY `idx_limited_backoffice_employees_role_key` (`role_key`),
|
||||
KEY `idx_limited_backoffice_employees_deactivated_at` (`deactivated_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class module_usage_registry
|
||||
{
|
||||
public const DEFAULT_SOFT_LIMIT_PERCENT = 90.0;
|
||||
|
||||
public function all(): array
|
||||
{
|
||||
return array_map(
|
||||
fn(array $descriptor): array => $this->withDefaults($descriptor),
|
||||
[
|
||||
[
|
||||
'module_key' => 'motorapi',
|
||||
'module_label' => 'MotorAPI',
|
||||
'metric_key' => 'lookup_calls',
|
||||
'metric_label' => 'License plate lookups',
|
||||
'unit' => 'calls',
|
||||
'period' => 'day',
|
||||
'source' => 'internal_counter',
|
||||
'default_enforce_mode' => 'block',
|
||||
'config_module' => 'motorapi',
|
||||
'config_variable' => 'daily_limit',
|
||||
'config_type' => 'int',
|
||||
'legacy_count_table' => 'motorapi_lookups',
|
||||
'primary' => true,
|
||||
'writable_limit' => true,
|
||||
],
|
||||
[
|
||||
'module_key' => 'motorapi',
|
||||
'module_label' => 'MotorAPI',
|
||||
'metric_key' => 'provider_usage',
|
||||
'metric_label' => 'Provider usage',
|
||||
'unit' => 'calls',
|
||||
'period' => 'provider',
|
||||
'source' => 'provider_snapshot',
|
||||
],
|
||||
[
|
||||
'module_key' => 'fxratesapi',
|
||||
'module_label' => 'FXRatesAPI',
|
||||
'metric_key' => 'rate_fetch_calls',
|
||||
'metric_label' => 'Currency rate fetches',
|
||||
'unit' => 'calls',
|
||||
'period' => 'day',
|
||||
'source' => 'internal_counter',
|
||||
'default_enforce_mode' => 'block',
|
||||
'config_module' => 'fxratesapi',
|
||||
'config_variable' => 'daily_limit',
|
||||
'config_type' => 'int',
|
||||
'legacy_count_table' => 'fxratesapi_conversion_rates',
|
||||
'primary' => true,
|
||||
'writable_limit' => true,
|
||||
],
|
||||
[
|
||||
'module_key' => 'virkdata',
|
||||
'module_label' => 'VirkData',
|
||||
'metric_key' => 'company_search_calls',
|
||||
'metric_label' => 'Company searches',
|
||||
'unit' => 'calls',
|
||||
'period' => 'month',
|
||||
'source' => 'internal_counter',
|
||||
'default_enforce_mode' => 'observe',
|
||||
'config_module' => 'virkdata',
|
||||
'config_variable' => 'monthly_limit',
|
||||
'config_type' => 'int',
|
||||
'legacy_log_module' => 'VIRKDATA',
|
||||
'legacy_log_action' => 'VIRKDATA_SEARCH',
|
||||
'primary' => true,
|
||||
'writable_limit' => true,
|
||||
],
|
||||
[
|
||||
'module_key' => 'licenseplaterecognizer',
|
||||
'module_label' => 'License Plate Recognizer',
|
||||
'metric_key' => 'plate_recognition_calls',
|
||||
'metric_label' => 'Plate recognition calls',
|
||||
'unit' => 'calls',
|
||||
'period' => 'provider',
|
||||
'source' => 'provider_snapshot',
|
||||
'default_enforce_mode' => 'observe',
|
||||
'primary' => true,
|
||||
],
|
||||
[
|
||||
'module_key' => 'email',
|
||||
'module_label' => 'Email',
|
||||
'metric_key' => 'mailersend_messages',
|
||||
'metric_label' => 'MailerSend messages',
|
||||
'unit' => 'messages',
|
||||
'period' => 'provider',
|
||||
'source' => 'provider_snapshot',
|
||||
],
|
||||
['module_key' => 'openai', 'module_label' => 'OpenAI', 'metric_key' => 'api_calls', 'metric_label' => 'API calls', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'openai', 'module_label' => 'OpenAI', 'metric_key' => 'total_tokens', 'metric_label' => 'Total tokens', 'unit' => 'tokens', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'weatherapi', 'module_label' => 'WeatherAPI', 'metric_key' => 'requests', 'metric_label' => 'Weather requests', 'unit' => 'calls', 'period' => 'day', 'source' => 'internal_counter'],
|
||||
['module_key' => 'gatewayapi', 'module_label' => 'GatewayAPI', 'metric_key' => 'sms_messages', 'metric_label' => 'SMS messages', 'unit' => 'messages', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'bird', 'module_label' => 'Bird', 'metric_key' => 'messages_and_calls', 'metric_label' => 'Messages and calls', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'ocrspace', 'module_label' => 'OCRSpace', 'metric_key' => 'ocr_requests', 'metric_label' => 'OCR requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'limble', 'module_label' => 'Limble', 'metric_key' => 'api_requests', 'metric_label' => 'API requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'workfeed', 'module_label' => 'Workfeed', 'metric_key' => 'api_requests', 'metric_label' => 'API requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'stripe', 'module_label' => 'Stripe', 'metric_key' => 'payment_events', 'metric_label' => 'Payment events', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'coolify', 'module_label' => 'Coolify', 'metric_key' => 'operations', 'metric_label' => 'Operations', 'unit' => 'events', 'period' => 'month', 'source' => 'derived'],
|
||||
['module_key' => 'backups', 'module_label' => 'Backups', 'metric_key' => 'backup_jobs', 'metric_label' => 'Backup jobs', 'unit' => 'jobs', 'period' => 'month', 'source' => 'derived'],
|
||||
['module_key' => 'backups', 'module_label' => 'Backups', 'metric_key' => 'stored_bytes', 'metric_label' => 'Stored backup data', 'unit' => 'bytes', 'period' => 'all_time', 'source' => 'derived'],
|
||||
['module_key' => 'selfserve', 'module_label' => 'Self Serve', 'metric_key' => 'wash_sessions', 'metric_label' => 'Wash sessions', 'unit' => 'sessions', 'period' => 'month', 'source' => 'derived'],
|
||||
['module_key' => 'selfserve', 'module_label' => 'Self Serve', 'metric_key' => 'lane_commands', 'metric_label' => 'Lane commands', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter', 'legacy_log_module' => 'SELFSERVE'],
|
||||
['module_key' => 'xlvask', 'module_label' => 'XLVask', 'metric_key' => 'usage_rows', 'metric_label' => 'Usage rows', 'unit' => 'rows', 'period' => 'month', 'source' => 'derived'],
|
||||
['module_key' => 'attachments', 'module_label' => 'Attachments', 'metric_key' => 'stored_files', 'metric_label' => 'Stored files', 'unit' => 'files', 'period' => 'all_time', 'source' => 'derived'],
|
||||
['module_key' => 'dynamicimages', 'module_label' => 'Dynamic Images', 'metric_key' => 'renders', 'metric_label' => 'Image renders', 'unit' => 'renders', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'html2pdf', 'module_label' => 'HTML2PDF', 'metric_key' => 'pdf_jobs', 'metric_label' => 'PDF jobs', 'unit' => 'jobs', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'forms', 'module_label' => 'Forms', 'metric_key' => 'submissions', 'metric_label' => 'Submissions', 'unit' => 'submissions', 'period' => 'month', 'source' => 'derived'],
|
||||
['module_key' => 'notifications', 'module_label' => 'Notifications', 'metric_key' => 'notification_sends', 'metric_label' => 'Notification sends', 'unit' => 'notifications', 'period' => 'month', 'source' => 'derived'],
|
||||
['module_key' => 'shelly', 'module_label' => 'Shelly', 'metric_key' => 'relay_commands', 'metric_label' => 'Relay commands', 'unit' => 'commands', 'period' => 'month', 'source' => 'internal_counter', 'legacy_log_module' => 'SHELLY'],
|
||||
['module_key' => 'edgegateway', 'module_label' => 'Edge Gateway', 'metric_key' => 'relay_commands', 'metric_label' => 'Relay commands', 'unit' => 'commands', 'period' => 'month', 'source' => 'derived'],
|
||||
['module_key' => 'system', 'module_label' => 'System', 'metric_key' => 'cron_runs', 'metric_label' => 'Cron runs', 'unit' => 'runs', 'period' => 'day', 'source' => 'derived'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function find(string $moduleKey, string $metricKey): ?array
|
||||
{
|
||||
$moduleKey = $this->normalizeModuleKey($moduleKey);
|
||||
$metricKey = $this->normalizeMetricKey($metricKey);
|
||||
|
||||
foreach ($this->all() as $descriptor) {
|
||||
if ($descriptor['module_key'] === $moduleKey && $descriptor['metric_key'] === $metricKey) {
|
||||
return $descriptor;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function forModule(string $moduleKey): array
|
||||
{
|
||||
$moduleKey = $this->normalizeModuleKey($moduleKey);
|
||||
return array_values(array_filter(
|
||||
$this->all(),
|
||||
static fn(array $descriptor): bool => $descriptor['module_key'] === $moduleKey
|
||||
));
|
||||
}
|
||||
|
||||
public function normalizeModuleKey(string $moduleKey): string
|
||||
{
|
||||
return strtolower(trim($moduleKey));
|
||||
}
|
||||
|
||||
public function normalizeMetricKey(string $metricKey): string
|
||||
{
|
||||
return strtolower(trim($metricKey));
|
||||
}
|
||||
|
||||
private function withDefaults(array $descriptor): array
|
||||
{
|
||||
$descriptor['module_key'] = $this->normalizeModuleKey((string)$descriptor['module_key']);
|
||||
$descriptor['metric_key'] = $this->normalizeMetricKey((string)$descriptor['metric_key']);
|
||||
$descriptor['module_label'] = (string)($descriptor['module_label'] ?? $descriptor['module_key']);
|
||||
$descriptor['metric_label'] = (string)($descriptor['metric_label'] ?? $descriptor['metric_key']);
|
||||
$descriptor['unit'] = (string)($descriptor['unit'] ?? 'count');
|
||||
$descriptor['period'] = (string)($descriptor['period'] ?? 'all_time');
|
||||
$descriptor['scope_type'] = (string)($descriptor['scope_type'] ?? 'global');
|
||||
$descriptor['scope_id'] = (string)($descriptor['scope_id'] ?? '');
|
||||
$descriptor['source'] = (string)($descriptor['source'] ?? 'internal_counter');
|
||||
$descriptor['default_enforce_mode'] = (string)($descriptor['default_enforce_mode'] ?? 'observe');
|
||||
$descriptor['soft_limit_percent'] = (float)($descriptor['soft_limit_percent'] ?? self::DEFAULT_SOFT_LIMIT_PERCENT);
|
||||
$descriptor['writable_limit'] = (bool)($descriptor['writable_limit'] ?? false);
|
||||
$descriptor['primary'] = (bool)($descriptor['primary'] ?? false);
|
||||
return $descriptor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class module_usage_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS module_usage_counters (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
module_key VARCHAR(64) NOT NULL,
|
||||
metric_key VARCHAR(128) NOT NULL,
|
||||
scope_type VARCHAR(32) NOT NULL DEFAULT 'global',
|
||||
scope_id VARCHAR(191) NOT NULL DEFAULT '',
|
||||
period_key VARCHAR(32) NOT NULL DEFAULT 'all_time',
|
||||
period_start DATETIME NOT NULL,
|
||||
period_end DATETIME NULL,
|
||||
unit VARCHAR(32) NOT NULL DEFAULT 'count',
|
||||
used_quantity DECIMAL(20,4) NOT NULL DEFAULT 0,
|
||||
limit_quantity DECIMAL(20,4) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ok',
|
||||
metadata_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_module_usage_counter (module_key, metric_key, scope_type, scope_id, period_key, period_start),
|
||||
KEY idx_module_usage_counters_module_period (module_key, period_key, period_start),
|
||||
KEY idx_module_usage_counters_status (status, updated_at),
|
||||
KEY idx_module_usage_counters_scope (scope_type, scope_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS module_usage_snapshots (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
module_key VARCHAR(64) NOT NULL,
|
||||
metric_key VARCHAR(128) NOT NULL,
|
||||
source VARCHAR(32) NOT NULL DEFAULT 'provider',
|
||||
period_key VARCHAR(32) NOT NULL DEFAULT 'provider',
|
||||
period_start DATETIME NULL,
|
||||
period_end DATETIME NULL,
|
||||
unit VARCHAR(32) NOT NULL DEFAULT 'count',
|
||||
used_quantity DECIMAL(20,4) NULL,
|
||||
limit_quantity DECIMAL(20,4) NULL,
|
||||
remaining_quantity DECIMAL(20,4) NULL,
|
||||
usage_percent DECIMAL(8,4) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'unknown',
|
||||
raw_payload_json LONGTEXT NULL,
|
||||
checked_at DATETIME NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_module_usage_snapshots_module_checked (module_key, metric_key, checked_at),
|
||||
KEY idx_module_usage_snapshots_status (status, checked_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS module_quota_settings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
module_key VARCHAR(64) NOT NULL,
|
||||
metric_key VARCHAR(128) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
enforce_mode VARCHAR(16) NOT NULL DEFAULT 'observe',
|
||||
soft_limit_percent DECIMAL(6,2) NOT NULL DEFAULT 90.00,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_module_quota_setting (module_key, metric_key),
|
||||
KEY idx_module_quota_settings_mode (enforce_mode, enabled)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS module_usage_logs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
module VARCHAR(64) NOT NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
status_code INT NOT NULL DEFAULT 0,
|
||||
data LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_module_usage_logs_module_created (module, created_at),
|
||||
KEY idx_module_usage_logs_action_created (action, created_at),
|
||||
KEY idx_module_usage_logs_status_created (status_code, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
self::ensureColumn('module_quota_settings', 'enabled', "TINYINT(1) NOT NULL DEFAULT 1 AFTER metric_key");
|
||||
self::ensureColumn('module_quota_settings', 'soft_limit_percent', "DECIMAL(6,2) NOT NULL DEFAULT 90.00 AFTER enforce_mode");
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||
if ($table === '' || $column === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
||||
if ($result && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
use mysqli_result;
|
||||
use Throwable;
|
||||
|
||||
class module_usage_service
|
||||
{
|
||||
private module_usage_registry $registry;
|
||||
|
||||
public function __construct(?module_usage_registry $registry = null)
|
||||
{
|
||||
module_usage_schema_bootstrap::ensureTables();
|
||||
$this->registry = $registry ?? new module_usage_registry();
|
||||
}
|
||||
|
||||
public function summary(array $filters = []): array
|
||||
{
|
||||
$moduleFilter = isset($filters['module']) ? $this->registry->normalizeModuleKey((string)$filters['module']) : '';
|
||||
$periodFilter = isset($filters['period']) ? strtolower(trim((string)$filters['period'])) : '';
|
||||
$statusFilter = isset($filters['status']) ? strtolower(trim((string)$filters['status'])) : '';
|
||||
$date = isset($filters['date']) ? (string)$filters['date'] : null;
|
||||
|
||||
$metrics = [];
|
||||
foreach ($this->registry->all() as $descriptor) {
|
||||
if ($moduleFilter !== '' && $descriptor['module_key'] !== $moduleFilter) {
|
||||
continue;
|
||||
}
|
||||
if ($periodFilter !== '' && $periodFilter !== 'all' && $descriptor['period'] !== $periodFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$metric = $this->currentMetric($descriptor, $date);
|
||||
if ($statusFilter !== '' && $metric['status'] !== $statusFilter) {
|
||||
continue;
|
||||
}
|
||||
$metrics[] = $metric;
|
||||
}
|
||||
|
||||
$modules = [];
|
||||
foreach ($metrics as $metric) {
|
||||
$moduleKey = $metric['module_key'];
|
||||
if (!isset($modules[$moduleKey])) {
|
||||
$modules[$moduleKey] = [
|
||||
'key' => $moduleKey,
|
||||
'label' => $metric['module_label'],
|
||||
'status' => 'ok',
|
||||
'metrics' => [],
|
||||
];
|
||||
}
|
||||
$modules[$moduleKey]['metrics'][] = $metric;
|
||||
$modules[$moduleKey]['status'] = $this->worseStatus($modules[$moduleKey]['status'], $metric['status']);
|
||||
}
|
||||
|
||||
return [
|
||||
'generated_at' => date('c'),
|
||||
'filters' => [
|
||||
'module' => $moduleFilter !== '' ? $moduleFilter : null,
|
||||
'period' => $periodFilter !== '' ? $periodFilter : null,
|
||||
'status' => $statusFilter !== '' ? $statusFilter : null,
|
||||
'date' => $date,
|
||||
],
|
||||
'modules' => array_values($modules),
|
||||
'metrics' => $metrics,
|
||||
];
|
||||
}
|
||||
|
||||
public function moduleDetail(string $moduleKey, array $filters = []): array
|
||||
{
|
||||
$moduleKey = $this->registry->normalizeModuleKey($moduleKey);
|
||||
$descriptors = $this->registry->forModule($moduleKey);
|
||||
$date = isset($filters['date']) ? (string)$filters['date'] : null;
|
||||
$metrics = array_map(fn(array $descriptor): array => $this->currentMetric($descriptor, $date), $descriptors);
|
||||
|
||||
return [
|
||||
'generated_at' => date('c'),
|
||||
'module_key' => $moduleKey,
|
||||
'metrics' => $metrics,
|
||||
'history' => $this->historyForModule($moduleKey, $filters),
|
||||
];
|
||||
}
|
||||
|
||||
public function metricsForModule(string $moduleKey): array
|
||||
{
|
||||
$moduleKey = $this->registry->normalizeModuleKey($moduleKey);
|
||||
return array_map(
|
||||
fn(array $descriptor): array => $this->currentMetric($descriptor),
|
||||
$this->registry->forModule($moduleKey)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically records usage and enforces blocking quotas when the metric is configured for block mode.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function reserveOrFail(string $moduleKey, string $metricKey, float $quantity = 1.0, array $metadata = []): array
|
||||
{
|
||||
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
|
||||
$quantity = max(0.0, $quantity);
|
||||
if ($quantity <= 0.0) {
|
||||
return $this->currentMetric($descriptor);
|
||||
}
|
||||
|
||||
$setting = $this->settingFor($descriptor);
|
||||
if (($setting['enabled'] ?? true) !== true || ($setting['enforce_mode'] ?? 'observe') !== 'block') {
|
||||
return $this->recordUsage($moduleKey, $metricKey, $quantity, $metadata);
|
||||
}
|
||||
|
||||
$limit = $this->resolveLimitQuantity($descriptor);
|
||||
if ($limit === null) {
|
||||
return $this->recordUsage($moduleKey, $metricKey, $quantity, $metadata);
|
||||
}
|
||||
|
||||
$period = $this->periodWindow((string)$descriptor['period']);
|
||||
$this->insertCounterIfMissing($descriptor, $period, $limit);
|
||||
|
||||
global $db;
|
||||
$where = $this->counterWhereSql($descriptor, $period);
|
||||
$quantitySql = $this->numberSql($quantity);
|
||||
$limitSql = $this->numberSql($limit);
|
||||
$metadataSql = $this->jsonSql($metadata);
|
||||
|
||||
$db->query(
|
||||
"UPDATE module_usage_counters
|
||||
SET used_quantity = used_quantity + {$quantitySql},
|
||||
limit_quantity = {$limitSql},
|
||||
metadata_json = {$metadataSql},
|
||||
status = CASE
|
||||
WHEN {$limitSql} <= 0 OR ((used_quantity + {$quantitySql}) >= {$limitSql}) THEN 'exhausted'
|
||||
WHEN ((used_quantity + {$quantitySql}) / {$limitSql}) * 100 >= " . module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT . " THEN 'near_limit'
|
||||
ELSE 'ok'
|
||||
END
|
||||
WHERE {$where} AND (used_quantity + {$quantitySql}) <= {$limitSql}"
|
||||
);
|
||||
|
||||
if ((int)$db->conn()->affected_rows <= 0) {
|
||||
throw new Exception($this->quotaExceededMessage($descriptor));
|
||||
}
|
||||
|
||||
return $this->currentMetric($descriptor);
|
||||
}
|
||||
|
||||
public function recordUsage(string $moduleKey, string $metricKey, float $quantity = 1.0, array $metadata = []): array
|
||||
{
|
||||
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
|
||||
$quantity = max(0.0, $quantity);
|
||||
if ($quantity <= 0.0 || !$this->databaseReady()) {
|
||||
return $this->currentMetric($descriptor);
|
||||
}
|
||||
|
||||
$limit = $this->resolveLimitQuantity($descriptor);
|
||||
$setting = $this->settingFor($descriptor);
|
||||
$period = $this->periodWindow((string)$descriptor['period']);
|
||||
$this->insertCounterIfMissing($descriptor, $period, $limit);
|
||||
|
||||
global $db;
|
||||
$where = $this->counterWhereSql($descriptor, $period);
|
||||
$quantitySql = $this->numberSql($quantity);
|
||||
$limitSql = $this->nullableNumberSql($limit);
|
||||
$metadataSql = $this->jsonSql($metadata);
|
||||
$softLimitSql = $this->numberSql((float)$setting['soft_limit_percent']);
|
||||
$statusSql = (($setting['enabled'] ?? true) !== true)
|
||||
? $this->sqlString('disabled')
|
||||
: "CASE
|
||||
WHEN {$limitSql} IS NULL THEN 'unlimited'
|
||||
WHEN {$limitSql} <= 0 AND (used_quantity + {$quantitySql}) > 0 THEN 'exhausted'
|
||||
WHEN {$limitSql} <= 0 THEN 'ok'
|
||||
WHEN (used_quantity + {$quantitySql}) >= {$limitSql} THEN 'exhausted'
|
||||
WHEN ((used_quantity + {$quantitySql}) / {$limitSql}) * 100 >= {$softLimitSql} THEN 'near_limit'
|
||||
ELSE 'ok'
|
||||
END";
|
||||
|
||||
$db->query(
|
||||
"UPDATE module_usage_counters
|
||||
SET used_quantity = used_quantity + {$quantitySql},
|
||||
limit_quantity = {$limitSql},
|
||||
metadata_json = {$metadataSql},
|
||||
status = {$statusSql}
|
||||
WHERE {$where}"
|
||||
);
|
||||
|
||||
return $this->currentMetric($descriptor);
|
||||
}
|
||||
|
||||
public function currentUsedQuantity(string $moduleKey, string $metricKey): int
|
||||
{
|
||||
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
|
||||
$metric = $this->currentMetric($descriptor);
|
||||
return (int)floor((float)($metric['used'] ?? 0));
|
||||
}
|
||||
|
||||
public function updateQuotaSetting(string $moduleKey, string $metricKey, array $payload): array
|
||||
{
|
||||
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
|
||||
$setting = $this->settingFor($descriptor);
|
||||
|
||||
$enabled = array_key_exists('enabled', $payload) ? $this->toBool($payload['enabled']) : (bool)$setting['enabled'];
|
||||
$enforceMode = array_key_exists('enforce_mode', $payload) ? strtolower(trim((string)$payload['enforce_mode'])) : (string)$setting['enforce_mode'];
|
||||
if (!in_array($enforceMode, ['observe', 'block'], true)) {
|
||||
throw new Exception('Invalid enforce mode.');
|
||||
}
|
||||
|
||||
$softLimitPercent = array_key_exists('soft_limit_percent', $payload)
|
||||
? (float)$payload['soft_limit_percent']
|
||||
: (float)$setting['soft_limit_percent'];
|
||||
if ($softLimitPercent < 1.0 || $softLimitPercent > 100.0) {
|
||||
throw new Exception('Soft limit percent must be between 1 and 100.');
|
||||
}
|
||||
|
||||
if (array_key_exists('limit', $payload) || array_key_exists('hard_limit', $payload) || array_key_exists('hard_limit_quantity', $payload)) {
|
||||
if (empty($descriptor['writable_limit']) || empty($descriptor['config_module']) || empty($descriptor['config_variable'])) {
|
||||
throw new Exception('quota_not_writable');
|
||||
}
|
||||
$limitValue = $payload['limit'] ?? $payload['hard_limit'] ?? $payload['hard_limit_quantity'];
|
||||
if (!is_numeric($limitValue) || (int)$limitValue < 0) {
|
||||
throw new Exception('Limit must be a non-negative integer.');
|
||||
}
|
||||
$this->writeConfigLimit($descriptor, (int)$limitValue);
|
||||
}
|
||||
|
||||
if ($this->databaseReady()) {
|
||||
global $db;
|
||||
$moduleKeySql = $this->sqlString((string)$descriptor['module_key']);
|
||||
$metricKeySql = $this->sqlString((string)$descriptor['metric_key']);
|
||||
$enabledSql = $enabled ? '1' : '0';
|
||||
$modeSql = $this->sqlString($enforceMode);
|
||||
$softLimitSql = $this->numberSql($softLimitPercent);
|
||||
$db->query(
|
||||
"INSERT INTO module_quota_settings (module_key, metric_key, enabled, enforce_mode, soft_limit_percent)
|
||||
VALUES ({$moduleKeySql}, {$metricKeySql}, {$enabledSql}, {$modeSql}, {$softLimitSql})
|
||||
ON DUPLICATE KEY UPDATE
|
||||
enabled = VALUES(enabled),
|
||||
enforce_mode = VALUES(enforce_mode),
|
||||
soft_limit_percent = VALUES(soft_limit_percent)"
|
||||
);
|
||||
}
|
||||
|
||||
return $this->currentMetric($descriptor);
|
||||
}
|
||||
|
||||
public function recordProviderSnapshotFromLegacyUsage(string $moduleKey, array $usage, array $rawPayload = []): ?array
|
||||
{
|
||||
$moduleKey = $this->registry->normalizeModuleKey($moduleKey);
|
||||
$descriptor = null;
|
||||
foreach ($this->registry->forModule($moduleKey) as $candidate) {
|
||||
if (($candidate['source'] ?? '') === 'provider_snapshot') {
|
||||
$descriptor = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($descriptor === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$used = $this->firstNumeric($usage, ['used', 'calls_used', 'messages_used']);
|
||||
$limit = $this->firstNumeric($usage, ['limit', 'quota', 'quota_calls', 'total_calls']);
|
||||
$remaining = $this->firstNumeric($usage, ['remaining', 'calls_remaining', 'messages_remaining']);
|
||||
$percent = $this->firstNumeric($usage, ['usage_percent', 'percent']);
|
||||
$usageAvailable = !array_key_exists('usage_available', $usage) || $this->toBool($usage['usage_available']);
|
||||
$unavailableReason = trim((string)($usage['unavailable_reason'] ?? ''));
|
||||
|
||||
if ($used === null && $limit === null && $usageAvailable && $unavailableReason === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($remaining === null && $used !== null && $limit !== null) {
|
||||
$remaining = max(0.0, $limit - $used);
|
||||
}
|
||||
if ($percent === null && $used !== null && $limit !== null && $limit > 0) {
|
||||
$percent = round(($used / $limit) * 100, 4);
|
||||
}
|
||||
|
||||
$status = (!$usageAvailable || $unavailableReason !== '')
|
||||
? 'unknown'
|
||||
: $this->statusForUsage($used, $limit, $this->settingFor($descriptor));
|
||||
$payload = array_merge($rawPayload, ['usage' => $this->redactPayload($usage)]);
|
||||
|
||||
if ($this->databaseReady()) {
|
||||
try {
|
||||
global $db;
|
||||
$db->query(
|
||||
"INSERT INTO module_usage_snapshots
|
||||
(module_key, metric_key, source, period_key, unit, used_quantity, limit_quantity, remaining_quantity, usage_percent, status, raw_payload_json, checked_at)
|
||||
VALUES (
|
||||
" . $this->sqlString((string)$descriptor['module_key']) . ",
|
||||
" . $this->sqlString((string)$descriptor['metric_key']) . ",
|
||||
'provider',
|
||||
'provider',
|
||||
" . $this->sqlString((string)$descriptor['unit']) . ",
|
||||
" . $this->nullableNumberSql($used) . ",
|
||||
" . $this->nullableNumberSql($limit) . ",
|
||||
" . $this->nullableNumberSql($remaining) . ",
|
||||
" . $this->nullableNumberSql($percent) . ",
|
||||
" . $this->sqlString($status) . ",
|
||||
" . $this->jsonSql($payload) . ",
|
||||
" . $this->sqlString(date('Y-m-d H:i:s')) . "
|
||||
)"
|
||||
);
|
||||
} catch (Throwable) {
|
||||
// Provider snapshots are observability data. They must never break probes.
|
||||
}
|
||||
}
|
||||
|
||||
return $this->providerMetricFromValues($descriptor, $used, $limit, $remaining, $percent, $status, date('c'), $usage);
|
||||
}
|
||||
|
||||
public function primarySystemUsage(array $metrics): ?array
|
||||
{
|
||||
$metrics = array_values(array_filter(
|
||||
$metrics,
|
||||
static fn(array $metric): bool => ($metric['limit'] ?? null) !== null || ($metric['used'] ?? null) !== null
|
||||
));
|
||||
if ($metrics === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($metrics, function (array $left, array $right): int {
|
||||
if (($left['primary'] ?? false) !== ($right['primary'] ?? false)) {
|
||||
return ($right['primary'] ?? false) <=> ($left['primary'] ?? false);
|
||||
}
|
||||
$leftRank = $this->statusRank((string)($left['status'] ?? 'unknown'));
|
||||
$rightRank = $this->statusRank((string)($right['status'] ?? 'unknown'));
|
||||
return $rightRank <=> $leftRank;
|
||||
});
|
||||
|
||||
$metric = $metrics[0];
|
||||
return [
|
||||
'provider' => $metric['module_key'],
|
||||
'metric_key' => $metric['metric_key'],
|
||||
'unit' => $metric['unit'],
|
||||
'period' => $metric['period'],
|
||||
'calls_used' => $metric['used'],
|
||||
'quota_calls' => $metric['limit'],
|
||||
'calls_remaining' => $metric['remaining'],
|
||||
'usage_percent' => $metric['usage_percent'],
|
||||
'status' => $metric['status'],
|
||||
'source' => $metric['source'],
|
||||
'enforce_mode' => $metric['enforce_mode'],
|
||||
];
|
||||
}
|
||||
|
||||
public function currentMetric(array $descriptor, ?string $date = null): array
|
||||
{
|
||||
$descriptor = $this->normalizeDescriptor($descriptor);
|
||||
$setting = $this->settingFor($descriptor);
|
||||
$window = $this->periodWindow((string)$descriptor['period'], $date);
|
||||
$limit = $this->resolveLimitQuantity($descriptor);
|
||||
$used = null;
|
||||
$updatedAt = null;
|
||||
$historyAvailable = false;
|
||||
$snapshotExtra = [];
|
||||
|
||||
if (($descriptor['source'] ?? '') === 'provider_snapshot') {
|
||||
$snapshot = $this->latestProviderSnapshot($descriptor);
|
||||
if ($snapshot !== null) {
|
||||
$used = $snapshot['used_quantity'];
|
||||
$limit = $snapshot['limit_quantity'];
|
||||
$updatedAt = $snapshot['checked_at'];
|
||||
$snapshotExtra = $snapshot['extra'];
|
||||
$historyAvailable = true;
|
||||
}
|
||||
} else {
|
||||
$counter = $this->counterFor($descriptor, $window);
|
||||
if ($counter !== null) {
|
||||
$used = $counter['used_quantity'];
|
||||
$limit = $counter['limit_quantity'] ?? $limit;
|
||||
$updatedAt = $counter['updated_at'] ?? $counter['created_at'] ?? null;
|
||||
$historyAvailable = true;
|
||||
} else {
|
||||
$derived = $this->derivedOrLegacyUsage($descriptor, $window);
|
||||
if ($derived !== null) {
|
||||
$used = $derived;
|
||||
$historyAvailable = true;
|
||||
} elseif (($descriptor['source'] ?? '') === 'internal_counter') {
|
||||
$used = 0.0;
|
||||
$historyAvailable = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$status = $this->statusForUsage($used, $limit, $setting);
|
||||
$remaining = ($used !== null && $limit !== null) ? max(0.0, $limit - $used) : null;
|
||||
$usagePercent = ($used !== null && $limit !== null && $limit > 0) ? round(($used / $limit) * 100, 2) : null;
|
||||
|
||||
return array_merge([
|
||||
'module_key' => $descriptor['module_key'],
|
||||
'module_label' => $descriptor['module_label'],
|
||||
'metric_key' => $descriptor['metric_key'],
|
||||
'metric_label' => $descriptor['metric_label'],
|
||||
'unit' => $descriptor['unit'],
|
||||
'period' => $descriptor['period'],
|
||||
'scope_type' => $descriptor['scope_type'],
|
||||
'scope_id' => $descriptor['scope_id'],
|
||||
'source' => $descriptor['source'],
|
||||
'primary' => (bool)$descriptor['primary'],
|
||||
'writable_limit' => (bool)$descriptor['writable_limit'],
|
||||
'limit_source' => isset($descriptor['config_variable']) ? 'module_config' : (($descriptor['source'] ?? '') === 'provider_snapshot' ? 'provider' : null),
|
||||
'config_module' => $descriptor['config_module'] ?? null,
|
||||
'config_variable' => $descriptor['config_variable'] ?? null,
|
||||
'used' => $used,
|
||||
'limit' => $limit,
|
||||
'remaining' => $remaining,
|
||||
'usage_percent' => $usagePercent,
|
||||
'status' => $status,
|
||||
'enabled' => (bool)$setting['enabled'],
|
||||
'enforce_mode' => $setting['enforce_mode'],
|
||||
'soft_limit_percent' => (float)$setting['soft_limit_percent'],
|
||||
'window' => [
|
||||
'start' => $window['start_c'],
|
||||
'end' => $window['end_c'],
|
||||
'timezone' => date_default_timezone_get(),
|
||||
],
|
||||
'updated_at' => $updatedAt,
|
||||
'history_available' => $historyAvailable,
|
||||
], $snapshotExtra);
|
||||
}
|
||||
|
||||
private function requireDescriptor(string $moduleKey, string $metricKey): array
|
||||
{
|
||||
$descriptor = $this->registry->find($moduleKey, $metricKey);
|
||||
if ($descriptor === null) {
|
||||
throw new Exception('Unknown module usage metric.');
|
||||
}
|
||||
return $descriptor;
|
||||
}
|
||||
|
||||
private function normalizeDescriptor(array $descriptor): array
|
||||
{
|
||||
$found = $this->registry->find((string)$descriptor['module_key'], (string)$descriptor['metric_key']);
|
||||
return $found ?? $descriptor;
|
||||
}
|
||||
|
||||
private function settingFor(array $descriptor): array
|
||||
{
|
||||
$default = [
|
||||
'enabled' => true,
|
||||
'enforce_mode' => (string)($descriptor['default_enforce_mode'] ?? 'observe'),
|
||||
'soft_limit_percent' => (float)($descriptor['soft_limit_percent'] ?? module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT),
|
||||
];
|
||||
|
||||
if (!$this->databaseReady()) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
try {
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
"SELECT enabled, enforce_mode, soft_limit_percent
|
||||
FROM module_quota_settings
|
||||
WHERE module_key = " . $this->sqlString((string)$descriptor['module_key']) . "
|
||||
AND metric_key = " . $this->sqlString((string)$descriptor['metric_key']) . "
|
||||
LIMIT 1"
|
||||
);
|
||||
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||
if (!is_array($row)) {
|
||||
return $default;
|
||||
}
|
||||
return [
|
||||
'enabled' => (bool)((int)($row['enabled'] ?? 1)),
|
||||
'enforce_mode' => in_array((string)($row['enforce_mode'] ?? ''), ['observe', 'block'], true)
|
||||
? (string)$row['enforce_mode']
|
||||
: $default['enforce_mode'],
|
||||
'soft_limit_percent' => is_numeric($row['soft_limit_percent'] ?? null)
|
||||
? (float)$row['soft_limit_percent']
|
||||
: $default['soft_limit_percent'],
|
||||
];
|
||||
} catch (Throwable) {
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveLimitQuantity(array $descriptor): ?float
|
||||
{
|
||||
if (empty($descriptor['config_module']) || empty($descriptor['config_variable']) || !$this->databaseReady()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
"SELECT value
|
||||
FROM module_config
|
||||
WHERE module = " . $this->sqlString((string)$descriptor['config_module']) . "
|
||||
AND variable = " . $this->sqlString((string)$descriptor['config_variable']) . "
|
||||
LIMIT 1"
|
||||
);
|
||||
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||
if (!is_array($row) || !is_numeric($row['value'] ?? null)) {
|
||||
return null;
|
||||
}
|
||||
return max(0.0, (float)$row['value']);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function writeConfigLimit(array $descriptor, int $limit): void
|
||||
{
|
||||
if (!$this->databaseReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
$module = (string)$descriptor['config_module'];
|
||||
$variable = (string)$descriptor['config_variable'];
|
||||
$type = (string)($descriptor['config_type'] ?? 'int');
|
||||
|
||||
$existing = $db->query(
|
||||
"SELECT id
|
||||
FROM module_config
|
||||
WHERE module = " . $this->sqlString($module) . "
|
||||
AND variable = " . $this->sqlString($variable) . "
|
||||
LIMIT 1"
|
||||
);
|
||||
if ($existing instanceof mysqli_result && $existing->num_rows > 0) {
|
||||
$db->query(
|
||||
"UPDATE module_config
|
||||
SET value = " . $this->sqlString((string)$limit) . ", type = " . $this->sqlString($type) . "
|
||||
WHERE module = " . $this->sqlString($module) . "
|
||||
AND variable = " . $this->sqlString($variable)
|
||||
);
|
||||
} else {
|
||||
$db->query(
|
||||
"INSERT INTO module_config (module, variable, value, type)
|
||||
VALUES (" . $this->sqlString($module) . ", " . $this->sqlString($variable) . ", " . $this->sqlString((string)$limit) . ", " . $this->sqlString($type) . ")"
|
||||
);
|
||||
}
|
||||
system_search_cache::markDirtyTable('module_config');
|
||||
}
|
||||
|
||||
private function insertCounterIfMissing(array $descriptor, array $period, ?float $limit): void
|
||||
{
|
||||
if (!$this->databaseReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
$baseline = $this->derivedOrLegacyUsage($descriptor, $period);
|
||||
$baseline = $baseline === null ? 0.0 : max(0.0, (float)$baseline);
|
||||
$metadata = [
|
||||
'created_from' => $baseline > 0 ? 'legacy_or_derived_baseline' : 'counter',
|
||||
];
|
||||
|
||||
$db->query(
|
||||
"INSERT IGNORE INTO module_usage_counters
|
||||
(module_key, metric_key, scope_type, scope_id, period_key, period_start, period_end, unit, used_quantity, limit_quantity, status, metadata_json)
|
||||
VALUES (
|
||||
" . $this->sqlString((string)$descriptor['module_key']) . ",
|
||||
" . $this->sqlString((string)$descriptor['metric_key']) . ",
|
||||
" . $this->sqlString((string)$descriptor['scope_type']) . ",
|
||||
" . $this->sqlString((string)$descriptor['scope_id']) . ",
|
||||
" . $this->sqlString($period['key']) . ",
|
||||
" . $this->sqlString($period['start_sql']) . ",
|
||||
" . ($period['end_sql'] === null ? 'NULL' : $this->sqlString($period['end_sql'])) . ",
|
||||
" . $this->sqlString((string)$descriptor['unit']) . ",
|
||||
" . $this->numberSql($baseline) . ",
|
||||
" . $this->nullableNumberSql($limit) . ",
|
||||
" . $this->sqlString($this->statusForUsage($baseline, $limit, $this->settingFor($descriptor))) . ",
|
||||
" . $this->jsonSql($metadata) . "
|
||||
)"
|
||||
);
|
||||
}
|
||||
|
||||
private function counterFor(array $descriptor, array $period): ?array
|
||||
{
|
||||
if (!$this->databaseReady()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
"SELECT used_quantity, limit_quantity, status, created_at, updated_at
|
||||
FROM module_usage_counters
|
||||
WHERE " . $this->counterWhereSql($descriptor, $period) . "
|
||||
LIMIT 1"
|
||||
);
|
||||
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||
if (!is_array($row)) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'used_quantity' => (float)$row['used_quantity'],
|
||||
'limit_quantity' => $row['limit_quantity'] === null ? null : (float)$row['limit_quantity'],
|
||||
'status' => (string)$row['status'],
|
||||
'created_at' => $row['created_at'] ?? null,
|
||||
'updated_at' => $row['updated_at'] ?? null,
|
||||
];
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function latestProviderSnapshot(array $descriptor): ?array
|
||||
{
|
||||
if (!$this->databaseReady()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
"SELECT used_quantity, limit_quantity, remaining_quantity, usage_percent, status, raw_payload_json, checked_at
|
||||
FROM module_usage_snapshots
|
||||
WHERE module_key = " . $this->sqlString((string)$descriptor['module_key']) . "
|
||||
AND metric_key = " . $this->sqlString((string)$descriptor['metric_key']) . "
|
||||
ORDER BY checked_at DESC, id DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||
if (!is_array($row)) {
|
||||
return null;
|
||||
}
|
||||
$raw = json_decode((string)($row['raw_payload_json'] ?? ''), true);
|
||||
$usage = is_array($raw) && isset($raw['usage']) && is_array($raw['usage']) ? $raw['usage'] : [];
|
||||
$extra = [];
|
||||
if (isset($usage['version'])) {
|
||||
$extra['version'] = (string)$usage['version'];
|
||||
}
|
||||
if (array_key_exists('usage_available', $usage)) {
|
||||
$extra['usage_available'] = $this->toBool($usage['usage_available']);
|
||||
}
|
||||
if (isset($usage['unavailable_reason'])) {
|
||||
$extra['unavailable_reason'] = (string)$usage['unavailable_reason'];
|
||||
}
|
||||
if (isset($usage['detected_keys']) && is_array($usage['detected_keys'])) {
|
||||
$extra['detected_keys'] = array_values(array_map('strval', $usage['detected_keys']));
|
||||
}
|
||||
|
||||
return [
|
||||
'used_quantity' => $row['used_quantity'] === null ? null : (float)$row['used_quantity'],
|
||||
'limit_quantity' => $row['limit_quantity'] === null ? null : (float)$row['limit_quantity'],
|
||||
'remaining_quantity' => $row['remaining_quantity'] === null ? null : (float)$row['remaining_quantity'],
|
||||
'usage_percent' => $row['usage_percent'] === null ? null : (float)$row['usage_percent'],
|
||||
'status' => (string)$row['status'],
|
||||
'checked_at' => $row['checked_at'] ? date('c', strtotime((string)$row['checked_at'])) : null,
|
||||
'extra' => $extra,
|
||||
];
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function providerMetricFromValues(array $descriptor, ?float $used, ?float $limit, ?float $remaining, ?float $percent, string $status, string $checkedAt, array $usage): array
|
||||
{
|
||||
return [
|
||||
'module_key' => $descriptor['module_key'],
|
||||
'module_label' => $descriptor['module_label'],
|
||||
'metric_key' => $descriptor['metric_key'],
|
||||
'metric_label' => $descriptor['metric_label'],
|
||||
'unit' => $descriptor['unit'],
|
||||
'period' => $descriptor['period'],
|
||||
'scope_type' => $descriptor['scope_type'],
|
||||
'scope_id' => $descriptor['scope_id'],
|
||||
'source' => $descriptor['source'],
|
||||
'primary' => (bool)$descriptor['primary'],
|
||||
'writable_limit' => false,
|
||||
'limit_source' => 'provider',
|
||||
'used' => $used,
|
||||
'limit' => $limit,
|
||||
'remaining' => $remaining,
|
||||
'usage_percent' => $percent,
|
||||
'status' => $status,
|
||||
'enabled' => true,
|
||||
'enforce_mode' => 'observe',
|
||||
'soft_limit_percent' => module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT,
|
||||
'window' => ['start' => null, 'end' => null, 'timezone' => date_default_timezone_get()],
|
||||
'updated_at' => $checkedAt,
|
||||
'history_available' => true,
|
||||
'version' => isset($usage['version']) ? (string)$usage['version'] : null,
|
||||
'usage_available' => array_key_exists('usage_available', $usage) ? $this->toBool($usage['usage_available']) : true,
|
||||
'unavailable_reason' => isset($usage['unavailable_reason']) ? (string)$usage['unavailable_reason'] : null,
|
||||
'detected_keys' => isset($usage['detected_keys']) && is_array($usage['detected_keys'])
|
||||
? array_values(array_map('strval', $usage['detected_keys']))
|
||||
: [],
|
||||
];
|
||||
}
|
||||
|
||||
private function derivedOrLegacyUsage(array $descriptor, array $period): ?float
|
||||
{
|
||||
try {
|
||||
if (isset($descriptor['legacy_count_table'])) {
|
||||
return $this->countRowsInPeriod((string)$descriptor['legacy_count_table'], 'created_at', $period);
|
||||
}
|
||||
|
||||
if (isset($descriptor['legacy_log_module'])) {
|
||||
return $this->countActionLogs(
|
||||
(string)$descriptor['legacy_log_module'],
|
||||
isset($descriptor['legacy_log_action']) ? (string)$descriptor['legacy_log_action'] : null,
|
||||
$period
|
||||
);
|
||||
}
|
||||
|
||||
return match ($descriptor['module_key'] . '.' . $descriptor['metric_key']) {
|
||||
'backups.backup_jobs' => $this->countRowsInPeriod('backup_jobs', 'created_at', $period),
|
||||
'backups.stored_bytes' => $this->sumColumn('backup_records', 'total_bytes'),
|
||||
'coolify.operations' => $this->countRowsInPeriod('coolify_operations', 'created_at', $period),
|
||||
'selfserve.wash_sessions' => $this->countRowsInPeriod('selfserve_wash_sessions', 'created_at', $period),
|
||||
'xlvask.usage_rows' => $this->countRowsInPeriod('xlvask_usage_logs', 'StartTime', $period),
|
||||
'attachments.stored_files' => $this->countRowsInPeriod('object_attachments', null, $period),
|
||||
'forms.submissions' => $this->countRowsInPeriod('form_submissions', 'created_at', $period),
|
||||
'notifications.notification_sends' => $this->countRowsInPeriod('notifications', 'created_at', $period),
|
||||
'edgegateway.relay_commands' => $this->countRowsInPeriod('edge_gateway_operations', 'created_at', $period),
|
||||
'system.cron_runs' => $this->countRowsInPeriod('cron_task_runs', 'created_at', $period),
|
||||
default => null,
|
||||
};
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function countActionLogs(string $module, ?string $action, array $period): ?float
|
||||
{
|
||||
if (!$this->tableExists('module_usage_logs')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
global $db;
|
||||
$where = "UPPER(module) = " . $this->sqlString(strtoupper($module));
|
||||
if ($action !== null && $action !== '') {
|
||||
$where .= " AND UPPER(action) = " . $this->sqlString(strtoupper($action));
|
||||
}
|
||||
$where .= $this->periodWhereSql('created_at', $period);
|
||||
|
||||
$result = $db->query("SELECT COUNT(*) AS usage_count FROM module_usage_logs WHERE {$where}");
|
||||
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||
return is_array($row) ? (float)$row['usage_count'] : null;
|
||||
}
|
||||
|
||||
private function countRowsInPeriod(string $table, ?string $dateColumn, array $period): ?float
|
||||
{
|
||||
if (!$this->tableExists($table)) {
|
||||
return null;
|
||||
}
|
||||
if ($dateColumn !== null && !$this->columnExists($table, $dateColumn)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
global $db;
|
||||
$where = '1=1';
|
||||
if ($dateColumn !== null) {
|
||||
$where .= $this->periodWhereSql($dateColumn, $period);
|
||||
} elseif ($period['key'] !== 'all_time') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = $db->query("SELECT COUNT(*) AS usage_count FROM `{$table}` WHERE {$where}");
|
||||
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||
return is_array($row) ? (float)$row['usage_count'] : null;
|
||||
}
|
||||
|
||||
private function sumColumn(string $table, string $column): ?float
|
||||
{
|
||||
if (!$this->tableExists($table) || !$this->columnExists($table, $column)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
global $db;
|
||||
$result = $db->query("SELECT COALESCE(SUM(`{$column}`), 0) AS usage_sum FROM `{$table}`");
|
||||
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||
return is_array($row) ? (float)$row['usage_sum'] : null;
|
||||
}
|
||||
|
||||
private function historyForModule(string $moduleKey, array $filters): array
|
||||
{
|
||||
if (!$this->databaseReady()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$limit = isset($filters['limit']) && is_numeric($filters['limit']) ? max(1, min(200, (int)$filters['limit'])) : 100;
|
||||
$rows = [];
|
||||
|
||||
try {
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
"SELECT module_key, metric_key, period_key, period_start, period_end, used_quantity, limit_quantity, status, updated_at, created_at
|
||||
FROM module_usage_counters
|
||||
WHERE module_key = " . $this->sqlString($moduleKey) . "
|
||||
ORDER BY period_start DESC, id DESC
|
||||
LIMIT {$limit}"
|
||||
);
|
||||
if ($result instanceof mysqli_result) {
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function statusForUsage(?float $used, ?float $limit, array $setting): string
|
||||
{
|
||||
if (($setting['enabled'] ?? true) !== true) {
|
||||
return 'disabled';
|
||||
}
|
||||
if ($used === null) {
|
||||
return 'unknown';
|
||||
}
|
||||
if ($limit === null) {
|
||||
return 'unlimited';
|
||||
}
|
||||
if ($limit <= 0.0) {
|
||||
return $used > 0.0 ? 'exhausted' : 'ok';
|
||||
}
|
||||
|
||||
$percent = ($used / $limit) * 100;
|
||||
if ($used >= $limit || $percent >= 100.0) {
|
||||
return 'exhausted';
|
||||
}
|
||||
if ($percent >= (float)($setting['soft_limit_percent'] ?? module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT)) {
|
||||
return 'near_limit';
|
||||
}
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
private function worseStatus(string $current, string $candidate): string
|
||||
{
|
||||
return $this->statusRank($candidate) > $this->statusRank($current) ? $candidate : $current;
|
||||
}
|
||||
|
||||
private function statusRank(string $status): int
|
||||
{
|
||||
return match ($status) {
|
||||
'exhausted' => 5,
|
||||
'near_limit' => 4,
|
||||
'unknown' => 3,
|
||||
'disabled' => 2,
|
||||
'unlimited' => 1,
|
||||
'ok' => 0,
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
|
||||
private function quotaExceededMessage(array $descriptor): string
|
||||
{
|
||||
return match ((string)$descriptor['period']) {
|
||||
'day' => 'Daily limit exceeded',
|
||||
'month' => 'Monthly limit exceeded',
|
||||
default => 'Quota limit exceeded',
|
||||
};
|
||||
}
|
||||
|
||||
private function periodWindow(string $period, ?string $date = null): array
|
||||
{
|
||||
$timestamp = $date ? strtotime($date) : time();
|
||||
if ($timestamp === false) {
|
||||
$timestamp = time();
|
||||
}
|
||||
|
||||
return match ($period) {
|
||||
'day' => $this->periodFromTimestamps('day', strtotime(date('Y-m-d 00:00:00', $timestamp)), strtotime(date('Y-m-d 00:00:00', $timestamp) . ' +1 day')),
|
||||
'month' => $this->periodFromTimestamps('month', strtotime(date('Y-m-01 00:00:00', $timestamp)), strtotime(date('Y-m-01 00:00:00', $timestamp) . ' +1 month')),
|
||||
'provider' => ['key' => 'provider', 'start_sql' => date('Y-m-d 00:00:00', $timestamp), 'end_sql' => null, 'start_c' => null, 'end_c' => null],
|
||||
default => ['key' => 'all_time', 'start_sql' => '1970-01-01 00:00:00', 'end_sql' => null, 'start_c' => null, 'end_c' => null],
|
||||
};
|
||||
}
|
||||
|
||||
private function periodFromTimestamps(string $key, int $start, int $end): array
|
||||
{
|
||||
return [
|
||||
'key' => $key,
|
||||
'start_sql' => date('Y-m-d H:i:s', $start),
|
||||
'end_sql' => date('Y-m-d H:i:s', $end),
|
||||
'start_c' => date('c', $start),
|
||||
'end_c' => date('c', $end),
|
||||
];
|
||||
}
|
||||
|
||||
private function periodWhereSql(string $dateColumn, array $period): string
|
||||
{
|
||||
if ($period['key'] === 'all_time' || $period['key'] === 'provider') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$dateColumn = preg_replace('/[^a-zA-Z0-9_]/', '', $dateColumn);
|
||||
if ($dateColumn === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return " AND `{$dateColumn}` >= " . $this->sqlString($period['start_sql']) . " AND `{$dateColumn}` < " . $this->sqlString((string)$period['end_sql']);
|
||||
}
|
||||
|
||||
private function counterWhereSql(array $descriptor, array $period): string
|
||||
{
|
||||
return "module_key = " . $this->sqlString((string)$descriptor['module_key'])
|
||||
. " AND metric_key = " . $this->sqlString((string)$descriptor['metric_key'])
|
||||
. " AND scope_type = " . $this->sqlString((string)$descriptor['scope_type'])
|
||||
. " AND scope_id = " . $this->sqlString((string)$descriptor['scope_id'])
|
||||
. " AND period_key = " . $this->sqlString($period['key'])
|
||||
. " AND period_start = " . $this->sqlString($period['start_sql']);
|
||||
}
|
||||
|
||||
private function databaseReady(): bool
|
||||
{
|
||||
global $db;
|
||||
return isset($db) && is_object($db) && method_exists($db, 'query') && method_exists($db, 'conn');
|
||||
}
|
||||
|
||||
private function tableExists(string $table): bool
|
||||
{
|
||||
if (!$this->databaseReady()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
global $db;
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
if ($table === '') {
|
||||
return false;
|
||||
}
|
||||
$result = $db->query("SHOW TABLES LIKE " . $this->sqlString($table));
|
||||
return $result instanceof mysqli_result && $result->num_rows > 0;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function columnExists(string $table, string $column): bool
|
||||
{
|
||||
if (!$this->databaseReady()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
global $db;
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||
if ($table === '' || $column === '') {
|
||||
return false;
|
||||
}
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE " . $this->sqlString($column));
|
||||
return $result instanceof mysqli_result && $result->num_rows > 0;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function firstNumeric(array $payload, array $keys): ?float
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
if (isset($payload[$key]) && is_numeric($payload[$key])) {
|
||||
return (float)$payload[$key];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function redactPayload(array $payload): array
|
||||
{
|
||||
$redacted = [];
|
||||
foreach ($payload as $key => $value) {
|
||||
$normalized = strtolower((string)$key);
|
||||
if (str_contains($normalized, 'key') || str_contains($normalized, 'token') || str_contains($normalized, 'secret')) {
|
||||
$redacted[$key] = '[redacted]';
|
||||
continue;
|
||||
}
|
||||
$redacted[$key] = is_array($value) ? $this->redactPayload($value) : $value;
|
||||
}
|
||||
return $redacted;
|
||||
}
|
||||
|
||||
private function toBool(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private function sqlString(string $value): string
|
||||
{
|
||||
global $db;
|
||||
return "'" . $db->escape_string($value) . "'";
|
||||
}
|
||||
|
||||
private function numberSql(float $value): string
|
||||
{
|
||||
return rtrim(rtrim(sprintf('%.4F', $value), '0'), '.') ?: '0';
|
||||
}
|
||||
|
||||
private function nullableNumberSql(?float $value): string
|
||||
{
|
||||
return $value === null ? 'NULL' : $this->numberSql($value);
|
||||
}
|
||||
|
||||
private function jsonSql(array $value): string
|
||||
{
|
||||
return $this->sqlString(json_encode($this->redactPayload($value), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use motorapi\actions\license_plate_lookup_a;
|
||||
use motorapi\helpers\motorapi_vehicle_types;
|
||||
use motorapi\motorapi_c;
|
||||
use objects\motorapi_lookups_o;
|
||||
use Throwable;
|
||||
|
||||
class motorapi implements motorapi_i
|
||||
{
|
||||
@@ -182,10 +183,10 @@ class motorapi implements motorapi_i
|
||||
self::requireModuleEnabled();
|
||||
// Validate the license plate
|
||||
self::requireValidLicensePlate($licensePlate);
|
||||
// Validate the daily limit
|
||||
self::requireDailyLimitNotExceeded();
|
||||
// Validate the secret key
|
||||
self::requireValidSecretKey();
|
||||
// Reserve quota for the outbound provider call. Cache hits return before this point.
|
||||
$this->reserveLookupQuota($licensePlate, $endpoint, $method);
|
||||
// Send the request
|
||||
$response = match ($method) {
|
||||
'GET' => self::sendGetRequest($licensePlate, $endpoint, $data),
|
||||
@@ -217,7 +218,7 @@ class motorapi implements motorapi_i
|
||||
function requireDailyLimitNotExceeded(): void
|
||||
{
|
||||
// Check if the daily limit is exceeded
|
||||
if ($this->getDailyRequestCounter() >= $this->config->daily_limit->getVariableValue()) {
|
||||
if ($this->getDailyRequestCounter() >= (int)$this->config->daily_limit->getVariableValue()) {
|
||||
throw new Exception('Daily limit exceeded');
|
||||
}
|
||||
}
|
||||
@@ -227,12 +228,29 @@ class motorapi implements motorapi_i
|
||||
*/
|
||||
function getDailyRequestCounter(): int
|
||||
{
|
||||
try {
|
||||
return (new module_usage_service())->currentUsedQuantity('motorapi', 'lookup_calls');
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
// Count the rows from the motorapi request log that was made today
|
||||
$motorapi_lookups = new motorapi_lookups_o();
|
||||
$motorapi_lookups->getTodayCount();
|
||||
return $motorapi_lookups->getTodayCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function reserveLookupQuota(string $licensePlate, string $endpoint, string $method): void
|
||||
{
|
||||
(new module_usage_service())->reserveOrFail('motorapi', 'lookup_calls', 1, [
|
||||
'license_plate' => $licensePlate,
|
||||
'endpoint' => $endpoint,
|
||||
'method' => strtoupper($method),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
|
||||
@@ -340,18 +340,49 @@ class n8n implements n8n_i
|
||||
throw new Exception('Webhook target must not be empty.');
|
||||
}
|
||||
|
||||
if (filter_var($target, FILTER_VALIDATE_URL) !== false) {
|
||||
return $target;
|
||||
}
|
||||
|
||||
$baseUrl = trim((string)$this->config->webhook_base_url->getVariableValue());
|
||||
if ($baseUrl === '') {
|
||||
throw new Exception('n8n webhook base URL is not configured.');
|
||||
}
|
||||
|
||||
if (filter_var($target, FILTER_VALIDATE_URL) !== false) {
|
||||
if (!$this->isAllowedWebhookAbsoluteUrl($target, $baseUrl)) {
|
||||
throw new Exception('Webhook URL must use the configured n8n webhook host.');
|
||||
}
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
return rtrim($baseUrl, '/') . '/' . ltrim($target, '/');
|
||||
}
|
||||
|
||||
private function isAllowedWebhookAbsoluteUrl(string $targetUrl, string $baseUrl): bool
|
||||
{
|
||||
$targetParts = parse_url($targetUrl);
|
||||
$baseParts = parse_url($baseUrl);
|
||||
|
||||
if (!is_array($targetParts) || !is_array($baseParts)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$targetHost = strtolower((string)($targetParts['host'] ?? ''));
|
||||
$baseHost = strtolower((string)($baseParts['host'] ?? ''));
|
||||
if ($targetHost === '' || $baseHost === '' || $targetHost !== $baseHost) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$targetScheme = strtolower((string)($targetParts['scheme'] ?? ''));
|
||||
$baseScheme = strtolower((string)($baseParts['scheme'] ?? ''));
|
||||
if ($targetScheme === '' || $baseScheme === '' || $targetScheme !== $baseScheme) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$targetPort = (int)($targetParts['port'] ?? ($targetScheme === 'https' ? 443 : 80));
|
||||
$basePort = (int)($baseParts['port'] ?? ($baseScheme === 'https' ? 443 : 80));
|
||||
|
||||
return $targetPort === $basePort;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
|
||||
@@ -46,7 +46,7 @@ class order_reference_suggestions_service
|
||||
$rows = [
|
||||
...$this->fetchBookingRows($departmentId, $search),
|
||||
...$this->fetchOrderRows($departmentId, $search),
|
||||
...$this->fetchVehicleRows($customerId, $plates, $search),
|
||||
...$this->fetchVehicleRows($departmentId, $customerId, $plates, $search),
|
||||
];
|
||||
|
||||
$suggestions = $this->aggregateRows($rows, $search, $customerId, $plates);
|
||||
@@ -137,7 +137,7 @@ class order_reference_suggestions_service
|
||||
* @param array<int, string> $plates
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function fetchVehicleRows(?int $customerId, array $plates, string $search): array
|
||||
private function fetchVehicleRows(int $departmentId, ?int $customerId, array $plates, string $search): array
|
||||
{
|
||||
$contextWhere = [];
|
||||
$params = [];
|
||||
@@ -171,6 +171,10 @@ class order_reference_suggestions_service
|
||||
$params['search'] = '%' . $this->lower($search) . '%';
|
||||
}
|
||||
|
||||
$where[] = $this->vehicleDepartmentAccessPredicate();
|
||||
$params['orders_department_id'] = $departmentId;
|
||||
$params['bookings_department_id'] = $departmentId;
|
||||
|
||||
$sql = "SELECT
|
||||
'vehicle' AS source,
|
||||
id AS origin_id,
|
||||
@@ -190,6 +194,41 @@ class order_reference_suggestions_service
|
||||
return $this->fetchRows($sql, $params);
|
||||
}
|
||||
|
||||
private function vehicleDepartmentAccessPredicate(): string
|
||||
{
|
||||
$ordersWhere = [
|
||||
'authorized_orders.department_id = :orders_department_id',
|
||||
'(authorized_orders.customer_id = customer_vehicles.customer_id'
|
||||
. " OR UPPER(REPLACE(authorized_orders.reg_1, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
|
||||
. " OR UPPER(REPLACE(authorized_orders.reg_2, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
|
||||
. " OR UPPER(REPLACE(authorized_orders.reg_3, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', '')))"
|
||||
];
|
||||
if ($this->tableHasColumn('orders', 'deleted_at')) {
|
||||
$ordersWhere[] = 'authorized_orders.deleted_at IS NULL';
|
||||
}
|
||||
|
||||
$bookingsWhere = [
|
||||
'authorized_bookings.department = :bookings_department_id',
|
||||
'(authorized_bookings.customer_number = customer_vehicles.customer_id'
|
||||
. " OR UPPER(REPLACE(authorized_bookings.reg_1, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
|
||||
. " OR UPPER(REPLACE(authorized_bookings.reg_2, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
|
||||
. " OR UPPER(REPLACE(authorized_bookings.reg_3, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', '')))"
|
||||
];
|
||||
if ($this->tableHasColumn('order_bookings', 'deleted_at')) {
|
||||
$bookingsWhere[] = 'authorized_bookings.deleted_at IS NULL';
|
||||
}
|
||||
|
||||
return '(EXISTS (
|
||||
SELECT 1
|
||||
FROM orders authorized_orders
|
||||
WHERE ' . implode(' AND ', $ordersWhere) . '
|
||||
) OR EXISTS (
|
||||
SELECT 1
|
||||
FROM order_bookings authorized_bookings
|
||||
WHERE ' . implode(' AND ', $bookingsWhere) . '
|
||||
))';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @return array<int, array<string, mixed>>
|
||||
|
||||
@@ -58,6 +58,9 @@ class orders_schema_bootstrap
|
||||
|| !self::columnExists($db, 'orders', 'booking_id')
|
||||
|| !self::columnExists($db, 'orders', 'po')
|
||||
|| !self::columnExists($db, 'order_bookings', 'po')
|
||||
|| !self::columnExists($db, 'order_bookings', 'customer_number')
|
||||
|| !self::columnExists($db, 'order_bookings', 'department')
|
||||
|| !self::columnExists($db, 'order_bookings', 'deleted_at')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -65,6 +68,9 @@ class orders_schema_bootstrap
|
||||
$db->query(
|
||||
"UPDATE orders o
|
||||
INNER JOIN order_bookings b ON b.id = o.booking_id
|
||||
AND b.customer_number = o.customer_id
|
||||
AND b.department = o.department_id
|
||||
AND b.deleted_at IS NULL
|
||||
SET o.po = b.po
|
||||
WHERE o.booking_id IS NOT NULL
|
||||
AND o.booking_id > 0
|
||||
|
||||
@@ -47,8 +47,12 @@ class pdf_store implements minio_pdfs_i
|
||||
*/
|
||||
public function download(string $file): string
|
||||
{
|
||||
if ($this->shouldUseLocalTestStorage()) {
|
||||
return $this->getLocalTestObjectPath($file);
|
||||
}
|
||||
|
||||
$path = '/tmp/' . $file;
|
||||
$result = self::getS3Client()->getObject([
|
||||
self::getS3Client()->getObject([
|
||||
'Bucket' => self::getBucket(),
|
||||
'Key' => $file,
|
||||
'SaveAs' => $path
|
||||
@@ -58,7 +62,7 @@ class pdf_store implements minio_pdfs_i
|
||||
|
||||
public function isFileInStore(string $file): bool
|
||||
{
|
||||
return self::getS3Client()->doesObjectExist(self::getBucket(), $file);
|
||||
return self::doesObjectExist($file);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,4 +76,4 @@ class pdf_store implements minio_pdfs_i
|
||||
$file_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);
|
||||
}
|
||||
}
|
||||
@@ -392,7 +392,19 @@ class redis implements redis_i
|
||||
|
||||
private function invoicePeriodCacheKey(string $prefix, string $dateFrom, string $dateTo): string
|
||||
{
|
||||
return $prefix . ':' . $dateFrom . ':' . $dateTo;
|
||||
return $prefix . ':'
|
||||
. $this->normalizeInvoicePeriodCacheDate($dateFrom, true) . ':'
|
||||
. $this->normalizeInvoicePeriodCacheDate($dateTo, false);
|
||||
}
|
||||
|
||||
private function normalizeInvoicePeriodCacheDate(string $date, bool $startOfDay): string
|
||||
{
|
||||
$timestamp = strtotime($date);
|
||||
if ($timestamp === false) {
|
||||
return $date;
|
||||
}
|
||||
|
||||
return date($startOfDay ? 'Y-m-d 00:00:00' : 'Y-m-d 23:59:59', $timestamp);
|
||||
}
|
||||
|
||||
private function workfeedEmployeeNameCacheKey(string $employeeId): string
|
||||
@@ -512,7 +524,11 @@ class redis implements redis_i
|
||||
*/
|
||||
public function enqueue_invoice_period_warming(string $dateFrom, string $dateTo): self
|
||||
{
|
||||
$this->get_client()->sadd('invoice_period_warming_queue', [$dateFrom . '|' . $dateTo]);
|
||||
$this->get_client()->sadd('invoice_period_warming_queue', [
|
||||
$this->normalizeInvoicePeriodCacheDate($dateFrom, true)
|
||||
. '|'
|
||||
. $this->normalizeInvoicePeriodCacheDate($dateTo, false),
|
||||
]);
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -89,7 +89,7 @@ class router
|
||||
}
|
||||
// Regex
|
||||
$route = str_replace('/', '\/', $route);
|
||||
$route = preg_replace('/{[a-zA-Z0-9]+}/', '([a-zA-Z0-9]+)', $route);
|
||||
$route = preg_replace('/{[a-zA-Z0-9_]+}/', '([a-zA-Z0-9]+)', $route);
|
||||
if (preg_match('/^' . $route . '$/', $this->url)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class security_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
$queries = [
|
||||
"CREATE TABLE IF NOT EXISTS security_firewall_rules (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
action VARCHAR(16) NOT NULL,
|
||||
target_type VARCHAR(32) NOT NULL,
|
||||
target_value VARCHAR(255) NOT NULL,
|
||||
route_pattern VARCHAR(255) NULL,
|
||||
priority INT NOT NULL DEFAULT 100,
|
||||
reason TEXT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
expires_at DATETIME NULL,
|
||||
metadata_json LONGTEXT NULL,
|
||||
created_by INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
INDEX idx_security_firewall_rules_active (enabled, deleted_at, expires_at),
|
||||
INDEX idx_security_firewall_rules_target (target_type, target_value),
|
||||
INDEX idx_security_firewall_rules_priority (priority)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS security_policy_rules (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
rule_key VARCHAR(64) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
threshold_count INT NOT NULL,
|
||||
window_seconds INT NOT NULL,
|
||||
mode VARCHAR(16) NOT NULL DEFAULT 'observe',
|
||||
exempt_permission_nodes_json LONGTEXT NULL,
|
||||
updated_by INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_security_policy_rules_key (rule_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS security_policy_events (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
rule_key VARCHAR(64) NOT NULL,
|
||||
subject_type VARCHAR(32) NOT NULL,
|
||||
subject_key VARCHAR(191) NOT NULL,
|
||||
route_path VARCHAR(255) NULL,
|
||||
route_template VARCHAR(255) NULL,
|
||||
method VARCHAR(16) NULL,
|
||||
source_ip VARCHAR(64) NULL,
|
||||
customer_number INT NULL,
|
||||
user_id INT NULL,
|
||||
subuser_id INT NULL,
|
||||
metadata_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_security_policy_events_window (rule_key, subject_type, subject_key, created_at),
|
||||
INDEX idx_security_policy_events_created (created_at),
|
||||
INDEX idx_security_policy_events_customer (customer_number, created_at),
|
||||
INDEX idx_security_policy_events_ip (source_ip, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS security_incidents (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
incident_key VARCHAR(191) NOT NULL,
|
||||
type VARCHAR(64) NOT NULL,
|
||||
severity VARCHAR(16) NOT NULL DEFAULT 'medium',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'open',
|
||||
title VARCHAR(255) NOT NULL,
|
||||
source_ip VARCHAR(64) NULL,
|
||||
customer_number INT NULL,
|
||||
user_id INT NULL,
|
||||
subuser_id INT NULL,
|
||||
route_path VARCHAR(255) NULL,
|
||||
route_template VARCHAR(255) NULL,
|
||||
method VARCHAR(16) NULL,
|
||||
related_rule_id BIGINT UNSIGNED NULL,
|
||||
related_firewall_rule_id BIGINT UNSIGNED NULL,
|
||||
occurrence_count INT NOT NULL DEFAULT 1,
|
||||
metadata_json LONGTEXT NULL,
|
||||
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_by INT NULL,
|
||||
resolved_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_security_incidents_key (incident_key),
|
||||
INDEX idx_security_incidents_status_seen (status, last_seen_at),
|
||||
INDEX idx_security_incidents_type_seen (type, last_seen_at),
|
||||
INDEX idx_security_incidents_customer_seen (customer_number, last_seen_at),
|
||||
INDEX idx_security_incidents_ip_seen (source_ip, last_seen_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS security_incident_notes (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
incident_id BIGINT UNSIGNED NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
created_by INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_security_incident_notes_incident (incident_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $query) {
|
||||
$db->query($query);
|
||||
}
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
public static function tablesExist(): bool
|
||||
{
|
||||
global $db;
|
||||
|
||||
$database = $db->escape_string($db->getDatabase());
|
||||
$result = $db->query(
|
||||
"SELECT COUNT(*) AS count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = '{$database}'
|
||||
AND table_name IN (
|
||||
'security_firewall_rules',
|
||||
'security_policy_rules',
|
||||
'security_policy_events',
|
||||
'security_incidents',
|
||||
'security_incident_notes'
|
||||
)"
|
||||
);
|
||||
$row = $result ? $result->fetch_assoc() : ['count' => 0];
|
||||
return (int)($row['count'] ?? 0) === 5;
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ class selfserve_schema_bootstrap
|
||||
department_id INT NOT NULL,
|
||||
machine_type_id INT NULL,
|
||||
customer_number INT NULL,
|
||||
subuser_id INT NULL,
|
||||
vehicle_id INT NULL,
|
||||
vehicle_type_id INT NULL,
|
||||
reg VARCHAR(255) NOT NULL,
|
||||
@@ -73,7 +74,11 @@ class selfserve_schema_bootstrap
|
||||
INDEX idx_selfserve_wash_sessions_lane_reg (lane_id, reg),
|
||||
INDEX idx_selfserve_wash_sessions_status (status),
|
||||
INDEX idx_selfserve_wash_sessions_customer (customer_number),
|
||||
INDEX idx_selfserve_wash_sessions_created_at (created_at)
|
||||
INDEX idx_selfserve_wash_sessions_subuser_active (subuser_id, completed_at),
|
||||
INDEX idx_selfserve_wash_sessions_customer_subuser_active (customer_number, subuser_id, completed_at),
|
||||
INDEX idx_selfserve_wash_sessions_created_at (created_at),
|
||||
INDEX idx_selfserve_wash_sessions_department_completed (department_id, completed_at),
|
||||
INDEX idx_selfserve_wash_sessions_order (order_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS selfserve_wash_session_answers (
|
||||
@@ -95,7 +100,7 @@ class selfserve_schema_bootstrap
|
||||
session_id INT NOT NULL,
|
||||
task_id INT NULL,
|
||||
task_text VARCHAR(255) NOT NULL,
|
||||
description VARCHAR(255) NULL,
|
||||
description TEXT NULL,
|
||||
services JSON NULL,
|
||||
buttons JSON NULL,
|
||||
dynamic_image_id INT NULL,
|
||||
@@ -197,16 +202,63 @@ class selfserve_schema_bootstrap
|
||||
'gate_ref_id',
|
||||
'ALTER TABLE department_selfserve_tasks ADD COLUMN gate_ref_id INT NULL AFTER gate_type'
|
||||
);
|
||||
self::ensureColumnDataType(
|
||||
'department_selfserve_tasks',
|
||||
'description',
|
||||
['text', 'mediumtext', 'longtext'],
|
||||
'ALTER TABLE department_selfserve_tasks MODIFY COLUMN description TEXT NULL AFTER task'
|
||||
);
|
||||
self::ensureColumnDataType(
|
||||
'selfserve_wash_session_tasks',
|
||||
'description',
|
||||
['text', 'mediumtext', 'longtext'],
|
||||
'ALTER TABLE selfserve_wash_session_tasks MODIFY COLUMN description TEXT NULL AFTER task_text'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'selfserve_wash_session_tasks',
|
||||
'dynamic_images_vehicle_type',
|
||||
'ALTER TABLE selfserve_wash_session_tasks ADD COLUMN dynamic_images_vehicle_type INT NULL AFTER buttons'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'selfserve_wash_sessions',
|
||||
'customer_number',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD COLUMN customer_number INT NULL AFTER department_id'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'selfserve_wash_sessions',
|
||||
'subuser_id',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD COLUMN subuser_id INT NULL AFTER customer_number'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'selfserve_wash_sessions',
|
||||
'wash_started_at',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at'
|
||||
);
|
||||
self::ensureIndex(
|
||||
'selfserve_wash_sessions',
|
||||
'idx_selfserve_wash_sessions_customer',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_customer (customer_number)'
|
||||
);
|
||||
self::ensureIndex(
|
||||
'selfserve_wash_sessions',
|
||||
'idx_selfserve_wash_sessions_subuser_active',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_subuser_active (subuser_id, completed_at)'
|
||||
);
|
||||
self::ensureIndex(
|
||||
'selfserve_wash_sessions',
|
||||
'idx_selfserve_wash_sessions_customer_subuser_active',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_customer_subuser_active (customer_number, subuser_id, completed_at)'
|
||||
);
|
||||
self::ensureIndex(
|
||||
'selfserve_wash_sessions',
|
||||
'idx_selfserve_wash_sessions_department_completed',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_department_completed (department_id, completed_at)'
|
||||
);
|
||||
self::ensureIndex(
|
||||
'selfserve_wash_sessions',
|
||||
'idx_selfserve_wash_sessions_order',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_order (order_id)'
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
@@ -239,4 +291,78 @@ class selfserve_schema_bootstrap
|
||||
}
|
||||
$db->query($alterSql);
|
||||
}
|
||||
|
||||
public static function tableHasIndex(string $table, string $index): bool
|
||||
{
|
||||
global $db;
|
||||
$table = $db->escape_string($table);
|
||||
$index = $db->escape_string($index);
|
||||
$database = $db->escape_string($db->getDatabase());
|
||||
|
||||
$sql = "SELECT COUNT(*) AS c
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = '$database'
|
||||
AND TABLE_NAME = '$table'
|
||||
AND INDEX_NAME = '$index'";
|
||||
$result = $db->query($sql);
|
||||
if (!$result) {
|
||||
return false;
|
||||
}
|
||||
$row = $result->fetch_assoc();
|
||||
return ((int)($row['c'] ?? 0)) > 0;
|
||||
}
|
||||
|
||||
public static function ensureIndex(string $table, string $index, string $alterSql): void
|
||||
{
|
||||
global $db;
|
||||
if (self::tableHasIndex($table, $index)) {
|
||||
return;
|
||||
}
|
||||
$db->query($alterSql);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $acceptedDataTypes
|
||||
*/
|
||||
public static function ensureColumnDataType(string $table, string $column, array $acceptedDataTypes, string $alterSql): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$columnInfo = self::columnInfo($table, $column);
|
||||
if ($columnInfo === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$dataType = strtolower((string)($columnInfo['DATA_TYPE'] ?? ''));
|
||||
$acceptedDataTypes = array_map(static fn(string $type): string => strtolower($type), $acceptedDataTypes);
|
||||
if (in_array($dataType, $acceptedDataTypes, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query($alterSql);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public static function columnInfo(string $table, string $column): ?array
|
||||
{
|
||||
global $db;
|
||||
$table = $db->escape_string($table);
|
||||
$column = $db->escape_string($column);
|
||||
$database = $db->escape_string($db->getDatabase());
|
||||
|
||||
$sql = "SELECT DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, CHARACTER_MAXIMUM_LENGTH
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = '$database'
|
||||
AND TABLE_NAME = '$table'
|
||||
AND COLUMN_NAME = '$column'
|
||||
LIMIT 1";
|
||||
$result = $db->query($sql);
|
||||
if (!$result) {
|
||||
return null;
|
||||
}
|
||||
$row = $result->fetch_assoc();
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ class shelly implements shelly_i
|
||||
private const SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS = 20;
|
||||
private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;
|
||||
private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate';
|
||||
private const SHELLY_CONNECT_TIMEOUT_SECONDS = 2;
|
||||
private const SHELLY_REQUEST_TIMEOUT_SECONDS = 5;
|
||||
/**
|
||||
* @var array<int,array<string,mixed>>
|
||||
*/
|
||||
@@ -178,6 +180,9 @@ class shelly implements shelly_i
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::SHELLY_CONNECT_TIMEOUT_SECONDS);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, self::SHELLY_REQUEST_TIMEOUT_SECONDS);
|
||||
curl_setopt($ch, CURLOPT_NOSIGNAL, true);
|
||||
// Execute the request
|
||||
$response = curl_exec($ch);
|
||||
// Get the status code
|
||||
@@ -224,6 +229,9 @@ class shelly implements shelly_i
|
||||
);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPGET, true);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::SHELLY_CONNECT_TIMEOUT_SECONDS);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, self::SHELLY_REQUEST_TIMEOUT_SECONDS);
|
||||
curl_setopt($ch, CURLOPT_NOSIGNAL, true);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
@@ -33,7 +33,7 @@ class shelly_relay_inventory
|
||||
* @return array<int,array<string,mixed>>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function listRelayOptions(): array
|
||||
public function listRelayOptions(bool $include_sensitive_network_details = false): array
|
||||
{
|
||||
$devices_status = $this->fetchOwnedDevicesStatus();
|
||||
$device_catalog = $this->fetchOwnedDeviceCatalog();
|
||||
@@ -49,7 +49,8 @@ class shelly_relay_inventory
|
||||
|
||||
$option = $this->buildRelayOption(
|
||||
$normalized_device,
|
||||
is_array($catalog_entry) ? $catalog_entry : null
|
||||
is_array($catalog_entry) ? $catalog_entry : null,
|
||||
$include_sensitive_network_details
|
||||
);
|
||||
if ($option === null) {
|
||||
continue;
|
||||
@@ -160,7 +161,11 @@ class shelly_relay_inventory
|
||||
* @param array<string,mixed> $device
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function buildRelayOption(array $device, ?array $catalog_entry = null): ?array
|
||||
private function buildRelayOption(
|
||||
array $device,
|
||||
?array $catalog_entry = null,
|
||||
bool $include_sensitive_network_details = false
|
||||
): ?array
|
||||
{
|
||||
if ($device === [] || !$this->isRelayCapableDevice($device)) {
|
||||
return null;
|
||||
@@ -203,9 +208,8 @@ class shelly_relay_inventory
|
||||
$online = $this->normalizeBoolean($catalog_entry['cloud_online'] ?? null);
|
||||
}
|
||||
$status_color = $this->extractStatusColor($online);
|
||||
$local_ip = $this->extractLocalIp($device, $catalog_entry);
|
||||
|
||||
return [
|
||||
$option = [
|
||||
'id' => $device_id,
|
||||
'name' => $this->buildRelayLabel(
|
||||
$device_type,
|
||||
@@ -223,10 +227,15 @@ class shelly_relay_inventory
|
||||
'device_generation' => $device_generation,
|
||||
'control_type' => $control_type,
|
||||
'control_name' => $control_name !== '' ? $control_name : null,
|
||||
'local_ip' => $local_ip,
|
||||
'status_color' => $status_color,
|
||||
'online' => $online,
|
||||
];
|
||||
|
||||
if ($include_sensitive_network_details) {
|
||||
$option['local_ip'] = $this->extractLocalIp($device, $catalog_entry);
|
||||
}
|
||||
|
||||
return $option;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,13 +6,25 @@ use GuzzleHttp\Client;
|
||||
use interfaces\notification_i;
|
||||
use objects\departments_o;
|
||||
use objects\users_o;
|
||||
use slack\slack_c;
|
||||
use traits\notification_t;
|
||||
|
||||
require_once WD . '/modules/slack/slack_c.php';
|
||||
|
||||
class slack implements notification_i
|
||||
{
|
||||
use notification_t;
|
||||
|
||||
private ?slack_c $config = null;
|
||||
|
||||
public function getConfig(): slack_c
|
||||
{
|
||||
if ($this->config === null) {
|
||||
$this->config = new slack_c();
|
||||
}
|
||||
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
@@ -122,7 +134,7 @@ class slack implements notification_i
|
||||
. "Status: $status";
|
||||
}
|
||||
|
||||
public function send_message(string $string, string $module = null): void
|
||||
public function send_message(string $string, ?string $module = null): void
|
||||
{
|
||||
global $SLACK_DEFAULT_WEBHOOK;
|
||||
// Format the message if a module is provided
|
||||
@@ -132,4 +144,189 @@ class slack implements notification_i
|
||||
// Send the message to the slack webhook
|
||||
self::add_log(self::send_webhook_message($string, $SLACK_DEFAULT_WEBHOOK));
|
||||
}
|
||||
}
|
||||
|
||||
public function send_customer_registration_notification(int $customer_number): self
|
||||
{
|
||||
$webhook = $this->get_customer_registration_webhook_url();
|
||||
if ($webhook === '') {
|
||||
return $this;
|
||||
}
|
||||
|
||||
self::add_log(self::send_webhook_message(
|
||||
$this->format_customer_registration($customer_number),
|
||||
$webhook
|
||||
));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a sanitized customer-registration test notification to the saved Slack webhook.
|
||||
*
|
||||
* @return array{configured:bool,sent:bool,message:string}
|
||||
*/
|
||||
public function test_customer_registration_webhook(): array
|
||||
{
|
||||
$webhook = $this->get_customer_registration_webhook_url();
|
||||
if ($webhook === '') {
|
||||
return [
|
||||
'configured' => false,
|
||||
'sent' => false,
|
||||
'message' => 'Slack customer registration webhook URL is not configured.',
|
||||
];
|
||||
}
|
||||
|
||||
$result = $this->send_webhook_message(
|
||||
$this->format_customer_registration_test(),
|
||||
$webhook
|
||||
);
|
||||
$sent = $this->is_webhook_send_successful($result);
|
||||
|
||||
self::add_log($sent
|
||||
? 'Slack customer registration test webhook sent successfully.'
|
||||
: 'Slack customer registration test webhook failed.'
|
||||
);
|
||||
|
||||
return [
|
||||
'configured' => true,
|
||||
'sent' => $sent,
|
||||
'message' => $sent
|
||||
? 'Slack test message sent successfully.'
|
||||
: 'Slack test message failed.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a sanitized internal department goal progress test notification to the saved Slack webhook.
|
||||
*
|
||||
* @return array{configured:bool,sent:bool,message:string}
|
||||
*/
|
||||
public function test_internal_department_goal_progress_webhook(): array
|
||||
{
|
||||
$webhook = $this->get_internal_department_goal_progress_webhook_url();
|
||||
if ($webhook === '') {
|
||||
return [
|
||||
'configured' => false,
|
||||
'sent' => false,
|
||||
'message' => 'Slack internal department goal progress webhook URL is not configured.',
|
||||
];
|
||||
}
|
||||
|
||||
$result = $this->send_webhook_message(
|
||||
$this->format_internal_department_goal_progress_test(),
|
||||
$webhook
|
||||
);
|
||||
$sent = $this->is_webhook_send_successful($result);
|
||||
|
||||
self::add_log($sent
|
||||
? 'Slack internal department goal progress test webhook sent successfully.'
|
||||
: 'Slack internal department goal progress test webhook failed.'
|
||||
);
|
||||
|
||||
return [
|
||||
'configured' => true,
|
||||
'sent' => $sent,
|
||||
'message' => $sent
|
||||
? 'Slack test message sent successfully.'
|
||||
: 'Slack test message failed.',
|
||||
];
|
||||
}
|
||||
|
||||
protected function get_customer_registration_webhook_url(): string
|
||||
{
|
||||
return trim((string)$this->getConfig()->customer_registration_webhook_url->getVariableValue());
|
||||
}
|
||||
|
||||
public function get_internal_department_goal_progress_webhook_url(): string
|
||||
{
|
||||
return trim((string)$this->getConfig()->internal_department_goal_progress_webhook_url->getVariableValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
public function get_internal_department_ids(): array
|
||||
{
|
||||
return $this->getConfig()->internal_department_ids->getDepartmentIds();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $department_ids
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function set_internal_department_goal_progress_config(string $webhook_url, array $department_ids): array
|
||||
{
|
||||
$this->getConfig()->internal_department_goal_progress_webhook_url->setVariableValue(trim($webhook_url));
|
||||
$this->getConfig()->internal_department_ids->setVariableValue($department_ids);
|
||||
|
||||
return $this->get_internal_department_goal_progress_config();
|
||||
}
|
||||
|
||||
public function get_internal_department_goal_progress_config(): array
|
||||
{
|
||||
$departments = (new departments_o())->getFieldsWhere(
|
||||
[
|
||||
'visible' => 1,
|
||||
'archived' => 0,
|
||||
],
|
||||
[
|
||||
'id',
|
||||
'name',
|
||||
'order_priority',
|
||||
]
|
||||
);
|
||||
|
||||
usort($departments, static function (array $a, array $b): int {
|
||||
return (int)($a['order_priority'] ?? 0) <=> (int)($b['order_priority'] ?? 0)
|
||||
?: (int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0);
|
||||
});
|
||||
|
||||
return [
|
||||
'internal_department_goal_progress_webhook_url' => $this->get_internal_department_goal_progress_webhook_url(),
|
||||
'internal_department_ids' => $this->get_internal_department_ids(),
|
||||
'departments' => array_map(static function (array $department): array {
|
||||
return [
|
||||
'id' => (int)$department['id'],
|
||||
'name' => (string)$department['name'],
|
||||
'order_priority' => (int)$department['order_priority'],
|
||||
];
|
||||
}, $departments),
|
||||
];
|
||||
}
|
||||
|
||||
public function is_webhook_send_successful(string $result): bool
|
||||
{
|
||||
return !str_starts_with($result, 'Failed to send message:');
|
||||
}
|
||||
|
||||
public function format_customer_registration(int $customer_number): string
|
||||
{
|
||||
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
|
||||
$customerName = $customer->exists()
|
||||
? $customer->getCustomerName((int)$customer->customer_number->value())
|
||||
: '';
|
||||
$customerName = trim((string)$customerName);
|
||||
if ($customerName === '') {
|
||||
$customerName = 'Unknown customer';
|
||||
}
|
||||
|
||||
$safeCustomerNumber = (int)$customer_number;
|
||||
$customerUrl = 'https://truckwash.io/superuser/users?search=' . $safeCustomerNumber;
|
||||
|
||||
return "*New customer registered on Truck Wash*\n"
|
||||
. "Customer: $customerName ($safeCustomerNumber)\n"
|
||||
. "Open in Superuser: $customerUrl";
|
||||
}
|
||||
|
||||
public function format_customer_registration_test(): string
|
||||
{
|
||||
return "*Truck Wash Slack test*\n"
|
||||
. "Customer registration notifications are configured correctly.";
|
||||
}
|
||||
|
||||
public function format_internal_department_goal_progress_test(): string
|
||||
{
|
||||
return "*Truck Wash Slack test*\n"
|
||||
. "Internal department goal progress notifications are configured correctly.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
use objects\logs_o;
|
||||
use objects\subusers_o;
|
||||
|
||||
class subuser_contact_verification_service
|
||||
{
|
||||
public const CHANNEL_EMAIL = 'email';
|
||||
public const CHANNEL_PHONE = 'phone';
|
||||
public const CODE_TTL_SECONDS = 600;
|
||||
public const RESEND_COOLDOWN_SECONDS = 60;
|
||||
public const MAX_ATTEMPTS = 5;
|
||||
|
||||
private ?object $redis;
|
||||
private $codeGenerator;
|
||||
private $timeProvider;
|
||||
private $smsSender;
|
||||
private $emailSender;
|
||||
|
||||
public function __construct(
|
||||
?object $redis = null,
|
||||
?callable $codeGenerator = null,
|
||||
?callable $timeProvider = null,
|
||||
?callable $smsSender = null,
|
||||
?callable $emailSender = null
|
||||
) {
|
||||
$this->redis = $redis ?? (defined('redis') ? constant('redis') : null);
|
||||
$this->codeGenerator = $codeGenerator;
|
||||
$this->timeProvider = $timeProvider;
|
||||
$this->smsSender = $smsSender;
|
||||
$this->emailSender = $emailSender;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{state:string,email:array<string,mixed>,phone:array<string,mixed>}
|
||||
*/
|
||||
public function status(subusers_o $subuser): array
|
||||
{
|
||||
$email = $this->emailDestination($subuser);
|
||||
$phone = $this->phoneDestination($subuser);
|
||||
$emailVerifiedAt = $this->verifiedAtValue($subuser, self::CHANNEL_EMAIL);
|
||||
$phoneVerifiedAt = $this->verifiedAtValue($subuser, self::CHANNEL_PHONE);
|
||||
|
||||
$emailStatus = [
|
||||
'channel' => self::CHANNEL_EMAIL,
|
||||
'value' => $email,
|
||||
'masked_value' => $email !== null ? $this->maskEmail($email) : null,
|
||||
'available' => $email !== null,
|
||||
'verified' => $email !== null && $emailVerifiedAt !== null,
|
||||
'verified_at' => $emailVerifiedAt,
|
||||
];
|
||||
$phoneStatus = [
|
||||
'channel' => self::CHANNEL_PHONE,
|
||||
'country_code' => $subuser->phone_country_code->value() !== null ? (int)$subuser->phone_country_code->value() : null,
|
||||
'phone' => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null,
|
||||
'value' => $phone,
|
||||
'masked_value' => $phone !== null ? $this->maskPhone($phone) : null,
|
||||
'available' => $phone !== null,
|
||||
'verified' => $phone !== null && $phoneVerifiedAt !== null,
|
||||
'verified_at' => $phoneVerifiedAt,
|
||||
];
|
||||
|
||||
return [
|
||||
'state' => $this->verificationState($emailStatus, $phoneStatus),
|
||||
'email' => $emailStatus,
|
||||
'phone' => $phoneStatus,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function sendCode(subusers_o $subuser, string $channel, array $actor = []): array
|
||||
{
|
||||
$channel = $this->normalizeChannel($channel);
|
||||
$destination = $this->destinationFor($subuser, $channel);
|
||||
if ($destination === null) {
|
||||
return $this->delivery($channel, 'missing_destination', 'No contact value is available for verification.');
|
||||
}
|
||||
|
||||
if ($this->redis === null) {
|
||||
return $this->delivery($channel, 'unavailable', 'Verification delivery is not available.');
|
||||
}
|
||||
|
||||
$now = $this->now();
|
||||
$existing = $this->readChallenge($subuser, $channel);
|
||||
if ($existing !== null) {
|
||||
$sentAt = (int)($existing['sent_at'] ?? 0);
|
||||
$retryAfter = self::RESEND_COOLDOWN_SECONDS - ($now - $sentAt);
|
||||
if ($retryAfter > 0) {
|
||||
return $this->delivery($channel, 'throttled', 'Please wait before requesting another code.', [
|
||||
'retry_after' => $retryAfter,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$code = $this->generateCode();
|
||||
$destinationKey = $this->destinationKey($subuser, $channel);
|
||||
$nonce = bin2hex(random_bytes(16));
|
||||
$payload = [
|
||||
'hash' => $this->hashCode($code, $destinationKey, $nonce),
|
||||
'nonce' => $nonce,
|
||||
'destination' => $destinationKey,
|
||||
'attempts' => 0,
|
||||
'sent_at' => $now,
|
||||
'expires_at' => $now + self::CODE_TTL_SECONDS,
|
||||
];
|
||||
|
||||
try {
|
||||
$this->deliverCode($subuser, $channel, $destination, $code);
|
||||
$this->writeChallenge($subuser, $channel, $payload);
|
||||
$this->logEvent('SUBUSER_CONTACT_VERIFICATION_SENT', $subuser, $channel, $actor);
|
||||
} catch (Exception $exception) {
|
||||
$this->logEvent('SUBUSER_CONTACT_VERIFICATION_FAILED', $subuser, $channel, $actor);
|
||||
return $this->delivery($channel, 'failed', 'Verification delivery failed.');
|
||||
}
|
||||
|
||||
return $this->delivery($channel, 'sent', 'Verification code sent.', [
|
||||
'masked_destination' => $channel === self::CHANNEL_EMAIL
|
||||
? $this->maskEmail($destination)
|
||||
: $this->maskPhone($destination),
|
||||
'expires_in' => self::CODE_TTL_SECONDS,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function verifyCode(subusers_o $subuser, string $channel, string $code, array $actor = []): array
|
||||
{
|
||||
$channel = $this->normalizeChannel($channel);
|
||||
$code = trim($code);
|
||||
if (!preg_match('/^[0-9]{6}$/', $code)) {
|
||||
return $this->verificationResult($channel, 'invalid_code', 'Invalid verification code.');
|
||||
}
|
||||
|
||||
$challenge = $this->readChallenge($subuser, $channel);
|
||||
if ($challenge === null || (int)($challenge['expires_at'] ?? 0) < $this->now()) {
|
||||
$this->deleteChallenge($subuser, $channel);
|
||||
return $this->verificationResult($channel, 'expired', 'Verification code expired.');
|
||||
}
|
||||
|
||||
if (($challenge['destination'] ?? null) !== $this->destinationKey($subuser, $channel)) {
|
||||
$this->deleteChallenge($subuser, $channel);
|
||||
return $this->verificationResult($channel, 'destination_changed', 'Contact value changed. Request a new code.');
|
||||
}
|
||||
|
||||
$attempts = (int)($challenge['attempts'] ?? 0);
|
||||
if ($attempts >= self::MAX_ATTEMPTS) {
|
||||
$this->deleteChallenge($subuser, $channel);
|
||||
return $this->verificationResult($channel, 'too_many_attempts', 'Too many verification attempts.');
|
||||
}
|
||||
|
||||
$expectedHash = (string)($challenge['hash'] ?? '');
|
||||
$nonce = (string)($challenge['nonce'] ?? '');
|
||||
if (!hash_equals($expectedHash, $this->hashCode($code, (string)$challenge['destination'], $nonce))) {
|
||||
$nextAttempts = $attempts + 1;
|
||||
if ($nextAttempts >= self::MAX_ATTEMPTS) {
|
||||
$this->deleteChallenge($subuser, $channel);
|
||||
return $this->verificationResult($channel, 'too_many_attempts', 'Too many verification attempts.');
|
||||
}
|
||||
$challenge['attempts'] = $nextAttempts;
|
||||
$remainingTtl = max(1, (int)($challenge['expires_at'] ?? $this->now()) - $this->now());
|
||||
$this->writeChallenge($subuser, $channel, $challenge, $remainingTtl);
|
||||
return $this->verificationResult($channel, 'invalid_code', 'Invalid verification code.');
|
||||
}
|
||||
|
||||
$this->markVerified($subuser, $channel);
|
||||
$this->deleteChallenge($subuser, $channel);
|
||||
$this->logEvent('SUBUSER_CONTACT_VERIFICATION_VERIFIED', $subuser, $channel, $actor);
|
||||
|
||||
return $this->verificationResult($channel, 'verified', 'Contact value verified.', [
|
||||
'verified_at' => $this->verifiedAtValue($subuser, $channel),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function setVerificationState(subusers_o $subuser, string $channel, bool $verified, array $actor = []): array
|
||||
{
|
||||
$channel = $this->normalizeChannel($channel);
|
||||
if ($verified && $this->destinationFor($subuser, $channel) === null) {
|
||||
return $this->verificationResult($channel, 'missing_destination', 'No contact value is available for verification.');
|
||||
}
|
||||
|
||||
if ($verified) {
|
||||
$this->markVerified($subuser, $channel);
|
||||
} else {
|
||||
$this->markUnverified($subuser, $channel);
|
||||
}
|
||||
|
||||
$this->deleteChallenge($subuser, $channel);
|
||||
$this->logEvent(
|
||||
$verified
|
||||
? 'SUBUSER_CONTACT_VERIFICATION_MARKED_VERIFIED'
|
||||
: 'SUBUSER_CONTACT_VERIFICATION_MARKED_UNVERIFIED',
|
||||
$subuser,
|
||||
$channel,
|
||||
$actor
|
||||
);
|
||||
|
||||
return $this->verificationResult($channel, $verified ? 'verified' : 'unverified', 'Contact verification state updated.', [
|
||||
'verified_at' => $this->verifiedAtValue($subuser, $channel),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $updates
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function clearVerificationForChangedContacts(subusers_o $subuser, array $updates): array
|
||||
{
|
||||
if (array_key_exists('email', $updates)) {
|
||||
$current = $this->normalizeNullableString($subuser->email->value());
|
||||
$next = $this->normalizeNullableString($updates['email']);
|
||||
if ($current !== $next) {
|
||||
$updates['email_verified_at'] = null;
|
||||
$this->deleteChallenge($subuser, self::CHANNEL_EMAIL);
|
||||
}
|
||||
}
|
||||
|
||||
$phoneChanged = false;
|
||||
if (array_key_exists('phone_country_code', $updates)) {
|
||||
$phoneChanged = (int)$subuser->phone_country_code->value() !== (int)$updates['phone_country_code'];
|
||||
}
|
||||
if (array_key_exists('phone', $updates)) {
|
||||
$phoneChanged = $phoneChanged || (int)$subuser->phone->value() !== (int)$updates['phone'];
|
||||
}
|
||||
if ($phoneChanged) {
|
||||
$updates['phone_verified_at'] = null;
|
||||
$this->deleteChallenge($subuser, self::CHANNEL_PHONE);
|
||||
}
|
||||
|
||||
return $updates;
|
||||
}
|
||||
|
||||
public function normalizeChannel(string $channel): string
|
||||
{
|
||||
$channel = strtolower(trim($channel));
|
||||
if (!in_array($channel, [self::CHANNEL_EMAIL, self::CHANNEL_PHONE], true)) {
|
||||
throw new Exception('Invalid verification channel');
|
||||
}
|
||||
|
||||
return $channel;
|
||||
}
|
||||
|
||||
private function now(): int
|
||||
{
|
||||
if ($this->timeProvider !== null) {
|
||||
return (int)call_user_func($this->timeProvider);
|
||||
}
|
||||
|
||||
return time();
|
||||
}
|
||||
|
||||
private function generateCode(): string
|
||||
{
|
||||
if ($this->codeGenerator !== null) {
|
||||
$code = (string)call_user_func($this->codeGenerator);
|
||||
if (preg_match('/^[0-9]{6}$/', $code)) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
return (string)random_int(100000, 999999);
|
||||
}
|
||||
|
||||
private function hashCode(string $code, string $destination, string $nonce): string
|
||||
{
|
||||
$secret = (string)(getenv('APP_KEY') ?: getenv('JWT_SECRET') ?: __FILE__);
|
||||
return hash('sha256', $secret . ':' . $nonce . ':' . $destination . ':' . $code);
|
||||
}
|
||||
|
||||
private function destinationFor(subusers_o $subuser, string $channel): ?string
|
||||
{
|
||||
return $channel === self::CHANNEL_EMAIL
|
||||
? $this->emailDestination($subuser)
|
||||
: $this->phoneDestination($subuser);
|
||||
}
|
||||
|
||||
private function emailDestination(subusers_o $subuser): ?string
|
||||
{
|
||||
$email = $this->normalizeNullableString($subuser->email->value());
|
||||
return $email !== null && filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null;
|
||||
}
|
||||
|
||||
private function phoneDestination(subusers_o $subuser): ?string
|
||||
{
|
||||
$countryCode = $subuser->phone_country_code->value();
|
||||
$phone = $subuser->phone->value();
|
||||
if ($countryCode === null || $phone === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = preg_replace('/[^0-9]/', '', (string)$countryCode . (string)$phone);
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
|
||||
private function destinationKey(subusers_o $subuser, string $channel): string
|
||||
{
|
||||
return $channel . ':' . (string)($this->destinationFor($subuser, $channel) ?? '');
|
||||
}
|
||||
|
||||
private function key(subusers_o $subuser, string $channel): string
|
||||
{
|
||||
return 'subuser_contact_verification:' . (int)$subuser->id . ':' . $channel;
|
||||
}
|
||||
|
||||
private function readChallenge(subusers_o $subuser, string $channel): ?array
|
||||
{
|
||||
if ($this->redis === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = $this->redis->get($this->key($subuser, $channel));
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
private function writeChallenge(subusers_o $subuser, string $channel, array $payload, ?int $ttl = null): void
|
||||
{
|
||||
if ($this->redis === null) {
|
||||
throw new Exception('Verification cache is unavailable');
|
||||
}
|
||||
|
||||
$encoded = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
if ($encoded === false) {
|
||||
throw new Exception('Failed to encode verification challenge');
|
||||
}
|
||||
|
||||
$this->redis->setEx($this->key($subuser, $channel), $encoded, $ttl ?? self::CODE_TTL_SECONDS);
|
||||
}
|
||||
|
||||
private function deleteChallenge(subusers_o $subuser, string $channel): void
|
||||
{
|
||||
if ($this->redis !== null) {
|
||||
$this->redis->delete($this->key($subuser, $channel));
|
||||
}
|
||||
}
|
||||
|
||||
private function deliverCode(subusers_o $subuser, string $channel, string $destination, string $code): void
|
||||
{
|
||||
if ($channel === self::CHANNEL_PHONE) {
|
||||
if ($this->smsSender !== null) {
|
||||
call_user_func($this->smsSender, $destination, $code, $subuser);
|
||||
return;
|
||||
}
|
||||
|
||||
$gateway = new gatewayapi();
|
||||
if (!$gateway->isEnabled()) {
|
||||
throw new Exception('SMS delivery is not configured.');
|
||||
}
|
||||
$gateway->send([$destination], 'Truck Wash verifikationskode: ' . $code . '. Den udløber om 10 minutter.');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->emailSender !== null) {
|
||||
call_user_func($this->emailSender, $destination, $code, $subuser);
|
||||
return;
|
||||
}
|
||||
|
||||
$recipientName = $this->normalizeNullableString($subuser->name->value()) ?? 'Chauffør';
|
||||
$message = '<p>Din verifikationskode til Truck Wash er <strong>' . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '</strong>.</p>'
|
||||
. '<p>Koden udløber om 10 minutter.</p>';
|
||||
(new email())->sendEmail($destination, $recipientName, 'Truck Wash verifikationskode', $message);
|
||||
}
|
||||
|
||||
private function markVerified(subusers_o $subuser, string $channel): void
|
||||
{
|
||||
$timestamp = date('Y-m-d H:i:s', $this->now());
|
||||
if ($channel === self::CHANNEL_EMAIL) {
|
||||
$subuser->email_verified_at->set($timestamp);
|
||||
return;
|
||||
}
|
||||
|
||||
$subuser->phone_verified_at->set($timestamp);
|
||||
}
|
||||
|
||||
private function markUnverified(subusers_o $subuser, string $channel): void
|
||||
{
|
||||
if ($channel === self::CHANNEL_EMAIL) {
|
||||
$subuser->email_verified_at->set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
$subuser->phone_verified_at->set(null);
|
||||
}
|
||||
|
||||
private function verifiedAtValue(subusers_o $subuser, string $channel): ?string
|
||||
{
|
||||
$value = $channel === self::CHANNEL_EMAIL
|
||||
? $subuser->email_verified_at->value()
|
||||
: $subuser->phone_verified_at->value();
|
||||
$value = $this->normalizeNullableString($value);
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function verificationState(array $emailStatus, array $phoneStatus): string
|
||||
{
|
||||
if (!$phoneStatus['available'] && !$emailStatus['available']) {
|
||||
return 'missing_contacts';
|
||||
}
|
||||
if (!$phoneStatus['available']) {
|
||||
return 'missing_phone';
|
||||
}
|
||||
if (!$emailStatus['available']) {
|
||||
return 'missing_email';
|
||||
}
|
||||
if ($phoneStatus['verified'] && $emailStatus['verified']) {
|
||||
return 'verified';
|
||||
}
|
||||
if ($phoneStatus['verified'] || $emailStatus['verified']) {
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
return 'unverified';
|
||||
}
|
||||
|
||||
private function normalizeNullableString(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = trim((string)$value);
|
||||
return $normalized === '' ? null : $normalized;
|
||||
}
|
||||
|
||||
private function maskEmail(string $email): string
|
||||
{
|
||||
[$local, $domain] = array_pad(explode('@', $email, 2), 2, '');
|
||||
$prefix = substr($local, 0, 2);
|
||||
return $prefix . str_repeat('*', max(2, strlen($local) - 2)) . '@' . $domain;
|
||||
}
|
||||
|
||||
private function maskPhone(string $phone): string
|
||||
{
|
||||
$suffix = substr($phone, -4);
|
||||
return str_repeat('*', max(0, strlen($phone) - 4)) . $suffix;
|
||||
}
|
||||
|
||||
private function delivery(string $channel, string $status, string $message, array $extra = []): array
|
||||
{
|
||||
return [
|
||||
'channel' => $channel,
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
...$extra,
|
||||
];
|
||||
}
|
||||
|
||||
private function verificationResult(string $channel, string $status, string $message, array $extra = []): array
|
||||
{
|
||||
return [
|
||||
'channel' => $channel,
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
...$extra,
|
||||
];
|
||||
}
|
||||
|
||||
private function logEvent(string $action, subusers_o $subuser, string $channel, array $actor): void
|
||||
{
|
||||
try {
|
||||
if (!defined('redis')) {
|
||||
return;
|
||||
}
|
||||
$actorId = isset($actor['id']) ? (int)$actor['id'] : 0;
|
||||
(new logs_o())->add(
|
||||
'subusers',
|
||||
'global',
|
||||
1,
|
||||
$actorId,
|
||||
$action,
|
||||
'Chauffeur contact verification ' . $channel . ': ' . (int)$subuser->id
|
||||
);
|
||||
} catch (Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use modules\subusers\helpers\subusers_permission_node_key;
|
||||
|
||||
class subuser_permission_templates_service
|
||||
{
|
||||
public const TEMPLATE_DEACTIVATED = 'deactivated';
|
||||
public const TEMPLATE_DRIVER = 'driver';
|
||||
public const TEMPLATE_BOOKING_COORDINATOR = 'booking_coordinator';
|
||||
public const TEMPLATE_FLEET_ADMIN = 'fleet_admin';
|
||||
public const TEMPLATE_CUSTOM = 'custom';
|
||||
|
||||
/**
|
||||
* @var array<string, array{label:string,description:string,enabled:bool,permissions:array<int,string>}>
|
||||
*/
|
||||
private const TEMPLATES = [
|
||||
self::TEMPLATE_DEACTIVATED => [
|
||||
'label' => 'Deactivated',
|
||||
'description' => 'Keeps the driver linked to the customer without active access.',
|
||||
'enabled' => false,
|
||||
'permissions' => [],
|
||||
],
|
||||
self::TEMPLATE_DRIVER => [
|
||||
'label' => 'Driver',
|
||||
'description' => 'Can use self-service, manage own bookings, see vehicles, and view orders.',
|
||||
'enabled' => true,
|
||||
'permissions' => [
|
||||
'VEHICLES_LIST',
|
||||
'SELFSERVE_LIST',
|
||||
'SELFSERVE_ADD',
|
||||
'BOOKINGS_LIST',
|
||||
'BOOKINGS_ADD',
|
||||
'ORDERS_LIST',
|
||||
],
|
||||
],
|
||||
self::TEMPLATE_BOOKING_COORDINATOR => [
|
||||
'label' => 'Booking coordinator',
|
||||
'description' => 'Can coordinate bookings and see the related vehicles and orders.',
|
||||
'enabled' => true,
|
||||
'permissions' => [
|
||||
'VEHICLES_LIST',
|
||||
'BOOKINGS_LIST',
|
||||
'BOOKINGS_ADD',
|
||||
'BOOKINGS_EDIT',
|
||||
'ORDERS_LIST',
|
||||
],
|
||||
],
|
||||
self::TEMPLATE_FLEET_ADMIN => [
|
||||
'label' => 'Fleet admin',
|
||||
'description' => 'Can manage drivers, vehicles, bookings, self-service, and orders for the customer.',
|
||||
'enabled' => true,
|
||||
'permissions' => [
|
||||
'VEHICLES_LIST',
|
||||
'VEHICLES_EDIT',
|
||||
'VEHICLES_DELETE',
|
||||
'VEHICLES_ADD',
|
||||
'SELFSERVE_LIST',
|
||||
'SELFSERVE_EDIT',
|
||||
'SELFSERVE_DELETE',
|
||||
'SELFSERVE_ADD',
|
||||
'BOOKINGS_LIST',
|
||||
'BOOKINGS_EDIT',
|
||||
'BOOKINGS_DELETE',
|
||||
'BOOKINGS_ADD',
|
||||
'ORDERS_LIST',
|
||||
'ORDERS_EDIT',
|
||||
'SUBUSERS_LIST',
|
||||
'SUBUSERS_EDIT',
|
||||
'SUBUSERS_DELETE',
|
||||
'SUBUSERS_ADD',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, array{group:string,capability:string}>
|
||||
*/
|
||||
private const PERMISSION_CAPABILITIES = [
|
||||
'VEHICLES_LIST' => ['group' => 'vehicles', 'capability' => 'view_vehicles'],
|
||||
'VEHICLES_EDIT' => ['group' => 'vehicles', 'capability' => 'edit_vehicles'],
|
||||
'VEHICLES_DELETE' => ['group' => 'vehicles', 'capability' => 'delete_vehicles'],
|
||||
'VEHICLES_ADD' => ['group' => 'vehicles', 'capability' => 'add_vehicles'],
|
||||
'SELFSERVE_LIST' => ['group' => 'selfserve', 'capability' => 'view_selfserve'],
|
||||
'SELFSERVE_EDIT' => ['group' => 'selfserve', 'capability' => 'edit_selfserve'],
|
||||
'SELFSERVE_DELETE' => ['group' => 'selfserve', 'capability' => 'delete_selfserve'],
|
||||
'SELFSERVE_ADD' => ['group' => 'selfserve', 'capability' => 'start_selfserve'],
|
||||
'BOOKINGS_LIST' => ['group' => 'bookings', 'capability' => 'view_bookings'],
|
||||
'BOOKINGS_EDIT' => ['group' => 'bookings', 'capability' => 'edit_bookings'],
|
||||
'BOOKINGS_DELETE' => ['group' => 'bookings', 'capability' => 'delete_bookings'],
|
||||
'BOOKINGS_ADD' => ['group' => 'bookings', 'capability' => 'add_bookings'],
|
||||
'ORDERS_LIST' => ['group' => 'orders', 'capability' => 'view_orders'],
|
||||
'ORDERS_EDIT' => ['group' => 'orders', 'capability' => 'edit_orders'],
|
||||
'SUBUSERS_LIST' => ['group' => 'driver_management', 'capability' => 'view_drivers'],
|
||||
'SUBUSERS_EDIT' => ['group' => 'driver_management', 'capability' => 'edit_driver_access'],
|
||||
'SUBUSERS_DELETE' => ['group' => 'driver_management', 'capability' => 'disable_driver_access'],
|
||||
'SUBUSERS_ADD' => ['group' => 'driver_management', 'capability' => 'invite_drivers'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const GROUP_ORDER = [
|
||||
'vehicles',
|
||||
'selfserve',
|
||||
'bookings',
|
||||
'orders',
|
||||
'driver_management',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function accessModel(): array
|
||||
{
|
||||
return [
|
||||
'templates' => $this->templates(),
|
||||
'groups' => $this->groups(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function templates(): array
|
||||
{
|
||||
$templates = [];
|
||||
foreach (self::TEMPLATES as $key => $template) {
|
||||
$templates[] = [
|
||||
'key' => $key,
|
||||
'label' => $template['label'],
|
||||
'description' => $template['description'],
|
||||
'enabled' => $template['enabled'],
|
||||
'permissions' => array_values($template['permissions']),
|
||||
'permission_groups' => $this->permissionGroups($template['permissions']),
|
||||
];
|
||||
}
|
||||
|
||||
return $templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{key:string,capabilities:array<int,string>}>
|
||||
*/
|
||||
public function groups(): array
|
||||
{
|
||||
$groups = [];
|
||||
foreach (self::GROUP_ORDER as $group) {
|
||||
$capabilities = [];
|
||||
foreach (self::PERMISSION_CAPABILITIES as $capability) {
|
||||
if ($capability['group'] === $group) {
|
||||
$capabilities[] = $capability['capability'];
|
||||
}
|
||||
}
|
||||
$groups[] = [
|
||||
'key' => $group,
|
||||
'capabilities' => array_values(array_unique($capabilities)),
|
||||
];
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{enabled:bool,permissions:array<int,string>}
|
||||
*/
|
||||
public function expandTemplate(string $templateKey): array
|
||||
{
|
||||
$key = $this->normalizeTemplateKey($templateKey);
|
||||
if ($key === null || $key === self::TEMPLATE_CUSTOM) {
|
||||
throw new \InvalidArgumentException('Unknown driver access template.');
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => self::TEMPLATES[$key]['enabled'],
|
||||
'permissions' => array_values(self::TEMPLATES[$key]['permissions']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{enabled:bool,permissions:array<int,string>}|null
|
||||
*/
|
||||
public function expandTemplateForWritePayload(?string $templateKey): ?array
|
||||
{
|
||||
$key = $this->normalizeTemplateKey($templateKey);
|
||||
if ($key === self::TEMPLATE_CUSTOM) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($key === null) {
|
||||
throw new \InvalidArgumentException('Unknown driver access template.');
|
||||
}
|
||||
|
||||
return $this->expandTemplate($key);
|
||||
}
|
||||
|
||||
public function normalizeTemplateKey(?string $templateKey): ?string
|
||||
{
|
||||
if ($templateKey === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = strtolower(trim($templateKey));
|
||||
if ($key === self::TEMPLATE_CUSTOM) {
|
||||
return self::TEMPLATE_CUSTOM;
|
||||
}
|
||||
|
||||
return array_key_exists($key, self::TEMPLATES) ? $key : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $permissions
|
||||
*/
|
||||
public function classify(array $permissions, bool $enabled = true): string
|
||||
{
|
||||
$normalized = $this->normalizePermissions($permissions);
|
||||
if (!$enabled || $normalized === []) {
|
||||
return self::TEMPLATE_DEACTIVATED;
|
||||
}
|
||||
|
||||
foreach (self::TEMPLATES as $key => $template) {
|
||||
if (!$template['enabled']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($normalized === $this->normalizePermissions($template['permissions'])) {
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
|
||||
return self::TEMPLATE_CUSTOM;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $permissions
|
||||
* @return array<int, array{key:string,capabilities:array<int,string>}>
|
||||
*/
|
||||
public function permissionGroups(array $permissions): array
|
||||
{
|
||||
$permissions = $this->normalizePermissions($permissions);
|
||||
$groups = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$capability = self::PERMISSION_CAPABILITIES[$permission] ?? null;
|
||||
if ($capability === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$group = $capability['group'];
|
||||
$groups[$group] ??= [];
|
||||
$groups[$group][] = $capability['capability'];
|
||||
}
|
||||
|
||||
$payload = [];
|
||||
foreach (self::GROUP_ORDER as $group) {
|
||||
if (!isset($groups[$group])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload[] = [
|
||||
'key' => $group,
|
||||
'capabilities' => array_values(array_unique($groups[$group])),
|
||||
];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $permissions
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizePermissions(array $permissions): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($permissions as $permission) {
|
||||
if ($permission instanceof subusers_permission_node_key) {
|
||||
$permission = $permission->name;
|
||||
}
|
||||
if (!is_string($permission)) {
|
||||
continue;
|
||||
}
|
||||
$permission = strtoupper(trim($permission));
|
||||
if ($permission !== '' && subusers_permission_node_key::tryFrom($permission) !== null) {
|
||||
$normalized[] = $permission;
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class subusers_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::ensureColumn(
|
||||
'subuser_grants',
|
||||
'assigned_vehicle_id',
|
||||
'INT NULL AFTER `subuser`'
|
||||
);
|
||||
self::ensureIndex(
|
||||
'subuser_grants',
|
||||
'idx_subuser_grants_assigned_vehicle_id',
|
||||
'`assigned_vehicle_id`'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'subusers',
|
||||
'phone_verified_at',
|
||||
'DATETIME NULL AFTER `phone`'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'subusers',
|
||||
'email_verified_at',
|
||||
'DATETIME NULL AFTER `email`'
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||
if ($table === '' || $column === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$columnSql = $db->escape_string($column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$columnSql'");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
|
||||
}
|
||||
|
||||
private static function ensureIndex(string $table, string $index, string $columns): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$index = preg_replace('/[^a-zA-Z0-9_]/', '', $index);
|
||||
if ($table === '' || $index === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$indexSql = $db->escape_string($index);
|
||||
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user