Compare commits
176
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 |
@@ -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,14 +3,16 @@ on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
branches: # Specify your branches here
|
||||
- main # The 'main' branch
|
||||
- 'releases/*' # The release branches
|
||||
branches:
|
||||
- master
|
||||
- beta
|
||||
- canary
|
||||
- internal
|
||||
|
||||
jobs:
|
||||
qodana:
|
||||
# CI runs on the repository's self-hosted runner pool.
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
+142
-42
@@ -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,7 +254,7 @@ 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: |
|
||||
@@ -246,44 +339,51 @@ jobs:
|
||||
vendor/bin/pest tests/Integration/EdgeGateway --colors=always"
|
||||
|
||||
- name: Run edge gateway E2E smoke
|
||||
run: |
|
||||
set -euo pipefail
|
||||
compose_project="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}"
|
||||
runner="edge-e2e-runner-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
docker rm -f "$runner" >/dev/null 2>&1 || true
|
||||
trap 'docker rm -f "$runner" >/dev/null 2>&1 || true' EXIT
|
||||
docker create \
|
||||
--name "$runner" \
|
||||
--network "${compose_project}_default" \
|
||||
-e COMPOSE_FILE="$COMPOSE_FILE" \
|
||||
-e COMPOSE_PROJECT_NAME="$compose_project" \
|
||||
-e TRAEFIK_WEB_PORT="${TRAEFIK_WEB_PORT:-18080}" \
|
||||
-e TRAEFIK_WEBSECURE_PORT="${TRAEFIK_WEBSECURE_PORT:-18443}" \
|
||||
-e TRAEFIK_WEBSECURE_STAGING_PORT="${TRAEFIK_WEBSECURE_STAGING_PORT:-18433}" \
|
||||
-e TRAEFIK_METRICS_PORT="${TRAEFIK_METRICS_PORT:-19100}" \
|
||||
-e EDGE_GATEWAY_E2E_BASE_URL="http://caddy" \
|
||||
-e EDGE_GATEWAY_E2E_COMPOSE_PROJECT="$compose_project" \
|
||||
-e EDGE_GATEWAY_E2E_COPY_CONFIG="true" \
|
||||
-e EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP="true" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-w /workspace \
|
||||
node:22-alpine \
|
||||
sh -lc "apk add --no-cache docker-cli docker-cli-compose >/dev/null && node scripts/edge-gateway-e2e.mjs"
|
||||
docker cp . "$runner:/workspace"
|
||||
docker start "$runner" >/dev/null
|
||||
docker logs -f "$runner"
|
||||
exit_code="$(docker wait "$runner")"
|
||||
exit "$exit_code"
|
||||
env:
|
||||
EDGE_GATEWAY_E2E_COPY_CONFIG: "true"
|
||||
EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP: "true"
|
||||
run: node scripts/edge-gateway-e2e.mjs
|
||||
|
||||
- name: Tear down local stack
|
||||
if: always()
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
|
||||
|
||||
required-ci:
|
||||
name: Required CI
|
||||
runs-on: ubuntu-latest
|
||||
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
||||
if: ${{ always() }}
|
||||
|
||||
steps:
|
||||
- name: Verify required jobs succeeded
|
||||
env:
|
||||
PHP_RESULT: ${{ needs.php.result }}
|
||||
EDGE_AGENT_RESULT: ${{ needs.edge-agent.result }}
|
||||
EDGE_BROKER_RESULT: ${{ needs.edge-broker.result }}
|
||||
EDGE_GATEWAY_BACKEND_RESULT: ${{ needs.edge-gateway-backend.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
for dependency in \
|
||||
"php=${PHP_RESULT}" \
|
||||
"edge-agent=${EDGE_AGENT_RESULT}" \
|
||||
"edge-broker=${EDGE_BROKER_RESULT}" \
|
||||
"edge-gateway-backend=${EDGE_GATEWAY_BACKEND_RESULT}"
|
||||
do
|
||||
name="${dependency%%=*}"
|
||||
result="${dependency#*=}"
|
||||
if [ "$result" != "success" ]; then
|
||||
echo "Required dependency ${name} completed with result: ${result:-missing}" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
release-manager-gate:
|
||||
name: Release Manager gate
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend]
|
||||
needs: [required-ci]
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.required-ci.result == 'success' }}
|
||||
|
||||
steps:
|
||||
- name: Record Release Manager API gate
|
||||
|
||||
+4
-1
@@ -13,4 +13,7 @@
|
||||
.env.old
|
||||
/.tmp/
|
||||
/.env.staging
|
||||
/services/nginx/app/storage/replication-bootstrap.json
|
||||
/services/nginx/app/storage/replication-bootstrap.json
|
||||
/.env_old_2
|
||||
/.openclaw/
|
||||
/services/nginx/app/build/phpstan/
|
||||
|
||||
@@ -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,6 +47,7 @@ RUN set -eux; \
|
||||
|
||||
COPY services/nginx/app/ /var/www/html/
|
||||
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
|
||||
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
+1595
-98
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
|
||||
+25
-3
@@ -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"
|
||||
@@ -84,6 +85,17 @@ composer_install() {
|
||||
'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"
|
||||
@@ -103,7 +115,8 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
retry_command "${PHP_CI_DOCKER_RETRIES:-3}" docker compose $compose_files up -d redis mysql-debug php1
|
||||
configure_ci_docker_subnet
|
||||
sh scripts/ci-docker-compose-up.sh redis mysql-debug php1
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc '
|
||||
set -eu
|
||||
@@ -126,9 +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 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,
|
||||
|
||||
Vendored
+58
@@ -754,6 +754,60 @@ export async function setRelayState(payload, fetchImpl = fetch) {
|
||||
}
|
||||
}
|
||||
|
||||
async function mapWithConcurrency(items, limit, mapper) {
|
||||
const results = new Array(items.length);
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.max(1, Math.min(Number(limit) || 1, items.length || 1));
|
||||
|
||||
await Promise.all(Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[index] = await mapper(items[index], index);
|
||||
}
|
||||
}));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function executeRelayBatch(command, handler, fetchImpl = fetch) {
|
||||
const commands = Array.isArray(command?.payload?.commands)
|
||||
? command.payload.commands
|
||||
: Array.isArray(command?.commands)
|
||||
? command.commands
|
||||
: [];
|
||||
const concurrency = Math.max(1, Math.min(Number(command?.payload?.concurrency || command?.concurrency || 5), 5));
|
||||
|
||||
const results = await mapWithConcurrency(commands, concurrency, async (entry = {}) => {
|
||||
const target = String(entry.target || entry.relay || "");
|
||||
const relayId = String(entry.relayId || entry.relay_id || "");
|
||||
try {
|
||||
const payload = await handler(entry, fetchImpl);
|
||||
return {
|
||||
target,
|
||||
relayId,
|
||||
relay_id: relayId,
|
||||
ok: true,
|
||||
payload,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
target,
|
||||
relayId,
|
||||
relay_id: relayId,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
batchId: command?.payload?.batchId || command?.payload?.batch_id || command?.batchId || command?.batch_id || null,
|
||||
batch_id: command?.payload?.batch_id || command?.payload?.batchId || command?.batch_id || command?.batchId || null,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch) {
|
||||
if (!url) {
|
||||
return null;
|
||||
@@ -1416,6 +1470,10 @@ export async function handleAgentCommand(command, deps = {}) {
|
||||
return await getRelayStatus(command.payload || {}, fetchImpl);
|
||||
case "SET_RELAY_STATE":
|
||||
return await setRelayState(command.payload || {}, fetchImpl);
|
||||
case "BATCH_RELAY_STATUS":
|
||||
return await executeRelayBatch(command, getRelayStatus, fetchImpl);
|
||||
case "BATCH_SET_RELAY_STATE":
|
||||
return await executeRelayBatch(command, setRelayState, fetchImpl);
|
||||
case "RUN_UPDATE":
|
||||
return await runUpdate(command.payload || {}, fetchImpl, deps);
|
||||
case "UNINSTALL_AGENT":
|
||||
|
||||
@@ -127,6 +127,39 @@ test("relay status and switch commands support both Shelly RPC and legacy endpoi
|
||||
assert.equal(switched.on, false);
|
||||
});
|
||||
|
||||
test("batch relay commands return per-relay results without failing the whole batch", async () => {
|
||||
const fakeFetch = async (url) => {
|
||||
const value = String(url);
|
||||
if (value.includes("10.1.0.31")) {
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { output: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error("relay offline");
|
||||
};
|
||||
|
||||
const result = await handleAgentCommand({
|
||||
commandType: "BATCH_SET_RELAY_STATE",
|
||||
payload: {
|
||||
batch_id: "batch-1",
|
||||
commands: [
|
||||
{ target: "MACHINE", relayId: "relay-machine", localIp: "10.1.0.31", channel: 0, on: true },
|
||||
{ target: "EXIT", relayId: "relay-out", localIp: "10.1.0.32", channel: 0, on: true },
|
||||
],
|
||||
},
|
||||
}, { fetchImpl: fakeFetch });
|
||||
|
||||
assert.equal(result.batch_id, "batch-1");
|
||||
assert.equal(result.results.length, 2);
|
||||
assert.equal(result.results[0].ok, true);
|
||||
assert.equal(result.results[0].target, "MACHINE");
|
||||
assert.equal(result.results[1].ok, false);
|
||||
assert.match(result.results[1].error, /relay offline/);
|
||||
});
|
||||
|
||||
test("Shelly discovery infers Gen3 from S3 relay model codes when generation is omitted", async () => {
|
||||
const inventory = await discoverShellyDevices({ candidateIps: ["192.168.1.2"] }, async (url) => {
|
||||
assert.equal(String(url), "http://192.168.1.2/shelly");
|
||||
|
||||
@@ -296,6 +296,12 @@ export function createBrokerServer(options = {}) {
|
||||
? async (_gatewayId, payload = {}) => payload
|
||||
: async (gatewayId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/logs`, payload));
|
||||
const ingestMachineSignal =
|
||||
options.ingestMachineSignal ||
|
||||
(authMode === "stub"
|
||||
? async (_gatewayId, payload = {}) => payload
|
||||
: async (gatewayId, payload = {}) =>
|
||||
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/selfserve/machine-signal`, payload));
|
||||
|
||||
const broadcastGatewayEvent = (gatewayId, message) => {
|
||||
const sessionIds = gatewayStreamSessions.get(String(gatewayId));
|
||||
@@ -853,6 +859,11 @@ export function createBrokerServer(options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "MACHINE_SIGNAL") {
|
||||
await ingestMachineSignal(String(ws.gatewayId), message.payload || {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
|
||||
const sessionRecord = browserShellSessions.get(String(message.sessionId));
|
||||
if (!sessionRecord) {
|
||||
|
||||
@@ -766,6 +766,51 @@ test("broker fans out telemetry, task, log, and presence updates to browser gate
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker ingests self-serve machine signals from connected agents", async () => {
|
||||
const machineSignals = [];
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
|
||||
ingestMachineSignal: async (gatewayId, payload) => {
|
||||
machineSignals.push({ gatewayId, payload });
|
||||
return { recorded: true, lane_id: payload.lane_id };
|
||||
},
|
||||
});
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
|
||||
await new Promise((resolve) => agent.once("open", resolve));
|
||||
|
||||
agent.send(
|
||||
JSON.stringify({
|
||||
type: "MACHINE_SIGNAL",
|
||||
payload: {
|
||||
lane_id: 3,
|
||||
relay_id: "machine-relay",
|
||||
component: "input",
|
||||
channel: 0,
|
||||
event: "input.toggle_on",
|
||||
state: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => machineSignals.length === 1, { description: "machine signal ingestion" });
|
||||
assert.equal(machineSignals[0].gatewayId, "701");
|
||||
assert.deepEqual(machineSignals[0].payload, {
|
||||
lane_id: 3,
|
||||
relay_id: "machine-relay",
|
||||
component: "input",
|
||||
channel: 0,
|
||||
event: "input.toggle_on",
|
||||
state: true,
|
||||
});
|
||||
|
||||
agent.terminate();
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker survives telemetry ingestion failures for stale gateways", async () => {
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -12452,3 +12452,875 @@
|
||||
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55074 Closing
|
||||
[Tue Jun 2 14:17:06 2026] 127.0.0.1:34490 Accepted
|
||||
[Tue Jun 2 14:17:06 2026] 127.0.0.1:34490 Closing
|
||||
[Thu Jun 4 05:49:23 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41145) started
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56234 Accepted
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56234 Closing
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56242 Accepted
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56242 Closing
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56250 Accepted
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56250 Closing
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56258 Accepted
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56258 Closing
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56272 Accepted
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56272 Closing
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56278 Accepted
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56278 Closing
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56288 Accepted
|
||||
[Thu Jun 4 05:49:24 2026] 127.0.0.1:56288 Closing
|
||||
[Thu Jun 4 05:49:46 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45671) started
|
||||
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37442 Accepted
|
||||
[Thu Jun 4 05:49:47 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37442 Closing
|
||||
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37444 Accepted
|
||||
[Thu Jun 4 05:49:47 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37444 Closing
|
||||
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37448 Accepted
|
||||
[Thu Jun 4 05:49:47 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37448 Closing
|
||||
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37462 Accepted
|
||||
[Thu Jun 4 05:49:47 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:47 2026] 127.0.0.1:37462 Closing
|
||||
[Thu Jun 4 05:49:57 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33783) started
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59700 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59700 Closing
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59702 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59702 Closing
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59708 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59708 Closing
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59722 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59722 Closing
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59732 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 05:49:58 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59732 Closing
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59738 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59738 Closing
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59752 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59752 Closing
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59756 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 05:49:58 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59756 Closing
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59762 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59762 Closing
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59772 Accepted
|
||||
[Thu Jun 4 05:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:58 2026] 127.0.0.1:59772 Closing
|
||||
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59784 Accepted
|
||||
[Thu Jun 4 05:49:59 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59784 Closing
|
||||
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59790 Accepted
|
||||
[Thu Jun 4 05:49:59 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59790 Closing
|
||||
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59806 Accepted
|
||||
[Thu Jun 4 05:49:59 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59806 Closing
|
||||
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59810 Accepted
|
||||
[Thu Jun 4 05:49:59 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 05:49:59 2026] 127.0.0.1:59810 Closing
|
||||
[Thu Jun 4 06:06:51 2026] PHP 8.2.15 Development Server (http://127.0.0.1:36081) started
|
||||
[Thu Jun 4 06:06:51 2026] 127.0.0.1:34670 Accepted
|
||||
[Thu Jun 4 06:06:51 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:51 2026] 127.0.0.1:34670 Closing
|
||||
[Thu Jun 4 06:06:51 2026] 127.0.0.1:34674 Accepted
|
||||
[Thu Jun 4 06:06:51 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:51 2026] 127.0.0.1:34674 Closing
|
||||
[Thu Jun 4 06:06:51 2026] 127.0.0.1:58440 Accepted
|
||||
[Thu Jun 4 06:06:51 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:51 2026] 127.0.0.1:58440 Closing
|
||||
[Thu Jun 4 06:06:51 2026] 127.0.0.1:58454 Accepted
|
||||
[Thu Jun 4 06:06:51 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58454 Closing
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58466 Accepted
|
||||
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:52 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:06:52 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58466 Closing
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58478 Accepted
|
||||
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58478 Closing
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58492 Accepted
|
||||
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58492 Closing
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58502 Accepted
|
||||
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:52 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:06:52 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58502 Closing
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58512 Accepted
|
||||
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58512 Closing
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58522 Accepted
|
||||
[Thu Jun 4 06:06:52 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:06:52 2026] 127.0.0.1:58522 Closing
|
||||
[Thu Jun 4 06:07:21 2026] 127.0.0.1:56322 Accepted
|
||||
[Thu Jun 4 06:07:21 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:07:21 2026] 127.0.0.1:56322 Closing
|
||||
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56336 Accepted
|
||||
[Thu Jun 4 06:07:22 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56336 Closing
|
||||
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56352 Accepted
|
||||
[Thu Jun 4 06:07:22 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56352 Closing
|
||||
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56362 Accepted
|
||||
[Thu Jun 4 06:07:22 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56362 Closing
|
||||
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56374 Accepted
|
||||
[Thu Jun 4 06:07:22 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:07:22 2026] 127.0.0.1:56374 Closing
|
||||
[Thu Jun 4 06:18:18 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45139) started
|
||||
[Thu Jun 4 06:18:18 2026] 127.0.0.1:40632 Accepted
|
||||
[Thu Jun 4 06:18:18 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:18 2026] 127.0.0.1:40632 Closing
|
||||
[Thu Jun 4 06:18:18 2026] 127.0.0.1:40646 Accepted
|
||||
[Thu Jun 4 06:18:18 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40646 Closing
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40658 Accepted
|
||||
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40658 Closing
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40672 Accepted
|
||||
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40672 Closing
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40674 Accepted
|
||||
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:18:19 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40674 Closing
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40684 Accepted
|
||||
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40684 Closing
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40686 Accepted
|
||||
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40686 Closing
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40700 Accepted
|
||||
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:18:19 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40700 Closing
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40702 Accepted
|
||||
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40702 Closing
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40714 Accepted
|
||||
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40714 Closing
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40722 Accepted
|
||||
[Thu Jun 4 06:18:19 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:19 2026] 127.0.0.1:40722 Closing
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40736 Accepted
|
||||
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40736 Closing
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40752 Accepted
|
||||
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40752 Closing
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40760 Accepted
|
||||
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40760 Closing
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40764 Accepted
|
||||
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40764 Closing
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40768 Accepted
|
||||
[Thu Jun 4 06:18:20 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:20 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:18:20 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:18:20 2026] 127.0.0.1:40768 Closing
|
||||
[Thu Jun 4 06:18:21 2026] 127.0.0.1:40774 Accepted
|
||||
[Thu Jun 4 06:18:21 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:18:21 2026] 127.0.0.1:40774 Closing
|
||||
[Thu Jun 4 06:31:38 2026] PHP 8.2.15 Development Server (http://127.0.0.1:40389) started
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60850 Accepted
|
||||
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60850 Closing
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60856 Accepted
|
||||
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60856 Closing
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60858 Accepted
|
||||
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60858 Closing
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60872 Accepted
|
||||
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60872 Closing
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60874 Accepted
|
||||
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:38 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:31:38 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60874 Closing
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60890 Accepted
|
||||
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60890 Closing
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60904 Accepted
|
||||
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60904 Closing
|
||||
[Thu Jun 4 06:31:38 2026] 127.0.0.1:60916 Accepted
|
||||
[Thu Jun 4 06:31:38 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:38 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:31:38 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60916 Closing
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60926 Accepted
|
||||
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60926 Closing
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60936 Accepted
|
||||
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60936 Closing
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60946 Accepted
|
||||
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60946 Closing
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60954 Accepted
|
||||
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60954 Closing
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60960 Accepted
|
||||
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60960 Closing
|
||||
[Thu Jun 4 06:31:39 2026] 127.0.0.1:60964 Accepted
|
||||
[Thu Jun 4 06:31:39 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60964 Closing
|
||||
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60966 Accepted
|
||||
[Thu Jun 4 06:31:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60966 Closing
|
||||
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60982 Accepted
|
||||
[Thu Jun 4 06:31:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:40 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:31:40 2026] Failed to log lane action: Table 'nnks_db_test_clone.module_usage_logs' doesn't exist
|
||||
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60982 Closing
|
||||
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60990 Accepted
|
||||
[Thu Jun 4 06:31:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:40 2026] 127.0.0.1:60990 Closing
|
||||
[Thu Jun 4 06:31:40 2026] 127.0.0.1:32770 Accepted
|
||||
[Thu Jun 4 06:31:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Thu Jun 4 06:31:40 2026] 127.0.0.1:32770 Closing
|
||||
[Fri Jun 12 12:04:15 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44515) started
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34204 Accepted
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34204 Closing
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34210 Accepted
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34210 Closing
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34214 Accepted
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34214 Closing
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34226 Accepted
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34226 Closing
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34228 Accepted
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34228 Closing
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34238 Accepted
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34238 Closing
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34244 Accepted
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34244 Closing
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34260 Accepted
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34260 Closing
|
||||
[Fri Jun 12 12:04:15 2026] 127.0.0.1:34274 Accepted
|
||||
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34274 Closing
|
||||
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34280 Accepted
|
||||
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34280 Closing
|
||||
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34288 Accepted
|
||||
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34288 Closing
|
||||
[Fri Jun 12 12:04:16 2026] 127.0.0.1:34290 Accepted
|
||||
[Fri Jun 12 12:04:17 2026] 127.0.0.1:34290 Closing
|
||||
[Fri Jun 12 12:04:17 2026] 127.0.0.1:34296 Accepted
|
||||
[Fri Jun 12 12:04:18 2026] 127.0.0.1:34296 Closing
|
||||
[Fri Jun 12 12:04:18 2026] 127.0.0.1:34304 Accepted
|
||||
[Fri Jun 12 12:04:19 2026] 127.0.0.1:34304 Closing
|
||||
[Fri Jun 12 12:04:19 2026] 127.0.0.1:34314 Accepted
|
||||
[Fri Jun 12 12:04:20 2026] 127.0.0.1:34314 Closing
|
||||
[Fri Jun 12 12:19:47 2026] PHP 8.2.15 Development Server (http://127.0.0.1:35009) started
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57422 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57422 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57428 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57428 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57444 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57444 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57454 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57454 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57470 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57470 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57472 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57472 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57478 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57478 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57488 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57488 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57496 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57496 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57510 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57510 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57520 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57520 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57522 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57522 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57536 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57536 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57548 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57548 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57550 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57550 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57566 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57566 Closing
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57582 Accepted
|
||||
[Fri Jun 12 12:19:47 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:19:47 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:19:47 2026] 127.0.0.1:57582 Closing
|
||||
[Fri Jun 12 12:23:27 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33981) started
|
||||
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56426 Accepted
|
||||
[Fri Jun 12 12:23:27 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:23:27 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56426 Closing
|
||||
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56432 Accepted
|
||||
[Fri Jun 12 12:23:27 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:23:27 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56432 Closing
|
||||
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56438 Accepted
|
||||
[Fri Jun 12 12:23:27 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:23:27 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:23:27 2026] 127.0.0.1:56438 Closing
|
||||
[Fri Jun 12 12:39:31 2026] PHP 8.2.15 Development Server (http://127.0.0.1:43643) started
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58744 Accepted
|
||||
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58744 Closing
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58758 Accepted
|
||||
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58758 Closing
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58764 Accepted
|
||||
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58764 Closing
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58774 Accepted
|
||||
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58774 Closing
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58782 Accepted
|
||||
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58782 Closing
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58788 Accepted
|
||||
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58788 Closing
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58804 Accepted
|
||||
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58804 Closing
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58818 Accepted
|
||||
[Fri Jun 12 12:39:31 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:31 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:31 2026] 127.0.0.1:58818 Closing
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58830 Accepted
|
||||
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58830 Closing
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58834 Accepted
|
||||
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58834 Closing
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58838 Accepted
|
||||
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58838 Closing
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58846 Accepted
|
||||
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58846 Closing
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58862 Accepted
|
||||
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58862 Closing
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58872 Accepted
|
||||
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58872 Closing
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58884 Accepted
|
||||
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58884 Closing
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58890 Accepted
|
||||
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58890 Closing
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58896 Accepted
|
||||
[Fri Jun 12 12:39:32 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:32 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:32 2026] 127.0.0.1:58896 Closing
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58898 Accepted
|
||||
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58898 Closing
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58912 Accepted
|
||||
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58912 Closing
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58918 Accepted
|
||||
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58918 Closing
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58932 Accepted
|
||||
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58932 Closing
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58944 Accepted
|
||||
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58944 Closing
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58956 Accepted
|
||||
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58956 Closing
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58962 Accepted
|
||||
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58962 Closing
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58976 Accepted
|
||||
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58976 Closing
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58982 Accepted
|
||||
[Fri Jun 12 12:39:33 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:33 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:33 2026] 127.0.0.1:58982 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:58998 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:58998 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59004 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59004 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59012 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59012 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59028 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59028 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59040 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59040 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59046 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59046 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59052 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59052 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59062 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59062 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59074 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59074 Closing
|
||||
[Fri Jun 12 12:39:34 2026] 127.0.0.1:59078 Accepted
|
||||
[Fri Jun 12 12:39:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59078 Closing
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59080 Accepted
|
||||
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59080 Closing
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59090 Accepted
|
||||
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59090 Closing
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59100 Accepted
|
||||
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59100 Closing
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59102 Accepted
|
||||
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59102 Closing
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59106 Accepted
|
||||
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59106 Closing
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59120 Accepted
|
||||
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59120 Closing
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59122 Accepted
|
||||
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59122 Closing
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59136 Accepted
|
||||
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59136 Closing
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59144 Accepted
|
||||
[Fri Jun 12 12:39:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:35 2026] 127.0.0.1:59144 Closing
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59158 Accepted
|
||||
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59158 Closing
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59162 Accepted
|
||||
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59162 Closing
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59176 Accepted
|
||||
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59176 Closing
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59190 Accepted
|
||||
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59190 Closing
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59202 Accepted
|
||||
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59202 Closing
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59204 Accepted
|
||||
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59204 Closing
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59212 Accepted
|
||||
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59212 Closing
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59222 Accepted
|
||||
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59222 Closing
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59224 Accepted
|
||||
[Fri Jun 12 12:39:36 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:36 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:36 2026] 127.0.0.1:59224 Closing
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59226 Accepted
|
||||
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59226 Closing
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59230 Accepted
|
||||
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59230 Closing
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59232 Accepted
|
||||
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59232 Closing
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59248 Accepted
|
||||
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59248 Closing
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59254 Accepted
|
||||
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59254 Closing
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59270 Accepted
|
||||
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59270 Closing
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59276 Accepted
|
||||
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59276 Closing
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59290 Accepted
|
||||
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59290 Closing
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59300 Accepted
|
||||
[Fri Jun 12 12:39:37 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:37 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:37 2026] 127.0.0.1:59300 Closing
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59316 Accepted
|
||||
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59316 Closing
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59326 Accepted
|
||||
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59326 Closing
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59330 Accepted
|
||||
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59330 Closing
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59332 Accepted
|
||||
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59332 Closing
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59338 Accepted
|
||||
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59338 Closing
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59350 Accepted
|
||||
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59350 Closing
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59356 Accepted
|
||||
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59356 Closing
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59366 Accepted
|
||||
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59366 Closing
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59382 Accepted
|
||||
[Fri Jun 12 12:39:38 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:38 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:38 2026] 127.0.0.1:59382 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59392 Accepted
|
||||
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59392 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59408 Accepted
|
||||
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59408 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59410 Accepted
|
||||
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59410 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59426 Accepted
|
||||
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59426 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59430 Accepted
|
||||
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59430 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59444 Accepted
|
||||
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59444 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59452 Accepted
|
||||
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59452 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59458 Accepted
|
||||
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59458 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59462 Accepted
|
||||
[Fri Jun 12 12:39:39 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:39 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59462 Closing
|
||||
[Fri Jun 12 12:39:39 2026] 127.0.0.1:59470 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59470 Closing
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59484 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59484 Closing
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59488 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59488 Closing
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59496 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59496 Closing
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59512 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59512 Closing
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59516 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59516 Closing
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59524 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59524 Closing
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59526 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59526 Closing
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59534 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59534 Closing
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59536 Accepted
|
||||
[Fri Jun 12 12:39:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:40 2026] 127.0.0.1:59536 Closing
|
||||
[Fri Jun 12 12:39:41 2026] 127.0.0.1:42172 Accepted
|
||||
[Fri Jun 12 12:39:41 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:39:41 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:39:41 2026] 127.0.0.1:42172 Closing
|
||||
[Fri Jun 12 12:41:48 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45327) started
|
||||
[Fri Jun 12 12:41:48 2026] 127.0.0.1:49112 Accepted
|
||||
[Fri Jun 12 12:41:48 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:41:48 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:41:48 2026] 127.0.0.1:49112 Closing
|
||||
[Fri Jun 12 12:42:48 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44757) started
|
||||
[Fri Jun 12 12:42:48 2026] 127.0.0.1:44444 Accepted
|
||||
[Fri Jun 12 12:42:48 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:42:48 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:42:48 2026] 127.0.0.1:44444 Closing
|
||||
[Fri Jun 12 12:43:43 2026] PHP 8.2.15 Development Server (http://127.0.0.1:43731) started
|
||||
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40338 Accepted
|
||||
[Fri Jun 12 12:43:43 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:43:43 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:43:43 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40338 Closing
|
||||
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40354 Accepted
|
||||
[Fri Jun 12 12:43:43 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:43:43 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:43:43 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40354 Closing
|
||||
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40358 Accepted
|
||||
[Fri Jun 12 12:43:43 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:43:43 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:43:43 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 12:43:43 2026] 127.0.0.1:40358 Closing
|
||||
[Fri Jun 12 12:53:28 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41773) started
|
||||
[Fri Jun 12 12:53:29 2026] 127.0.0.1:59496 Accepted
|
||||
[Fri Jun 12 12:53:29 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:53:29 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:53:29 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 12:53:29 2026] 127.0.0.1:59496 Closing
|
||||
[Fri Jun 12 12:53:29 2026] 127.0.0.1:59506 Accepted
|
||||
[Fri Jun 12 12:53:29 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 12:53:29 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 12:53:29 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 12:53:29 2026] 127.0.0.1:59506 Closing
|
||||
[Fri Jun 12 13:42:40 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45259) started
|
||||
[Fri Jun 12 13:42:40 2026] 127.0.0.1:50638 Accepted
|
||||
[Fri Jun 12 13:42:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:42:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:42:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:42:40 2026] 127.0.0.1:50638 Closing
|
||||
[Fri Jun 12 13:42:40 2026] 127.0.0.1:50644 Accepted
|
||||
[Fri Jun 12 13:42:40 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:42:40 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:42:40 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:42:40 2026] 127.0.0.1:50644 Closing
|
||||
[Fri Jun 12 13:45:11 2026] PHP 8.2.15 Development Server (http://127.0.0.1:43693) started
|
||||
[Fri Jun 12 13:45:12 2026] 127.0.0.1:59572 Accepted
|
||||
[Fri Jun 12 13:45:12 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:45:12 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:45:12 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:45:12 2026] 127.0.0.1:59572 Closing
|
||||
[Fri Jun 12 13:45:12 2026] 127.0.0.1:59578 Accepted
|
||||
[Fri Jun 12 13:45:12 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:45:12 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:45:12 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:45:12 2026] 127.0.0.1:59578 Closing
|
||||
[Fri Jun 12 13:45:12 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37353) started
|
||||
[Fri Jun 12 13:45:12 2026] 127.0.0.1:37666 Accepted
|
||||
[Fri Jun 12 13:45:12 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:45:12 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:45:12 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:45:12 2026] 127.0.0.1:37666 Closing
|
||||
[Fri Jun 12 13:45:12 2026] 127.0.0.1:37676 Accepted
|
||||
[Fri Jun 12 13:45:12 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:45:12 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:45:12 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:45:12 2026] 127.0.0.1:37676 Closing
|
||||
[Fri Jun 12 13:49:21 2026] PHP 8.2.15 Development Server (http://127.0.0.1:32781) started
|
||||
[Fri Jun 12 13:49:21 2026] 127.0.0.1:57936 Accepted
|
||||
[Fri Jun 12 13:49:21 2026] 127.0.0.1:57936 Closing
|
||||
[Fri Jun 12 13:49:21 2026] 127.0.0.1:57940 Accepted
|
||||
[Fri Jun 12 13:49:21 2026] 127.0.0.1:57940 Closing
|
||||
[Fri Jun 12 13:49:22 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33723) started
|
||||
[Fri Jun 12 13:49:22 2026] 127.0.0.1:60016 Accepted
|
||||
[Fri Jun 12 13:49:22 2026] 127.0.0.1:60016 Closing
|
||||
[Fri Jun 12 13:49:22 2026] 127.0.0.1:60018 Accepted
|
||||
[Fri Jun 12 13:49:22 2026] 127.0.0.1:60018 Closing
|
||||
[Fri Jun 12 13:49:57 2026] PHP 8.2.15 Development Server (http://127.0.0.1:34549) started
|
||||
[Fri Jun 12 13:49:57 2026] 127.0.0.1:59336 Accepted
|
||||
[Fri Jun 12 13:49:57 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:49:57 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:49:57 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:49:57 2026] 127.0.0.1:59336 Closing
|
||||
[Fri Jun 12 13:49:57 2026] 127.0.0.1:59342 Accepted
|
||||
[Fri Jun 12 13:49:57 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:49:57 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:49:57 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:49:58 2026] 127.0.0.1:59342 Closing
|
||||
[Fri Jun 12 13:49:58 2026] PHP 8.2.15 Development Server (http://127.0.0.1:36181) started
|
||||
[Fri Jun 12 13:49:58 2026] 127.0.0.1:39290 Accepted
|
||||
[Fri Jun 12 13:49:58 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:49:58 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:49:58 2026] 127.0.0.1:39290 Closing
|
||||
[Fri Jun 12 13:49:58 2026] 127.0.0.1:39302 Accepted
|
||||
[Fri Jun 12 13:49:58 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 13:49:58 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 13:49:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 13:49:58 2026] 127.0.0.1:39302 Closing
|
||||
[Fri Jun 12 14:03:34 2026] PHP 8.2.15 Development Server (http://127.0.0.1:40101) started
|
||||
[Fri Jun 12 14:03:34 2026] 127.0.0.1:59406 Accepted
|
||||
[Fri Jun 12 14:03:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 14:03:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 14:03:34 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 14:03:34 2026] 127.0.0.1:59406 Closing
|
||||
[Fri Jun 12 14:03:34 2026] 127.0.0.1:59414 Accepted
|
||||
[Fri Jun 12 14:03:34 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 14:03:34 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 14:03:34 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 14:03:34 2026] 127.0.0.1:59414 Closing
|
||||
[Fri Jun 12 14:03:35 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33543) started
|
||||
[Fri Jun 12 14:03:35 2026] 127.0.0.1:60992 Accepted
|
||||
[Fri Jun 12 14:03:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 14:03:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 14:03:35 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 14:03:35 2026] 127.0.0.1:60992 Closing
|
||||
[Fri Jun 12 14:03:35 2026] 127.0.0.1:60994 Accepted
|
||||
[Fri Jun 12 14:03:35 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 14:03:35 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 14:03:35 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 14:03:35 2026] 127.0.0.1:60994 Closing
|
||||
[Fri Jun 12 14:05:57 2026] PHP 8.2.15 Development Server (http://127.0.0.1:46061) started
|
||||
[Fri Jun 12 14:05:57 2026] 127.0.0.1:38780 Accepted
|
||||
[Fri Jun 12 14:05:57 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 14:05:57 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 14:05:57 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 14:05:57 2026] 127.0.0.1:38780 Closing
|
||||
[Fri Jun 12 14:05:57 2026] 127.0.0.1:38792 Accepted
|
||||
[Fri Jun 12 14:05:57 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 14:05:57 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 14:05:57 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 14:05:57 2026] 127.0.0.1:38792 Closing
|
||||
[Fri Jun 12 14:05:57 2026] PHP 8.2.15 Development Server (http://127.0.0.1:34023) started
|
||||
[Fri Jun 12 14:05:58 2026] 127.0.0.1:59736 Accepted
|
||||
[Fri Jun 12 14:05:58 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 14:05:58 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 14:05:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 14:05:58 2026] 127.0.0.1:59736 Closing
|
||||
[Fri Jun 12 14:05:58 2026] 127.0.0.1:59748 Accepted
|
||||
[Fri Jun 12 14:05:58 2026] [config] Economic API is missing required credentials. Set ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN, then recreate php containers.
|
||||
[Fri Jun 12 14:05:58 2026] [config] ECONOMIC_API_APP_ACCESS_GRANT2 is not set. Secondary e-conomic token requests will fall back to ECONOMIC_API_APP_ACCESS_GRANT.
|
||||
[Fri Jun 12 14:05:58 2026] [replication-bootstrap] Could not sync startup failover metadata: Class "replication_bootstrap_config" not found
|
||||
[Fri Jun 12 14:05:58 2026] 127.0.0.1:59748 Closing
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
@@ -169,6 +174,11 @@ 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');
|
||||
@@ -179,6 +189,11 @@ class coolify_api_client
|
||||
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
|
||||
}
|
||||
|
||||
public function deleteApplication(string $uuid): array
|
||||
{
|
||||
return $this->request('DELETE', '/applications/' . rawurlencode($uuid));
|
||||
}
|
||||
|
||||
public function listDeployments(): array
|
||||
{
|
||||
return $this->request('GET', '/deployments');
|
||||
|
||||
@@ -6,6 +6,7 @@ class cors_policy
|
||||
{
|
||||
public const ALLOWED_HEADERS = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
|
||||
public const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
|
||||
public const EXPOSED_HEADERS = 'Server-Timing';
|
||||
public const MAX_AGE_SECONDS = '86400';
|
||||
|
||||
private const REQUIRED_ALLOWED_ORIGINS = [
|
||||
@@ -128,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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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]++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -688,6 +690,7 @@ class invoice_period_flag_service
|
||||
o.po AS order_po,
|
||||
o.notes AS order_notes,
|
||||
o.department_id,
|
||||
d.custom_pricing_only AS department_custom_pricing_only,
|
||||
o.reg_1,
|
||||
o.invoice_collection_id,
|
||||
o.wash_id,
|
||||
@@ -710,8 +713,21 @@ class invoice_period_flag_service
|
||||
p.max_quantity_per_order,
|
||||
c.name AS category_name,
|
||||
pdp.price AS department_price,
|
||||
product_discount.percentage AS product_discount_percentage,
|
||||
category_discount.percentage AS category_discount_percentage
|
||||
CASE
|
||||
WHEN d.custom_pricing_only = 1 THEN department_product_discount.percentage
|
||||
ELSE product_discount.percentage
|
||||
END AS product_discount_percentage,
|
||||
CASE
|
||||
WHEN d.custom_pricing_only = 1 THEN department_product_discount.fixed_price
|
||||
ELSE product_discount.fixed_price
|
||||
END AS product_fixed_price,
|
||||
CASE
|
||||
WHEN d.custom_pricing_only = 1 THEN GREATEST(
|
||||
COALESCE(department_category_discount.percentage, 0),
|
||||
COALESCE(department_global_discount.percentage, 0)
|
||||
)
|
||||
ELSE category_discount.percentage
|
||||
END AS category_discount_percentage
|
||||
FROM orders o
|
||||
LEFT JOIN (
|
||||
SELECT customer_number, MIN(id) AS id, MAX(display_name) AS display_name
|
||||
@@ -720,11 +736,12 @@ class invoice_period_flag_service
|
||||
GROUP BY customer_number
|
||||
) u ON u.customer_number = o.customer_id
|
||||
LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '')
|
||||
LEFT JOIN departments d ON d.id = o.department_id
|
||||
LEFT JOIN products p ON p.id = oi.product_id
|
||||
LEFT JOIN categories c ON c.id = p.category
|
||||
LEFT JOIN product_department_prices pdp ON pdp.department_id = o.department_id AND pdp.product_id = p.id
|
||||
LEFT JOIN (
|
||||
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage
|
||||
SELECT discount_user.customer_number, po.product_or_category_id, MAX(po.percentage) AS percentage, MAX(po.fixed_price) AS fixed_price
|
||||
FROM price_overrides po
|
||||
INNER JOIN users discount_user ON discount_user.id = po.user_id
|
||||
WHERE po.is_category = 0
|
||||
@@ -741,6 +758,32 @@ class invoice_period_flag_service
|
||||
) category_discount
|
||||
ON category_discount.customer_number = o.customer_id
|
||||
AND category_discount.product_or_category_id = p.category
|
||||
LEFT JOIN (
|
||||
SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage, MAX(fixed_price) AS fixed_price
|
||||
FROM department_customer_price_overrides
|
||||
WHERE is_category = 0
|
||||
GROUP BY department_id, user_id, product_or_category_id
|
||||
) department_product_discount
|
||||
ON department_product_discount.department_id = o.department_id
|
||||
AND department_product_discount.user_id = u.id
|
||||
AND department_product_discount.product_or_category_id = p.id
|
||||
LEFT JOIN (
|
||||
SELECT department_id, user_id, product_or_category_id, MAX(percentage) AS percentage
|
||||
FROM department_customer_price_overrides
|
||||
WHERE is_category = 1 AND product_or_category_id <> 'global'
|
||||
GROUP BY department_id, user_id, product_or_category_id
|
||||
) department_category_discount
|
||||
ON department_category_discount.department_id = o.department_id
|
||||
AND department_category_discount.user_id = u.id
|
||||
AND department_category_discount.product_or_category_id = p.category
|
||||
LEFT JOIN (
|
||||
SELECT department_id, user_id, MAX(percentage) AS percentage
|
||||
FROM department_customer_price_overrides
|
||||
WHERE is_category = 1 AND product_or_category_id = 'global'
|
||||
GROUP BY department_id, user_id
|
||||
) department_global_discount
|
||||
ON department_global_discount.department_id = o.department_id
|
||||
AND department_global_discount.user_id = u.id
|
||||
WHERE o.created_at BETWEEN '{$escapedDateFrom}' AND '{$escapedDateTo}'
|
||||
AND o.deleted_at IS NULL
|
||||
ORDER BY o.customer_id, o.id, oi.id";
|
||||
@@ -804,11 +847,16 @@ class invoice_period_flag_service
|
||||
{
|
||||
global $db;
|
||||
|
||||
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
|
||||
|
||||
$customerFilter = $this->customerFilterSql('u.customer_number', $onlyCustomerNumbers);
|
||||
$result = $db->query(
|
||||
"SELECT u.customer_number, ca.attribute
|
||||
"SELECT u.customer_number, ca.attribute, cp.product_id
|
||||
FROM customer_attributes ca
|
||||
JOIN users u ON u.id = ca.user_id
|
||||
LEFT JOIN customer_rule_product_restrictions r ON r.attribute = ca.attribute
|
||||
LEFT JOIN customer_rule_product_collections c ON c.attribute = r.attribute
|
||||
LEFT JOIN customer_rule_product_collection_products cp ON cp.collection_id = c.id
|
||||
WHERE 1=1 {$customerFilter}"
|
||||
);
|
||||
|
||||
@@ -819,7 +867,11 @@ class invoice_period_flag_service
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$customerNumber = (int)$row['customer_number'];
|
||||
$attributes[$customerNumber][(string)$row['attribute']] = true;
|
||||
$attribute = (string)$row['attribute'];
|
||||
$attributes[$customerNumber][$attribute] = true;
|
||||
if ($row['product_id'] !== null && in_array($attribute, customer_rule_product_restriction_service::PRODUCT_IMPACT_ATTRIBUTES, true)) {
|
||||
$attributes[$customerNumber]['__disabled_products'][(int)$row['product_id']][$attribute] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
@@ -830,6 +882,8 @@ class invoice_period_flag_service
|
||||
$flags = [];
|
||||
$orders = [];
|
||||
$collectionOrders = [];
|
||||
$matchingRules = static fn(int $customerNumber, int $productId): array =>
|
||||
array_keys($attributes[$customerNumber]['__disabled_products'][$productId] ?? []);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$customerNumber = (int)$row['customer_number'];
|
||||
@@ -855,57 +909,17 @@ class invoice_period_flag_service
|
||||
continue;
|
||||
}
|
||||
|
||||
$isTankCleaningProduct = $this->rowIsTankCleaningProduct($row);
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'restrictAdditionalServices')
|
||||
&& (int)($row['related_item_id'] ?? 0) > 0
|
||||
&& (int)($row['item_price'] ?? 0) > 0) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'customer_rule_restrict_addon_services',
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
$row,
|
||||
['product' => $this->productLabel($row)],
|
||||
$this->orderItemContext($row)
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'restrictTankCleaning') && $isTankCleaningProduct) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'customer_rule_restrict_tank_cleaning',
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
$row,
|
||||
['product' => $this->productLabel($row)],
|
||||
$this->orderItemContext($row)
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'onlyTankCleaning') && !$isTankCleaningProduct) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'customer_rule_only_tank_cleaning',
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
$row,
|
||||
['product' => $this->productLabel($row)],
|
||||
$this->orderItemContext($row)
|
||||
);
|
||||
}
|
||||
|
||||
$restrictedProducts = [
|
||||
'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree']],
|
||||
'restrictInteriorCleaning' => ['customer_rule_restrict_interior_cleaning', ['interior', 'indvendig']],
|
||||
'exemptFromAdministrationFee' => ['customer_rule_exempt_from_administration_fees', ['administration fee', 'administrationsgebyr', 'administration']],
|
||||
$productRuleDefinitions = [
|
||||
'restrictAdditionalServices' => 'customer_rule_restrict_addon_services',
|
||||
'restrictTankCleaning' => 'customer_rule_restrict_tank_cleaning',
|
||||
'onlyTankCleaning' => 'customer_rule_only_tank_cleaning',
|
||||
'restrictSpotFree' => 'customer_rule_restrict_spot_free',
|
||||
'restrictInteriorCleaning' => 'customer_rule_restrict_interior_cleaning',
|
||||
];
|
||||
|
||||
foreach ($restrictedProducts as $attribute => [$definitionKey, $terms]) {
|
||||
if ($this->hasAttribute($attributes, $customerNumber, $attribute)
|
||||
&& $this->rowMatchesProductTerms($row, $terms)) {
|
||||
foreach ($matchingRules($customerNumber, (int)($row['product_id'] ?? 0)) as $attribute) {
|
||||
if (isset($productRuleDefinitions[$attribute])) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
$definitionKey,
|
||||
$productRuleDefinitions[$attribute],
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
@@ -915,6 +929,19 @@ class invoice_period_flag_service
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'exemptFromAdministrationFee')
|
||||
&& $this->rowMatchesProductTerms($row, ['administration fee', 'administrationsgebyr', 'administration'])) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'customer_rule_exempt_from_administration_fees',
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
$row,
|
||||
['product' => $this->productLabel($row)],
|
||||
$this->orderItemContext($row)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($orders as $orderId => $row) {
|
||||
@@ -1921,7 +1948,19 @@ class invoice_period_flag_service
|
||||
|
||||
private function calculateExpectedPrice(array $row): int
|
||||
{
|
||||
$base = $row['department_price'] !== null ? (int)$row['department_price'] : (int)($row['product_base_price'] ?? 0);
|
||||
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
|
||||
if ($customMissingPrice) {
|
||||
return \objects\products_o::CUSTOM_PRICING_MISSING_PRICE;
|
||||
}
|
||||
|
||||
$fixedPrice = $this->rowProductFixedPrice($row);
|
||||
if ($fixedPrice !== null) {
|
||||
return $fixedPrice;
|
||||
}
|
||||
|
||||
$base = $row['department_price'] !== null
|
||||
? (int)$row['department_price']
|
||||
: (int)($row['product_base_price'] ?? 0);
|
||||
$discount = $this->discountBreakdown($row)['applied_discount_percentage'];
|
||||
return (int)round($base * (1 - ($discount / 100)));
|
||||
}
|
||||
@@ -1929,13 +1968,18 @@ class invoice_period_flag_service
|
||||
private function priceBreakdown(array $row, int $expected): array
|
||||
{
|
||||
$departmentPrice = $row['department_price'] !== null ? (int)$row['department_price'] : null;
|
||||
$base = $departmentPrice ?? (int)($row['product_base_price'] ?? 0);
|
||||
$customMissingPrice = $this->isCustomMissingDepartmentPrice($row);
|
||||
$base = $departmentPrice ?? ($customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0));
|
||||
$discount = $this->discountBreakdown($row);
|
||||
if ($customMissingPrice) {
|
||||
$discount['applied_discount_percentage'] = 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'product_price' => (int)($row['product_base_price'] ?? 0),
|
||||
'product_price' => $customMissingPrice ? \objects\products_o::CUSTOM_PRICING_MISSING_PRICE : (int)($row['product_base_price'] ?? 0),
|
||||
'department_price' => $departmentPrice,
|
||||
'effective_base_price' => $base,
|
||||
'product_fixed_price' => $this->rowProductFixedPrice($row),
|
||||
'product_discount_percentage' => $discount['product_discount_percentage'],
|
||||
'category_discount_percentage' => $discount['category_discount_percentage'],
|
||||
'economic_customer_discount_percentage' => $discount['economic_customer_discount_percentage'],
|
||||
@@ -1944,21 +1988,36 @@ class invoice_period_flag_service
|
||||
];
|
||||
}
|
||||
|
||||
private function isCustomMissingDepartmentPrice(array $row): bool
|
||||
{
|
||||
return $row['department_price'] === null && (bool)(int)($row['department_custom_pricing_only'] ?? 0);
|
||||
}
|
||||
|
||||
private function discountBreakdown(array $row): array
|
||||
{
|
||||
$productDiscount = (int)($row['product_discount_percentage'] ?? 0);
|
||||
$categoryApplied = (int)($row['apply_category_discount'] ?? 0) === 1;
|
||||
$categoryDiscount = $categoryApplied ? (int)($row['category_discount_percentage'] ?? 0) : 0;
|
||||
$economicDiscount = $categoryApplied ? $this->economicCustomerDiscountPercentage($row) : 0;
|
||||
$appliedDiscount = $this->rowProductFixedPrice($row) !== null
|
||||
? 0
|
||||
: max($productDiscount, $categoryDiscount, $economicDiscount);
|
||||
|
||||
return [
|
||||
'product_discount_percentage' => $productDiscount,
|
||||
'category_discount_percentage' => $categoryDiscount,
|
||||
'economic_customer_discount_percentage' => $economicDiscount,
|
||||
'applied_discount_percentage' => max($productDiscount, $categoryDiscount, $economicDiscount),
|
||||
'applied_discount_percentage' => $appliedDiscount,
|
||||
];
|
||||
}
|
||||
|
||||
private function rowProductFixedPrice(array $row): ?int
|
||||
{
|
||||
return array_key_exists('product_fixed_price', $row) && $row['product_fixed_price'] !== null
|
||||
? (int)$row['product_fixed_price']
|
||||
: null;
|
||||
}
|
||||
|
||||
private function economicCustomerDiscountPercentage(array $row): int
|
||||
{
|
||||
$customerNumber = (int)($row['customer_number'] ?? 0);
|
||||
@@ -2034,12 +2093,6 @@ class invoice_period_flag_service
|
||||
return false;
|
||||
}
|
||||
|
||||
private function rowIsTankCleaningProduct(array $row): bool
|
||||
{
|
||||
return (int)($row['product_category'] ?? 0) === 5
|
||||
|| $this->rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
||||
}
|
||||
|
||||
private function isIncludedOrderItem(array $row): bool
|
||||
{
|
||||
$value = $row['item_include_in_invoice'] ?? 1;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -62,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,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);
|
||||
}
|
||||
}
|
||||
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 (
|
||||
@@ -214,11 +219,46 @@ class selfserve_schema_bootstrap
|
||||
'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;
|
||||
}
|
||||
@@ -252,6 +292,35 @@ 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
|
||||
*/
|
||||
|
||||
@@ -196,11 +196,104 @@ class slack implements notification_i
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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:');
|
||||
@@ -230,4 +323,10 @@ class slack implements notification_i
|
||||
return "*Truck Wash Slack test*\n"
|
||||
. "Customer registration notifications are configured correctly.";
|
||||
}
|
||||
|
||||
public function format_internal_department_goal_progress_test(): string
|
||||
{
|
||||
return "*Truck Wash Slack test*\n"
|
||||
. "Internal department goal progress notifications are configured correctly.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
use objects\logs_o;
|
||||
use objects\subusers_o;
|
||||
|
||||
class subuser_contact_verification_service
|
||||
{
|
||||
public const CHANNEL_EMAIL = 'email';
|
||||
public const CHANNEL_PHONE = 'phone';
|
||||
public const CODE_TTL_SECONDS = 600;
|
||||
public const RESEND_COOLDOWN_SECONDS = 60;
|
||||
public const MAX_ATTEMPTS = 5;
|
||||
|
||||
private ?object $redis;
|
||||
private $codeGenerator;
|
||||
private $timeProvider;
|
||||
private $smsSender;
|
||||
private $emailSender;
|
||||
|
||||
public function __construct(
|
||||
?object $redis = null,
|
||||
?callable $codeGenerator = null,
|
||||
?callable $timeProvider = null,
|
||||
?callable $smsSender = null,
|
||||
?callable $emailSender = null
|
||||
) {
|
||||
$this->redis = $redis ?? (defined('redis') ? constant('redis') : null);
|
||||
$this->codeGenerator = $codeGenerator;
|
||||
$this->timeProvider = $timeProvider;
|
||||
$this->smsSender = $smsSender;
|
||||
$this->emailSender = $emailSender;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{state:string,email:array<string,mixed>,phone:array<string,mixed>}
|
||||
*/
|
||||
public function status(subusers_o $subuser): array
|
||||
{
|
||||
$email = $this->emailDestination($subuser);
|
||||
$phone = $this->phoneDestination($subuser);
|
||||
$emailVerifiedAt = $this->verifiedAtValue($subuser, self::CHANNEL_EMAIL);
|
||||
$phoneVerifiedAt = $this->verifiedAtValue($subuser, self::CHANNEL_PHONE);
|
||||
|
||||
$emailStatus = [
|
||||
'channel' => self::CHANNEL_EMAIL,
|
||||
'value' => $email,
|
||||
'masked_value' => $email !== null ? $this->maskEmail($email) : null,
|
||||
'available' => $email !== null,
|
||||
'verified' => $email !== null && $emailVerifiedAt !== null,
|
||||
'verified_at' => $emailVerifiedAt,
|
||||
];
|
||||
$phoneStatus = [
|
||||
'channel' => self::CHANNEL_PHONE,
|
||||
'country_code' => $subuser->phone_country_code->value() !== null ? (int)$subuser->phone_country_code->value() : null,
|
||||
'phone' => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null,
|
||||
'value' => $phone,
|
||||
'masked_value' => $phone !== null ? $this->maskPhone($phone) : null,
|
||||
'available' => $phone !== null,
|
||||
'verified' => $phone !== null && $phoneVerifiedAt !== null,
|
||||
'verified_at' => $phoneVerifiedAt,
|
||||
];
|
||||
|
||||
return [
|
||||
'state' => $this->verificationState($emailStatus, $phoneStatus),
|
||||
'email' => $emailStatus,
|
||||
'phone' => $phoneStatus,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function sendCode(subusers_o $subuser, string $channel, array $actor = []): array
|
||||
{
|
||||
$channel = $this->normalizeChannel($channel);
|
||||
$destination = $this->destinationFor($subuser, $channel);
|
||||
if ($destination === null) {
|
||||
return $this->delivery($channel, 'missing_destination', 'No contact value is available for verification.');
|
||||
}
|
||||
|
||||
if ($this->redis === null) {
|
||||
return $this->delivery($channel, 'unavailable', 'Verification delivery is not available.');
|
||||
}
|
||||
|
||||
$now = $this->now();
|
||||
$existing = $this->readChallenge($subuser, $channel);
|
||||
if ($existing !== null) {
|
||||
$sentAt = (int)($existing['sent_at'] ?? 0);
|
||||
$retryAfter = self::RESEND_COOLDOWN_SECONDS - ($now - $sentAt);
|
||||
if ($retryAfter > 0) {
|
||||
return $this->delivery($channel, 'throttled', 'Please wait before requesting another code.', [
|
||||
'retry_after' => $retryAfter,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$code = $this->generateCode();
|
||||
$destinationKey = $this->destinationKey($subuser, $channel);
|
||||
$nonce = bin2hex(random_bytes(16));
|
||||
$payload = [
|
||||
'hash' => $this->hashCode($code, $destinationKey, $nonce),
|
||||
'nonce' => $nonce,
|
||||
'destination' => $destinationKey,
|
||||
'attempts' => 0,
|
||||
'sent_at' => $now,
|
||||
'expires_at' => $now + self::CODE_TTL_SECONDS,
|
||||
];
|
||||
|
||||
try {
|
||||
$this->deliverCode($subuser, $channel, $destination, $code);
|
||||
$this->writeChallenge($subuser, $channel, $payload);
|
||||
$this->logEvent('SUBUSER_CONTACT_VERIFICATION_SENT', $subuser, $channel, $actor);
|
||||
} catch (Exception $exception) {
|
||||
$this->logEvent('SUBUSER_CONTACT_VERIFICATION_FAILED', $subuser, $channel, $actor);
|
||||
return $this->delivery($channel, 'failed', 'Verification delivery failed.');
|
||||
}
|
||||
|
||||
return $this->delivery($channel, 'sent', 'Verification code sent.', [
|
||||
'masked_destination' => $channel === self::CHANNEL_EMAIL
|
||||
? $this->maskEmail($destination)
|
||||
: $this->maskPhone($destination),
|
||||
'expires_in' => self::CODE_TTL_SECONDS,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function verifyCode(subusers_o $subuser, string $channel, string $code, array $actor = []): array
|
||||
{
|
||||
$channel = $this->normalizeChannel($channel);
|
||||
$code = trim($code);
|
||||
if (!preg_match('/^[0-9]{6}$/', $code)) {
|
||||
return $this->verificationResult($channel, 'invalid_code', 'Invalid verification code.');
|
||||
}
|
||||
|
||||
$challenge = $this->readChallenge($subuser, $channel);
|
||||
if ($challenge === null || (int)($challenge['expires_at'] ?? 0) < $this->now()) {
|
||||
$this->deleteChallenge($subuser, $channel);
|
||||
return $this->verificationResult($channel, 'expired', 'Verification code expired.');
|
||||
}
|
||||
|
||||
if (($challenge['destination'] ?? null) !== $this->destinationKey($subuser, $channel)) {
|
||||
$this->deleteChallenge($subuser, $channel);
|
||||
return $this->verificationResult($channel, 'destination_changed', 'Contact value changed. Request a new code.');
|
||||
}
|
||||
|
||||
$attempts = (int)($challenge['attempts'] ?? 0);
|
||||
if ($attempts >= self::MAX_ATTEMPTS) {
|
||||
$this->deleteChallenge($subuser, $channel);
|
||||
return $this->verificationResult($channel, 'too_many_attempts', 'Too many verification attempts.');
|
||||
}
|
||||
|
||||
$expectedHash = (string)($challenge['hash'] ?? '');
|
||||
$nonce = (string)($challenge['nonce'] ?? '');
|
||||
if (!hash_equals($expectedHash, $this->hashCode($code, (string)$challenge['destination'], $nonce))) {
|
||||
$nextAttempts = $attempts + 1;
|
||||
if ($nextAttempts >= self::MAX_ATTEMPTS) {
|
||||
$this->deleteChallenge($subuser, $channel);
|
||||
return $this->verificationResult($channel, 'too_many_attempts', 'Too many verification attempts.');
|
||||
}
|
||||
$challenge['attempts'] = $nextAttempts;
|
||||
$remainingTtl = max(1, (int)($challenge['expires_at'] ?? $this->now()) - $this->now());
|
||||
$this->writeChallenge($subuser, $channel, $challenge, $remainingTtl);
|
||||
return $this->verificationResult($channel, 'invalid_code', 'Invalid verification code.');
|
||||
}
|
||||
|
||||
$this->markVerified($subuser, $channel);
|
||||
$this->deleteChallenge($subuser, $channel);
|
||||
$this->logEvent('SUBUSER_CONTACT_VERIFICATION_VERIFIED', $subuser, $channel, $actor);
|
||||
|
||||
return $this->verificationResult($channel, 'verified', 'Contact value verified.', [
|
||||
'verified_at' => $this->verifiedAtValue($subuser, $channel),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function setVerificationState(subusers_o $subuser, string $channel, bool $verified, array $actor = []): array
|
||||
{
|
||||
$channel = $this->normalizeChannel($channel);
|
||||
if ($verified && $this->destinationFor($subuser, $channel) === null) {
|
||||
return $this->verificationResult($channel, 'missing_destination', 'No contact value is available for verification.');
|
||||
}
|
||||
|
||||
if ($verified) {
|
||||
$this->markVerified($subuser, $channel);
|
||||
} else {
|
||||
$this->markUnverified($subuser, $channel);
|
||||
}
|
||||
|
||||
$this->deleteChallenge($subuser, $channel);
|
||||
$this->logEvent(
|
||||
$verified
|
||||
? 'SUBUSER_CONTACT_VERIFICATION_MARKED_VERIFIED'
|
||||
: 'SUBUSER_CONTACT_VERIFICATION_MARKED_UNVERIFIED',
|
||||
$subuser,
|
||||
$channel,
|
||||
$actor
|
||||
);
|
||||
|
||||
return $this->verificationResult($channel, $verified ? 'verified' : 'unverified', 'Contact verification state updated.', [
|
||||
'verified_at' => $this->verifiedAtValue($subuser, $channel),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $updates
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function clearVerificationForChangedContacts(subusers_o $subuser, array $updates): array
|
||||
{
|
||||
if (array_key_exists('email', $updates)) {
|
||||
$current = $this->normalizeNullableString($subuser->email->value());
|
||||
$next = $this->normalizeNullableString($updates['email']);
|
||||
if ($current !== $next) {
|
||||
$updates['email_verified_at'] = null;
|
||||
$this->deleteChallenge($subuser, self::CHANNEL_EMAIL);
|
||||
}
|
||||
}
|
||||
|
||||
$phoneChanged = false;
|
||||
if (array_key_exists('phone_country_code', $updates)) {
|
||||
$phoneChanged = (int)$subuser->phone_country_code->value() !== (int)$updates['phone_country_code'];
|
||||
}
|
||||
if (array_key_exists('phone', $updates)) {
|
||||
$phoneChanged = $phoneChanged || (int)$subuser->phone->value() !== (int)$updates['phone'];
|
||||
}
|
||||
if ($phoneChanged) {
|
||||
$updates['phone_verified_at'] = null;
|
||||
$this->deleteChallenge($subuser, self::CHANNEL_PHONE);
|
||||
}
|
||||
|
||||
return $updates;
|
||||
}
|
||||
|
||||
public function normalizeChannel(string $channel): string
|
||||
{
|
||||
$channel = strtolower(trim($channel));
|
||||
if (!in_array($channel, [self::CHANNEL_EMAIL, self::CHANNEL_PHONE], true)) {
|
||||
throw new Exception('Invalid verification channel');
|
||||
}
|
||||
|
||||
return $channel;
|
||||
}
|
||||
|
||||
private function now(): int
|
||||
{
|
||||
if ($this->timeProvider !== null) {
|
||||
return (int)call_user_func($this->timeProvider);
|
||||
}
|
||||
|
||||
return time();
|
||||
}
|
||||
|
||||
private function generateCode(): string
|
||||
{
|
||||
if ($this->codeGenerator !== null) {
|
||||
$code = (string)call_user_func($this->codeGenerator);
|
||||
if (preg_match('/^[0-9]{6}$/', $code)) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
return (string)random_int(100000, 999999);
|
||||
}
|
||||
|
||||
private function hashCode(string $code, string $destination, string $nonce): string
|
||||
{
|
||||
$secret = (string)(getenv('APP_KEY') ?: getenv('JWT_SECRET') ?: __FILE__);
|
||||
return hash('sha256', $secret . ':' . $nonce . ':' . $destination . ':' . $code);
|
||||
}
|
||||
|
||||
private function destinationFor(subusers_o $subuser, string $channel): ?string
|
||||
{
|
||||
return $channel === self::CHANNEL_EMAIL
|
||||
? $this->emailDestination($subuser)
|
||||
: $this->phoneDestination($subuser);
|
||||
}
|
||||
|
||||
private function emailDestination(subusers_o $subuser): ?string
|
||||
{
|
||||
$email = $this->normalizeNullableString($subuser->email->value());
|
||||
return $email !== null && filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null;
|
||||
}
|
||||
|
||||
private function phoneDestination(subusers_o $subuser): ?string
|
||||
{
|
||||
$countryCode = $subuser->phone_country_code->value();
|
||||
$phone = $subuser->phone->value();
|
||||
if ($countryCode === null || $phone === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = preg_replace('/[^0-9]/', '', (string)$countryCode . (string)$phone);
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
|
||||
private function destinationKey(subusers_o $subuser, string $channel): string
|
||||
{
|
||||
return $channel . ':' . (string)($this->destinationFor($subuser, $channel) ?? '');
|
||||
}
|
||||
|
||||
private function key(subusers_o $subuser, string $channel): string
|
||||
{
|
||||
return 'subuser_contact_verification:' . (int)$subuser->id . ':' . $channel;
|
||||
}
|
||||
|
||||
private function readChallenge(subusers_o $subuser, string $channel): ?array
|
||||
{
|
||||
if ($this->redis === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = $this->redis->get($this->key($subuser, $channel));
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
private function writeChallenge(subusers_o $subuser, string $channel, array $payload, ?int $ttl = null): void
|
||||
{
|
||||
if ($this->redis === null) {
|
||||
throw new Exception('Verification cache is unavailable');
|
||||
}
|
||||
|
||||
$encoded = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
if ($encoded === false) {
|
||||
throw new Exception('Failed to encode verification challenge');
|
||||
}
|
||||
|
||||
$this->redis->setEx($this->key($subuser, $channel), $encoded, $ttl ?? self::CODE_TTL_SECONDS);
|
||||
}
|
||||
|
||||
private function deleteChallenge(subusers_o $subuser, string $channel): void
|
||||
{
|
||||
if ($this->redis !== null) {
|
||||
$this->redis->delete($this->key($subuser, $channel));
|
||||
}
|
||||
}
|
||||
|
||||
private function deliverCode(subusers_o $subuser, string $channel, string $destination, string $code): void
|
||||
{
|
||||
if ($channel === self::CHANNEL_PHONE) {
|
||||
if ($this->smsSender !== null) {
|
||||
call_user_func($this->smsSender, $destination, $code, $subuser);
|
||||
return;
|
||||
}
|
||||
|
||||
$gateway = new gatewayapi();
|
||||
if (!$gateway->isEnabled()) {
|
||||
throw new Exception('SMS delivery is not configured.');
|
||||
}
|
||||
$gateway->send([$destination], 'Truck Wash verifikationskode: ' . $code . '. Den udløber om 10 minutter.');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->emailSender !== null) {
|
||||
call_user_func($this->emailSender, $destination, $code, $subuser);
|
||||
return;
|
||||
}
|
||||
|
||||
$recipientName = $this->normalizeNullableString($subuser->name->value()) ?? 'Chauffør';
|
||||
$message = '<p>Din verifikationskode til Truck Wash er <strong>' . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '</strong>.</p>'
|
||||
. '<p>Koden udløber om 10 minutter.</p>';
|
||||
(new email())->sendEmail($destination, $recipientName, 'Truck Wash verifikationskode', $message);
|
||||
}
|
||||
|
||||
private function markVerified(subusers_o $subuser, string $channel): void
|
||||
{
|
||||
$timestamp = date('Y-m-d H:i:s', $this->now());
|
||||
if ($channel === self::CHANNEL_EMAIL) {
|
||||
$subuser->email_verified_at->set($timestamp);
|
||||
return;
|
||||
}
|
||||
|
||||
$subuser->phone_verified_at->set($timestamp);
|
||||
}
|
||||
|
||||
private function markUnverified(subusers_o $subuser, string $channel): void
|
||||
{
|
||||
if ($channel === self::CHANNEL_EMAIL) {
|
||||
$subuser->email_verified_at->set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
$subuser->phone_verified_at->set(null);
|
||||
}
|
||||
|
||||
private function verifiedAtValue(subusers_o $subuser, string $channel): ?string
|
||||
{
|
||||
$value = $channel === self::CHANNEL_EMAIL
|
||||
? $subuser->email_verified_at->value()
|
||||
: $subuser->phone_verified_at->value();
|
||||
$value = $this->normalizeNullableString($value);
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function verificationState(array $emailStatus, array $phoneStatus): string
|
||||
{
|
||||
if (!$phoneStatus['available'] && !$emailStatus['available']) {
|
||||
return 'missing_contacts';
|
||||
}
|
||||
if (!$phoneStatus['available']) {
|
||||
return 'missing_phone';
|
||||
}
|
||||
if (!$emailStatus['available']) {
|
||||
return 'missing_email';
|
||||
}
|
||||
if ($phoneStatus['verified'] && $emailStatus['verified']) {
|
||||
return 'verified';
|
||||
}
|
||||
if ($phoneStatus['verified'] || $emailStatus['verified']) {
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
return 'unverified';
|
||||
}
|
||||
|
||||
private function normalizeNullableString(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = trim((string)$value);
|
||||
return $normalized === '' ? null : $normalized;
|
||||
}
|
||||
|
||||
private function maskEmail(string $email): string
|
||||
{
|
||||
[$local, $domain] = array_pad(explode('@', $email, 2), 2, '');
|
||||
$prefix = substr($local, 0, 2);
|
||||
return $prefix . str_repeat('*', max(2, strlen($local) - 2)) . '@' . $domain;
|
||||
}
|
||||
|
||||
private function maskPhone(string $phone): string
|
||||
{
|
||||
$suffix = substr($phone, -4);
|
||||
return str_repeat('*', max(0, strlen($phone) - 4)) . $suffix;
|
||||
}
|
||||
|
||||
private function delivery(string $channel, string $status, string $message, array $extra = []): array
|
||||
{
|
||||
return [
|
||||
'channel' => $channel,
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
...$extra,
|
||||
];
|
||||
}
|
||||
|
||||
private function verificationResult(string $channel, string $status, string $message, array $extra = []): array
|
||||
{
|
||||
return [
|
||||
'channel' => $channel,
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
...$extra,
|
||||
];
|
||||
}
|
||||
|
||||
private function logEvent(string $action, subusers_o $subuser, string $channel, array $actor): void
|
||||
{
|
||||
try {
|
||||
if (!defined('redis')) {
|
||||
return;
|
||||
}
|
||||
$actorId = isset($actor['id']) ? (int)$actor['id'] : 0;
|
||||
(new logs_o())->add(
|
||||
'subusers',
|
||||
'global',
|
||||
1,
|
||||
$actorId,
|
||||
$action,
|
||||
'Chauffeur contact verification ' . $channel . ': ' . (int)$subuser->id
|
||||
);
|
||||
} catch (Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use modules\subusers\helpers\subusers_permission_node_key;
|
||||
|
||||
class subuser_permission_templates_service
|
||||
{
|
||||
public const TEMPLATE_DEACTIVATED = 'deactivated';
|
||||
public const TEMPLATE_DRIVER = 'driver';
|
||||
public const TEMPLATE_BOOKING_COORDINATOR = 'booking_coordinator';
|
||||
public const TEMPLATE_FLEET_ADMIN = 'fleet_admin';
|
||||
public const TEMPLATE_CUSTOM = 'custom';
|
||||
|
||||
/**
|
||||
* @var array<string, array{label:string,description:string,enabled:bool,permissions:array<int,string>}>
|
||||
*/
|
||||
private const TEMPLATES = [
|
||||
self::TEMPLATE_DEACTIVATED => [
|
||||
'label' => 'Deactivated',
|
||||
'description' => 'Keeps the driver linked to the customer without active access.',
|
||||
'enabled' => false,
|
||||
'permissions' => [],
|
||||
],
|
||||
self::TEMPLATE_DRIVER => [
|
||||
'label' => 'Driver',
|
||||
'description' => 'Can use self-service, manage own bookings, see vehicles, and view orders.',
|
||||
'enabled' => true,
|
||||
'permissions' => [
|
||||
'VEHICLES_LIST',
|
||||
'SELFSERVE_LIST',
|
||||
'SELFSERVE_ADD',
|
||||
'BOOKINGS_LIST',
|
||||
'BOOKINGS_ADD',
|
||||
'ORDERS_LIST',
|
||||
],
|
||||
],
|
||||
self::TEMPLATE_BOOKING_COORDINATOR => [
|
||||
'label' => 'Booking coordinator',
|
||||
'description' => 'Can coordinate bookings and see the related vehicles and orders.',
|
||||
'enabled' => true,
|
||||
'permissions' => [
|
||||
'VEHICLES_LIST',
|
||||
'BOOKINGS_LIST',
|
||||
'BOOKINGS_ADD',
|
||||
'BOOKINGS_EDIT',
|
||||
'ORDERS_LIST',
|
||||
],
|
||||
],
|
||||
self::TEMPLATE_FLEET_ADMIN => [
|
||||
'label' => 'Fleet admin',
|
||||
'description' => 'Can manage drivers, vehicles, bookings, self-service, and orders for the customer.',
|
||||
'enabled' => true,
|
||||
'permissions' => [
|
||||
'VEHICLES_LIST',
|
||||
'VEHICLES_EDIT',
|
||||
'VEHICLES_DELETE',
|
||||
'VEHICLES_ADD',
|
||||
'SELFSERVE_LIST',
|
||||
'SELFSERVE_EDIT',
|
||||
'SELFSERVE_DELETE',
|
||||
'SELFSERVE_ADD',
|
||||
'BOOKINGS_LIST',
|
||||
'BOOKINGS_EDIT',
|
||||
'BOOKINGS_DELETE',
|
||||
'BOOKINGS_ADD',
|
||||
'ORDERS_LIST',
|
||||
'ORDERS_EDIT',
|
||||
'SUBUSERS_LIST',
|
||||
'SUBUSERS_EDIT',
|
||||
'SUBUSERS_DELETE',
|
||||
'SUBUSERS_ADD',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, array{group:string,capability:string}>
|
||||
*/
|
||||
private const PERMISSION_CAPABILITIES = [
|
||||
'VEHICLES_LIST' => ['group' => 'vehicles', 'capability' => 'view_vehicles'],
|
||||
'VEHICLES_EDIT' => ['group' => 'vehicles', 'capability' => 'edit_vehicles'],
|
||||
'VEHICLES_DELETE' => ['group' => 'vehicles', 'capability' => 'delete_vehicles'],
|
||||
'VEHICLES_ADD' => ['group' => 'vehicles', 'capability' => 'add_vehicles'],
|
||||
'SELFSERVE_LIST' => ['group' => 'selfserve', 'capability' => 'view_selfserve'],
|
||||
'SELFSERVE_EDIT' => ['group' => 'selfserve', 'capability' => 'edit_selfserve'],
|
||||
'SELFSERVE_DELETE' => ['group' => 'selfserve', 'capability' => 'delete_selfserve'],
|
||||
'SELFSERVE_ADD' => ['group' => 'selfserve', 'capability' => 'start_selfserve'],
|
||||
'BOOKINGS_LIST' => ['group' => 'bookings', 'capability' => 'view_bookings'],
|
||||
'BOOKINGS_EDIT' => ['group' => 'bookings', 'capability' => 'edit_bookings'],
|
||||
'BOOKINGS_DELETE' => ['group' => 'bookings', 'capability' => 'delete_bookings'],
|
||||
'BOOKINGS_ADD' => ['group' => 'bookings', 'capability' => 'add_bookings'],
|
||||
'ORDERS_LIST' => ['group' => 'orders', 'capability' => 'view_orders'],
|
||||
'ORDERS_EDIT' => ['group' => 'orders', 'capability' => 'edit_orders'],
|
||||
'SUBUSERS_LIST' => ['group' => 'driver_management', 'capability' => 'view_drivers'],
|
||||
'SUBUSERS_EDIT' => ['group' => 'driver_management', 'capability' => 'edit_driver_access'],
|
||||
'SUBUSERS_DELETE' => ['group' => 'driver_management', 'capability' => 'disable_driver_access'],
|
||||
'SUBUSERS_ADD' => ['group' => 'driver_management', 'capability' => 'invite_drivers'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const GROUP_ORDER = [
|
||||
'vehicles',
|
||||
'selfserve',
|
||||
'bookings',
|
||||
'orders',
|
||||
'driver_management',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function accessModel(): array
|
||||
{
|
||||
return [
|
||||
'templates' => $this->templates(),
|
||||
'groups' => $this->groups(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function templates(): array
|
||||
{
|
||||
$templates = [];
|
||||
foreach (self::TEMPLATES as $key => $template) {
|
||||
$templates[] = [
|
||||
'key' => $key,
|
||||
'label' => $template['label'],
|
||||
'description' => $template['description'],
|
||||
'enabled' => $template['enabled'],
|
||||
'permissions' => array_values($template['permissions']),
|
||||
'permission_groups' => $this->permissionGroups($template['permissions']),
|
||||
];
|
||||
}
|
||||
|
||||
return $templates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{key:string,capabilities:array<int,string>}>
|
||||
*/
|
||||
public function groups(): array
|
||||
{
|
||||
$groups = [];
|
||||
foreach (self::GROUP_ORDER as $group) {
|
||||
$capabilities = [];
|
||||
foreach (self::PERMISSION_CAPABILITIES as $capability) {
|
||||
if ($capability['group'] === $group) {
|
||||
$capabilities[] = $capability['capability'];
|
||||
}
|
||||
}
|
||||
$groups[] = [
|
||||
'key' => $group,
|
||||
'capabilities' => array_values(array_unique($capabilities)),
|
||||
];
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{enabled:bool,permissions:array<int,string>}
|
||||
*/
|
||||
public function expandTemplate(string $templateKey): array
|
||||
{
|
||||
$key = $this->normalizeTemplateKey($templateKey);
|
||||
if ($key === null || $key === self::TEMPLATE_CUSTOM) {
|
||||
throw new \InvalidArgumentException('Unknown driver access template.');
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => self::TEMPLATES[$key]['enabled'],
|
||||
'permissions' => array_values(self::TEMPLATES[$key]['permissions']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{enabled:bool,permissions:array<int,string>}|null
|
||||
*/
|
||||
public function expandTemplateForWritePayload(?string $templateKey): ?array
|
||||
{
|
||||
$key = $this->normalizeTemplateKey($templateKey);
|
||||
if ($key === self::TEMPLATE_CUSTOM) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($key === null) {
|
||||
throw new \InvalidArgumentException('Unknown driver access template.');
|
||||
}
|
||||
|
||||
return $this->expandTemplate($key);
|
||||
}
|
||||
|
||||
public function normalizeTemplateKey(?string $templateKey): ?string
|
||||
{
|
||||
if ($templateKey === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = strtolower(trim($templateKey));
|
||||
if ($key === self::TEMPLATE_CUSTOM) {
|
||||
return self::TEMPLATE_CUSTOM;
|
||||
}
|
||||
|
||||
return array_key_exists($key, self::TEMPLATES) ? $key : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $permissions
|
||||
*/
|
||||
public function classify(array $permissions, bool $enabled = true): string
|
||||
{
|
||||
$normalized = $this->normalizePermissions($permissions);
|
||||
if (!$enabled || $normalized === []) {
|
||||
return self::TEMPLATE_DEACTIVATED;
|
||||
}
|
||||
|
||||
foreach (self::TEMPLATES as $key => $template) {
|
||||
if (!$template['enabled']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($normalized === $this->normalizePermissions($template['permissions'])) {
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
|
||||
return self::TEMPLATE_CUSTOM;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $permissions
|
||||
* @return array<int, array{key:string,capabilities:array<int,string>}>
|
||||
*/
|
||||
public function permissionGroups(array $permissions): array
|
||||
{
|
||||
$permissions = $this->normalizePermissions($permissions);
|
||||
$groups = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$capability = self::PERMISSION_CAPABILITIES[$permission] ?? null;
|
||||
if ($capability === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$group = $capability['group'];
|
||||
$groups[$group] ??= [];
|
||||
$groups[$group][] = $capability['capability'];
|
||||
}
|
||||
|
||||
$payload = [];
|
||||
foreach (self::GROUP_ORDER as $group) {
|
||||
if (!isset($groups[$group])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload[] = [
|
||||
'key' => $group,
|
||||
'capabilities' => array_values(array_unique($groups[$group])),
|
||||
];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $permissions
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizePermissions(array $permissions): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($permissions as $permission) {
|
||||
if ($permission instanceof subusers_permission_node_key) {
|
||||
$permission = $permission->name;
|
||||
}
|
||||
if (!is_string($permission)) {
|
||||
continue;
|
||||
}
|
||||
$permission = strtoupper(trim($permission));
|
||||
if ($permission !== '' && subusers_permission_node_key::tryFrom($permission) !== null) {
|
||||
$normalized[] = $permission;
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class subusers_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::ensureColumn(
|
||||
'subuser_grants',
|
||||
'assigned_vehicle_id',
|
||||
'INT NULL AFTER `subuser`'
|
||||
);
|
||||
self::ensureIndex(
|
||||
'subuser_grants',
|
||||
'idx_subuser_grants_assigned_vehicle_id',
|
||||
'`assigned_vehicle_id`'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'subusers',
|
||||
'phone_verified_at',
|
||||
'DATETIME NULL AFTER `phone`'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'subusers',
|
||||
'email_verified_at',
|
||||
'DATETIME NULL AFTER `email`'
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||
if ($table === '' || $column === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$columnSql = $db->escape_string($column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$columnSql'");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
|
||||
}
|
||||
|
||||
private static function ensureIndex(string $table, string $index, string $columns): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$index = preg_replace('/[^a-zA-Z0-9_]/', '', $index);
|
||||
if ($table === '' || $index === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$indexSql = $db->escape_string($index);
|
||||
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,6 @@ class superuser_system_status_service
|
||||
$dependencies['database']['status'] ?? 'down',
|
||||
$dependencies['redis']['status'] ?? 'down',
|
||||
$dependencies['minio']['status'] ?? 'down',
|
||||
$dependencies['database']['replication']['status'] ?? 'not_configured',
|
||||
$dependencies['redis']['replication']['status'] ?? 'not_configured',
|
||||
$dependencies['minio']['replication']['status'] ?? 'not_configured',
|
||||
];
|
||||
foreach ($modules as $module) {
|
||||
if (($module['enabled'] ?? false) === true) {
|
||||
@@ -298,16 +295,6 @@ class superuser_system_status_service
|
||||
$database = $this->probeDatabase();
|
||||
$redis = $this->probeRedis();
|
||||
$minio = $this->probeMinio();
|
||||
try {
|
||||
$replicationManager = new replication_manager();
|
||||
$database['replication'] = $replicationManager->dependencyReplication('database');
|
||||
$redis['replication'] = $replicationManager->dependencyReplication('redis');
|
||||
$minio['replication'] = $replicationManager->dependencyReplication('minio');
|
||||
} catch (Throwable $throwable) {
|
||||
$database['replication'] = $this->replicationStatusFallback('database', $throwable);
|
||||
$redis['replication'] = $this->replicationStatusFallback('redis', $throwable);
|
||||
$minio['replication'] = $this->replicationStatusFallback('minio', $throwable);
|
||||
}
|
||||
|
||||
if (($redis['status'] ?? '') === 'down') {
|
||||
$this->pushWarning(
|
||||
@@ -333,19 +320,6 @@ class superuser_system_status_service
|
||||
];
|
||||
}
|
||||
|
||||
private function replicationStatusFallback(string $kind, Throwable $throwable): array
|
||||
{
|
||||
return [
|
||||
'status' => 'degraded',
|
||||
'min_percent' => 0.0,
|
||||
'average_percent' => 0.0,
|
||||
'replicas' => [],
|
||||
'blockers' => [
|
||||
'Replication status for ' . $kind . ' could not be loaded: ' . $throwable->getMessage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function probeCpu(array &$warnings): array
|
||||
{
|
||||
$checkedAt = date('c');
|
||||
@@ -563,6 +537,7 @@ class superuser_system_status_service
|
||||
$result['configured'] = false;
|
||||
$result['status'] = 'disabled';
|
||||
$result = array_merge($result, $this->moduleReason('module_disabled', [], 'Module is disabled.'));
|
||||
$result = $this->attachModuleUsageMetrics($result);
|
||||
$results[] = $result;
|
||||
continue;
|
||||
}
|
||||
@@ -574,6 +549,7 @@ class superuser_system_status_service
|
||||
$result['status_reason_params'] = isset($configuration['reason_params']) && is_array($configuration['reason_params'])
|
||||
? $configuration['reason_params']
|
||||
: ['variables' => implode(', ', $missingRequired), 'variables_list' => $missingRequired];
|
||||
$result = $this->attachModuleUsageMetrics($result);
|
||||
$results[] = $result;
|
||||
continue;
|
||||
}
|
||||
@@ -589,6 +565,7 @@ class superuser_system_status_service
|
||||
'Configuration is present, but no safe read-only probe is available.'
|
||||
)
|
||||
);
|
||||
$result = $this->attachModuleUsageMetrics($result);
|
||||
$results[] = $result;
|
||||
continue;
|
||||
}
|
||||
@@ -601,6 +578,10 @@ class superuser_system_status_service
|
||||
? $probeResult['status_reason_params']
|
||||
: [];
|
||||
$result['checked_at'] = (string)($probeResult['checked_at'] ?? $result['checked_at']);
|
||||
if (isset($probeResult['usage']) && is_array($probeResult['usage'])) {
|
||||
$result['usage'] = $probeResult['usage'];
|
||||
}
|
||||
$result = $this->attachModuleUsageMetrics($result, isset($probeResult['usage']) && is_array($probeResult['usage']) ? $probeResult['usage'] : null);
|
||||
$results[] = $result;
|
||||
}
|
||||
|
||||
@@ -619,6 +600,60 @@ class superuser_system_status_service
|
||||
return $results;
|
||||
}
|
||||
|
||||
protected function attachModuleUsageMetrics(array $moduleResult, ?array $probeUsage = null): array
|
||||
{
|
||||
try {
|
||||
$service = new module_usage_service();
|
||||
$metrics = $service->metricsForModule((string)($moduleResult['key'] ?? ''));
|
||||
|
||||
if ($probeUsage !== null) {
|
||||
$probeMetric = $service->recordProviderSnapshotFromLegacyUsage(
|
||||
(string)($moduleResult['key'] ?? ''),
|
||||
$probeUsage,
|
||||
['source' => 'system_status_probe']
|
||||
);
|
||||
if ($probeMetric !== null) {
|
||||
$metrics = $this->replaceModuleUsageMetric($metrics, $probeMetric);
|
||||
}
|
||||
}
|
||||
|
||||
if ($metrics !== []) {
|
||||
$moduleResult['usage_metrics'] = $metrics;
|
||||
if (!isset($moduleResult['usage'])) {
|
||||
$primaryUsage = $service->primarySystemUsage($metrics);
|
||||
if ($primaryUsage !== null) {
|
||||
$moduleResult['usage'] = $primaryUsage;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
$moduleResult['usage_metrics_error'] = $throwable->getMessage();
|
||||
}
|
||||
|
||||
return $moduleResult;
|
||||
}
|
||||
|
||||
protected function replaceModuleUsageMetric(array $metrics, array $replacement): array
|
||||
{
|
||||
$replaced = false;
|
||||
foreach ($metrics as $index => $metric) {
|
||||
if (
|
||||
($metric['module_key'] ?? null) === ($replacement['module_key'] ?? null)
|
||||
&& ($metric['metric_key'] ?? null) === ($replacement['metric_key'] ?? null)
|
||||
) {
|
||||
$metrics[$index] = $replacement;
|
||||
$replaced = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$replaced) {
|
||||
$metrics[] = $replacement;
|
||||
}
|
||||
|
||||
return $metrics;
|
||||
}
|
||||
|
||||
protected function resolveModuleProbeResult(array $descriptor, array $moduleConfig, bool $force, array &$warnings): array
|
||||
{
|
||||
$cacheKey = self::MODULE_PROBE_CACHE_KEY_PREFIX . $descriptor['key'];
|
||||
@@ -824,7 +859,7 @@ class superuser_system_status_service
|
||||
protected function moduleDescriptors(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'economic', 'module' => 'economic', 'always_enabled' => true, 'required' => ['invoiceLayoutNumber', 'paymentTermsNumber', 'adminFeeMonthly', 'adminFeeOrder', 'feeProductId'], 'probe' => fn(array $config): array => $this->probeEconomicModule($config)],
|
||||
['key' => 'economic', 'module' => 'economic', 'always_enabled' => true, 'required' => ['invoiceLayoutNumber', 'invoiceDiscountLayoutNumber', 'paymentTermsNumber', 'adminFeeMonthly', 'adminFeeOrder', 'feeProductId'], 'probe' => fn(array $config): array => $this->probeEconomicModule($config)],
|
||||
['key' => 'reCAPTCHA', 'module' => 'reCAPTCHA', 'enabled_variable' => 'enabled', 'required' => ['site_key_v2', 'secret_key_v2'], 'probe' => fn(array $config): array => $this->probeRecaptchaModule($config)],
|
||||
['key' => 'email', 'module' => 'Email', 'enabled_variable' => 'enabled', 'required' => ['smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', 'smtp_encryption', 'smtp_from', 'smtp_from_name', 'smtp_reply_to', 'smtp_reply_to_name'], 'probe' => fn(array $config): array => $this->probeEmailModule($config)],
|
||||
['key' => 'backups', 'module' => 'Backups', 'enabled_variable' => 'enabled', 'required' => [], 'probe' => fn(array $config): array => $this->probeBackupsModule($config)],
|
||||
@@ -979,7 +1014,11 @@ class superuser_system_status_service
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
'Accept: application/json',
|
||||
],
|
||||
'MailerSend API'
|
||||
'MailerSend API',
|
||||
null,
|
||||
'GET',
|
||||
null,
|
||||
fn(array $httpResponse, string $label): array => $this->evaluateProviderQuotaProbeResponse($httpResponse, $label, 'email')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -990,12 +1029,50 @@ class superuser_system_status_service
|
||||
|
||||
try {
|
||||
$this->validateBackupsStore();
|
||||
$health = $this->backupHealthSummary();
|
||||
|
||||
if (empty($health['encryption']['available'])) {
|
||||
$error = (string)($health['encryption']['error'] ?? 'Backup encryption key is missing.');
|
||||
return [
|
||||
'status' => 'down',
|
||||
'status_reason' => 'Backup encryption is not ready: ' . $error,
|
||||
'status_reason_key' => 'backup_encryption_key_missing',
|
||||
'status_reason_params' => ['error' => $error],
|
||||
'checked_at' => $checkedAt,
|
||||
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($health['latest_verified_backup'])) {
|
||||
return [
|
||||
'status' => 'degraded',
|
||||
'status_reason' => 'No verified backup is available for restore.',
|
||||
'status_reason_key' => 'backup_no_verified_backup',
|
||||
'status_reason_params' => [],
|
||||
'checked_at' => $checkedAt,
|
||||
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($health['fresh'])) {
|
||||
return [
|
||||
'status' => 'degraded',
|
||||
'status_reason' => 'Latest verified backup is stale.',
|
||||
'status_reason_key' => 'backup_latest_verified_stale',
|
||||
'status_reason_params' => ['age_seconds' => (string)($health['latest_verified_age_seconds'] ?? '')],
|
||||
'checked_at' => $checkedAt,
|
||||
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'status_reason' => 'Backup store connectivity confirmed.',
|
||||
'status_reason_key' => 'backup_connectivity_confirmed',
|
||||
'status_reason_params' => [],
|
||||
'status_reason_params' => [
|
||||
'latest_verified_age_seconds' => (string)($health['latest_verified_age_seconds'] ?? ''),
|
||||
'encryption_key_id' => (string)($health['encryption']['key_id'] ?? ''),
|
||||
],
|
||||
'checked_at' => $checkedAt,
|
||||
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||
];
|
||||
@@ -1018,7 +1095,11 @@ class superuser_system_status_service
|
||||
return $this->performHttpProbe(
|
||||
$this->buildUrlWithQuery('https://v1.motorapi.dk', '/usage'),
|
||||
['X-AUTH-TOKEN: ' . $secretKey],
|
||||
'MotorAPI'
|
||||
'MotorAPI',
|
||||
null,
|
||||
'GET',
|
||||
null,
|
||||
fn(array $httpResponse, string $label): array => $this->evaluateProviderQuotaProbeResponse($httpResponse, $label, 'motorapi')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1151,12 +1232,16 @@ class superuser_system_status_service
|
||||
$apiKey = trim((string)($config['api_key']['parsed'] ?? ''));
|
||||
|
||||
return $this->performHttpProbe(
|
||||
$this->buildUrlWithQuery('https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk', '/info/'),
|
||||
$this->buildUrlWithQuery(licenseplaterecognizer::configuredApiBaseUrl(), '/info/'),
|
||||
[
|
||||
'Authorization: Token ' . $apiKey,
|
||||
'Accept: application/json',
|
||||
],
|
||||
'License Plate Recognizer'
|
||||
'License Plate Recognizer',
|
||||
null,
|
||||
'GET',
|
||||
null,
|
||||
fn(array $httpResponse, string $label): array => $this->evaluateLicensePlateRecognizerProbeResponse($httpResponse, $label)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1474,6 +1559,270 @@ class superuser_system_status_service
|
||||
];
|
||||
}
|
||||
|
||||
protected function evaluateLicensePlateRecognizerProbeResponse(array $httpResponse, string $label): array
|
||||
{
|
||||
$classified = $this->classifyHttpProbeResult($httpResponse, $label);
|
||||
if (($classified['status'] ?? 'down') !== 'ok') {
|
||||
return $classified;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)($httpResponse['body'] ?? ''), true);
|
||||
if (!is_array($decoded)) {
|
||||
return $this->providerQuotaUnavailableProbeResult(
|
||||
$httpResponse,
|
||||
$label,
|
||||
'licenseplaterecognizer',
|
||||
'unreadable_payload',
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
$usage = is_array($decoded['usage'] ?? null) ? $decoded['usage'] : [];
|
||||
$callsUsedRaw = $usage['calls'] ?? null;
|
||||
$quotaCallsRaw = $decoded['total_calls'] ?? null;
|
||||
if (!is_numeric($callsUsedRaw) || !is_numeric($quotaCallsRaw) || (int)$quotaCallsRaw <= 0) {
|
||||
$reason = !is_numeric($quotaCallsRaw) ? 'missing_limit' : 'missing_usage';
|
||||
if (is_numeric($quotaCallsRaw) && (int)$quotaCallsRaw <= 0) {
|
||||
$reason = 'invalid_limit';
|
||||
}
|
||||
|
||||
return $this->providerQuotaUnavailableProbeResult(
|
||||
$httpResponse,
|
||||
$label,
|
||||
'licenseplaterecognizer',
|
||||
$reason,
|
||||
$this->payloadKeyPaths($decoded)
|
||||
);
|
||||
}
|
||||
|
||||
$callsUsed = max(0, (int)$callsUsedRaw);
|
||||
$quotaCalls = max(1, (int)$quotaCallsRaw);
|
||||
$callsRemaining = max(0, $quotaCalls - $callsUsed);
|
||||
$usagePercent = round(($callsUsed / $quotaCalls) * 100, 2);
|
||||
$usagePayload = [
|
||||
'provider' => 'licenseplaterecognizer',
|
||||
'calls_used' => $callsUsed,
|
||||
'quota_calls' => $quotaCalls,
|
||||
'calls_remaining' => $callsRemaining,
|
||||
'usage_percent' => $usagePercent,
|
||||
'version' => trim((string)($decoded['version'] ?? '')),
|
||||
];
|
||||
$reasonParams = [
|
||||
'label' => $label,
|
||||
'used' => (string)$callsUsed,
|
||||
'quota' => (string)$quotaCalls,
|
||||
'remaining' => (string)$callsRemaining,
|
||||
'percent' => number_format($usagePercent, 1, '.', ''),
|
||||
];
|
||||
|
||||
if ($callsRemaining <= 0 || $usagePercent >= 100.0) {
|
||||
return [
|
||||
'status' => 'down',
|
||||
'status_reason' => $label . ' quota is exhausted.',
|
||||
'status_reason_key' => 'licenseplaterecognizer_quota_exhausted',
|
||||
'status_reason_params' => $reasonParams,
|
||||
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||
'http_status' => $httpResponse['http_status'] ?? null,
|
||||
'usage' => $usagePayload,
|
||||
];
|
||||
}
|
||||
|
||||
if ($usagePercent >= 90.0) {
|
||||
return [
|
||||
'status' => 'degraded',
|
||||
'status_reason' => $label . ' quota usage is near the limit.',
|
||||
'status_reason_key' => 'licenseplaterecognizer_quota_near_limit',
|
||||
'status_reason_params' => $reasonParams,
|
||||
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||
'http_status' => $httpResponse['http_status'] ?? null,
|
||||
'usage' => $usagePayload,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'status_reason' => $label . ' usage and quota are available.',
|
||||
'status_reason_key' => 'licenseplaterecognizer_usage_available',
|
||||
'status_reason_params' => $reasonParams,
|
||||
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||
'http_status' => $httpResponse['http_status'] ?? null,
|
||||
'usage' => $usagePayload,
|
||||
];
|
||||
}
|
||||
|
||||
protected function evaluateProviderQuotaProbeResponse(array $httpResponse, string $label, string $moduleKey): array
|
||||
{
|
||||
$classified = $this->classifyHttpProbeResult($httpResponse, $label);
|
||||
if (($classified['status'] ?? 'down') !== 'ok') {
|
||||
return $classified;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)($httpResponse['body'] ?? ''), true);
|
||||
if (!is_array($decoded)) {
|
||||
return $this->providerQuotaUnavailableProbeResult(
|
||||
$httpResponse,
|
||||
$label,
|
||||
$moduleKey,
|
||||
'unreadable_payload',
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
$used = $this->findFirstNumericPayloadValue($decoded, ['used', 'usage', 'calls', 'messages_used', 'used_messages', 'sent', 'total_used']);
|
||||
$limit = $this->findFirstNumericPayloadValue($decoded, ['limit', 'quota', 'total', 'total_calls', 'messages_limit', 'max', 'allowed']);
|
||||
$remaining = $this->findFirstNumericPayloadValue($decoded, ['remaining', 'left', 'available', 'calls_remaining', 'messages_remaining']);
|
||||
|
||||
if ($used === null && $limit !== null && $remaining !== null) {
|
||||
$used = max(0.0, $limit - $remaining);
|
||||
}
|
||||
if ($remaining === null && $used !== null && $limit !== null) {
|
||||
$remaining = max(0.0, $limit - $used);
|
||||
}
|
||||
|
||||
if ($limit === null) {
|
||||
return $this->providerQuotaUnavailableProbeResult(
|
||||
$httpResponse,
|
||||
$label,
|
||||
$moduleKey,
|
||||
'missing_limit',
|
||||
$this->payloadKeyPaths($decoded)
|
||||
);
|
||||
}
|
||||
|
||||
if ($limit <= 0) {
|
||||
return $this->providerQuotaUnavailableProbeResult(
|
||||
$httpResponse,
|
||||
$label,
|
||||
$moduleKey,
|
||||
'invalid_limit',
|
||||
$this->payloadKeyPaths($decoded)
|
||||
);
|
||||
}
|
||||
|
||||
if ($used === null) {
|
||||
return $this->providerQuotaUnavailableProbeResult(
|
||||
$httpResponse,
|
||||
$label,
|
||||
$moduleKey,
|
||||
'missing_usage',
|
||||
$this->payloadKeyPaths($decoded)
|
||||
);
|
||||
}
|
||||
|
||||
$usagePercent = round(($used / $limit) * 100, 2);
|
||||
$usagePayload = [
|
||||
'provider' => $moduleKey,
|
||||
'calls_used' => $used,
|
||||
'quota_calls' => $limit,
|
||||
'calls_remaining' => $remaining,
|
||||
'usage_percent' => $usagePercent,
|
||||
];
|
||||
|
||||
if (($remaining !== null && $remaining <= 0) || $usagePercent >= 100.0) {
|
||||
return [
|
||||
'status' => 'down',
|
||||
'status_reason' => $label . ' quota is exhausted.',
|
||||
'status_reason_key' => 'provider_quota_exhausted',
|
||||
'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')],
|
||||
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||
'http_status' => $httpResponse['http_status'] ?? null,
|
||||
'usage' => $usagePayload,
|
||||
];
|
||||
}
|
||||
|
||||
if ($usagePercent >= 90.0) {
|
||||
return [
|
||||
'status' => 'degraded',
|
||||
'status_reason' => $label . ' quota usage is near the limit.',
|
||||
'status_reason_key' => 'provider_quota_near_limit',
|
||||
'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')],
|
||||
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||
'http_status' => $httpResponse['http_status'] ?? null,
|
||||
'usage' => $usagePayload,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'status_reason' => $label . ' quota usage is available.',
|
||||
'status_reason_key' => 'provider_quota_available',
|
||||
'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')],
|
||||
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||
'http_status' => $httpResponse['http_status'] ?? null,
|
||||
'usage' => $usagePayload,
|
||||
];
|
||||
}
|
||||
|
||||
protected function providerQuotaUnavailableProbeResult(array $httpResponse, string $label, string $moduleKey, string $reason, array $detectedKeys = []): array
|
||||
{
|
||||
$usagePayload = [
|
||||
'provider' => $moduleKey,
|
||||
'usage_available' => false,
|
||||
'status' => 'unknown',
|
||||
'calls_used' => null,
|
||||
'quota_calls' => null,
|
||||
'calls_remaining' => null,
|
||||
'usage_percent' => null,
|
||||
'unavailable_reason' => $reason,
|
||||
'detected_keys' => $detectedKeys,
|
||||
];
|
||||
|
||||
return [
|
||||
'status' => 'degraded',
|
||||
'status_reason' => $label . ' responded, but quota usage could not be read.',
|
||||
'status_reason_key' => 'provider_quota_unavailable',
|
||||
'status_reason_params' => [
|
||||
'label' => $label,
|
||||
'reason' => $reason,
|
||||
'detected_keys' => implode(', ', array_slice($detectedKeys, 0, 12)),
|
||||
],
|
||||
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||
'http_status' => $httpResponse['http_status'] ?? null,
|
||||
'usage' => $usagePayload,
|
||||
];
|
||||
}
|
||||
|
||||
protected function findFirstNumericPayloadValue(array $payload, array $keys): ?float
|
||||
{
|
||||
foreach ($payload as $key => $value) {
|
||||
$normalizedKey = strtolower((string)$key);
|
||||
if (in_array($normalizedKey, $keys, true) && is_numeric($value)) {
|
||||
return (float)$value;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
$nested = $this->findFirstNumericPayloadValue($value, $keys);
|
||||
if ($nested !== null) {
|
||||
return $nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function payloadKeyPaths(array $payload, string $prefix = ''): array
|
||||
{
|
||||
$paths = [];
|
||||
foreach ($payload as $key => $value) {
|
||||
$path = $prefix === '' ? (string)$key : $prefix . '.' . (string)$key;
|
||||
$paths[] = $path;
|
||||
|
||||
if (is_array($value)) {
|
||||
array_push($paths, ...$this->payloadKeyPaths($value, $path));
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($paths));
|
||||
}
|
||||
|
||||
protected function evaluateRecaptchaProbeResponse(array $httpResponse, string $label): array
|
||||
{
|
||||
$classified = $this->classifyHttpProbeResult($httpResponse, $label);
|
||||
@@ -1539,6 +1888,11 @@ class superuser_system_status_service
|
||||
new backup_store();
|
||||
}
|
||||
|
||||
protected function backupHealthSummary(): array
|
||||
{
|
||||
return (new backup_store())->healthSummary();
|
||||
}
|
||||
|
||||
protected function bootstrapSelfserveSchema(): void
|
||||
{
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
|
||||
@@ -6,9 +6,8 @@ use Throwable;
|
||||
|
||||
class system_search_cache
|
||||
{
|
||||
public const PREFIX = 'system_search:v1:';
|
||||
public const PREFIX = 'system_search:v2:';
|
||||
public const QUERY_PREFIX = self::PREFIX . 'query:';
|
||||
public const INTENT_PREFIX = self::PREFIX . 'intent:';
|
||||
public const DIRTY_TABLES_KEY = self::PREFIX . 'dirty_tables';
|
||||
public const REBUILD_REQUEST_KEY = self::PREFIX . 'rebuild_request';
|
||||
public const TABLE_VERSION_PREFIX = self::PREFIX . 'table_version:';
|
||||
@@ -40,24 +39,6 @@ class system_search_cache
|
||||
self::redisSetEx(self::QUERY_PREFIX . $hash, json_encode($payload, JSON_UNESCAPED_UNICODE), $ttlSeconds);
|
||||
}
|
||||
|
||||
public static function getIntent(string $hash): ?array
|
||||
{
|
||||
$raw = self::redisGet(self::INTENT_PREFIX . $hash);
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
public static function setIntent(string $hash, array $payload, int $ttlSeconds = 3600): void
|
||||
{
|
||||
self::redisSetEx(self::INTENT_PREFIX . $hash, json_encode($payload, JSON_UNESCAPED_UNICODE), $ttlSeconds);
|
||||
}
|
||||
|
||||
public static function clearAll(): void
|
||||
{
|
||||
self::clearPattern(self::PREFIX . '*');
|
||||
@@ -68,11 +49,6 @@ class system_search_cache
|
||||
self::clearPattern(self::QUERY_PREFIX . '*');
|
||||
}
|
||||
|
||||
public static function clearIntentCaches(): void
|
||||
{
|
||||
self::clearPattern(self::INTENT_PREFIX . '*');
|
||||
}
|
||||
|
||||
public static function markDirtyTable(string $table): void
|
||||
{
|
||||
$table = trim($table, " `\t\n\r\0\x0B");
|
||||
|
||||
@@ -499,6 +499,7 @@ class system_search_document_index
|
||||
*/
|
||||
private function buildCustomerDiscountDocuments(): array
|
||||
{
|
||||
price_overrides_schema_bootstrap::ensureColumns();
|
||||
$fromClause = 'price_overrides po INNER JOIN users u ON u.id = po.user_id';
|
||||
$selectFields = [
|
||||
'po.id AS entity_id',
|
||||
@@ -506,6 +507,7 @@ class system_search_document_index
|
||||
'po.is_category',
|
||||
'po.product_or_category_id',
|
||||
'po.percentage',
|
||||
'po.fixed_price',
|
||||
'u.customer_number',
|
||||
'u.display_name',
|
||||
...$this->joinTemporalSelectFields('price_overrides', 'po'),
|
||||
@@ -554,6 +556,7 @@ class system_search_document_index
|
||||
$row['search_text'] ?? null,
|
||||
$row['product_or_category_id'] ?? null,
|
||||
$row['percentage'] ?? null,
|
||||
$row['fixed_price'] ?? null,
|
||||
$row['user_id'] ?? null,
|
||||
]),
|
||||
$this->toIntOrNull($row['customer_number'] ?? null),
|
||||
@@ -564,6 +567,7 @@ class system_search_document_index
|
||||
'customer_number' => $this->toIntOrNull($row['customer_number'] ?? null),
|
||||
'product_or_category_id' => $row['product_or_category_id'] ?? null,
|
||||
'percentage' => $this->toIntOrNull($row['percentage'] ?? null),
|
||||
'fixed_price' => $this->toIntOrNull($row['fixed_price'] ?? null),
|
||||
'economic_name' => $row['economic_name'] ?? null,
|
||||
'economic_cvr' => $row['economic_cvr'] ?? null,
|
||||
'is_category' => $row['is_category'] ?? null,
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
use interfaces\system_search_intent_parser_i;
|
||||
use Throwable;
|
||||
|
||||
class system_search_openai_intent_parser implements system_search_intent_parser_i
|
||||
{
|
||||
private string $apiUrl = 'https://api.openai.com/v1/responses';
|
||||
private string $model = 'gpt-4.1-mini';
|
||||
private float $temperature = 0.1;
|
||||
private int $timeoutSeconds = 10;
|
||||
private int $maxAliases = 12;
|
||||
private int $maxEntityHints = 8;
|
||||
private int $maxAliasLength = 64;
|
||||
private int $maxHintLength = 32;
|
||||
private int $maxNormalizedQueryLength = 256;
|
||||
private int $maxFallbackReasonLength = 160;
|
||||
|
||||
/**
|
||||
* @var callable|null
|
||||
*/
|
||||
private $transport;
|
||||
private ?bool $forcedEnabled;
|
||||
private ?string $forcedApiKey;
|
||||
|
||||
public function __construct(?callable $transport = null, ?bool $forcedEnabled = null, ?string $forcedApiKey = null)
|
||||
{
|
||||
$this->transport = $transport;
|
||||
$this->forcedEnabled = $forcedEnabled;
|
||||
$this->forcedApiKey = $forcedApiKey;
|
||||
}
|
||||
|
||||
public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array
|
||||
{
|
||||
$query = trim($query);
|
||||
if ($query === '') {
|
||||
return $this->failed('empty_query', 'none');
|
||||
}
|
||||
|
||||
[$enabled, $apiKey] = $this->resolveOpenAISettings();
|
||||
if (!$enabled) {
|
||||
return $this->failed('openai_disabled', 'none');
|
||||
}
|
||||
if (empty($apiKey)) {
|
||||
return $this->failed('openai_missing_key', 'none');
|
||||
}
|
||||
|
||||
$redactedQuery = self::redactSensitiveQuery($query);
|
||||
$payload = $this->buildPayload($redactedQuery, $allowedEntityTypes, $taxonomy);
|
||||
$cacheHash = md5(json_encode([
|
||||
'q' => $redactedQuery,
|
||||
'types' => $allowedEntityTypes,
|
||||
'taxonomy' => $taxonomy,
|
||||
'v' => 1,
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$cached = system_search_cache::getIntent($cacheHash);
|
||||
if (is_array($cached) && isset($cached['success'])) {
|
||||
$cached['source'] = 'cache';
|
||||
return $this->normalizeResult($cached, $allowedEntityTypes);
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = $this->sendRequest($payload, $apiKey);
|
||||
$parsed = $this->parseResponse($raw);
|
||||
$parsed['source'] = 'openai';
|
||||
system_search_cache::setIntent($cacheHash, $parsed, 3600);
|
||||
return $this->normalizeResult($parsed, $allowedEntityTypes);
|
||||
} catch (Throwable $e) {
|
||||
return $this->failed($e->getMessage(), 'openai');
|
||||
}
|
||||
}
|
||||
|
||||
public static function redactSensitiveQuery(string $query): string
|
||||
{
|
||||
$query = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[email]', $query) ?? $query;
|
||||
$query = preg_replace('/\b\d{8}\b/', '[cvr]', $query) ?? $query;
|
||||
$query = preg_replace('/\b[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}\b/i', '[uuid]', $query) ?? $query;
|
||||
$query = preg_replace('/\+?\d[\d\s\-]{6,}\d/', '[phone]', $query) ?? $query;
|
||||
$query = preg_replace('/\b(order|invoice|booking|customer|kunde|faktura)\s*[#:\-]?\s*\d{4,}\b/iu', '$1 [id]', $query) ?? $query;
|
||||
$query = preg_replace('/\b(reg(?:istration)?|plate|license plate|nummerplade)\s*[#:\-]?\s*[a-z0-9\-]{4,10}\b/iu', '$1 [plate]', $query) ?? $query;
|
||||
$query = preg_replace('/\b[a-z]{2}\s?\d{5}\b/iu', '[plate]', $query) ?? $query;
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function resolveOpenAISettings(): array
|
||||
{
|
||||
if ($this->forcedEnabled !== null) {
|
||||
return [(bool)$this->forcedEnabled, (string)($this->forcedApiKey ?? '')];
|
||||
}
|
||||
|
||||
try {
|
||||
$openai = new openai();
|
||||
$enabled = (bool)$openai->config->enabled->getVariableValue();
|
||||
$apiKey = (string)$openai->config->api_key->getVariableValue();
|
||||
return [$enabled, $apiKey];
|
||||
} catch (Throwable) {
|
||||
return [false, ''];
|
||||
}
|
||||
}
|
||||
|
||||
private function buildPayload(string $query, array $allowedEntityTypes, array $taxonomy): array
|
||||
{
|
||||
$taxonomyText = json_encode([
|
||||
'allowed_entity_types' => array_values($allowedEntityTypes),
|
||||
'taxonomy' => $taxonomy,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$prompt = "You parse user search intent into strict JSON.\n"
|
||||
. "Rules:\n"
|
||||
. "- Keep output concise and valid JSON only.\n"
|
||||
. "- Do not invent entity types not listed in allowed_entity_types.\n"
|
||||
. "- Infer what the user is trying to find, not just literal words.\n"
|
||||
. "- aliases should contain user-friendly and backend-friendly equivalent terms.\n"
|
||||
. "- Include cross-language/domain synonyms when likely (example: Danish 'rabat' -> 'discount').\n"
|
||||
. "- If user references a customer/company by name, include hints that help find related invoices/orders/discounts.\n"
|
||||
. "- confidence must be between 0 and 1.\n"
|
||||
. "- association_hint should be true if related records likely needed.\n\n"
|
||||
. "Context:\n"
|
||||
. $taxonomyText . "\n\n"
|
||||
. "User query:\n"
|
||||
. $query;
|
||||
|
||||
return [
|
||||
'model' => $this->model,
|
||||
'temperature' => $this->temperature,
|
||||
'input' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => [
|
||||
['type' => 'input_text', 'text' => $prompt],
|
||||
],
|
||||
],
|
||||
],
|
||||
'text' => [
|
||||
'format' => [
|
||||
'type' => 'json_schema',
|
||||
'name' => 'system_search_intent',
|
||||
'schema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'success' => ['type' => 'boolean'],
|
||||
'normalized_query' => [
|
||||
'type' => 'string',
|
||||
'maxLength' => $this->maxNormalizedQueryLength,
|
||||
],
|
||||
'aliases' => [
|
||||
'type' => 'array',
|
||||
'maxItems' => $this->maxAliases,
|
||||
'items' => [
|
||||
'type' => 'string',
|
||||
'maxLength' => $this->maxAliasLength,
|
||||
],
|
||||
],
|
||||
'entity_hints' => [
|
||||
'type' => 'array',
|
||||
'maxItems' => $this->maxEntityHints,
|
||||
'items' => [
|
||||
'type' => 'string',
|
||||
'maxLength' => $this->maxHintLength,
|
||||
],
|
||||
],
|
||||
'confidence' => [
|
||||
'type' => 'number',
|
||||
'minimum' => 0,
|
||||
'maximum' => 1,
|
||||
],
|
||||
'association_hint' => ['type' => 'boolean'],
|
||||
'fallback_reason' => [
|
||||
'anyOf' => [
|
||||
['type' => 'string', 'maxLength' => $this->maxFallbackReasonLength],
|
||||
['type' => 'null'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'required' => [
|
||||
'success',
|
||||
'normalized_query',
|
||||
'aliases',
|
||||
'entity_hints',
|
||||
'confidence',
|
||||
'association_hint',
|
||||
'fallback_reason',
|
||||
],
|
||||
'additionalProperties' => false,
|
||||
],
|
||||
'strict' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function sendRequest(array $payload, string $apiKey): array
|
||||
{
|
||||
if ($this->transport !== null) {
|
||||
$result = call_user_func($this->transport, $payload, $apiKey);
|
||||
if (!is_array($result)) {
|
||||
throw new Exception('Transport returned invalid payload');
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
$curl = curl_init($this->apiUrl);
|
||||
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($curl, CURLOPT_POST, true);
|
||||
curl_setopt($curl, CURLOPT_TIMEOUT, $this->timeoutSeconds);
|
||||
curl_setopt($curl, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
]);
|
||||
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
$raw = curl_exec($curl);
|
||||
if ($raw === false) {
|
||||
$error = curl_error($curl);
|
||||
curl_close($curl);
|
||||
throw new Exception('cURL error: ' . $error);
|
||||
}
|
||||
$status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
curl_close($curl);
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new Exception('Invalid JSON from OpenAI');
|
||||
}
|
||||
if ($status >= 400) {
|
||||
$message = $decoded['error']['message'] ?? ('OpenAI HTTP ' . $status);
|
||||
throw new Exception($message);
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function parseResponse(array $response): array
|
||||
{
|
||||
$text = $response['output'][0]['content'][0]['text'] ?? null;
|
||||
if (!is_string($text) || $text === '') {
|
||||
throw new Exception('Invalid response format (missing output text)');
|
||||
}
|
||||
$decoded = json_decode($text, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new Exception('Invalid intent JSON');
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function normalizeResult(array $result, array $allowedEntityTypes = []): array
|
||||
{
|
||||
$normalizedQuery = trim((string)($result['normalized_query'] ?? ''));
|
||||
if (mb_strlen($normalizedQuery) > $this->maxNormalizedQueryLength) {
|
||||
$normalizedQuery = mb_substr($normalizedQuery, 0, $this->maxNormalizedQueryLength);
|
||||
}
|
||||
|
||||
$aliases = $this->sanitizeStringList((array)($result['aliases'] ?? []), $this->maxAliases, $this->maxAliasLength);
|
||||
$entityHints = $this->sanitizeStringList((array)($result['entity_hints'] ?? []), $this->maxEntityHints, $this->maxHintLength);
|
||||
if (!empty($allowedEntityTypes)) {
|
||||
$entityHints = array_values(array_intersect($allowedEntityTypes, $entityHints));
|
||||
}
|
||||
|
||||
$fallbackReason = null;
|
||||
if (isset($result['fallback_reason']) && $result['fallback_reason'] !== null) {
|
||||
$fallbackReason = trim((string)$result['fallback_reason']);
|
||||
if (mb_strlen($fallbackReason) > $this->maxFallbackReasonLength) {
|
||||
$fallbackReason = mb_substr($fallbackReason, 0, $this->maxFallbackReasonLength);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => (bool)($result['success'] ?? false),
|
||||
'normalized_query' => $normalizedQuery,
|
||||
'aliases' => $aliases,
|
||||
'entity_hints' => $entityHints,
|
||||
'confidence' => max(0.0, min(1.0, (float)($result['confidence'] ?? 0.0))),
|
||||
'association_hint' => (bool)($result['association_hint'] ?? false),
|
||||
'fallback_reason' => $fallbackReason,
|
||||
'source' => (string)($result['source'] ?? 'openai'),
|
||||
];
|
||||
}
|
||||
|
||||
private function sanitizeStringList(array $values, int $maxItems, int $maxLength): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($values as $value) {
|
||||
if (!is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
$item = trim(mb_strtolower($value));
|
||||
if ($item === '') {
|
||||
continue;
|
||||
}
|
||||
if (mb_strlen($item) > $maxLength) {
|
||||
$item = mb_substr($item, 0, $maxLength);
|
||||
}
|
||||
if (!in_array($item, $result, true)) {
|
||||
$result[] = $item;
|
||||
}
|
||||
if (count($result) >= $maxItems) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function failed(string $reason, string $source): array
|
||||
{
|
||||
return [
|
||||
'success' => false,
|
||||
'normalized_query' => '',
|
||||
'aliases' => [],
|
||||
'entity_hints' => [],
|
||||
'confidence' => 0.0,
|
||||
'association_hint' => false,
|
||||
'fallback_reason' => $reason,
|
||||
'source' => $source,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
namespace classes;
|
||||
|
||||
use interfaces\system_search_intent_parser_i;
|
||||
use Throwable;
|
||||
|
||||
class system_search_service
|
||||
{
|
||||
private system_search_intent_parser_i $intentParser;
|
||||
private int $lowConfidenceResultThreshold = 5;
|
||||
private int $lowConfidenceTopScoreThreshold = 60;
|
||||
private int $defaultEntityFetchLimit = 200;
|
||||
private int $defaultMaxResults = 50;
|
||||
private int $maxExpandedTerms = 24;
|
||||
private int $maxTermLength = 64;
|
||||
private int $recencyScoreTolerance = 12;
|
||||
private int $minimumEffectiveScore = 45;
|
||||
private int $minimumExplicitTypeScore = 20;
|
||||
private int $associationSeedScoreThreshold = 80;
|
||||
private array $rankingBoostByType = [
|
||||
'invoices' => 35,
|
||||
'orders' => 35,
|
||||
@@ -35,9 +35,8 @@ class system_search_service
|
||||
private array $tableColumnsCache = [];
|
||||
private array $customerContextCache = [];
|
||||
|
||||
public function __construct(?system_search_intent_parser_i $intentParser = null)
|
||||
public function __construct()
|
||||
{
|
||||
$this->intentParser = $intentParser ?? new system_search_openai_intent_parser();
|
||||
try {
|
||||
system_search_economic_customer_index::ensureTable();
|
||||
system_search_document_index::ensureTable();
|
||||
@@ -59,18 +58,13 @@ class system_search_service
|
||||
$permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []);
|
||||
$moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []);
|
||||
$includeAssociations = (bool)($options['include_associations'] ?? true);
|
||||
$debugIntent = (bool)($options['debug_intent'] ?? false);
|
||||
|
||||
$limit = (int)($options['limit'] ?? 50);
|
||||
$offset = (int)($options['offset'] ?? 0);
|
||||
if ($limit < 1) {
|
||||
$limit = 50;
|
||||
$maxResults = (int)($options['max_results'] ?? $this->defaultMaxResults);
|
||||
if ($maxResults < 1) {
|
||||
$maxResults = $this->defaultMaxResults;
|
||||
}
|
||||
if ($limit > 200) {
|
||||
$limit = 200;
|
||||
}
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
if ($maxResults > $this->defaultMaxResults) {
|
||||
$maxResults = $this->defaultMaxResults;
|
||||
}
|
||||
|
||||
$allTypes = $this->allEntityTypes();
|
||||
@@ -79,11 +73,12 @@ class system_search_service
|
||||
$activeTypes = array_values(array_diff($activeTypes, $excludeTypes));
|
||||
}
|
||||
$activeTypes = array_values(array_intersect($activeTypes, $allowedTypes));
|
||||
$terms = $this->buildExpandedTerms($this->tokenize($query));
|
||||
$activeTypes = $this->selectSearchTypes($activeTypes, $includeTypes, $terms, $query);
|
||||
|
||||
$baseMeta = [
|
||||
'query' => $query,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'max_results' => $maxResults,
|
||||
'allowed_types' => $activeTypes,
|
||||
'cache' => ['hit' => false],
|
||||
];
|
||||
@@ -94,7 +89,8 @@ class system_search_service
|
||||
'grouped_results' => $this->groupResultsByType([]),
|
||||
'meta' => [
|
||||
...$baseMeta,
|
||||
'total' => 0,
|
||||
'returned' => 0,
|
||||
'truncated' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -104,16 +100,14 @@ class system_search_service
|
||||
'include' => $includeTypes,
|
||||
'exclude' => $excludeTypes,
|
||||
'active' => $activeTypes,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'max' => $maxResults,
|
||||
'own' => $ownCustomerNumber,
|
||||
'own_only' => $ownOnlyTypes,
|
||||
'dept' => $allowedDepartmentIds,
|
||||
'assoc' => $includeAssociations,
|
||||
'dbg' => $debugIntent,
|
||||
'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility),
|
||||
'table_versions' => system_search_cache::tableVersionFingerprint($this->relevantSourceTables($activeTypes)),
|
||||
'v' => 12,
|
||||
'v' => 13,
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$cached = system_search_cache::getQuery($queryCacheHash);
|
||||
@@ -122,7 +116,6 @@ class system_search_service
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$terms = $this->buildExpandedTerms($this->tokenize($query));
|
||||
$entityBoost = [];
|
||||
$initialResults = $this->executeLexicalSearch(
|
||||
$activeTypes,
|
||||
@@ -135,76 +128,23 @@ class system_search_service
|
||||
$moduleConfigVisibility,
|
||||
$allowedDepartmentIds
|
||||
);
|
||||
|
||||
$intentAssociationHint = false;
|
||||
$intentMeta = [
|
||||
'invoked' => false,
|
||||
'source' => 'none',
|
||||
'status' => 'skipped',
|
||||
'confidence' => 0.0,
|
||||
'expanded_terms' => $terms,
|
||||
'entity_hints' => [],
|
||||
'fallback_reason' => null,
|
||||
];
|
||||
|
||||
$shouldInvokeIntent = !empty($terms) && (
|
||||
$this->shouldInvokeIntentParser($initialResults)
|
||||
|| $this->queryLooksIntentDriven($query, $terms)
|
||||
);
|
||||
if ($shouldInvokeIntent) {
|
||||
$intentMeta['invoked'] = true;
|
||||
$taxonomy = $this->taxonomy($activeTypes);
|
||||
$intent = $this->intentParser->parse($query, $activeTypes, $taxonomy);
|
||||
$intentMeta['source'] = (string)($intent['source'] ?? 'none');
|
||||
$intentMeta['confidence'] = (float)($intent['confidence'] ?? 0.0);
|
||||
$intentMeta['fallback_reason'] = $intent['fallback_reason'] ?? null;
|
||||
$intentMeta['entity_hints'] = (array)($intent['entity_hints'] ?? []);
|
||||
$intentAssociationHint = (bool)($intent['association_hint'] ?? false);
|
||||
|
||||
if (!empty($intent['success'])) {
|
||||
$intentMeta['status'] = 'ok';
|
||||
$boostedTypes = array_values(array_intersect($activeTypes, (array)($intent['entity_hints'] ?? [])));
|
||||
foreach ($boostedTypes as $boostedType) {
|
||||
$entityBoost[$boostedType] = 25;
|
||||
}
|
||||
$expandedTerms = $this->buildExpandedTerms([
|
||||
...$terms,
|
||||
...$this->tokenize((string)($intent['normalized_query'] ?? '')),
|
||||
...$this->tokenize(implode(' ', (array)($intent['aliases'] ?? []))),
|
||||
...$this->hintAliasTerms($boostedTypes, $taxonomy),
|
||||
]);
|
||||
$intentMeta['expanded_terms'] = $expandedTerms;
|
||||
|
||||
$initialResults = $this->executeLexicalSearch(
|
||||
$activeTypes,
|
||||
$expandedTerms,
|
||||
$entityBoost,
|
||||
$ownOnlyTypes,
|
||||
$ownCustomerNumber,
|
||||
$permissionsCatalogAll,
|
||||
$permissionsCatalogOwn,
|
||||
$moduleConfigVisibility,
|
||||
$allowedDepartmentIds
|
||||
);
|
||||
} else {
|
||||
$intentMeta['status'] = 'fallback';
|
||||
}
|
||||
}
|
||||
$initialResults = $this->filterRelevantResults($initialResults, $includeTypes);
|
||||
|
||||
if ($includeAssociations) {
|
||||
$customerNumbers = [];
|
||||
foreach ($initialResults as $result) {
|
||||
if (!isset($result['customer_number'])) {
|
||||
$customerNumber = $this->toIntOrNull($result['customer_number'] ?? null);
|
||||
if ($customerNumber === null || $customerNumber <= 0) {
|
||||
continue;
|
||||
}
|
||||
if ($result['entity_type'] !== 'customers' && !$intentAssociationHint) {
|
||||
if (!$this->shouldExpandAssociationsFromResult($result, $includeTypes)) {
|
||||
continue;
|
||||
}
|
||||
$customerNumbers[] = (int)$result['customer_number'];
|
||||
$customerNumbers[] = $customerNumber;
|
||||
}
|
||||
$customerNumbers = array_values(array_unique(array_filter($customerNumbers)));
|
||||
if (count($customerNumbers) > 15) {
|
||||
$customerNumbers = array_slice($customerNumbers, 0, 15);
|
||||
if (count($customerNumbers) > 5) {
|
||||
$customerNumbers = array_slice($customerNumbers, 0, 5);
|
||||
}
|
||||
if (!empty($customerNumbers)) {
|
||||
$associationTypes = array_values(array_intersect(
|
||||
@@ -232,18 +172,21 @@ class system_search_service
|
||||
}
|
||||
$item['score'] = max((int)$item['score'], 35);
|
||||
}
|
||||
unset($item);
|
||||
$associated = $this->filterRelevantResults($associated, $includeTypes);
|
||||
$initialResults = $this->mergeResults($initialResults, $associated);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$initialResults = $this->filterRelevantResults($initialResults, $includeTypes);
|
||||
|
||||
$preferRecency = $this->shouldPreferRecencySort($query, $terms);
|
||||
usort($initialResults, function (array $a, array $b) use ($preferRecency): int {
|
||||
$scoreA = (int)($a['score'] ?? 0);
|
||||
$scoreB = (int)($b['score'] ?? 0);
|
||||
$effectiveScoreA = $scoreA + $this->rankingBoost($a) - $this->rankingPenalty($a);
|
||||
$effectiveScoreB = $scoreB + $this->rankingBoost($b) - $this->rankingPenalty($b);
|
||||
$effectiveScoreA = $this->effectiveResultScore($a);
|
||||
$effectiveScoreB = $this->effectiveResultScore($b);
|
||||
$recencyA = $this->resultRecencyTimestamp($a);
|
||||
$recencyB = $this->resultRecencyTimestamp($b);
|
||||
$cancelledA = $this->isCancelledBookingResult($a);
|
||||
@@ -272,20 +215,17 @@ class system_search_service
|
||||
return strcmp((string)$a['entity_type'] . ':' . (string)$a['entity_id'], (string)$b['entity_type'] . ':' . (string)$b['entity_id']);
|
||||
});
|
||||
|
||||
$total = count($initialResults);
|
||||
$paged = array_slice($initialResults, $offset, $limit);
|
||||
$grouped = $this->groupResultsByType($paged);
|
||||
$limited = array_slice($initialResults, 0, $maxResults);
|
||||
$grouped = $this->groupResultsByType($limited);
|
||||
|
||||
$meta = [
|
||||
...$baseMeta,
|
||||
'total' => $total,
|
||||
'returned' => count($limited),
|
||||
'truncated' => count($initialResults) > count($limited),
|
||||
];
|
||||
if ($debugIntent) {
|
||||
$meta['intent_parser'] = $intentMeta;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'results' => $paged,
|
||||
'results' => $limited,
|
||||
'grouped_results' => $grouped,
|
||||
'meta' => $meta,
|
||||
];
|
||||
@@ -294,13 +234,127 @@ class system_search_service
|
||||
return $payload;
|
||||
}
|
||||
|
||||
protected function shouldInvokeIntentParser(array $results): bool
|
||||
/**
|
||||
* @param array<int, string> $activeTypes
|
||||
* @param array<int, string> $includeTypes
|
||||
* @param array<int, string> $terms
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function selectSearchTypes(array $activeTypes, array $includeTypes, array $terms, string $query): array
|
||||
{
|
||||
if (count($results) < $this->lowConfidenceResultThreshold) {
|
||||
return true;
|
||||
if (empty($activeTypes) || !empty($includeTypes)) {
|
||||
return $activeTypes;
|
||||
}
|
||||
$topScore = (int)($results[0]['score'] ?? 0);
|
||||
return $topScore < $this->lowConfidenceTopScoreThreshold;
|
||||
|
||||
$selected = array_values(array_intersect($activeTypes, $this->defaultSearchEntityTypes()));
|
||||
foreach ($activeTypes as $entityType) {
|
||||
if (in_array($entityType, $selected, true)) {
|
||||
continue;
|
||||
}
|
||||
if ($this->entityTypeMatchesQuery($entityType, $terms, $query)) {
|
||||
$selected[] = $entityType;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_intersect($activeTypes, array_values(array_unique($selected))));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function defaultSearchEntityTypes(): array
|
||||
{
|
||||
return [
|
||||
'customers',
|
||||
'users',
|
||||
'employees',
|
||||
'orders',
|
||||
'order_bookings',
|
||||
'bookings',
|
||||
'bookings_new',
|
||||
'invoices',
|
||||
'vehicles',
|
||||
'departments',
|
||||
'products',
|
||||
'objects',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $terms
|
||||
*/
|
||||
private function entityTypeMatchesQuery(string $entityType, array $terms, string $query): bool
|
||||
{
|
||||
$normalizedQuery = ' ' . trim(mb_strtolower($query)) . ' ';
|
||||
if (trim($normalizedQuery) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$aliases = system_search_registry::taxonomyAliases()[$entityType] ?? [];
|
||||
$human = str_replace('_', ' ', $entityType);
|
||||
$aliases[] = $entityType;
|
||||
$aliases[] = $human;
|
||||
$aliases[] = rtrim($human, 's');
|
||||
$aliases = array_values(array_unique(array_filter($aliases, static fn($alias) => is_string($alias) && trim($alias) !== '')));
|
||||
|
||||
foreach ($aliases as $alias) {
|
||||
$aliasTerms = $this->tokenize($alias);
|
||||
if (empty($aliasTerms)) {
|
||||
continue;
|
||||
}
|
||||
if (count($aliasTerms) === 1 && in_array($aliasTerms[0], $terms, true)) {
|
||||
return true;
|
||||
}
|
||||
if (count($aliasTerms) > 1 && empty(array_diff($aliasTerms, $terms))) {
|
||||
return true;
|
||||
}
|
||||
$aliasText = trim(mb_strtolower($alias));
|
||||
if ($aliasText !== '' && str_contains($normalizedQuery, ' ' . $aliasText . ' ')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $results
|
||||
* @param array<int, string> $includeTypes
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function filterRelevantResults(array $results, array $includeTypes): array
|
||||
{
|
||||
$explicitTypes = array_flip($includeTypes);
|
||||
$filtered = [];
|
||||
foreach ($results as $result) {
|
||||
$entityType = (string)($result['entity_type'] ?? '');
|
||||
$score = (int)($result['score'] ?? 0);
|
||||
if ($score <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (isset($explicitTypes[$entityType])) {
|
||||
if ($score >= $this->minimumExplicitTypeScore) {
|
||||
$filtered[] = $result;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($this->effectiveResultScore($result) >= $this->minimumEffectiveScore) {
|
||||
$filtered[] = $result;
|
||||
}
|
||||
}
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
private function shouldExpandAssociationsFromResult(array $result, array $includeTypes): bool
|
||||
{
|
||||
$entityType = trim(mb_strtolower((string)($result['entity_type'] ?? '')));
|
||||
if (!in_array($entityType, ['customers', 'users'], true)) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($includeTypes) && !in_array($entityType, $includeTypes, true)) {
|
||||
return false;
|
||||
}
|
||||
return $this->effectiveResultScore($result) >= $this->associationSeedScoreThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -347,20 +401,6 @@ class system_search_service
|
||||
$allowedDepartmentIds,
|
||||
$forcedCustomerNumbers
|
||||
);
|
||||
if (empty($rows)) {
|
||||
$rows = $this->searchEntity(
|
||||
$entityType,
|
||||
$terms,
|
||||
$boost,
|
||||
$ownOnly,
|
||||
$ownCustomerNumber,
|
||||
$permissionsCatalogAll,
|
||||
$permissionsCatalogOwn,
|
||||
$moduleConfigVisibility,
|
||||
$allowedDepartmentIds,
|
||||
$forcedCustomerNumbers
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$rows = $this->searchEntity(
|
||||
$entityType,
|
||||
@@ -1086,6 +1126,7 @@ class system_search_service
|
||||
|
||||
private function searchCustomerDiscounts(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber, array $forcedCustomerNumbers): array
|
||||
{
|
||||
price_overrides_schema_bootstrap::ensureColumns();
|
||||
$customerFilter = '';
|
||||
if (!empty($forcedCustomerNumbers)) {
|
||||
$customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $forcedCustomerNumbers)) . ')';
|
||||
@@ -1100,11 +1141,12 @@ class system_search_service
|
||||
'po.is_category',
|
||||
'po.product_or_category_id',
|
||||
'po.percentage',
|
||||
'po.fixed_price',
|
||||
'u.customer_number',
|
||||
'u.display_name',
|
||||
...$this->joinTemporalSelectFields('price_overrides', 'po'),
|
||||
];
|
||||
$searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'u.customer_number', 'u.display_name'];
|
||||
$searchFields = ['po.id', 'po.user_id', 'po.product_or_category_id', 'po.percentage', 'po.fixed_price', 'u.customer_number', 'u.display_name'];
|
||||
|
||||
if ($this->isEconomicCustomerIndexAvailable()) {
|
||||
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
|
||||
@@ -1166,6 +1208,7 @@ class system_search_service
|
||||
'search_text',
|
||||
'product_or_category_id',
|
||||
'percentage',
|
||||
'fixed_price',
|
||||
'user_id',
|
||||
], $terms) + $entityBoost,
|
||||
'payload' => $this->augmentPayloadWithTemporal([
|
||||
@@ -1173,6 +1216,7 @@ class system_search_service
|
||||
'customer_number' => isset($row['customer_number']) ? (int)$row['customer_number'] : null,
|
||||
'product_or_category_id' => $row['product_or_category_id'] ?? null,
|
||||
'percentage' => isset($row['percentage']) ? (int)$row['percentage'] : null,
|
||||
'fixed_price' => isset($row['fixed_price']) ? (int)$row['fixed_price'] : null,
|
||||
'economic_name' => $row['economic_name'] ?? null,
|
||||
'economic_cvr' => $row['economic_cvr'] ?? null,
|
||||
], $row),
|
||||
@@ -2643,6 +2687,11 @@ class system_search_service
|
||||
return (int)($this->rankingPenaltyByType[$entityType] ?? 0);
|
||||
}
|
||||
|
||||
private function effectiveResultScore(array $result): int
|
||||
{
|
||||
return (int)($result['score'] ?? 0) + $this->rankingBoost($result) - $this->rankingPenalty($result);
|
||||
}
|
||||
|
||||
private function boolishTrue(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
@@ -2702,7 +2751,7 @@ class system_search_service
|
||||
return false;
|
||||
}
|
||||
|
||||
// Explicit identifiers (order numbers, customer numbers, emails, etc.) imply exact intent.
|
||||
// Explicit identifiers (order numbers, customer numbers, emails, etc.) imply exact matches.
|
||||
if ($this->queryHasExplicitIdentifier($normalized)) {
|
||||
return false;
|
||||
}
|
||||
@@ -2725,68 +2774,6 @@ class system_search_service
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $boostedTypes
|
||||
* @param array<string, array<int, string>> $taxonomy
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function hintAliasTerms(array $boostedTypes, array $taxonomy): array
|
||||
{
|
||||
$terms = [];
|
||||
foreach ($boostedTypes as $type) {
|
||||
$aliases = $taxonomy[$type] ?? [];
|
||||
if (!is_array($aliases)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($aliases as $alias) {
|
||||
if (!is_string($alias) || trim($alias) === '') {
|
||||
continue;
|
||||
}
|
||||
$terms = [...$terms, ...$this->tokenize($alias)];
|
||||
if (count($terms) >= 12) {
|
||||
return array_slice(array_values(array_unique($terms)), 0, 12);
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_slice(array_values(array_unique($terms)), 0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect natural-language style queries where intent parsing is valuable
|
||||
* even when lexical score looks strong.
|
||||
*
|
||||
* @param array<int, string> $terms
|
||||
*/
|
||||
private function queryLooksIntentDriven(string $query, array $terms): bool
|
||||
{
|
||||
$normalized = trim(mb_strtolower($query));
|
||||
if ($normalized === '' || count($terms) < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->queryHasExplicitIdentifier($normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hasIntentVerb = preg_match('/\b(find|show|search|looking|need|want|where|which)\b/iu', $normalized) === 1;
|
||||
$hasRelationalLanguage = preg_match('/\b(with|without|from|between|for|unpaid|overdue|rabat|discount|faktura|invoice|kunde|customer|orders?|vehicles?)\b/iu', $normalized) === 1;
|
||||
$hasStrongDomainLanguage = preg_match('/\b(unpaid|overdue|rabat|discount|faktura|invoice)\b/iu', $normalized) === 1;
|
||||
|
||||
if ($hasStrongDomainLanguage && count($terms) >= 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($hasIntentVerb && count($terms) >= 3) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($hasRelationalLanguage && count($terms) >= 3 && mb_strlen($normalized) >= 16) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return mb_strlen($normalized) >= 28 && count($terms) >= 4;
|
||||
}
|
||||
|
||||
private function queryHasExplicitIdentifier(string $normalizedQuery): bool
|
||||
{
|
||||
if ($normalizedQuery === '') {
|
||||
@@ -2981,22 +2968,6 @@ class system_search_service
|
||||
return system_search_registry::allEntityTypes();
|
||||
}
|
||||
|
||||
private function taxonomy(array $activeTypes): array
|
||||
{
|
||||
$aliases = system_search_registry::taxonomyAliases();
|
||||
$taxonomy = [];
|
||||
foreach ($activeTypes as $type) {
|
||||
$resolved = $aliases[$type] ?? [];
|
||||
if (empty($resolved)) {
|
||||
$human = str_replace('_', ' ', $type);
|
||||
$singular = rtrim($human, 's');
|
||||
$resolved = array_values(array_unique(array_filter([$human, $singular], static fn($v) => is_string($v) && $v !== '')));
|
||||
}
|
||||
$taxonomy[$type] = $resolved;
|
||||
}
|
||||
return $taxonomy;
|
||||
}
|
||||
|
||||
private function groupResultsByType(array $results): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
@@ -72,6 +72,8 @@ class virkdata implements virkdata_i
|
||||
self::requireModuleEnabled();
|
||||
// Validate the secret key
|
||||
self::requireValidSecretKey();
|
||||
// VirkData has a monthly limit config, but defaults to observe mode until explicitly switched to block.
|
||||
$this->reserveCompanySearchQuota($search, $endpoint, $method);
|
||||
// Send the request
|
||||
$response = match ($method) {
|
||||
'GET' => self::sendGetRequest($search, $endpoint, $data),
|
||||
@@ -128,6 +130,18 @@ class virkdata implements virkdata_i
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function reserveCompanySearchQuota(string $search, string $endpoint, string $method): void
|
||||
{
|
||||
(new module_usage_service())->reserveOrFail('virkdata', 'company_search_calls', 1, [
|
||||
'search' => $search,
|
||||
'endpoint' => $endpoint,
|
||||
'method' => strtoupper($method),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -235,4 +249,4 @@ class virkdata implements virkdata_i
|
||||
{
|
||||
return $this->sendRequest($query, 'search', [], 'GET');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +291,12 @@ class xlvask_automation_service
|
||||
return array_reduce($items, fn(int $total, array $item): int => $total + ((int)($item['price'] ?? 0) * (int)($item['quantity'] ?? 0)), 0);
|
||||
}
|
||||
|
||||
public static function isExactItemMatchForAutomation(array $usageItems, array $orderItems): bool
|
||||
{
|
||||
return self::itemSignaturePartsForAutomation($usageItems) === self::itemSignaturePartsForAutomation($orderItems)
|
||||
&& self::itemsTotalForAutomation($usageItems) === self::itemsTotalForAutomation($orderItems);
|
||||
}
|
||||
|
||||
public static function productOverlapForAutomation(array $usageItems, array $orderItems): float
|
||||
{
|
||||
$usageBag = self::productBagForAutomation($usageItems);
|
||||
@@ -600,18 +606,52 @@ class xlvask_automation_service
|
||||
|
||||
if ($action === self::ACTION_ATTACH) {
|
||||
return $xlvask->config->automatic_order_attachment_enabled->isTrue()
|
||||
&& $confidence >= self::AUTO_ATTACH_CONFIDENCE;
|
||||
&& $confidence >= self::AUTO_ATTACH_CONFIDENCE
|
||||
&& $this->isExactAttachSuggestionForContext($suggestion, $context);
|
||||
}
|
||||
|
||||
if ($action === self::ACTION_CREATE) {
|
||||
return $xlvask->config->automatic_order_creation_enabled->isTrue()
|
||||
&& $context['age_hours'] >= self::CREATE_MIN_AGE_HOURS
|
||||
&& $confidence >= self::AUTO_CREATE_CONFIDENCE;
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isExactAttachSuggestionForContext(array $suggestion, array $context): bool
|
||||
{
|
||||
if ((string)($suggestion['action'] ?? '') !== self::ACTION_ATTACH) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$matchedOrderId = (int)($suggestion['matched_order_id'] ?? 0);
|
||||
$candidateOrder = $this->candidateOrderFromSuggestion($suggestion);
|
||||
if ($matchedOrderId < 1 || !is_array($candidateOrder) || (int)($candidateOrder['id'] ?? 0) !== $matchedOrderId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$usageItems = $context['items'] ?? [];
|
||||
$orderItems = $candidateOrder['order_items'] ?? [];
|
||||
return is_array($usageItems)
|
||||
&& is_array($orderItems)
|
||||
&& self::isExactItemMatchForAutomation($usageItems, $orderItems);
|
||||
}
|
||||
|
||||
private function candidateOrderFromSuggestion(array $suggestion): ?array
|
||||
{
|
||||
$candidateOrder = $suggestion['candidate_order'] ?? null;
|
||||
if (is_array($candidateOrder)) {
|
||||
return $candidateOrder;
|
||||
}
|
||||
|
||||
$candidateOrderJson = $suggestion['candidate_order_json'] ?? null;
|
||||
if (!is_string($candidateOrderJson) || trim($candidateOrderJson) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($candidateOrderJson, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
private function executeSuggestion(array $suggestion, array $context, ?int $actorId, bool $automatic): array
|
||||
{
|
||||
try {
|
||||
@@ -646,7 +686,7 @@ class xlvask_automation_service
|
||||
|
||||
$latest = $this->loadSuggestion((int)$suggestion['id']) ?? $suggestion;
|
||||
if ($automatic) {
|
||||
$this->persistFeedback($context, $action, 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, 'Automatisk accepteret.');
|
||||
$this->persistFeedback($context, $action, 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, $this->automaticFeedbackReason($suggestion, $context));
|
||||
}
|
||||
|
||||
return $this->formatSuggestion($latest);
|
||||
@@ -660,6 +700,15 @@ class xlvask_automation_service
|
||||
}
|
||||
}
|
||||
|
||||
private function automaticFeedbackReason(array $suggestion, array $context): string
|
||||
{
|
||||
if ($this->isExactAttachSuggestionForContext($suggestion, $context)) {
|
||||
return 'Automatisk accepteret: Prisoverensstemmelse.';
|
||||
}
|
||||
|
||||
return 'Automatisk accepteret.';
|
||||
}
|
||||
|
||||
private function createOrderFromContext(array $context): orders_o
|
||||
{
|
||||
$orderData = $context['proposed_order'];
|
||||
@@ -1246,19 +1295,20 @@ class xlvask_automation_service
|
||||
{
|
||||
global $db;
|
||||
(new xlvask_usage_logs_o())->structure();
|
||||
$startTimeExpression = "STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')";
|
||||
$where = [
|
||||
'FinishStatus = 1',
|
||||
'(ignored_at IS NULL OR ignored_at = "")',
|
||||
];
|
||||
|
||||
if ($dateFrom !== null && strtotime($dateFrom) !== false) {
|
||||
$where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'";
|
||||
$where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'";
|
||||
} else {
|
||||
$where[] = "StartTime >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'";
|
||||
$where[] = "{$startTimeExpression} >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'";
|
||||
}
|
||||
|
||||
if ($dateTo !== null && strtotime($dateTo) !== false) {
|
||||
$where[] = "StartTime <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
|
||||
$where[] = "{$startTimeExpression} <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
|
||||
}
|
||||
|
||||
$limit = max(1, min(500, $limit));
|
||||
|
||||
@@ -102,6 +102,10 @@ if ($args[1] === 'run') {
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] Running the cron script\n";
|
||||
require_once 'cron/Cron.php';
|
||||
break;
|
||||
case 'cron-worker':
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON_WORKER] Starting cron worker\n";
|
||||
(new \classes\cron_worker())->run();
|
||||
break;
|
||||
default:
|
||||
echo "Invalid script name";
|
||||
break;
|
||||
|
||||
@@ -39,8 +39,11 @@ $htaccess = "
|
||||
# php -- END cPanel-generated handler, do not edit
|
||||
";
|
||||
|
||||
// Write the .htaccess file (This is not really ideal, but it works for now. This is because the .htaccess file is changed by cPanel, and it's not going to be kept there anyway.)
|
||||
file_put_contents(__DIR__ . '/.htaccess', $htaccess);
|
||||
// Keep the compatibility rewrite file stable without rewriting it every minute.
|
||||
$htaccessPath = __DIR__ . '/.htaccess';
|
||||
if (!is_file($htaccessPath) || file_get_contents($htaccessPath) !== $htaccess) {
|
||||
file_put_contents($htaccessPath, $htaccess);
|
||||
}
|
||||
|
||||
// Log the time of the cron job
|
||||
file_put_contents(__DIR__ . '/cron.log', date('Y-m-d H:i:s', $now) . ' - Cron job executed in ' . (time() - $now) . ' seconds ( ' . (microtime(true) - $now) . 'ms )' . PHP_EOL, FILE_APPEND);
|
||||
|
||||
@@ -8,6 +8,7 @@ use classes\invoice_period_flag_service;
|
||||
use classes\coolify_manager;
|
||||
use classes\replication_manager;
|
||||
use classes\redis;
|
||||
use classes\selfserve;
|
||||
use classes\system_search_cache;
|
||||
use classes\system_search_document_index;
|
||||
use classes\system_search_economic_customer_index;
|
||||
@@ -39,7 +40,14 @@ require_once __DIR__ . '/../classes/economic_transfer_executor.php';
|
||||
const DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH = 1600;
|
||||
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
|
||||
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
|
||||
require_once __DIR__ . '/../classes/backup_schema_bootstrap.php';
|
||||
require_once __DIR__ . '/../classes/backup_store.php';
|
||||
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
|
||||
require_once __DIR__ . '/../classes/cron_schedule.php';
|
||||
require_once __DIR__ . '/../classes/cron_task_definition.php';
|
||||
require_once __DIR__ . '/../classes/cron_task_registry.php';
|
||||
require_once __DIR__ . '/../classes/cron_schema_bootstrap.php';
|
||||
require_once __DIR__ . '/../classes/cron_scheduler.php';
|
||||
|
||||
if (!defined('WD')) {
|
||||
exit;
|
||||
@@ -532,12 +540,34 @@ function backup(): void
|
||||
{
|
||||
try {
|
||||
$backup = new backup_store();
|
||||
$backup->createBackup();
|
||||
$backup->enqueueCreateBackup(null, null, null, 'scheduled');
|
||||
} catch (Exception $e) {
|
||||
warn('Backup failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function processBackupJobs(): array
|
||||
{
|
||||
try {
|
||||
return (new backup_store())->processPendingJobs(3);
|
||||
} catch (Exception $e) {
|
||||
warn('Backup job processing failed: ' . $e->getMessage());
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
function pruneBackupRetention(): array
|
||||
{
|
||||
try {
|
||||
$backup = new backup_store();
|
||||
$queued = $backup->enqueueRetentionPrune();
|
||||
return $backup->runJobById((int)$queued['job_id']);
|
||||
} catch (Exception $e) {
|
||||
warn('Backup retention prune failed: ' . $e->getMessage());
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
function SyncUserEconomicCustomerDiscounts(): void
|
||||
{
|
||||
$users_o = new users_o();
|
||||
@@ -652,7 +682,6 @@ function SystemSearchCacheMaintenanceCron(): void
|
||||
|
||||
if ($rebuildRequest !== null) {
|
||||
system_search_cache::clearQueryCaches();
|
||||
system_search_cache::clearIntentCaches();
|
||||
$scope = (string)($rebuildRequest['scope'] ?? 'all');
|
||||
$types = array_values(array_filter(array_map('strval', (array)($rebuildRequest['types'] ?? []))));
|
||||
if ($scope === 'types' && !empty($types)) {
|
||||
@@ -1470,17 +1499,292 @@ function goalsProgressAlertDue(goals_criteria $criteria, DateTimeImmutable $nowU
|
||||
}
|
||||
}
|
||||
|
||||
foreach ( $cron_tasks as $task => $data ) {
|
||||
$lastRun = redis->get_last_crond_run($task) === null ? 0 : redis->get_last_crond_run($task);
|
||||
$nextRun = $lastRun + $data['interval'];
|
||||
$cron_tasks[$task]['last_run'] = $lastRun;
|
||||
$cron_tasks[$task]['next_run'] = $nextRun;
|
||||
if ($nextRun <= time()) {
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] Running task: " . $task . "\n";
|
||||
$data['function']();
|
||||
redis->set_last_crond_run($task, time());
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] Completed task: " . $task . "\n";
|
||||
} else {
|
||||
$response_cron[] = $task . ' is not due to run yet, next run is at ' . date('Y-m-d H:i:s', $nextRun) . ' (' . ($nextRun - time()) . ' seconds)';
|
||||
function SelfserveOpeningRelayActivationCron(): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (!($db instanceof \classes\db)) {
|
||||
warn('SelfserveOpeningRelayActivationCron skipped: database connection is unavailable.');
|
||||
return [
|
||||
'skipped' => true,
|
||||
'reason' => 'database_unavailable',
|
||||
];
|
||||
}
|
||||
|
||||
$now = new DateTimeImmutable('now', new DateTimeZone('Europe/Copenhagen'));
|
||||
$candidates = selfserveOpeningCleanerRelayActivationCandidates($now);
|
||||
$summary = [
|
||||
'checked_departments' => count($candidates),
|
||||
'activated_departments' => 0,
|
||||
'skipped_departments' => 0,
|
||||
'failed_departments' => 0,
|
||||
'activated_relays' => 0,
|
||||
'skipped_relays' => 0,
|
||||
'failed_relays' => 0,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$departmentId = (int)($candidate['department_id'] ?? 0);
|
||||
$opensAt = (string)($candidate['opens_at'] ?? '');
|
||||
if ($departmentId <= 0 || $opensAt === '') {
|
||||
$summary['skipped_departments']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$cacheKey = selfserveOpeningCleanerRelayActivationKey($departmentId, $opensAt, $now);
|
||||
if (selfserveOpeningCleanerRelayActivationAlreadyHandled($cacheKey)) {
|
||||
$summary['skipped_departments']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$departmentSummary = selfserveActivateOpeningCleanerRelaysForDepartment($departmentId);
|
||||
$summary['activated_relays'] += (int)$departmentSummary['activated'];
|
||||
$summary['skipped_relays'] += (int)$departmentSummary['skipped'];
|
||||
$summary['failed_relays'] += (int)$departmentSummary['failed'];
|
||||
|
||||
if ((int)$departmentSummary['failed'] > 0) {
|
||||
$summary['failed_departments']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
selfserveMarkOpeningCleanerRelayActivationHandled($cacheKey);
|
||||
if ((int)$departmentSummary['activated'] > 0) {
|
||||
$summary['activated_departments']++;
|
||||
} else {
|
||||
$summary['skipped_departments']++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] SelfserveOpeningRelayActivationCron: "
|
||||
. $summary['activated_relays'] . " cleaner relays activated across "
|
||||
. $summary['activated_departments'] . " departments.\n";
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
function selfserveOpeningCleanerRelayActivationCandidates(DateTimeImmutable $now): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$weekday = strtolower($now->format('l'));
|
||||
$allowedWeekdays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||
if (!in_array($weekday, $allowedWeekdays, true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$startColumn = $weekday . '_start';
|
||||
$endColumn = $weekday . '_end';
|
||||
$sql = "
|
||||
SELECT
|
||||
oh.department AS department_id,
|
||||
TIME_FORMAT(oh.`$startColumn`, '%H:%i:%s') AS opens_at,
|
||||
TIME_FORMAT(oh.`$endColumn`, '%H:%i:%s') AS closes_at
|
||||
FROM department_time_bookings_opening_hours oh
|
||||
INNER JOIN department_variables dv ON dv.department_id = oh.department
|
||||
INNER JOIN department_lanes dl ON dl.department = oh.department
|
||||
WHERE dv.variable = 'selfserve_enabled'
|
||||
AND LOWER(TRIM(COALESCE(dv.value, ''))) IN ('true', '1', 'yes', 'on')
|
||||
AND oh.`$startColumn` IS NOT NULL
|
||||
AND oh.`$endColumn` IS NOT NULL
|
||||
AND dl.deleted_at IS NULL
|
||||
AND COALESCE(dl.selfserve_enabled, 1) = 1
|
||||
AND dl.relay_machine_cleaner_id IS NOT NULL
|
||||
AND TRIM(dl.relay_machine_cleaner_id) <> ''
|
||||
GROUP BY oh.department, oh.`$startColumn`, oh.`$endColumn`
|
||||
";
|
||||
|
||||
$result = $db->query($sql);
|
||||
if (!$result instanceof mysqli_result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$db->fetch_all($result),
|
||||
static fn(array $row): bool => selfserveOpeningCleanerRelayWindowActive(
|
||||
$now,
|
||||
(string)($row['opens_at'] ?? ''),
|
||||
(string)($row['closes_at'] ?? '')
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
function selfserveOpeningCleanerRelayWindowActive(
|
||||
DateTimeImmutable $now,
|
||||
?string $opensAt,
|
||||
?string $closesAt
|
||||
): bool {
|
||||
$opensAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($opensAt);
|
||||
$closesAtSeconds = selfserveOpeningCleanerRelayTimeToSeconds($closesAt);
|
||||
if ($opensAtSeconds === null || $closesAtSeconds === null || $opensAtSeconds === $closesAtSeconds) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$nowSeconds = ((int)$now->format('G') * 3600)
|
||||
+ ((int)$now->format('i') * 60)
|
||||
+ (int)$now->format('s');
|
||||
|
||||
if ($opensAtSeconds < $closesAtSeconds) {
|
||||
return $nowSeconds >= $opensAtSeconds && $nowSeconds < $closesAtSeconds;
|
||||
}
|
||||
|
||||
return $nowSeconds >= $opensAtSeconds;
|
||||
}
|
||||
|
||||
function selfserveOpeningCleanerRelayTimeToSeconds(?string $time): ?int
|
||||
{
|
||||
$time = trim((string)$time);
|
||||
if ($time === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!preg_match('/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $time, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hours = (int)$matches[1];
|
||||
$minutes = (int)$matches[2];
|
||||
$seconds = isset($matches[3]) ? (int)$matches[3] : 0;
|
||||
|
||||
if ($hours > 23 || $minutes > 59 || $seconds > 59) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ($hours * 3600) + ($minutes * 60) + $seconds;
|
||||
}
|
||||
|
||||
function selfserveActivateOpeningCleanerRelaysForDepartment(int $departmentId): array
|
||||
{
|
||||
$summary = [
|
||||
'activated' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
$selfserve = new selfserve();
|
||||
$lanes = (new department_lanes_o())->getDepartmentLanes($departmentId);
|
||||
|
||||
foreach ($lanes as $departmentLane) {
|
||||
if (!($departmentLane instanceof department_lanes_o)) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!$departmentLane->isSelfServeEnabled()) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!selfserveLaneHasConfiguredCleanerRelay($departmentLane)) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$laneId = (int)$departmentLane->id;
|
||||
if ($laneId <= 0) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$lane = $selfserve->lane($laneId);
|
||||
if (!selfserveLaneHasConfiguredCleanerRelay($lane->department_lane)) {
|
||||
$summary['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$lane->setMachineCleanerRelayStatusHard(true);
|
||||
$summary['activated']++;
|
||||
} catch (Throwable $throwable) {
|
||||
$summary['failed']++;
|
||||
warn(
|
||||
'SelfserveOpeningRelayActivationCron failed for department '
|
||||
. $departmentId
|
||||
. ', lane '
|
||||
. $laneId
|
||||
. ': '
|
||||
. $throwable->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
function selfserveLaneHasConfiguredCleanerRelay(?object $departmentLane): bool
|
||||
{
|
||||
if (
|
||||
$departmentLane === null
|
||||
|| !isset($departmentLane->relay_machine_cleaner_id)
|
||||
|| !is_object($departmentLane->relay_machine_cleaner_id)
|
||||
|| !method_exists($departmentLane->relay_machine_cleaner_id, 'value')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$relayId = trim((string)$departmentLane->relay_machine_cleaner_id->value());
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $relayId !== '' && $relayId !== '0' && strtolower($relayId) !== 'null';
|
||||
}
|
||||
|
||||
function selfserveOpeningCleanerRelayActivationKey(
|
||||
int $departmentId,
|
||||
string $opensAt,
|
||||
DateTimeImmutable $now
|
||||
): string {
|
||||
$normalizedOpensAt = preg_replace('/[^0-9]/', '', $opensAt) ?: 'unknown';
|
||||
return 'selfserve:opening-cleaner-relays:'
|
||||
. $now->format('Y-m-d')
|
||||
. ':'
|
||||
. $departmentId
|
||||
. ':'
|
||||
. $normalizedOpensAt;
|
||||
}
|
||||
|
||||
function selfserveOpeningCleanerRelayActivationAlreadyHandled(string $cacheKey): bool
|
||||
{
|
||||
if (!defined('redis')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return redis->exists($cacheKey);
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function selfserveMarkOpeningCleanerRelayActivationHandled(string $cacheKey): void
|
||||
{
|
||||
if (!defined('redis')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
redis->setEx($cacheKey, '1', 36 * 3600);
|
||||
} catch (Throwable) {
|
||||
// Redis idempotency should not block relay activation.
|
||||
}
|
||||
}
|
||||
|
||||
if (defined('CRON_LOAD_LEGACY_FUNCTIONS_ONLY') && CRON_LOAD_LEGACY_FUNCTIONS_ONLY) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$response_cron = (new \classes\cron_scheduler())->runDue('automatic');
|
||||
} catch (Throwable $throwable) {
|
||||
warn('Cron scheduler failed: ' . $throwable->getMessage());
|
||||
$response_cron = [
|
||||
'ran' => [],
|
||||
'count' => 0,
|
||||
'error' => $throwable->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -12,10 +12,35 @@ interface licenseplaterecognizer_i extends universal_module_i
|
||||
* @return array An array containing the plate number and other relevant information.
|
||||
*/
|
||||
public function licenseplaterecognizer(string $base64_image): array;
|
||||
|
||||
/**
|
||||
* Get the plate number from raw uploaded image bytes.
|
||||
* @param string $image_data Raw uploaded image bytes.
|
||||
* @param string $mime_type The image MIME type.
|
||||
* @return array An array containing the plate number and other relevant information.
|
||||
*/
|
||||
public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array;
|
||||
|
||||
/**
|
||||
* Get the plate number from raw uploaded image bytes without building an exact-result cache key.
|
||||
* @param string $image_data Raw uploaded image bytes.
|
||||
* @param string $mime_type The image MIME type.
|
||||
* @return array An array containing the plate number and other relevant information.
|
||||
*/
|
||||
public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array;
|
||||
|
||||
/**
|
||||
* Get the plate number from a PHP upload temp file without copying it into memory.
|
||||
* @param string $image_path The uploaded image temp-file path.
|
||||
* @param string $mime_type The image MIME type.
|
||||
* @return array An array containing the plate number and other relevant information.
|
||||
*/
|
||||
public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array;
|
||||
|
||||
/**
|
||||
* Get usage information about the license plate recognizer module.
|
||||
* @returns licenseplaterecognizer_info An object containing usage statistics and information.
|
||||
* @see licenseplaterecognizer_info
|
||||
*/
|
||||
public function get_usage(): licenseplaterecognizer_info;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ interface minio_backups_i
|
||||
* Create a backup, upload it to the Minio bucket and return the UUID
|
||||
* @return string The UUID of the backup
|
||||
*/
|
||||
public function createBackup(): string;
|
||||
public function createBackup($backup_name = null, $backup_description = null): string;
|
||||
|
||||
/**
|
||||
* Create the metadata for a backup
|
||||
@@ -98,4 +98,4 @@ interface minio_backups_i
|
||||
*/
|
||||
public function backupEnvironmentVariables(string $backup_uuid): string;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace interfaces;
|
||||
|
||||
interface system_search_intent_parser_i
|
||||
{
|
||||
/**
|
||||
* Parse a natural-language search query into structured search hints.
|
||||
*
|
||||
* @param string $query The raw user query.
|
||||
* @param array $allowedEntityTypes Entity types the caller is allowed to search.
|
||||
* @param array $taxonomy Public taxonomy/aliases to improve intent parsing.
|
||||
* @return array{
|
||||
* success: bool,
|
||||
* normalized_query: string,
|
||||
* aliases: array<int, string>,
|
||||
* entity_hints: array<int, string>,
|
||||
* confidence: float,
|
||||
* association_hint: bool,
|
||||
* fallback_reason: string|null,
|
||||
* source: string
|
||||
* }
|
||||
*/
|
||||
public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,22 @@
|
||||
|
||||
namespace backups;
|
||||
require_once WD . '/modules/backups/config/backups_enabled_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_retention_recent_hours_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_retention_daily_days_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_retention_weekly_weeks_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_retention_monthly_months_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_app_data_enabled_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_verification_required_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_restore_enabled_c.php';
|
||||
|
||||
use backups\config\backups_app_data_enabled_c;
|
||||
use backups\config\backups_enabled_c;
|
||||
use backups\config\backups_restore_enabled_c;
|
||||
use backups\config\backups_retention_daily_days_c;
|
||||
use backups\config\backups_retention_monthly_months_c;
|
||||
use backups\config\backups_retention_recent_hours_c;
|
||||
use backups\config\backups_retention_weekly_weeks_c;
|
||||
use backups\config\backups_verification_required_c;
|
||||
use traits\module_config_t;
|
||||
|
||||
class backups_c
|
||||
@@ -15,6 +29,13 @@ class backups_c
|
||||
* @var backups_enabled_c
|
||||
*/
|
||||
public backups_enabled_c $enabled;
|
||||
public backups_retention_recent_hours_c $retention_recent_hours;
|
||||
public backups_retention_daily_days_c $retention_daily_days;
|
||||
public backups_retention_weekly_weeks_c $retention_weekly_weeks;
|
||||
public backups_retention_monthly_months_c $retention_monthly_months;
|
||||
public backups_app_data_enabled_c $app_data_enabled;
|
||||
public backups_verification_required_c $verification_required;
|
||||
public backups_restore_enabled_c $restore_enabled;
|
||||
|
||||
|
||||
public function __construct()
|
||||
@@ -22,8 +43,22 @@ class backups_c
|
||||
$this->setupConfig('Backups');
|
||||
$this->allowUpdate([
|
||||
backups_enabled_c::class,
|
||||
backups_retention_recent_hours_c::class,
|
||||
backups_retention_daily_days_c::class,
|
||||
backups_retention_weekly_weeks_c::class,
|
||||
backups_retention_monthly_months_c::class,
|
||||
backups_app_data_enabled_c::class,
|
||||
backups_verification_required_c::class,
|
||||
backups_restore_enabled_c::class,
|
||||
]);
|
||||
$this->enabled = new backups_enabled_c();
|
||||
$this->retention_recent_hours = new backups_retention_recent_hours_c();
|
||||
$this->retention_daily_days = new backups_retention_daily_days_c();
|
||||
$this->retention_weekly_weeks = new backups_retention_weekly_weeks_c();
|
||||
$this->retention_monthly_months = new backups_retention_monthly_months_c();
|
||||
$this->app_data_enabled = new backups_app_data_enabled_c();
|
||||
$this->verification_required = new backups_verification_required_c();
|
||||
$this->restore_enabled = new backups_restore_enabled_c();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_app_data_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'app_data_enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether app-owned object storage buckets are included in backups.',
|
||||
'1',
|
||||
false,
|
||||
'true'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_restore_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'restore_enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether direct production restore execution is enabled for superusers.',
|
||||
'0',
|
||||
false,
|
||||
'false'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_retention_daily_days_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'retention_daily_days',
|
||||
'int',
|
||||
true,
|
||||
null,
|
||||
'How many days of daily backup representatives are retained.',
|
||||
'30',
|
||||
false,
|
||||
'30'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_retention_monthly_months_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'retention_monthly_months',
|
||||
'int',
|
||||
true,
|
||||
null,
|
||||
'How many months of monthly backup representatives are retained.',
|
||||
'3',
|
||||
false,
|
||||
'3'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_retention_recent_hours_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'retention_recent_hours',
|
||||
'int',
|
||||
true,
|
||||
null,
|
||||
'How many hours of recent recovery points are protected before tiered pruning.',
|
||||
'48',
|
||||
false,
|
||||
'48'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_retention_weekly_weeks_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'retention_weekly_weeks',
|
||||
'int',
|
||||
true,
|
||||
null,
|
||||
'How many weeks of weekly backup representatives are retained.',
|
||||
'8',
|
||||
false,
|
||||
'8'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_verification_required_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'verification_required',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether a backup must pass verification before it is marked available for restore.',
|
||||
'1',
|
||||
false,
|
||||
'true'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'backups.create_backup',
|
||||
'legacy_name' => 'backup',
|
||||
'name' => 'Enqueue backup',
|
||||
'description' => 'Queues the scheduled production recovery backup through the configured backup store.',
|
||||
'module' => 'backups',
|
||||
'handler' => 'backup',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 3600],
|
||||
'timeout_seconds' => 120,
|
||||
'estimated_duration_ms' => 5000,
|
||||
'priority' => 115,
|
||||
],
|
||||
[
|
||||
'id' => 'backups.process_jobs',
|
||||
'legacy_name' => null,
|
||||
'name' => 'Process backup jobs',
|
||||
'description' => 'Processes queued backup create, verify, restore, and prune jobs.',
|
||||
'module' => 'backups',
|
||||
'handler' => 'processBackupJobs',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||
'timeout_seconds' => 3600,
|
||||
'estimated_duration_ms' => 60000,
|
||||
'priority' => 116,
|
||||
],
|
||||
[
|
||||
'id' => 'backups.prune_retention',
|
||||
'legacy_name' => null,
|
||||
'name' => 'Prune backup retention',
|
||||
'description' => 'Queues tiered retention cleanup for old backup artifacts.',
|
||||
'module' => 'backups',
|
||||
'handler' => 'pruneBackupRetention',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 86400],
|
||||
'timeout_seconds' => 1800,
|
||||
'estimated_duration_ms' => 30000,
|
||||
'priority' => 117,
|
||||
],
|
||||
];
|
||||
@@ -131,7 +131,7 @@
|
||||
"post": {
|
||||
"tags": ["User Bookings"],
|
||||
"summary": "Get download link for a booking's wash certificate",
|
||||
"description": "Requires permission `download_own_wash_certificate`. Returns a presigned download link if certificate exists and user has access.",
|
||||
"description": "Requires permission `download_own_wash_certificate`, except authenticated customer accounts may download certificates for their own bookings. Returns a presigned download link if certificate exists and user has access.",
|
||||
"parameters": [ { "$ref": "#/components/parameters/id" } ],
|
||||
"responses": {
|
||||
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } },
|
||||
@@ -146,7 +146,7 @@
|
||||
"get": {
|
||||
"tags": ["User Bookings"],
|
||||
"summary": "Get download link for a booking's wash certificate PDF",
|
||||
"description": "Requires permission `download_own_wash_certificate`. Checks both legacy and current storage buckets.",
|
||||
"description": "Requires permission `download_own_wash_certificate`, except authenticated customer accounts may download certificates for their own bookings. Checks both legacy and current storage buckets.",
|
||||
"parameters": [ { "$ref": "#/components/parameters/id" } ],
|
||||
"responses": {
|
||||
"200": { "description": "Link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopeDownloadLink" } } } },
|
||||
@@ -213,6 +213,18 @@
|
||||
}
|
||||
},
|
||||
|
||||
"/department/timebookings/departments/public": {
|
||||
"get": {
|
||||
"tags": ["Public Time Bookings"],
|
||||
"summary": "List public departments with time bookings enabled",
|
||||
"description": "Returns only visible, active departments where `bookingsystem_time_based_enabled` is `true`.",
|
||||
"parameters": [ { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/search" }, { "$ref": "#/components/parameters/filters" }, { "$ref": "#/components/parameters/order" } ],
|
||||
"responses": {
|
||||
"200": { "description": "Departments", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopePublicTimeBookingDepartmentsList" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"/department/timebookings/opening-hours/public": {
|
||||
"get": {
|
||||
"tags": ["Public Time Bookings"],
|
||||
@@ -339,6 +351,12 @@
|
||||
{ "type": "object", "properties": { "data": { "$ref": "#/components/schemas/OpeningHours" } } }
|
||||
]
|
||||
},
|
||||
"EnvelopePublicTimeBookingDepartmentsList": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/components/schemas/Envelope" },
|
||||
{ "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/PublicTimeBookingDepartment" } } } }
|
||||
]
|
||||
},
|
||||
"EnvelopeBookingTypesList": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/components/schemas/Envelope" },
|
||||
@@ -396,6 +414,21 @@
|
||||
"required": ["link"]
|
||||
},
|
||||
|
||||
"PublicTimeBookingDepartment": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"address": { "type": "string" },
|
||||
"longitude": { "type": "number", "format": "float" },
|
||||
"latitude": { "type": "number", "format": "float" },
|
||||
"order_priority": { "type": "integer" },
|
||||
"time_booking_enabled": { "type": "boolean" }
|
||||
},
|
||||
"required": ["id", "name", "description", "address", "longitude", "latitude", "order_priority", "time_booking_enabled"]
|
||||
},
|
||||
|
||||
"OpeningHours": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'coolify.availability_monitor',
|
||||
'legacy_name' => 'CoolifyAvailabilityMonitorCron',
|
||||
'name' => 'Coolify availability monitor',
|
||||
'description' => 'Checks registered Coolify-managed infrastructure targets.',
|
||||
'module' => 'coolify',
|
||||
'handler' => 'CoolifyAvailabilityMonitorCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||
'timeout_seconds' => 300,
|
||||
'estimated_duration_ms' => 2000,
|
||||
'priority' => 25,
|
||||
],
|
||||
[
|
||||
'id' => 'coolify.load_balancer_reconcile',
|
||||
'legacy_name' => 'CoolifyLoadBalancerReconcileCron',
|
||||
'name' => 'Coolify load balancer reconcile',
|
||||
'description' => 'Reconciles public gateway load balancer targets when automation is enabled.',
|
||||
'module' => 'coolify',
|
||||
'handler' => 'CoolifyLoadBalancerReconcileCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||
'timeout_seconds' => 300,
|
||||
'estimated_duration_ms' => 3000,
|
||||
'priority' => 26,
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'dynamicimages.pre_render',
|
||||
'legacy_name' => 'PreRenderDynamicImagesCron',
|
||||
'name' => 'Pre-render dynamic images',
|
||||
'description' => 'Pre-renders common self-serve dynamic image variants into Redis.',
|
||||
'module' => 'dynamicimages',
|
||||
'handler' => 'PreRenderDynamicImagesCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 900],
|
||||
'timeout_seconds' => 900,
|
||||
'estimated_duration_ms' => 30000,
|
||||
'priority' => 90,
|
||||
],
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user