Compare commits
110
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4430345831 | ||
|
|
5cde8103f9 | ||
|
|
f4ba70623e | ||
|
|
cdf8541e78 | ||
|
|
51e5c2ed01 | ||
|
|
0dea2f0b76 | ||
|
|
22ec6696b5 | ||
|
|
a71194d46e | ||
|
|
5441fea665 | ||
|
|
02a4665bc7 | ||
|
|
3e683c8559 | ||
|
|
d81634033e | ||
|
|
58d5e177a9 | ||
|
|
82a3684f05 | ||
|
|
d850075397 | ||
|
|
43df3e4dca | ||
|
|
7174e3be6c | ||
|
|
2ef0f78541 | ||
|
|
801c8e1f6d | ||
|
|
3e89085296 | ||
|
|
2cf2538525 | ||
|
|
6475f817c7 | ||
|
|
b107ba649c | ||
|
|
0aaf32efa4 | ||
|
|
8735bae8d5 | ||
|
|
bd2ff1be9a | ||
|
|
59107a6bb2 | ||
|
|
622fe59f5c | ||
|
|
23fc410d25 | ||
|
|
db9f589bf7 | ||
|
|
ab6c3ba5b6 | ||
|
|
40b104abed | ||
|
|
842b06c93f | ||
|
|
1f03b46564 | ||
|
|
6d888a455d | ||
|
|
0b304a2203 | ||
|
|
068f9e254f | ||
|
|
f37feef1e6 | ||
|
|
c795df4aad | ||
|
|
1e0e051775 | ||
|
|
4587bdfb06 | ||
|
|
a442e70744 | ||
|
|
1b161f1b96 | ||
|
|
448e0e50c2 | ||
|
|
710baad28e | ||
|
|
42ddce84bc | ||
|
|
da0113e3ed | ||
|
|
3c13892366 | ||
|
|
30d860fdef | ||
|
|
d3e4798b11 | ||
|
|
8d8f0eccce | ||
|
|
6e24718c1f | ||
|
|
9b481e0957 | ||
|
|
0060fb45ca | ||
|
|
34cf804d75 | ||
|
|
677d4700b0 | ||
|
|
abde54c898 | ||
|
|
b177347bf5 | ||
|
|
32a5b99204 | ||
|
|
2a6a86c9c3 | ||
|
|
6566027746 | ||
|
|
511605b619 | ||
|
|
c2abf17cd7 | ||
|
|
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 |
@@ -0,0 +1,56 @@
|
||||
# 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.
|
||||
|
||||
## Activation record
|
||||
|
||||
Repository ruleset `19041620` was activated on 2026-07-16 after preparation
|
||||
PR #311 passed `Required CI` and the merged `master` commit passed both
|
||||
`Required CI` and the `Release Manager gate`. This documentation update is
|
||||
the after-activation canary for the normal protected pull-request path.
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,52 +1,74 @@
|
||||
name: Qodana
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- beta
|
||||
- canary
|
||||
- internal
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
push:
|
||||
branches: # Specify your branches here
|
||||
- main # The 'main' branch
|
||||
- 'releases/*' # The release branches
|
||||
branches:
|
||||
- master
|
||||
- beta
|
||||
- canary
|
||||
- internal
|
||||
|
||||
concurrency:
|
||||
group: qodana-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
qodana:
|
||||
# CI runs on the repository's self-hosted runner pool.
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
name: Qodana
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
(
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
checks: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }} # Use PR head when available, otherwise the pushed SHA.
|
||||
fetch-depth: 0 # a full history is required for pull request analysis
|
||||
persist-credentials: false
|
||||
- name: Mark repository as safe for Git
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
- name: Prepare Qodana cache directories
|
||||
run: |
|
||||
mkdir -p "${RUNNER_TEMP}/qodana/caches"
|
||||
mkdir -p "${RUNNER_TEMP}/qodana/results"
|
||||
- name: Detect Qodana Cloud token
|
||||
id: qodana-token
|
||||
- name: Require Qodana Cloud token
|
||||
env:
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -n "${QODANA_TOKEN:-}" ]; then
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
set -euo pipefail
|
||||
if [[ -z "${QODANA_TOKEN}" ]]; then
|
||||
echo "::error::QODANA_TOKEN is not configured for this repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 'Qodana Scan'
|
||||
if: ${{ steps.qodana-token.outputs.present == 'true' }}
|
||||
uses: JetBrains/qodana-action@v2026.1
|
||||
- name: Check out the analyzed commit
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
pr-mode: false
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Qodana
|
||||
uses: JetBrains/qodana-action@4861e015da555e86a72b862892aba6c2b93e6891 # v2026.1.3
|
||||
with:
|
||||
pr-mode: ${{ github.event_name == 'pull_request' }}
|
||||
use-caches: true
|
||||
cache-default-branch-only: true
|
||||
use-annotations: true
|
||||
post-pr-comment: true
|
||||
github-token: ${{ github.token }}
|
||||
push-fixes: none
|
||||
upload-result: false
|
||||
env:
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
QODANA_ENDPOINT: 'https://qodana.cloud'
|
||||
|
||||
- name: 'Skip Qodana Scan (missing cloud token)'
|
||||
if: ${{ steps.qodana-token.outputs.present != 'true' }}
|
||||
run: echo "Skipping Qodana because QODANA_TOKEN is not configured."
|
||||
|
||||
+84
-52
@@ -3,39 +3,49 @@ 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, pleno, backend, docker]
|
||||
# Docker jobs use disposable workspaces so root-owned container artifacts cannot poison later checkouts.
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suite: [unit, integration, api, legacy]
|
||||
env:
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
COMPOSE_PROJECT_NAME: php-${{ github.run_id }}-${{ github.job }}-${{ matrix.suite }}-${{ github.run_attempt }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
docker ps >/dev/null 2>&1 || {
|
||||
echo "Docker is unavailable to the runner identity. Fix the isolated runner configuration; the workflow will not weaken /var/run/docker.sock permissions." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Setup Node.js
|
||||
if: ${{ matrix.suite == 'unit' }}
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -49,7 +59,7 @@ jobs:
|
||||
- name: Upload PHP suite logs
|
||||
if: ${{ failure() }}
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: php-${{ matrix.suite }}-logs
|
||||
path: .tmp/ci-logs/${{ matrix.suite }}
|
||||
@@ -58,14 +68,16 @@ jobs:
|
||||
|
||||
edge-agent:
|
||||
name: Edge Agent (required)
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend]
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -103,25 +115,23 @@ jobs:
|
||||
|
||||
edge-broker:
|
||||
name: Edge Broker (required)
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
docker ps >/dev/null 2>&1 || {
|
||||
echo "Docker is unavailable to the runner identity. Fix the isolated runner configuration; the workflow will not weaken /var/run/docker.sock permissions." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Materialize CI compose env files
|
||||
run: |
|
||||
@@ -135,7 +145,7 @@ jobs:
|
||||
docker compose -f docker-compose.example.yml config > /dev/null
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -149,8 +159,9 @@ jobs:
|
||||
|
||||
edge-gateway-backend:
|
||||
name: Edge Gateway Backend (required)
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
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
|
||||
@@ -163,21 +174,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
docker ps >/dev/null 2>&1 || {
|
||||
echo "Docker is unavailable to the runner identity. Fix the isolated runner configuration; the workflow will not weaken /var/run/docker.sock permissions." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Allocate CI ports
|
||||
run: |
|
||||
@@ -228,7 +235,7 @@ jobs:
|
||||
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300/edge-broker\n' >> .env
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -336,11 +343,42 @@ jobs:
|
||||
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, pleno, backend]
|
||||
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||
runs-on: ubuntu-24.04
|
||||
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
|
||||
@@ -368,12 +406,6 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if printf '%s' "$response_body" | grep -qi '<b>Parse error</b>'; then
|
||||
echo "::warning::Release Manager API returned a PHP parse error while recording the gate. Treating this as a break-glass pass so a fix can be deployed."
|
||||
printf '%s\n' "$response_body"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf '%s\n' "$response_body"
|
||||
echo "Release Manager gate failed with HTTP $http_code." >&2
|
||||
exit 1
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
/docker-compose.yml
|
||||
/services/nginx/app/vendor/
|
||||
/services/nginx/app/modules/washcertificates/vendor/
|
||||
/services/nginx/app/.phpunit.cache/
|
||||
/services/nginx/letsencrypt/
|
||||
*.pem
|
||||
*.log.gz
|
||||
|
||||
@@ -24,6 +24,7 @@ RUN set -eux; \
|
||||
libzip-dev \
|
||||
mariadb-client \
|
||||
nginx \
|
||||
openssl \
|
||||
pkg-config \
|
||||
redis-tools \
|
||||
unzip \
|
||||
@@ -46,6 +47,8 @@ RUN set -eux; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY services/nginx/app/ /var/www/html/
|
||||
COPY scripts/bird-control-plane-activate.php /var/www/html/scripts/bird-control-plane-activate.php
|
||||
COPY scripts/bird-control-plane-auto-activate.php /var/www/html/scripts/bird-control-plane-auto-activate.php
|
||||
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
|
||||
@@ -61,6 +64,8 @@ RUN set -eux; \
|
||||
fi; \
|
||||
COMPOSER_ALLOW_SUPERUSER=1 composer dump-autoload --no-dev --optimize --no-interaction -d /var/www/html; \
|
||||
php -d display_errors=1 -r 'require "/var/www/html/vendor/autoload.php"; exit(interface_exists("Psr\\Http\\Message\\UriInterface") && interface_exists("Psr\\Http\\Message\\StreamInterface") ? 0 : 1);'; \
|
||||
php -r 'exit(function_exists("proc_open") && extension_loaded("openssl") ? 0 : 1);'; \
|
||||
test "$(openssl pkey -pubin -in /var/www/html/modules/bird/resources/control-plane-bootstrap-public.pem -outform DER | sha256sum | cut -d " " -f 1)" = "6dc63c6ffe33b8de0b1396d7f529f56aea0a685ef98168016161cf721ddc8c21"; \
|
||||
chown -R www-data:www-data /var/www/html; \
|
||||
chmod -R 755 /var/www/html
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
Backend API for Copenhagen Truck Wash services.
|
||||
|
||||
Changes are published from a scoped feature branch through a pull request to
|
||||
`master`; direct default-branch pushes are not part of the release workflow.
|
||||
See [default branch protection](.github/BRANCH_PROTECTION.md) for the CI gate
|
||||
and emergency procedure.
|
||||
|
||||
## Architecture & Stack
|
||||
- **Edge Proxy:** [Traefik 2.11](https://doc.traefik.io/traefik/) (Handles TLS termination, routing, and rate limiting).
|
||||
- **Web Server:** [Caddy 2.7](https://caddyserver.com/) (Serves the PHP application via FastCGI).
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ $MINIO = [
|
||||
'access_key' => '', // Minio access
|
||||
'secret_key' => '' // Minio secret key
|
||||
];
|
||||
$SLACK_DEFAULT_WEBHOOK = ''; // Default Slack webhook URL e.g. https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXX
|
||||
$SLACK_DEFAULT_WEBHOOK = ''; // Set through SLACK_DEFAULT_WEBHOOK; never commit a production webhook URL.
|
||||
$REDIS_CONFIG = [
|
||||
'host' => '', // Redis host (IP address)
|
||||
'user' => '', // Redis user
|
||||
|
||||
@@ -129,7 +129,7 @@ services:
|
||||
- redis
|
||||
- mysql
|
||||
- edge-broker
|
||||
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
|
||||
command: ["php", "index.php", "run", "cron-worker"]
|
||||
env_file:
|
||||
- .env.example
|
||||
environment:
|
||||
|
||||
@@ -367,7 +367,7 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- edge-broker
|
||||
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
|
||||
command: ["php", "index.php", "run", "cron-worker"]
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
+1
-1
@@ -425,7 +425,7 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- edge-broker
|
||||
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
|
||||
command: ["php", "index.php", "run", "cron-worker"]
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# AGENT MCP SMOKE
|
||||
|
||||
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
|
||||
Safe to close.
|
||||
@@ -9856,6 +9856,7 @@
|
||||
"Orders"
|
||||
],
|
||||
"summary": "Create Stripe payment intent",
|
||||
"description": "Creates a Stripe Terminal card payment intent with fixed 25% moms.",
|
||||
"operationId": "createStripePaymentIntent",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -9873,9 +9874,6 @@
|
||||
},
|
||||
"reader": {
|
||||
"type": "string"
|
||||
},
|
||||
"tax_percentage": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@
|
||||
|
||||
<!-- AUTO-GENERATED, DO NOT EDIT -->
|
||||
<p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p>
|
||||
<p>The edge broker's <code>/api/health</code> response additionally exposes a <code>lastActivityAt</code> field (ISO 8601 timestamp). It reports the most recent successful HTTP request handled by the broker container and defaults to the container's start time when no request has been processed yet.</p>
|
||||
</topic>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</chapter>
|
||||
<chapter title="Operation" id="operation">
|
||||
<p>Operation ID: <code>createStripePaymentIntent</code></p>
|
||||
<p>Create Stripe payment intent</p>
|
||||
<p>Creates a Stripe Terminal card payment intent with fixed 25% moms.</p>
|
||||
</chapter>
|
||||
<chapter title="Authentication" id="authentication">
|
||||
<p>Security requirements:</p>
|
||||
@@ -32,9 +32,6 @@
|
||||
},
|
||||
"reader": {
|
||||
"type": "string"
|
||||
},
|
||||
"tax_percentage": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# XL Vask Selvvask surface — inventory & simplification plan
|
||||
|
||||
## Scope
|
||||
|
||||
The XLVask surface that powers the **Superuser → Fakturaer → Periode → Selvvask**
|
||||
view. Goal: remove the AI / MiniMax / autopilot pipeline, leaving only the
|
||||
operator-facing review and order-creation flow.
|
||||
|
||||
Out of scope: any other XLVask, plate scanner, customer, or vehicle surface.
|
||||
|
||||
## Files removed
|
||||
|
||||
| Path | Reason |
|
||||
| --- | --- |
|
||||
| `services/nginx/app/classes/xlvask_autopilot_service.php` | AI autopilot pipeline |
|
||||
| `services/nginx/app/classes/xlvask_automation_service.php` | AI automation pipeline |
|
||||
| `services/nginx/app/classes/xlvask_automation_policy_service.php` | AI policy service |
|
||||
| `services/nginx/app/classes/minimax.php` | MiniMax integration |
|
||||
| `services/nginx/app/modules/miniMax/` | MiniMax module (config + class) |
|
||||
| `services/nginx/app/modules/xlvask/AUTOMATION_RUNBOOK.md` | Runbook for removed pipeline |
|
||||
| `services/nginx/app/modules/xlvask/cron/tasks.php` | Module-owned cron registry (replaced by empty `cron_task_registry` discovery) |
|
||||
| `services/nginx/app/modules/xlvask/migrations/20260804_xlvask_ai_auto_policy_v2.php` | Migration for removed AI schema |
|
||||
| `services/nginx/app/modules/xlvask/config/xlvask_automatic_order_attachment_enabled_c.php` | Legacy autopilot gate |
|
||||
| `services/nginx/app/modules/xlvask/config/xlvask_automatic_order_creation_enabled_c.php` | Legacy autopilot gate |
|
||||
| `services/nginx/app/modules/xlvask/config/xlvask_minimax_integration_enabled_c.php` | MiniMax gate |
|
||||
| `services/nginx/app/modules/xlvask/config/xlvask_openai_integration_enabled_c.php` | OpenAI gate |
|
||||
| `services/nginx/app/cron/EnsureXLVaskAutomationSchema.php` | Migration helper |
|
||||
| `scripts/xlvask-automation-migrate.php` | CLI wrapper for migration |
|
||||
| `services/nginx/app/tests/Unit/XLVask/XLVaskAutomationMigrateScriptTest.php` | Removed migration test |
|
||||
| `services/nginx/app/tests/Unit/XLVask/XLVaskAutomationServiceTest.php` | Removed automation test |
|
||||
| `services/nginx/app/tests/Api/XLVaskReviewApiTest.php` | Replaced by Selvvask route contract test |
|
||||
|
||||
## Code changes (kept & simplified)
|
||||
|
||||
| Path | Change |
|
||||
| --- | --- |
|
||||
| `services/nginx/app/cron/Cron.php` | Drop `ProcessXLVaskAutopilotQueueCron` registration + function |
|
||||
| `services/nginx/app/cli.php` | Drop `xlvask-automation-migrate` case |
|
||||
| `services/nginx/app/routes/moduleConfigRoute.php` | Drop `/minimax/config` GET/POST endpoints |
|
||||
| `services/nginx/app/routes/moduleXLVaskRoute.php` | Drop `/modules/xlvask/tasks/import-usage` 410 stub and `/tasks/debug` route |
|
||||
| `services/nginx/app/routes/xlvaskUsageLogsRoute.php` | Slim to operator-only: list, summary, ignore/unignore, accept, reject, fast-link |
|
||||
| `services/nginx/app/modules/xlvask/helpers/xlvask_tasks.php` | Drop `runScheduledAutomationIfReady`, `processAutopilotQueue`, autopilot cleanup, legacy auto-creation branch |
|
||||
| `services/nginx/app/modules/xlvask/xlvask_c.php` | Drop `minimax_integration_enabled`, `automatic_order_attachment_enabled`, `automatic_order_creation_enabled`, `openai_integration_enabled` |
|
||||
| `services/nginx/app/objects/xlvask_usage_logs_o.php` | Add `summarizeUsageOrdersReadOnly` (replaces autopilot summary) |
|
||||
| `services/nginx/app/openapi.yaml` | Replace autopilot/automation openapi block with operator-flow endpoints |
|
||||
| `services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php` | Update count: 24 → 22, drop `xlvask.autopilot_queue` assertion |
|
||||
| `services/nginx/app/tests/Unit/XLVask/XLVaskUsageRouteContractTest.php` | Replaced with end-to-end contract assertions for the new operator surface |
|
||||
|
||||
## New operator-facing endpoints
|
||||
|
||||
All under `routes/xlvaskUsageLogsRoute.php` and scoped to the operator's
|
||||
`allowedHallIds` (all-scope users see every configured scanner hall; own-scope
|
||||
users see only their group's halls).
|
||||
|
||||
| Method | Path | Permission | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET` | `/modules/xlvask/services/usage/orders` | `list_xlvask_usage_orders_own/all` | List usage logs with direct linked order id, amount summary, ignored metadata |
|
||||
| `GET` | `/modules/xlvask/services/usage/orders/summary` | `list_xlvask_usage_orders_own/all` | Read-only per-period summary (counts + net amount) |
|
||||
| `PATCH` | `/modules/xlvask/services/usage/orders/{id}/ignore` | `review_xlvask_usage_order` | Mark ignored with reason |
|
||||
| `POST` | `/modules/xlvask/services/usage/orders/{id}/unignore` | `review_xlvask_usage_order` | Clear ignored metadata |
|
||||
| `POST` | `/modules/xlvask/services/usage/orders/{id}/accept` | `review_xlvask_usage_order` | Convert to order via `createOrderFromWash` |
|
||||
| `POST` | `/modules/xlvask/services/usage/orders/{id}/reject` | `review_xlvask_usage_order` | Mark ignored with reject reason |
|
||||
| `GET` | `/modules/xlvask/services/usage/orders/fast-link` | `list_xlvask_usage_orders_own` | Cached fast-link redeem (existing) |
|
||||
|
||||
## Permissions
|
||||
|
||||
The Selvvask surface uses these permissions only:
|
||||
|
||||
- `list_xlvask_usage_orders_own`
|
||||
- `list_xlvask_usage_orders_all`
|
||||
- `review_xlvask_usage_order`
|
||||
|
||||
`manage_xlvask_usage_automation`, `ignore_xlvask_usage_order`,
|
||||
`superuser_xlvask_automation_activate` are not referenced anywhere in the
|
||||
slimmed surface.
|
||||
|
||||
## Persistence model
|
||||
|
||||
`xlvask_usage_logs_o` already exposes `ignored_at`, `ignored_by`, `ignored_reason`
|
||||
columns — no migration required for the simplified flow.
|
||||
|
||||
`orders_o::selectByWashId(int|string $WashId)` and
|
||||
`orders_o::addXLVaskOrder(users_o $user, xlvask_usage_log $xlvask_usage_log)` are
|
||||
the only integration points with the order pipeline.
|
||||
|
||||
## Tests
|
||||
|
||||
- `vendor/bin/pest --testsuite=Unit --colors=never` passes 1266 tests.
|
||||
- One pre-existing failure (`BirdControlPlaneActivationTest`) requires
|
||||
`PLENO_REPO_ROOT_FOR_TESTS` (coolify repo) and is unrelated to this change.
|
||||
|
||||
## Repo scope
|
||||
|
||||
This inventory covers `api`. The `pleno-vue` side has not yet been updated in
|
||||
this session and will be handled in a follow-up PR.
|
||||
+1867
-104
File diff suppressed because it is too large
Load Diff
+49
-40
@@ -1,46 +1,55 @@
|
||||
#-------------------------------------------------------------------------------#
|
||||
# Qodana analysis is configured by qodana.yaml file #
|
||||
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
|
||||
#-------------------------------------------------------------------------------#
|
||||
|
||||
#################################################################################
|
||||
# WARNING: Do not store sensitive information in this file, #
|
||||
# as its contents will be included in the Qodana report. #
|
||||
#################################################################################
|
||||
version: "1.0"
|
||||
|
||||
#Specify inspection profile for code analysis
|
||||
linter: jetbrains/qodana-php:2026.1
|
||||
|
||||
profile:
|
||||
name: qodana.starter
|
||||
name: qodana.recommended
|
||||
|
||||
#Enable inspections
|
||||
#include:
|
||||
# - name: <SomeEnabledInspectionId>
|
||||
php:
|
||||
version: "8.2"
|
||||
|
||||
#Disable inspections
|
||||
#exclude:
|
||||
# - name: <SomeDisabledInspectionId>
|
||||
# paths:
|
||||
# - <path/where/not/run/inspection>
|
||||
bootstrap: |+
|
||||
set -eu
|
||||
composer --working-dir=services/nginx/app install --no-interaction --prefer-dist --no-progress --ignore-platform-reqs
|
||||
composer --working-dir=services/nginx/app/modules/washcertificates install --no-interaction --prefer-dist --no-progress --ignore-platform-reqs
|
||||
npm --prefix services/edge-agent ci --ignore-scripts
|
||||
npm --prefix services/edge-broker ci --ignore-scripts
|
||||
|
||||
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
|
||||
#bootstrap: sh ./prepare-qodana.sh
|
||||
|
||||
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
|
||||
#plugins:
|
||||
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
|
||||
|
||||
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
|
||||
# severityThresholds - configures maximum thresholds for different problem severities
|
||||
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
|
||||
# Code Coverage is available in Ultimate and Ultimate Plus plans
|
||||
#failureConditions:
|
||||
# severityThresholds:
|
||||
# any: 15
|
||||
# critical: 5
|
||||
# testCoverageThresholds:
|
||||
# fresh: 70
|
||||
# total: 50
|
||||
|
||||
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
|
||||
linter: jetbrains/qodana-php:2025.3
|
||||
exclude:
|
||||
# This application is intentionally Composer-classmapped and keeps legacy snake_case
|
||||
# classes plus multiple local test doubles in single files; PSR path rules do not apply.
|
||||
- name: PhpIllegalPsrClassPathInspection
|
||||
paths:
|
||||
- services/nginx/app
|
||||
# Unit-test doubles intentionally bypass integration-heavy parent constructors.
|
||||
- name: PhpMissingParentConstructorInspection
|
||||
paths:
|
||||
- services/nginx/app/tests
|
||||
# These focused tests configure doubles through public fields before invoking behavior.
|
||||
- name: PhpObjectFieldsAreOnlyWrittenInspection
|
||||
paths:
|
||||
- services/nginx/app/tests/Unit/Bird/BirdGateCallFlowTest.php
|
||||
- services/nginx/app/tests/Unit/Invoicing/EconomicCustomersDiscountFallbackTest.php
|
||||
- services/nginx/app/tests/Unit/Selfserve/SelfserveCustomerLaneAccessTest.php
|
||||
# API coverage markers are intentional statement-style calls in the Pest DSL.
|
||||
# Their return value is irrelevant; the call records route/scenario coverage.
|
||||
- name: PhpExpressionResultUnusedInspection
|
||||
paths:
|
||||
- services/nginx/app/tests/Api
|
||||
- name: All
|
||||
paths:
|
||||
- services/nginx/app/vendor
|
||||
- services/nginx/app/modules/washcertificates/vendor
|
||||
- services/nginx/app/build
|
||||
- services/nginx/app/.phpunit.cache
|
||||
- services/nginx/app/tests/Legacy
|
||||
- services/edge-agent/node_modules
|
||||
- services/edge-broker/node_modules
|
||||
- services/edge-agent/dist
|
||||
- documentation/generated
|
||||
- documentation/topics/generated
|
||||
- documentation/_build
|
||||
- documentation/_site_rebuild_20260317
|
||||
- docs_bird_voice_calls.html
|
||||
- .tmp
|
||||
- .openclaw
|
||||
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
fwrite(STDERR, "This command is CLI-only.\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
const WD = __DIR__ . '/../services/nginx/app';
|
||||
require_once WD . '/vendor/autoload.php';
|
||||
require_once WD . '/config.php';
|
||||
require_once WD . '/classes/db.php';
|
||||
require_once WD . '/classes/account_deletion_schema_bootstrap.php';
|
||||
|
||||
$response = null;
|
||||
$db = new \classes\db($CONFIG_DB);
|
||||
$db->connect();
|
||||
$command = $argv[1] ?? 'check';
|
||||
|
||||
if ($command === 'apply') {
|
||||
if (($argv[2] ?? '') !== '--yes') {
|
||||
fwrite(STDERR, "Refusing schema mutation without: apply --yes\n");
|
||||
exit(2);
|
||||
}
|
||||
\classes\account_deletion_schema_bootstrap::apply();
|
||||
}
|
||||
|
||||
if (!in_array($command, ['check', 'apply'], true)) {
|
||||
fwrite(STDERR, "Usage: scripts/account-deletion-schema.php check|apply --yes\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$status = \classes\account_deletion_schema_bootstrap::check();
|
||||
fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
exit($status['ready'] ? 0 : 1);
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
fwrite(STDERR, "This command is CLI-only.\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$command = $argv[1] ?? 'check';
|
||||
if (!in_array($command, ['check', 'apply', 'webhooks-check', 'webhooks-apply'], true)) {
|
||||
fwrite(
|
||||
STDERR,
|
||||
"Usage: scripts/bird-control-plane-activate.php check|apply|webhooks-check|webhooks-apply [--yes]\n"
|
||||
);
|
||||
exit(2);
|
||||
}
|
||||
if (in_array($command, ['apply', 'webhooks-apply'], true) && ($argv[2] ?? '') !== '--yes') {
|
||||
fwrite(STDERR, "Refusing Bird activation without: apply --yes\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$appDirectory = __DIR__ . '/../services/nginx/app';
|
||||
if (!is_file($appDirectory . '/config.php')) {
|
||||
$appDirectory = dirname(__DIR__);
|
||||
}
|
||||
define('WD', $appDirectory);
|
||||
require_once WD . '/vendor/autoload.php';
|
||||
require_once WD . '/config.php';
|
||||
require_once WD . '/classes/db.php';
|
||||
require_once WD . '/modules/bird/classes/bird_control_plane_activator.php';
|
||||
require_once WD . '/modules/bird/classes/bird_webhook_subscription_reconciler.php';
|
||||
|
||||
try {
|
||||
$pdo = \classes\db::getPDO();
|
||||
if (str_starts_with($command, 'webhooks-')) {
|
||||
$reconciler = new \bird\classes\bird_webhook_subscription_reconciler($pdo);
|
||||
$organizationId = trim((string)(getenv('BIRD_ORGANIZATION_ID') ?: ''));
|
||||
$status = $command === 'webhooks-apply'
|
||||
? $reconciler->apply($organizationId)
|
||||
: $reconciler->check($organizationId);
|
||||
} else {
|
||||
$activator = new \bird\classes\bird_control_plane_activator($pdo);
|
||||
$status = $command === 'apply' ? $activator->apply([
|
||||
'controlPlaneToken' => trim((string)(getenv('BIRD_CONTROL_PLANE_TOKEN') ?: '')),
|
||||
'webhookSigningKey' => trim((string)(getenv('BIRD_WEBHOOK_SIGNING_KEY') ?: '')),
|
||||
'participantId' => trim((string)(getenv('BIRD_PARTICIPANT_ID') ?: '')),
|
||||
]) : $activator->check();
|
||||
}
|
||||
fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
exit(($status['ready'] ?? false) === true ? 0 : 1);
|
||||
} catch (Throwable $throwable) {
|
||||
error_log('[bird-control-plane-activate] Failed: ' . get_class($throwable));
|
||||
fwrite(STDOUT, json_encode([
|
||||
'ready' => false,
|
||||
'errorCode' => 'bird_activation_failed',
|
||||
], JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$appDirectory = __DIR__ . '/../services/nginx/app';
|
||||
if (!is_file($appDirectory . '/config.php')) {
|
||||
$appDirectory = dirname(__DIR__);
|
||||
}
|
||||
define('WD', $appDirectory);
|
||||
require_once WD . '/vendor/autoload.php';
|
||||
require_once WD . '/config.php';
|
||||
require_once WD . '/classes/db.php';
|
||||
require_once WD . '/modules/bird/classes/bird_control_plane_auto_activation.php';
|
||||
|
||||
try {
|
||||
$status = (new \bird\classes\bird_control_plane_auto_activation(
|
||||
\classes\db::getPDO()
|
||||
))->run();
|
||||
fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
exit(($status['ready'] ?? false) === true ? 0 : 1);
|
||||
} catch (Throwable $throwable) {
|
||||
error_log('[bird-control-plane-auto-activate] Failed: ' . get_class($throwable));
|
||||
fwrite(STDOUT, '{"ready":false,"errorCode":"bird_auto_activation_failed"}' . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
bootstrap_url='https://api.truckwash.io:4433/bird/control-plane/v1/bootstrap'
|
||||
status_url='https://api.truckwash.io:4433/bird/control-plane/v1/status'
|
||||
expected_algorithm='RSA-OAEP-256'
|
||||
expected_fingerprint='6dc63c6ffe33b8de0b1396d7f529f56aea0a685ef98168016161cf721ddc8c21'
|
||||
private_key='/home/jeppe/.openclaw/credentials/bird.bootstrap-private.pem'
|
||||
credential_dir='/home/jeppe/.openclaw/credentials'
|
||||
destination="$credential_dir/bird.gateway-token"
|
||||
|
||||
umask 077
|
||||
mkdir -p "$credential_dir"
|
||||
envelope_file="$(mktemp "$credential_dir/.bird-bootstrap-envelope.XXXXXX")"
|
||||
candidate_file="$(mktemp "$credential_dir/.bird-gateway-token.XXXXXX")"
|
||||
payload_file="$(mktemp "$credential_dir/.bird-bootstrap-payload.XXXXXX")"
|
||||
status_file="$(mktemp "$credential_dir/.bird-bootstrap-status.XXXXXX")"
|
||||
cleanup() {
|
||||
rm -f "$envelope_file" "$candidate_file" "$payload_file" "$status_file"
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
test -r "$private_key"
|
||||
test "$(stat -c '%a' "$private_key")" = '600'
|
||||
|
||||
curl --proto '=https' --tlsv1.2 --fail --silent --show-error \
|
||||
--max-time 30 "$bootstrap_url" > "$envelope_file"
|
||||
|
||||
test "$(jq -r '.success // false' "$envelope_file")" = 'true'
|
||||
test "$(jq -r '.data.algorithm // empty' "$envelope_file")" = "$expected_algorithm"
|
||||
test "$(jq -r '.data.keyFingerprint // empty' "$envelope_file")" = "$expected_fingerprint"
|
||||
jq -e '.data | keys == ["algorithm","ciphertext","keyFingerprint","tokenVersion","updatedAt"]' \
|
||||
"$envelope_file" >/dev/null
|
||||
jq -e '.data.tokenVersion | type == "number" and . >= 1 and floor == .' \
|
||||
"$envelope_file" >/dev/null
|
||||
jq -e '.data.updatedAt | type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")' \
|
||||
"$envelope_file" >/dev/null
|
||||
jq -e '.data.ciphertext | type == "string" and length == 512 and test("^[A-Za-z0-9+/]{512}$")' \
|
||||
"$envelope_file" >/dev/null
|
||||
|
||||
jq -r '.data.ciphertext' "$envelope_file" \
|
||||
| base64 -d \
|
||||
| openssl pkeyutl -decrypt -inkey "$private_key" \
|
||||
-pkeyopt rsa_padding_mode:oaep \
|
||||
-pkeyopt rsa_oaep_md:sha256 \
|
||||
-pkeyopt rsa_mgf1_md:sha256 > "$payload_file"
|
||||
|
||||
jq -e '. | keys == ["algorithm","keyFingerprint","token","tokenVersion","updatedAt"]' \
|
||||
"$payload_file" >/dev/null
|
||||
test "$(jq -r '.algorithm // empty' "$payload_file")" = "$expected_algorithm"
|
||||
test "$(jq -r '.keyFingerprint // empty' "$payload_file")" = "$expected_fingerprint"
|
||||
test "$(jq -r '.tokenVersion // empty' "$payload_file")" = \
|
||||
"$(jq -r '.data.tokenVersion' "$envelope_file")"
|
||||
test "$(jq -r '.updatedAt // empty' "$payload_file")" = \
|
||||
"$(jq -r '.data.updatedAt' "$envelope_file")"
|
||||
jq -j '.token' "$payload_file" > "$candidate_file"
|
||||
|
||||
test "$(wc -c < "$candidate_file")" = '64'
|
||||
grep -Eq '^[A-Za-z0-9_-]{64}$' "$candidate_file"
|
||||
chmod 600 "$candidate_file"
|
||||
|
||||
token="$(cat "$candidate_file")"
|
||||
{
|
||||
printf 'url = "%s"\n' "$status_url"
|
||||
printf 'proto = "=https"\n'
|
||||
printf 'tlsv1.2\n'
|
||||
printf 'fail\nsilent\nshow-error\n'
|
||||
printf 'max-time = 30\n'
|
||||
printf 'header = "Authorization: Bearer %s"\n' "$token"
|
||||
} | curl --config - > "$status_file"
|
||||
unset token
|
||||
|
||||
jq -e '.success == true and .data.enabled == true and .data.webhookConfigured == true' \
|
||||
"$status_file" >/dev/null
|
||||
mv -f "$candidate_file" "$destination"
|
||||
chmod 600 "$destination"
|
||||
trap - EXIT HUP INT TERM
|
||||
rm -f "$envelope_file" "$payload_file" "$status_file"
|
||||
printf 'Bird gateway credential bootstrapped and authenticated.\n'
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
fwrite(STDERR, "This command is CLI-only.\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
const WD = __DIR__ . '/../services/nginx/app';
|
||||
require_once WD . '/vendor/autoload.php';
|
||||
require_once WD . '/config.php';
|
||||
require_once WD . '/classes/db.php';
|
||||
require_once WD . '/modules/bird/classes/bird_control_plane_schema_bootstrap.php';
|
||||
|
||||
$command = $argv[1] ?? 'check';
|
||||
if (!in_array($command, ['check', 'apply'], true)) {
|
||||
fwrite(STDERR, "Usage: scripts/bird-control-plane-schema.php check|apply --yes\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$pdo = \classes\db::getPDO();
|
||||
if ($command === 'apply') {
|
||||
if (($argv[2] ?? '') !== '--yes') {
|
||||
fwrite(STDERR, "Refusing schema mutation without: apply --yes\n");
|
||||
exit(2);
|
||||
}
|
||||
\bird\classes\bird_control_plane_schema_bootstrap::apply($pdo);
|
||||
}
|
||||
|
||||
$status = \bird\classes\bird_control_plane_schema_bootstrap::check($pdo);
|
||||
fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
exit($status['ready'] ? 0 : 1);
|
||||
@@ -144,7 +144,7 @@ function requestJson({ method = "GET", port, path: requestPath, body = null, hea
|
||||
raw += chunk;
|
||||
});
|
||||
response.on("end", () => {
|
||||
let decoded = {};
|
||||
let decoded;
|
||||
try {
|
||||
decoded = raw.trim() === "" ? {} : JSON.parse(raw);
|
||||
} catch {
|
||||
|
||||
@@ -95,7 +95,7 @@ function directCaddyBaseUrl(baseUrl) {
|
||||
}
|
||||
|
||||
function isLocalHost(hostname) {
|
||||
const normalized = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
|
||||
const normalized = String(hostname || "").toLowerCase().replace(/^\x5b|\x5d$/g, "");
|
||||
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
|
||||
}
|
||||
|
||||
@@ -227,11 +227,7 @@ async function connectCurrentContainerToComposeNetwork(rootDir, composeProject)
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/already exists|already connected/i.test(stderr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return /already exists|already connected/i.test(stderr);
|
||||
}
|
||||
|
||||
async function disconnectCurrentContainerFromComposeNetwork(rootDir, composeProject) {
|
||||
@@ -833,7 +829,7 @@ async function main() {
|
||||
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
|
||||
);
|
||||
} catch (error) {
|
||||
let operationSnapshot = null;
|
||||
let operationSnapshot;
|
||||
try {
|
||||
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
|
||||
token: authToken,
|
||||
@@ -959,9 +955,10 @@ async function main() {
|
||||
allowFailure: true,
|
||||
}).catch(() => {});
|
||||
|
||||
if (gatewayId !== null && fixture?.auth_token) {
|
||||
const fixtureAuthToken = fixture?.auth_token;
|
||||
if (gatewayId !== null && fixtureAuthToken) {
|
||||
await apiRequest(baseUrl, "DELETE", `/edge-gateways/${gatewayId}`, {
|
||||
token: String(fixture.auth_token),
|
||||
token: String(fixtureAuthToken),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
|
||||
@@ -405,6 +405,10 @@ def render_api_reference_topic() -> str:
|
||||
' title="API Reference" id="API-Reference">\n'
|
||||
f"\n <!-- {AUTOGEN_NOTE} -->\n"
|
||||
" <p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p>\n"
|
||||
" <p>The edge broker's <code>/api/health</code> response additionally exposes a "
|
||||
"<code>lastActivityAt</code> field (ISO 8601 timestamp). It reports the most recent "
|
||||
"successful HTTP request handled by the broker container and defaults to the container's "
|
||||
"start time when no request has been processed yet.</p>\n"
|
||||
"</topic>\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -146,6 +146,13 @@ tar \
|
||||
-cf - \
|
||||
Dockerfile \
|
||||
Dockerfile.coolify-api \
|
||||
docker-compose.yml \
|
||||
docker-compose.example.yml \
|
||||
docker-compose.prod.standalone.yml \
|
||||
scripts/bird-control-plane-auto-activate.php \
|
||||
scripts/bird-control-plane-bootstrap-local.sh \
|
||||
scripts/xlvask-automation-migrate.php \
|
||||
services/coolify/api/start.sh \
|
||||
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 -
|
||||
|
||||
+3
-3
@@ -4,9 +4,9 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
+37
@@ -40,6 +40,10 @@ class Receiver extends Writable {
|
||||
* extensions
|
||||
* @param {Boolean} [options.isServer=false] Specifies whether to operate in
|
||||
* client or server mode
|
||||
* @param {Number} [options.maxBufferedChunks=0] The maximum number of
|
||||
* buffered data chunks
|
||||
* @param {Number} [options.maxFragments=0] The maximum number of message
|
||||
* fragments
|
||||
* @param {Number} [options.maxPayload=0] The maximum allowed message length
|
||||
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
|
||||
* not to skip UTF-8 validation for text and close messages
|
||||
@@ -54,6 +58,8 @@ class Receiver extends Writable {
|
||||
this._binaryType = options.binaryType || BINARY_TYPES[0];
|
||||
this._extensions = options.extensions || {};
|
||||
this._isServer = !!options.isServer;
|
||||
this._maxBufferedChunks = options.maxBufferedChunks | 0;
|
||||
this._maxFragments = options.maxFragments | 0;
|
||||
this._maxPayload = options.maxPayload | 0;
|
||||
this._skipUTF8Validation = !!options.skipUTF8Validation;
|
||||
this[kWebSocket] = undefined;
|
||||
@@ -71,6 +77,7 @@ class Receiver extends Writable {
|
||||
|
||||
this._totalPayloadLength = 0;
|
||||
this._messageLength = 0;
|
||||
this._numFragments = 0;
|
||||
this._fragments = [];
|
||||
|
||||
this._errored = false;
|
||||
@@ -89,6 +96,22 @@ class Receiver extends Writable {
|
||||
_write(chunk, encoding, cb) {
|
||||
if (this._opcode === 0x08 && this._state == GET_INFO) return cb();
|
||||
|
||||
if (
|
||||
this._maxBufferedChunks > 0 &&
|
||||
this._buffers.length >= this._maxBufferedChunks
|
||||
) {
|
||||
cb(
|
||||
this.createError(
|
||||
RangeError,
|
||||
'Too many buffered chunks',
|
||||
false,
|
||||
1008,
|
||||
'WS_ERR_TOO_MANY_BUFFERED_PARTS'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this._bufferedBytes += chunk.length;
|
||||
this._buffers.push(chunk);
|
||||
this.startLoop(cb);
|
||||
@@ -478,6 +501,19 @@ class Receiver extends Writable {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._maxFragments > 0 && ++this._numFragments > this._maxFragments) {
|
||||
const error = this.createError(
|
||||
RangeError,
|
||||
'Too many message fragments',
|
||||
false,
|
||||
1008,
|
||||
'WS_ERR_TOO_MANY_BUFFERED_PARTS'
|
||||
);
|
||||
|
||||
cb(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._compressed) {
|
||||
this._state = INFLATING;
|
||||
this.decompress(data, cb);
|
||||
@@ -550,6 +586,7 @@ class Receiver extends Writable {
|
||||
this._totalPayloadLength = 0;
|
||||
this._messageLength = 0;
|
||||
this._fragmented = 0;
|
||||
this._numFragments = 0;
|
||||
this._fragments = [];
|
||||
|
||||
if (this._opcode === 2) {
|
||||
|
||||
+6
-1
@@ -4,6 +4,9 @@
|
||||
|
||||
const { Duplex } = require('stream');
|
||||
const { randomFillSync } = require('crypto');
|
||||
const {
|
||||
types: { isUint8Array }
|
||||
} = require('util');
|
||||
|
||||
const PerMessageDeflate = require('./permessage-deflate');
|
||||
const { EMPTY_BUFFER, kWebSocket, NOOP } = require('./constants');
|
||||
@@ -200,8 +203,10 @@ class Sender {
|
||||
|
||||
if (typeof data === 'string') {
|
||||
buf.write(data, 2);
|
||||
} else {
|
||||
} else if (isUint8Array(data)) {
|
||||
buf.set(data, 2);
|
||||
} else {
|
||||
throw new TypeError('Second argument must be a string or a Uint8Array');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -43,6 +43,10 @@ class WebSocketServer extends EventEmitter {
|
||||
* called
|
||||
* @param {Function} [options.handleProtocols] A hook to handle protocols
|
||||
* @param {String} [options.host] The hostname where to bind the server
|
||||
* @param {Number} [options.maxBufferedChunks=262144] The maximum number of
|
||||
* buffered data chunks
|
||||
* @param {Number} [options.maxFragments=16384] The maximum number of message
|
||||
* fragments
|
||||
* @param {Number} [options.maxPayload=104857600] The maximum allowed message
|
||||
* size
|
||||
* @param {Boolean} [options.noServer=false] Enable no server mode
|
||||
@@ -65,6 +69,8 @@ class WebSocketServer extends EventEmitter {
|
||||
options = {
|
||||
allowSynchronousEvents: true,
|
||||
autoPong: true,
|
||||
maxBufferedChunks: 256 * 1024,
|
||||
maxFragments: 16 * 1024,
|
||||
maxPayload: 100 * 1024 * 1024,
|
||||
skipUTF8Validation: false,
|
||||
perMessageDeflate: false,
|
||||
@@ -424,6 +430,8 @@ class WebSocketServer extends EventEmitter {
|
||||
|
||||
ws.setSocket(socket, head, {
|
||||
allowSynchronousEvents: this.options.allowSynchronousEvents,
|
||||
maxBufferedChunks: this.options.maxBufferedChunks,
|
||||
maxFragments: this.options.maxFragments,
|
||||
maxPayload: this.options.maxPayload,
|
||||
skipUTF8Validation: this.options.skipUTF8Validation
|
||||
});
|
||||
|
||||
+14
@@ -201,6 +201,10 @@ class WebSocket extends EventEmitter {
|
||||
* multiple times in the same tick
|
||||
* @param {Function} [options.generateMask] The function used to generate the
|
||||
* masking key
|
||||
* @param {Number} [options.maxBufferedChunks=0] The maximum number of
|
||||
* buffered data chunks
|
||||
* @param {Number} [options.maxFragments=0] The maximum number of message
|
||||
* fragments
|
||||
* @param {Number} [options.maxPayload=0] The maximum allowed message size
|
||||
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
|
||||
* not to skip UTF-8 validation for text and close messages
|
||||
@@ -212,6 +216,8 @@ class WebSocket extends EventEmitter {
|
||||
binaryType: this.binaryType,
|
||||
extensions: this._extensions,
|
||||
isServer: this._isServer,
|
||||
maxBufferedChunks: options.maxBufferedChunks,
|
||||
maxFragments: options.maxFragments,
|
||||
maxPayload: options.maxPayload,
|
||||
skipUTF8Validation: options.skipUTF8Validation
|
||||
});
|
||||
@@ -640,6 +646,10 @@ module.exports = WebSocket;
|
||||
* masking key
|
||||
* @param {Number} [options.handshakeTimeout] Timeout in milliseconds for the
|
||||
* handshake request
|
||||
* @param {Number} [options.maxBufferedChunks=262144] The maximum number of
|
||||
* buffered data chunks
|
||||
* @param {Number} [options.maxFragments=16384] The maximum number of message
|
||||
* fragments
|
||||
* @param {Number} [options.maxPayload=104857600] The maximum allowed message
|
||||
* size
|
||||
* @param {Number} [options.maxRedirects=10] The maximum number of redirects
|
||||
@@ -660,6 +670,8 @@ function initAsClient(websocket, address, protocols, options) {
|
||||
autoPong: true,
|
||||
closeTimeout: CLOSE_TIMEOUT,
|
||||
protocolVersion: protocolVersions[1],
|
||||
maxBufferedChunks: 256 * 1024,
|
||||
maxFragments: 16 * 1024,
|
||||
maxPayload: 100 * 1024 * 1024,
|
||||
skipUTF8Validation: false,
|
||||
perMessageDeflate: true,
|
||||
@@ -1017,6 +1029,8 @@ function initAsClient(websocket, address, protocols, options) {
|
||||
websocket.setSocket(socket, head, {
|
||||
allowSynchronousEvents: opts.allowSynchronousEvents,
|
||||
generateMask: opts.generateMask,
|
||||
maxBufferedChunks: opts.maxBufferedChunks,
|
||||
maxFragments: opts.maxFragments,
|
||||
maxPayload: opts.maxPayload,
|
||||
skipUTF8Validation: opts.skipUTF8Validation
|
||||
});
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ws",
|
||||
"version": "8.20.0",
|
||||
"version": "8.21.1",
|
||||
"description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",
|
||||
"keywords": [
|
||||
"HyBi",
|
||||
@@ -66,5 +66,9 @@
|
||||
"nyc": "^15.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
"utf-8-validate": "^6.0.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"bufferutil": true,
|
||||
"utf-8-validate": true
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -6,13 +6,13 @@
|
||||
"": {
|
||||
"name": "truckwash-edge-broker",
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
"ws": "^8.21.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"test:live": "node --test live/live-smoke.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
"ws": "^8.21.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ function resolveManagerUrl(options = {}) {
|
||||
return trimTrailingSlash(options.managerUrl || process.env.EDGE_MANAGER_URL || process.env.EDGE_PUBLIC_API_URL || "");
|
||||
}
|
||||
|
||||
function resolveAuthMode(options = {}, managerUrl = "") {
|
||||
function resolveAuthMode(options = {}) {
|
||||
if (options.authMode) {
|
||||
return options.authMode;
|
||||
}
|
||||
@@ -179,7 +179,7 @@ function rejectUpgrade(socket, statusCode, errorCode, message, details = {}) {
|
||||
export function createBrokerServer(options = {}) {
|
||||
const sharedSecret = resolveSharedSecret(options);
|
||||
const managerUrl = resolveManagerUrl(options);
|
||||
const authMode = resolveAuthMode(options, managerUrl);
|
||||
const authMode = resolveAuthMode(options);
|
||||
const commandTimeoutMs = options.commandTimeoutMs ?? 10000;
|
||||
const shellOpenTimeoutMs = options.shellOpenTimeoutMs ?? DEFAULT_SHELL_OPEN_TIMEOUT_MS;
|
||||
|
||||
@@ -189,6 +189,8 @@ export function createBrokerServer(options = {}) {
|
||||
const browserStreamSessions = new Map();
|
||||
const gatewayStreamSessions = new Map();
|
||||
const inflightGatewaySyncs = new Map();
|
||||
const containerStartedAt = currentTimestamp();
|
||||
let lastActivityAt = containerStartedAt;
|
||||
|
||||
const managerRequest = async (path, body = {}, method = "POST") => {
|
||||
if (!managerUrl) {
|
||||
@@ -480,6 +482,7 @@ export function createBrokerServer(options = {}) {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
lastActivityAt = currentTimestamp();
|
||||
if (req.method === "GET" && url.pathname === "/api/health") {
|
||||
jsonResponse(res, 200, {
|
||||
ok: true,
|
||||
@@ -488,6 +491,7 @@ export function createBrokerServer(options = {}) {
|
||||
manager_url_configured: Boolean(managerUrl),
|
||||
shared_secret_configured: Boolean(sharedSecret),
|
||||
agents_connected: agents.size,
|
||||
lastActivityAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1083,6 +1087,10 @@ export function createBrokerServer(options = {}) {
|
||||
pendingCommands,
|
||||
managerUrl,
|
||||
authMode,
|
||||
containerStartedAt,
|
||||
get lastActivityAt() {
|
||||
return lastActivityAt;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -219,6 +219,10 @@ test("broker exposes health and shared-secret diagnostics", async () => {
|
||||
assert.equal(healthJson.auth_mode, "manager");
|
||||
assert.equal(healthJson.manager_url_configured, true);
|
||||
assert.equal(healthJson.shared_secret_configured, true);
|
||||
assert.equal(typeof healthJson.lastActivityAt, "string");
|
||||
assert.match(healthJson.lastActivityAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
assert.ok(healthJson.lastActivityAt >= broker.state.containerStartedAt);
|
||||
assert.equal(healthJson.lastActivityAt, broker.state.lastActivityAt);
|
||||
|
||||
const invalidSecretResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
|
||||
method: "POST",
|
||||
@@ -247,6 +251,41 @@ test("broker exposes health and shared-secret diagnostics", async () => {
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker updates lastActivityAt after each successful request", async () => {
|
||||
const broker = createBrokerServer({ authMode: "manager", sharedSecret: "secret", managerUrl: "http://manager.test" });
|
||||
const address = await broker.listen(0);
|
||||
const port = address.port;
|
||||
|
||||
assert.equal(broker.state.lastActivityAt, broker.state.containerStartedAt);
|
||||
|
||||
const firstResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
const firstJson = await firstResponse.json();
|
||||
const firstActivityAt = broker.state.lastActivityAt;
|
||||
|
||||
assert.equal(typeof firstJson.lastActivityAt, "string");
|
||||
assert.equal(firstJson.lastActivityAt, firstActivityAt);
|
||||
assert.ok(firstActivityAt >= broker.state.containerStartedAt);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-edge-broker-secret": "secret",
|
||||
},
|
||||
});
|
||||
|
||||
assert.notEqual(broker.state.lastActivityAt, firstActivityAt);
|
||||
assert.ok(broker.state.lastActivityAt > firstActivityAt);
|
||||
|
||||
const secondResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
const secondJson = await secondResponse.json();
|
||||
|
||||
assert.equal(secondJson.lastActivityAt, broker.state.lastActivityAt);
|
||||
|
||||
await broker.close();
|
||||
});
|
||||
|
||||
test("broker bridges browser shell sessions through the connected agent", async () => {
|
||||
const closedSessions = [];
|
||||
const broker = createBrokerServer({
|
||||
|
||||
@@ -39,8 +39,8 @@ test("traefik does not expose a dedicated public edge broker port", () => {
|
||||
test("base docker compose routes edge broker traffic through traefik", () => {
|
||||
const serviceBlock = readComposeServiceBlock(baseComposeSource, "edge-broker");
|
||||
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
|
||||
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-strict\}/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
|
||||
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\x7bEDGE_AUTH_MODE:-strict\x7d/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\x7bEDGE_MANAGER_URL:-http:\/\/caddy\x7d/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.priority=200/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/);
|
||||
@@ -55,8 +55,8 @@ test("base docker compose routes edge broker traffic through traefik", () => {
|
||||
test("example docker compose routes edge broker traffic through traefik", () => {
|
||||
const serviceBlock = readComposeServiceBlock(exampleComposeSource, "edge-broker");
|
||||
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
|
||||
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-strict\}/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
|
||||
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\x7bEDGE_AUTH_MODE:-strict\x7d/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\x7bEDGE_MANAGER_URL:-http:\/\/caddy\x7d/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.example\.com`\) && PathPrefix\(`\/edge-broker`\)/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
|
||||
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
|
||||
@@ -65,8 +65,8 @@ test("example docker compose routes edge broker traffic through traefik", () =>
|
||||
test("standalone production compose routes edge broker traffic through traefik", () => {
|
||||
const serviceBlock = readComposeServiceBlock(standaloneProdComposeSource, "edge-broker");
|
||||
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
|
||||
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-manager\}/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
|
||||
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\x7bEDGE_AUTH_MODE:-manager\x7d/);
|
||||
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\x7bEDGE_MANAGER_URL:-http:\/\/caddy\x7d/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.priority=200/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/);
|
||||
@@ -78,8 +78,8 @@ test("standalone production compose routes edge broker traffic through traefik",
|
||||
|
||||
test("compose config does not provide insecure broker secret defaults", () => {
|
||||
for (const composeSource of [baseComposeSource, exampleComposeSource]) {
|
||||
assert.match(composeSource, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/);
|
||||
assert.match(composeSource, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/);
|
||||
assert.match(composeSource, /EDGE_BROKER_URL:\s*\$\x7bEDGE_BROKER_URL:-http:\/\/edge-broker:4300\x7d/);
|
||||
assert.match(composeSource, /EDGE_BROKER_SHARED_SECRET:\s*\$\x7bEDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\x7d/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ test("base docker compose wires the broker into each php worker", () => {
|
||||
for (const serviceName of ["php1", "php2", "php3", "php4", "php5", "php-staging", "php-cron"]) {
|
||||
const serviceBlock = readComposeServiceBlock(baseComposeSource, serviceName);
|
||||
assert.match(serviceBlock, /\n\s+depends_on:\s*\n[\s\S]*?\n\s+- edge-broker/);
|
||||
assert.match(serviceBlock, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/);
|
||||
assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/);
|
||||
assert.match(serviceBlock, /EDGE_BROKER_URL:\s*\$\x7bEDGE_BROKER_URL:-http:\/\/edge-broker:4300\x7d/);
|
||||
assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\x7bEDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\x7d/);
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class account_deletion_http_exception extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, public readonly int $status)
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Explicit account-deletion schema management.
|
||||
*
|
||||
* apply() must only be invoked by the dedicated CLI. Web requests and cron jobs
|
||||
* are deliberately limited to the read-only check().
|
||||
*/
|
||||
class account_deletion_schema_bootstrap
|
||||
{
|
||||
/** @return array{ready:bool,missing:array<int,string>} */
|
||||
public static function check(): array
|
||||
{
|
||||
global $db;
|
||||
$missing = [];
|
||||
foreach (['account_deletion_requests', 'account_deletion_credential_attempts', 'account_deletion_outbox'] as $table) {
|
||||
$tableSql = $db->escape_string($table);
|
||||
$result = $db->query("SHOW TABLES LIKE '$tableSql'");
|
||||
if ($result === false || $result->num_rows === 0) {
|
||||
$missing[] = 'table:' . $table;
|
||||
}
|
||||
}
|
||||
foreach (['users' => 'deleted_at', 'subusers' => 'deleted_at'] as $table => $column) {
|
||||
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
|
||||
if ($result === false || $result->num_rows === 0) {
|
||||
$missing[] = 'column:' . $table . '.' . $column;
|
||||
}
|
||||
}
|
||||
if (!in_array('table:account_deletion_requests', $missing, true)) {
|
||||
$result = $db->query("SHOW COLUMNS FROM account_deletion_requests LIKE 'manual_review_required_at'");
|
||||
if ($result === false || $result->num_rows === 0) {
|
||||
$missing[] = 'column:account_deletion_requests.manual_review_required_at';
|
||||
}
|
||||
}
|
||||
return ['ready' => $missing === [], 'missing' => $missing];
|
||||
}
|
||||
|
||||
public static function apply(): void
|
||||
{
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
throw new \RuntimeException('Account deletion schema changes are CLI-only.');
|
||||
}
|
||||
global $db;
|
||||
self::execute("CREATE TABLE IF NOT EXISTS account_deletion_requests (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
request_id CHAR(36) NOT NULL,
|
||||
principal_type VARCHAR(16) NOT NULL,
|
||||
principal_id BIGINT UNSIGNED NOT NULL,
|
||||
customer_number_snapshot INT NULL,
|
||||
active_principal_key VARCHAR(191) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'requested',
|
||||
policy_version VARCHAR(32) NOT NULL,
|
||||
retained_data_json LONGTEXT NOT NULL,
|
||||
request_ip VARCHAR(45) NULL,
|
||||
request_user_agent VARCHAR(512) NULL,
|
||||
retry_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
failure_code VARCHAR(191) NULL,
|
||||
requested_at DATETIME NOT NULL,
|
||||
processing_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
next_attempt_at DATETIME NULL,
|
||||
manual_review_required_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_account_deletion_request_id (request_id),
|
||||
UNIQUE KEY uniq_account_deletion_active_principal (active_principal_key),
|
||||
INDEX idx_account_deletion_worker (status, next_attempt_at, requested_at),
|
||||
INDEX idx_account_deletion_principal (principal_type, principal_id, requested_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||||
self::ensureColumn('account_deletion_requests', 'manual_review_required_at', 'DATETIME NULL AFTER `next_attempt_at`');
|
||||
self::execute("CREATE TABLE IF NOT EXISTS account_deletion_credential_attempts (
|
||||
throttle_key CHAR(64) NOT NULL,
|
||||
attempt_count INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
window_started_at DATETIME NOT NULL,
|
||||
blocked_until DATETIME NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (throttle_key), INDEX idx_account_deletion_throttle_expiry (updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||||
self::execute("CREATE TABLE IF NOT EXISTS account_deletion_outbox (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
request_id CHAR(36) NOT NULL,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
payload_json LONGTEXT NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
attempts INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
available_at DATETIME NOT NULL,
|
||||
processing_at DATETIME NULL,
|
||||
delivered_at DATETIME NULL,
|
||||
last_error VARCHAR(191) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id), UNIQUE KEY uniq_account_deletion_outbox_event (request_id, event_type),
|
||||
INDEX idx_account_deletion_outbox_delivery (status, available_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||||
self::ensureColumn('account_deletion_outbox', 'processing_at', 'DATETIME NULL AFTER `available_at`');
|
||||
self::ensureColumn('users', 'deleted_at', 'DATETIME NULL AFTER `updated_at`');
|
||||
self::ensureColumn('subusers', 'deleted_at', 'DATETIME NULL AFTER `suspended_at`');
|
||||
self::ensureIndex('users', 'idx_users_deleted_at', '`deleted_at`');
|
||||
self::ensureIndex('subusers', 'idx_subusers_deleted_at', '`deleted_at`');
|
||||
}
|
||||
|
||||
private static function execute(string $sql): void
|
||||
{
|
||||
global $db;
|
||||
if ($db->query($sql) === false) {
|
||||
throw new \RuntimeException('Account deletion schema operation failed.');
|
||||
}
|
||||
}
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
|
||||
if ($result === false) throw new \RuntimeException('Unable to inspect account deletion schema.');
|
||||
if ($result->num_rows === 0) self::execute("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
|
||||
}
|
||||
|
||||
private static function ensureIndex(string $table, string $index, string $columns): void
|
||||
{
|
||||
global $db;
|
||||
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$index'");
|
||||
if ($result === false) throw new \RuntimeException('Unable to inspect account deletion indexes.');
|
||||
if ($result->num_rows === 0) self::execute("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,8 +37,21 @@ class attachment_store implements minio_uploads_i
|
||||
*/
|
||||
public function isValidFilePath(string $filePath): bool
|
||||
{
|
||||
// Check if the file path is valid
|
||||
return preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $filePath) === 1;
|
||||
if (
|
||||
$filePath === ''
|
||||
|| str_starts_with($filePath, '/')
|
||||
|| preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $filePath) !== 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (explode('/', $filePath) as $segment) {
|
||||
if ($segment === '' || $segment === '.' || $segment === '..') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +95,10 @@ class attachment_store implements minio_uploads_i
|
||||
{
|
||||
$host = 'https://api.truckwash.io';
|
||||
|
||||
$this->requireValidFilePath($fileName);
|
||||
$encodedPath = implode('/', array_map('rawurlencode', explode('/', $fileName)));
|
||||
|
||||
// Generate a direct download URL for the given file name
|
||||
return $host . '/files/' . $fileName;
|
||||
return $host . '/files/' . $encodedPath;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace classes;
|
||||
|
||||
require_once WD . '/classes/account_deletion_service.php';
|
||||
|
||||
use classes\totp;
|
||||
use Exception;
|
||||
use interfaces\authentication_i;
|
||||
@@ -69,6 +71,10 @@ class authentication implements authentication_i
|
||||
|
||||
public function create_2fa_token(int $id, string $type): string
|
||||
{
|
||||
$principalType = $type === '2FA_VERIFICATION_SUBUSER' ? 'subuser' : 'customer';
|
||||
if (account_deletion_service::principalIsBlocked($principalType, $id)) {
|
||||
throw new Exception('Account unavailable');
|
||||
}
|
||||
// Create a temporary 2FA token
|
||||
$token = bin2hex(random_bytes(32));
|
||||
(new tokens_o())->create($id, $token, $type);
|
||||
@@ -100,6 +106,9 @@ class authentication implements authentication_i
|
||||
throw new \Exception('User not found for customer number: ' . $customer_number);
|
||||
}
|
||||
$user_id = $user->id;
|
||||
if (account_deletion_service::principalIsBlocked('customer', (int)$user_id)) {
|
||||
throw new Exception('Account unavailable');
|
||||
}
|
||||
// Save the token in the database
|
||||
(new tokens_o())->create($user_id, $token, 'AUTH_TOKEN');
|
||||
return $token;
|
||||
@@ -107,6 +116,9 @@ class authentication implements authentication_i
|
||||
|
||||
public function create_token_by_user_id(int $user_id): string
|
||||
{
|
||||
if (account_deletion_service::principalIsBlocked('customer', $user_id)) {
|
||||
throw new Exception('Account unavailable');
|
||||
}
|
||||
// Create a token
|
||||
$token = bin2hex(random_bytes(32));
|
||||
// Save the token in the database
|
||||
@@ -116,6 +128,9 @@ class authentication implements authentication_i
|
||||
|
||||
public function create_employee_token(int $employee_id): string
|
||||
{
|
||||
if (account_deletion_service::principalIsBlocked('customer', $employee_id)) {
|
||||
throw new Exception('Account unavailable');
|
||||
}
|
||||
// Create a token
|
||||
$token = bin2hex(random_bytes(32));
|
||||
// Save the token in the database
|
||||
@@ -123,13 +138,26 @@ class authentication implements authentication_i
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function create_impersonation_token(int $target_user_id, int $actor_user_id): string
|
||||
{
|
||||
if ($actor_user_id <= 0 || account_deletion_service::principalIsBlocked('customer', $target_user_id)) {
|
||||
throw new Exception('Account unavailable');
|
||||
}
|
||||
$token = bin2hex(random_bytes(32));
|
||||
(new tokens_o())->create($target_user_id, $token, 'AUTH_TOKEN_IMPERSONATION:' . $actor_user_id);
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function validate_token(string $token): bool
|
||||
{
|
||||
// First: try validating as a classic user auth token
|
||||
try {
|
||||
$dbToken = (new tokens_o())->getToken($token);
|
||||
if ($dbToken && $dbToken->id && $dbToken->type->value() === 'AUTH_TOKEN') {
|
||||
return true;
|
||||
if ($dbToken && $dbToken->id && $this->isClassicAuthTokenType((string)$dbToken->type->value())) {
|
||||
return !account_deletion_service::principalIsBlocked(
|
||||
'customer',
|
||||
(int)$dbToken->user_id->value()
|
||||
);
|
||||
}
|
||||
} catch (Exception) {
|
||||
// Ignore and continue to subuser session validation
|
||||
@@ -137,7 +165,7 @@ class authentication implements authentication_i
|
||||
// Fallback: try validating as a subuser session token
|
||||
$subuser = (new subusers_o())->getSubuserBySessionToken($token);
|
||||
if ($subuser !== null) {
|
||||
return true;
|
||||
return !account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -168,7 +196,10 @@ class authentication implements authentication_i
|
||||
if (!$token->id) {
|
||||
return false;
|
||||
}
|
||||
if ($token->type->value() !== 'AUTH_TOKEN') {
|
||||
if (!$this->isClassicAuthTokenType((string)$token->type->value())) {
|
||||
return false;
|
||||
}
|
||||
if (account_deletion_service::principalIsBlocked('customer', (int)$token->user_id->value())) {
|
||||
return false;
|
||||
}
|
||||
// Get the user from the database
|
||||
@@ -177,6 +208,11 @@ class authentication implements authentication_i
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function isClassicAuthTokenType(string $type): bool
|
||||
{
|
||||
return $type === 'AUTH_TOKEN' || str_starts_with($type, 'AUTH_TOKEN_IMPERSONATION:');
|
||||
}
|
||||
|
||||
public function get_plate_scanner(): plate_scanners_o|false
|
||||
{
|
||||
// Get the token from the headers
|
||||
@@ -227,6 +263,9 @@ class authentication implements authentication_i
|
||||
if ($subuser === null) {
|
||||
return false;
|
||||
}
|
||||
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
|
||||
return false;
|
||||
}
|
||||
$customerNumberContext = null;
|
||||
if (isset($headers['X-Customer-Number'])) {
|
||||
$customerNumberContext = (int)$headers['X-Customer-Number'];
|
||||
|
||||
@@ -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
@@ -212,19 +212,7 @@ class bird implements bird_i
|
||||
throw new Exception('cURL error: ' . $err);
|
||||
}
|
||||
curl_close($ch);
|
||||
// Debug slack
|
||||
$data = json_decode($body, true) ?? [];
|
||||
$resp = $resp === false ? 'cURL error with no response' : $resp;
|
||||
$slack_debug_message = "*Bird API Request Debug:*"
|
||||
. "\nEndpoint: $url"
|
||||
. "\nMethod: $method"
|
||||
. "\nStatus: $code"
|
||||
. "\nPayload Keys: " . implode(',', array_keys($data))
|
||||
. "\nResponse: $resp";
|
||||
|
||||
// Send slack notification for every request for easier debugging of issues in production (can be removed later if too noisy)
|
||||
$slack = new \classes\slack();
|
||||
$slack->send_message($slack_debug_message);
|
||||
return [
|
||||
'status_code' => (int)$code,
|
||||
'body' => $resp,
|
||||
@@ -1073,4 +1061,3 @@ class bird implements bird_i
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace classes;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
require_once __DIR__ . '/cors_policy.php';
|
||||
|
||||
class coolify_manager
|
||||
{
|
||||
private const KINDS = ['database', 'redis', 'minio'];
|
||||
@@ -1070,7 +1072,9 @@ class coolify_manager
|
||||
$targetPublicUrl,
|
||||
$resourceUuid,
|
||||
self::resourceFirstExposedPort($resource, $target),
|
||||
$resource['custom_labels'] ?? null
|
||||
$resource['custom_labels'] ?? null,
|
||||
$app,
|
||||
self::gatewayRouteTargetCorsConfig($target)
|
||||
);
|
||||
$update = $resourceType === 'service'
|
||||
? $client->updateService($resourceUuid, $updatePayload)
|
||||
@@ -2420,7 +2424,9 @@ class coolify_manager
|
||||
string $publicUrl,
|
||||
string $resourceUuid = '',
|
||||
?int $port = null,
|
||||
mixed $existingLabels = null
|
||||
mixed $existingLabels = null,
|
||||
string $app = '',
|
||||
string $corsConfig = ''
|
||||
): array
|
||||
{
|
||||
$decodedLabels = self::decodeCoolifyLabels($existingLabels);
|
||||
@@ -2435,7 +2441,9 @@ class coolify_manager
|
||||
$publicUrl,
|
||||
$resourceUuid,
|
||||
$routePort,
|
||||
self::gatewayRouteDefaultCertResolver($publicUrl)
|
||||
self::gatewayRouteDefaultCertResolver($publicUrl),
|
||||
$app,
|
||||
$corsConfig
|
||||
);
|
||||
if ($labels !== []) {
|
||||
$payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels(
|
||||
@@ -2460,6 +2468,49 @@ class coolify_manager
|
||||
];
|
||||
}
|
||||
|
||||
private static function gatewayRouteTargetCorsConfig(array $target): string
|
||||
{
|
||||
$context = self::jsonDecode($target['deploy_context_json'] ?? null);
|
||||
$configured = null;
|
||||
|
||||
foreach (['coolify_env', 'runtime_env', 'environment_variables'] as $key) {
|
||||
$env = $context[$key] ?? null;
|
||||
if (is_array($env) && array_key_exists('CORS', $env) && is_scalar($env['CORS'])) {
|
||||
$configured = (string)$env['CORS'];
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['coolify_env_file', 'env'] as $key) {
|
||||
$raw = $context[$key] ?? null;
|
||||
if (!is_string($raw)) {
|
||||
continue;
|
||||
}
|
||||
foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $line) {
|
||||
$line = trim((string)$line);
|
||||
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
|
||||
continue;
|
||||
}
|
||||
[$envKey, $value] = explode('=', $line, 2);
|
||||
if (trim($envKey) === 'CORS') {
|
||||
$configured = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($configured === null) {
|
||||
$runtimeValue = getenv('CORS');
|
||||
if ($runtimeValue !== false) {
|
||||
$configured = $runtimeValue;
|
||||
} elseif (array_key_exists('CORS', $_ENV ?? [])) {
|
||||
$configured = (string)$_ENV['CORS'];
|
||||
} elseif (array_key_exists('CORS', $_SERVER ?? [])) {
|
||||
$configured = (string)$_SERVER['CORS'];
|
||||
}
|
||||
}
|
||||
|
||||
return cors_policy::withRequiredOrigins((string)($configured ?? ''));
|
||||
}
|
||||
|
||||
private static function coolifyProxyUrl(string $publicUrl, ?int $port): string
|
||||
{
|
||||
if ($port === null || $port <= 0) {
|
||||
@@ -2483,7 +2534,9 @@ class coolify_manager
|
||||
string $publicUrl,
|
||||
string $resourceUuid,
|
||||
?int $port = null,
|
||||
?string $certResolver = null
|
||||
?string $certResolver = null,
|
||||
string $app = '',
|
||||
string $corsConfig = ''
|
||||
): array
|
||||
{
|
||||
$resourceUuid = self::gatewayRouteLabelId($resourceUuid);
|
||||
@@ -2508,6 +2561,7 @@ class coolify_manager
|
||||
$certResolver = trim((string)($certResolver ?? ''));
|
||||
$httpLabel = 'http-0-' . $resourceUuid;
|
||||
$httpsLabel = 'https-0-' . $resourceUuid;
|
||||
$isApi = strtolower(trim($app)) === 'api';
|
||||
$labels = [
|
||||
'traefik.enable=true',
|
||||
'traefik.http.middlewares.gzip.compress=true',
|
||||
@@ -2521,12 +2575,18 @@ class coolify_manager
|
||||
$labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}";
|
||||
$labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}";
|
||||
}
|
||||
$httpsMiddlewares = [];
|
||||
if ($isApi) {
|
||||
$corsMiddleware = "{$httpsLabel}-cors";
|
||||
$labels = array_merge($labels, cors_policy::traefikHeadersMiddlewareLabels($corsMiddleware, $corsConfig));
|
||||
$httpsMiddlewares[] = $corsMiddleware;
|
||||
}
|
||||
if ($path !== '/') {
|
||||
$labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}";
|
||||
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip";
|
||||
} else {
|
||||
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip";
|
||||
$httpsMiddlewares[] = "{$httpsLabel}-stripprefix";
|
||||
}
|
||||
$httpsMiddlewares[] = 'gzip';
|
||||
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=" . implode(',', $httpsMiddlewares);
|
||||
$labels[] = "traefik.http.routers.{$httpsLabel}.tls=true";
|
||||
if ($certResolver !== '') {
|
||||
$labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}";
|
||||
|
||||
@@ -29,6 +29,7 @@ class cors_policy
|
||||
'http://localhost:5174',
|
||||
'http://127.0.0.1:5173',
|
||||
'http://127.0.0.1:5174',
|
||||
'capacitor://localhost',
|
||||
];
|
||||
|
||||
public static function normalizeOrigin(?string $value): string
|
||||
@@ -38,7 +39,7 @@ class cors_policy
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (preg_match('#^https?://#i', $value) !== 1) {
|
||||
if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $value) !== 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -48,7 +49,7 @@ class cors_policy
|
||||
}
|
||||
|
||||
$scheme = strtolower((string)$parts['scheme']);
|
||||
if (!in_array($scheme, ['http', 'https'], true)) {
|
||||
if (!in_array($scheme, ['http', 'https', 'capacitor'], true)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -58,6 +59,27 @@ class cors_policy
|
||||
return $scheme . '://' . $host . $port;
|
||||
}
|
||||
|
||||
public static function normalizeRequestOrigin(?string $value): string
|
||||
{
|
||||
$value = trim((string)$value);
|
||||
if ($value === '' || $value === '*') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parts = parse_url($value);
|
||||
if (!is_array($parts)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
foreach (['user', 'pass', 'path', 'query', 'fragment'] as $disallowedPart) {
|
||||
if (array_key_exists($disallowedPart, $parts)) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
return self::normalizeOrigin($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
@@ -66,6 +88,39 @@ class cors_policy
|
||||
return self::REQUIRED_ALLOWED_ORIGINS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public static function traefikHeadersMiddlewareLabels(string $middlewareName, string $corsConfig = ''): array
|
||||
{
|
||||
$middlewareName = trim($middlewareName);
|
||||
if ($middlewareName === '' || preg_match('/^[a-zA-Z0-9-]+$/', $middlewareName) !== 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$allowedHeaders = array_values(array_filter(
|
||||
array_map('trim', explode(',', self::ALLOWED_HEADERS)),
|
||||
static fn(string $header): bool => $header !== '' && $header !== '*'
|
||||
));
|
||||
$allowedMethods = array_values(array_filter(array_map('trim', explode(',', self::ALLOWED_METHODS))));
|
||||
$exposedHeaders = array_values(array_filter(array_map('trim', explode(',', self::EXPOSED_HEADERS))));
|
||||
$prefix = "traefik.http.middlewares.{$middlewareName}.headers";
|
||||
$allowedOrigins = self::allowedOrigins($corsConfig);
|
||||
$originLabel = $allowedOrigins === ['*']
|
||||
? "{$prefix}.accesscontrolalloworiginlistregex=^(https?://[^/]+|capacitor://[^/]+)$"
|
||||
: "{$prefix}.accesscontrolalloworiginlist=" . implode(',', $allowedOrigins);
|
||||
|
||||
return [
|
||||
"{$prefix}.accesscontrolallowcredentials=true",
|
||||
"{$prefix}.accesscontrolallowheaders=" . implode(',', $allowedHeaders),
|
||||
"{$prefix}.accesscontrolallowmethods=" . implode(',', $allowedMethods),
|
||||
$originLabel,
|
||||
"{$prefix}.accesscontrolexposeheaders=" . implode(',', $exposedHeaders),
|
||||
"{$prefix}.accesscontrolmaxage=" . self::MAX_AGE_SECONDS,
|
||||
"{$prefix}.addvaryheader=true",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
@@ -105,8 +160,8 @@ class cors_policy
|
||||
|
||||
public static function isOriginAllowed(?string $origin, string $corsConfig): bool
|
||||
{
|
||||
$origin = self::normalizeOrigin($origin);
|
||||
if ($origin === '' || $origin === '*') {
|
||||
$origin = self::normalizeRequestOrigin($origin);
|
||||
if ($origin === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -119,7 +174,7 @@ class cors_policy
|
||||
*/
|
||||
public static function responseHeaders(?string $origin, string $corsConfig): array
|
||||
{
|
||||
$origin = self::normalizeOrigin($origin);
|
||||
$origin = self::normalizeRequestOrigin($origin);
|
||||
if ($origin === '' || !self::isOriginAllowed($origin, $corsConfig)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -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,702 @@
|
||||
<?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, $scheduled_for);
|
||||
}
|
||||
|
||||
private function executeClaimedRun(
|
||||
cron_task_definition $definition,
|
||||
int $run_id,
|
||||
float $started,
|
||||
?string $scheduled_for = null
|
||||
): 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, $scheduled_for);
|
||||
|
||||
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,
|
||||
isset($queuedRun['scheduled_for']) ? (string)$queuedRun['scheduled_for'] : null
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
?string $scheduled_for = null
|
||||
): 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;
|
||||
}
|
||||
|
||||
// Automatic runs stay anchored to their intended schedule slot. Anchoring
|
||||
// to completion time causes every task to drift by its execution time.
|
||||
$scheduleAnchor = $scheduled_for !== null && strtotime($scheduled_for) !== false
|
||||
? $scheduled_for
|
||||
: $completed_at;
|
||||
$nextRunAt = cron_schedule::nextRunAt($schedule, $scheduleAnchor, 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,121 @@
|
||||
<?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,
|
||||
last_loop_gap_seconds INT UNSIGNED NULL,
|
||||
consecutive_minute_loops INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
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::ensureColumn('cron_worker_state', 'last_loop_gap_seconds', 'INT UNSIGNED NULL AFTER last_loop_finished_at');
|
||||
self::ensureColumn('cron_worker_state', 'consecutive_minute_loops', 'INT UNSIGNED NOT NULL DEFAULT 0 AFTER last_loop_gap_seconds');
|
||||
|
||||
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,361 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Throwable;
|
||||
use traits\boolean_normalization_t;
|
||||
|
||||
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
|
||||
|
||||
class cron_worker
|
||||
{
|
||||
use boolean_normalization_t;
|
||||
|
||||
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) {
|
||||
$pollStarted = microtime(true);
|
||||
$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($pollStarted + $this->poll_seconds);
|
||||
}
|
||||
|
||||
$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(float $nextPollAt): void
|
||||
{
|
||||
while (!$this->should_stop) {
|
||||
$remaining = $nextPollAt - microtime(true);
|
||||
if ($remaining <= 0) {
|
||||
return;
|
||||
}
|
||||
usleep((int)(min(1.0, $remaining) * 1000000));
|
||||
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';
|
||||
$nowSql = $this->sql($now);
|
||||
|
||||
$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, last_loop_gap_seconds, consecutive_minute_loops, stopped_at
|
||||
) VALUES (
|
||||
$workerId, $name, $hostname, $pid, $source, $statusSql, $releaseChannelId, $releaseTargetId,
|
||||
$resourceUuid, $resourceType, $commitSha, $this->poll_seconds, $runCount,
|
||||
$staleRunCount, $errorSql, $nowSql, $nowSql, $loopStarted,
|
||||
$nowSql, NULL, " . ($loopStartedAt !== null ? '1' : '0') . ", $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_gap_seconds = CASE
|
||||
WHEN VALUES(last_loop_started_at) IS NULL OR last_loop_started_at IS NULL THEN last_loop_gap_seconds
|
||||
ELSE GREATEST(0, TIMESTAMPDIFF(SECOND, last_loop_started_at, VALUES(last_loop_started_at)))
|
||||
END,
|
||||
consecutive_minute_loops = CASE
|
||||
WHEN VALUES(last_loop_started_at) IS NULL THEN consecutive_minute_loops
|
||||
WHEN last_loop_started_at IS NULL THEN 1
|
||||
WHEN TIMESTAMPDIFF(SECOND, last_loop_started_at, VALUES(last_loop_started_at)) BETWEEN 0 AND 60
|
||||
THEN consecutive_minute_loops + 1
|
||||
ELSE 1
|
||||
END,
|
||||
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;
|
||||
$loopStartedAt = (string)($row['last_loop_started_at'] ?? '');
|
||||
$loopStartedTs = strtotime($loopStartedAt);
|
||||
$loopAge = $loopStartedTs !== false ? max(0, time() - $loopStartedTs) : null;
|
||||
$loopGap = isset($row['last_loop_gap_seconds']) ? (int)$row['last_loop_gap_seconds'] : null;
|
||||
$consecutiveMinuteLoops = (int)($row['consecutive_minute_loops'] ?? 0);
|
||||
$minuteCadenceVerified = ($row['status'] ?? '') === 'running'
|
||||
&& $loopAge !== null
|
||||
&& $loopAge <= 60
|
||||
&& $loopGap !== null
|
||||
&& $loopGap <= 60
|
||||
&& $consecutiveMinuteLoops >= 2;
|
||||
|
||||
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,
|
||||
'last_loop_age_seconds' => $loopAge,
|
||||
'last_loop_gap_seconds' => $loopGap,
|
||||
'consecutive_minute_loops' => $consecutiveMinuteLoops,
|
||||
'minute_cadence' => [
|
||||
'verified' => $minuteCadenceVerified,
|
||||
'maximum_gap_seconds' => 60,
|
||||
'last_gap_seconds' => $loopGap,
|
||||
'consecutive_loops' => $consecutiveMinuteLoops,
|
||||
],
|
||||
'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 self::normalizeBoolean($value);
|
||||
}
|
||||
|
||||
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) . "'";
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,6 @@ use RuntimeException;
|
||||
|
||||
class customer_order_product_policy
|
||||
{
|
||||
public const ONLY_TANKCLEANING_ATTRIBUTE = 'onlyTankCleaning';
|
||||
public const ONLY_TANKCLEANING_MESSAGE = 'Only tankcleaning customers can only have tankcleaning products in their orders.';
|
||||
|
||||
public static function assertOrderAllowsProduct(int $orderId, int $productId): void
|
||||
{
|
||||
$message = self::orderProductViolationMessage($orderId, $productId);
|
||||
@@ -19,80 +16,31 @@ class customer_order_product_policy
|
||||
|
||||
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
|
||||
{
|
||||
$context = self::loadOrderProductContext($orderId, $productId);
|
||||
if ($context === null) {
|
||||
$customerNumber = self::loadOrderCustomerNumber($orderId);
|
||||
if ($customerNumber === null) {
|
||||
return null;
|
||||
}
|
||||
if ((int)($context['product_id'] ?? 0) < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::onlyTankCleaningViolation((bool)((int)($context['has_only_tank_cleaning'] ?? 0)), $context)
|
||||
? self::ONLY_TANKCLEANING_MESSAGE
|
||||
: null;
|
||||
$violation = (new customer_rule_product_restriction_service())
|
||||
->violationForCustomerProduct($customerNumber, $productId);
|
||||
return $violation === null ? null : (string)$violation['message'];
|
||||
}
|
||||
|
||||
public static function onlyTankCleaningViolation(bool $customerHasOnlyTankCleaning, array $productRow): bool
|
||||
{
|
||||
return $customerHasOnlyTankCleaning && !self::isTankCleaningProductRow($productRow);
|
||||
}
|
||||
|
||||
public static function isTankCleaningProductRow(array $row): bool
|
||||
{
|
||||
return (int)($row['product_category'] ?? $row['category'] ?? 0) === 5
|
||||
|| self::rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
||||
}
|
||||
|
||||
private static function loadOrderProductContext(int $orderId, int $productId): ?array
|
||||
private static function loadOrderCustomerNumber(int $orderId): ?int
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($orderId < 1 || $productId < 1) {
|
||||
if ($orderId < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
o.id AS order_id,
|
||||
o.customer_id AS customer_number,
|
||||
p.id AS product_id,
|
||||
p.name AS product_name,
|
||||
p.category AS product_category,
|
||||
c.name AS category_name,
|
||||
MAX(CASE WHEN ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "' THEN 1 ELSE 0 END) AS has_only_tank_cleaning
|
||||
FROM orders o
|
||||
LEFT JOIN products p ON p.id = {$productId}
|
||||
LEFT JOIN categories c ON c.id = p.category
|
||||
LEFT JOIN users u ON u.customer_number = o.customer_id
|
||||
LEFT JOIN customer_attributes ca ON ca.user_id = u.id
|
||||
AND ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "'
|
||||
WHERE o.id = {$orderId}
|
||||
GROUP BY o.id, o.customer_id, p.id, p.name, p.category, c.name
|
||||
LIMIT 1
|
||||
";
|
||||
|
||||
$result = $db->query($sql);
|
||||
$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();
|
||||
return is_array($row) ? $row : null;
|
||||
$customerNumber = (int)($row['customer_id'] ?? 0);
|
||||
return $customerNumber > 0 ? $customerNumber : null;
|
||||
}
|
||||
|
||||
private static function rowMatchesProductTerms(array $row, array $terms): bool
|
||||
{
|
||||
$haystack = strtolower(trim(
|
||||
(string)($row['product_name'] ?? $row['name'] ?? '') . ' ' .
|
||||
(string)($row['category_name'] ?? '')
|
||||
));
|
||||
|
||||
foreach ($terms as $term) {
|
||||
if ($term !== '' && str_contains($haystack, strtolower($term))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,13 @@
|
||||
namespace classes;
|
||||
|
||||
use objects\orders_o;
|
||||
use objects\products_o;
|
||||
use objects\users_o;
|
||||
|
||||
class customer_product_rule_service
|
||||
{
|
||||
public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer';
|
||||
|
||||
private const ADDON_CATEGORY_ID = 4;
|
||||
private const TANK_CLEANING_CATEGORY_ID = 5;
|
||||
|
||||
/**
|
||||
* @return array{rule:string,message:string}|null
|
||||
* @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
|
||||
{
|
||||
@@ -22,137 +17,17 @@ class customer_product_rule_service
|
||||
if (!$order->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$product = (new products_o())->getProductById($productId);
|
||||
if (!$product->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$customer = (new users_o())->getUserByCustomerNumber((int)$order->customer_id->value());
|
||||
if (!$customer->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$categoryId = (int)$product->category->value();
|
||||
$categoryName = $this->categoryName($categoryId);
|
||||
$searchableProduct = $this->searchableProductText($product, $categoryName);
|
||||
$isTankCleaningProduct = $this->isTankCleaningProduct($categoryId, $searchableProduct);
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictAdditionalServices')
|
||||
&& $this->isAdditionalServiceProduct($orderId, $relatedItemId, $categoryId, $searchableProduct)) {
|
||||
return $this->violation('restrictAdditionalServices');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictTankCleaning') && $isTankCleaningProduct) {
|
||||
return $this->violation('restrictTankCleaning');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('onlyTankCleaning') && !$isTankCleaningProduct) {
|
||||
return $this->violation('onlyTankCleaning');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictSpotFree')
|
||||
&& $this->containsAny($searchableProduct, ['spot free', 'spotfree'])) {
|
||||
return $this->violation('restrictSpotFree');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictInteriorCleaning')
|
||||
&& $this->containsAny($searchableProduct, ['interior', 'indvendig'])) {
|
||||
return $this->violation('restrictInteriorCleaning');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{rule:string,message:string}
|
||||
*/
|
||||
private function violation(string $rule): array
|
||||
{
|
||||
return [
|
||||
'rule' => $rule,
|
||||
'message' => self::BLOCK_MESSAGE,
|
||||
];
|
||||
}
|
||||
|
||||
private function isAdditionalServiceProduct(int $orderId, ?int $relatedItemId, int $categoryId, string $searchableProduct): bool
|
||||
{
|
||||
if ($relatedItemId !== null && $relatedItemId > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($categoryId === self::ADDON_CATEGORY_ID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->containsAny($searchableProduct, ['add-on', 'add on', 'addon', 'tilvalg'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->countStandaloneOrderItems($orderId) > 0;
|
||||
}
|
||||
|
||||
private function isTankCleaningProduct(int $categoryId, string $searchableProduct): bool
|
||||
{
|
||||
if ($categoryId === self::TANK_CLEANING_CATEGORY_ID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->containsAny($searchableProduct, ['tank cleaning', 'tankcleaning', 'tankrens', 'tank rens']);
|
||||
}
|
||||
|
||||
private function searchableProductText(products_o $product, string $categoryName): string
|
||||
{
|
||||
return strtolower(trim((string)$product->name->value() . ' ' . $categoryName));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $terms
|
||||
*/
|
||||
private function containsAny(string $value, array $terms): bool
|
||||
{
|
||||
foreach ($terms as $term) {
|
||||
if ($term !== '' && str_contains($value, $term)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function categoryName(int $categoryId): string
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($categoryId <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$result = $db->query('SELECT name FROM categories WHERE id = ' . $categoryId . ' LIMIT 1');
|
||||
if (!$result || $result->num_rows === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
return strtolower((string)($row['name'] ?? ''));
|
||||
}
|
||||
|
||||
private function countStandaloneOrderItems(int $orderId): int
|
||||
{
|
||||
global $db;
|
||||
|
||||
$result = $db->query(
|
||||
'SELECT COUNT(*) AS item_count
|
||||
FROM order_items
|
||||
WHERE order_id = ' . $orderId . '
|
||||
AND deleted_at IS NULL
|
||||
AND (related_item_id IS NULL OR related_item_id = 0)'
|
||||
$violation = (new customer_rule_product_restriction_service())->violationForCustomerProduct(
|
||||
(int)$order->customer_id->value(),
|
||||
$productId
|
||||
);
|
||||
if (!$result) {
|
||||
return 0;
|
||||
if ($violation === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)($row['item_count'] ?? 0);
|
||||
// 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
|
||||
|
||||
@@ -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,458 @@
|
||||
<?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']),
|
||||
'revision' => $this->revision($overrides),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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();
|
||||
$expectedRevision = $this->normalizeExpectedRevision($payload['expected_revision'] ?? null);
|
||||
$existingOverrides = [];
|
||||
$normalizedKeys = [];
|
||||
foreach ($normalized as $override) {
|
||||
$normalizedKeys[$this->overrideKey((bool)$override['is_category'], $override['product_or_category_id'])] = true;
|
||||
}
|
||||
|
||||
global $db;
|
||||
$mysqli = $db->conn();
|
||||
$mysqli->begin_transaction();
|
||||
try {
|
||||
$this->lockDepartment($departmentId);
|
||||
$existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']);
|
||||
$currentRevision = $this->revision($existingOverrides);
|
||||
if ($expectedRevision !== null && !hash_equals($currentRevision, $expectedRevision)) {
|
||||
throw $this->revisionConflict($currentRevision);
|
||||
}
|
||||
|
||||
$deleteStatement = $mysqli->prepare(
|
||||
'DELETE FROM `department_customer_price_overrides` WHERE `department_id` = ? AND `user_id` = ?'
|
||||
);
|
||||
$insertStatement = $mysqli->prepare(
|
||||
'INSERT INTO `department_customer_price_overrides`
|
||||
(`department_id`, `user_id`, `is_category`, `product_or_category_id`, `percentage`, `fixed_price`)
|
||||
VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
if ($deleteStatement === false || $insertStatement === false) {
|
||||
throw new \RuntimeException('Unable to prepare department customer pricing update.');
|
||||
}
|
||||
|
||||
$customerId = (int)$customer['id'];
|
||||
$deleteStatement->bind_param('ii', $departmentId, $customerId);
|
||||
if (!$deleteStatement->execute()) {
|
||||
throw new \RuntimeException('Unable to clear department customer pricing.');
|
||||
}
|
||||
|
||||
foreach ($normalized as $override) {
|
||||
$isCategory = (int)(bool)$override['is_category'];
|
||||
$objectId = (string)$override['product_or_category_id'];
|
||||
$percentage = (int)$override['percentage'];
|
||||
$fixedPrice = $override['fixed_price'] === null ? null : (int)$override['fixed_price'];
|
||||
$insertStatement->bind_param(
|
||||
'iiisii',
|
||||
$departmentId,
|
||||
$customerId,
|
||||
$isCategory,
|
||||
$objectId,
|
||||
$percentage,
|
||||
$fixedPrice
|
||||
);
|
||||
if (!$insertStatement->execute()) {
|
||||
throw new \RuntimeException('Unable to save department customer pricing.');
|
||||
}
|
||||
}
|
||||
|
||||
$deleteStatement->close();
|
||||
$insertStatement->close();
|
||||
$mysqli->commit();
|
||||
} catch (limited_backoffice_exception $exception) {
|
||||
$mysqli->rollback();
|
||||
throw $exception;
|
||||
} catch (\Throwable) {
|
||||
$mysqli->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) {
|
||||
if ($fixedPrice !== null) {
|
||||
throw new limited_backoffice_exception('Fixed prices can only be assigned to products.', 400);
|
||||
}
|
||||
$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);
|
||||
if (isset($normalized[$key])) {
|
||||
throw new limited_backoffice_exception('Duplicate customer price overrides are not allowed.', 400);
|
||||
}
|
||||
|
||||
if ($fixedPrice !== null && $percentage > 0) {
|
||||
throw new limited_backoffice_exception('Choose either a discount or a fixed price.', 400);
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $overrides
|
||||
*/
|
||||
private function revision(array $overrides): string
|
||||
{
|
||||
$revisionRows = array_map(static fn(array $override): array => [
|
||||
'is_category' => (bool)$override['is_category'],
|
||||
'product_or_category_id' => (string)$override['product_or_category_id'],
|
||||
'percentage' => (int)$override['percentage'],
|
||||
'fixed_price' => $override['fixed_price'] === null ? null : (int)$override['fixed_price'],
|
||||
], $overrides);
|
||||
|
||||
usort($revisionRows, static function (array $left, array $right): int {
|
||||
return [$left['is_category'] ? 0 : 1, $left['product_or_category_id']]
|
||||
<=> [$right['is_category'] ? 0 : 1, $right['product_or_category_id']];
|
||||
});
|
||||
|
||||
return hash('sha256', json_encode($revisionRows, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function normalizeExpectedRevision(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
// Keep the backend-first rollout compatible with the currently deployed UI.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_string($value) || preg_match('/^[a-f0-9]{64}$/', $value) !== 1) {
|
||||
throw new limited_backoffice_exception('Expected revision is invalid.', 400, [
|
||||
'message' => 'Expected revision is invalid.',
|
||||
'code' => 'pricing_revision_invalid',
|
||||
]);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function lockDepartment(int $departmentId): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$result = $db->query(
|
||||
'SELECT `id` FROM `departments` WHERE `id` = ' . (int)$departmentId . ' FOR UPDATE'
|
||||
);
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
throw new limited_backoffice_exception('Department not found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
private function revisionConflict(string $currentRevision): limited_backoffice_exception
|
||||
{
|
||||
return new limited_backoffice_exception('Pricing has changed. Reload and try again.', 409, [
|
||||
'message' => 'Pricing has changed. Reload and try again.',
|
||||
'code' => 'pricing_revision_conflict',
|
||||
'current_revision' => $currentRevision,
|
||||
]);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,10 +302,10 @@ class department_outside_hours_statistics_service
|
||||
* @param array<int,array<string,mixed>> $opening_hours_by_department_id
|
||||
* @param array<string,array<int,bool>>|null $missing_lookup_by_day
|
||||
* @return array{
|
||||
* counted:bool,
|
||||
* reason:string,
|
||||
* candidate_date:?string,
|
||||
* department_id:int
|
||||
* counted: bool,
|
||||
* reason: string,
|
||||
* candidate_date: ?string,
|
||||
* department_id: int
|
||||
* }
|
||||
*/
|
||||
public function classifyCandidateAgainstOpeningHours(
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -328,23 +328,21 @@ class economic_transfer_executor
|
||||
$economic_dimension_id = $department['economic_dimension_id'] ?? 0;
|
||||
$order_item_price = (float)($order_item['price'] ?? 0);
|
||||
$product_price = (float)($order_item['product']['price'] ?? 0);
|
||||
$discount_percentage = 0.0;
|
||||
if (abs($product_price) > 0.00001 && $order_item_price < $product_price) {
|
||||
$discount_percentage = round((($product_price - $order_item_price) / $product_price) * 100, 10);
|
||||
}
|
||||
|
||||
$economic_invoice_draft->addLine(
|
||||
$product_number,
|
||||
$product_name,
|
||||
$quantity,
|
||||
$order_item_price,
|
||||
0,
|
||||
$discount_percentage,
|
||||
(int)$economic_department_id ?? 0,
|
||||
(int)$economic_dimension_id ?? 0
|
||||
);
|
||||
|
||||
$show_discount = abs($order_item_price - $product_price) > 0.00001;
|
||||
if ($show_discount && abs($product_price) > 0.00001) {
|
||||
$discount_percentage = round((($product_price - $order_item_price) / $product_price) * 100, 0);
|
||||
$economic_invoice_draft->addLineTEXT('Rabat: ' . ($order_item_price - $product_price) . ' DKK (' . $discount_percentage . '%)');
|
||||
}
|
||||
|
||||
if ($reference !== '') {
|
||||
$economic_invoice_draft->addLineTEXT('Reference:');
|
||||
if (str_contains($reference, "\n")) {
|
||||
|
||||
@@ -40,9 +40,14 @@ class economic_transfer_queue
|
||||
$max_attempts = max(1, min(10, $max_attempts));
|
||||
$transfer_type = $this->validateTransferType($transfer_type);
|
||||
$payload = $this->normalizePayloadForTransferType($transfer_type, $payload, $created_by);
|
||||
$collected_invoice_lock = $this->acquireCollectedInvoiceExportLock($transfer_type, $payload);
|
||||
if ($collected_invoice_lock !== null) {
|
||||
$this->assertCollectedInvoiceExportIsStillEligible($payload);
|
||||
}
|
||||
|
||||
$active_job = $this->findActiveJobByTarget($transfer_type, $payload, $created_by);
|
||||
if ($active_job !== null) {
|
||||
$this->registerJobRequester((int)($active_job['id'] ?? 0), $created_by);
|
||||
$target_label = $this->buildTargetLabel($transfer_type, $payload);
|
||||
$this->logQueueEvent(
|
||||
1,
|
||||
@@ -75,6 +80,8 @@ class economic_transfer_queue
|
||||
$job_id = (int)$db->insert_id();
|
||||
$stmt->close();
|
||||
|
||||
$this->registerJobRequester($job_id, $created_by);
|
||||
|
||||
$this->logQueueEvent(
|
||||
1,
|
||||
$created_by,
|
||||
@@ -145,12 +152,19 @@ class economic_transfer_queue
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("SELECT * FROM economic_transfer_queue_jobs WHERE id = ? AND created_by = ? LIMIT 1");
|
||||
$stmt = $db->prepare(
|
||||
"SELECT q.*
|
||||
FROM economic_transfer_queue_jobs q
|
||||
LEFT JOIN economic_transfer_queue_job_requesters r
|
||||
ON r.queue_job_id = q.id AND r.user_id = ?
|
||||
WHERE q.id = ? AND (q.created_by = ? OR r.user_id = ?)
|
||||
LIMIT 1"
|
||||
);
|
||||
if (!$stmt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stmt->bind_param('ii', $job_id, $created_by);
|
||||
$stmt->bind_param('iiii', $created_by, $job_id, $created_by, $created_by);
|
||||
if (!$stmt->execute()) {
|
||||
$stmt->close();
|
||||
return null;
|
||||
@@ -179,7 +193,8 @@ class economic_transfer_queue
|
||||
$offset = max(0, $offset);
|
||||
|
||||
$where = $this->buildListJobsWhereClause($statuses, $transfer_type);
|
||||
$where .= $where === '' ? 'WHERE created_by = ' . $created_by : ' AND created_by = ' . $created_by;
|
||||
$visibility = $this->jobVisibilitySql('economic_transfer_queue_jobs', $created_by);
|
||||
$where .= $where === '' ? 'WHERE ' . $visibility : ' AND ' . $visibility;
|
||||
$sql = "SELECT * FROM economic_transfer_queue_jobs $where ORDER BY id DESC LIMIT $limit OFFSET $offset";
|
||||
$result = $db->query($sql);
|
||||
if (!$result instanceof mysqli_result) {
|
||||
@@ -203,7 +218,8 @@ class economic_transfer_queue
|
||||
}
|
||||
|
||||
$where = $this->buildListJobsWhereClause($statuses, $transfer_type);
|
||||
$where .= $where === '' ? 'WHERE created_by = ' . $created_by : ' AND created_by = ' . $created_by;
|
||||
$visibility = $this->jobVisibilitySql('economic_transfer_queue_jobs', $created_by);
|
||||
$where .= $where === '' ? 'WHERE ' . $visibility : ' AND ' . $visibility;
|
||||
$sql = "SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs $where";
|
||||
$result = $db->query($sql);
|
||||
if (!$result instanceof mysqli_result) {
|
||||
@@ -242,6 +258,9 @@ class economic_transfer_queue
|
||||
global $db;
|
||||
|
||||
$user_id = max(0, $user_id);
|
||||
if ($user_id < 1) {
|
||||
return [];
|
||||
}
|
||||
$limit = max(1, min(100, $limit));
|
||||
try {
|
||||
$normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== ''
|
||||
@@ -262,7 +281,7 @@ class economic_transfer_queue
|
||||
ON d.queue_job_id = q.id
|
||||
AND d.user_id = $user_id
|
||||
AND d.dismissed_status = q.status
|
||||
WHERE q.created_by = $user_id
|
||||
WHERE " . $this->jobVisibilitySql('q', $user_id) . "
|
||||
$transfer_condition
|
||||
AND (
|
||||
q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "')
|
||||
@@ -355,7 +374,7 @@ class economic_transfer_queue
|
||||
ON d.queue_job_id = q.id
|
||||
AND d.user_id = $user_id
|
||||
AND d.dismissed_status = q.status
|
||||
WHERE q.created_by = $user_id
|
||||
WHERE " . $this->jobVisibilitySql('q', $user_id) . "
|
||||
AND q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "')
|
||||
$transfer_condition
|
||||
AND d.queue_job_id IS NULL
|
||||
@@ -389,6 +408,9 @@ class economic_transfer_queue
|
||||
if ($existing_job === null) {
|
||||
throw new Exception('Queue job not found');
|
||||
}
|
||||
if ($created_by !== null && (int)($existing_job['created_by'] ?? 0) !== $created_by) {
|
||||
throw new Exception('Only the queue job creator can retry this job');
|
||||
}
|
||||
if ((string)($existing_job['status'] ?? '') !== self::STATUS_FAILED) {
|
||||
throw new Exception('Only failed jobs can be retried');
|
||||
}
|
||||
@@ -610,11 +632,53 @@ class economic_transfer_queue
|
||||
if ($collected_invoice_id < 1) {
|
||||
throw new Exception('collected_invoice_id is required');
|
||||
}
|
||||
$collection_lock = $this->acquireCollectedInvoiceExportLock(
|
||||
self::TYPE_COLLECTED_INVOICE_EXPORT,
|
||||
$payload
|
||||
);
|
||||
$this->assertCollectedInvoiceExportIsStillEligible($payload);
|
||||
$send_as_is = (bool)($payload['send_as_is'] ?? false);
|
||||
$this->updateProgress((int)$job['id'], 65, 'Exporting collected invoice');
|
||||
return $this->executor->exportCollectedInvoice($collected_invoice_id, $send_as_is, $requested_by);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue, worker execution, payments, and invoice-tree mutations share the
|
||||
* same collection lock. The returned object intentionally stays in scope
|
||||
* for the complete enqueue/export operation and releases in its destructor.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function acquireCollectedInvoiceExportLock(string $transfer_type, array $payload): ?order_payment_lock
|
||||
{
|
||||
if ($transfer_type !== self::TYPE_COLLECTED_INVOICE_EXPORT) {
|
||||
return null;
|
||||
}
|
||||
$collected_invoice_id = (int)($payload['collected_invoice_id'] ?? 0);
|
||||
if ($collected_invoice_id < 1) {
|
||||
throw new Exception('collected_invoice_id is required');
|
||||
}
|
||||
$lock = order_payment_lock::tryAcquireInvoiceCollection($collected_invoice_id);
|
||||
if ($lock === null) {
|
||||
throw new Exception('Invoice collection is currently being changed or paid. Try again.');
|
||||
}
|
||||
return $lock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read eligibility after acquiring the collection lock so a queued job
|
||||
* cannot export a collection that was booked or superseded while waiting.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function assertCollectedInvoiceExportIsStillEligible(array $payload): void
|
||||
{
|
||||
$collection = (new \objects\collected_order_invoices_o())->select(
|
||||
(int)($payload['collected_invoice_id'] ?? 0)
|
||||
);
|
||||
invoice_collection_bulk_action_service::assertCollectionCanQueueEconomic($collection);
|
||||
}
|
||||
|
||||
private function updateProgress(int $job_id, int $percent, string $message): void
|
||||
{
|
||||
global $db;
|
||||
@@ -727,6 +791,38 @@ class economic_transfer_queue
|
||||
$db->query("DELETE FROM economic_transfer_queue_job_dismissals WHERE queue_job_id = $job_id");
|
||||
}
|
||||
|
||||
private function registerJobRequester(int $job_id, int $user_id): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($job_id < 1 || $user_id < 1) {
|
||||
return;
|
||||
}
|
||||
$stmt = $db->prepare(
|
||||
"INSERT INTO economic_transfer_queue_job_requesters (queue_job_id, user_id, requested_at)
|
||||
VALUES (?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE requested_at = VALUES(requested_at)"
|
||||
);
|
||||
if (!$stmt) {
|
||||
throw new Exception('Failed to prepare queue requester registration');
|
||||
}
|
||||
$stmt->bind_param('ii', $job_id, $user_id);
|
||||
if (!$stmt->execute()) {
|
||||
$stmt->close();
|
||||
throw new Exception('Failed to register queue requester');
|
||||
}
|
||||
$stmt->close();
|
||||
}
|
||||
|
||||
private function jobVisibilitySql(string $alias, int $user_id): string
|
||||
{
|
||||
$user_id = max(0, $user_id);
|
||||
return "($alias.created_by = $user_id OR EXISTS (
|
||||
SELECT 1 FROM economic_transfer_queue_job_requesters requester
|
||||
WHERE requester.queue_job_id = $alias.id AND requester.user_id = $user_id
|
||||
))";
|
||||
}
|
||||
|
||||
/**
|
||||
* Release jobs stuck in PROCESSING due to crashes or killed workers.
|
||||
*/
|
||||
@@ -756,11 +852,14 @@ class economic_transfer_queue
|
||||
$normalized_payload['requested_by'] = max(0, (int)$normalized_payload['requested_by']);
|
||||
}
|
||||
|
||||
return match ($transfer_type) {
|
||||
self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => $this->normalizeOrderPayload($normalized_payload, $created_by),
|
||||
self::TYPE_COLLECTED_INVOICE_EXPORT => $this->normalizeCollectedInvoicePayload($normalized_payload, $created_by),
|
||||
default => $this->rejectPayload($created_by, 'Unsupported transfer type payload: ' . $transfer_type),
|
||||
};
|
||||
if ($transfer_type === self::TYPE_COLLECTED_INVOICE_EXPORT) {
|
||||
return $this->normalizeCollectedInvoicePayload($normalized_payload, $created_by);
|
||||
}
|
||||
if (!in_array($transfer_type, [self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT], true)) {
|
||||
$this->rejectPayload($created_by, 'Unsupported transfer type payload: ' . $transfer_type);
|
||||
}
|
||||
|
||||
return $this->normalizeOrderPayload($normalized_payload, $created_by);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -770,7 +869,7 @@ class economic_transfer_queue
|
||||
{
|
||||
$order_id = $payload['order_id'] ?? null;
|
||||
if ($order_id === null || !is_numeric($order_id) || (int)$order_id < 1) {
|
||||
return $this->rejectPayload($created_by, 'order_id is required and must be a positive number');
|
||||
$this->rejectPayload($created_by, 'order_id is required and must be a positive number');
|
||||
}
|
||||
$payload['order_id'] = (int)$order_id;
|
||||
return $payload;
|
||||
@@ -783,7 +882,7 @@ class economic_transfer_queue
|
||||
{
|
||||
$collected_invoice_id = $payload['collected_invoice_id'] ?? null;
|
||||
if ($collected_invoice_id === null || !is_numeric($collected_invoice_id) || (int)$collected_invoice_id < 1) {
|
||||
return $this->rejectPayload($created_by, 'collected_invoice_id is required and must be a positive number');
|
||||
$this->rejectPayload($created_by, 'collected_invoice_id is required and must be a positive number');
|
||||
}
|
||||
|
||||
$payload['collected_invoice_id'] = (int)$collected_invoice_id;
|
||||
@@ -804,17 +903,17 @@ class economic_transfer_queue
|
||||
if ($numeric === 0 || $numeric === 1) {
|
||||
return $numeric === 1;
|
||||
}
|
||||
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
|
||||
$this->rejectPayload($created_by, $field_name . ' must be a boolean');
|
||||
}
|
||||
if (is_string($value)) {
|
||||
$normalized = strtolower(trim($value));
|
||||
if (in_array($normalized, ['true', 'false', '1', '0'], true)) {
|
||||
return in_array($normalized, ['true', '1'], true);
|
||||
}
|
||||
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
|
||||
$this->rejectPayload($created_by, $field_name . ' must be a boolean');
|
||||
}
|
||||
|
||||
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
|
||||
$this->rejectPayload($created_by, $field_name . ' must be a boolean');
|
||||
}
|
||||
|
||||
private function findActiveJobByTarget(string $transfer_type, array $payload, int $created_by): ?array
|
||||
@@ -840,8 +939,8 @@ class economic_transfer_queue
|
||||
{
|
||||
global $db;
|
||||
|
||||
$created_by = max(0, $created_by);
|
||||
if ($target_value < 1 || $created_by < 1) {
|
||||
// Active work is unique by transfer type and business target across all requesting users.
|
||||
if ($target_value < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -851,7 +950,6 @@ class economic_transfer_queue
|
||||
WHERE transfer_type = ?
|
||||
AND status IN (?, ?)
|
||||
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$json_path')) AS UNSIGNED) = ?
|
||||
AND created_by = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
@@ -861,7 +959,7 @@ class economic_transfer_queue
|
||||
|
||||
$queued = self::STATUS_QUEUED;
|
||||
$processing = self::STATUS_PROCESSING;
|
||||
$stmt->bind_param('sssii', $transfer_type, $queued, $processing, $target_value, $created_by);
|
||||
$stmt->bind_param('sssi', $transfer_type, $queued, $processing, $target_value);
|
||||
if (!$stmt->execute()) {
|
||||
$stmt->close();
|
||||
return null;
|
||||
|
||||
@@ -54,6 +54,17 @@ class economic_transfer_queue_schema_bootstrap
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS economic_transfer_queue_job_requesters (
|
||||
queue_job_id BIGINT UNSIGNED NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (queue_job_id, user_id),
|
||||
INDEX idx_economic_transfer_queue_job_requesters_user (user_id, queue_job_id),
|
||||
INDEX idx_economic_transfer_queue_job_requesters_job (queue_job_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 @@ 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;
|
||||
}
|
||||
@@ -1528,7 +1529,7 @@ 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;
|
||||
@@ -1544,14 +1545,18 @@ 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);
|
||||
$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
|
||||
@@ -1563,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ class economic_v2_revenue_statistics_service
|
||||
|
||||
private economic $economic;
|
||||
|
||||
/** @var array<int, array{customer_number:int,name:?string,barred:?bool,status:string}> */
|
||||
/** @var array<int, array{customer_number: int, name: ?string, barred: ?bool, status: string}> */
|
||||
private array $customer_cache = [];
|
||||
|
||||
public function __construct(?economic $economic = null)
|
||||
@@ -44,7 +44,6 @@ class economic_v2_revenue_statistics_service
|
||||
$summary = [
|
||||
'invoice_count' => 0,
|
||||
'line_count' => 0,
|
||||
'unique_customers' => 0,
|
||||
'net_amount' => 0.0,
|
||||
'vat_amount' => 0.0,
|
||||
'gross_amount' => 0.0,
|
||||
@@ -398,7 +397,7 @@ class economic_v2_revenue_statistics_service
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{customer_number:int,name:?string,barred:?bool,status:string}
|
||||
* @return array{customer_number: int, name: ?string, barred: ?bool, status: string}
|
||||
*/
|
||||
private function resolveCustomerSnapshot(int $customer_number, array &$warnings): array
|
||||
{
|
||||
@@ -500,4 +499,3 @@ class economic_v2_revenue_statistics_service
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ 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,
|
||||
@@ -75,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",
|
||||
@@ -92,6 +94,14 @@ class economic_v2_schema_bootstrap
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -136,7 +136,8 @@ class economic_v2_versioning_service
|
||||
float $confidence = 1.0,
|
||||
bool $inferred = false,
|
||||
array $metadata = [],
|
||||
?int $fixed_price = null
|
||||
?int $fixed_price = null,
|
||||
?int $department_id = null
|
||||
): array {
|
||||
$identity = [
|
||||
'user_id' => $user_id,
|
||||
@@ -144,6 +145,9 @@ 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) && $fixed_price === null) {
|
||||
return $this->closeActiveVersion(
|
||||
@@ -240,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
|
||||
|
||||
@@ -34,6 +34,7 @@ use MailerSend\Helpers\Builder\Recipient;
|
||||
use MailerSend\MailerSend;
|
||||
use objects\bookings_o;
|
||||
use objects\departments_o;
|
||||
use objects\logs_o;
|
||||
use objects\users_o;
|
||||
use Psr\Http\Client\ClientExceptionInterface;
|
||||
|
||||
@@ -144,6 +145,15 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
];
|
||||
// If the email is blacklisted, return without sending the email
|
||||
if (in_array($to, $blacklisted_emails)) {
|
||||
// Previously this was a silent return - ops could not tell whether a
|
||||
// missing delivery was caused by the blacklist or a real provider
|
||||
// outage. Emit a structured skip event before returning.
|
||||
$context = [
|
||||
'reason' => 'recipient_blacklisted',
|
||||
'recipient' => $to,
|
||||
'subject' => $subject,
|
||||
];
|
||||
error_log('[email-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
return;
|
||||
}
|
||||
// Send POST request to email service
|
||||
@@ -164,12 +174,19 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
if ($attachments) {
|
||||
$attachments = array_map(function ($attachment) {
|
||||
// Read the data from the path (Attachment[0]) and set the filename (Attachment[1])
|
||||
$attachment[0] = file_get_contents($attachment[0]);
|
||||
if ($attachment[0] === false) {
|
||||
throw new Exception('Failed to read file: ' . $attachment[0]);
|
||||
$path = (string)$attachment[0];
|
||||
$contents = file_get_contents($path);
|
||||
if ($contents === false) {
|
||||
// Capture the path before it is overwritten so the resulting
|
||||
// exception message is useful in ops logs. Previously this
|
||||
// threw with binary contents (because $attachment[0] had
|
||||
// already been replaced by file_get_contents()'s output),
|
||||
// making the failure essentially un-diagnosable.
|
||||
throw new Exception('Failed to read attachment file: ' . $path);
|
||||
}
|
||||
$attachment[0] = $contents;
|
||||
if (empty($attachment[1])) {
|
||||
throw new Exception('Filename is empty');
|
||||
throw new Exception('Attachment filename is empty (path: ' . $path . ')');
|
||||
}
|
||||
return new Attachment($attachment[0], $attachment[1]);
|
||||
}, $attachments);
|
||||
@@ -489,7 +506,13 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
{
|
||||
// Validate the booking object
|
||||
$order_booking->requireSelected();
|
||||
if (!$order_booking->hasTransaction()) return; // Only send a wash certificate if the order has been created.
|
||||
if (!$order_booking->hasTransaction()) {
|
||||
// Previously this was a silent return which made wash-certificate
|
||||
// delivery failures (e.g. k.sand@ksand.dk) impossible to diagnose
|
||||
// without DB access. Emit a structured skip event before returning.
|
||||
self::logWashCertificateSkip('no_transaction_email', (int)$order_booking->id, (int)$order_booking->customer_number->value());
|
||||
return;
|
||||
} // Only send a wash certificate if the order has been created.
|
||||
// Get the order details
|
||||
$order = $order_booking->getOrder();
|
||||
// Get customer details
|
||||
@@ -604,6 +627,14 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
foreach ((new users_o())->getSuperuserNewCustomerEmailNotificationRecipients() as $recipient) {
|
||||
$recipientEmail = trim((string)($recipient['email'] ?? ''));
|
||||
if ($recipientEmail === '') {
|
||||
// Recipient has no email address; surface the skip so an admin
|
||||
// with no configured inbox can be fixed instead of silently
|
||||
// dropping new-customer notifications.
|
||||
$context = [
|
||||
'reason' => 'superuser_recipient_empty_email',
|
||||
'recipient_display_name' => trim((string)($recipient['display_name'] ?? '')),
|
||||
];
|
||||
error_log('[email-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -612,12 +643,61 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
$recipientName = $recipientEmail;
|
||||
}
|
||||
|
||||
$this->sendEmail(
|
||||
$recipientEmail,
|
||||
$recipientName,
|
||||
'New customer registered on Truck Wash',
|
||||
$message,
|
||||
);
|
||||
// Per-recipient try/catch so a single bad MailerSend response does
|
||||
// not break delivery to the remaining superuser recipients - this
|
||||
// loop is unprotected upstream and a transient 5xx would otherwise
|
||||
// mean the rest of the team silently stops hearing about new
|
||||
// customer registrations.
|
||||
try {
|
||||
$this->sendEmail(
|
||||
$recipientEmail,
|
||||
$recipientName,
|
||||
'New customer registered on Truck Wash',
|
||||
$message,
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
$context = [
|
||||
'reason' => 'superuser_recipient_send_failed',
|
||||
'recipient' => $recipientEmail,
|
||||
'subject' => 'New customer registered on Truck Wash',
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
error_log('[email-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a structured "wash certificate was skipped" event.
|
||||
*
|
||||
* Mirrors the helper of the same name on order_bookings_o so that every
|
||||
* silent-return path in the email delivery flow (this class plus the
|
||||
* order-bookings wrapper) is observable from the same grep target.
|
||||
*
|
||||
* TODO: migrate to the project logger when one is available globally.
|
||||
*
|
||||
* @param array<string, mixed> $extra
|
||||
*/
|
||||
private static function logWashCertificateSkip(string $reason, int $booking_id, int $customer_number, array $extra = []): void
|
||||
{
|
||||
$context = array_merge([
|
||||
'reason' => $reason,
|
||||
'booking_id' => $booking_id,
|
||||
'customer_number' => $customer_number,
|
||||
], $extra);
|
||||
try {
|
||||
(new logs_o())->add(
|
||||
'email',
|
||||
'global',
|
||||
3,
|
||||
0,
|
||||
'WASH_CERT_SKIP',
|
||||
json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Logging must never block the booking flow.
|
||||
}
|
||||
// Also emit to PHP error stream so this is visible in container logs.
|
||||
error_log('[wash-cert-skip] ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,41 +9,11 @@ class encrypt implements encrypt_i
|
||||
|
||||
public function encrypt(string $data): string
|
||||
{
|
||||
// Debug:
|
||||
return $data;
|
||||
// Encrypt data
|
||||
global $ENCRYPTION_KEY;
|
||||
// Use AES 256 encryption
|
||||
$cipher = "aes-256-cbc";
|
||||
// Use the encryption key
|
||||
$options = 0;
|
||||
// Get the initialization vector
|
||||
$iv_length = openssl_cipher_iv_length($cipher);
|
||||
$iv = openssl_random_pseudo_bytes($iv_length);
|
||||
// Use the first 16 bytes of the initialization vector
|
||||
$iv = substr($iv, 0, 16);
|
||||
// Encrypt the data
|
||||
$encrypted = openssl_encrypt($data, $cipher, $ENCRYPTION_KEY, $options, $iv);
|
||||
// Save the initialization vector for decryption
|
||||
return $iv . $encrypted;
|
||||
}
|
||||
|
||||
public function decrypt(string $data): string
|
||||
{
|
||||
// Debug:
|
||||
return $data;
|
||||
// Decrypt data
|
||||
global $ENCRYPTION_KEY;
|
||||
// Use AES 256 encryption
|
||||
$cipher = "aes-256-cbc";
|
||||
// Use the encryption key and initialization vector
|
||||
$options = 0;
|
||||
// Get the initialization vector
|
||||
$iv_length = openssl_cipher_iv_length($cipher);
|
||||
$iv = substr($data, 0, $iv_length);
|
||||
// Get the encrypted data
|
||||
$encrypted = substr($data, $iv_length);
|
||||
// Decrypt the data
|
||||
return openssl_decrypt($encrypted, $cipher, $ENCRYPTION_KEY, $options, $iv);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,6 @@ namespace classes;
|
||||
require_once WD . '/modules/entra/entra_c.php';
|
||||
|
||||
use entra\entra_c;
|
||||
use Microsoft\Graph\GraphServiceClient;
|
||||
use Microsoft\Kiota\Authentication\Oauth\ClientCredentialContext;
|
||||
use Microsoft\Kiota\Authentication\Oauth\ClientCredentialContextBuilder;
|
||||
|
||||
|
||||
class entra
|
||||
@@ -23,42 +20,95 @@ class entra
|
||||
$this->config = new entra_c();
|
||||
}
|
||||
|
||||
public function get_users($array = false): array|object
|
||||
public function get_users(bool $array = false): array
|
||||
{
|
||||
$graphClient = $this->getGraphClient();
|
||||
|
||||
$users = $graphClient->users()
|
||||
->get()
|
||||
->wait()
|
||||
->getValue();
|
||||
$accessToken = $this->requestAccessToken();
|
||||
$usersResponse = $this->requestJson(
|
||||
'https://graph.microsoft.com/v1.0/users?$select=id,displayName,mail,userPrincipalName',
|
||||
['Authorization: Bearer ' . $accessToken]
|
||||
);
|
||||
$users = is_array($usersResponse['value'] ?? null) ? $usersResponse['value'] : [];
|
||||
if (!$array) {
|
||||
return $users;
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ( $users as $user ) {
|
||||
foreach ($users as $user) {
|
||||
if (!is_array($user)) {
|
||||
continue;
|
||||
}
|
||||
$result[] = [
|
||||
'id' => $user->getId(),
|
||||
'displayName' => $user->getDisplayName(),
|
||||
'mail' => $user->getMail(),
|
||||
'userPrincipalName' => $user->getUserPrincipalName(),
|
||||
'id' => $user['id'] ?? null,
|
||||
'displayName' => $user['displayName'] ?? null,
|
||||
'mail' => $user['mail'] ?? null,
|
||||
'userPrincipalName' => $user['userPrincipalName'] ?? null,
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getGraphClient(): GraphServiceClient
|
||||
private function requestAccessToken(): string
|
||||
{
|
||||
return new GraphServiceClient(
|
||||
$this->getTokenRequestContext(),
|
||||
$tenantId = trim((string)$this->config->tenant_id->getVariableValue());
|
||||
$response = $this->requestJson(
|
||||
'https://login.microsoftonline.com/' . rawurlencode($tenantId) . '/oauth2/v2.0/token',
|
||||
['Content-Type: application/x-www-form-urlencoded'],
|
||||
http_build_query([
|
||||
'client_id' => (string)$this->config->client_id->getVariableValue(),
|
||||
'client_secret' => (string)$this->config->client_secret->getVariableValue(),
|
||||
'scope' => 'https://graph.microsoft.com/.default',
|
||||
'grant_type' => 'client_credentials',
|
||||
])
|
||||
);
|
||||
|
||||
$token = trim((string)($response['access_token'] ?? ''));
|
||||
if ($token === '') {
|
||||
throw new \RuntimeException('Microsoft Entra token response did not contain an access token.');
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function getTokenRequestContext(): ClientCredentialContext
|
||||
/**
|
||||
* @param list<string> $headers
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function requestJson(string $url, array $headers, ?string $postFields = null): array
|
||||
{
|
||||
return new ClientCredentialContext(
|
||||
$this->config->tenant_id->getVariableValue(),
|
||||
$this->config->client_id->getVariableValue(),
|
||||
$this->config->client_secret->getVariableValue()
|
||||
);
|
||||
$curl = curl_init($url);
|
||||
if ($curl === false) {
|
||||
throw new \RuntimeException('Unable to initialize Microsoft Entra request.');
|
||||
}
|
||||
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
]);
|
||||
if ($postFields !== null) {
|
||||
curl_setopt($curl, CURLOPT_POST, true);
|
||||
curl_setopt($curl, CURLOPT_POSTFIELDS, $postFields);
|
||||
}
|
||||
|
||||
try {
|
||||
$body = curl_exec($curl);
|
||||
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
if ($body === false) {
|
||||
throw new \RuntimeException('Microsoft Entra request failed: ' . curl_error($curl));
|
||||
}
|
||||
} finally {
|
||||
curl_close($curl);
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)$body, true);
|
||||
if ($status < 200 || $status >= 300 || !is_array($decoded)) {
|
||||
$message = is_array($decoded)
|
||||
? (string)($decoded['error_description'] ?? $decoded['error']['message'] ?? 'Unexpected response')
|
||||
: 'Invalid JSON response';
|
||||
throw new \RuntimeException('Microsoft Entra request failed with HTTP ' . $status . ': ' . $message);
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ require_once WD . '/modules/forms/form_helper_c.php';
|
||||
|
||||
use Exception;
|
||||
use forms\form_helper_c;
|
||||
use forms\objects\book_interior_wash_f;
|
||||
use forms\objects\book_wash_f;
|
||||
use objects\form_submissions_o;
|
||||
use traits\form_t;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Ensures additive audit columns used when invoice collections are superseded.
|
||||
*/
|
||||
class invoice_collection_schema_bootstrap
|
||||
{
|
||||
private const TABLE = 'collected_order_invoices';
|
||||
private const LOCK_NAME = 'invoice_collection_schema_v1';
|
||||
|
||||
/** @var array<string, string> */
|
||||
private const REQUIRED_COLUMNS = [
|
||||
'superseded_by_collection_id' => 'INT NULL',
|
||||
'superseded_at' => 'DATETIME NULL',
|
||||
'superseded_by_user_id' => 'INT NULL',
|
||||
];
|
||||
|
||||
public static function hasRequiredColumns(): bool
|
||||
{
|
||||
try {
|
||||
global $db;
|
||||
if (!self::canInspectSchema($db) || !self::tableExists($db)) {
|
||||
return false;
|
||||
}
|
||||
if (self::allColumnsExist($db)) {
|
||||
return true;
|
||||
}
|
||||
if (!self::acquireLock($db)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Another worker may have completed the additive migration while
|
||||
// this request waited for the advisory lock.
|
||||
foreach (self::REQUIRED_COLUMNS as $column => $definition) {
|
||||
if (self::columnExists($db, $column)) {
|
||||
continue;
|
||||
}
|
||||
$result = $db->query(
|
||||
"ALTER TABLE `" . self::TABLE . "` ADD COLUMN `{$column}` {$definition}"
|
||||
);
|
||||
if ($result === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!self::allColumnsExist($db)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
self::releaseLock($db);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Schema readiness is a capability gate. It must never break the
|
||||
// legacy invoicing routes when DDL or metadata access is unavailable.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function canInspectSchema(mixed $db): bool
|
||||
{
|
||||
return is_object($db)
|
||||
&& method_exists($db, 'query')
|
||||
&& method_exists($db, 'escape_string')
|
||||
&& method_exists($db, 'getDatabase');
|
||||
}
|
||||
|
||||
private static function tableExists(object $db): bool
|
||||
{
|
||||
return self::informationSchemaCount(
|
||||
$db,
|
||||
'information_schema.TABLES',
|
||||
'TABLE_NAME',
|
||||
self::TABLE
|
||||
) > 0;
|
||||
}
|
||||
|
||||
private static function columnExists(object $db, string $column): bool
|
||||
{
|
||||
return self::informationSchemaCount(
|
||||
$db,
|
||||
'information_schema.COLUMNS',
|
||||
'COLUMN_NAME',
|
||||
$column
|
||||
) > 0;
|
||||
}
|
||||
|
||||
private static function informationSchemaCount(
|
||||
object $db,
|
||||
string $informationSchemaTable,
|
||||
string $nameField,
|
||||
string $name
|
||||
): int {
|
||||
$database = $db->escape_string((string)$db->getDatabase());
|
||||
$name = $db->escape_string($name);
|
||||
$result = $db->query(
|
||||
"SELECT COUNT(*) AS c FROM {$informationSchemaTable} "
|
||||
. "WHERE TABLE_SCHEMA = '{$database}' AND TABLE_NAME = '" . self::TABLE . "' "
|
||||
. "AND {$nameField} = '{$name}'"
|
||||
);
|
||||
if (!is_object($result) || !method_exists($result, 'fetch_assoc')) {
|
||||
throw new \RuntimeException('Invoice collection schema inspection failed');
|
||||
}
|
||||
$row = $result->fetch_assoc();
|
||||
if (!is_array($row) || !array_key_exists('c', $row)) {
|
||||
throw new \RuntimeException('Invoice collection schema inspection returned an invalid result');
|
||||
}
|
||||
return (int)$row['c'];
|
||||
}
|
||||
|
||||
private static function allColumnsExist(object $db): bool
|
||||
{
|
||||
foreach (array_keys(self::REQUIRED_COLUMNS) as $column) {
|
||||
if (!self::columnExists($db, $column)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function acquireLock(object $db): bool
|
||||
{
|
||||
$result = $db->query("SELECT GET_LOCK('" . self::LOCK_NAME . "', 5) AS acquired");
|
||||
if (!is_object($result) || !method_exists($result, 'fetch_assoc')) {
|
||||
return false;
|
||||
}
|
||||
$row = $result->fetch_assoc();
|
||||
return is_array($row) && (int)($row['acquired'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
private static function releaseLock(object $db): void
|
||||
{
|
||||
try {
|
||||
$db->query("SELECT RELEASE_LOCK('" . self::LOCK_NAME . "')");
|
||||
} catch (\Throwable) {
|
||||
// The connection also releases advisory locks automatically.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,10 @@ class invoice_period_flag_schema_bootstrap
|
||||
);
|
||||
|
||||
products_schema_bootstrap::ensureTables();
|
||||
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
||||
|
||||
// Invoice-period flags remain available while the separately operated
|
||||
// XL Vask automation migration is pending. XL Vask-specific flag
|
||||
// detection already fails closed when its optional schema is absent.
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ class invoice_period_flag_service
|
||||
{
|
||||
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
|
||||
@@ -75,8 +76,11 @@ class invoice_period_flag_service
|
||||
$this->nullableIntSql($userId > 0 ? $userId : null)
|
||||
);
|
||||
$db->query($sql);
|
||||
$flagId = (int)$db->insert_id();
|
||||
|
||||
return $this->getStoredFlag((int)$db->insert_id());
|
||||
$this->refreshManualFlagsCacheAfterMutation();
|
||||
|
||||
return $this->getStoredFlag($flagId);
|
||||
}
|
||||
|
||||
public function updateManualFlagStatus(int $id, string $status, ?string $reason, int $userId): array
|
||||
@@ -106,6 +110,8 @@ class invoice_period_flag_service
|
||||
);
|
||||
$db->query($sql);
|
||||
|
||||
$this->refreshManualFlagsCacheAfterMutation();
|
||||
|
||||
return $this->getStoredFlag($id);
|
||||
}
|
||||
|
||||
@@ -226,6 +232,61 @@ class invoice_period_flag_service
|
||||
return $types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add only aggregate active manual-flag counts for readiness derivation.
|
||||
* Flag details remain absent when the caller lacks list_invoice_period_flags.
|
||||
*
|
||||
* @param array<string,array<int,array<string,mixed>>> $types
|
||||
* @param int[]|null $onlyCustomerNumbers
|
||||
* @return array<string,array<int,array<string,mixed>>>
|
||||
*/
|
||||
public function applyManualFlagCountsToPeriodTypes(
|
||||
array $types,
|
||||
string $dateFrom,
|
||||
string $dateTo,
|
||||
?array $onlyCustomerNumbers = null
|
||||
): array {
|
||||
$context = $this->buildPeriodContext($types, $dateFrom, $dateTo, $onlyCustomerNumbers);
|
||||
$manualFlags = $this->getManualFlagsForPeriod($context, $dateFrom, $dateTo, $onlyCustomerNumbers);
|
||||
return $this->applyManualFlagCounts($types, $manualFlags);
|
||||
}
|
||||
|
||||
private function applyManualFlagCounts(array $types, array $manualFlags): array
|
||||
{
|
||||
$flagsByCustomerNumber = [];
|
||||
foreach ($manualFlags as $flag) {
|
||||
$customerNumber = (int)($flag['customer_number'] ?? 0);
|
||||
if ($customerNumber > 0) {
|
||||
$flagsByCustomerNumber[$customerNumber][] = $flag;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($types as $typeName => $customers) {
|
||||
foreach ($customers as $index => $customer) {
|
||||
$customerNumber = (int)($customer['customer_number'] ?? 0);
|
||||
$customerFlags = $this->flagsForCustomerCard(
|
||||
$customer,
|
||||
$flagsByCustomerNumber[$customerNumber] ?? [],
|
||||
(string)$typeName
|
||||
);
|
||||
$activeManualCount = count(array_filter(
|
||||
$customerFlags,
|
||||
static fn(array $flag): bool => ($flag['source'] ?? null) === self::SOURCE_MANUAL
|
||||
&& ($flag['status'] ?? self::STATUS_ACTIVE) === self::STATUS_ACTIVE
|
||||
));
|
||||
$existingAutomaticCount = (int)($customer['flag_counts']['automatic'] ?? 0);
|
||||
$types[$typeName][$index]['flag_counts'] = [
|
||||
'manual' => $activeManualCount,
|
||||
'automatic' => $existingAutomaticCount,
|
||||
'total' => $activeManualCount + $existingAutomaticCount,
|
||||
];
|
||||
unset($types[$typeName][$index]['flags']);
|
||||
}
|
||||
}
|
||||
|
||||
return $types;
|
||||
}
|
||||
|
||||
private function flagsForCustomerCard(array $customer, array $flags, string $typeName = ''): array
|
||||
{
|
||||
$transactionIds = [];
|
||||
@@ -330,6 +391,12 @@ class invoice_period_flag_service
|
||||
}
|
||||
}
|
||||
|
||||
private function refreshManualFlagsCacheAfterMutation(): void
|
||||
{
|
||||
$this->manualFlagsInstanceCache = null;
|
||||
$this->warmManualFlagsCache();
|
||||
}
|
||||
|
||||
private function fetchActiveManualFlagsFromDb(): array
|
||||
{
|
||||
global $db;
|
||||
@@ -712,9 +779,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,
|
||||
product_discount.fixed_price AS product_fixed_price,
|
||||
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
|
||||
@@ -745,6 +824,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";
|
||||
@@ -808,11 +913,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}"
|
||||
);
|
||||
|
||||
@@ -823,7 +933,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;
|
||||
@@ -834,6 +948,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'];
|
||||
@@ -859,57 +975,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,
|
||||
@@ -919,6 +995,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) {
|
||||
@@ -1208,7 +1297,34 @@ class invoice_period_flag_service
|
||||
}
|
||||
}
|
||||
|
||||
$history = $this->getPrimaryProductHistory($dateFrom, array_column($primaryRows, 'reg_1'));
|
||||
// Partition primary rows by whether reg_2 is empty. A single-tractor order (reg_2 = '')
|
||||
// should only be compared against historical orders that ALSO had reg_2 empty, so we don't
|
||||
// raise a "historical_primary_product_mismatch" flag that names the tractor-trailer
|
||||
// (Forvogn med hænger) product as the expected one when the current order has no trailer.
|
||||
$hasReg2 = [];
|
||||
$emptyReg2 = [];
|
||||
foreach ($primaryRows as $row) {
|
||||
if (trim((string)($row['reg_2'] ?? '')) === '') {
|
||||
$emptyReg2[] = $row;
|
||||
} else {
|
||||
$hasReg2[] = $row;
|
||||
}
|
||||
}
|
||||
$history = [];
|
||||
if (!empty($emptyReg2)) {
|
||||
$history = $history + $this->getPrimaryProductHistory(
|
||||
$dateFrom,
|
||||
array_column($emptyReg2, 'reg_1'),
|
||||
true
|
||||
);
|
||||
}
|
||||
if (!empty($hasReg2)) {
|
||||
$history = $history + $this->getPrimaryProductHistory(
|
||||
$dateFrom,
|
||||
array_column($hasReg2, 'reg_1'),
|
||||
false
|
||||
);
|
||||
}
|
||||
foreach ($primaryRows as $row) {
|
||||
$reg = strtoupper(trim((string)($row['reg_1'] ?? '')));
|
||||
if ($reg === '' || !isset($history[$reg])) {
|
||||
@@ -1379,6 +1495,7 @@ class invoice_period_flag_service
|
||||
{
|
||||
$product = (string)($params['product'] ?? 'Item');
|
||||
$expectedProduct = (string)($params['expected_product'] ?? 'expected product');
|
||||
$washId = (string)($params['wash_id'] ?? '');
|
||||
return match ($definitionKey) {
|
||||
'price_mismatch' => "{$product} product price differs from expected.",
|
||||
'customer_rule_restrict_addon_services' => "{$product} violates restricted addon services.",
|
||||
@@ -1398,7 +1515,9 @@ class invoice_period_flag_service
|
||||
'duplicate_vehicle_subscription_charge_same_month' => "Duplicate vehicle subscription charges exist in the same month.",
|
||||
'vehicle_subscription_type_mismatch' => "{$product} does not match the vehicle subscription type {$expectedProduct}.",
|
||||
'historical_primary_product_mismatch' => "{$product} differs from the registration number's usual product {$expectedProduct}.",
|
||||
'xlvask_missing_order_link' => "XL Vask wash is neither ignored nor linked to an order in the selected period.",
|
||||
'xlvask_missing_order_link' => $washId === ''
|
||||
? 'XL Vask wash is neither ignored nor linked to an order in the selected period.'
|
||||
: "XL Vask wash {$washId} is neither ignored nor linked to an order in the selected period.",
|
||||
default => "Automatically detected invoice-period issue.",
|
||||
};
|
||||
}
|
||||
@@ -1425,7 +1544,8 @@ class invoice_period_flag_service
|
||||
['type' => 'text', 'text' => ' is attached without a wash certificate item.'],
|
||||
],
|
||||
'xlvask_missing_order_link' => [
|
||||
['type' => 'xlvask_usage_log', 'text' => 'XL Vask wash'],
|
||||
['type' => 'text', 'text' => 'XL Vask wash '],
|
||||
['type' => 'xlvask_usage_log', 'text' => (string)($params['wash_id'] ?? '')],
|
||||
['type' => 'text', 'text' => ' is neither ignored nor linked to an order in the selected period.'],
|
||||
],
|
||||
default => [],
|
||||
@@ -1685,7 +1805,7 @@ class invoice_period_flag_service
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers): array
|
||||
private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers, ?bool $requireReg2Empty = null): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
@@ -1706,6 +1826,16 @@ class invoice_period_flag_service
|
||||
$registrationFilter = implode(',', array_map(static function (string $registrationNumber) use ($db): string {
|
||||
return "'" . $db->escape_string($registrationNumber) . "'";
|
||||
}, array_keys($registrations)));
|
||||
// Restrict historical orders to those whose reg_2 status matches the current rows:
|
||||
// - null → no filter (default behaviour, backwards compatible)
|
||||
// - true → reg_2 empty (single-tractor orders only)
|
||||
// - false → reg_2 non-empty (tractor-trailer combo orders only)
|
||||
$reg2Filter = '';
|
||||
if ($requireReg2Empty === true) {
|
||||
$reg2Filter = "AND (COALESCE(o.reg_2, '') = '')";
|
||||
} elseif ($requireReg2Empty === false) {
|
||||
$reg2Filter = "AND (COALESCE(o.reg_2, '') <> '')";
|
||||
}
|
||||
$result = $db->query(
|
||||
"SELECT UPPER(TRIM(o.reg_1)) AS reg, oi.product_id, p.name AS product_name, COUNT(*) AS usage_count
|
||||
FROM orders o
|
||||
@@ -1719,6 +1849,7 @@ class invoice_period_flag_service
|
||||
AND COALESCE(oi.related_item_id, 0) = 0
|
||||
AND COALESCE(o.reg_1, '') <> ''
|
||||
AND o.reg_1 IN ({$registrationFilter})
|
||||
{$reg2Filter}
|
||||
GROUP BY UPPER(TRIM(o.reg_1)), oi.product_id, p.name
|
||||
ORDER BY reg, usage_count DESC, oi.product_id ASC"
|
||||
);
|
||||
@@ -2070,11 +2201,6 @@ class invoice_period_flag_service
|
||||
return false;
|
||||
}
|
||||
|
||||
private function rowIsTankCleaningProduct(array $row): bool
|
||||
{
|
||||
return customer_order_product_policy::isTankCleaningProductRow($row);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
$cached_result = $this->readRecognitionResultCache($result_cache, $result_cache_key);
|
||||
if ($cached_result !== null) {
|
||||
$this->last_timings['cache_hit'] = 1;
|
||||
return $cached_result;
|
||||
return $this->completeRecognition($started_at, $cached_result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,7 +202,7 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
|
||||
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
|
||||
|
||||
return $recognized_result;
|
||||
return $this->completeRecognition($started_at, $recognized_result);
|
||||
}
|
||||
|
||||
$recognized_result = [
|
||||
@@ -211,12 +211,19 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
];
|
||||
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
|
||||
|
||||
return $recognized_result;
|
||||
} finally {
|
||||
return $this->completeRecognition($started_at, $recognized_result);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->last_timings['total'] = $this->elapsedMs($started_at);
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function completeRecognition(float $started_at, array $result): array
|
||||
{
|
||||
$this->last_timings['total'] = $this->elapsedMs($started_at);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function clientDisconnectAbortCallback(): callable
|
||||
{
|
||||
return static function (): int {
|
||||
@@ -244,6 +251,11 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use objects\logs_o;
|
||||
use objects\users_o;
|
||||
|
||||
/**
|
||||
* Issues narrowly scoped bearer grants which can be exchanged once for a normal
|
||||
* employee session. Only a SHA-256 digest is persisted. The bearer is derived
|
||||
* under the server encryption key so the same authorized idempotent request can
|
||||
* recover an unconsumed grant after a lost response without storing plaintext.
|
||||
*/
|
||||
class limited_backoffice_login_grant_service
|
||||
{
|
||||
public const PURPOSE_EMPLOYEE_DIRECT_LOGIN = 'limited_backoffice_employee_login';
|
||||
public const DEFAULT_TTL_SECONDS = 300;
|
||||
public const MIN_TTL_SECONDS = 60;
|
||||
public const MAX_TTL_SECONDS = 900;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
limited_backoffice_schema_bootstrap::ensureTables();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function create(users_o $manager, int $employeeId, array $payload): array
|
||||
{
|
||||
(new limited_backoffice_service())->assertEmployeeLoginTarget($manager, $employeeId);
|
||||
|
||||
$purpose = trim((string)($payload['purpose'] ?? self::PURPOSE_EMPLOYEE_DIRECT_LOGIN));
|
||||
if ($purpose !== self::PURPOSE_EMPLOYEE_DIRECT_LOGIN) {
|
||||
throw new limited_backoffice_exception('Unsupported login grant purpose.', 400);
|
||||
}
|
||||
|
||||
$ttlSeconds = $this->ttlSeconds($payload['ttl_seconds'] ?? self::DEFAULT_TTL_SECONDS);
|
||||
$expiresAt = time() + $ttlSeconds;
|
||||
$preflight = ($payload['preflight'] ?? false) === true;
|
||||
|
||||
$base = [
|
||||
'employee_id' => $employeeId,
|
||||
'purpose' => $purpose,
|
||||
'ttl_seconds' => $ttlSeconds,
|
||||
'expires_at' => gmdate('c', $expiresAt),
|
||||
'one_time' => true,
|
||||
];
|
||||
if ($preflight) {
|
||||
return $base + ['preflight' => true];
|
||||
}
|
||||
|
||||
$idempotencyKey = trim((string)($payload['idempotency_key'] ?? ''));
|
||||
if (strlen($idempotencyKey) < 16 || strlen($idempotencyKey) > 128) {
|
||||
throw new limited_backoffice_exception(
|
||||
'idempotency_key must contain between 16 and 128 characters.',
|
||||
400
|
||||
);
|
||||
}
|
||||
$idempotencyKeyHash = hash('sha256', $idempotencyKey);
|
||||
|
||||
$grantId = bin2hex(random_bytes(16));
|
||||
$bearer = $this->bearerForIdempotency(
|
||||
(int)$manager->id,
|
||||
$employeeId,
|
||||
$purpose,
|
||||
$ttlSeconds,
|
||||
$idempotencyKey
|
||||
);
|
||||
$secretHash = hash('sha256', $bearer);
|
||||
|
||||
$mysqli = $this->mysqli();
|
||||
for ($attempt = 0; $attempt < 3; $attempt++) {
|
||||
$mysqli->begin_transaction();
|
||||
try {
|
||||
if (!$this->lockActiveManagedEmployee($employeeId)) {
|
||||
throw new limited_backoffice_exception(
|
||||
'Cannot create a login grant for an inactive employee.',
|
||||
409
|
||||
);
|
||||
}
|
||||
// Target-first ordering matches employee update/deletion. Two
|
||||
// cross-managing actors can still form a cycle, so deadlock
|
||||
// victims are retried below with the same idempotency identity.
|
||||
$authorizedActor = $this->lockAuthorizedActor($manager);
|
||||
(new limited_backoffice_service())->assertEmployeeLoginTarget(
|
||||
$authorizedActor['manager'],
|
||||
$employeeId,
|
||||
$authorizedActor['group_id']
|
||||
);
|
||||
|
||||
$existing = $this->findIdempotentGrant(
|
||||
(int)$manager->id,
|
||||
$employeeId,
|
||||
$purpose,
|
||||
$idempotencyKeyHash
|
||||
);
|
||||
if ($existing !== null) {
|
||||
if ($this->isReplayableGrant($existing, $secretHash)) {
|
||||
$mysqli->commit();
|
||||
return $this->grantResult($employeeId, $purpose, $bearer, $existing);
|
||||
}
|
||||
throw $this->duplicateGrantException($existing);
|
||||
}
|
||||
|
||||
$statement = $mysqli->prepare(
|
||||
'INSERT INTO `limited_backoffice_login_grants`
|
||||
(`grant_id`, `secret_hash`, `target_user_id`, `actor_user_id`, `purpose`,
|
||||
`idempotency_key_hash`, `expires_at`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new limited_backoffice_exception('Unable to prepare login grant.', 500);
|
||||
}
|
||||
$actorUserId = (int)$manager->id;
|
||||
$statement->bind_param(
|
||||
'ssiissi',
|
||||
$grantId,
|
||||
$secretHash,
|
||||
$employeeId,
|
||||
$actorUserId,
|
||||
$purpose,
|
||||
$idempotencyKeyHash,
|
||||
$expiresAt
|
||||
);
|
||||
try {
|
||||
$statement->execute();
|
||||
} finally {
|
||||
$statement->close();
|
||||
}
|
||||
$mysqli->commit();
|
||||
break;
|
||||
} catch (limited_backoffice_exception $exception) {
|
||||
$mysqli->rollback();
|
||||
throw $exception;
|
||||
} catch (\mysqli_sql_exception $exception) {
|
||||
$mysqli->rollback();
|
||||
$errorCode = (int)$exception->getCode();
|
||||
if (in_array($errorCode, [1205, 1213], true) && $attempt < 2) {
|
||||
usleep(1000 * ($attempt + 1));
|
||||
continue;
|
||||
}
|
||||
if ($errorCode === 1062) {
|
||||
$existing = $this->findIdempotentGrant(
|
||||
(int)$manager->id,
|
||||
$employeeId,
|
||||
$purpose,
|
||||
$idempotencyKeyHash
|
||||
);
|
||||
if ($existing !== null) {
|
||||
if ($this->isReplayableGrant($existing, $secretHash)) {
|
||||
return $this->grantResult($employeeId, $purpose, $bearer, $existing);
|
||||
}
|
||||
throw $this->duplicateGrantException($existing);
|
||||
}
|
||||
}
|
||||
throw new limited_backoffice_exception('Unable to create login grant.', 500);
|
||||
} catch (\Throwable) {
|
||||
$mysqli->rollback();
|
||||
throw new limited_backoffice_exception('Unable to create login grant.', 500);
|
||||
}
|
||||
}
|
||||
|
||||
$this->audit(
|
||||
(int)$manager->id,
|
||||
'AUTH_SUCCESS_LIMITED_BACKOFFICE_LOGIN_GRANT_CREATED',
|
||||
'Created one-time login grant ' . $grantId . ' for employee: ' . $employeeId
|
||||
);
|
||||
|
||||
return $this->grantResult($employeeId, $purpose, $bearer, [
|
||||
'grant_id' => $grantId,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{employee_id:int,token:string}
|
||||
*/
|
||||
public function exchange(string $bearer): array
|
||||
{
|
||||
if (!preg_match('/^lbg_[a-f0-9]{64}$/', $bearer)) {
|
||||
throw $this->invalidGrantException();
|
||||
}
|
||||
|
||||
$mysqli = $this->mysqli();
|
||||
$secretHash = hash('sha256', $bearer);
|
||||
$mysqli->begin_transaction();
|
||||
|
||||
try {
|
||||
// Resolve the target without locking, then lock employee -> grant. Employee
|
||||
// deactivation uses the same order, preventing a direct-login session from
|
||||
// surviving a concurrent deactivation and avoiding inverse-order deadlocks.
|
||||
$targetLookup = $mysqli->prepare(
|
||||
'SELECT `target_user_id`
|
||||
FROM `limited_backoffice_login_grants`
|
||||
WHERE `secret_hash` = ?
|
||||
LIMIT 1'
|
||||
);
|
||||
if ($targetLookup === false) {
|
||||
throw new \RuntimeException('Unable to prepare login grant target lookup.');
|
||||
}
|
||||
$targetLookup->bind_param('s', $secretHash);
|
||||
$targetLookup->execute();
|
||||
$target = $targetLookup->get_result()->fetch_assoc() ?: null;
|
||||
$targetLookup->close();
|
||||
if ($target === null || !$this->lockActiveManagedEmployee((int)$target['target_user_id'])) {
|
||||
throw $this->invalidGrantException();
|
||||
}
|
||||
|
||||
$statement = $mysqli->prepare(
|
||||
'SELECT `id`, `grant_id`, `target_user_id`, `purpose`, `expires_at`,
|
||||
`consumed_at`, `revoked_at`
|
||||
FROM `limited_backoffice_login_grants`
|
||||
WHERE `secret_hash` = ?
|
||||
LIMIT 1
|
||||
FOR UPDATE'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare login grant exchange.');
|
||||
}
|
||||
$statement->bind_param('s', $secretHash);
|
||||
$statement->execute();
|
||||
$row = $statement->get_result()->fetch_assoc() ?: null;
|
||||
$statement->close();
|
||||
|
||||
if (
|
||||
$row === null
|
||||
|| $row['purpose'] !== self::PURPOSE_EMPLOYEE_DIRECT_LOGIN
|
||||
|| $row['consumed_at'] !== null
|
||||
|| $row['revoked_at'] !== null
|
||||
|| (int)$row['expires_at'] <= time()
|
||||
|| (int)$row['target_user_id'] !== (int)$target['target_user_id']
|
||||
) {
|
||||
throw $this->invalidGrantException();
|
||||
}
|
||||
|
||||
$grantRowId = (int)$row['id'];
|
||||
$consume = $mysqli->prepare(
|
||||
'UPDATE `limited_backoffice_login_grants`
|
||||
SET `consumed_at` = UTC_TIMESTAMP()
|
||||
WHERE `id` = ? AND `consumed_at` IS NULL AND `revoked_at` IS NULL
|
||||
LIMIT 1'
|
||||
);
|
||||
if ($consume === false) {
|
||||
throw new \RuntimeException('Unable to prepare login grant consumption.');
|
||||
}
|
||||
$consume->bind_param('i', $grantRowId);
|
||||
$consume->execute();
|
||||
$affectedRows = $consume->affected_rows;
|
||||
$consume->close();
|
||||
if ($affectedRows !== 1) {
|
||||
throw $this->invalidGrantException();
|
||||
}
|
||||
|
||||
$employeeId = (int)$row['target_user_id'];
|
||||
$token = (new authentication())->create_employee_token($employeeId);
|
||||
$mysqli->commit();
|
||||
} catch (limited_backoffice_exception $exception) {
|
||||
$mysqli->rollback();
|
||||
throw $exception;
|
||||
} catch (\Throwable) {
|
||||
$mysqli->rollback();
|
||||
throw new limited_backoffice_exception('Unable to exchange login grant.', 500);
|
||||
}
|
||||
|
||||
$this->audit(
|
||||
$employeeId,
|
||||
'AUTH_SUCCESS_LIMITED_BACKOFFICE_LOGIN_GRANT_EXCHANGED',
|
||||
'Exchanged one-time login grant ' . (string)$row['grant_id'] . ' for employee: ' . $employeeId
|
||||
);
|
||||
|
||||
return ['employee_id' => $employeeId, 'token' => $token];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{employee_id:int,revoked_count:int}
|
||||
*/
|
||||
public function revokeForEmployee(users_o $manager, int $employeeId): array
|
||||
{
|
||||
(new limited_backoffice_service())->assertEmployeeLoginTarget($manager, $employeeId);
|
||||
|
||||
$statement = $this->mysqli()->prepare(
|
||||
'UPDATE `limited_backoffice_login_grants`
|
||||
SET `revoked_at` = UTC_TIMESTAMP()
|
||||
WHERE `target_user_id` = ?
|
||||
AND `consumed_at` IS NULL
|
||||
AND `revoked_at` IS NULL
|
||||
AND `expires_at` >= ?'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new limited_backoffice_exception('Unable to prepare login grant revocation.', 500);
|
||||
}
|
||||
$now = time();
|
||||
$statement->bind_param('ii', $employeeId, $now);
|
||||
$statement->execute();
|
||||
$revokedCount = $statement->affected_rows;
|
||||
$statement->close();
|
||||
|
||||
$this->audit(
|
||||
(int)$manager->id,
|
||||
'AUTH_SUCCESS_LIMITED_BACKOFFICE_LOGIN_GRANTS_REVOKED',
|
||||
'Revoked ' . $revokedCount . ' login grants for employee: ' . $employeeId
|
||||
);
|
||||
|
||||
return ['employee_id' => $employeeId, 'revoked_count' => $revokedCount];
|
||||
}
|
||||
|
||||
private function ttlSeconds(mixed $value): int
|
||||
{
|
||||
if (is_string($value) && ctype_digit($value)) {
|
||||
$value = (int)$value;
|
||||
}
|
||||
if (!is_int($value) || $value < self::MIN_TTL_SECONDS || $value > self::MAX_TTL_SECONDS) {
|
||||
throw new limited_backoffice_exception(
|
||||
'ttl_seconds must be between ' . self::MIN_TTL_SECONDS . ' and ' . self::MAX_TTL_SECONDS . '.',
|
||||
400
|
||||
);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function findIdempotentGrant(
|
||||
int $actorUserId,
|
||||
int $employeeId,
|
||||
string $purpose,
|
||||
string $idempotencyKeyHash
|
||||
): ?array {
|
||||
$statement = $this->mysqli()->prepare(
|
||||
'SELECT `grant_id`, `secret_hash`, `target_user_id`, `purpose`, `expires_at`,
|
||||
`consumed_at`, `revoked_at`
|
||||
FROM `limited_backoffice_login_grants`
|
||||
WHERE `actor_user_id` = ?
|
||||
AND `target_user_id` = ?
|
||||
AND `purpose` = ?
|
||||
AND `idempotency_key_hash` = ?
|
||||
LIMIT 1'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new limited_backoffice_exception('Unable to check login grant idempotency.', 500);
|
||||
}
|
||||
$statement->bind_param('iiss', $actorUserId, $employeeId, $purpose, $idempotencyKeyHash);
|
||||
$statement->execute();
|
||||
$row = $statement->get_result()->fetch_assoc() ?: null;
|
||||
$statement->close();
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function bearerForIdempotency(
|
||||
int $actorUserId,
|
||||
int $employeeId,
|
||||
string $purpose,
|
||||
int $ttlSeconds,
|
||||
string $idempotencyKey
|
||||
): string {
|
||||
$key = trim((string)($GLOBALS['ENCRYPTION_KEY'] ?? getenv('ENCRYPTION_KEY') ?: ''));
|
||||
if ($key === '') {
|
||||
throw new limited_backoffice_exception('Login grant encryption key is unavailable.', 503);
|
||||
}
|
||||
return 'lbg_' . hash_hmac(
|
||||
'sha256',
|
||||
$actorUserId . ':' . $employeeId . ':' . $purpose . ':' . $ttlSeconds . ':' . $idempotencyKey,
|
||||
$key
|
||||
);
|
||||
}
|
||||
|
||||
private function isReplayableGrant(array $row, string $secretHash): bool
|
||||
{
|
||||
return hash_equals((string)($row['secret_hash'] ?? ''), $secretHash)
|
||||
&& ($row['consumed_at'] ?? null) === null
|
||||
&& ($row['revoked_at'] ?? null) === null
|
||||
&& (int)($row['expires_at'] ?? 0) > time();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function grantResult(
|
||||
int $employeeId,
|
||||
string $purpose,
|
||||
string $bearer,
|
||||
array $row
|
||||
): array {
|
||||
$expiresAt = (int)$row['expires_at'];
|
||||
return [
|
||||
'employee_id' => $employeeId,
|
||||
'purpose' => $purpose,
|
||||
'ttl_seconds' => max(0, $expiresAt - time()),
|
||||
'expires_at' => gmdate('c', $expiresAt),
|
||||
'one_time' => true,
|
||||
'preflight' => false,
|
||||
'grant_id' => (string)$row['grant_id'],
|
||||
// The fragment avoids ingress request logs and Referer propagation.
|
||||
'login_path' => '/login/qr#grant=' . rawurlencode($bearer),
|
||||
'exchange_path' => '/auth/limited-backoffice-login-grants/exchange',
|
||||
];
|
||||
}
|
||||
|
||||
private function duplicateGrantException(array $row): limited_backoffice_exception
|
||||
{
|
||||
return new limited_backoffice_exception(
|
||||
'A login grant already exists for this idempotency key.',
|
||||
409,
|
||||
[
|
||||
'message' => 'A login grant already exists for this idempotency key.',
|
||||
'code' => 'LOGIN_GRANT_IDEMPOTENCY_CONFLICT',
|
||||
'grant_id' => (string)$row['grant_id'],
|
||||
'employee_id' => (int)$row['target_user_id'],
|
||||
'purpose' => (string)$row['purpose'],
|
||||
'expires_at' => gmdate('c', (int)$row['expires_at']),
|
||||
'consumed' => $row['consumed_at'] !== null,
|
||||
'revoked' => $row['revoked_at'] !== null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function invalidGrantException(): limited_backoffice_exception
|
||||
{
|
||||
return new limited_backoffice_exception('Invalid or expired login grant.', 401);
|
||||
}
|
||||
|
||||
private function lockActiveManagedEmployee(int $employeeId): bool
|
||||
{
|
||||
$statement = $this->mysqli()->prepare(
|
||||
'SELECT lbe.`user_id`, lbe.`managed_group_id`, u.`group_id`
|
||||
FROM `limited_backoffice_employees` lbe
|
||||
INNER JOIN `users` u ON u.`id` = lbe.`user_id`
|
||||
WHERE lbe.`user_id` = ? AND lbe.`deactivated_at` IS NULL
|
||||
LIMIT 1
|
||||
FOR UPDATE'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new limited_backoffice_exception('Unable to lock login grant employee.', 500);
|
||||
}
|
||||
$statement->bind_param('i', $employeeId);
|
||||
$statement->execute();
|
||||
$employee = $statement->get_result()->fetch_assoc() ?: null;
|
||||
$statement->close();
|
||||
if ($employee === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$groupId = (int)$employee['group_id'];
|
||||
$managedGroupId = (int)$employee['managed_group_id'];
|
||||
if ($groupId <= 0 || $groupId === 1 || $managedGroupId !== $groupId) {
|
||||
throw new limited_backoffice_exception('Login grant target is no longer safe.', 403);
|
||||
}
|
||||
|
||||
// Lock the complete permission range so role changes cannot add elevated
|
||||
// capabilities between validation and token creation.
|
||||
$permissions = $this->mysqli()->prepare(
|
||||
'SELECT `permission`
|
||||
FROM `groups_permissions`
|
||||
WHERE `group_id` = ?
|
||||
FOR UPDATE'
|
||||
);
|
||||
if ($permissions === false) {
|
||||
throw new limited_backoffice_exception('Unable to validate login grant role.', 500);
|
||||
}
|
||||
$permissions->bind_param('i', $groupId);
|
||||
$permissions->execute();
|
||||
$result = $permissions->get_result();
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
if ((string)($row['permission'] ?? '') === 'superuser') {
|
||||
$permissions->close();
|
||||
throw new limited_backoffice_exception('Login grant target is no longer safe.', 403);
|
||||
}
|
||||
}
|
||||
$permissions->close();
|
||||
|
||||
$groupUsers = $this->mysqli()->prepare(
|
||||
'SELECT `id` FROM `users` WHERE `group_id` = ? FOR UPDATE'
|
||||
);
|
||||
if ($groupUsers === false) {
|
||||
throw new limited_backoffice_exception('Unable to validate login grant group.', 500);
|
||||
}
|
||||
$groupUsers->bind_param('i', $groupId);
|
||||
$groupUsers->execute();
|
||||
$groupUserResult = $groupUsers->get_result();
|
||||
$userCount = 0;
|
||||
while ($groupUserResult->fetch_assoc() !== null) {
|
||||
$userCount++;
|
||||
}
|
||||
$groupUsers->close();
|
||||
if ($userCount !== 1) {
|
||||
throw new limited_backoffice_exception('Login grant target group is shared.', 403);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{manager:users_o,group_id:int}
|
||||
*/
|
||||
private function lockAuthorizedActor(users_o $manager): array
|
||||
{
|
||||
$actorUserId = (int)$manager->id;
|
||||
$statement = $this->mysqli()->prepare(
|
||||
'SELECT `group_id` FROM `users` WHERE `id` = ? LIMIT 1 FOR UPDATE'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new limited_backoffice_exception('Unable to lock login grant actor.', 500);
|
||||
}
|
||||
$statement->bind_param('i', $actorUserId);
|
||||
$statement->execute();
|
||||
$actor = $statement->get_result()->fetch_assoc() ?: null;
|
||||
$statement->close();
|
||||
if (
|
||||
$actor === null
|
||||
|| (int)$actor['group_id'] <= 0
|
||||
|| account_deletion_service::principalIsBlocked('customer', $actorUserId)
|
||||
) {
|
||||
throw new limited_backoffice_exception('Login grant actor is no longer authorized.', 403);
|
||||
}
|
||||
|
||||
$groupId = (int)$actor['group_id'];
|
||||
if ($groupId !== 1) {
|
||||
$requiredPermissions = [
|
||||
limited_backoffice_service::PERMISSION_ACCESS,
|
||||
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES,
|
||||
];
|
||||
// Lock the actor's complete permission set, including every department_access_* row
|
||||
// consumed by assertEmployeeLoginTarget.
|
||||
// The group_id range lock prevents concurrent role replacement
|
||||
// from revoking scope between authorization and grant insertion.
|
||||
$permissions = $this->mysqli()->prepare(
|
||||
'SELECT `permission`
|
||||
FROM `groups_permissions`
|
||||
WHERE `group_id` = ?
|
||||
FOR UPDATE'
|
||||
);
|
||||
if ($permissions === false) {
|
||||
throw new limited_backoffice_exception('Unable to validate login grant actor.', 500);
|
||||
}
|
||||
$permissions->bind_param('i', $groupId);
|
||||
$permissions->execute();
|
||||
$result = $permissions->get_result();
|
||||
$granted = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$granted[] = (string)$row['permission'];
|
||||
}
|
||||
$permissions->close();
|
||||
if (array_diff($requiredPermissions, $granted) !== []) {
|
||||
throw new limited_backoffice_exception(
|
||||
'Login grant actor is no longer authorized.',
|
||||
403
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$currentManager = (new users_o())->getUserById($actorUserId);
|
||||
if (!$currentManager->exists()) {
|
||||
throw new limited_backoffice_exception('Login grant actor is no longer authorized.', 403);
|
||||
}
|
||||
return [
|
||||
'manager' => $currentManager,
|
||||
'group_id' => $groupId,
|
||||
];
|
||||
}
|
||||
|
||||
private function audit(int $actorUserId, string $event, string $message): void
|
||||
{
|
||||
try {
|
||||
(new logs_o())->add('auth', 'global', 1, $actorUserId, $event, $message);
|
||||
} catch (\Throwable) {
|
||||
// Audit logging must not expose a bearer or block the grant lifecycle.
|
||||
}
|
||||
}
|
||||
|
||||
private function mysqli(): \mysqli
|
||||
{
|
||||
global $db;
|
||||
return $db->conn();
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,45 @@ CREATE TABLE IF NOT EXISTS `limited_backoffice_employees` (
|
||||
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);
|
||||
|
||||
$db->query(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `limited_backoffice_login_grants` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`grant_id` CHAR(32) NOT NULL,
|
||||
`secret_hash` CHAR(64) NOT NULL,
|
||||
`target_user_id` INT NOT NULL,
|
||||
`actor_user_id` INT NOT NULL,
|
||||
`purpose` VARCHAR(64) NOT NULL,
|
||||
`idempotency_key_hash` CHAR(64) NULL,
|
||||
`expires_at` BIGINT UNSIGNED NOT NULL,
|
||||
`consumed_at` DATETIME NULL,
|
||||
`revoked_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_limited_backoffice_login_grants_grant_id` (`grant_id`),
|
||||
UNIQUE KEY `uniq_limited_backoffice_login_grants_secret_hash` (`secret_hash`),
|
||||
UNIQUE KEY `uniq_limited_backoffice_login_grants_idempotency` (`actor_user_id`, `target_user_id`, `purpose`, `idempotency_key_hash`),
|
||||
KEY `idx_limited_backoffice_login_grants_target` (`target_user_id`, `expires_at`),
|
||||
KEY `idx_limited_backoffice_login_grants_expiry` (`expires_at`),
|
||||
KEY `idx_limited_backoffice_login_grants_state` (`consumed_at`, `revoked_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL);
|
||||
|
||||
$db->query(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `limited_backoffice_action_idempotency` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`actor_user_id` INT NOT NULL,
|
||||
`action_type` VARCHAR(64) NOT NULL,
|
||||
`idempotency_key_hash` CHAR(64) NOT NULL,
|
||||
`payload_hash` CHAR(64) NOT NULL,
|
||||
`result_user_id` INT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_limited_backoffice_action_idempotency`
|
||||
(`actor_user_id`, `action_type`, `idempotency_key_hash`),
|
||||
KEY `idx_limited_backoffice_action_result` (`result_user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL);
|
||||
|
||||
self::$initialized = true;
|
||||
|
||||
@@ -11,6 +11,8 @@ class limited_backoffice_service
|
||||
{
|
||||
public const PERMISSION_ACCESS = 'limited_backoffice_access';
|
||||
public const PERMISSION_MANAGE_PRICES = 'limited_backoffice_prices_manage';
|
||||
public const PERMISSION_VIEW_CUSTOMER_PRICING = 'limited_backoffice_customer_pricing_view';
|
||||
public const PERMISSION_MANAGE_CUSTOMER_PRICING = 'limited_backoffice_customer_pricing_manage';
|
||||
public const PERMISSION_MANAGE_EMPLOYEES = 'limited_backoffice_employees_manage';
|
||||
|
||||
private const PERMISSION_PUBLIC_EMPLOYEE_DATA = 'employee_public_data';
|
||||
@@ -251,6 +253,8 @@ class limited_backoffice_service
|
||||
'statistics_bookings_new',
|
||||
self::PERMISSION_ACCESS,
|
||||
self::PERMISSION_MANAGE_PRICES,
|
||||
self::PERMISSION_VIEW_CUSTOMER_PRICING,
|
||||
self::PERMISSION_MANAGE_CUSTOMER_PRICING,
|
||||
self::PERMISSION_MANAGE_EMPLOYEES,
|
||||
],
|
||||
],
|
||||
@@ -484,6 +488,14 @@ class limited_backoffice_service
|
||||
'group' => 'limited_backoffice',
|
||||
'capability' => 'manage_department_prices',
|
||||
],
|
||||
self::PERMISSION_VIEW_CUSTOMER_PRICING => [
|
||||
'group' => 'limited_backoffice',
|
||||
'capability' => 'view_customer_pricing',
|
||||
],
|
||||
self::PERMISSION_MANAGE_CUSTOMER_PRICING => [
|
||||
'group' => 'limited_backoffice',
|
||||
'capability' => 'manage_customer_pricing',
|
||||
],
|
||||
self::PERMISSION_MANAGE_EMPLOYEES => [
|
||||
'group' => 'limited_backoffice',
|
||||
'capability' => 'manage_employee_access',
|
||||
@@ -616,12 +628,47 @@ class limited_backoffice_service
|
||||
}
|
||||
|
||||
if ($user->hasPermission('superuser')) {
|
||||
global $db;
|
||||
$rows = $db->fetch_all($db->query(
|
||||
'SELECT `id` FROM `departments` ORDER BY `id` ASC'
|
||||
));
|
||||
return $this->allDepartmentIds();
|
||||
}
|
||||
|
||||
return array_values(array_map(static fn(array $row): int => (int)$row['id'], $rows));
|
||||
return $this->accessibleDepartmentIdsForGroup($groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the authoritative department scope only when the authenticated
|
||||
* user is managed by limited backoffice. A null result means the caller
|
||||
* must preserve the route's existing non-limited authorization semantics.
|
||||
*
|
||||
* @return array<int, int>|null
|
||||
*/
|
||||
public function managedEmployeeDepartmentIds(users_o $user): ?array
|
||||
{
|
||||
if (!$user->exists() || (int)$user->id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$managedEmployee = $this->loadManagedEmployee((int)$user->id);
|
||||
if ($managedEmployee === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->decodeDepartmentIds((string)($managedEmployee['department_ids'] ?? '[]'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads department scope from an authoritative group identity rather than
|
||||
* from user object properties that may be backed by a stale Redis value.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function accessibleDepartmentIdsForGroup(int $groupId): array
|
||||
{
|
||||
if ($groupId <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($groupId === 1) {
|
||||
return $this->allDepartmentIds();
|
||||
}
|
||||
|
||||
global $db;
|
||||
@@ -641,6 +688,9 @@ class limited_backoffice_service
|
||||
$departmentIds = [];
|
||||
foreach ($rows as $row) {
|
||||
$permission = (string)($row['permission'] ?? '');
|
||||
if ($permission === 'superuser') {
|
||||
return $this->allDepartmentIds();
|
||||
}
|
||||
if (preg_match('/^department_access_([0-9]+)$/', $permission, $matches) !== 1) {
|
||||
continue;
|
||||
}
|
||||
@@ -654,6 +704,19 @@ class limited_backoffice_service
|
||||
return array_values(array_unique($departmentIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function allDepartmentIds(): array
|
||||
{
|
||||
global $db;
|
||||
$rows = $db->fetch_all($db->query(
|
||||
'SELECT `id` FROM `departments` ORDER BY `id` ASC'
|
||||
));
|
||||
|
||||
return array_values(array_map(static fn(array $row): int => (int)$row['id'], $rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
@@ -711,6 +774,7 @@ class limited_backoffice_service
|
||||
return [
|
||||
'department' => $department,
|
||||
'categories' => $catalog['categories'],
|
||||
'revision' => $this->departmentPricesRevision($departmentId),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -762,10 +826,17 @@ class limited_backoffice_service
|
||||
}
|
||||
}
|
||||
|
||||
$expectedRevision = $this->normalizeExpectedPricingRevision($payload['expected_revision'] ?? null);
|
||||
$mysqli = $this->mysqli();
|
||||
$mysqli->begin_transaction();
|
||||
|
||||
try {
|
||||
$this->lockDepartmentForPricingUpdate($departmentId);
|
||||
$currentRevision = $this->departmentPricesRevision($departmentId);
|
||||
if ($expectedRevision !== null && !hash_equals($currentRevision, $expectedRevision)) {
|
||||
throw $this->pricingRevisionConflict($currentRevision);
|
||||
}
|
||||
|
||||
$deleteStatement = $mysqli->prepare(
|
||||
'DELETE FROM `product_department_prices` WHERE `department_id` = ? AND `product_id` = ?'
|
||||
);
|
||||
@@ -778,15 +849,22 @@ class limited_backoffice_service
|
||||
|
||||
foreach ($normalizedPrices as $productId => $price) {
|
||||
$deleteStatement->bind_param('ii', $departmentId, $productId);
|
||||
$deleteStatement->execute();
|
||||
if (!$deleteStatement->execute()) {
|
||||
throw new \RuntimeException('Unable to clear department price.');
|
||||
}
|
||||
|
||||
$insertStatement->bind_param('iii', $departmentId, $productId, $price);
|
||||
$insertStatement->execute();
|
||||
if (!$insertStatement->execute()) {
|
||||
throw new \RuntimeException('Unable to save department price.');
|
||||
}
|
||||
}
|
||||
|
||||
$deleteStatement->close();
|
||||
$insertStatement->close();
|
||||
$mysqli->commit();
|
||||
} catch (limited_backoffice_exception $exception) {
|
||||
$mysqli->rollback();
|
||||
throw $exception;
|
||||
} catch (\Throwable $throwable) {
|
||||
$mysqli->rollback();
|
||||
throw new limited_backoffice_exception('Unable to update department prices.', 500);
|
||||
@@ -795,6 +873,34 @@ class limited_backoffice_service
|
||||
return $this->getDepartmentPrices($user, $departmentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getDepartmentCustomerPricing(users_o $user, int $departmentId, int $customerUserId): array
|
||||
{
|
||||
$this->assertDepartmentAccess($user, $departmentId);
|
||||
return (new department_customer_pricing_service())->getPricing($departmentId, $customerUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function updateDepartmentCustomerPricing(users_o $user, int $departmentId, int $customerUserId, array $payload): array
|
||||
{
|
||||
$this->assertDepartmentAccess($user, $departmentId);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return (new department_customer_pricing_service())->updatePricing($departmentId, $customerUserId, [
|
||||
...$payload,
|
||||
'department_id' => $departmentId,
|
||||
'user_id' => $customerUserId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
@@ -859,11 +965,65 @@ class limited_backoffice_service
|
||||
$password = $this->normalizePassword($payload['password'] ?? null, true);
|
||||
$email = $this->normalizeEmail($payload['email'] ?? null, true);
|
||||
$phone = $this->normalizeOptionalPhonePair($payload);
|
||||
$idempotencyKey = trim((string)($payload['idempotency_key'] ?? ''));
|
||||
if ($idempotencyKey !== '' && (strlen($idempotencyKey) < 16 || strlen($idempotencyKey) > 128)) {
|
||||
throw new limited_backoffice_exception(
|
||||
'idempotency_key must contain between 16 and 128 characters.',
|
||||
400
|
||||
);
|
||||
}
|
||||
$idempotencyKeyHash = $idempotencyKey === '' ? null : hash('sha256', $idempotencyKey);
|
||||
$payloadJson = (string)json_encode([
|
||||
'department_ids' => $departmentIds,
|
||||
'role_key' => $roleKey,
|
||||
'display_name' => $displayName,
|
||||
'password' => $password,
|
||||
'email' => $email,
|
||||
'phone_country_code' => $phone['phone_country_code'],
|
||||
'phone' => $phone['phone'],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$payloadHash = $idempotencyKeyHash === null
|
||||
? null
|
||||
: hash_hmac('sha256', $payloadJson, $this->idempotencyDigestKey());
|
||||
|
||||
$mysqli = $this->mysqli();
|
||||
$mysqli->begin_transaction();
|
||||
|
||||
try {
|
||||
if ($idempotencyKeyHash !== null) {
|
||||
$replayedEmployeeId = $this->reserveEmployeeCreateIdempotency(
|
||||
(int)$manager->id,
|
||||
$idempotencyKeyHash,
|
||||
$payloadHash
|
||||
);
|
||||
if ($replayedEmployeeId !== null) {
|
||||
$employee = $this->loadManagedEmployee($replayedEmployeeId);
|
||||
if ($employee === null) {
|
||||
throw new limited_backoffice_exception(
|
||||
'Idempotent employee result is unavailable.',
|
||||
409
|
||||
);
|
||||
}
|
||||
$currentDepartmentIds = $this->decodeDepartmentIds(
|
||||
(string)$employee['department_ids']
|
||||
);
|
||||
$this->assertDepartmentSubset($manager, $currentDepartmentIds);
|
||||
$this->assertManagedTargetIsSafe($employee);
|
||||
if (!$this->isEmployeeRowActive($employee)) {
|
||||
throw new limited_backoffice_exception(
|
||||
'Idempotent employee result is no longer active.',
|
||||
409
|
||||
);
|
||||
}
|
||||
$mysqli->commit();
|
||||
return $this->formatEmployee(
|
||||
$employee,
|
||||
$currentDepartmentIds,
|
||||
$this->isEmployeeRowActive($employee)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$groupId = $this->insertManagedGroup($manager, $roleKey, $departmentIds);
|
||||
$customerNumber = self::MANAGED_EMPLOYEE_CUSTOMER_NUMBER;
|
||||
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
|
||||
@@ -910,7 +1070,35 @@ class limited_backoffice_service
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
|
||||
if ($idempotencyKeyHash !== null) {
|
||||
$statement = $mysqli->prepare(
|
||||
'UPDATE `limited_backoffice_action_idempotency`
|
||||
SET `result_user_id` = ?
|
||||
WHERE `actor_user_id` = ?
|
||||
AND `action_type` = ?
|
||||
AND `idempotency_key_hash` = ?
|
||||
LIMIT 1'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare employee idempotency result.');
|
||||
}
|
||||
$managerId = (int)$manager->id;
|
||||
$actionType = 'employee.create';
|
||||
$statement->bind_param(
|
||||
'iiss',
|
||||
$employeeId,
|
||||
$managerId,
|
||||
$actionType,
|
||||
$idempotencyKeyHash
|
||||
);
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
}
|
||||
|
||||
$mysqli->commit();
|
||||
} catch (limited_backoffice_exception $exception) {
|
||||
$mysqli->rollback();
|
||||
throw $exception;
|
||||
} catch (\Throwable) {
|
||||
$mysqli->rollback();
|
||||
throw new limited_backoffice_exception('Unable to create employee.', 500);
|
||||
@@ -924,6 +1112,66 @@ class limited_backoffice_service
|
||||
return $this->formatEmployee($employee, $departmentIds, true);
|
||||
}
|
||||
|
||||
private function reserveEmployeeCreateIdempotency(
|
||||
int $actorUserId,
|
||||
string $keyHash,
|
||||
string $payloadHash
|
||||
): ?int {
|
||||
$mysqli = $this->mysqli();
|
||||
$actionType = 'employee.create';
|
||||
$statement = $mysqli->prepare(
|
||||
'INSERT INTO `limited_backoffice_action_idempotency`
|
||||
(`actor_user_id`, `action_type`, `idempotency_key_hash`, `payload_hash`)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE `id` = `id`'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare employee idempotency reservation.');
|
||||
}
|
||||
$statement->bind_param('isss', $actorUserId, $actionType, $keyHash, $payloadHash);
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
|
||||
$statement = $mysqli->prepare(
|
||||
'SELECT `payload_hash`, `result_user_id`
|
||||
FROM `limited_backoffice_action_idempotency`
|
||||
WHERE `actor_user_id` = ?
|
||||
AND `action_type` = ?
|
||||
AND `idempotency_key_hash` = ?
|
||||
LIMIT 1
|
||||
FOR UPDATE'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare employee idempotency lookup.');
|
||||
}
|
||||
$statement->bind_param('iss', $actorUserId, $actionType, $keyHash);
|
||||
$statement->execute();
|
||||
$row = $statement->get_result()->fetch_assoc() ?: null;
|
||||
$statement->close();
|
||||
if ($row === null) {
|
||||
throw new \RuntimeException('Unable to load employee idempotency reservation.');
|
||||
}
|
||||
if (!hash_equals((string)$row['payload_hash'], $payloadHash)) {
|
||||
throw new limited_backoffice_exception(
|
||||
'Idempotency key was already used with a different employee payload.',
|
||||
409
|
||||
);
|
||||
}
|
||||
return $row['result_user_id'] === null ? null : (int)$row['result_user_id'];
|
||||
}
|
||||
|
||||
private function idempotencyDigestKey(): string
|
||||
{
|
||||
$key = trim((string)($GLOBALS['ENCRYPTION_KEY'] ?? getenv('ENCRYPTION_KEY') ?: ''));
|
||||
if ($key === '') {
|
||||
throw new limited_backoffice_exception(
|
||||
'Employee idempotency protection is not configured.',
|
||||
500
|
||||
);
|
||||
}
|
||||
return hash_hmac('sha256', 'limited-backoffice-employee-idempotency-v1', $key, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
@@ -1042,6 +1290,25 @@ class limited_backoffice_service
|
||||
$mysqli->begin_transaction();
|
||||
|
||||
try {
|
||||
$lock = $mysqli->prepare(
|
||||
'SELECT lbe.`user_id`
|
||||
FROM `limited_backoffice_employees` lbe
|
||||
INNER JOIN `users` u ON u.`id` = lbe.`user_id`
|
||||
WHERE lbe.`user_id` = ? AND lbe.`managed_group_id` = ?
|
||||
LIMIT 1
|
||||
FOR UPDATE'
|
||||
);
|
||||
if ($lock === false) {
|
||||
throw new \RuntimeException('Unable to prepare employee update lock.');
|
||||
}
|
||||
$lock->bind_param('ii', $employeeId, $managedGroupId);
|
||||
$lock->execute();
|
||||
$lockedEmployee = $lock->get_result()->fetch_assoc() ?: null;
|
||||
$lock->close();
|
||||
if ($lockedEmployee === null) {
|
||||
throw new limited_backoffice_exception('Managed employee changed before update.', 409);
|
||||
}
|
||||
|
||||
if ($active) {
|
||||
$this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($manager, $roleKey, $newDepartmentIds));
|
||||
}
|
||||
@@ -1117,9 +1384,13 @@ class limited_backoffice_service
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{employee_id:int,login_path:string}
|
||||
* Applies the target and department boundary used by one-time login grants.
|
||||
*/
|
||||
public function createEmployeeLoginLink(users_o $manager, int $employeeId): array
|
||||
public function assertEmployeeLoginTarget(
|
||||
users_o $manager,
|
||||
int $employeeId,
|
||||
?int $authoritativeGroupId = null
|
||||
): void
|
||||
{
|
||||
$this->assertNotSelfEdit($manager, $employeeId);
|
||||
|
||||
@@ -1129,32 +1400,12 @@ class limited_backoffice_service
|
||||
}
|
||||
|
||||
$departmentIds = $this->decodeDepartmentIds((string)$employee['department_ids']);
|
||||
$this->assertDepartmentSubset($manager, $departmentIds);
|
||||
$this->assertDepartmentSubset($manager, $departmentIds, $authoritativeGroupId);
|
||||
$this->assertManagedTargetIsSafe($employee);
|
||||
|
||||
if (!$this->isEmployeeRowActive($employee)) {
|
||||
throw new limited_backoffice_exception('Cannot create a login link for an inactive employee.', 409);
|
||||
}
|
||||
|
||||
$token = (new authentication())->create_employee_token($employeeId);
|
||||
|
||||
try {
|
||||
(new logs_o())->add(
|
||||
'auth',
|
||||
'global',
|
||||
1,
|
||||
(int)$manager->id,
|
||||
'AUTH_SUCCESS_LIMITED_BACKOFFICE_EMPLOYEE_LOGIN_LINK',
|
||||
'Created limited backoffice login link for employee: ' . $employeeId
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Audit logging should not block login-link generation.
|
||||
}
|
||||
|
||||
return [
|
||||
'employee_id' => $employeeId,
|
||||
'login_path' => '/login/qr?token=' . $token,
|
||||
];
|
||||
}
|
||||
|
||||
private function mysqli(): mysqli
|
||||
@@ -1163,6 +1414,80 @@ class limited_backoffice_service
|
||||
return $db->conn();
|
||||
}
|
||||
|
||||
private function lockDepartmentForPricingUpdate(int $departmentId): void
|
||||
{
|
||||
$statement = $this->mysqli()->prepare(
|
||||
'SELECT `id` FROM `departments` WHERE `id` = ? FOR UPDATE'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare pricing update lock.');
|
||||
}
|
||||
|
||||
$statement->bind_param('i', $departmentId);
|
||||
$statement->execute();
|
||||
$result = $statement->get_result();
|
||||
$exists = $result->num_rows > 0;
|
||||
$statement->close();
|
||||
|
||||
if (!$exists) {
|
||||
throw new limited_backoffice_exception('Department not found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
private function departmentPricesRevision(int $departmentId): string
|
||||
{
|
||||
global $db;
|
||||
|
||||
$statement = $this->mysqli()->prepare(
|
||||
'SELECT `product_id`, `price`
|
||||
FROM `product_department_prices`
|
||||
WHERE `department_id` = ?
|
||||
ORDER BY `product_id` ASC, `id` ASC'
|
||||
);
|
||||
if ($statement === false) {
|
||||
throw new \RuntimeException('Unable to prepare department price revision.');
|
||||
}
|
||||
|
||||
$statement->bind_param('i', $departmentId);
|
||||
$statement->execute();
|
||||
$rows = $db->fetch_all($statement->get_result());
|
||||
$statement->close();
|
||||
|
||||
$revisionRows = array_map(static fn(array $row): array => [
|
||||
'product_id' => (int)$row['product_id'],
|
||||
'price' => (int)$row['price'],
|
||||
], $rows);
|
||||
|
||||
return hash('sha256', json_encode($revisionRows, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function normalizeExpectedPricingRevision(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
// Transitional compatibility for already-deployed clients. New clients
|
||||
// send the revision returned by GET and receive stale-write protection.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_string($value) || preg_match('/^[a-f0-9]{64}$/', $value) !== 1) {
|
||||
throw new limited_backoffice_exception('Expected revision is invalid.', 400, [
|
||||
'message' => 'Expected revision is invalid.',
|
||||
'code' => 'pricing_revision_invalid',
|
||||
]);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function pricingRevisionConflict(string $currentRevision): limited_backoffice_exception
|
||||
{
|
||||
return new limited_backoffice_exception('Pricing has changed. Reload and try again.', 409, [
|
||||
'message' => 'Pricing has changed. Reload and try again.',
|
||||
'code' => 'pricing_revision_conflict',
|
||||
'current_revision' => $currentRevision,
|
||||
]);
|
||||
}
|
||||
|
||||
private function tableHasColumn(string $table, string $column): bool
|
||||
{
|
||||
$cacheKey = $table . '.' . $column;
|
||||
@@ -1412,13 +1737,19 @@ class limited_backoffice_service
|
||||
/**
|
||||
* @param array<int, int> $departmentIds
|
||||
*/
|
||||
private function assertDepartmentSubset(users_o $manager, array $departmentIds): void
|
||||
private function assertDepartmentSubset(
|
||||
users_o $manager,
|
||||
array $departmentIds,
|
||||
?int $authoritativeGroupId = null
|
||||
): void
|
||||
{
|
||||
if ($departmentIds === []) {
|
||||
throw new limited_backoffice_exception('At least one department is required.', 400);
|
||||
}
|
||||
|
||||
$managerDepartmentIds = $this->accessibleDepartmentIds($manager);
|
||||
$managerDepartmentIds = $authoritativeGroupId === null
|
||||
? $this->accessibleDepartmentIds($manager)
|
||||
: $this->accessibleDepartmentIdsForGroup($authoritativeGroupId);
|
||||
$outside = array_values(array_diff($departmentIds, $managerDepartmentIds));
|
||||
if ($outside !== []) {
|
||||
$permissions = array_map(static fn(int $id): string => 'department_access_' . $id, $outside);
|
||||
@@ -2015,6 +2346,13 @@ class limited_backoffice_service
|
||||
}
|
||||
}
|
||||
$db->query('DELETE FROM `tokens` WHERE `user_id` = ' . (int)$userId);
|
||||
$db->query(
|
||||
'UPDATE `limited_backoffice_login_grants`
|
||||
SET `revoked_at` = UTC_TIMESTAMP()
|
||||
WHERE `target_user_id` = ' . (int)$userId . '
|
||||
AND `consumed_at` IS NULL
|
||||
AND `revoked_at` IS NULL'
|
||||
);
|
||||
$this->clearUserSessionCache($userId);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,999 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
use mysqli_result;
|
||||
use Throwable;
|
||||
use traits\boolean_normalization_t;
|
||||
|
||||
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
|
||||
|
||||
class module_usage_service
|
||||
{
|
||||
use boolean_normalization_t;
|
||||
|
||||
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
|
||||
{
|
||||
return self::normalizeBoolean($value);
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,6 @@ class object_property
|
||||
private string $table; // The id of the object in the database
|
||||
private string $column; // The column name of the field in the database table (e.g. id, name, email)
|
||||
private string $type; // The data type of the field in the database table (e.g. int, varchar, text)
|
||||
private bool $required; // Whether the field is required or not
|
||||
private mixed $default; // The default value of the field
|
||||
private mixed $fake_value; // The fake value of the field, used for testing purposes (When the object id is -1)
|
||||
|
||||
@@ -18,7 +17,7 @@ class object_property
|
||||
$this->id = $id;
|
||||
$this->column = $column;
|
||||
$this->type = $type;
|
||||
$this->required = $required;
|
||||
unset($required); // Retained in the constructor for compatibility with existing object definitions.
|
||||
$this->default = $default;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,14 @@ use Exception;
|
||||
use interfaces\openai_i;
|
||||
use openAI\openAI_c;
|
||||
|
||||
class openai_request_exception extends Exception
|
||||
{
|
||||
public function __construct(string $message, public readonly bool $retryable = false, public readonly ?int $httpStatus = null)
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
|
||||
class openai implements openai_i
|
||||
{
|
||||
/**
|
||||
@@ -44,19 +52,35 @@ class openai implements openai_i
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function jsonTask(string $schemaName, string $prompt, array $payload, array $schema, float $temperature = 0.1): array
|
||||
public function jsonTask(
|
||||
string $schemaName,
|
||||
string $prompt,
|
||||
array $payload,
|
||||
array $schema,
|
||||
float $temperature = 0.1,
|
||||
?string $model = null
|
||||
): array
|
||||
{
|
||||
$this->requireModuleEnabled();
|
||||
|
||||
$data = [
|
||||
'model' => $this->model,
|
||||
'model' => $model ?? $this->model,
|
||||
// The caller owns the durable audit record. Do not retain application state at OpenAI.
|
||||
'store' => false,
|
||||
'input' => [
|
||||
[
|
||||
'role' => 'developer',
|
||||
'content' => [[
|
||||
'type' => 'input_text',
|
||||
'text' => $prompt,
|
||||
]],
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => [
|
||||
[
|
||||
'type' => 'input_text',
|
||||
'text' => $prompt . "\n\nData:\n" . json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'text' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
],
|
||||
],
|
||||
],
|
||||
@@ -73,17 +97,56 @@ class openai implements openai_i
|
||||
];
|
||||
|
||||
$response = $this->sendRequest($data);
|
||||
$output = $response['output'][0]['content'][0]['text'] ?? null;
|
||||
if (!is_string($output) || $output === '') {
|
||||
throw new Exception('Invalid response format from OpenAI API. (Missing text field)');
|
||||
return self::parseJsonTaskResponse($response);
|
||||
}
|
||||
|
||||
public static function parseJsonTaskResponse(array $response): array
|
||||
{
|
||||
$status = (string)($response['status'] ?? '');
|
||||
if ($status === 'incomplete') {
|
||||
$reason = preg_replace('/[^a-z0-9_.-]/i', '', (string)($response['incomplete_details']['reason'] ?? 'unknown')) ?: 'unknown';
|
||||
throw new openai_request_exception('OpenAI response was incomplete: ' . $reason, true);
|
||||
}
|
||||
if ($status !== 'completed') {
|
||||
throw new openai_request_exception('OpenAI response did not complete.', in_array($status, ['queued', 'in_progress'], true));
|
||||
}
|
||||
|
||||
$decoded = json_decode($output, true);
|
||||
$outputText = null;
|
||||
foreach ((array)($response['output'] ?? []) as $output) {
|
||||
foreach ((array)($output['content'] ?? []) as $content) {
|
||||
if (($content['type'] ?? null) === 'refusal') {
|
||||
throw new openai_request_exception('OpenAI refused the structured task.', false);
|
||||
}
|
||||
if (($content['type'] ?? null) === 'output_text' && is_string($content['text'] ?? null)) {
|
||||
$outputText = (string)$content['text'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($outputText === null || $outputText === '') {
|
||||
throw new openai_request_exception('OpenAI completed without structured output text.', false);
|
||||
}
|
||||
$decoded = json_decode($outputText, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
|
||||
throw new Exception('Error parsing JSON response: ' . json_last_error_msg());
|
||||
throw new openai_request_exception('OpenAI returned invalid structured JSON.', false);
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
$resolvedModel = trim((string)($response['model'] ?? ''));
|
||||
if ($resolvedModel === '') {
|
||||
throw new openai_request_exception('OpenAI response omitted the resolved model.', false);
|
||||
}
|
||||
$usage = (array)($response['usage'] ?? []);
|
||||
$inputTokens = max(0, (int)($usage['input_tokens'] ?? 0));
|
||||
$outputTokens = max(0, (int)($usage['output_tokens'] ?? 0));
|
||||
$totalTokens = max(0, (int)($usage['total_tokens'] ?? ($inputTokens + $outputTokens)));
|
||||
return [
|
||||
...$decoded,
|
||||
'_openai_response_model' => $resolvedModel,
|
||||
'_openai_usage' => [
|
||||
'input_tokens' => $inputTokens,
|
||||
'output_tokens' => $outputTokens,
|
||||
'total_tokens' => $totalTokens,
|
||||
'service_tier' => (string)($response['service_tier'] ?? ''),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function getLPRSchema(): array
|
||||
@@ -278,6 +341,8 @@ class openai implements openai_i
|
||||
$curl = curl_init($this->api_url);
|
||||
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($curl, CURLOPT_POST, true);
|
||||
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 10);
|
||||
curl_setopt($curl, CURLOPT_TIMEOUT, 45);
|
||||
curl_setopt($curl, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $this->config->api_key->getVariableValue()
|
||||
@@ -285,14 +350,19 @@ class openai implements openai_i
|
||||
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
$response = curl_exec($curl);
|
||||
if (curl_errno($curl)) {
|
||||
throw new Exception('cURL error: ' . curl_error($curl));
|
||||
$curlCode = curl_errno($curl);
|
||||
curl_close($curl);
|
||||
throw new openai_request_exception('OpenAI transport failed.', in_array($curlCode, [CURLE_OPERATION_TIMEDOUT, CURLE_COULDNT_CONNECT, CURLE_COULDNT_RESOLVE_HOST], true));
|
||||
}
|
||||
$httpStatus = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($curl);
|
||||
$responseData = json_decode($response, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new Exception('Error parsing JSON response: ' . json_last_error_msg());
|
||||
throw new openai_request_exception('OpenAI returned a non-JSON response.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus);
|
||||
}
|
||||
if ($httpStatus < 200 || $httpStatus >= 300 || !is_array($responseData)) {
|
||||
throw new openai_request_exception('OpenAI request failed with HTTP status ' . $httpStatus . '.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus);
|
||||
}
|
||||
//print_r($responseData);
|
||||
return $responseData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class order_item_reason_policy
|
||||
{
|
||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
||||
public const AFFECTED_PRODUCT_IDS = [21, 22, 25, 26, 27];
|
||||
|
||||
public static function reasons(): array
|
||||
{
|
||||
return [
|
||||
'customer_approved_extra_work' => [
|
||||
'label' => 'Kunde godkendte ekstra arbejde',
|
||||
'requires_comment' => true,
|
||||
'active' => true,
|
||||
],
|
||||
'vehicle_condition_extra_work' => [
|
||||
'label' => 'Køretøjets tilstand krævede ekstra tid',
|
||||
'requires_comment' => true,
|
||||
'active' => true,
|
||||
],
|
||||
'quality_rework' => [
|
||||
'label' => 'Kvalitetsopfølgning eller omvask',
|
||||
'requires_comment' => true,
|
||||
'active' => true,
|
||||
],
|
||||
'legacy_note_only' => [
|
||||
'label' => 'Legacy note only',
|
||||
'requires_comment' => true,
|
||||
'active' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function productRequiresReason(int $productId): bool
|
||||
{
|
||||
return in_array($productId, self::AFFECTED_PRODUCT_IDS, true);
|
||||
}
|
||||
|
||||
public static function validateForProduct(int $productId, array $data): array
|
||||
{
|
||||
if (!self::productRequiresReason($productId)) {
|
||||
return ['reason_code' => null, 'reason_label_snapshot' => null, 'reason_comment' => null];
|
||||
}
|
||||
|
||||
$code = trim((string)($data['reason_code'] ?? $data['order_item_reason_code'] ?? ''));
|
||||
if ($code === '') {
|
||||
throw new InvalidArgumentException('Reason code is required for this product');
|
||||
}
|
||||
|
||||
$reasons = self::reasons();
|
||||
if (!array_key_exists($code, $reasons)) {
|
||||
throw new InvalidArgumentException('Reason code is invalid for this product');
|
||||
}
|
||||
|
||||
$reason = $reasons[$code];
|
||||
if (!$reason['active']) {
|
||||
throw new InvalidArgumentException('Reason code is deprecated for this product');
|
||||
}
|
||||
|
||||
$comment = trim((string)($data['reason_comment'] ?? $data['comment'] ?? $data['notes'] ?? ''));
|
||||
if ($reason['requires_comment'] && $comment === '') {
|
||||
throw new InvalidArgumentException('Reason comment is required for this product');
|
||||
}
|
||||
|
||||
$snapshot = $reason['label'];
|
||||
|
||||
return ['reason_code' => $code, 'reason_label_snapshot' => $snapshot, 'reason_comment' => $comment];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
use UnexpectedValueException;
|
||||
|
||||
final class order_payment_lock
|
||||
{
|
||||
private const LOCK_TIMEOUT_SECONDS = 10;
|
||||
private const ORDER_RESOURCE = 'order-payment-v1';
|
||||
private const INVOICE_COLLECTION_RESOURCE = 'invoice-collection-payment-v1';
|
||||
|
||||
/** @var list<string> */
|
||||
private array $lockNames = [];
|
||||
|
||||
public static function tryAcquire(int $orderId): ?self
|
||||
{
|
||||
return self::tryAcquireResource(self::ORDER_RESOURCE, $orderId);
|
||||
}
|
||||
|
||||
public static function tryAcquireInvoiceCollection(int $invoiceCollectionId): ?self
|
||||
{
|
||||
return self::tryAcquireResource(self::INVOICE_COLLECTION_RESOURCE, $invoiceCollectionId);
|
||||
}
|
||||
|
||||
public static function tryAcquireOrderMutation(int $orderId): ?self
|
||||
{
|
||||
return self::tryAcquireOrderMutations([$orderId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $orderIds
|
||||
*/
|
||||
public static function tryAcquireOrderMutations(array $orderIds): ?self
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(
|
||||
array_map('intval', $orderIds),
|
||||
static fn(int $id): bool => $id > 0
|
||||
)));
|
||||
sort($ids, SORT_NUMERIC);
|
||||
$collectionByOrder = [];
|
||||
foreach ($ids as $id) {
|
||||
$collectionByOrder[$id] = self::invoiceCollectionIdForOrder($id);
|
||||
}
|
||||
$collectionIds = array_values(array_unique(array_filter(
|
||||
$collectionByOrder,
|
||||
static fn(int $id): bool => $id > 0
|
||||
)));
|
||||
sort($collectionIds, SORT_NUMERIC);
|
||||
$resources = array_map(
|
||||
static fn(int $id): string => self::resourceName(self::INVOICE_COLLECTION_RESOURCE, $id),
|
||||
$collectionIds
|
||||
);
|
||||
array_push(
|
||||
$resources,
|
||||
...array_map(
|
||||
static fn(int $id): string => self::resourceName(self::ORDER_RESOURCE, $id),
|
||||
$ids
|
||||
)
|
||||
);
|
||||
$lock = self::tryAcquireNames($resources);
|
||||
if ($lock !== null) {
|
||||
foreach ($collectionByOrder as $id => $invoiceCollectionId) {
|
||||
if (self::invoiceCollectionIdForOrder($id) !== $invoiceCollectionId) {
|
||||
$lock->release();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $lock;
|
||||
}
|
||||
|
||||
public static function tryAcquireReassignment(int $orderId, int $targetInvoiceCollectionId): ?self
|
||||
{
|
||||
$sourceInvoiceCollectionId = self::invoiceCollectionIdForOrder($orderId);
|
||||
$collectionIds = array_values(array_unique(array_filter([
|
||||
$sourceInvoiceCollectionId,
|
||||
$targetInvoiceCollectionId,
|
||||
], static fn(int $id): bool => $id > 0)));
|
||||
sort($collectionIds, SORT_NUMERIC);
|
||||
$resources = array_map(
|
||||
static fn(int $id): string => self::resourceName(self::INVOICE_COLLECTION_RESOURCE, $id),
|
||||
$collectionIds
|
||||
);
|
||||
$resources[] = self::resourceName(self::ORDER_RESOURCE, $orderId);
|
||||
$lock = self::tryAcquireNames($resources);
|
||||
if ($lock !== null && self::invoiceCollectionIdForOrder($orderId) !== $sourceInvoiceCollectionId) {
|
||||
$lock->release();
|
||||
return null;
|
||||
}
|
||||
return $lock;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $invoiceCollectionIds
|
||||
*/
|
||||
public static function tryAcquireInvoiceCollections(array $invoiceCollectionIds): ?self
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(
|
||||
array_map('intval', $invoiceCollectionIds),
|
||||
static fn(int $id): bool => $id > 0
|
||||
)));
|
||||
sort($ids, SORT_NUMERIC);
|
||||
return self::tryAcquireNames(array_map(
|
||||
static fn(int $id): string => self::resourceName(self::INVOICE_COLLECTION_RESOURCE, $id),
|
||||
$ids
|
||||
));
|
||||
}
|
||||
|
||||
public static function tryAcquireInvoiceCollectionWithOrders(int $invoiceCollectionId): ?self
|
||||
{
|
||||
$lock = self::tryAcquireInvoiceCollection($invoiceCollectionId);
|
||||
if ($lock === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$lock->acquireNames(array_map(
|
||||
static fn(int $id): string => self::resourceName(self::ORDER_RESOURCE, $id),
|
||||
self::orderIdsForInvoiceCollection($invoiceCollectionId)
|
||||
));
|
||||
return $lock;
|
||||
} catch (UnexpectedValueException) {
|
||||
$lock->release();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static function tryAcquireResource(string $resource, int $resourceId): ?self
|
||||
{
|
||||
return self::tryAcquireNames([self::resourceName($resource, $resourceId)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $lockNames
|
||||
*/
|
||||
private static function tryAcquireNames(array $lockNames): ?self
|
||||
{
|
||||
try {
|
||||
return new self($lockNames);
|
||||
} catch (UnexpectedValueException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static function resourceName(string $resource, int $resourceId): string
|
||||
{
|
||||
if ($resourceId <= 0) {
|
||||
throw new RuntimeException('A valid resource ID is required for the payment lock.');
|
||||
}
|
||||
return $resource . ':' . $resourceId;
|
||||
}
|
||||
|
||||
private static function invoiceCollectionIdForOrder(int $orderId): int
|
||||
{
|
||||
global $db;
|
||||
if ($orderId <= 0) {
|
||||
throw new RuntimeException('A valid order ID is required for the payment lock.');
|
||||
}
|
||||
$result = $db->query(
|
||||
'SELECT `invoice_collection_id` FROM `orders` WHERE `id` = ' . $orderId . ' LIMIT 1'
|
||||
);
|
||||
$row = $result ? $result->fetch_assoc() : null;
|
||||
return (int)($row['invoice_collection_id'] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private static function orderIdsForInvoiceCollection(int $invoiceCollectionId): array
|
||||
{
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
'SELECT `id` FROM `orders` WHERE `invoice_collection_id` = '
|
||||
. $invoiceCollectionId . ' ORDER BY `id` ASC'
|
||||
);
|
||||
$ids = [];
|
||||
while ($result && ($row = $result->fetch_assoc())) {
|
||||
$ids[] = (int)$row['id'];
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $lockNames
|
||||
*/
|
||||
private function __construct(array $lockNames)
|
||||
{
|
||||
$this->acquireNames($lockNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $lockNames
|
||||
*/
|
||||
private function acquireNames(array $lockNames): void
|
||||
{
|
||||
global $db;
|
||||
foreach ($lockNames as $lockName) {
|
||||
if (in_array($lockName, $this->lockNames, true)) {
|
||||
continue;
|
||||
}
|
||||
$statement = $db->prepare('SELECT GET_LOCK(?, ?) AS acquired');
|
||||
if ($statement === false) {
|
||||
$this->release();
|
||||
throw new RuntimeException('Unable to prepare the order payment lock.');
|
||||
}
|
||||
$timeout = self::LOCK_TIMEOUT_SECONDS;
|
||||
$statement->bind_param('si', $lockName, $timeout);
|
||||
$statement->execute();
|
||||
$result = $statement->get_result()->fetch_assoc();
|
||||
$statement->close();
|
||||
if ((int)($result['acquired'] ?? 0) !== 1) {
|
||||
$this->release();
|
||||
throw new UnexpectedValueException(
|
||||
'The order or invoice collection is currently being changed or paid. Try again.'
|
||||
);
|
||||
}
|
||||
$this->lockNames[] = $lockName;
|
||||
}
|
||||
}
|
||||
|
||||
public function release(): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
foreach (array_reverse($this->lockNames) as $lockName) {
|
||||
$statement = $db->prepare('SELECT RELEASE_LOCK(?)');
|
||||
if ($statement !== false) {
|
||||
$statement->bind_param('s', $lockName);
|
||||
$statement->execute();
|
||||
$statement->close();
|
||||
}
|
||||
}
|
||||
$this->lockNames = [];
|
||||
}
|
||||
|
||||
public function __destruct()
|
||||
{
|
||||
$this->release();
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,38 @@ class orders_schema_bootstrap
|
||||
);
|
||||
}
|
||||
|
||||
if (self::tableExists($db, 'order_items')) {
|
||||
if (!self::columnExists($db, 'order_items', 'reason_code')) {
|
||||
$db->query(
|
||||
"ALTER TABLE order_items
|
||||
ADD COLUMN reason_code VARCHAR(64) NULL DEFAULT NULL
|
||||
AFTER include_in_invoice"
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::columnExists($db, 'order_items', 'reason_label_snapshot')) {
|
||||
$db->query(
|
||||
"ALTER TABLE order_items
|
||||
ADD COLUMN reason_label_snapshot VARCHAR(255) NULL DEFAULT NULL
|
||||
AFTER reason_code"
|
||||
);
|
||||
}
|
||||
|
||||
if (!self::columnExists($db, 'order_items', 'reason_comment')) {
|
||||
$db->query(
|
||||
"ALTER TABLE order_items
|
||||
ADD COLUMN reason_comment TEXT NULL
|
||||
AFTER reason_label_snapshot"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self::backfillBookingPoDefaults($db);
|
||||
|
||||
self::ensureIndex($db, 'orders', 'idx_orders_period_customer_created_deleted', 'customer_id, created_at, deleted_at');
|
||||
self::ensureIndex($db, 'orders', 'idx_orders_period_created_deleted_customer', 'created_at, deleted_at, customer_id');
|
||||
self::ensureIndex($db, 'order_items', 'idx_order_items_order_deleted', 'order_id, deleted_at');
|
||||
self::ensureIndex($db, 'order_items', 'idx_order_items_reason_code', 'reason_code');
|
||||
self::ensureIndex($db, 'customer_attributes', 'idx_customer_attributes_attribute_user', 'attribute, user_id');
|
||||
|
||||
self::$initialized = true;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,12 +8,11 @@ use objects\ratelimit_o;
|
||||
class ratelimit implements ratelimit_i
|
||||
{
|
||||
private int $limit; // The number of requests allowed in the time period
|
||||
private int $time; // The time period in seconds
|
||||
|
||||
public function __construct(int $defaultLimit, int $defaultTime)
|
||||
{
|
||||
$this->limit = $defaultLimit;
|
||||
$this->time = $defaultTime;
|
||||
unset($defaultTime); // The reset interval is managed by the rate-limit maintenance task.
|
||||
}
|
||||
|
||||
public function enforceIP(string $ip): bool
|
||||
|
||||
@@ -339,10 +339,10 @@ class redis implements redis_i
|
||||
/**
|
||||
* Cache auth session payload for a token with TTL
|
||||
*/
|
||||
public function cache_auth_session(string $token, array $data, int $ttl = 60): self
|
||||
public function cache_auth_session(string $token, array $session, int $ttl = 60): self
|
||||
{
|
||||
$key = 'auth_session_' . $token;
|
||||
$this->set_array($key, $data);
|
||||
$this->set_array($key, $session);
|
||||
$this->expire($key, $ttl);
|
||||
return $this;
|
||||
}
|
||||
@@ -636,6 +636,24 @@ class redis implements redis_i
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically increments a fixed-window counter and assigns its TTL on the
|
||||
* first increment. This avoids the GET/SET race in public abuse controls.
|
||||
*/
|
||||
public function incrementWithExpiration(string $key, int $seconds): int
|
||||
{
|
||||
if (!self::is_connected()) {
|
||||
self::connect();
|
||||
}
|
||||
|
||||
$count = (int)$this->redis->incr($key);
|
||||
if ($count === 1) {
|
||||
$this->redis->expire($key, max(1, $seconds));
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function generateTemporaryCacheKey(): string
|
||||
{
|
||||
// Generate a temporary cache key
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,14 @@
|
||||
|
||||
namespace classes;
|
||||
|
||||
use traits\boolean_normalization_t;
|
||||
|
||||
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
|
||||
|
||||
class releasemanager
|
||||
{
|
||||
use boolean_normalization_t;
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
try {
|
||||
@@ -11,7 +17,7 @@ class releasemanager
|
||||
global $db;
|
||||
$result = $db->query("SELECT value FROM module_config WHERE module = 'ReleaseManager' AND variable = 'enabled' LIMIT 1");
|
||||
$row = $result ? $result->fetch_assoc() : null;
|
||||
return in_array(strtolower(trim((string)($row['value'] ?? 'true'))), ['1', 'true', 'yes', 'on'], true);
|
||||
return self::normalizeBoolean((string)($row['value'] ?? 'true'));
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user