Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef377ae6bd | ||
|
|
f0d7d59951 | ||
|
|
b0ef6aaef9 | ||
|
|
1742033bb7 | ||
|
|
ccae88f4ef | ||
|
|
64c5cd45db | ||
|
|
7258c72f60 | ||
|
|
52d43fc16c | ||
|
|
cae91d6a11 | ||
|
|
9025a8af6e | ||
|
|
f32bca2ac7 | ||
|
|
d2528c5ed7 | ||
|
|
0c57460540 | ||
|
|
3e39a50a4f | ||
|
|
fd70864434 | ||
|
|
e9f1b8f880 | ||
|
|
18dc8e6e9c | ||
|
|
6cf94b8cd9 | ||
|
|
51a87655d6 | ||
|
|
4b08453ee2 | ||
|
|
ae4b7aef07 | ||
|
|
ea9bdbe12c | ||
|
|
b16b2cfe44 | ||
|
|
339b6eb7a5 | ||
|
|
935b2d58ce | ||
|
|
4c6b60c9f2 | ||
|
|
18092b271e | ||
|
|
76ad696691 | ||
|
|
80dca6b5f0 | ||
|
|
7c4acc636c |
@@ -86,15 +86,54 @@ jobs:
|
||||
fi
|
||||
# Restart generic services
|
||||
sudo systemctl reload nginx || true
|
||||
# Install and start the cron-worker systemd service (long-running scheduler)
|
||||
if [ -f services/nginx/app/resources/cron-worker.service ]; then
|
||||
sudo install -m 0644 services/nginx/app/resources/cron-worker.service /etc/systemd/system/cron-worker.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable cron-worker || true
|
||||
sudo systemctl restart cron-worker || true
|
||||
echo "cron-worker status: $(sudo systemctl is-active cron-worker || echo unknown)"
|
||||
fi
|
||||
echo "Deploy complete: $(git rev-parse --short HEAD)"
|
||||
'
|
||||
|
||||
- name: Pre-deploy schema check (run all *_schema_bootstrap)
|
||||
id: pre_schema
|
||||
run: |
|
||||
echo "Running schema bootstraps against the live database…"
|
||||
# Idempotent — adds missing columns, never drops anything.
|
||||
# Catches the "Unknown column 'invoice_email' in 'SELECT'"
|
||||
# production failure mode (TRU-77) where migrations were
|
||||
# merged to master but never applied to the live DB.
|
||||
php scripts/run-schema-bootstraps.php
|
||||
echo "Schema bootstraps complete."
|
||||
|
||||
- name: Alert Slack if schema-check fails (pre-deploy)
|
||||
if: failure()
|
||||
run: |
|
||||
php scripts/schema-health-check.php > /tmp/schema.json 2>&1 || true
|
||||
msg=$(jq -r '"Schema health FAILED on '$SMOKE_BASE_URL'\nMissing: " + (.missing | join(", "))' /tmp/schema.json 2>/dev/null || echo "Schema check produced no JSON")
|
||||
curl -sS -X POST -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
|
||||
-H "Content-Type: application/json; charset=utf-8" \
|
||||
https://slack.com/api/chat.postMessage \
|
||||
-d "{\"channel\":\"$AI_DAILY_CHANNEL\",\"text\":\":rotating_light: *${{ github.event.repository.name }} — schema health FAIL\n${msg}\"}"
|
||||
|
||||
- name: Smoke test
|
||||
id: smoke
|
||||
continue-on-error: true
|
||||
run: |
|
||||
chmod +x scripts/smoke-test.sh
|
||||
./scripts/smoke-test.sh
|
||||
# Also hit the new admin schema-check endpoint to verify
|
||||
# no required columns are missing.
|
||||
echo "::group::Schema health check"
|
||||
php scripts/schema-health-check.php | tee /tmp/schema-report.json
|
||||
if [ "$(jq -r .ok /tmp/schema-report.json)" != "true" ]; then
|
||||
echo "::error::Schema health check FAILED — missing columns:"
|
||||
jq -r '.missing[]' /tmp/schema-report.json | sed 's/^/ • /'
|
||||
exit 1
|
||||
fi
|
||||
echo "Schema health check OK."
|
||||
|
||||
- name: Auto-rollback on smoke failure
|
||||
if: steps.smoke.outcome == 'failure'
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
name: Verify e-conomic Live
|
||||
|
||||
# Live verification of e-conomic export sanitization.
|
||||
# Creates a real draft invoice for customer 12345679, verifies, and cleans up.
|
||||
# Only runs on-demand (workflow_dispatch) to avoid creating real drafts in prod.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
customer_number:
|
||||
description: 'e-conomic customer number to test against'
|
||||
required: false
|
||||
default: '12345679'
|
||||
type: string
|
||||
dry_run:
|
||||
description: 'Dry run (skip actual API calls, just verify env)'
|
||||
required: false
|
||||
default: 'true'
|
||||
type: choice
|
||||
options:
|
||||
- 'true'
|
||||
- 'false'
|
||||
schedule:
|
||||
# Run every Monday at 06:00 UTC to catch any drift in e-conomic behavior
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
concurrency:
|
||||
group: live-verify-economic
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Live verify e-conomic draft flow
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
ECONOMIC_API_APP_ACCESS_GRANT: ${{ secrets.ECONOMIC_API_APP_ACCESS_GRANT }}
|
||||
ECONOMIC_API_APP_SECRET_TOKEN: ${{ secrets.ECONOMIC_API_APP_SECRET_TOKEN }}
|
||||
ECONOMIC_API_BASE_URL: ${{ secrets.ECONOMIC_API_BASE_URL || 'https://restapi.e-conomic.com' }}
|
||||
ECONOMIC_CUSTOMER_NUMBER: ${{ github.event.inputs.customer_number || '12345679' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf5b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@e4a38cfe05f3813d096c1c2c0e7bf21a3100c93a # v2
|
||||
with:
|
||||
php-version: '8.4'
|
||||
extensions: curl
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Dry-run mode (verify env only)
|
||||
if: ${{ github.event.inputs.dry_run == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Dry-run mode: checking environment..."
|
||||
if [ -z "${ECONOMIC_API_APP_ACCESS_GRANT:-}" ]; then
|
||||
echo "::error::ECONOMIC_API_APP_ACCESS_GRANT is not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${ECONOMIC_API_APP_SECRET_TOKEN:-}" ]; then
|
||||
echo "::error::ECONOMIC_API_APP_SECRET_TOKEN is not set"
|
||||
exit 1
|
||||
fi
|
||||
# Mask secrets in logs
|
||||
echo "ECONOMIC_API_APP_ACCESS_GRANT=${ECONOMIC_API_APP_ACCESS_GRANT:0:8}..."
|
||||
echo "ECONOMIC_API_APP_SECRET_TOKEN=${ECONOMIC_API_APP_SECRET_TOKEN:0:4}..."
|
||||
echo "ECONOMIC_API_BASE_URL=${ECONOMIC_API_BASE_URL}"
|
||||
echo "ECONOMIC_CUSTOMER_NUMBER=${ECONOMIC_CUSTOMER_NUMBER}"
|
||||
echo "All env vars present. Re-run with dry_run=false to do a live test."
|
||||
|
||||
- name: Run live verification (creates and cleans up a real draft)
|
||||
if: ${{ github.event.inputs.dry_run == 'false' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd /workspace/copenhagentruckwash/api
|
||||
# Use the script that's checked in
|
||||
# (we expect the script to be in the repo, e.g., scripts/verify-economic-drafts-live.php)
|
||||
if [ -f scripts/verify-economic-drafts-live.php ]; then
|
||||
php8.4 scripts/verify-economic-drafts-live.php
|
||||
else
|
||||
# Fallback: use the script from /workspace (where we keep platform scripts)
|
||||
if [ -f /workspace/scripts/verify-economic-drafts-live.php ]; then
|
||||
php8.4 /workspace/scripts/verify-economic-drafts-live.php
|
||||
else
|
||||
echo "::error::Live verification script not found"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Upload verification logs
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: live-verify-logs
|
||||
path: |
|
||||
/tmp/verify-economic-*.log
|
||||
.tmp/verify-economic-*.log
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: Notify Slack on failure
|
||||
if: ${{ failure() && env.SLACK_BOT_TOKEN != '' }}
|
||||
env:
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
SLACK_DEFAULT_WEBHOOK: ${{ secrets.SLACK_DEFAULT_WEBHOOK }}
|
||||
AI_DAILY_CHANNEL: ${{ secrets.AI_DAILY_CHANNEL || 'C0AM3E43249' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${SLACK_DEFAULT_WEBHOOK:-}" ]; then
|
||||
curl -fsS -X POST "$SLACK_DEFAULT_WEBHOOK" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(cat <<EOF
|
||||
{
|
||||
"channel": "$AI_DAILY_CHANNEL",
|
||||
"text": ":rotating_light: e-conomic live verification failed\nWorkflow: ${{ github.workflow }}\nRun: ${{ github.run_id }}\nURL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
}
|
||||
EOF
|
||||
)"
|
||||
fi
|
||||
@@ -0,0 +1,115 @@
|
||||
# Plan: Remove broken Coolify cron-worker deployment; add reliable 5-min cron
|
||||
|
||||
## Audit findings
|
||||
|
||||
The "Coolify cron worker flow" is a **dual-deployment mechanism** that:
|
||||
- Tries to auto-deploy a **separate Coolify "cron" application** every time the API is deployed
|
||||
- That separate app runs `php index.php run cron-worker` as a long-running process
|
||||
- Tracks worker heartbeats in a `cron_worker_state` table
|
||||
|
||||
The "auto-deploy" part is implemented in `release_manager.php` (~300 lines of
|
||||
`cronWorker*` methods: `deployCronWorker*`, `cronWorkerAutoprovision*`,
|
||||
`cronWorkerTarget*`, etc.) and is **broken** because the Coolify API endpoints
|
||||
for creating a new application for the cron worker are not stable/reliable in
|
||||
our setup.
|
||||
|
||||
Meanwhile, the **actual cron mechanism** (`cron_worker.php`, `cron_scheduler.php`,
|
||||
`cron_task_registry.php`, and the 20+ scheduled tasks in `modules/*/cron/tasks.php`)
|
||||
is sound. The Docker compose files already define a `cron-worker` service
|
||||
that runs the long-running process. The auto-deploy logic is just trying to
|
||||
maintain a separate Coolify app for the same purpose — and failing.
|
||||
|
||||
## The plan
|
||||
|
||||
### 1. Remove the broken auto-deploy logic
|
||||
|
||||
Delete or no-op the following from `release_manager.php`:
|
||||
- `cronWorkerStatus()`
|
||||
- `deployCronWorker()`
|
||||
- `deployCronWorkerForApiTarget()`
|
||||
- `deployCronWorkerAfterApiDeployment()`
|
||||
- `cronWorkerAutoprovisionEnabled()`
|
||||
- `cronWorkerAutoprovisionRequired()`
|
||||
- `cronWorkerTarget*()` (5 methods)
|
||||
- `cronWorkerSummary()`, `cronWorkerHealth()`, `cronWorkerDeploymentReadiness()`
|
||||
- `cronWorkerMergeIssues()`, `cronWorkerIssue()`
|
||||
- `cronWorkerDeploymentAgeSeconds()`, `cronWorkerProviderStatus()`
|
||||
- `cronWorkerDeployContext()`
|
||||
- `createCronWorkerDeploymentRecord()`, `cronWorkerDeployments()`
|
||||
- `cronWorkerChannels()`, `cronWorkersForTarget()`
|
||||
- `cronWorkerSourceFromCronTarget()`
|
||||
- Constants: `CRON_WORKER_APP`, `CRON_WORKER_START_COMMAND`, `CRON_WORKER_DESIRED_COUNT`, `CRON_WORKER_HEARTBEAT_GRACE_SECONDS`
|
||||
- The `$result['cron_worker'] = ...` call after API deployment
|
||||
|
||||
Keep:
|
||||
- `cron_worker.php` class (the actual worker)
|
||||
- `cron_scheduler.php`, `cron_schedule.php`, `cron_task_registry.php`
|
||||
- `cron_schema_bootstrap.php` and the `cron_worker_state` table
|
||||
- All 20+ scheduled tasks in `modules/*/cron/tasks.php`
|
||||
- The `cron-worker` service in `docker-compose*.yml`
|
||||
- The `cron-worker` case in `cli.php`
|
||||
|
||||
### 2. Remove the corresponding tests
|
||||
|
||||
- `tests/Unit/Cron/CronWorkerWiringTest.php` — delete or rewrite (only assert things that still exist)
|
||||
- `tests/Unit/ReleaseManager/ReleaseManagerTest.php` — remove the `cron_worker_*` test cases (~150 lines)
|
||||
- `tests/Smoke/boolean_normalization_smoke.php` — remove cron_worker reference
|
||||
|
||||
### 3. Add a reliable 5-min cron mechanism
|
||||
|
||||
Two-layer approach:
|
||||
1. **Long-running `cron-worker` Docker service** (already in compose) — handles
|
||||
tasks that need to run frequently (60s intervals, etc.). Started automatically
|
||||
with the rest of the stack.
|
||||
2. **System cron / health-check loop** — verifies the cron-worker is alive every
|
||||
5 min. If no fresh heartbeat in 10 min, alert.
|
||||
|
||||
This replaces the broken auto-deploy with a simple, observable contract.
|
||||
|
||||
### 4. Add a verification harness
|
||||
|
||||
`/workspace/scripts/verify-api-cron.py`:
|
||||
- Hits the API's `cronWorkerStatus` endpoint
|
||||
- Reads `cron_worker_state` rows via the public route (or a new `/api/admin/cron-status` endpoint)
|
||||
- If no fresh heartbeat in 10 min, post to #ai-daily
|
||||
- Run every 5 min via a new cron job
|
||||
|
||||
### 5. Update documentation
|
||||
|
||||
- `inventory/self-serve-inventory.md` — remove coolify-cron-worker references
|
||||
- `openapi.yaml` — remove `cron_worker_status` route documentation
|
||||
- `routes/cronRoute.php` — remove the coolify-cron-worker endpoints
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `release_manager.php` no longer contains `deployCronWorker`, `cronWorkerAutoprovision*`, `cronWorkerTarget*`, `CRON_WORKER_APP`
|
||||
- [ ] No tests reference removed methods
|
||||
- [ ] `docker-compose.yml` still has a `cron-worker` service (unchanged)
|
||||
- [ ] `cronWorkerStatus` route returns 200 with `{"workers":[],"issues":[]}` or similar (not 500)
|
||||
- [ ] A new cron job runs `verify-api-cron.py` every 5 min
|
||||
- [ ] Verify script posts to #ai-daily if no heartbeat in 10 min
|
||||
- [ ] PR created, tests pass, merge
|
||||
|
||||
## Risk
|
||||
|
||||
- **Removing `deployCronWorker*` could break live deployments** if someone is
|
||||
actively using the API endpoint to deploy a cron worker. Mitigation: keep the
|
||||
HTTP route returning a friendly "removed" message instead of deleting it.
|
||||
- **Removing `cronWorkerStatus()` from the release_manager endpoint** could
|
||||
break dashboards. Mitigation: replace the route handler with a direct query
|
||||
to `cron_worker_state` so the response shape is preserved.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Create a feature branch `fix/remove-coolify-cron-worker`
|
||||
2. Edit `release_manager.php`: remove the broken methods, replace `cronWorkerStatus` with a direct query
|
||||
3. Edit `tests/Unit/ReleaseManager/ReleaseManagerTest.php`: remove cron_worker tests
|
||||
4. Edit `tests/Unit/Cron/CronWorkerWiringTest.php`: drop assertions on removed wiring
|
||||
5. Edit `routes/cronRoute.php`: keep the status endpoint but call the new direct query
|
||||
6. Edit `cli.php`: no change needed (cron-worker case still works)
|
||||
7. Edit `docker-compose*.yml`: no change needed (cron-worker service unchanged)
|
||||
8. Create `/workspace/scripts/verify-api-cron.py` for the verification harness
|
||||
9. Add a new cron job `5 * * * *` Europe/Copenhagen that runs `verify-api-cron.py`
|
||||
10. Add a new endpoint `GET /api/admin/cron-status` that returns the cron state JSON
|
||||
11. Run the test suite locally
|
||||
12. Push branch, create PR, get user review
|
||||
@@ -0,0 +1,245 @@
|
||||
# Route Scope Audit — TRU-149
|
||||
|
||||
**Generated:** 2026-08-17
|
||||
**Scope:** All route files under `services/nginx/app/routes/`
|
||||
**Total route files:** 116
|
||||
**Total route handlers:** ~600+
|
||||
|
||||
## Scope Definitions
|
||||
|
||||
The scope set is defined in `services/nginx/app/classes/auth/scope.php`
|
||||
(this is the local TRU-149 stub — TRU-145 will replace/extend it).
|
||||
|
||||
| Constant | String | Used by |
|
||||
|---|---|---|
|
||||
| `CUSTOMER_READ` | `customer:read` | GET on customer resources |
|
||||
| `CUSTOMER_WRITE` | `customer:write` | POST/PUT/DELETE on customer resources |
|
||||
| `BOOKING_READ` | `booking:read` | GET on bookings / time-bookings |
|
||||
| `BOOKING_WRITE` | `booking:write` | POST/PUT/DELETE on bookings |
|
||||
| `SUBUSER_READ` | `subuser:read` | GET on subuser management |
|
||||
| `SUBUSER_WRITE` | `subuser:write` | POST/PUT/DELETE on subuser management |
|
||||
| `INVOICE_READ` | `invoice:read` | GET on invoices / invoicing period |
|
||||
| `INVOICE_WRITE` | `invoice:write` | POST/PUT/DELETE on invoices |
|
||||
| `SUPERUSER_READ` | `superuser:read` | GET on superuser-only resources (cron, replication, coolify, system status) |
|
||||
| `SUPERUSER_WRITE` | `superuser:write` | POST/PUT/DELETE on superuser-only resources (cron run, replication trigger, intimidation) |
|
||||
| `SUPERUSER_WRITE` | `superuser:write` | POST/PUT/DELETE on superuser-only resources |
|
||||
|
||||
### Role → Scope mapping
|
||||
|
||||
Defined in `Scope::forRole()`. Centralised so role changes don't
|
||||
ripple through every route.
|
||||
|
||||
| Role | Scopes |
|
||||
|---|---|
|
||||
| `superuser` | all 10 |
|
||||
| `admin` | all except `SUPERUSER_*` (8) |
|
||||
| `customer` | `CUSTOMER_READ`, `BOOKING_READ`, `INVOICE_READ` (3) |
|
||||
| `subuser` | `BOOKING_READ`, `BOOKING_WRITE` (2) |
|
||||
| (default) | none — deny |
|
||||
|
||||
## Routes by group
|
||||
|
||||
The full per-route audit is in the "Route inventory" section below.
|
||||
Here is the high-level grouping used when applying scopes.
|
||||
|
||||
### Admin / superuser routes (require `SUPERUSER_*` or `CUSTOMER_*` write)
|
||||
|
||||
| File | Endpoints | Scope applied |
|
||||
|---|---|---|
|
||||
| `adminRoute.php` | `GET /admin/schema-check` | `SUPERUSER_READ` (intentionally anonymous infra check, but scoped for safety) — see TODO |
|
||||
| `cronRoute.php` | `GET/POST /superuser/cron*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
|
||||
| `superuserCoolifyRoute.php` | `/superuser/coolify/*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
|
||||
| `superuserDepartmentRoute.php` | `/superuser/departments/*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
|
||||
| `superuserReplicationRoute.php` | `/superuser/replication/*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
|
||||
| `superuserSecurityRoute.php` | `/superuser/security/*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
|
||||
| `superuserSystemStatusRoute.php` | `/superuser/system-status/*` | `SUPERUSER_READ` |
|
||||
| `superuserCustomerRuleProductRestrictionsRoute.php` | `/superuser/customer-rule-product-restrictions/*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
|
||||
| `customerCodeDepartmentRoute.php` | `GET/POST /admin/customer/code` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
|
||||
| `customerSearchRoute.php` | `POST /customers/search` | `CUSTOMER_READ` (admin) |
|
||||
| `customerSearchRoute.php` | `POST /customers/import` | `CUSTOMER_WRITE` (admin) |
|
||||
| `washCertificateDebugRoute.php` | `/admin/wash-certificate-debug/*` | `SUPERUSER_READ` |
|
||||
|
||||
### Customer routes (read mostly, write selectively)
|
||||
|
||||
| File | Endpoints | Scope |
|
||||
|---|---|---|
|
||||
| `customerAttributes.php` | `GET/POST/DELETE /customer/attributes` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
|
||||
| `customerDefaultDepartmentRoute.php` | `/customer/department/default` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
|
||||
| `customerFixedPricingRoute.php` | `/customer/pricing/fixed` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
|
||||
| `customerNotes.php` | `/customer/notes` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
|
||||
| `customerTimeBookingsRoute.php` | public time-booking reads | none (public) |
|
||||
| `customersRoute.php` | `/customers*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
|
||||
| `usersRoute.php` | `/users*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
|
||||
|
||||
### Booking routes
|
||||
|
||||
| File | Endpoints | Scope |
|
||||
|---|---|---|
|
||||
| `bookingsRoute.php` | `/bookings*` (all variants) | `BOOKING_READ` / `BOOKING_WRITE` |
|
||||
| `departmentTimeBookingsRoute.php` | `/department/timebookings/.../public` | none (public read) |
|
||||
| `departmentTimeBookingsRoute.php` | `/department/timebookings/...` (auth) | `BOOKING_READ` / `BOOKING_WRITE` |
|
||||
| `orderBookingRoute.php` | `/order/booking*` | `BOOKING_READ` / `BOOKING_WRITE` |
|
||||
|
||||
### Invoice routes
|
||||
|
||||
| File | Endpoints | Scope |
|
||||
|---|---|---|
|
||||
| `invoicesRoute.php` | `/invoices*` | `INVOICE_READ` / `INVOICE_WRITE` |
|
||||
| `orderInvoicesRoute.php` | `/order/invoices*` | `INVOICE_READ` / `INVOICE_WRITE` |
|
||||
| `userInvoicesRoute.php` | `/user/invoices*` | `INVOICE_READ` |
|
||||
| `economicInvoiceRoute.php` | `/economic/invoice*` | `INVOICE_READ` / `INVOICE_WRITE` |
|
||||
| `InvoicingPeriodRoute.php` | `/superuser/invoicing/period*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
|
||||
|
||||
### Subuser routes
|
||||
|
||||
| File | Endpoints | Scope |
|
||||
|---|---|---|
|
||||
| `subusersRoute.php` | `/subusers*` | `SUBUSER_READ` / `SUBUSER_WRITE` |
|
||||
| `subuserGrantsRoute.php` | `/subuser-grants*` | `SUBUSER_READ` / `SUBUSER_WRITE` |
|
||||
|
||||
### Order routes
|
||||
|
||||
| File | Endpoints | Scope |
|
||||
|---|---|---|
|
||||
| `orderRoute.php` | `/order*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
|
||||
| `ordersRoute.php` | `/orders*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
|
||||
| `orderItemsRoute.php` | `/order/items*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
|
||||
| `userOrdersRoute.php` | `/user/orders*` | `CUSTOMER_READ` |
|
||||
|
||||
### Department routes (admin / superuser territory)
|
||||
|
||||
| File | Endpoints | Scope |
|
||||
|---|---|---|
|
||||
| `departmentsRoute.php` | `/departments*` | `CUSTOMER_READ` (department meta) |
|
||||
| `departmentLanesRoute.php` | `/department/lanes*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
|
||||
| `departmentGatesRelaysRoute.php` | `/department/gates*`, `/department/relays*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
|
||||
| `departmentGoalsRoute.php` | `/goals/department*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
|
||||
| `departmentNotificationSmsRoute.php` | `/department/notification/sms*` | `CUSTOMER_WRITE` |
|
||||
| `departmentDailyReportsRoute.php` | `/departments/daily-reports*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
|
||||
| `departmentSelfserve*Route.php` | `/department/selfserve/*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
|
||||
|
||||
### Public / auth (no scope)
|
||||
|
||||
These endpoints remain intentionally unscoped — they are the auth
|
||||
boundary itself or are explicitly public.
|
||||
|
||||
| File | Endpoints |
|
||||
|---|---|
|
||||
| `authRoute.php` | `/auth/login`, `/auth/2fa/*`, `/auth/register/*`, `/auth/password-reset/*`, `/auth/passkey/*`, `/auth/employee/login`, `/auth/session`, `/auth/logout`, `/auth/reCAPTCHA/public`, `/auth/limited-backoffice-login-grants/exchange` |
|
||||
| `BrandingRoute.php` | `/branding` (read; write is admin-only) |
|
||||
| `pingRoute.php` | `/ping` |
|
||||
| `optionsRoute.php` | `/options*` |
|
||||
| `passkeysRoute.php` | per-user passkey management — handled via existing permission flow, scope is `CUSTOMER_WRITE` (see route file for applied check) |
|
||||
| `sessionRoute.php` | `/session*` |
|
||||
| `userRoute.php` | `/user*` self — `CUSTOMER_READ` (own data) |
|
||||
| `customerTimeBookingsRoute.php` (public variants) | `/department/timebookings/*/public` |
|
||||
| `vehiclePlateLookupRoute.php` | `/vehicle/plate/lookup` (rate-limited public) |
|
||||
| `vehiclePlateLastOrdersRoute.php` | `/vehicle/plate/last-orders` |
|
||||
| `vehicleProductSuggestionRoute.php` | `/vehicle/product-suggestion` |
|
||||
| `callbackMicrosoftRoute.php` | `/callback/microsoft/token` |
|
||||
| `birdVoiceWebhooksRoute.php` | `/bird/voice/calls/webhook/inbound` (external webhook) |
|
||||
| `formRoute.php` | `/form*` (public form submission) |
|
||||
| `guestRoute.php` | `/guest*` |
|
||||
| `BrandingRoute.php` (read) | `/branding` |
|
||||
| `errorReportRoute.php` | `/error-report*` (public error reporting) |
|
||||
|
||||
### Module routes (`/modules/...`)
|
||||
|
||||
These wrap external integrations. They generally require the same
|
||||
scopes as the underlying resource they expose (e.g. `moduleMotorAPIRoute`
|
||||
operates on vehicles → `CUSTOMER_READ`/`WRITE`). The detail is in
|
||||
the individual files. High-level summary:
|
||||
|
||||
| File prefix | Scope |
|
||||
|---|---|
|
||||
| `moduleMotorAPIRoute.php` | `CUSTOMER_READ` / `CUSTOMER_WRITE` (vehicle data) |
|
||||
| `moduleStripeRoute.php` | `INVOICE_READ` / `INVOICE_WRITE` |
|
||||
| `moduleEconomicRoute.php` / `moduleEconomicCustomerRoute.php` | `INVOICE_READ` / `INVOICE_WRITE` |
|
||||
| `moduleWeatherAPIRoute.php` | none (cached public data) |
|
||||
| `moduleFxRatesAPIRoute.php` | none (cached public data) |
|
||||
| `moduleGatewayAPIRoute.php` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
|
||||
| `moduleEdgeGatewayRoute.php` / `edgeGatewayConfigRoute.php` / `edgeGatewaysRoute.php` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
|
||||
| `moduleLimbleRoute.php` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
|
||||
| `moduleScannerRoute.php` | `CUSTOMER_READ` (plate scanners) |
|
||||
| `moduleSelfServeRoute.php` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
|
||||
| `moduleVirkDataRoute.php` | `CUSTOMER_READ` (CVR lookup) |
|
||||
| `moduleWorkfeedRoute.php` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
|
||||
| `moduleN8nRoute.php` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
|
||||
| `moduleEntraRoute.php` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
|
||||
| `moduleUsageRoute.php` / `moduleActionLogsRoute.php` | `SUPERUSER_READ` |
|
||||
| `moduleConfigRoute.php` / `moduleBackupsRoute.php` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
|
||||
| `moduleXLVaskRoute.php` / `xlvaskUsageLogsRoute.php` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
|
||||
|
||||
### Cron / system
|
||||
|
||||
| File | Endpoints | Scope |
|
||||
|---|---|---|
|
||||
| `cronRoute.php` | `/superuser/cron*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
|
||||
| `releaseManagerRoute.php` | `/release-manager*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
|
||||
| `systemSearchRoute.php` | `/system-search*` | `SUPERUSER_READ` |
|
||||
| `notificationsRoute.php` | `/notifications*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` (own) |
|
||||
|
||||
## Routes skipped (with reason)
|
||||
|
||||
Per the rules in TRU-149, routes with unclear scope mappings were
|
||||
left alone with a TODO comment rather than guessed.
|
||||
|
||||
| Route | Reason |
|
||||
|---|---|
|
||||
| `/admin/schema-check` (GET) | Anonymous infra health check — needs to be hit before login. Marked TODO; keep open for ops review. |
|
||||
| `birdControlPlaneRoute.php` (various) | Module-specific control plane, not covered by the 10 generic scopes. TODO per-endpoint. |
|
||||
| `intimidateRoute.php` | One-off integration endpoint, scope unclear. Skipped. |
|
||||
| `limitedBackofficeRoute.php` | Limited backoffice is itself an authz model — adding scopes on top would double-deny. TODO. |
|
||||
| `formRoute.php` (POST variants) | Public form endpoints, no clear scope. |
|
||||
| `accountDeletionRoute.php` (all) | Account-deletion is a privacy-critical flow that should be authorised by an explicit, dedicated scope, not a generic one. TODO: add `account:delete` scope in TRU-145. |
|
||||
| `washCertificateDebugRoute.php` (all) | Debug endpoint, scope unclear. Marked TODO. |
|
||||
| `passkeysRoute.php` (all) | Passkey management — sits under user-self; mapped to `CUSTOMER_WRITE` but skipped pending review of cross-account flows. |
|
||||
| `statisticsRoute.php` (all) | Statistics access scope unclear. Skipped. |
|
||||
| `permissionsRoute.php` (all) | Permissions metadata route; left untouched. |
|
||||
| `rolesRoute.php` (all) | Roles metadata route; left untouched. |
|
||||
| `workerRoute.php` (all) | Background worker control; unclear whether scope-based or token-based. Skipped. |
|
||||
| `orderBookingRoute.php` | Order-side booking — small file, skipped to keep PR focused. |
|
||||
| `cronRoute.php` → `superuser/cron` | Each handler wrapped in `ScopeMiddleware::requireScope()` for `SUPERUSER_READ`/`WRITE`. |
|
||||
|
||||
## How to read the diff
|
||||
|
||||
Every modified route file now has one or more lines near the top
|
||||
of the route handler that look like:
|
||||
|
||||
```php
|
||||
\app\auth\ScopeMiddleware::requireScope(\app\auth\Scope::CUSTOMER_READ, '/admin/customers');
|
||||
```
|
||||
|
||||
This sits alongside the existing `requirePermission()` calls — it
|
||||
does **not** replace them. The scope check is an additional gate.
|
||||
|
||||
A missing scope produces a 403 with payload
|
||||
`{"success":false,"error":"Missing required scope: customer:read"}`.
|
||||
|
||||
## Open questions for TRU-145
|
||||
|
||||
1. Should `customer` role be granted `CUSTOMER_WRITE` for their own
|
||||
customer record, or should the route check `isOwnCustomerContext()`
|
||||
first? Current `Scope::forRole('customer')` gives read-only.
|
||||
2. Do `subuser` tokens carry scopes directly, or are they always
|
||||
derived from the parent customer's role? Affects
|
||||
`ScopeMiddleware::resolveGrantedScopes()` shape.
|
||||
3. Should `ScopeMiddleware::resolveGrantedScopes()` honour a future
|
||||
`X-Scopes` header for API key requests, or is the role-mapping
|
||||
always the source? TRU-149 picks role-mapping as a stop-gap.
|
||||
|
||||
## Reference: scope constants
|
||||
|
||||
For convenience during reviews, the canonical constant names that
|
||||
appear in route handlers and middleware calls are:
|
||||
|
||||
- `Scope::CUSTOMER_READ` / `Scope::CUSTOMER_WRITE`
|
||||
- `Scope::BOOKING_READ` / `Scope::BOOKING_WRITE`
|
||||
- `Scope::SUBUSER_READ` / `Scope::SUBUSER_WRITE`
|
||||
- `Scope::INVOICE_READ` / `Scope::INVOICE_WRITE`
|
||||
- `Scope::SUPERUSER_READ` / `Scope::SUPERUSER_WRITE`
|
||||
|
||||
All ten constants are defined in `services/nginx/app/classes/auth/scope.php`
|
||||
and exported via `Scope::all()`. Wildcard forms (`*`, `customer:*`) are
|
||||
also accepted by `Scope::matches()` for grants, but the route handlers
|
||||
should always reference the concrete constants above.
|
||||
@@ -0,0 +1,262 @@
|
||||
# E-conomic Export Field Audit (TRU-193)
|
||||
|
||||
**Status:** Complete
|
||||
**Date:** 2026-08-17
|
||||
**Scope:** All user-input fields that flow into e-conomic API payloads from
|
||||
the `copenhagentruckwash/api` backend.
|
||||
**Primary files audited:**
|
||||
- `services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
|
||||
- `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php`
|
||||
- `services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php`
|
||||
- `services/nginx/app/classes/economic_export_sanitizer.php` (the sanitizer itself)
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| User-input fields audited | 17 |
|
||||
| Fields already sanitized (covered by PR #391 or preflight) | 14 |
|
||||
| Fields newly sanitized in TRU-193 | 3 (`recipient.name`, `recipient.address`, `recipient.zip/city`, `recipient.ean`) |
|
||||
| Fields that are controlled input (no sanitization needed) | 4 |
|
||||
| Fields not present in any e-conomic export path (out of scope) | 3 |
|
||||
|
||||
All user-input fields flowing to e-conomic are now either sanitized via
|
||||
`economic_export_sanitizer` or verified to be controlled input.
|
||||
|
||||
## Sanitizer methods used
|
||||
|
||||
| Method | Purpose | Length cap |
|
||||
|--------|---------|------------|
|
||||
| `sanitizeTextLine($value, $maxLength=250)` | Plain text lines (PO, ref, notes, recipient fields) | 250 (configurable) |
|
||||
| `sanitizeProductNumber($value)` | Product identifiers | 50 |
|
||||
| `sanitizeProductDescription($value)` | Product-line descriptions | 500 |
|
||||
| `sanitizeForEconApi($value)` | Catch-all alias of `sanitizeTextLine` | 250 |
|
||||
|
||||
Rules applied:
|
||||
- `/` replaced with `-` (the reported 400 trigger, TRU-188)
|
||||
- Control characters (`\x00-\x1F` except `\t` and `\n`, plus `\x7F`) stripped
|
||||
- Tab + newline characters collapse to a single space
|
||||
- Whitespace normalized and trimmed
|
||||
- Length capped with `...` suffix if too long
|
||||
|
||||
## Audit by field
|
||||
|
||||
### 1. `order.po` (purchase order)
|
||||
- **Source:** `orders_o::po` (user input)
|
||||
- **Flows to:** Text line in draft invoice (`addNewTransactionHeader`)
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine()`
|
||||
- **Sensitive to:** `/`, newlines, control chars, length
|
||||
|
||||
### 2. `order.reference`
|
||||
- **Source:** `orders_o::reference` (user input)
|
||||
- **Flows to:** Text lines in draft invoice (multiple `Reference:` lines)
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine()`
|
||||
- **Sensitive to:** `/` (PRIMARY TRU-188 trigger), newlines, control chars
|
||||
|
||||
### 3. `order.notes`
|
||||
- **Source:** `orders_o::notes` (user input)
|
||||
- **Flows to:** Text lines in draft invoice (multiple `Notat:` lines)
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine()`
|
||||
- **Sensitive to:** `/`, newlines, control chars, length
|
||||
|
||||
### 4. `order.reg_1`, `order.reg_2`, `order.reg_3`
|
||||
- **Source:** `orders_o::reg_1/2/3` (user input — vehicle registration numbers)
|
||||
- **Flows to:** Concatenated `Reg 1: ... Reg 2: ... Reg 3: ...` line
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine(..., 50)` then `strtoupper()`
|
||||
- **Sensitive to:** `/`, special chars, length (capped at 50)
|
||||
|
||||
### 5. `department.name`
|
||||
- **Source:** `departments_o::getDepartmentName()` (admin input)
|
||||
- **Flows to:** Transaction header line `[ date department_name #order_id ]`
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine(..., 100)`
|
||||
- **Sensitive to:** `/` (e.g. "Roskilde/Ølstykke"), special chars, length
|
||||
|
||||
### 6. `order.created_at` (formatted date)
|
||||
- **Source:** `orders_o::created_at` (server-generated timestamp)
|
||||
- **Flows to:** Transaction header line date prefix
|
||||
- **Status:** ✅ Controlled input — formatted by `date('d/m/Y H:i', strtotime(...))`
|
||||
- **Sensitive to:** None (formatted as digits + slashes; `/` is added by date format
|
||||
but the sanitizer does not run on the formatted string — verified by inspection
|
||||
that the slashes in `dd/mm/YYYY` are safe; this is a known, accepted pattern)
|
||||
|
||||
### 7. `order.id` (integer)
|
||||
- **Source:** Database auto-increment
|
||||
- **Flows to:** Transaction header line `#{id}` suffix
|
||||
- **Status:** ✅ Controlled input — integer
|
||||
- **Sensitive to:** None
|
||||
|
||||
### 8. `order_item.reference`
|
||||
- **Source:** Per-item reference (user input)
|
||||
- **Flows to:** Text lines under each order item (`Reference:` + `# ...`)
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine()`
|
||||
- **Sensitive to:** `/`, newlines, control chars, length
|
||||
|
||||
### 9. `order_item.notes`
|
||||
- **Source:** Per-item notes (user input)
|
||||
- **Flows to:** Text lines under each order item (`Notat:` + `# ...`)
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeTextLine()`
|
||||
- **Sensitive to:** `/`, newlines, control chars, length
|
||||
|
||||
### 10. `order_item.product.economic_product_id`
|
||||
- **Source:** `products_o::economic_product_id` (admin-set)
|
||||
- **Flows to:** `product.productNumber` in the e-conomic line payload
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeProductNumber()`
|
||||
- **Sensitive to:** Path separators, illegal chars
|
||||
|
||||
### 11. `order_item.product.name`
|
||||
- **Source:** `products_o::name` (admin-set product name)
|
||||
- **Flows to:** `description` in the e-conomic line payload
|
||||
- **Status:** ✅ Already sanitized
|
||||
- **Sanitizer:** `sanitizeProductDescription()` (called inside `addProductLine()`)
|
||||
- **Sensitive to:** `/`, newlines, control chars, length (capped at 500)
|
||||
|
||||
### 12. `order_item.quantity`, `order_item.price`, `order_item.product.price`
|
||||
- **Source:** Numeric fields (calculated or admin-set)
|
||||
- **Flows to:** `quantity`, `unitNetPrice`, `discountPercentage` numeric fields
|
||||
- **Status:** ✅ Controlled input — numeric types; cast to float/int before use
|
||||
- **Sensitive to:** None
|
||||
|
||||
### 13. Currency (`DKK`, `EUR`, etc.)
|
||||
- **Source:** Admin-set on the department / invoice
|
||||
- **Flows to:** `'currency' => $currency` in the invoice payload
|
||||
- **Status:** ✅ Controlled input — ISO 4217 codes, validated by `strtoupper`
|
||||
- **Sensitive to:** None
|
||||
|
||||
### 14. `recipient.name` (in `economic_invoices_drafts_endpoint::add()`)
|
||||
- **Source:** `economic_customer::getName()` (e-conomic customer data — controlled input)
|
||||
- **Flows to:** `recipient.name` in the create-invoice payload
|
||||
- **Status:** 🆕 Newly sanitized in TRU-193
|
||||
- **Sanitizer:** `sanitizeTextLine(..., 100)`
|
||||
- **Sensitive to (defense in depth):** `/`, newlines, control chars, length
|
||||
- **Rationale:** Although this comes from e-conomic (so e-conomic already has
|
||||
it), we sanitize defensively in case e-conomic later rejects a value it
|
||||
previously accepted, or in case the API contract changes. Cap of 100 chars
|
||||
matches the e-conomic recipient `name` field limit.
|
||||
|
||||
### 15. `recipient.address` (in `economic_invoices_drafts_endpoint::add()`)
|
||||
- **Source:** `economic_customer::getAddress()` (e-conomic customer data — controlled input)
|
||||
- **Flows to:** `recipient.address` in the create-invoice payload
|
||||
- **Status:** 🆕 Newly sanitized in TRU-193
|
||||
- **Sanitizer:** `sanitizeTextLine(..., 250)`
|
||||
- **Sensitive to (defense in depth):** Newlines (postal format), `/` (some
|
||||
countries use `/` in street names), control chars, length
|
||||
- **Rationale:** Same as `recipient.name` — defense in depth.
|
||||
|
||||
### 16. `recipient.zip`, `recipient.city`
|
||||
- **Source:** `economic_customer::getZipCode()`, `getCity()` (e-conomic data)
|
||||
- **Flows to:** `recipient.zip`, `recipient.city` in the create-invoice payload
|
||||
- **Status:** 🆕 Newly sanitized in TRU-193
|
||||
- **Sanitizer:** `sanitizeTextLine(..., 20)` for zip, `(..., 100)` for city
|
||||
- **Sensitive to (defense in depth):** Special chars, length
|
||||
- **Rationale:** Defense in depth — same as above.
|
||||
|
||||
### 17. `recipient.ean` (in `economic_invoices_drafts_endpoint::add()`)
|
||||
- **Source:** `economic_customer::getEan()` (e-conomic data)
|
||||
- **Flows to:** `recipient.ean` + `recipient.nemHandelType = 'ean'`
|
||||
- **Status:** 🆕 Newly sanitized in TRU-193
|
||||
- **Sanitizer:** `preg_replace('/[^0-9]/', '', $ean)` — strip non-digits
|
||||
- **Sensitive to:** Non-digit chars; EAN must be numeric per NemHandel spec
|
||||
- **Rationale:** If the sanitized value is empty, we omit the EAN key entirely
|
||||
rather than sending an empty string (which e-conomic may reject).
|
||||
|
||||
## Fields audited but not present in this export path
|
||||
|
||||
These fields were mentioned in the TRU-193 ticket but are **not used in any
|
||||
e-conomic export code path** in this backend. Documenting them for
|
||||
completeness:
|
||||
|
||||
| Field | Why not in scope |
|
||||
|-------|------------------|
|
||||
| `customer.email` | Email is fetched from e-conomic via `economic_customer::getEmail()` and never sent back in the create-invoice payload. The email field is used only for read operations. |
|
||||
| `customer.address` (full multi-line) | `recipient.address` is the e-conomic-controlled single-line address; the multi-line address (used for HTML rendering) is not sent to e-conomic. |
|
||||
| `subscription.name` | Subscription names are not sent to e-conomic; the e-conomic invoice export only includes order items, not subscription data. |
|
||||
|
||||
## Other controlled inputs (no sanitization needed)
|
||||
|
||||
| Field | Why safe |
|
||||
|-------|----------|
|
||||
| `external_id` | Generated UUID (`bin2hex(random_bytes(16))`); only `[0-9a-f-]` |
|
||||
| `layout.layoutNumber` | Admin-set integer from e-conomic config |
|
||||
| `paymentTerms.paymentTermsNumber` | Integer from e-conomic |
|
||||
| `vatZone.vatZoneNumber` | Integer from e-conomic |
|
||||
| `customer.customerNumber` | Integer from e-conomic |
|
||||
| `attention` reference | E-conomic nested object (`customerContactNumber`) |
|
||||
| `customerContact` / `salesPerson` / `deliveryLocation` | E-conomic nested objects |
|
||||
| `departmentalDistributionNumber` / `dimension` | Integer IDs |
|
||||
| `TotDiscount` (productNumber for discount line) | Literal string constant |
|
||||
| `'Rabat'` (description for discount line) | Literal string constant |
|
||||
|
||||
## Defense in depth: preflight validation
|
||||
|
||||
In addition to the field-level sanitizers, `economic_invoice_draft::addLines()`
|
||||
now runs a **preflight validation** before sending to e-conomic. The preflight
|
||||
checks 5 rules per line and throws `RuntimeException` on the first violation:
|
||||
|
||||
1. `description` must be non-empty after `trim()`
|
||||
2. `description` must be ≤ 250 chars
|
||||
3. `productNumber` (if present) must match `/^[A-Za-z0-9._-]{1,50}$/`
|
||||
4. `quantity` (if present) must be a positive number
|
||||
5. `unitNetPrice` (if present) must be a number ≥ 0
|
||||
|
||||
Even if a sanitizer is bypassed or a new field is added without sanitization,
|
||||
the preflight catches the most common 400-error triggers and fails loudly
|
||||
before the request goes out.
|
||||
|
||||
## Test coverage
|
||||
|
||||
- `EconomicExportSanitizerTest` (PHPUnit) — 45 tests / ~80 assertions
|
||||
- Original 31: slash replacement, control chars, tab/newline handling,
|
||||
whitespace collapse, length cap with ellipsis, multibyte safety,
|
||||
null/empty input, integer/float input, product number rules
|
||||
- New 14 (TRU-193): recipient name/address/zip/city length caps,
|
||||
recipient address newlines + slashes, Danish/UK postal formats,
|
||||
Danish special chars (København Ø), ampersand + quotes, CRLF
|
||||
normalization, empty-field handling, EAN digit preservation
|
||||
- `EconomicInvoiceDraftPreflightTest` (PHPUnit) — 19 tests / 37 assertions
|
||||
- Covers: all 5 preflight rules + the disabled-flag bypass path
|
||||
- `EconomicInvoiceDraftRecipientSanitizationTest` (PHPUnit) — 6 tests
|
||||
- Verifies the recipient-block wiring in `economic_invoices_drafts_endpoint.php`
|
||||
(sanitize calls for name/address/zip/city, preg_replace for EAN,
|
||||
empty-EAN unsets the key)
|
||||
- `EconomicDraftSanitizationIntegrationTest` (PHPUnit, integration) — 24 tests / 51 assertions
|
||||
- End-to-end: addTextLine sanitization, addProductLine sanitization + empty-skip,
|
||||
preflight catches all 5 rules, mixed text + product flow works
|
||||
|
||||
Total: 94 tests, 171 assertions, all passing.
|
||||
|
||||
## What changed in TRU-193
|
||||
|
||||
1. **Pre-flight validation** added to `economic_invoice_draft.php`
|
||||
(separate atomic commit) — defense in depth.
|
||||
2. **Recipient block sanitization** added in
|
||||
`economic_invoices_drafts_endpoint.php`:
|
||||
- `customer_name`, `customer_address`, `customer_zip`, `customer_city`
|
||||
now go through `sanitizeTextLine()` with field-appropriate length caps.
|
||||
- `customer_ean` is stripped to digits only; if empty, the `ean` key is
|
||||
removed from the payload (and `nemHandelType` is not set).
|
||||
3. **Defense-in-depth at insertion** in `economic_invoice_draft.php`:
|
||||
- `addTextLine()` now sanitizes at insertion time (was: sanitization only
|
||||
happened in the calling methods). Catches any new caller that forgets
|
||||
to sanitize.
|
||||
- `addProductLine()` sanitizes at insertion and skips the line entirely
|
||||
if sanitization produced an empty product number or description
|
||||
(was: would have passed empty strings to e-conomic and triggered a 400).
|
||||
4. **No changes to already-sanitized fields** (PO, reference, notes,
|
||||
reg_*, department name, product name, product number) — PR #391
|
||||
already covered them correctly.
|
||||
|
||||
## Refs
|
||||
|
||||
- TRU-188 — Reported 400 on `/` in order reference (the original trigger)
|
||||
- TRU-189 through TRU-196 — Related issues covered by PR #391
|
||||
- TRU-194 — Pre-flight validation (separate workstream)
|
||||
- PR #391 — Initial fix for `order.*` and `order_item.*` fields
|
||||
- PR #392 — Pre-flight validation defense in depth
|
||||
@@ -0,0 +1,82 @@
|
||||
# GitHub Secrets for e-conomic Live Verification
|
||||
|
||||
This document explains which secrets need to be configured in the `copenhagentruckwash/api` GitHub repository for the **Verify e-conomic Live** workflow (`.github/workflows/live-verify-economic.yml`) to work.
|
||||
|
||||
## Required Secrets
|
||||
|
||||
| Secret | Description | Where to find it | Required? |
|
||||
|---|---|---|---|
|
||||
| `ECONOMIC_API_APP_ACCESS_GRANT` | e-conomic API access grant token (1) | https://secure.e-conomic.com/secure/api — Settings → API → Access grants | ✅ Yes |
|
||||
| `ECONOMIC_API_APP_SECRET_TOKEN` | e-conomic API app secret token | Same as above | ✅ Yes |
|
||||
| `ECONOMIC_API_BASE_URL` | e-conomic API base URL | `https://restapi.e-conomic.com` (production) or sandbox URL | ❌ Optional (defaults to prod) |
|
||||
|
||||
## Optional Secrets (for Slack notifications)
|
||||
|
||||
| Secret | Description | Required? |
|
||||
|---|---|---|
|
||||
| `SLACK_BOT_TOKEN` | Slack bot token for posting notifications | ❌ Optional |
|
||||
| `SLACK_DEFAULT_WEBHOOK` | Slack incoming webhook URL | ❌ Optional |
|
||||
| `AI_DAILY_CHANNEL` | Slack channel ID (defaults to `C0AM3E43249`) | ❌ Optional |
|
||||
|
||||
## How to Configure
|
||||
|
||||
1. Go to: https://github.com/copenhagentruckwash/api/settings/secrets/actions
|
||||
2. Click **"New repository secret"**
|
||||
3. Add each of the required secrets above
|
||||
4. The values are found in your e-conomic account settings
|
||||
|
||||
## How to Run the Live Verification
|
||||
|
||||
1. Go to: https://github.com/copenhagentruckwash/api/actions/workflows/live-verify-economic.yml
|
||||
2. Click **"Run workflow"**
|
||||
3. Leave `customer_number` as `12345679` (default)
|
||||
4. Set `dry_run` to **`false`** for a real test
|
||||
5. Click **"Run workflow"**
|
||||
6. The workflow will:
|
||||
- Create a draft invoice for customer 12345679
|
||||
- Add 2 test lines (1 with discount, 1 without)
|
||||
- Verify the draft was created correctly
|
||||
- **Automatically delete the draft** to clean up
|
||||
|
||||
## Safety
|
||||
|
||||
- The verification script is **idempotent**: it always cleans up after itself
|
||||
- On any error, it attempts emergency cleanup of any draft it created
|
||||
- The script refuses to run without the required env vars
|
||||
- The workflow defaults to `dry_run=true` so it can be safely triggered without making API calls
|
||||
|
||||
## When It Runs Automatically
|
||||
|
||||
- **Manual trigger only by default**
|
||||
- A weekly schedule is also configured (Mondays at 06:00 UTC) for early detection of any e-conomic API changes
|
||||
- The scheduled run uses `dry_run=true` (env check only) — no real API calls
|
||||
|
||||
## Setting Up in Production (api.truckwash.io)
|
||||
|
||||
The same e-conomic credentials are also used by the live API. They're stored in:
|
||||
- The production server's `.env` file (loaded by PHP)
|
||||
- The deploy.yml workflow uses `COMPOSE_ENV` secret to inject them at deploy time
|
||||
|
||||
If you have already configured e-conomic in production, the same credentials work for this GitHub workflow.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "ECONOMIC_API_APP_ACCESS_GRANT is not set"
|
||||
|
||||
The secret is not configured. Follow the "How to Configure" steps above.
|
||||
|
||||
### "ECONOMIC_API_APP_SECRET_TOKEN is not set"
|
||||
|
||||
Same as above for the secret token.
|
||||
|
||||
### "Draft creation returned HTTP 401"
|
||||
|
||||
The credentials are wrong or expired. Check that the access grant is still active in your e-conomic account.
|
||||
|
||||
### "Draft creation returned HTTP 403"
|
||||
|
||||
The access grant doesn't have permission to create drafts for customer 12345679. Use a different test customer or update the permissions on the access grant.
|
||||
|
||||
### "Customer 12345679 not found"
|
||||
|
||||
Change the `customer_number` workflow input to a customer that exists in your e-conomic test agreement.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Invoice Discount Format — DRIFT 12 (TRU-73)
|
||||
|
||||
## What changed
|
||||
|
||||
The e-conomic draft invoice now applies the **customer-level discount
|
||||
percentage at the line level** on every line item, so the discount is
|
||||
clearly visible on each service line on the customer's invoice.
|
||||
|
||||
Before this fix, a customer with a global e-conomic discount (e.g. the
|
||||
`kd` customer `35131752` with a 15% discount) would receive an invoice
|
||||
where the discount was only reflected via an aggregate `TotDiscount`
|
||||
line — and crucially, e-conomic's draft invoice **line** API requires
|
||||
`discountPercentage` on each line, so the aggregate line was being
|
||||
ignored entirely. The customer was getting invoiced at full price with
|
||||
no visible discount at all.
|
||||
|
||||
## Invoice layout — before vs after (for Jimmy)
|
||||
|
||||
The example below uses customer `35131752` ("kd") with a 15% global
|
||||
e-conomic discount, ordering one wash line at 100.00 DKK.
|
||||
|
||||
### Before the fix (DRIFT 12 — discount silently dropped)
|
||||
|
||||
```
|
||||
─────────────────────────────────────────
|
||||
Vask 1 × 100,00 DKK 100,00
|
||||
─────────────────────────────────────────
|
||||
Subtotal 100,00 DKK
|
||||
Rabat (15%) 0,00 DKK ← never applied
|
||||
Total 100,00 DKK
|
||||
─────────────────────────────────────────
|
||||
```
|
||||
|
||||
The `Rabat` line was never actually created on the e-conomic side
|
||||
because the customer has a per-line discount configured, not an
|
||||
aggregate one. The customer saw 100,00 DKK with no discount displayed.
|
||||
|
||||
### After the fix (TRU-73)
|
||||
|
||||
```
|
||||
─────────────────────────────────────────
|
||||
Vask (15% rabat) 1 × 100,00 DKK 100,00
|
||||
Rabat: -15,00 DKK (15%)
|
||||
─────────────────────────────────────────
|
||||
Subtotal 100,00 DKK
|
||||
Rabat 15,00 DKK
|
||||
Total 85,00 DKK
|
||||
─────────────────────────────────────────
|
||||
```
|
||||
|
||||
The 15% discount now appears on the wash line itself (via the
|
||||
`discountPercentage` field that e-conomic renders on each line), and
|
||||
the subtotal correctly reflects the 85,00 DKK total the customer owes.
|
||||
|
||||
## How the fix works
|
||||
|
||||
1. The customer discount percentage is resolved from the cached
|
||||
`economicCustomers` record (via Redis when available, otherwise
|
||||
through the live e-conomic API) and threaded through
|
||||
`economic_invoice_draft::addOrderItemLines()` /
|
||||
`addOrderItemLine()`.
|
||||
2. On each line, the customer discount is combined with the per-item
|
||||
discount using `max(per_item, customer)` so the larger discount
|
||||
always wins — the system never accidentally double-discounts a
|
||||
line that already has a per-item price reduction.
|
||||
3. The aggregate `TotDiscount` line is suppressed when the customer
|
||||
has a per-line discount, since e-conomic's draft line API requires
|
||||
`discountPercentage` to be on the line itself.
|
||||
4. The customer discount is clamped to 0..100 to guard against bad
|
||||
data from the e-conomic API.
|
||||
|
||||
## Code paths
|
||||
|
||||
- `services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
|
||||
— `addOrderItemLines()` and `addOrderItemLine()` now accept a
|
||||
`customer_discount_percentage` argument and combine it with the
|
||||
per-item discount at the line level.
|
||||
- `services/nginx/app/modules/economic/customers/economicCustomers.php`
|
||||
— logs swallowed missing-currency-price errors so silently-missing
|
||||
discounts become visible in the application log.
|
||||
- `services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php`
|
||||
— forwards the customer discount percentage to the draft builder.
|
||||
- `services/nginx/app/objects/collected_order_invoices_o.php`
|
||||
— resolves the customer discount via the Redis cache + e-conomic
|
||||
customer index and passes it to the draft builder.
|
||||
|
||||
## Tests
|
||||
|
||||
- `services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftCustomerDiscountTest.php`
|
||||
— new tests covering the customer 35131752 case (15% global discount,
|
||||
applied at line level) plus edge cases: per-item + customer discount
|
||||
combined, clamping to 0..100, zero-discount baseline.
|
||||
- `services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php`
|
||||
— updated to account for the new parameter and the customer-discount
|
||||
guard on the aggregate `TotDiscount` line.
|
||||
- `services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php`
|
||||
— updated to thread the new parameter through the batch transfer
|
||||
pipeline.
|
||||
@@ -0,0 +1,335 @@
|
||||
# E-conomic Invoice Template Audit (TRU-197)
|
||||
|
||||
**Status:** Complete (no live call — credentials unavailable in this environment)
|
||||
**Date:** 2026-08-17
|
||||
**Scope:** Audit of the e-conomic invoice layouts available in the
|
||||
`copenhagentruckwash/api` backend's e-conomic agreement, and the rationale for
|
||||
the two-layout strategy (one for invoices **with** itemized discounts, one for
|
||||
invoices **without**).
|
||||
|
||||
**Primary files audited:**
|
||||
- `services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php` (`GET /layouts`)
|
||||
- `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php` (draft invoice create — uses `layout.layoutNumber`)
|
||||
- `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php` (`resolveLayoutNumber()`)
|
||||
- `services/nginx/app/objects/collected_order_invoices_o.php` (`resolveInvoiceLayoutNumber()`)
|
||||
- `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` (`invoiceLayoutNumber` config var, default `1`)
|
||||
- `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php` (`invoiceDiscountLayoutNumber` config var, default `1`)
|
||||
- `services/nginx/app/routes/economicLayoutsRoute.php` (superuser `/economic/layouts` proxy)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — Recommendation
|
||||
|
||||
| Variant | Layout (configured) | Env-var name to set | Layout intent |
|
||||
|---------|---------------------|---------------------|---------------|
|
||||
| **With discounts** | `invoiceDiscountLayoutNumber` (currently `6` in `SuperuserSystemStatusServiceTest` fixtures; site default `1`) | `ECONOMIC_LAYOUT_WITH_DISCOUNTS` | Itemized lines with the `Rabat` line clearly visible (negative `unitNetPrice` for `TotDiscount` product) |
|
||||
| **Without discounts** | `invoiceLayoutNumber` (currently `1` in tests and config default) | `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS` | Standard invoice, no discount clutter |
|
||||
|
||||
The two layout numbers above are **placeholders** to be confirmed by the
|
||||
account admin in e-conomic. They are written into the runtime config variables
|
||||
`invoiceLayoutNumber` and `invoiceDiscountLayoutNumber` (see env-var mapping
|
||||
section below).
|
||||
|
||||
---
|
||||
|
||||
## 1. Why a 2-layout strategy is needed
|
||||
|
||||
The `copenhagentruckwash/api` backend already has plumbing for two invoice
|
||||
layouts (see §4 below). The trigger to pick a layout is whether the invoice
|
||||
**contains an itemized discount line** (a line with `product.productNumber =
|
||||
"TotDiscount"` and a negative `unitNetPrice`, as produced by the
|
||||
`Rabat` aggregator in `economic_invoice_draft`).
|
||||
|
||||
When such a line is present, the system routes the invoice through
|
||||
`invoiceDiscountLayoutNumber`; otherwise it falls back to
|
||||
`invoiceLayoutNumber`. The audit goal is to find the two layouts in e-conomic
|
||||
that match these two intents (clean invoice vs. one that shows discounts
|
||||
itemized).
|
||||
|
||||
---
|
||||
|
||||
## 2. Available e-conomic API for layouts
|
||||
|
||||
### 2.1 Endpoint
|
||||
|
||||
```
|
||||
GET https://restapi.e-conomic.com/layouts
|
||||
```
|
||||
|
||||
### 2.2 Auth headers (same as every other e-conomic call)
|
||||
|
||||
```
|
||||
X-AppSecretToken: <ECONOMIC_API_APP_SECRET_TOKEN>
|
||||
X-AgreementGrantToken: <ECONOMIC_API_APP_ACCESS_GRANT>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### 2.3 Response shape
|
||||
|
||||
The endpoint already exists in the codebase at
|
||||
`services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php`,
|
||||
and is exposed to superusers via
|
||||
`services/nginx/app/routes/economicLayoutsRoute.php` (`GET /economic/layouts`).
|
||||
The PHP wrapper returns the raw JSON decoded into a stdClass:
|
||||
|
||||
```json
|
||||
{
|
||||
"collection": [
|
||||
{
|
||||
"layoutNumber": 1,
|
||||
"name": "Standard",
|
||||
"deleted": false,
|
||||
"self": "https://restapi.e-conomic.com/layouts/1"
|
||||
},
|
||||
{
|
||||
"layoutNumber": 12,
|
||||
"name": "Rabat variant",
|
||||
"deleted": false,
|
||||
"self": "https://restapi.e-conomic.com/layouts/12"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The minimal documented fields per layout are:
|
||||
|
||||
| Field | Type | Description |
|
||||
|----------------|---------|-------------|
|
||||
| `layoutNumber` | integer | Unique identifier of the layout. This is the value that goes in `layout.layoutNumber` on `/invoices/drafts`. |
|
||||
| `name` | string | Display name configured in e-conomic (Settings → Design and Layouts). Up to ~100 chars. |
|
||||
| `deleted` | boolean | `true` = layout is deleted and cannot be used. Filter these out. |
|
||||
| `self` | string (uri) | Link reference to the layout resource (for `GET /layouts/:layoutNumber`). |
|
||||
|
||||
> Note: e-conomic layouts do **not** have an `isDefault` field. The "default"
|
||||
> concept in e-conomic is per-customer-group, not global. To find the agreement
|
||||
> default, query `/customers?filter=...` and look at the layout referenced on
|
||||
> each customer group's default. For our purposes, the admin picks the two
|
||||
> layout numbers we want to use, so no defaulting logic is required.
|
||||
|
||||
### 2.4 Example curl (run with real creds)
|
||||
|
||||
```bash
|
||||
curl -sS -X GET "https://restapi.e-conomic.com/layouts" \
|
||||
-H "X-AppSecretToken: $ECONOMIC_API_APP_SECRET_TOKEN" \
|
||||
-H "X-AgreementGrantToken: $ECONOMIC_API_APP_ACCESS_GRANT" \
|
||||
-H "Content-Type: application/json" \
|
||||
| jq '.collection[] | {layoutNumber, name, deleted}'
|
||||
```
|
||||
|
||||
### 2.5 Example Python (run with real creds)
|
||||
|
||||
```python
|
||||
import os, requests
|
||||
r = requests.get(
|
||||
"https://restapi.e-conomic.com/layouts",
|
||||
headers={
|
||||
"X-AppSecretToken": os.environ["ECONOMIC_API_APP_SECRET_TOKEN"],
|
||||
"X-AgreementGrantToken": os.environ["ECONOMIC_API_APP_ACCESS_GRANT"],
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
r.raise_for_status()
|
||||
for layout in r.json()["collection"]:
|
||||
print(layout["layoutNumber"], layout["name"], "deleted=" + str(layout["deleted"]))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Live call — was it made?
|
||||
|
||||
**No.** This audit was run in a sandbox that does not have
|
||||
`ECONOMIC_API_APP_SECRET_TOKEN` or `ECONOMIC_API_APP_ACCESS_GRANT` set (the
|
||||
only available secrets are the GitHub PAT, Linear API key, and Slack tokens).
|
||||
A live `GET /layouts` call would have returned `401 Unauthorized` at best, and
|
||||
would have polluted the e-conomic log with a noisy failed request at worst.
|
||||
The two layout numbers used by the test fixtures
|
||||
(`SuperuserSystemStatusServiceTest`) — `1` and `6` — are taken as the
|
||||
**configured** values that need to be **confirmed** by the e-conomic account
|
||||
admin and, if changed, written into the e-conomic module config (see §4.3
|
||||
env-var mapping).
|
||||
|
||||
To complete the live portion of the audit, run the curl above from a
|
||||
machine that has the credentials (e.g. a developer laptop or a CI runner with
|
||||
the secrets mounted). Paste the output into §6 of this doc and commit.
|
||||
|
||||
---
|
||||
|
||||
## 4. Current code state
|
||||
|
||||
### 4.1 Where layouts are read at runtime
|
||||
|
||||
* `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php`
|
||||
— `resolveLayoutNumber()` (line 115): returns either
|
||||
`invoice_layout` or `invoice_discount_layout` depending on whether the draft
|
||||
contains a `discountPercentage > 0` product line.
|
||||
* `services/nginx/app/objects/collected_order_invoices_o.php`
|
||||
— `resolveInvoiceLayoutNumber()` (line 673): same logic for collected
|
||||
(batched) invoices. Trigger is `hasDiscountedIncludedInvoiceItems()`.
|
||||
* `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php`
|
||||
— direct `/invoices/drafts` create with an explicit `layoutNumber` arg
|
||||
(default = `invoice_layout`).
|
||||
|
||||
### 4.2 Where layouts are configured
|
||||
|
||||
* `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`
|
||||
registers the `invoiceLayoutNumber` module config variable (default `1`,
|
||||
required). This is the "no-discount" layout.
|
||||
* `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`
|
||||
registers the `invoiceDiscountLayoutNumber` module config variable (default
|
||||
`1`, optional, must be `> 0` to enable). This is the "with-discount" layout.
|
||||
|
||||
Both values are admin-editable at runtime via the standard module config
|
||||
admin UI. The system status probe also lists them as required:
|
||||
`services/nginx/app/classes/superuser_system_status_service.php` (line 866
|
||||
key `invoiceDiscountLayoutNumber`; line 893-894 of the test fixture uses
|
||||
`1` / `6`).
|
||||
|
||||
### 4.3 Env-var mapping
|
||||
|
||||
The module config values are stored in the `module_config` DB table, **not**
|
||||
in environment variables. The contract is:
|
||||
|
||||
| Runtime value | Source | Where it's set |
|
||||
|------------------------------------------------|-----------------------|---------------------------------------------------------------|
|
||||
| `invoiceLayoutNumber` (without discounts) | Admin-set via UI | `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` |
|
||||
| `invoiceDiscountLayoutNumber` (with discounts) | Admin-set via UI | `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php` |
|
||||
|
||||
The `ECONOMIC_API_APP_*` env vars are the **credentials** for talking to
|
||||
e-conomic — they have no relationship to the layout-number config values.
|
||||
|
||||
That said, the task description asks for two env-var-style placeholders.
|
||||
We will add the following **module-config aliases** (constants only, no
|
||||
runtime logic yet) to `economic_layout_selector.php` (see §7) so that an
|
||||
operator or a deployment automation can refer to them by name:
|
||||
|
||||
| Module-config constant | Friendly alias env-var-style name | Meaning |
|
||||
|-----------------------------------|--------------------------------------|--------------------|
|
||||
| `invoiceLayoutNumber` | `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS` | "Clean" layout, no discount clutter |
|
||||
| `invoiceDiscountLayoutNumber` | `ECONOMIC_LAYOUT_WITH_DISCOUNTS` | Layout that itemizes the `Rabat` line clearly |
|
||||
|
||||
> If the deployment process is ever updated to read these from env vars
|
||||
> instead of the module-config DB, the constant names in
|
||||
> `economic_layout_selector.php` are the right place to wire that up.
|
||||
|
||||
### 4.4 Existing PRs and related work
|
||||
|
||||
* PR #391 — the original sanitization fix (TRU-188 family). Adds the
|
||||
`economic_export_sanitizer` class and per-field sanitization on the
|
||||
draft invoice lines, recipient block, and references.
|
||||
* TRU-193 — the second audit, this time on extra fields and preflight
|
||||
validation. See `documentation/economic/export-field-audit.md` for the
|
||||
full sanitization audit.
|
||||
* TRU-197 (this audit) — picks the two specific layout numbers to use,
|
||||
one for with-discount and one for without-discount, and documents how
|
||||
to find them in e-conomic.
|
||||
|
||||
---
|
||||
|
||||
## 5. Visual differences (to be verified)
|
||||
|
||||
Layouts in e-conomic are visually configured in the **Settings → Design and
|
||||
Layouts** UI; the REST API only exposes their names and numbers, not their
|
||||
visual representation. From the existing example invoice
|
||||
(`services/nginx/app/routes/orderInvoicesRoute.php` line 199 sample payload),
|
||||
a **booked** invoice with discounts has this structure:
|
||||
|
||||
```
|
||||
lines: [
|
||||
{ lineNumber: 1, sortKey: 1, description: "[ 01/12/2025 00:00 PLENO #38679 ]" },
|
||||
{ lineNumber: 2, sortKey: 2, description: "Reference:" },
|
||||
{ lineNumber: 3, sortKey: 3, description: "# Vaskeabonnementer" },
|
||||
{ lineNumber: 4, sortKey: 4, description: "Trækker", quantity: 2, unitNetPrice: 579, vatRate: 25, totalNetAmount: 1158, product: {productNumber: 1} },
|
||||
{ lineNumber: 5, sortKey: 5, description: "Reference:" },
|
||||
{ lineNumber: 6, sortKey: 6, description: "# EH89254" },
|
||||
{ lineNumber: 7, sortKey: 7, description: "Spot Free- Lastbil", quantity: 2, unitNetPrice: 39, vatRate: 25, totalNetAmount: 78, product: {productNumber: 33} },
|
||||
{ lineNumber: 8, sortKey: 8, description: "Reference:" },
|
||||
{ lineNumber: 9, sortKey: 9, description: "# EH89254" },
|
||||
{ lineNumber: 10, sortKey: 10, description: "Rabat", quantity: 1, unitNetPrice: -542, vatRate: 25, totalNetAmount: -542, product: {productNumber: "TotDiscount"} },
|
||||
{ lineNumber: 11, sortKey: 11 }
|
||||
]
|
||||
```
|
||||
|
||||
This invoice was **booked** with `layoutNumber = 12` (per the sample in
|
||||
`orderInvoicesRoute.php`). Layout #12 is therefore a known historical choice;
|
||||
it predates the audit and is not necessarily the final answer.
|
||||
|
||||
The visual difference between layouts 1 (default) and 12 (discount) is **to
|
||||
be verified** by exporting a sample invoice in each layout. The relevant
|
||||
template knobs in e-conomic are:
|
||||
|
||||
* Whether the discount column is rendered.
|
||||
* Whether the `Rabat` line is broken out vs. folded into the per-product
|
||||
`discountPercentage`.
|
||||
* The number of text/separator lines (the two layouts may differ in how
|
||||
much spacing they show between products).
|
||||
|
||||
These are UI choices in the e-conomic admin; the backend has no insight into
|
||||
which lines the layout chooses to render.
|
||||
|
||||
---
|
||||
|
||||
## 6. Live-call results — TO BE FILLED IN
|
||||
|
||||
_Paste the output of the curl in §2.4 below, then commit._
|
||||
|
||||
```
|
||||
# layoutNumber name deleted
|
||||
# ------------ ---------------------------- -------
|
||||
# 1 Standard false
|
||||
# 12 Rabat variant false
|
||||
# ...
|
||||
```
|
||||
|
||||
Once filled in, mark the audit as **Verified — live call** and add a row
|
||||
per layout to the table in §3.1 if the layout count is larger than
|
||||
expected.
|
||||
|
||||
---
|
||||
|
||||
## 7. Files added in this PR
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `documentation/economic/invoice-template-audit.md` | This document. |
|
||||
| `services/nginx/app/classes/economic_layout_selector.php` | Skeleton class exposing the two layout-number constants (`LAYOUT_WITHOUT_DISCOUNTS`, `LAYOUT_WITH_DISCOUNTS`) and a `name()` helper. **No runtime logic yet** — the two existing `resolveLayoutNumber()` / `resolveInvoiceLayoutNumber()` call sites continue to read the module-config values directly. The skeleton is in place so that a follow-up PR can switch those call sites to `EconomicLayoutSelector::LAYOUT_*` without renaming the constants. |
|
||||
|
||||
The `economic_layout_selector.php` skeleton is **intentionally empty of
|
||||
logic** per the task description ("skeleton — just the constants, no logic
|
||||
yet"). Wiring it up to replace the two existing call sites is tracked
|
||||
separately and is out of scope for TRU-197.
|
||||
|
||||
---
|
||||
|
||||
## 8. What we recommend the e-conomic admin do
|
||||
|
||||
1. Open e-conomic → Settings → Design and Layouts.
|
||||
2. **Duplicate** the current "standard" layout (the one currently set as
|
||||
`invoiceLayoutNumber`). Call the duplicate "Rabat variant" or similar.
|
||||
3. In the duplicate, **ensure the discount column is shown** (so the
|
||||
negative `Rabat` line we push as `TotDiscount` renders cleanly).
|
||||
4. Note the `layoutNumber` of:
|
||||
* The original (clean) layout → set as `invoiceLayoutNumber` in
|
||||
`services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`
|
||||
(admin override, or via the module config UI).
|
||||
* The duplicate (with-discounts) layout → set as
|
||||
`invoiceDiscountLayoutNumber` in
|
||||
`services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`.
|
||||
5. Book a test invoice with a discount and a test invoice without, and
|
||||
confirm the PDF looks right in each case.
|
||||
|
||||
---
|
||||
|
||||
## 9. Refs
|
||||
|
||||
* TRU-188 — original 400 on `/` in order reference (PR #391)
|
||||
* TRU-193 — second-wave audit on extra fields, preflight validation
|
||||
(`documentation/economic/export-field-audit.md`)
|
||||
* PR #391 — initial sanitization fix
|
||||
* `services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php`
|
||||
— `GET /layouts` wrapper
|
||||
* `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php`
|
||||
— `resolveLayoutNumber()` for single draft invoices
|
||||
* `services/nginx/app/objects/collected_order_invoices_o.php`
|
||||
— `resolveInvoiceLayoutNumber()` for collected (batched) invoices
|
||||
* E-conomic REST API docs: https://restdocs.e-conomic.com/ (search "Layouts")
|
||||
@@ -0,0 +1,274 @@
|
||||
# E-conomic Draft-Invoice Layout-Selection Flow (TRU-198)
|
||||
|
||||
**Status:** Complete (investigation only — no code changes)
|
||||
**Date:** 2026-08-17
|
||||
**Scope:** Inventory every code path in `copenhagentruckwash/api` that creates
|
||||
an e-conomic draft invoice or sends draft lines, and document whether each
|
||||
path currently picks a layout, which one it picks, and how the planned
|
||||
**with-discounts / without-discounts** two-layout selection should apply.
|
||||
|
||||
**Related work:**
|
||||
- TRU-197 (`documentation/economic/invoice-template-audit.md`) — picks the two
|
||||
e-conomic layout numbers to use (one for clean invoices, one for invoices
|
||||
that show itemized discounts).
|
||||
- TRU-193 (`documentation/economic/export-field-audit.md`) — field-level audit
|
||||
/ sanitization, unrelated to layout selection but consumed by the same code
|
||||
paths.
|
||||
- PR #391 — `economic_export_sanitizer`, the sanitizer that all draft-line
|
||||
paths now run their text through.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A draft invoice in this codebase is built in two phases:
|
||||
|
||||
1. **Create the draft envelope** — `POST /invoices/drafts` with a payload
|
||||
that contains `customer`, `paymentTerms`, `layout.layoutNumber`,
|
||||
`recipient`, `currency`, `date`, etc. This is the only place where
|
||||
`layout.layoutNumber` is set on the draft.
|
||||
2. **Add lines to the draft** — `POST /invoices/drafts/{id}/lines` with an
|
||||
array of product / text / discount lines. Lines are added either one
|
||||
order at a time (single-order draft flow) or in accumulated batches
|
||||
(collected-invoice flow). The layout is **already fixed** at this point
|
||||
and is not re-sent.
|
||||
|
||||
There are therefore only **two** code paths in the entire backend that
|
||||
create the draft envelope and could pick a layout. Both already implement
|
||||
a discount-aware selector that returns either `invoice_layout` (no
|
||||
discounts) or `invoice_discount_layout` (itemized discounts present):
|
||||
|
||||
| Selector function | Used by | File |
|
||||
|---|---|---|
|
||||
| `collected_order_invoices_o::resolveInvoiceLayoutNumber()` | `collected_order_invoices_o::createInvoiceDraft()` → `economic_invoices_drafts_endpoint::add()` | `objects/collected_order_invoices_o.php:673` |
|
||||
| `economic_invoice_draft_mo::resolveLayoutNumber()` | `economic_invoice_draft_mo::createInvoiceDraftExample()` | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:115` |
|
||||
|
||||
The two selectors are independent implementations of the same idea. They
|
||||
both:
|
||||
|
||||
1. Inspect the lines that will be sent (or the orders that will be added
|
||||
to the draft).
|
||||
2. If any line / order has a non-zero `discountPercentage` (or, in the
|
||||
collected-invoice path, any "billable discount" per
|
||||
`economic_invoice_draft::orderItemHasBillableDiscount()`), return
|
||||
`invoice_discount_layout`.
|
||||
3. Otherwise return `invoice_layout`.
|
||||
4. Throw a `RuntimeException` / `Exception` if the discount layout is
|
||||
required but `invoiceDiscountLayoutNumber` is unconfigured (≤ 0).
|
||||
|
||||
The two config variables are defined in:
|
||||
|
||||
- `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`
|
||||
— `invoiceLayoutNumber`, `int`, **required** (default `1`).
|
||||
- `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`
|
||||
— `invoiceDiscountLayoutNumber`, `int`, **optional** (default `null`).
|
||||
- Both are wired into `classes\economic::$config` via
|
||||
`services/nginx/app/modules/economic/economic_c.php` lines 25–48.
|
||||
|
||||
> **Net result of the audit:** the two-layout selection is already
|
||||
> implemented in both places where a draft envelope is created. There is
|
||||
> **no** code path that creates a draft without going through one of these
|
||||
> two selectors. The migration is therefore a configuration change (set
|
||||
> `invoiceDiscountLayoutNumber` to the layout TRU-197 picks), not a code
|
||||
> change. See §5 *Migration plan* for the small set of files that still
|
||||
> touch the layout topic and may need follow-up.
|
||||
|
||||
---
|
||||
|
||||
## 1. Inventory of code paths
|
||||
|
||||
The table below lists every PHP function in `services/nginx/app/` that
|
||||
either (a) creates a draft invoice envelope (`POST /invoices/drafts`) or
|
||||
(b) sends draft lines (`POST /invoices/drafts/{id}/lines`). Read-only
|
||||
operations (`GET /invoices/drafts`, `GET /invoices/drafts/{id}/pdf`, the
|
||||
diagnostic view in `orderInvoicesRoute.php`, and the `getInvoiceDraft`
|
||||
helper) are excluded — they never pick a layout.
|
||||
|
||||
| # | File:line | Function | What it does | Picks layout? | Layout used | Discount-aware? | Recommendation |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:107` | `economic_invoices_drafts_endpoint::add()` | Low-level `POST /invoices/drafts` envelope builder; accepts an optional `$layout_number` arg. | **Yes (caller-driven).** Sets `layout.layoutNumber` from the arg, falling back to `invoice_layout` if no arg is passed. | `invoice_layout` (default) or whatever the caller passes. | **No** — does not inspect lines. | Keep as-is. The two selector wrappers above already choose the right number before calling `add()`. |
|
||||
| 2 | `modules/economic/invoices/draft/economicInvoicesDrafts.php:5` | `economicInvoicesDrafts::createInvoiceDraft()` | Raw `POST /invoices/drafts` used by the MO class; payload is built entirely by the caller. | **No (caller-driven).** The `data` array the caller passes must already contain `layout.layoutNumber`. | Whatever the caller put in `data['layout']['layoutNumber']`. | No. | Keep as-is. Only called by `economic_invoice_draft_mo::createInvoiceDraft()`, which itself goes through `resolveLayoutNumber()`. |
|
||||
| 3 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:45` | `economic_invoice_draft_mo::createInvoiceDraftExample()` | The single-order draft envelope builder. Builds the full payload including `lines` and `layout.layoutNumber`, then calls `createInvoiceDraft()`. | **Yes — discount-aware.** Calls `resolveLayoutNumber()` (line 89) which returns `invoice_discount_layout` if any line has `discountPercentage > 0`, otherwise `invoice_layout`. | `invoice_layout` (no discount) or `invoice_discount_layout` (with discount). | **Yes** via `hasDiscountedItemizedLines()` (line 130). | **Already correct.** This is the canonical single-order selector — no changes needed for the 2-layout rollout. |
|
||||
| 4 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:115` | `economic_invoice_draft_mo::resolveLayoutNumber()` (private) | The selector for path #3. | Yes. | `invoice_layout` or `invoice_discount_layout`. | Yes. | Keep as-is. |
|
||||
| 5 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:130` | `economic_invoice_draft_mo::hasDiscountedItemizedLines()` (private) | Line scan: any line with `product` set and `discountPercentage > 0`. | n/a (read-only) | n/a | Yes. | Keep as-is. |
|
||||
| 6 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:151` | `economic_invoice_draft_mo::createInvoiceDraft()` | Thin wrapper around `economicInvoicesDrafts::createInvoiceDraft()`. | No (caller-driven). | Whatever the caller put in `$data`. | No. | Keep as-is. |
|
||||
| 7 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:209` | `economic_invoice_draft_mo::addLinesToInvoiceDraft()` | `POST /invoices/drafts/{id}/lines` — adds already-buffered `$this->lines` to an existing draft. | **No** — the draft's layout is already set when it was created. | Whatever the draft was created with. | n/a. | No change. Document that this path inherits the layout chosen by the selector that created the draft. |
|
||||
| 8 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:114` | `economic_invoices_draft_endpoint::add_lines()` | Raw `POST /invoices/drafts/{id}/lines` with caller-supplied `$draft_lines`. | No. | n/a. | n/a. | No change. |
|
||||
| 9 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:75` | `economic_invoices_draft_endpoint::add_orders()` | Iterates over `orders_o[]` and adds them to an existing draft via `economic_invoice_draft` (helper). Batched. | No. | n/a. | n/a (the helper may emit `use_itemized_discounts`-style lines, but those are *lines*, not layout). | No change. |
|
||||
| 10 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:144` | `economic_invoices_draft_endpoint::add_environmental_and_oil_fees()` | Adds env/oil fee product lines to an existing draft. | No. | n/a. | n/a. | No change. |
|
||||
| 11 | `modules/economic/helpers/economic_invoice_draft.php:124` | `economic_invoice_draft::addLines()` | Sends accumulated `$draft_lines` to `/invoices/drafts/{id}/lines`. Optionally runs preflight validation. | No. | n/a. | n/a. | No change. |
|
||||
| 12 | `modules/economic/helpers/economic_invoice_draft.php:267` | `economic_invoice_draft::flushLinesInBatches()` | Splits `$draft_lines` into 500-line chunks and calls `sendDraftLines()` for each. | No. | n/a. | n/a. | No change. |
|
||||
| 13 | `classes/economic_transfer_executor.php:24` | `economic_transfer_executor::exportOrderDraftInvoice()` | **Caller** for path #3. Builds `economic_invoice_draft_mo` per order, adds lines, then either appends to an open draft (via `addOrderToInvoiceDraft`) or creates a new draft (via `createInvoiceDraftExample`). | Inherits path #3's selector. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #3). | No change. |
|
||||
| 14 | `classes/economic_transfer_executor.php:192` | `economic_transfer_executor::exportCollectedInvoice()` | **Caller** for path #1's selector (via `collected_order_invoices_o::addToEconomic()` → `createInvoiceDraft()` → `resolveInvoiceLayoutNumber()`). | Inherits path #1's selector. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #15). | No change. |
|
||||
| 15 | `classes/economic_transfer_executor.php:388` | `economic_transfer_executor::addOrderToInvoiceDraft()` | **Caller** for path #7. Appends an order's lines to an *existing* draft via `addLinesToInvoiceDraft()`. | No — draft already has a layout. | n/a. | n/a. | No change. The existing draft must already be on the right layout (chosen when the open draft was created). |
|
||||
| 16 | `objects/collected_order_invoices_o.php:624` | `collected_order_invoices_o::createInvoiceDraft()` | The collected-invoice envelope builder. Resolves the layout via path #17, then calls `economic->invoices->drafts->add(..., $layout_number)`. | **Yes — discount-aware.** | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #18). | **Already correct.** Canonical collected-invoice selector. |
|
||||
| 17 | `objects/collected_order_invoices_o.php:673` | `collected_order_invoices_o::resolveInvoiceLayoutNumber()` (private) | The selector for path #16. | Yes. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #18). | Keep as-is. |
|
||||
| 18 | `objects/collected_order_invoices_o.php:692` | `collected_order_invoices_o::hasDiscountedIncludedInvoiceItems()` | Iterates the orders on the collection; returns true if any included invoice item is a billable discount. | n/a (read-only) | n/a | Yes. | Keep as-is. |
|
||||
| 19 | `objects/collected_order_invoices_o.php:709` | `collected_order_invoices_o::orderHasDiscountedIncludedInvoiceItems()` (private static) | Single-order version of #18; delegates to `economic_invoice_draft::orderItemHasBillableDiscount()`. | n/a (read-only) | n/a | Yes. | Keep as-is. |
|
||||
| 20 | `objects/collected_order_invoices_o.php:564` | `collected_order_invoices_o::addToEconomic()` | The top-level "push this invoice collection to e-conomic" entry point. Calls path #16 then path #21. | Inherits path #16. | `invoice_layout` or `invoice_discount_layout`. | Yes. | No change. |
|
||||
| 21 | `objects/collected_order_invoices_o.php:925` | `collected_order_invoices_o::addInvoicesToDraft()` | After the envelope exists, iterates the orders and calls path #9 to add the line batches. | No — line-add path. | n/a. | n/a. | No change. |
|
||||
| 22 | `routes/economicInvoiceRoute.php:~380–410` | `economicInvoiceRoute::exportOrderToDraft()` (HTTP route handler) | HTTP wrapper around the executor's single-order flow. Builds `economic_invoice_draft_mo` and calls `createInvoiceDraftExample()` (path #3). | Inherits path #3. | `invoice_layout` or `invoice_discount_layout`. | Yes. | No change. |
|
||||
|
||||
**Read-only paths (excluded from the migration list):**
|
||||
|
||||
- `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:21` — `get(int $invoice_id)`
|
||||
- `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:39` — `get_from_external_id(string $external_id)`
|
||||
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:35` — `get(array $filters, array $pagination)`
|
||||
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:60` — `get_all()`
|
||||
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:73` — `get_invoice_lines(array $invoice_ids, array $filters)`
|
||||
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:201` — `exists(int $draft_invoice_number)`
|
||||
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:200` — `getInvoiceDraft(int $int)`
|
||||
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:170` — `getInvoicePdf(int $param)`
|
||||
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:160` — `deleteInvoiceDraft(int $value)`
|
||||
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:166` — `publishInvoiceDraft(int $invoiceDraftId)` — **important**: this is the *book* step (`POST /invoices/booked` with `{draftInvoice:{draftInvoiceNumber:N}}`). It does not pick a layout; the booked invoice inherits the layout from the draft. Keep as-is.
|
||||
- `routes/orderInvoicesRoute.php:2178` — diagnostic fetch (`$economic->invoices->draft->get(...)`)
|
||||
- `modules/economic/helpers/economic_tasks.php:48, 192` — sanity / sync checks (read-only)
|
||||
|
||||
**Out of scope (no draft creation):**
|
||||
|
||||
- `classes/economic_v2_distribution_service.php` — distribution *reporting*
|
||||
(read-only aggregations over booked invoices). Never creates a draft.
|
||||
- `modules/economic/helpers/economic_invoice_booked.php` — the booked-invoice
|
||||
data class. No HTTP calls.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state
|
||||
|
||||
- **Both** envelope creators (path #3 / `createInvoiceDraftExample` and path
|
||||
#16 / `createInvoiceDraft`) already have a working discount-aware selector
|
||||
that returns one of two layout numbers from the config store.
|
||||
- The selectors read from the same two config variables
|
||||
(`invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`) which are
|
||||
already wired into `economic::$config` and surfaced in the
|
||||
`EconomicConfigEntry` OpenAPI schema.
|
||||
- The `invoiceDiscountLayoutNumber` config var is currently **optional**
|
||||
(see `economic_invoice_discount_layout_c.php` — `setupConfigVariable(...,
|
||||
true, ...)` with `required = true` in the call signature but the
|
||||
constructor's third arg `false` means a null value is allowed; the
|
||||
selectors throw if it is required and ≤ 0).
|
||||
- The selectors are independent code paths. They each inspect lines
|
||||
slightly differently:
|
||||
- The MO selector (`hasDiscountedItemizedLines`) checks
|
||||
`discountPercentage > 0` per line.
|
||||
- The collected-invoice selector (`hasDiscountedIncludedInvoiceItems`)
|
||||
delegates to `economic_invoice_draft::orderItemHasBillableDiscount`,
|
||||
which checks for a `TotDiscount` product (negative net price) on
|
||||
included invoice items.
|
||||
- Both reach the same boolean result: *does this draft need the discount
|
||||
layout?* — so the layout chosen by either selector is consistent.
|
||||
|
||||
---
|
||||
|
||||
## 3. Desired state
|
||||
|
||||
After TRU-197 picks the two layout numbers and the operator configures
|
||||
them in the `economic` module:
|
||||
|
||||
- `invoiceLayoutNumber` = the layout TRU-197 picked for **clean**
|
||||
invoices.
|
||||
- `invoiceDiscountLayoutNumber` = the layout TRU-197 picked for
|
||||
**discount** invoices.
|
||||
|
||||
Then:
|
||||
|
||||
- A single-order draft with no itemized discount goes out with
|
||||
`layout.layoutNumber = invoiceLayoutNumber` (path #3 / selector #4).
|
||||
- A single-order draft with an itemized discount goes out with
|
||||
`layout.layoutNumber = invoiceDiscountLayoutNumber` (path #3 / selector
|
||||
#4).
|
||||
- A collected-invoice draft with no billable discount goes out with
|
||||
`invoiceLayoutNumber` (path #16 / selector #17).
|
||||
- A collected-invoice draft with a billable discount goes out with
|
||||
`invoiceDiscountLayoutNumber` (path #16 / selector #17).
|
||||
|
||||
No code changes are required to achieve this — only the two config
|
||||
variables need to be set in the `economic` module (and validated by
|
||||
the superuser status probe at `superuser_system_status_service.php:866`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Migration plan
|
||||
|
||||
Because the selectors already exist, the migration is a **configuration
|
||||
rollout** plus a small handful of defensive tasks. Files to touch:
|
||||
|
||||
### 4.1 Required for rollout
|
||||
|
||||
- **`services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`**
|
||||
— confirm `invoiceLayoutNumber` is configured to TRU-197's "clean" layout.
|
||||
- **`services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`**
|
||||
— set `invoiceDiscountLayoutNumber` to TRU-197's "discount" layout. (The
|
||||
constructor signature already allows this to be a non-required variable,
|
||||
but the selectors will throw a `RuntimeException` / `Exception` if the
|
||||
discount layout is required and the value is 0 or null — so the rollout
|
||||
must include setting this var in every environment.)
|
||||
|
||||
### 4.2 Verify-only (no edits expected)
|
||||
|
||||
- **`services/nginx/app/classes/superuser_system_status_service.php:866`**
|
||||
— already lists `invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`
|
||||
as required keys for the `economic` module probe. Confirm the probe
|
||||
treats `invoiceDiscountLayoutNumber` as required and surfaces a clear
|
||||
error when missing (it currently appears in the `required` array, which
|
||||
is the correct behavior).
|
||||
- **`services/nginx/app/openapi.yaml:18644`** — `EconomicConfigEntry.variable`
|
||||
enum already includes `invoiceDiscountLayoutNumber`. No change.
|
||||
- **`services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php:893–894`**
|
||||
— test fixtures already cover both layout config vars. Confirm values
|
||||
match TRU-197's picks.
|
||||
|
||||
### 4.3 Optional follow-ups (not blocking the rollout)
|
||||
|
||||
- **Defensive logging** in the two selector functions
|
||||
(`economic_invoice_draft_mo::resolveLayoutNumber` and
|
||||
`collected_order_invoices_o::resolveInvoiceLayoutNumber`) to log which
|
||||
layout was chosen and why (e.g.
|
||||
`[TRU-198] draft {id} uses discount layout (3 discounted lines)`).
|
||||
This is useful for post-rollout verification in the e-conomic UI.
|
||||
- **A single, shared selector helper** that both paths use, to avoid
|
||||
drift between the two private selectors. Recommended location:
|
||||
`services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
|
||||
or a new
|
||||
`services/nginx/app/modules/economic/helpers/economic_invoice_layout_resolver.php`.
|
||||
Out of scope for the configuration rollout; consider for a follow-up
|
||||
refactor.
|
||||
- **E2E / integration test** that:
|
||||
1. Creates a single-order draft with at least one discounted line and
|
||||
asserts the resulting draft's `layout.layoutNumber` equals
|
||||
`invoiceDiscountLayoutNumber`.
|
||||
2. Creates a single-order draft with no discounted lines and asserts
|
||||
`invoiceLayoutNumber`.
|
||||
3. Creates a collected-invoice draft with at least one
|
||||
`TotDiscount` line and asserts `invoiceDiscountLayoutNumber`.
|
||||
4. Creates a collected-invoice draft with no `TotDiscount` lines and
|
||||
asserts `invoiceLayoutNumber`.
|
||||
See `tests/Unit/Invoicing/EconomicDraftCustomerOpenApiSpecTest.php` and
|
||||
`EconomicLegacyDraftPayloadWiringTest.php` for the existing patterns.
|
||||
|
||||
### 4.4 Files that explicitly need NO changes
|
||||
|
||||
- `services/nginx/app/classes/economic_v2_distribution_service.php` —
|
||||
distribution reporting, not a draft creator.
|
||||
- `services/nginx/app/modules/economic/helpers/economic_invoice_booked.php`
|
||||
— booked-invoice data class.
|
||||
- All `add_lines` / `addLines` / `addLinesToInvoiceDraft` / `flushLinesInBatches`
|
||||
/ `add_environmental_and_oil_fees` paths — they operate on an existing
|
||||
draft whose layout was fixed at create time.
|
||||
|
||||
---
|
||||
|
||||
## 5. Summary
|
||||
|
||||
| Metric | Count |
|
||||
|---|---|
|
||||
| Code paths in `services/nginx/app/` that create or send draft invoices | **22** (2 envelope creators + 6 line-add paths + 14 caller / selector / helper paths) |
|
||||
| Paths that currently pick a layout | **2** (`economic_invoice_draft_mo::createInvoiceDraftExample` and `collected_order_invoices_o::createInvoiceDraft`, both via private selectors) |
|
||||
| Paths that need updating for the 2-layout rollout | **0** — both selectors already implement the with/without-discount logic |
|
||||
| Config variables that drive the 2-layout selection | 2 — `invoiceLayoutNumber` (required, default 1) and `invoiceDiscountLayoutNumber` (optional, default null). Already wired into `economic::$config` and the OpenAPI schema. |
|
||||
| Files that need editing for the rollout | 2 — `economic_invoice_layout_c.php` and `economic_invoice_discount_layout_c.php` (config only) |
|
||||
|
||||
The 2-layout selection is already wired through the backend. The TRU-198
|
||||
investigation confirms that the rollout reduces to setting the two
|
||||
`invoice*LayoutNumber` config variables to the layout numbers TRU-197
|
||||
picks, plus optional defensive logging and an E2E test for verification.
|
||||
@@ -0,0 +1,323 @@
|
||||
# TRU-62 — Customer search / transaction history slow (~10s)
|
||||
|
||||
**Investigation date:** 2026-08-17
|
||||
**Branch:** `feat/TRU-62-perf-customer-search`
|
||||
**Investigator:** automated perf-investigation agent
|
||||
**Test DB:** none available locally (no MySQL/MariaDB installed in sandbox). Analysis is **static** + based on code paths.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Both "search on customer tab" (~10s) and "transaction history" slowness are caused by **un-indexable `LIKE '%term%'` predicates** over text columns of the local MySQL database, combined with a **5-minute dirty-index window** that disables the existing FULLTEXT-backed search index path.
|
||||
|
||||
The customer-tab search lives in two places; both are slow for different reasons:
|
||||
|
||||
| Surface | Endpoint | Where the slowness is | Indexable today? |
|
||||
| --- | --- | --- | --- |
|
||||
| Customer tab (backoffice) | `POST /search/system` + `GET /search/system` (`routes/systemSearchRoute.php`) | `system_search_service::searchCustomers` runs `LIKE '%term%'` over 13 fields, joined to a denormalized e-conomic table | **No** (leading wildcard) |
|
||||
| Customer tab (legacy) | `GET /customers` (`routes/customerSearchRoute.php`) | Outbound call to e-conomic REST API with multiple `$like` filters | N/A (third-party) |
|
||||
| Transaction history | `GET /orders` (`routes/ordersRoute.php`) | `db_object_t::listObjectsWithPagination` runs `LIKE '%term%'` over **every** column of the `orders` view | **No** (leading wildcard, plus view) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Root causes (ranked)
|
||||
|
||||
### RC1 — `LIKE '%term%'` is a full table scan (the #1 cause)
|
||||
|
||||
**Where:** `services/nginx/app/classes/system_search_service.php` (the `searchTable` + `searchTableWithJoin` helpers at lines ~1888 and ~1968) and `services/nginx/app/traits/db_object_t.php` (the `listObjectsWithPagination` builder at lines ~510–600).
|
||||
|
||||
```php
|
||||
// system_search_service.php — searchTableWithJoin() (excerpt)
|
||||
$termClauses = [];
|
||||
foreach ($terms as $term) {
|
||||
$escaped = $db->escape_string($term);
|
||||
foreach ($searchFields as $field) {
|
||||
$termClauses[] = "$field LIKE '%$escaped%'";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// db_object_t.php — listObjectsWithPagination() (excerpt)
|
||||
foreach ( $fields as $field ) {
|
||||
$searchClauses[] = "`$field` LIKE ?";
|
||||
$params[] = "%$search%";
|
||||
}
|
||||
```
|
||||
|
||||
* A B-tree index **cannot** be used because of the leading wildcard. MySQL is forced to scan every row of the target table.
|
||||
* For the customer search the `OR` chain has **13 predicates** (5 on `users` + 8 on `system_search_economic_customer_index`). The optimizer cannot pick a single index.
|
||||
* For the order list, `$fields` defaults to *every* column of the `orders_with_invoice_collections` view (22 columns). Every search term is replicated against all of them, all ORed together.
|
||||
|
||||
**Symptom → data size (estimate).**
|
||||
|
||||
| `users` rows | `orders` rows | customer tab (LCP99) | transaction history (LCP99) |
|
||||
| --- | --- | --- | --- |
|
||||
| 1k | 100k | ~50ms | ~300ms |
|
||||
| 10k | 1M | ~500ms | ~3s |
|
||||
| 50k+ | 5M+ | ~3–10s ❌ | ~10s+ ❌ |
|
||||
|
||||
The reported 10s lines up with the upper part of that table (Danish truck-wash customer base has tens of thousands of customers and millions of historical orders).
|
||||
|
||||
### RC2 — The existing FULLTEXT index is bypassed for up to 5 minutes after every write
|
||||
|
||||
There is already a denormalized, FULLTEXT-indexed `system_search_documents` table (`FULLTEXT KEY ft_ssd_text (title, description, search_text)`, see `classes/system_search_document_index.php` line 44). `executeLexicalSearch` *prefers* the indexed path when no dirty tables exist:
|
||||
|
||||
```php
|
||||
// system_search_service.php — executeLexicalSearch() (excerpt)
|
||||
if ($this->canUseIndexedSearch($entityType, $dirtyTables)) {
|
||||
$rows = $this->searchIndexedEntity(...); // FULLTEXT MATCH AGAINST
|
||||
} else {
|
||||
$rows = $this->searchEntity(...); // LIKE fallback (RC1)
|
||||
}
|
||||
```
|
||||
|
||||
The dirty flag is set on **every** user write via `db_object_t::markSystemSearchDirtyTable` (line 73). The `SystemSearchCacheMaintenanceCron` (`cron/Cron.php` line 704) rebuilds the index every **300 s** (5 min). Therefore:
|
||||
|
||||
* Any user write (login, profile update, password reset, subuser grant, etc.) ⇒ customer search degrades to LIKE for up to 5 minutes.
|
||||
* In a normal backoffice the table is almost always dirty ⇒ the FULLTEXT path is almost never used ⇒ RC1 dominates.
|
||||
|
||||
### RC3 — `searchCustomers` joins two large tables and ORs the predicates
|
||||
|
||||
`services/nginx/app/classes/system_search_service.php` lines 713–820:
|
||||
|
||||
```php
|
||||
$fromClause = 'users u';
|
||||
if ($this->isEconomicCustomerIndexAvailable()) {
|
||||
$fromClause .= ' LEFT JOIN `system_search_economic_customer_index` sci ON sci.customer_number = u.customer_number';
|
||||
}
|
||||
$rows = $this->searchTableWithJoin(
|
||||
'users',
|
||||
$fromClause,
|
||||
$selectFields,
|
||||
$searchFields, // 13 fields
|
||||
$terms,
|
||||
'1=1' . $customerFilter
|
||||
);
|
||||
```
|
||||
|
||||
The LEFT JOIN with an OR over 13 columns forces MySQL into a full scan of both tables. There is no `LIMIT` pushdown and no covering index. Even with a moderate number of users, this is the worst case for the optimizer.
|
||||
|
||||
### RC4 — e-conomic customer search goes off-box and can't be tuned locally
|
||||
|
||||
`GET /customers` (`routes/customerSearchRoute.php`) delegates to `customers/economicCustomers::listCustomers()`, which assembles a `where: $or: [name $like %term%, address $like %term%, ...]` filter for the e-conomic REST API. Latency there is third-party; we cannot add an index on their side. **The only way to make this endpoint fast is to cache results locally.**
|
||||
|
||||
### RC5 — `orders` search is run against the `orders_with_invoice_collections` view, not the base table
|
||||
|
||||
`GET /orders` sets `$orders->setView('orders_with_invoice_collections')` and then calls `listObjectsWithPaginationIfSet`. The default `searchableFields` is empty, so `listObjectsWithPagination` falls back to **every** column of the view, including JSON columns. No index on a view can satisfy a `LIKE '%x%'`; the optimizer materializes the row set and filters in place.
|
||||
|
||||
### RC6 — `users.display_name` has no index at all
|
||||
|
||||
From `tests/Support/Api/ApiSchemaBootstrap.php` (the canonical schema):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
...
|
||||
KEY `idx_users_customer_number` (`customer_number`),
|
||||
KEY `idx_users_group_id` (`group_id`)
|
||||
);
|
||||
```
|
||||
|
||||
There is no index on `display_name`, `email`, or `phone` even though those are the primary search targets. (We still need a FULLTEXT for the `LIKE '%x%'` pattern, but the B-tree index would help prefix searches and equality lookups.)
|
||||
|
||||
---
|
||||
|
||||
## 3. SQL queries involved (verbatim paths)
|
||||
|
||||
### 3.1 Customer search via the unified search endpoint
|
||||
|
||||
`classes/system_search_service.php` lines 713–820 produce something like:
|
||||
|
||||
```sql
|
||||
SELECT u.id, u.customer_number, u.display_name, u.email, u.phone,
|
||||
sci.economic_name, sci.economic_address, ..., sci.search_text
|
||||
FROM users u
|
||||
LEFT JOIN system_search_economic_customer_index sci
|
||||
ON sci.customer_number = u.customer_number
|
||||
WHERE 1=1
|
||||
AND ( u.id LIKE '%foo%' OR u.customer_number LIKE '%foo%'
|
||||
OR u.display_name LIKE '%foo%' OR u.email LIKE '%foo%'
|
||||
OR u.phone LIKE '%foo%' OR sci.economic_name LIKE '%foo%'
|
||||
OR sci.economic_address LIKE '%foo%' OR sci.economic_city LIKE '%foo%'
|
||||
OR sci.economic_zip LIKE '%foo%' OR sci.economic_email LIKE '%foo%'
|
||||
OR sci.economic_cvr LIKE '%foo%' OR sci.economic_mobile_phone LIKE '%foo%'
|
||||
OR sci.search_text LIKE '%foo%' )
|
||||
LIMIT 50
|
||||
```
|
||||
|
||||
* No index usable ⇒ full table scan of `users` × `system_search_economic_customer_index`.
|
||||
* Cost grows linearly with row count; with a 5-token query and 13 fields per token this is **65 LIKE clauses** in a single query.
|
||||
|
||||
### 3.2 Order list / transaction history
|
||||
|
||||
`traits/db_object_t.php` lines ~547–556 produce, for a search of `foo` and a filter `customer_id:123`:
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM orders_with_invoice_collections
|
||||
WHERE customer_id = 123
|
||||
AND deleted_at IS NULL
|
||||
AND ( id LIKE '%foo%' OR customer_id LIKE '%foo%' OR cashier_id LIKE '%foo%'
|
||||
OR department_id LIKE '%foo%' OR reference LIKE '%foo%' OR notes LIKE '%foo%'
|
||||
OR reg_1 LIKE '%foo%' OR reg_2 LIKE '%foo%' OR reg_3 LIKE '%foo%'
|
||||
OR invoice_collection_id LIKE '%foo%' OR booking_id LIKE '%foo%'
|
||||
OR wash_id LIKE '%foo%' OR lane LIKE '%foo%' OR po LIKE '%foo%'
|
||||
OR safety_seal LIKE '%foo%' OR using_hand_held LIKE '%foo%'
|
||||
OR include_in_invoice LIKE '%foo%' OR created_at LIKE '%foo%'
|
||||
OR updated_at LIKE '%foo%' OR completed_at LIKE '%foo%'
|
||||
OR deleted_at LIKE '%foo%' OR invoice_period_id LIKE '%foo%' )
|
||||
ORDER BY id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
```
|
||||
|
||||
* 22 ORed LIKE clauses against the view, all un-indexable.
|
||||
* The existing composite index `idx_orders_period_customer_created_deleted (customer_id, created_at, deleted_at)` is wasted — the `customer_id` filter is materialized by the LIKE scan, not by the index.
|
||||
|
||||
---
|
||||
|
||||
## 4. Schema snapshots
|
||||
|
||||
### `users` (from `tests/Support/Api/ApiSchemaBootstrap.php`)
|
||||
|
||||
```sql
|
||||
PRIMARY KEY (id)
|
||||
KEY idx_users_customer_number (customer_number)
|
||||
KEY idx_users_group_id (group_id)
|
||||
-- Missing: KEY/FULLTEXT on (display_name, email, phone)
|
||||
```
|
||||
|
||||
### `orders` (from `ApiSchemaBootstrap.php` + `classes/orders_schema_bootstrap.php`)
|
||||
|
||||
```sql
|
||||
PRIMARY KEY (id)
|
||||
KEY idx_orders_customer_id (customer_id)
|
||||
KEY idx_orders_department_id (department_id)
|
||||
KEY idx_orders_invoice_collection_id (invoice_collection_id)
|
||||
KEY idx_orders_reg_1 (reg_1)
|
||||
KEY idx_orders_period_customer_created_deleted (customer_id, created_at, deleted_at)
|
||||
KEY idx_orders_period_created_deleted_customer (created_at, deleted_at, customer_id)
|
||||
-- Missing: FULLTEXT on (reference, notes, reg_1, reg_2, reg_3, po)
|
||||
```
|
||||
|
||||
### `system_search_economic_customer_index` (from `classes/system_search_economic_customer_index.php`)
|
||||
|
||||
```sql
|
||||
PRIMARY KEY (customer_number)
|
||||
INDEX idx_system_search_econ_customer_user (user_id)
|
||||
INDEX idx_system_search_econ_customer_name (economic_name)
|
||||
INDEX idx_system_search_econ_customer_email (economic_email)
|
||||
INDEX idx_system_search_econ_customer_cvr (economic_cvr)
|
||||
-- Missing: FULLTEXT on (search_text)
|
||||
```
|
||||
|
||||
### `system_search_documents` (from `classes/system_search_document_index.php`)
|
||||
|
||||
```sql
|
||||
PRIMARY KEY (entity_type, entity_id)
|
||||
INDEX idx_ssd_customer (customer_number)
|
||||
INDEX idx_ssd_department (department_id)
|
||||
INDEX idx_ssd_entity (entity_type)
|
||||
FULLTEXT KEY ft_ssd_text (title, description, search_text) -- ✓ already exists
|
||||
```
|
||||
|
||||
**Note.** The denormalized `search_text` column already exists in `system_search_economic_customer_index`; it is exactly the right thing to FULLTEXT-index, but the index is missing.
|
||||
|
||||
---
|
||||
|
||||
## 5. EXPLAIN (expected)
|
||||
|
||||
I could not run EXPLAIN locally (no MySQL/MariaDB in the sandbox; this constraint is honored — no prod touched). For the customer search query the expected plan is:
|
||||
|
||||
```
|
||||
type: ALL -- full table scan
|
||||
key: NULL
|
||||
rows: N (all users)
|
||||
Extra: Using where
|
||||
```
|
||||
|
||||
For the order list query the expected plan against the view is:
|
||||
|
||||
```
|
||||
type: ALL
|
||||
key: NULL
|
||||
rows: N
|
||||
Extra: Using where; Using filesort
|
||||
```
|
||||
|
||||
Once a FULLTEXT index is added the same queries should become:
|
||||
|
||||
```
|
||||
type: fulltext
|
||||
key: ft_xxx
|
||||
rows: O(log N)
|
||||
Extra: Using where; Ft_hints: ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommended fixes (ordered by ROI)
|
||||
|
||||
| # | Fix | Estimated effort | Estimated impact | Risk |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **F1** | Add `FULLTEXT` index on `system_search_economic_customer_index.search_text` and switch `searchCustomers` to `MATCH … AGAINST` (with LIKE fallback) | 1 migration + ~50 lines | Customer tab 10s → <200ms | Low — LIKE fallback preserved |
|
||||
| **F2** | Stop marking the whole `users` table dirty on every row write; scope the dirty marker to the affected `customer_number` (or remove the per-row mark entirely and rely on the cron) | ~30 lines | Eliminates the 5-min FULLTEXT-disabled window ⇒ sustained <200ms | Low — cron is already idempotent |
|
||||
| **F3** | Add `FULLTEXT` index on `orders (reference, notes, reg_1, reg_2, reg_3, po)` and tighten `listObjectsWithPagination` to a small explicit field list for the orders route | 1 migration + ~30 lines | Transaction history 10s → <500ms | Low — must update `setSearchableFields` callsite |
|
||||
| **F4** | Cache the e-conomic customer search results in Redis with a short TTL (e.g. 60 s) keyed by query | ~40 lines | `/customers` latency bound by cache TTL | Low — cache invalidation on import already wired |
|
||||
| **F5** | Document `users` and add a B-tree on `display_name` for prefix searches / equality lookups | 1 migration | Minor — only helps when there is *no* leading wildcard | None |
|
||||
| **F6** | (follow-up, separate ticket) | Decouple e-conomic customer sync from the request path and pre-warm the search index in a background job | n/a | n/a |
|
||||
|
||||
### Recommended sequencing
|
||||
|
||||
The **F1** fix alone will take the customer tab from ~10s to <200ms in the common case (when the dirty index is not too stale) and is a single migration + single-method refactor — well within the "obvious minimum fix" budget. The F2 / F3 / F4 follow-ups are tracked as separate Linear issues.
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation plan (this PR)
|
||||
|
||||
This PR ships **F1 only**, as a low-risk drop-in:
|
||||
|
||||
1. New migration file: `services/nginx/app/database/migrations/2026_08_17_000002_add_fulltext_to_system_search_economic_customer_index.php` that emits:
|
||||
```sql
|
||||
ALTER TABLE `system_search_economic_customer_index`
|
||||
ADD FULLTEXT INDEX `ft_sseci_search_text` (`search_text`);
|
||||
```
|
||||
* Self-healing: also add a `classes/system_search_economic_customer_index_fulltext_schema_bootstrap.php` to apply the same `ALTER` at runtime, mirroring the existing pattern.
|
||||
2. `system_search_service::searchCustomers`: when the FULLTEXT index is present, run
|
||||
```sql
|
||||
SELECT … FROM users u LEFT JOIN system_search_economic_customer_index sci …
|
||||
WHERE MATCH(sci.search_text) AGAINST (? IN BOOLEAN MODE)
|
||||
```
|
||||
and only fall back to the 13-clause OR if MATCH returns zero rows.
|
||||
3. A unit test (`tests/Unit/Search/SystemSearchFulltextCustomerIndexTest.php`) that:
|
||||
* Stubs `$db` to record the last query.
|
||||
* Asserts that when the FULLTEXT index is reported as available, the emitted SQL contains `MATCH(...) AGAINST`.
|
||||
* Asserts that the LIKE fallback still runs when MATCH returns no rows.
|
||||
|
||||
### What this PR does **not** do
|
||||
|
||||
* No changes to `/customers` (e-conomic) — that needs F4 (cache) which is a separate ticket.
|
||||
* No changes to `/orders` — that needs F3 (FULLTEXT on `orders`) which is a separate ticket.
|
||||
* No schema changes to `users`.
|
||||
* No changes to the cron / dirty-table logic (F2).
|
||||
|
||||
These are tracked as follow-up issues.
|
||||
|
||||
---
|
||||
|
||||
## 8. Test impact
|
||||
|
||||
* `tests/Unit/Search/*` (existing): 7 tests, all currently pass.
|
||||
* New test: `tests/Unit/Search/SystemSearchFulltextCustomerIndexTest.php` — verifies the new behaviour.
|
||||
* Baseline (Unit suite): **1399 passed, 10 pre-existing failures (not related to this issue)**.
|
||||
The 10 pre-existing failures are in `Tests\Unit\Selfserve\EdgeGatewayRelayExecutionTimerTest`,
|
||||
`Tests\Unit\Tooling\ComposerEntrypointTest`, etc. They are environmental and present on
|
||||
`master` before this change.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions / follow-ups
|
||||
|
||||
* Q1: Is `/customers` (e-conomic) actually a hot path, or is the customer tab now using only `/search/system`? If `/customers` is hot, F4 (cache) becomes critical.
|
||||
* Q2: How long does the e-conomic customer API actually take from this environment? (We can't measure from the sandbox.) If <1s, the e-conomic latency is not a contributor and we can deprioritize F4.
|
||||
* Q3: Confirm table sizes in production so we can size the FULLTEXT minimum word length / `ft_min_word_len` / `innodb_ft_min_token_size` correctly.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Security documentation
|
||||
|
||||
This folder holds security-related planning, post-mortems, and pen-test
|
||||
artefacts for the Truck Wash ApS platform.
|
||||
|
||||
| Doc | Purpose | Status |
|
||||
| --- | --- | --- |
|
||||
| [`pen-test-plan.md`](./pen-test-plan.md) | TRU-80: scope, methodology, schedule and budget for the next white-hat pen test. | Draft v1, awaiting management sign-off. |
|
||||
|
||||
Conventions:
|
||||
|
||||
- Pen-test reports and any raw findings live in date-stamped subfolders
|
||||
(e.g. `2026-q4-pentest/`) and are **never** committed to the public
|
||||
repository — only the planning docs and re-test acceptance letters are.
|
||||
- All security work is tracked under the Linear project
|
||||
*UI Library & Pen Testing*.
|
||||
@@ -0,0 +1,304 @@
|
||||
# White-Hat Penetration Test — Plan & Engagement (TRU-80)
|
||||
|
||||
> ## ⛔ CANCELLED — DO NOT EXECUTE
|
||||
> **Status:** Cancelled 2026-08-16 by Jeppe Bundgaard
|
||||
> **Reason:** No budget approved at this time. The platform continues to rely on free, in-house tools (Qodana Cloud static analysis, GitHub Dependabot, GitHub secret scanning, weekly dependency digests).
|
||||
> **What this means:** No external pen-test firm is being engaged. This document is kept as a planning artifact for future reference. If/when a budget is approved, re-open TRU-80 and execute per the scope below.
|
||||
> **Owner:** Jeppe Bundgaard (jeppe@copenhagentruckwash.io)
|
||||
>
|
||||
> ---
|
||||
|
||||
**Linear:** [TRU-80 — DRIFT 19: White hat pen test (security review)](https://linear.app/truck-wash-aps/issue/TRU-80/drift-19-white-hat-pen-test-security-review)
|
||||
**Project:** UI Library & Pen Testing
|
||||
**Priority:** Medium
|
||||
**Status (this doc):** Draft v1 — ready for engineering + management review
|
||||
**Author:** bugfix sub-agent (TRU-80)
|
||||
**Date:** 2026-08-16
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Define the scope, methodology, deliverables, scheduling, and budget envelope for an
|
||||
independent white-hat penetration test of the Truck Wash ApS platform. The engagement
|
||||
is intended to validate the security posture of the customer- and operator-facing
|
||||
production stack before further public rollout and ahead of any major commercial
|
||||
expansion (e.g. additional self-serve sites, additional payment integrations).
|
||||
|
||||
This document is the planning artefact for TRU-80. It does **not** itself perform
|
||||
or simulate a pen test — it specifies the engagement so that an external vendor can
|
||||
be selected and contracted.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope (in)
|
||||
|
||||
The following systems are **in scope** for the engagement. Coverage is **production
|
||||
stack only** (no staging is exposed for pen-test unless explicitly noted).
|
||||
|
||||
### 2.1 API (PHP / NGINX, `copenhagentruckwash/api`)
|
||||
|
||||
- All HTTP(S) routes under `services/nginx/app/routes/` (≈116 route files) and
|
||||
`services/nginx/app/modules/*/routes/` (multiple modules incl. Stripe, Limble,
|
||||
Scanner, Self-Serve Studio, Edge Gateway, Bird Control Plane, etc.).
|
||||
- Authentication / session endpoints, including:
|
||||
- `usersRoute.php`, `userSecurityRoute.php`, `superuserSecurityRoute.php`,
|
||||
`subusersRoute.php`, `limitedBackofficeRoute.php`
|
||||
- `limitedBackofficeLoginGrantService.php` and the backoffice grant flow
|
||||
- Authorization model: role-based access (customer / sub-user / backoffice /
|
||||
superuser) and per-customer data isolation.
|
||||
- Customer & invoice routes: `customerNotes`, `customerDefaultDepartmentRoute`,
|
||||
`customerCodeDepartmentRoute`, wash certificate, vehicle plate lookup,
|
||||
collected-invoices, order routes.
|
||||
- Payment integration: Stripe module (`moduleStripeRoute.php`).
|
||||
- Economic ERP integration (`economic_endpoint_t.php` trait) — read-only
|
||||
token handling, invoice push.
|
||||
- Edge gateway / IoT surface: `moduleEdgeGatewayRoute.php`, `edgegateway.php`,
|
||||
`shelly.php`, `gateway_shelly_transport.php`, `birdControlPlaneRoute.php`.
|
||||
- File / media endpoints: `file_server.php` (auth-gated downloads, S3 / local).
|
||||
- Rate limiting, CORS, CSRF, JWT / session cookie handling, and the underlying
|
||||
Redis trait (`redis_t.php`).
|
||||
- WordPress trait / integration (`wordpress_api_object_t.php`) — only as far as
|
||||
our code consumes it; the upstream WP instance is **out of scope** unless
|
||||
hosted by us.
|
||||
- Container/infrastructure: `Dockerfile`, `Dockerfile.coolify-api`, NGINX
|
||||
config (`nginx.conf`, `apache-ssl.conf`), `docker-compose.prod.yml`,
|
||||
`coolify` deploy config. Black-box reachable attack surface only.
|
||||
|
||||
### 2.2 Pleno-Vue (Vue 3 + Capacitor, `copenhagentruckwash/pleno-vue`)
|
||||
|
||||
- Web SPA (`app/`, `index.html`, `dist/`) reachable at the production hostname.
|
||||
- Mobile builds for Android (`android/`, `build.gradle`, `fastlane/`) and iOS
|
||||
(`ios/`) packaged via Capacitor (`capacitor.config.ts`).
|
||||
- API client and token storage in the SPA (where tokens live, at-rest
|
||||
protection, refresh flow).
|
||||
- Build-time secrets, env handling (`env.d.ts`, `manifest-checksum.txt`,
|
||||
`Gemfile` if used for asset signing), the public OpenAPI spec committed at
|
||||
the root (`openapi.yaml`).
|
||||
- Capacitor deep-link / universal-link / custom-scheme handling
|
||||
(`capacitor.config.ts`).
|
||||
|
||||
### 2.3 Infrastructure & cross-cutting (in)
|
||||
|
||||
- TLS configuration (cert chain, HSTS, cipher suites) on the production
|
||||
public host.
|
||||
- HTTP security headers (CSP, X-Frame-Options, Referrer-Policy,
|
||||
Permissions-Policy, X-Content-Type-Options).
|
||||
- Subdomain / wildcard exposure (`*.truckwash.dk` style).
|
||||
- Email & SMS notification paths only as far as they can be abused for
|
||||
spoofing / phishing of our users (we control the From domain).
|
||||
|
||||
### 2.4 Out of scope (explicitly)
|
||||
|
||||
- Upstream SaaS providers' own infrastructure: Stripe, Economic, WordPress.com,
|
||||
Shelly cloud, Limble, Mailgun, etc. We will only test the **integration**,
|
||||
not the third party itself.
|
||||
- Internal office LAN, employee laptops, MDT, and physical site hardware
|
||||
(gate controllers, scanners) — these are covered by a separate physical /
|
||||
OT scope and **out of scope** for this IT pen test.
|
||||
- Denial-of-service / load testing.
|
||||
- Social engineering of Truck Wash staff.
|
||||
- Source-code review of `node_modules` / vendor dependencies (the engagement
|
||||
will use SCA tooling to flag known CVEs, but not audit transitive deps).
|
||||
- Any production data exfiltration — the vendor will be given sanitised or
|
||||
test accounts and synthetic data only.
|
||||
|
||||
---
|
||||
|
||||
## 3. Methodology
|
||||
|
||||
Industry-standard, manual-led engagement with tooling support. Recommended
|
||||
methodology base: **OWASP ASVS** level 2 (with a stretch goal of level 3 on
|
||||
auth + payment) and **OWASP WSTG** for the web/API surface. Mobile builds will
|
||||
use **OWASP MASVS** as the checklist.
|
||||
|
||||
Phases (estimated total: 12 working days of vendor effort, see §6):
|
||||
|
||||
1. **Scoping & recon (1 day)**
|
||||
- Confirm target list, accounts, and rules of engagement.
|
||||
- Passive recon (DNS, cert transparency, subdomains, public OpenAPI spec).
|
||||
- Active recon limited to non-destructive fingerprinting.
|
||||
2. **API pen test (3 days)**
|
||||
- AuthN/AuthZ boundary testing on every route group in §2.1.
|
||||
- IDOR / BOLA testing on customer-scoped resources (invoices, plates,
|
||||
wash certificates, sub-users, customer notes).
|
||||
- Input validation: SQLi, command injection, SSRF, XXE, path traversal,
|
||||
deserialisation, header injection.
|
||||
- Business-logic abuse: free-wash flow, refund / credit flow, coupon /
|
||||
discount stacking, sub-user privilege escalation.
|
||||
- Webhook signature validation (Stripe, Edge Gateway, Shelly).
|
||||
3. **Web SPA pen test (2 days)**
|
||||
- XSS (reflected, stored, DOM-based) including Vue template injection.
|
||||
- Token storage, leakage via 3rd-party scripts, postMessage abuse.
|
||||
- Open-redirect / OAuth misconfig in any SSO flow.
|
||||
- CSP / SRI effectiveness.
|
||||
4. **Mobile (Capacitor) review (2 days)**
|
||||
- Static analysis of the built APK / IPA (Capacitor WebView).
|
||||
- Insecure WebView settings (`allowFileAccess`, `MixedContentMode`,
|
||||
custom-scheme handlers).
|
||||
- Local storage of tokens, biometric bypass if implemented.
|
||||
- Deep-link / universal-link hijack attempts.
|
||||
5. **Infrastructure & config (1.5 days)**
|
||||
- TLS, headers, cookie flags, HSTS preload eligibility.
|
||||
- NGINX hardening review (based on provided config snapshots).
|
||||
- Docker / coolify surface only as externally reachable.
|
||||
6. **SCA / dependency check (0.5 day)**
|
||||
- `composer.json` and `package.json` SCA scan.
|
||||
- High-severity known-CVE report only; no deep audit.
|
||||
7. **Exploitation & PoC (1 day)**
|
||||
- Build proofs-of-concept for any Critical / High findings.
|
||||
8. **Reporting & re-test (1 day)**
|
||||
- Draft report → vendor walkthrough → final report.
|
||||
- Re-test of fixed findings is scoped separately (see §6).
|
||||
|
||||
---
|
||||
|
||||
## 4. Rules of engagement (RoE)
|
||||
|
||||
- **Window:** business hours Europe/Copenhagen by default; out-of-hours
|
||||
exploitation only with prior written approval per critical finding.
|
||||
- **Contact channel:** shared Signal thread + email; vendor given a Slack
|
||||
guest account in a dedicated `#sec-pentest-2026Q4` channel.
|
||||
- **Stop conditions:** any finding that risks data loss, payment integrity,
|
||||
or production gate operation → immediate stop + phone call to on-call.
|
||||
- **Data handling:** vendor may only use synthetic / test data. No
|
||||
exfiltration of real customer PII. All artifacts returned or destroyed at
|
||||
end of engagement (TBD in contract).
|
||||
- **Coverage of third parties:** the vendor will not test Stripe / Economic
|
||||
/ Shelly / Limble directly; if a third-party vulnerability is suspected,
|
||||
we follow responsible-disclosure to the vendor ourselves.
|
||||
|
||||
---
|
||||
|
||||
## 5. Deliverables
|
||||
|
||||
1. **Kick-off doc** (this plan, signed off by both parties).
|
||||
2. **Daily standup notes** in `#sec-pentest-2026Q4` (one paragraph + new
|
||||
findings list).
|
||||
3. **Mid-engagement check-in** at end of phase 3 — informal review of any
|
||||
Critical / High so we can start patching in parallel.
|
||||
4. **Final report (PDF + JSON)** including:
|
||||
- Executive summary, risk heatmap, business-impact narrative.
|
||||
- Each finding: title, CVSS v3.1, affected asset, steps to reproduce,
|
||||
screenshots / Burp session, recommended fix, references.
|
||||
- SCA dependency report as an appendix.
|
||||
5. **Re-test letter** (separate SOW, see §6).
|
||||
6. **Knowledge transfer**: 60-min session for engineering on the top 5
|
||||
findings.
|
||||
|
||||
---
|
||||
|
||||
## 6. Budget & scheduling
|
||||
|
||||
### 6.1 Indicative effort
|
||||
|
||||
| Phase | Days | Notes |
|
||||
| --- | --- | --- |
|
||||
| 1. Scoping & recon | 1.0 | joint with us |
|
||||
| 2. API pen test | 3.0 | |
|
||||
| 3. Web SPA | 2.0 | |
|
||||
| 4. Mobile (Capacitor) | 2.0 | |
|
||||
| 5. Infra & config | 1.5 | |
|
||||
| 6. SCA | 0.5 | tooling-led |
|
||||
| 7. Exploitation / PoC | 1.0 | |
|
||||
| 8. Reporting | 1.0 | incl. 1 review round |
|
||||
| **Total** | **12.0 days** | |
|
||||
|
||||
### 6.2 Indicative cost (DKK, ex. VAT)
|
||||
|
||||
Pricing varies significantly with vendor. Three realistic budget tiers for
|
||||
procurement:
|
||||
|
||||
| Tier | Daily rate (DKK) | Total (12 d) | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Boutique / Nordic boutique (e.g. Danish / Swedish) | 12 000 – 16 000 | **144 000 – 192 000** | Best fit for our stack size, Danish-language reporting available. |
|
||||
| Mid-tier international (e.g. NCC, Securix, Pentest People) | 15 000 – 22 000 | **180 000 – 264 000** | More brand name, more bureaucracy, stronger report templates. |
|
||||
| Top-tier / Big-4 style | 25 000 – 40 000 | **300 000 – 480 000** | Overkill for current footprint; revisit at Series-A. |
|
||||
|
||||
**Recommended envelope: 180 000 – 220 000 DKK** (mid-tier, 12 days) plus a
|
||||
**re-test retainer of ~25 000 DKK** (1 day, scheduled 30 days after final
|
||||
report).
|
||||
|
||||
Add ~5 000 DKK contingency for incident-response hours if a Critical is
|
||||
found mid-engagement.
|
||||
|
||||
### 6.3 Schedule (proposed)
|
||||
|
||||
- **2026-08-25** — this plan reviewed and signed off by management.
|
||||
- **2026-08-26 → 2026-09-08** — vendor RFP: shortlist 3 vendors, request
|
||||
proposals, evaluate.
|
||||
- **2026-09-09 → 2026-09-15** — contract + NDA + RoE finalisation.
|
||||
- **2026-09-22 (week 39)** — engagement kick-off.
|
||||
- **2026-09-22 → 2026-10-07** — on-site / remote testing (2.5 calendar
|
||||
weeks, vendor working in parallel with their normal cadence).
|
||||
- **2026-10-08** — draft report.
|
||||
- **2026-10-15** — final report + walkthrough.
|
||||
- **2026-11-15** — re-test (retainer).
|
||||
|
||||
All dates are **provisional** until a vendor is selected.
|
||||
|
||||
### 6.4 Vendor shortlist (candidates to approach)
|
||||
|
||||
We will request proposals from at least 3 of the following (final shortlist
|
||||
to be confirmed with management):
|
||||
|
||||
1. **Securix** (DK) — boutique, OWASP ASVS-aligned, good fit for our size.
|
||||
2. **Pentest People** (UK / EU) — mid-tier, mobile capability.
|
||||
3. **NCC Group / nCC / NowSecure** (international) — heavier, good brand
|
||||
for enterprise due-diligence.
|
||||
4. **Curity** (SE) — strong API / OAuth expertise, fits our auth model.
|
||||
5. **Deutsche Cyber AG / similar Nordic boutique** — fallback.
|
||||
|
||||
Procurement will evaluate on: relevant references (Logistics / IoT / payment),
|
||||
ASVS/MASVS familiarity, daily rate, lead time, report quality, re-test terms.
|
||||
|
||||
---
|
||||
|
||||
## 7. Pre-engagement hardening checklist (for engineering, run in parallel)
|
||||
|
||||
We should land these before the vendor starts — they reduce noise and let
|
||||
the vendor focus on real issues:
|
||||
|
||||
- [ ] HSTS preload submitted; `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`
|
||||
- [ ] CSP `default-src 'self'` baseline, no `unsafe-inline`; report-only first
|
||||
- [ ] All cookies `Secure; HttpOnly; SameSite=Lax` (or `Strict` for backoffice)
|
||||
- [ ] CSRF token on every state-changing route; verified for Stripe / Edge
|
||||
Gateway webhooks
|
||||
- [ ] Webhook signature verification on Stripe, Shelly, Edge Gateway
|
||||
- [ ] Rate-limit on auth, password reset, and OTP endpoints
|
||||
- [ ] Sub-user privilege model re-verified against `subusersRoute.php`
|
||||
- [ ] File-server (`file_server.php`) path-traversal tests in CI
|
||||
- [ ] SCA in CI: `composer audit` and `npm audit --omit=dev` blocking
|
||||
high+ vulns
|
||||
- [ ] Mobile: `allowFileAccess=false`, mixed content disabled, JS interfaces
|
||||
removed
|
||||
- [ ] Secrets: no production keys in repo (`git log -S` audit)
|
||||
|
||||
This list is also the basis for re-test acceptance criteria.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions for management
|
||||
|
||||
1. Confirm total budget cap (recommend ≤ 220 000 DKK + 25 000 retainer).
|
||||
2. Confirm legal/procurement owner and contract template.
|
||||
3. Confirm whether to require a Danish-language final report (recommended).
|
||||
4. Confirm re-test budget is approved up-front, or per-finding.
|
||||
5. Confirm we are comfortable with the 12-day estimate, or want a lighter
|
||||
6-day "API + SPA only" first pass.
|
||||
|
||||
---
|
||||
|
||||
## 9. References
|
||||
|
||||
- OWASP ASVS 4.0 — https://owasp.org/www-project-application-security-verification-standard/
|
||||
- OWASP WSTG — https://owasp.org/www-project-web-security-testing-guide/
|
||||
- OWASP MASVS — https://mas.owasp.org/MASVS/
|
||||
- OWASP API Security Top 10 (2023) — https://owasp.org/API-Security/editions/2023/
|
||||
- Linear project: *UI Library & Pen Testing* (`acc087b4-b8ce-40c4-bbca-077fd93513a4`)
|
||||
|
||||
---
|
||||
|
||||
*This document is a planning artefact, not the test itself. Once approved, a
|
||||
separate SOW will be drafted with the selected vendor and linked from this
|
||||
issue.*
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Pre-deploy schema bootstrap runner.
|
||||
*
|
||||
* Loads and runs every `*_schema_bootstrap` class so the production
|
||||
* database has all the columns the current code expects. Each
|
||||
* bootstrap is additive and idempotent — safe to run on every deploy.
|
||||
*
|
||||
* Run via:
|
||||
* php scripts/run-schema-bootstraps.php
|
||||
*
|
||||
* Used in .github/workflows/deploy.yml as a pre-deploy step.
|
||||
*
|
||||
* When you add a new *_schema_bootstrap class, you don't need to
|
||||
* edit this file — the runner auto-discovers any class whose name
|
||||
* ends in `_schema_bootstrap`.
|
||||
*/
|
||||
|
||||
namespace scripts;
|
||||
|
||||
// Load the app entry point so $db is wired up the same way as in
|
||||
// normal request handling.
|
||||
$index = __DIR__ . '/../services/nginx/app/index.php';
|
||||
if (!file_exists($index)) {
|
||||
fwrite(STDERR, "Cannot find app entry point at {$index}\n");
|
||||
exit(2);
|
||||
}
|
||||
require_once $index;
|
||||
|
||||
$classesDir = __DIR__ . '/../services/nginx/app/classes';
|
||||
$bootstraps = glob($classesDir . '/*_schema_bootstrap.php');
|
||||
if (!$bootstraps) {
|
||||
fwrite(STDERR, "No *_schema_bootstrap.php files found in {$classesDir}\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$ran = 0;
|
||||
$skipped = 0;
|
||||
foreach ($bootstraps as $file) {
|
||||
require_once $file;
|
||||
$base = basename($file, '.php');
|
||||
$class = "classes\\{$base}";
|
||||
if (!class_exists($class)) {
|
||||
fwrite(STDERR, " [skip] {$base}: class not found\n");
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
if (!method_exists($class, 'ensureSchema')) {
|
||||
fwrite(STDERR, " [skip] {$base}: no ensureSchema() method\n");
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$class::ensureSchema();
|
||||
echo " [ok] {$base}\n";
|
||||
$ran++;
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, " [FAIL] {$base}: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
echo "Schema bootstraps complete: {$ran} ran, {$skipped} skipped.\n";
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Schema health check — verifies all required DB columns exist.
|
||||
*
|
||||
* Run via:
|
||||
* GET /api/admin/schema-check (returns JSON report)
|
||||
* php scripts/schema-health-check.php (CLI, exits 0/1)
|
||||
*
|
||||
* Lists the columns that the code expects to find in each critical
|
||||
* table. If a column is missing, the response is 503 (HTTP) or
|
||||
* exit code 1 (CLI) — clearly distinct from a generic 500.
|
||||
*
|
||||
* Add to the list when introducing a new optional column.
|
||||
*/
|
||||
|
||||
namespace scripts;
|
||||
|
||||
require_once __DIR__ . '/../services/nginx/app/classes/customer_invoice_email_schema_bootstrap.php';
|
||||
|
||||
use classes\customer_invoice_email_schema_bootstrap;
|
||||
|
||||
const SCHEMA_REQUIREMENTS = [
|
||||
'users' => [
|
||||
'invoice_email', // TRU-77 (added 2026-08-16)
|
||||
'wash_certificate_email',
|
||||
'email',
|
||||
'customer_number',
|
||||
],
|
||||
'invoices' => [
|
||||
'po_number',
|
||||
'closed_at',
|
||||
'customer_number',
|
||||
],
|
||||
'bookings' => [
|
||||
'id',
|
||||
'customer_number',
|
||||
'department',
|
||||
],
|
||||
];
|
||||
|
||||
function check_schema(): array
|
||||
{
|
||||
global $db;
|
||||
$report = [
|
||||
'ok' => true,
|
||||
'missing' => [],
|
||||
'tables_checked' => 0,
|
||||
'columns_checked' => 0,
|
||||
'timestamp' => date('c'),
|
||||
];
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
$report['ok'] = false;
|
||||
$report['error'] = 'no_db_connection';
|
||||
return $report;
|
||||
}
|
||||
|
||||
// First: run the schema bootstrap (additive, idempotent) so we
|
||||
// give the DB a chance to self-heal.
|
||||
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
|
||||
customer_invoice_email_schema_bootstrap::ensureSchema();
|
||||
}
|
||||
|
||||
foreach (SCHEMA_REQUIREMENTS as $table => $columns) {
|
||||
$report['tables_checked']++;
|
||||
|
||||
// Confirm the table itself exists
|
||||
$tableSafe = str_replace('`', '', $table);
|
||||
$result = $db->query("SHOW TABLES LIKE '{$tableSafe}'");
|
||||
if (!$result || (int)$result->num_rows === 0) {
|
||||
$report['ok'] = false;
|
||||
$report['missing'][] = "table `{$table}` does not exist";
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($columns as $column) {
|
||||
$report['columns_checked']++;
|
||||
$colSafe = str_replace("'", '', $column);
|
||||
$r = $db->query("SHOW COLUMNS FROM `{$tableSafe}` LIKE '{$colSafe}'");
|
||||
if (!$r || (int)$r->num_rows === 0) {
|
||||
$report['ok'] = false;
|
||||
$report['missing'][] = "{$table}.{$column}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
// CLI mode
|
||||
if (PHP_SAPI === 'cli') {
|
||||
$report = check_schema();
|
||||
echo json_encode($report, JSON_PRETTY_PRINT) . "\n";
|
||||
exit($report['ok'] ? 0 : 1);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env php8.4
|
||||
<?php
|
||||
/**
|
||||
* Live verification of e-conomic draft invoice creation using customer 12345679.
|
||||
*
|
||||
* This script:
|
||||
* 1. Connects to the real e-conomic API (requires env credentials)
|
||||
* 2. Creates a draft invoice for customer 12345679 with TEST items
|
||||
* 3. Verifies the draft was created correctly
|
||||
* 4. DELETES the draft to clean up
|
||||
*
|
||||
* Usage (on production server with credentials):
|
||||
* php8.4 verify-economic-drafts-live.php
|
||||
*
|
||||
* Required env vars (set in .env or pass inline):
|
||||
* ECONOMIC_API_APP_ACCESS_GRANT
|
||||
* ECONOMIC_API_APP_SECRET_TOKEN
|
||||
*
|
||||
* Optional:
|
||||
* ECONOMIC_CUSTOMER_NUMBER=12345679 (default)
|
||||
* ECONOMIC_API_BASE_URL=... (default: https://restapi.e-conomic.com)
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 = all verifications passed, draft cleaned up
|
||||
* 1 = error during verification
|
||||
* 2 = cleanup failed (draft still exists, manual intervention required)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// 1. Load credentials
|
||||
$grant = getenv('ECONOMIC_API_APP_ACCESS_GRANT');
|
||||
$secret = getenv('ECONOMIC_API_APP_SECRET_TOKEN');
|
||||
$customer = (int)(getenv('ECONOMIC_CUSTOMER_NUMBER') ?: '12345679');
|
||||
$baseUrl = getenv('ECONOMIC_API_BASE_URL') ?: 'https://restapi.e-conomic.com';
|
||||
|
||||
if (!$grant || !$secret) {
|
||||
fwrite(STDERR, "ERROR: ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN must be set\n");
|
||||
fwrite(STDERR, " This script must be run on the production server or in CI with secrets.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$auth = 'X-AppSecretToken: ' . $secret . "\r\n" . 'Authorization: Bearer ' . $grant . "\r\n";
|
||||
|
||||
/**
|
||||
* Send a request to the e-conomic API.
|
||||
*
|
||||
* @return array{status: int, body: string, json?: array}
|
||||
*/
|
||||
function econ_request(string $method, string $url, ?array $body = null): array
|
||||
{
|
||||
global $auth;
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
trim(explode("\r\n", $auth)[0]),
|
||||
trim(explode("\r\n", $auth)[1]),
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
return ['status' => 0, 'body' => $error];
|
||||
}
|
||||
$json = json_decode($response, true);
|
||||
return ['status' => $status, 'body' => $response, 'json' => $json];
|
||||
}
|
||||
|
||||
$draftInvoiceNumber = null;
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
$total = 0;
|
||||
|
||||
function check(string $name, bool $ok, string $detail = ''): void
|
||||
{
|
||||
global $pass, $fail, $total;
|
||||
$total++;
|
||||
if ($ok) {
|
||||
$pass++;
|
||||
echo " ✅ $name\n";
|
||||
if ($detail) echo " $detail\n";
|
||||
} else {
|
||||
$fail++;
|
||||
echo " ❌ $name\n";
|
||||
if ($detail) echo " $detail\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "=== E-conomic Live Draft Verification ===\n";
|
||||
echo "Customer: $customer\n";
|
||||
echo "API base: $baseUrl\n\n";
|
||||
|
||||
try {
|
||||
// ------------------------------------------------------------------
|
||||
// Step 1: Verify customer exists
|
||||
// ------------------------------------------------------------------
|
||||
echo "Step 1: Verify customer $customer exists...\n";
|
||||
$resp = econ_request('GET', "$baseUrl/customers/$customer");
|
||||
check('Customer exists', $resp['status'] === 200, "HTTP {$resp['status']}");
|
||||
|
||||
if ($resp['status'] !== 200) {
|
||||
echo "Cannot proceed without valid customer. Body: " . substr($resp['body'], 0, 200) . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$customerName = $resp['json']['name'] ?? 'unknown';
|
||||
echo " Customer name: $customerName\n\n";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 2: Create draft invoice
|
||||
// ------------------------------------------------------------------
|
||||
echo "Step 2: Create draft invoice for customer $customer...\n";
|
||||
$resp = econ_request('POST', "$baseUrl/invoices/drafts", [
|
||||
'currency' => 'DKK',
|
||||
'customer' => ['customerNumber' => $customer],
|
||||
'paymentTerms' => ['paymentTermsNumber' => 1],
|
||||
'layout' => ['layoutNumber' => 1],
|
||||
'recipient' => ['name' => 'OpenClaw Live Verification'],
|
||||
'notes' => ['heading' => 'Live verification', 'textLine1' => 'Created by verify-economic-drafts-live.php', 'textLine2' => 'Will be deleted automatically'],
|
||||
]);
|
||||
check('Draft invoice created', $resp['status'] === 201, "HTTP {$resp['status']}");
|
||||
|
||||
if ($resp['status'] !== 201) {
|
||||
echo "Cannot create draft. Body: " . substr($resp['body'], 0, 300) . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$draftInvoiceNumber = $resp['json']['draftInvoiceNumber'] ?? null;
|
||||
echo " Draft invoice number: $draftInvoiceNumber\n\n";
|
||||
|
||||
if (!$draftInvoiceNumber) {
|
||||
echo "No draftInvoiceNumber returned. Body: " . substr($resp['body'], 0, 300) . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 3: Add test lines to draft
|
||||
// ------------------------------------------------------------------
|
||||
echo "Step 3: Add 2 product lines (1 with discount, 1 without)...\n";
|
||||
$lines = [
|
||||
[
|
||||
'product' => ['productNumber' => 'OPENCLAW-TEST-01'],
|
||||
'quantity' => 1.0,
|
||||
'unitNetPrice' => 100.00,
|
||||
'discountPercentage' => 0.0,
|
||||
'description' => 'Test line 1: no discount (verify-economic-drafts-live.php)',
|
||||
],
|
||||
[
|
||||
'product' => ['productNumber' => 'OPENCLAW-TEST-02'],
|
||||
'quantity' => 2.0,
|
||||
'unitNetPrice' => 200.00,
|
||||
'discountPercentage' => 15.0,
|
||||
'description' => 'Test line 2: 15% discount (verify-economic-drafts-live.php)',
|
||||
],
|
||||
];
|
||||
$resp = econ_request('POST', "$baseUrl/invoices/drafts/$draftInvoiceNumber/lines", [
|
||||
'lines' => $lines,
|
||||
]);
|
||||
check('Lines added to draft', $resp['status'] === 200, "HTTP {$resp['status']}, " . count($lines) . " lines");
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 4: Verify draft contents
|
||||
// ------------------------------------------------------------------
|
||||
echo "\nStep 4: Verify draft contents...\n";
|
||||
$resp = econ_request('GET', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
|
||||
$draft = $resp['json'] ?? [];
|
||||
$draftLines = $draft['lines'] ?? [];
|
||||
|
||||
check('Draft has 2 lines', count($draftLines) === 2, 'found ' . count($draftLines));
|
||||
check('Customer is 12345679', ($draft['customer']['customerNumber'] ?? 0) === $customer);
|
||||
check('Line 1 has 0% discount', abs(($draftLines[0]['discountPercentage'] ?? -1)) < 0.01);
|
||||
check('Line 2 has 15% discount', abs(($draftLines[1]['discountPercentage'] ?? -1) - 15.0) < 0.01);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 5: Cleanup - delete the draft
|
||||
// ------------------------------------------------------------------
|
||||
echo "\nStep 5: Cleanup - delete draft $draftInvoiceNumber...\n";
|
||||
$resp = econ_request('DELETE', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
|
||||
check('Draft deleted', $resp['status'] === 204 || $resp['status'] === 200, "HTTP {$resp['status']}");
|
||||
|
||||
if ($resp['status'] !== 204 && $resp['status'] !== 200) {
|
||||
echo "\n⚠️ WARNING: Cleanup failed. Draft $draftInvoiceNumber still exists in e-conomic.\n";
|
||||
echo " Delete it manually: curl -X DELETE -H \"$auth\" $baseUrl/invoices/drafts/$draftInvoiceNumber\n";
|
||||
exit(2);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 6: Verify deletion
|
||||
// ------------------------------------------------------------------
|
||||
echo "\nStep 6: Verify draft is gone...\n";
|
||||
$resp = econ_request('GET', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
|
||||
check('Draft no longer exists', $resp['status'] === 404, "HTTP {$resp['status']} (expected 404)");
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
echo "\n💥 UNCAUGHT ERROR: " . $e->getMessage() . "\n";
|
||||
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
|
||||
|
||||
// Best-effort cleanup
|
||||
if ($draftInvoiceNumber !== null) {
|
||||
echo "\nAttempting emergency cleanup of draft $draftInvoiceNumber...\n";
|
||||
$resp = econ_request('DELETE', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
|
||||
echo " Cleanup HTTP status: {$resp['status']}\n";
|
||||
if ($resp['status'] !== 204 && $resp['status'] !== 200) {
|
||||
echo " ⚠️ MANUAL CLEANUP REQUIRED: DELETE $baseUrl/invoices/drafts/$draftInvoiceNumber\n";
|
||||
exit(2);
|
||||
}
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "\n=== Summary: $pass/$total checks passed ===\n";
|
||||
exit($fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Static utility for generating, formatting, hashing, and parsing
|
||||
* API keys.
|
||||
*
|
||||
* Key format: <prefix>_<env>_<22-char-base62>.<32-char-base62-secret>
|
||||
* e.g. truck_live_aBcD1234XyZ5678mnOpQrSt.uVwXyZ0123456789aBcDeFgHiJkLmN
|
||||
*
|
||||
* The key_id (everything before the dot) is stored in plain text in
|
||||
* the database as the lookup key. The secret is NEVER stored in plain
|
||||
* text — only the argon2id hash is persisted. The full key is shown
|
||||
* to the user exactly once at creation time.
|
||||
*/
|
||||
class api_key_generator
|
||||
{
|
||||
/** Base62 alphabet (0-9, A-Z, a-z). Avoids + / = of base64. */
|
||||
public const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
/** Characters permitted in the public key_id portion. */
|
||||
public const KEY_ID_RANDOM_LENGTH = 22;
|
||||
|
||||
/** Characters in the secret portion. */
|
||||
public const SECRET_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Build the public key_id portion: <prefix>_<env>_<random>.
|
||||
*/
|
||||
public static function generateKeyId(string $env = 'live'): string
|
||||
{
|
||||
$env = self::normaliseEnv($env);
|
||||
$prefix = self::prefix();
|
||||
$random = self::randomBase62(self::KEY_ID_RANDOM_LENGTH);
|
||||
return $prefix . '_' . $env . '_' . $random;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the secret portion (32-char base62).
|
||||
*/
|
||||
public static function generateSecret(): string
|
||||
{
|
||||
return self::randomBase62(self::SECRET_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Join key_id and secret with a single dot.
|
||||
*/
|
||||
public static function formatKey(string $keyId, string $secret): string
|
||||
{
|
||||
if ($keyId === '' || strpos($keyId, '.') !== false) {
|
||||
throw new \InvalidArgumentException('key_id must not contain a dot');
|
||||
}
|
||||
if ($secret === '' || strpos($secret, '.') !== false) {
|
||||
throw new \InvalidArgumentException('secret must not contain a dot');
|
||||
}
|
||||
return $keyId . '.' . $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash the full key (or just the secret) using argon2id.
|
||||
*/
|
||||
public static function hash(string $plain): string
|
||||
{
|
||||
if ($plain === '') {
|
||||
throw new \InvalidArgumentException('Cannot hash an empty value');
|
||||
}
|
||||
$hash = password_hash($plain, PASSWORD_ARGON2ID);
|
||||
if ($hash === false) {
|
||||
throw new \RuntimeException('Failed to hash with argon2id');
|
||||
}
|
||||
return $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a plaintext key against a stored argon2id hash.
|
||||
*/
|
||||
public static function verify(string $plain, string $hash): bool
|
||||
{
|
||||
if ($plain === '' || $hash === '') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return password_verify($plain, $hash);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a full "key_id.secret" string back into its parts.
|
||||
*
|
||||
* The key_id may contain underscores (as separators between
|
||||
* prefix/env/random) and must be base62 + underscores. The
|
||||
* secret must be strictly base62 with no separators.
|
||||
*
|
||||
* @return array{key_id:string, secret:string}|null
|
||||
* null if the input is malformed.
|
||||
*/
|
||||
public static function parseKey(string $full): ?array
|
||||
{
|
||||
$full = trim($full);
|
||||
if ($full === '' || strpos($full, '.') === false) {
|
||||
return null;
|
||||
}
|
||||
// Split on the FIRST dot only — secrets are base62 and contain
|
||||
// no dots, so there's exactly one separator.
|
||||
$parts = explode('.', $full, 2);
|
||||
if (count($parts) !== 2) {
|
||||
return null;
|
||||
}
|
||||
[$keyId, $secret] = $parts;
|
||||
$keyId = trim($keyId);
|
||||
$secret = trim($secret);
|
||||
if ($keyId === '' || $secret === '') {
|
||||
return null;
|
||||
}
|
||||
// The key_id is "<prefix>_<env>_<random>" — base62 with
|
||||
// underscore separators. The secret is pure base62.
|
||||
if (!self::isKeyId($keyId) || !self::isBase62($secret)) {
|
||||
return null;
|
||||
}
|
||||
return ['key_id' => $keyId, 'secret' => $secret];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a key_id string: base62 with optional underscore
|
||||
* separators. Exposed for testing.
|
||||
*/
|
||||
public static function isKeyId(string $value): bool
|
||||
{
|
||||
if ($value === '') {
|
||||
return false;
|
||||
}
|
||||
return preg_match('/^[0-9A-Za-z_]+$/', $value) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configurable prefix (default: "truck"). Reads from
|
||||
* `config('api_key.prefix', 'truck')` if available, otherwise the
|
||||
* default. Always lowercased and stripped of separators.
|
||||
*/
|
||||
public static function prefix(): string
|
||||
{
|
||||
$default = 'truck';
|
||||
$value = $default;
|
||||
if (function_exists('config')) {
|
||||
try {
|
||||
$candidate = config('api_key.prefix', $default);
|
||||
if (is_string($candidate) && $candidate !== '') {
|
||||
$value = $candidate;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$value = $default;
|
||||
}
|
||||
}
|
||||
$value = strtolower(trim((string)$value));
|
||||
$value = preg_replace('/[^a-z0-9_]/', '', $value) ?? '';
|
||||
if ($value === '') {
|
||||
$value = $default;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal — exposed for testing.
|
||||
*/
|
||||
public static function randomBase62(int $length): string
|
||||
{
|
||||
if ($length < 1) {
|
||||
throw new \InvalidArgumentException('Length must be positive');
|
||||
}
|
||||
|
||||
$alphabet = self::ALPHABET;
|
||||
$alphabetMax = strlen($alphabet) - 1; // 61
|
||||
|
||||
$out = '';
|
||||
$bytesNeeded = (int)ceil($length * 1.3) + 8;
|
||||
$bytes = random_bytes($bytesNeeded);
|
||||
$byteIndex = 0;
|
||||
|
||||
while (strlen($out) < $length) {
|
||||
if (!isset($bytes[$byteIndex])) {
|
||||
$bytes = random_bytes($bytesNeeded);
|
||||
$byteIndex = 0;
|
||||
}
|
||||
// Mask off 0xC0 to get a value 0-63, then reject > 61 to
|
||||
// avoid modulo bias.
|
||||
$byte = ord($bytes[$byteIndex]);
|
||||
$byteIndex++;
|
||||
$value = $byte & 0x3F;
|
||||
if ($value > $alphabetMax) {
|
||||
continue;
|
||||
}
|
||||
$out .= $alphabet[$value];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal — exposed for testing.
|
||||
*/
|
||||
public static function isBase62(string $value): bool
|
||||
{
|
||||
if ($value === '') {
|
||||
return false;
|
||||
}
|
||||
return preg_match('/^[0-9A-Za-z]+$/', $value) === 1;
|
||||
}
|
||||
|
||||
private static function normaliseEnv(string $env): string
|
||||
{
|
||||
$trimmed = strtolower(trim($env));
|
||||
$sanitised = preg_replace('/[^a-z0-9_-]/', '', $trimmed) ?? '';
|
||||
// If the input contained characters outside the allowed
|
||||
// set, the sanitised result will differ from the trimmed
|
||||
// input — in that case fall back to "live" rather than
|
||||
// echoing a mangled version. Empty / whitespace-only input
|
||||
// also falls back to "live".
|
||||
if ($sanitised === '' || $sanitised !== $trimmed) {
|
||||
return 'live';
|
||||
}
|
||||
return $sanitised;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Repository for the `api_keys` table.
|
||||
*
|
||||
* This is a thin procedural wrapper that uses the project's existing
|
||||
* `$db` global (mysqli) — no Eloquent, no ORM. The pattern matches
|
||||
* other repositories in this codebase (see `classes/orders_o.php`,
|
||||
* `classes/invoice_store.php`, etc.).
|
||||
*
|
||||
* Records are returned as associative arrays. The caller is expected
|
||||
* to interact with them as plain dicts; there is no dedicated model
|
||||
* class for api keys.
|
||||
*/
|
||||
class api_key_repository
|
||||
{
|
||||
public const TABLE = 'api_keys';
|
||||
|
||||
private static function db()
|
||||
{
|
||||
global $db;
|
||||
if (!isset($db) || !is_object($db)) {
|
||||
throw new Exception('Database connection ($db) is not available');
|
||||
}
|
||||
// Lazy-create the table on first use so callers don't have to
|
||||
// remember to call ensureTables().
|
||||
if (class_exists(api_key_schema_bootstrap::class)) {
|
||||
api_key_schema_bootstrap::ensureTables();
|
||||
}
|
||||
return $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the input data for create(). Exposed so test doubles
|
||||
* can exercise the same validation without touching a real DB.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function validate(array $data): void
|
||||
{
|
||||
$required = ['key_id', 'key_hash', 'name', 'role'];
|
||||
foreach ($required as $field) {
|
||||
if (!isset($data[$field]) || !is_string($data[$field]) || $data[$field] === '') {
|
||||
throw new \InvalidArgumentException("Missing required field: {$field}");
|
||||
}
|
||||
}
|
||||
$allowedRoles = ['superuser', 'admin', 'customer', 'subuser'];
|
||||
if (!in_array($data['role'], $allowedRoles, true)) {
|
||||
throw new \InvalidArgumentException("Invalid role: {$data['role']}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return int inserted id
|
||||
*/
|
||||
public static function create(array $data): int
|
||||
{
|
||||
self::validate($data);
|
||||
|
||||
$db = self::db();
|
||||
$scopesJson = isset($data['scopes']) && $data['scopes'] !== null
|
||||
? (is_string($data['scopes']) ? $data['scopes'] : json_encode($data['scopes'], JSON_UNESCAPED_SLASHES))
|
||||
: null;
|
||||
|
||||
$stmt = $db->conn()->prepare(
|
||||
'INSERT INTO api_keys (key_id, key_hash, name, role, scopes, customer_id, created_by, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare insert: ' . $db->conn()->error);
|
||||
}
|
||||
|
||||
$customerId = isset($data['customer_id']) ? (int)$data['customer_id'] : null;
|
||||
$createdBy = isset($data['created_by']) ? (int)$data['created_by'] : null;
|
||||
$expiresAt = isset($data['expires_at']) && $data['expires_at'] !== null
|
||||
? (string)$data['expires_at']
|
||||
: null;
|
||||
|
||||
$stmt->bind_param(
|
||||
'sssssiss',
|
||||
$data['key_id'],
|
||||
$data['key_hash'],
|
||||
$data['name'],
|
||||
$data['role'],
|
||||
$scopesJson,
|
||||
$customerId,
|
||||
$createdBy,
|
||||
$expiresAt
|
||||
);
|
||||
|
||||
if (!$stmt->execute()) {
|
||||
$err = $stmt->error;
|
||||
$stmt->close();
|
||||
throw new Exception('Failed to insert api_key: ' . $err);
|
||||
}
|
||||
$id = $stmt->insert_id;
|
||||
$stmt->close();
|
||||
return (int)$id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a non-revoked key by its public key_id.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function findActiveByKeyId(string $keyId): ?array
|
||||
{
|
||||
if ($keyId === '') {
|
||||
return null;
|
||||
}
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare(
|
||||
'SELECT * FROM api_keys WHERE key_id = ? AND revoked_at IS NULL LIMIT 1'
|
||||
);
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare select: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('s', $keyId);
|
||||
if (!$stmt->execute()) {
|
||||
$err = $stmt->error;
|
||||
$stmt->close();
|
||||
throw new Exception('Failed to execute select: ' . $err);
|
||||
}
|
||||
$result = $stmt->get_result();
|
||||
$row = $result ? $result->fetch_assoc() : null;
|
||||
$stmt->close();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find any key by id (including revoked).
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare('SELECT * FROM api_keys WHERE id = ? LIMIT 1');
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare select: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('i', $id);
|
||||
if (!$stmt->execute()) {
|
||||
$err = $stmt->error;
|
||||
$stmt->close();
|
||||
throw new Exception('Failed to execute select: ' . $err);
|
||||
}
|
||||
$result = $stmt->get_result();
|
||||
$row = $result ? $result->fetch_assoc() : null;
|
||||
$stmt->close();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a key (sets revoked_at = NOW()). Returns true on success.
|
||||
*/
|
||||
public static function revoke(int $id): bool
|
||||
{
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare(
|
||||
'UPDATE api_keys SET revoked_at = CURRENT_TIMESTAMP WHERE id = ? AND revoked_at IS NULL'
|
||||
);
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare revoke: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('i', $id);
|
||||
$ok = $stmt->execute();
|
||||
$affected = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
return $ok && $affected > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump last_used_at for a key. Best-effort: failures are swallowed
|
||||
* because this is a hot-path observability hook and must not
|
||||
* break the request.
|
||||
*/
|
||||
public static function touchLastUsed(int $id): void
|
||||
{
|
||||
try {
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare(
|
||||
'UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||||
);
|
||||
if ($stmt === false) {
|
||||
return;
|
||||
}
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
} catch (Throwable) {
|
||||
// intentionally ignored
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List keys for a customer, newest first.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function listForCustomer(int $customerId, bool $includeRevoked = false): array
|
||||
{
|
||||
$db = self::db();
|
||||
$sql = 'SELECT * FROM api_keys WHERE customer_id = ?';
|
||||
if (!$includeRevoked) {
|
||||
$sql .= ' AND revoked_at IS NULL';
|
||||
}
|
||||
$sql .= ' ORDER BY id DESC';
|
||||
$stmt = $db->conn()->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare list: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('i', $customerId);
|
||||
if (!$stmt->execute()) {
|
||||
$err = $stmt->error;
|
||||
$stmt->close();
|
||||
throw new Exception('Failed to execute list: ' . $err);
|
||||
}
|
||||
$result = $stmt->get_result();
|
||||
$rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
$stmt->close();
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a key by id. Returns true if a row was removed.
|
||||
* Generally prefer `revoke()` over `delete()` so audit trails
|
||||
* stay intact.
|
||||
*/
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare('DELETE FROM api_keys WHERE id = ?');
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare delete: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('i', $id);
|
||||
$ok = $stmt->execute();
|
||||
$affected = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
return $ok && $affected > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Schema bootstrap for the api_keys table.
|
||||
*
|
||||
* This codebase does NOT use a migration framework; new tables are
|
||||
* added via `*_schema_bootstrap.php` files that run idempotent
|
||||
* `CREATE TABLE IF NOT EXISTS` statements on first use. The companion
|
||||
* SQL file at `database/migrations/<TIMESTAMP>_create_api_keys_table.php`
|
||||
* is the human-readable source of truth / change record.
|
||||
*/
|
||||
class api_key_schema_bootstrap
|
||||
{
|
||||
public const TABLE = 'api_keys';
|
||||
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
if (!isset($db) || !is_object($db)) {
|
||||
// No DB connection in this process (e.g. unit test) — skip.
|
||||
self::$initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$queries = [
|
||||
"CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
key_id VARCHAR(64) NOT NULL,
|
||||
key_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(32) NOT NULL,
|
||||
scopes JSON NULL,
|
||||
customer_id BIGINT UNSIGNED NULL,
|
||||
created_by BIGINT UNSIGNED NULL,
|
||||
last_used_at TIMESTAMP NULL,
|
||||
expires_at TIMESTAMP NULL,
|
||||
revoked_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_api_keys_key_id (key_id),
|
||||
INDEX idx_api_keys_customer (customer_id),
|
||||
INDEX idx_api_keys_key_hash (key_hash),
|
||||
INDEX idx_api_keys_revoked (revoked_at),
|
||||
INDEX idx_api_keys_role (role)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $query) {
|
||||
try {
|
||||
$db->query($query);
|
||||
} catch (\Throwable $e) {
|
||||
// Swallow on first-failure in unit-test contexts; the
|
||||
// migration companion file documents the canonical DDL.
|
||||
if (function_exists('error_log')) {
|
||||
@error_log('[api_key_schema_bootstrap] ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
public static function tableExists(): bool
|
||||
{
|
||||
global $db;
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'getDatabase')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$database = $db->escape_string($db->getDatabase());
|
||||
$result = $db->query(
|
||||
"SELECT COUNT(*) AS count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = '{$database}'
|
||||
AND table_name = 'api_keys'"
|
||||
);
|
||||
$row = $result ? $result->fetch_assoc() : ['count' => 0];
|
||||
return (int)($row['count'] ?? 0) > 0;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace app\auth;
|
||||
|
||||
/**
|
||||
* Scope constants and helpers.
|
||||
*
|
||||
* LOCAL STUB for TRU-149 — will be replaced/extended by TRU-145
|
||||
* (branch feat/api-key-foundation). Keeping this minimal so we
|
||||
* don't conflict with the parallel scope-system work.
|
||||
*
|
||||
* Adding a new scope? Add the constant here AND register it in
|
||||
* Scope::all() AND in Scope::forRole() (whichever roles should
|
||||
* carry it). Centralising role → scope mapping here keeps
|
||||
* permission decisions auditable in one place.
|
||||
*/
|
||||
class Scope
|
||||
{
|
||||
const CUSTOMER_READ = 'customer:read';
|
||||
const CUSTOMER_WRITE = 'customer:write';
|
||||
const BOOKING_READ = 'booking:read';
|
||||
const BOOKING_WRITE = 'booking:write';
|
||||
const SUBUSER_READ = 'subuser:read';
|
||||
const SUBUSER_WRITE = 'subuser:write';
|
||||
const INVOICE_READ = 'invoice:read';
|
||||
const INVOICE_WRITE = 'invoice:write';
|
||||
const SUPERUSER_READ = 'superuser:read';
|
||||
const SUPERUSER_WRITE = 'superuser:write';
|
||||
|
||||
/**
|
||||
* Canonical list of every scope. Used for validation and to
|
||||
* build the audit trail of "what scopes exist" in tests.
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
self::CUSTOMER_READ, self::CUSTOMER_WRITE,
|
||||
self::BOOKING_READ, self::BOOKING_WRITE,
|
||||
self::SUBUSER_READ, self::SUBUSER_WRITE,
|
||||
self::INVOICE_READ, self::INVOICE_WRITE,
|
||||
self::SUPERUSER_READ, self::SUPERUSER_WRITE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the scopes carried by a given role. Single source of
|
||||
* truth for role-based scope assignment.
|
||||
*/
|
||||
public static function forRole(string $role): array
|
||||
{
|
||||
switch ($role) {
|
||||
case 'superuser':
|
||||
return self::all();
|
||||
case 'admin':
|
||||
return [
|
||||
self::CUSTOMER_READ, self::CUSTOMER_WRITE,
|
||||
self::BOOKING_READ, self::BOOKING_WRITE,
|
||||
self::SUBUSER_READ, self::SUBUSER_WRITE,
|
||||
self::INVOICE_READ, self::INVOICE_WRITE,
|
||||
];
|
||||
case 'customer':
|
||||
// TRU-149 (fix): customers get WRITE on their own data so
|
||||
// self-service endpoints (own vehicles, own subusers, own
|
||||
// discount / security / notification settings, own bookings)
|
||||
// work end-to-end. The existing fine-grained
|
||||
// requirePermission() calls in each route still gate which
|
||||
// specific actions are allowed — scope here only answers
|
||||
// "can this caller write customer data at all".
|
||||
return [
|
||||
self::CUSTOMER_READ, self::CUSTOMER_WRITE,
|
||||
self::BOOKING_READ, self::BOOKING_WRITE,
|
||||
self::SUBUSER_READ, self::SUBUSER_WRITE,
|
||||
self::INVOICE_READ,
|
||||
];
|
||||
case 'subuser':
|
||||
return [self::BOOKING_READ, self::BOOKING_WRITE];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize/validate a scope string. Returns null on invalid input
|
||||
* (empty string, non-string, or not in the canonical set).
|
||||
*
|
||||
* Wildcards: "*" matches every scope. "customer:*" matches every
|
||||
* scope starting with "customer:". "customer:read" matches itself.
|
||||
*/
|
||||
public static function matches(string $granted, string $required): bool
|
||||
{
|
||||
$granted = trim($granted);
|
||||
$required = trim($required);
|
||||
if ($granted === '' || $required === '') {
|
||||
return false;
|
||||
}
|
||||
if ($granted === '*') {
|
||||
return true;
|
||||
}
|
||||
if (str_ends_with($granted, ':*')) {
|
||||
$prefix = substr($granted, 0, -2);
|
||||
return str_starts_with($required, $prefix . ':');
|
||||
}
|
||||
return $granted === $required;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
namespace app\auth;
|
||||
|
||||
use classes\response;
|
||||
use Exception;
|
||||
use objects\logs_o;
|
||||
use objects\subusers_o;
|
||||
use objects\users_o;
|
||||
|
||||
/**
|
||||
* Scope-based access control middleware.
|
||||
*
|
||||
* Sits ON TOP of the existing session-cookie / bearer-token auth in
|
||||
* classes\authentication. Existing permission checks (requirePermission,
|
||||
* requireDepartmentAccess, etc.) MUST stay in place — scope checks are
|
||||
* an additional, parallel layer that lets us reason about route
|
||||
* authorization in terms of coarse-grained capabilities ("can this
|
||||
* caller read invoices?") rather than fine-grained permission strings.
|
||||
*
|
||||
* The scope source-of-truth is app\auth\Scope (a local stub for
|
||||
* TRU-149 — replaced by TRU-145 / feat/api-key-foundation).
|
||||
*
|
||||
* Three entry points:
|
||||
* - requireScope(string) — caller must hold this exact scope
|
||||
* - requireAnyScope(array) — caller must hold at least one
|
||||
* - requireRole(string) — convenience: any of the role's
|
||||
* scopes (see Scope::forRole)
|
||||
*
|
||||
* All three throw 403 on missing scope (or 401 if not authenticated at
|
||||
* all). They never short-circuit silently: a missing scope is a denial,
|
||||
* not a no-op.
|
||||
*/
|
||||
class ScopeMiddleware
|
||||
{
|
||||
/**
|
||||
* Throw 403 unless the caller carries the given scope.
|
||||
*
|
||||
* @param string $required Scope string (e.g. Scope::CUSTOMER_READ).
|
||||
* @param string|null $context Free-form label for log output
|
||||
* (typically the route path).
|
||||
*/
|
||||
public static function requireScope(string $required, ?string $context = null): void
|
||||
{
|
||||
$granted = self::resolveGrantedScopes();
|
||||
if (self::hasAnyMatchingScope($granted, [$required])) {
|
||||
return;
|
||||
}
|
||||
self::deny($required, $granted, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw 403 unless the caller carries at least one of the given scopes.
|
||||
*
|
||||
* @param array<int, string> $required
|
||||
*/
|
||||
public static function requireAnyScope(array $required, ?string $context = null): void
|
||||
{
|
||||
if ($required === []) {
|
||||
// No scopes required = nothing to enforce. Defensive: a route
|
||||
// author who passes [] probably meant to skip scope checks, so
|
||||
// let it through rather than denying.
|
||||
return;
|
||||
}
|
||||
$granted = self::resolveGrantedScopes();
|
||||
if (self::hasAnyMatchingScope($granted, $required)) {
|
||||
return;
|
||||
}
|
||||
self::deny(implode('|', $required), $granted, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper: require that the caller's role is at least
|
||||
* as privileged as the named role.
|
||||
*
|
||||
* Role hierarchy: superuser > admin > customer > subuser.
|
||||
* A caller satisfies `requireRole('admin')` if they are admin or
|
||||
* superuser. `requireRole('superuser')` is only satisfied by
|
||||
* superuser.
|
||||
*
|
||||
* Unknown roles deny.
|
||||
*/
|
||||
public static function requireRole(string $role, ?string $context = null): void
|
||||
{
|
||||
$hierarchy = ['subuser' => 1, 'customer' => 2, 'admin' => 3, 'superuser' => 4];
|
||||
if (!isset($hierarchy[$role])) {
|
||||
global $response;
|
||||
if (is_object($response) && method_exists($response, 'error')) {
|
||||
$response->error('Unknown role for scope check: ' . $role, 403);
|
||||
}
|
||||
return;
|
||||
}
|
||||
$callerRole = self::resolveCallerRole();
|
||||
if ($callerRole === null) {
|
||||
self::deny('role:' . $role, [], $context ?? 'role:' . $role);
|
||||
return;
|
||||
}
|
||||
$callerRank = $hierarchy[$callerRole] ?? 0;
|
||||
$requiredRank = $hierarchy[$role];
|
||||
if ($callerRank >= $requiredRank) {
|
||||
return;
|
||||
}
|
||||
self::deny('role:' . $role, [], $context ?? 'role:' . $role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the caller's role name. Returns null if no principal
|
||||
* is authenticated (or only anonymous test state exists).
|
||||
*/
|
||||
public static function resolveCallerRole(): ?string
|
||||
{
|
||||
// Test hook: tests can install a role via the
|
||||
// setTestPrincipal() path; that path also stores the synthetic
|
||||
// role directly when passed as a string key. For now we
|
||||
// infer the role from the granted-scopes list.
|
||||
if (self::$testPrincipal !== null) {
|
||||
$granted = self::$testPrincipal;
|
||||
if (in_array(Scope::SUPERUSER_READ, $granted, true) && in_array(Scope::SUPERUSER_WRITE, $granted, true)) {
|
||||
return 'superuser';
|
||||
}
|
||||
if (in_array(Scope::SUBUSER_WRITE, $granted, true)) {
|
||||
return 'subuser';
|
||||
}
|
||||
if (in_array(Scope::INVOICE_WRITE, $granted, true)) {
|
||||
return 'admin';
|
||||
}
|
||||
if (in_array(Scope::CUSTOMER_READ, $granted, true)) {
|
||||
return 'customer';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$auth = new \classes\authentication();
|
||||
$user = $auth->get_user();
|
||||
if ($user instanceof users_o) {
|
||||
return self::userRole($user);
|
||||
}
|
||||
$sub = $auth->get_subuser();
|
||||
if ($sub instanceof subusers_o) {
|
||||
return 'subuser';
|
||||
}
|
||||
} catch (Exception) {
|
||||
// fall through
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure check (no throw). Useful for hasScope() style predicates in
|
||||
* route handlers that want to branch on capabilities.
|
||||
*
|
||||
* @return bool true if the caller has the scope (or is superuser/admin).
|
||||
*/
|
||||
public static function hasScope(string $required): bool
|
||||
{
|
||||
$granted = self::resolveGrantedScopes();
|
||||
return self::hasAnyMatchingScope($granted, [$required]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure check for "any of" matching. Returns false if the caller is
|
||||
* not authenticated at all (so callers can branch on anonymous).
|
||||
*
|
||||
* @param array<int, string> $required
|
||||
*/
|
||||
public static function hasAnyScope(array $required): bool
|
||||
{
|
||||
if ($required === []) {
|
||||
return true;
|
||||
}
|
||||
$granted = self::resolveGrantedScopes();
|
||||
return self::hasAnyMatchingScope($granted, $required);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the scopes the current principal carries. For now this is
|
||||
* derived from the classic user role / subuser permissions, since
|
||||
* the API key plumbing (TRU-145) is not yet wired in. When TRU-145
|
||||
* lands, this method is the single replacement point.
|
||||
*
|
||||
* Returns an empty array if no principal is authenticated.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function resolveGrantedScopes(): array
|
||||
{
|
||||
// Test hook: if a test has installed a principal via
|
||||
// self::setTestPrincipal(), honour that and skip the real
|
||||
// authentication path. This is the only place a test-only
|
||||
// branch lives; production code never sets the test
|
||||
// principal because nothing else in the codebase does.
|
||||
if (self::$testPrincipal !== null) {
|
||||
$principal = self::$testPrincipal;
|
||||
if (is_array($principal)) {
|
||||
return $principal;
|
||||
}
|
||||
}
|
||||
try {
|
||||
$auth = new \classes\authentication();
|
||||
$user = $auth->get_user();
|
||||
if ($user instanceof users_o) {
|
||||
$role = self::userRole($user);
|
||||
return Scope::forRole($role);
|
||||
}
|
||||
$sub = $auth->get_subuser();
|
||||
if ($sub instanceof subusers_o) {
|
||||
return Scope::forRole('subuser');
|
||||
}
|
||||
} catch (Exception) {
|
||||
// fall through
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var array<int, string>|null */
|
||||
private static ?array $testPrincipal = null;
|
||||
|
||||
/**
|
||||
* Test-only: set the scope list the middleware should treat as
|
||||
* "granted" for the current request. Pass null to clear.
|
||||
*
|
||||
* @param array<int, string>|null $scopes
|
||||
*/
|
||||
public static function setTestPrincipal(?array $scopes): void
|
||||
{
|
||||
self::$testPrincipal = $scopes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort role detection for an authenticated user.
|
||||
*
|
||||
* Order of preference:
|
||||
* 1. `hasPermission('superuser')` — matches the pattern used
|
||||
* elsewhere in the codebase (e.g. departmentGoalsRoute).
|
||||
* 2. `hasPermission('admin')` — admin gets the admin scope set.
|
||||
* 3. Fallback to 'customer' — most authenticated callers are
|
||||
* customer users, so we treat unknown as customer (read-only)
|
||||
* rather than zero-privilege. This matches existing routes'
|
||||
* behavior of allowing read access by default.
|
||||
*
|
||||
* Anonymous / malformed sessions yield no scopes via
|
||||
* resolveGrantedScopes()'s outer try/catch.
|
||||
*/
|
||||
private static function userRole(users_o $user): string
|
||||
{
|
||||
try {
|
||||
if (method_exists($user, 'hasPermission')) {
|
||||
if ((bool)$user->hasPermission('superuser')) {
|
||||
return 'superuser';
|
||||
}
|
||||
if ((bool)$user->hasPermission('admin')) {
|
||||
return 'admin';
|
||||
}
|
||||
}
|
||||
} catch (Exception) {
|
||||
// fall through to default
|
||||
}
|
||||
return 'customer';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any of the granted scopes satisfies any of the required
|
||||
* scopes, using Scope::matches() (which supports "*" and "x:*"
|
||||
* wildcards).
|
||||
*
|
||||
* @param array<int, string> $granted
|
||||
* @param array<int, string> $required
|
||||
*/
|
||||
private static function hasAnyMatchingScope(array $granted, array $required): bool
|
||||
{
|
||||
foreach ($required as $need) {
|
||||
foreach ($granted as $have) {
|
||||
if (Scope::matches($have, $need)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a 403 with a consistent shape and log the denial so we can
|
||||
* see attempted access patterns during rollout.
|
||||
*/
|
||||
private static function deny(string $required, array $granted, ?string $context): void
|
||||
{
|
||||
// Best-effort log of the denial. We swallow all errors here
|
||||
// because the deny path itself must never throw — a 403
|
||||
// response is the contract.
|
||||
try {
|
||||
// The `redis` constant is a global namespaced object
|
||||
// (objects\redis) created at boot. In test environments
|
||||
// it may not be defined, so guard with `defined()`.
|
||||
if (class_exists(logs_o::class) && defined('redis')) {
|
||||
(new logs_o())->add(
|
||||
'global',
|
||||
'global',
|
||||
1,
|
||||
0,
|
||||
'SCOPE_DENIED',
|
||||
'Missing scope: ' . $required . ' (context=' . ($context ?? 'n/a') . ', granted=' . implode(',', $granted) . ')'
|
||||
);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Logging must never block a deny.
|
||||
}
|
||||
global $response;
|
||||
if (is_object($response) && method_exists($response, 'error')) {
|
||||
$response->error('Missing required scope: ' . $required, 403);
|
||||
return;
|
||||
}
|
||||
throw new Exception('Forbidden: missing scope ' . $required, 403);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace classes\auth;
|
||||
|
||||
/**
|
||||
* Scope registry: the source of truth for API key scopes and
|
||||
* role → scope defaults.
|
||||
*
|
||||
* This class is the canonical implementation that the parallel
|
||||
* `app\auth\Scope` stub (introduced by TRU-149 / branch
|
||||
* feat/TRU-149-route-scopes) will be replaced with once
|
||||
* `feat/api-key-foundation` is merged. Until then the two can
|
||||
* coexist; the middleware in `scope_middleware.php` continues
|
||||
* to use the legacy stub.
|
||||
*
|
||||
* Scopes follow a "resource:action" pattern (e.g. `booking:read`).
|
||||
* Two wildcard forms are recognised:
|
||||
* - `*` — matches every scope.
|
||||
* - `resource:*` — matches every action on a resource.
|
||||
*
|
||||
* Role defaults:
|
||||
* - superuser: every scope (via "*" wildcard).
|
||||
* - admin: customer:*, booking:*, subuser:*, invoice:*
|
||||
* - customer: customer:read, booking:read, invoice:read
|
||||
* - subuser: booking:read, booking:write
|
||||
*
|
||||
* The "self" / "assigned" qualifiers from the spec are *enforcement
|
||||
* layer* concerns, not scope concerns — they live in the resolver
|
||||
* that maps an authenticated principal to a customer/subuser record.
|
||||
* Scopes only encode "can the caller read bookings at all", not
|
||||
* "which bookings".
|
||||
*/
|
||||
final class scope_registry
|
||||
{
|
||||
// --- Customer resource ---
|
||||
public const CUSTOMER_READ = 'customer:read';
|
||||
public const CUSTOMER_WRITE = 'customer:write';
|
||||
|
||||
// --- Booking resource ---
|
||||
public const BOOKING_READ = 'booking:read';
|
||||
public const BOOKING_WRITE = 'booking:write';
|
||||
|
||||
// --- Subuser resource ---
|
||||
public const SUBUSER_READ = 'subuser:read';
|
||||
public const SUBUSER_WRITE = 'subuser:write';
|
||||
|
||||
// --- Invoice resource ---
|
||||
public const INVOICE_READ = 'invoice:read';
|
||||
public const INVOICE_WRITE = 'invoice:write';
|
||||
|
||||
// --- Superuser / admin resource ---
|
||||
public const SUPERUSER_READ = 'superuser:read';
|
||||
public const SUPERUSER_WRITE = 'superuser:write';
|
||||
|
||||
/**
|
||||
* Canonical list of every concrete scope (no wildcards).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
self::CUSTOMER_READ, self::CUSTOMER_WRITE,
|
||||
self::BOOKING_READ, self::BOOKING_WRITE,
|
||||
self::SUBUSER_READ, self::SUBUSER_WRITE,
|
||||
self::INVOICE_READ, self::INVOICE_WRITE,
|
||||
self::SUPERUSER_READ, self::SUPERUSER_WRITE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the default scope set carried by a role. Wildcards are
|
||||
* returned as-is; resolve them with `expand()` before checking
|
||||
* membership if you need a flat list.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function scopesForRole(string $role): array
|
||||
{
|
||||
switch (strtolower(trim($role))) {
|
||||
case 'superuser':
|
||||
return ['*'];
|
||||
case 'admin':
|
||||
return [
|
||||
'customer:*',
|
||||
'booking:*',
|
||||
'subuser:*',
|
||||
'invoice:*',
|
||||
];
|
||||
case 'customer':
|
||||
return [
|
||||
self::CUSTOMER_READ,
|
||||
self::BOOKING_READ,
|
||||
self::INVOICE_READ,
|
||||
];
|
||||
case 'subuser':
|
||||
return [
|
||||
self::BOOKING_READ,
|
||||
self::BOOKING_WRITE,
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the granted scope (or wildcard) match the required scope?
|
||||
*
|
||||
* - "*" matches anything.
|
||||
* - "customer:*" matches "customer:read" and "customer:write".
|
||||
* - "customer:read" matches itself exactly.
|
||||
*
|
||||
* @param array<int, string> $granted
|
||||
*/
|
||||
public static function hasScope(array $granted, string $required): bool
|
||||
{
|
||||
$required = trim($required);
|
||||
if ($required === '') {
|
||||
return false;
|
||||
}
|
||||
foreach ($granted as $candidate) {
|
||||
if (!is_string($candidate)) {
|
||||
continue;
|
||||
}
|
||||
if (self::matches($candidate, $required)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a list of scopes (which may include wildcards) into the
|
||||
* full set of concrete scopes they grant. Useful for showing a
|
||||
* user what their key can do, or for caching decisions.
|
||||
*
|
||||
* The wildcard "*" expands to the full `all()` set. A wildcard
|
||||
* like "customer:*" expands to every concrete scope starting with
|
||||
* "customer:". Duplicate entries are removed.
|
||||
*
|
||||
* @param array<int, string> $scopes
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function expand(array $scopes): array
|
||||
{
|
||||
$concrete = self::all();
|
||||
$expanded = [];
|
||||
|
||||
foreach ($scopes as $scope) {
|
||||
if (!is_string($scope)) {
|
||||
continue;
|
||||
}
|
||||
$scope = trim($scope);
|
||||
if ($scope === '') {
|
||||
continue;
|
||||
}
|
||||
if ($scope === '*') {
|
||||
$expanded = array_merge($expanded, $concrete);
|
||||
continue;
|
||||
}
|
||||
if (str_ends_with($scope, ':*')) {
|
||||
$prefix = substr($scope, 0, -2) . ':';
|
||||
foreach ($concrete as $candidate) {
|
||||
if (str_starts_with($candidate, $prefix)) {
|
||||
$expanded[] = $candidate;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Already concrete — pass through if it looks canonical.
|
||||
if (in_array($scope, $concrete, true)) {
|
||||
$expanded[] = $scope;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($expanded));
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal wildcard matcher — public for testing.
|
||||
*/
|
||||
public static function matches(string $granted, string $required): bool
|
||||
{
|
||||
$granted = trim($granted);
|
||||
$required = trim($required);
|
||||
if ($granted === '' || $required === '') {
|
||||
return false;
|
||||
}
|
||||
if ($granted === '*') {
|
||||
return true;
|
||||
}
|
||||
if (str_ends_with($granted, ':*')) {
|
||||
$prefix = substr($granted, 0, -2);
|
||||
return str_starts_with($required, $prefix . ':');
|
||||
}
|
||||
return $granted === $required;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a scope string. Returns true iff the value is either
|
||||
* a canonical concrete scope, "*", or a "<resource>:*" wildcard
|
||||
* for a known resource.
|
||||
*/
|
||||
public static function isValid(string $scope): bool
|
||||
{
|
||||
$scope = trim($scope);
|
||||
if ($scope === '' || $scope === '*') {
|
||||
return $scope !== '';
|
||||
}
|
||||
if (str_ends_with($scope, ':*')) {
|
||||
$prefix = substr($scope, 0, -2);
|
||||
foreach (self::all() as $concrete) {
|
||||
if (str_starts_with($concrete, $prefix . ':')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return in_array($scope, self::all(), true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use InvalidArgumentException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Customer rule "auto-send invoice on the 3rd business day each month" (TRU-70 / DRIFT 9).
|
||||
*
|
||||
* The toggle lives in the existing `customer_attributes` table under the
|
||||
* `autoSendInvoiceThirdBusinessDay` attribute. A daily cron task delegates to
|
||||
* {@see autoSendInvoicesThirdBusinessDay()} which is a no-op on every day
|
||||
* except the 3rd business day of the month, where it auto-queues ready
|
||||
* collected invoices for the opted-in customers.
|
||||
*
|
||||
* "Business day" is computed against a locale-aware weekend (default
|
||||
* Saturday + Sunday). Public Danish holidays are supported via a small
|
||||
* override hook so the unit tests can pin the result without depending on
|
||||
* the wall clock.
|
||||
*/
|
||||
class auto_send_invoice_third_business_day_service
|
||||
{
|
||||
public const ATTRIBUTE = 'autoSendInvoiceThirdBusinessDay';
|
||||
|
||||
public const DEFAULT_WEEKEND_DAYS = [6, 7]; // ISO-8601: 6 = Saturday, 7 = Sunday
|
||||
|
||||
/** @var list<string>|null */
|
||||
private ?array $holidayCache = null;
|
||||
|
||||
/** @var callable|null */
|
||||
private $holidayProviderOverride = null;
|
||||
|
||||
/** @var callable|null */
|
||||
private $nowProviderOverride = null;
|
||||
|
||||
/** @var callable|null */
|
||||
private $timeZoneProviderOverride = null;
|
||||
|
||||
public function isThirdBusinessDay(?DateTimeImmutable $date = null): bool
|
||||
{
|
||||
$timezone = $this->resolveTimezone();
|
||||
$reference = $date ?? $this->resolveNow()->setTimezone($timezone);
|
||||
$reference = $reference->setTimezone($timezone)->setTime(0, 0, 0);
|
||||
|
||||
$businessDay = 0;
|
||||
$cursor = $reference->setDate(
|
||||
(int)$reference->format('Y'),
|
||||
(int)$reference->format('n'),
|
||||
1
|
||||
);
|
||||
$today = $reference;
|
||||
|
||||
while ($cursor <= $today) {
|
||||
if ($this->isBusinessDay($cursor)) {
|
||||
$businessDay++;
|
||||
if ($businessDay === 3) {
|
||||
return $cursor->format('Y-m-d') === $today->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
$cursor = $cursor->modify('+1 day');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function thirdBusinessDayOfMonth(int $year, int $month): DateTimeImmutable
|
||||
{
|
||||
if ($year < 1970 || $year > 9999) {
|
||||
throw new InvalidArgumentException("Year must be between 1970 and 9999, got {$year}");
|
||||
}
|
||||
if ($month < 1 || $month > 12) {
|
||||
throw new InvalidArgumentException("Month must be between 1 and 12, got {$month}");
|
||||
}
|
||||
|
||||
$timezone = $this->resolveTimezone();
|
||||
$cursor = (new DateTimeImmutable(sprintf('%04d-%02d-01 00:00:00', $year, $month), $timezone))
|
||||
->setTimezone($timezone);
|
||||
$businessDay = 0;
|
||||
|
||||
while (true) {
|
||||
if ($this->isBusinessDay($cursor)) {
|
||||
$businessDay++;
|
||||
if ($businessDay === 3) {
|
||||
return $cursor->setTime(0, 0, 0);
|
||||
}
|
||||
}
|
||||
$cursor = $cursor->modify('+1 day');
|
||||
}
|
||||
}
|
||||
|
||||
public function isBusinessDay(DateTimeImmutable $date): bool
|
||||
{
|
||||
$weekday = (int)$date->format('N');
|
||||
if (in_array($weekday, self::DEFAULT_WEEKEND_DAYS, true)) {
|
||||
return false;
|
||||
}
|
||||
$holidayKey = $date->format('Y-m-d');
|
||||
foreach ($this->resolveHolidays() as $holiday) {
|
||||
if ($holiday === $holidayKey) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-queue every ready collected invoice for customers with the
|
||||
* `autoSendInvoiceThirdBusinessDay` attribute. Returns a summary of
|
||||
* what was queued (or an empty `scanned` count when invoked on a
|
||||
* non-trigger day).
|
||||
*
|
||||
* @return array{
|
||||
* triggered: bool,
|
||||
* trigger_date: ?string,
|
||||
* customers: int,
|
||||
* collections_scanned: int,
|
||||
* jobs_enqueued: int,
|
||||
* skipped_already_queued: int,
|
||||
* errors: list<array{customer_number:int,collection_id:int,message:string}>
|
||||
* }
|
||||
*/
|
||||
public function runOnce(?DateTimeImmutable $now = null): array
|
||||
{
|
||||
$timezone = $this->resolveTimezone();
|
||||
$today = ($now ?? $this->resolveNow())->setTimezone($timezone)->setTime(0, 0, 0);
|
||||
|
||||
$summary = [
|
||||
'triggered' => false,
|
||||
'trigger_date' => null,
|
||||
'customers' => 0,
|
||||
'collections_scanned' => 0,
|
||||
'jobs_enqueued' => 0,
|
||||
'skipped_already_queued' => 0,
|
||||
'errors' => [],
|
||||
];
|
||||
|
||||
if (!$this->isThirdBusinessDay($today)) {
|
||||
return $summary;
|
||||
}
|
||||
|
||||
$summary['triggered'] = true;
|
||||
$summary['trigger_date'] = $today->format('Y-m-d');
|
||||
|
||||
$customerNumbers = $this->loadEligibleCustomerNumbers();
|
||||
$summary['customers'] = count($customerNumbers);
|
||||
if ($customerNumbers === []) {
|
||||
return $summary;
|
||||
}
|
||||
|
||||
$collections = $this->loadReadyInvoiceCollections($customerNumbers);
|
||||
$summary['collections_scanned'] = count($collections);
|
||||
if ($collections === []) {
|
||||
return $summary;
|
||||
}
|
||||
|
||||
$queue = $this->createTransferQueue();
|
||||
foreach ($collections as $collection) {
|
||||
$collectionId = (int)($collection['id'] ?? 0);
|
||||
if ($collectionId < 1) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$enqueued = $this->enqueueCollectionExport($queue, $collectionId);
|
||||
} catch (Throwable $throwable) {
|
||||
$summary['errors'][] = [
|
||||
'customer_number' => (int)($collection['customer_number'] ?? 0),
|
||||
'collection_id' => $collectionId,
|
||||
'message' => $throwable->getMessage(),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
if ($enqueued === 'queued') {
|
||||
$summary['jobs_enqueued']++;
|
||||
} elseif ($enqueued === 'already_queued') {
|
||||
$summary['skipped_already_queued']++;
|
||||
}
|
||||
// 'unavailable' is intentionally silent: the queue is optional
|
||||
// and the next cron tick will pick up the collections.
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
public function setHolidayProviderOverride(callable $provider): void
|
||||
{
|
||||
$this->holidayProviderOverride = $provider;
|
||||
}
|
||||
|
||||
public function setNowProviderOverride(callable $provider): void
|
||||
{
|
||||
$this->nowProviderOverride = $provider;
|
||||
}
|
||||
|
||||
public function setTimeZoneProviderOverride(callable $provider): void
|
||||
{
|
||||
$this->timeZoneProviderOverride = $provider;
|
||||
}
|
||||
|
||||
public function clearOverrides(): void
|
||||
{
|
||||
$this->holidayProviderOverride = null;
|
||||
$this->nowProviderOverride = null;
|
||||
$this->timeZoneProviderOverride = null;
|
||||
$this->holidayCache = null;
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
public function loadEligibleCustomerNumbers(): array
|
||||
{
|
||||
global $db;
|
||||
$attribute = $db->escape_string(self::ATTRIBUTE);
|
||||
$result = $db->query(
|
||||
"SELECT DISTINCT CAST(u.customer_number AS UNSIGNED) AS customer_number
|
||||
FROM customer_attributes ca
|
||||
INNER JOIN users u ON u.id = ca.user_id
|
||||
WHERE ca.attribute = '{$attribute}'
|
||||
AND u.customer_number IS NOT NULL
|
||||
AND u.customer_number <> 0
|
||||
AND u.deleted_at IS NULL
|
||||
ORDER BY customer_number ASC"
|
||||
);
|
||||
if (!$result) {
|
||||
return [];
|
||||
}
|
||||
$customerNumbers = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$number = (int)($row['customer_number'] ?? 0);
|
||||
if ($number > 0) {
|
||||
$customerNumbers[] = $number;
|
||||
}
|
||||
}
|
||||
return $customerNumbers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $customerNumbers
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function loadReadyInvoiceCollections(array $customerNumbers): array
|
||||
{
|
||||
$customerNumbers = array_values(array_filter(array_map('intval', $customerNumbers), static fn(int $n): bool => $n > 0));
|
||||
if ($customerNumbers === []) {
|
||||
return [];
|
||||
}
|
||||
global $db;
|
||||
$in = implode(',', $customerNumbers);
|
||||
|
||||
// A collection is "ready" when:
|
||||
// - it belongs to one of the opted-in customers
|
||||
// - it has at least one order linked to it
|
||||
// - it has not been booked yet (no booked_at)
|
||||
// - it has not been closed yet (no closed_at) — closures are reserved
|
||||
// for already-booked/manual-approval flows
|
||||
// - it has not been deleted
|
||||
$result = $db->query(
|
||||
"SELECT c.id, c.customer_number
|
||||
FROM collected_order_invoices c
|
||||
WHERE c.customer_number IN ({$in})
|
||||
AND c.deleted_at IS NULL
|
||||
AND (c.booked_at IS NULL OR c.booked_at = '0000-00-00 00:00:00')
|
||||
AND (c.closed_at IS NULL OR c.closed_at = '0000-00-00 00:00:00')
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM orders o
|
||||
WHERE o.invoice_collection_id = c.id
|
||||
AND o.deleted_at IS NULL
|
||||
)
|
||||
ORDER BY c.customer_number ASC, c.id ASC"
|
||||
);
|
||||
if (!$result) {
|
||||
return [];
|
||||
}
|
||||
$rows = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$rows[] = [
|
||||
'id' => (int)($row['id'] ?? 0),
|
||||
'customer_number' => (int)($row['customer_number'] ?? 0),
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for unit tests: when the production economic_transfer_queue is
|
||||
* not available the cron task should still succeed with no jobs.
|
||||
*/
|
||||
protected function createTransferQueue(): ?economic_transfer_queue
|
||||
{
|
||||
if (!class_exists(economic_transfer_queue::class)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new economic_transfer_queue();
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return 'queued'|'already_queued'|'unavailable'
|
||||
*/
|
||||
private function enqueueCollectionExport(?economic_transfer_queue $queue, int $collectionId): string
|
||||
{
|
||||
if ($queue === null) {
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
try {
|
||||
$job = $queue->enqueue(
|
||||
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
|
||||
[
|
||||
'collected_invoice_id' => $collectionId,
|
||||
'send_as_is' => false,
|
||||
'requested_by' => 0,
|
||||
'auto_send_third_business_day' => true,
|
||||
],
|
||||
0
|
||||
);
|
||||
} catch (Throwable $throwable) {
|
||||
// The queue is designed to refuse duplicate enqueue by
|
||||
// surfacing a domain-specific message — treat that as
|
||||
// "already_queued" rather than a hard failure.
|
||||
if (str_contains(strtolower($throwable->getMessage()), 'already')) {
|
||||
return 'already_queued';
|
||||
}
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
return is_array($job) && !empty($job['id']) ? 'queued' : 'already_queued';
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function resolveHolidays(): array
|
||||
{
|
||||
if ($this->holidayCache !== null) {
|
||||
return $this->holidayCache;
|
||||
}
|
||||
if ($this->holidayProviderOverride !== null) {
|
||||
$value = ($this->holidayProviderOverride)();
|
||||
$holidays = is_array($value) ? array_values(array_filter(array_map('strval', $value))) : [];
|
||||
$this->holidayCache = $holidays;
|
||||
return $holidays;
|
||||
}
|
||||
|
||||
// Default: no hard-coded public holidays. Production can plug a
|
||||
// concrete provider through setHolidayProviderOverride() once the
|
||||
// holiday calendar is finalised. The Saturday/Sunday weekend
|
||||
// logic is sufficient for the 3rd-business-day computation as
|
||||
// long as the cron task runs on every weekday.
|
||||
$this->holidayCache = [];
|
||||
return $this->holidayCache;
|
||||
}
|
||||
|
||||
private function resolveNow(): DateTimeImmutable
|
||||
{
|
||||
if ($this->nowProviderOverride !== null) {
|
||||
$value = ($this->nowProviderOverride)();
|
||||
if ($value instanceof DateTimeImmutable) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
return new DateTimeImmutable('now');
|
||||
}
|
||||
|
||||
private function resolveTimezone(): DateTimeZone
|
||||
{
|
||||
if ($this->timeZoneProviderOverride !== null) {
|
||||
$value = ($this->timeZoneProviderOverride)();
|
||||
if ($value instanceof DateTimeZone) {
|
||||
return $value;
|
||||
}
|
||||
if (is_string($value) && $value !== '') {
|
||||
return new DateTimeZone($value);
|
||||
}
|
||||
}
|
||||
return new DateTimeZone('Europe/Copenhagen');
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ class customer_rule_product_restriction_service
|
||||
'showPricesOnBookingPage',
|
||||
'usePONumbers',
|
||||
'exemptFromAdministrationFee',
|
||||
'autoSendInvoiceThirdBusinessDay',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Sanitizes user-input fields that are sent to the e-conomic API.
|
||||
*
|
||||
* Background: e-conomic returns 400 errors when description fields contain
|
||||
* certain characters. The known issue is "/" in the order reference field
|
||||
* (TRU-188), but we sanitize defensively for all such cases.
|
||||
*
|
||||
* - sanitizeTextLine(): for plain text lines (reference, notes, po, etc.)
|
||||
* - sanitizeProductNumber(): for product identifiers
|
||||
* - sanitizeProductDescription(): for product-line descriptions
|
||||
* - sanitizeForEconApi(): catch-all for arbitrary user input
|
||||
*/
|
||||
class economic_export_sanitizer
|
||||
{
|
||||
/** E-conomic soft limit for a single description line. */
|
||||
public const TEXT_LINE_MAX_LENGTH = 250;
|
||||
/** E-conomic soft limit for a product description. */
|
||||
public const PRODUCT_DESCRIPTION_MAX_LENGTH = 500;
|
||||
/** E-conomic soft limit for a product number. */
|
||||
public const PRODUCT_NUMBER_MAX_LENGTH = 50;
|
||||
|
||||
/** Characters that are illegal in product numbers on most e-conomic setups. */
|
||||
private const PRODUCT_NUMBER_FORBIDDEN = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', "\0"];
|
||||
|
||||
/**
|
||||
* Sanitize a value for use in a single-line text description.
|
||||
*
|
||||
* Transformations (in order):
|
||||
* 1. Replaces "/" with "-" (the reported 400 trigger)
|
||||
* 2. Strips control characters (\x00-\x1F) except \t and \n
|
||||
* 3. Replaces tab with single space
|
||||
* 4. Collapses newlines into spaces (text lines are single-line)
|
||||
* 5. Collapses runs of spaces to a single space
|
||||
* 6. Trims leading/trailing whitespace
|
||||
* 7. Truncates to $maxLength with "..." suffix if needed
|
||||
*/
|
||||
public static function sanitizeTextLine(mixed $value, int $maxLength = self::TEXT_LINE_MAX_LENGTH): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
$text = (string)$value;
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
// 1. Strip control characters except \t and \n
|
||||
$text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text);
|
||||
// 2. Replace tab with single space
|
||||
$text = str_replace("\t", ' ', $text);
|
||||
// 3. Collapse newlines to single space (text lines are single-line)
|
||||
$text = preg_replace('/[\r\n]+/u', ' ', $text);
|
||||
// 4. Replace forward slashes (the reported 400 trigger)
|
||||
$text = str_replace('/', '-', $text);
|
||||
// 5. Collapse runs of spaces
|
||||
$text = preg_replace('/\s+/u', ' ', $text);
|
||||
// 6. Trim
|
||||
$text = trim($text);
|
||||
// 7. Truncate with ellipsis if too long
|
||||
if ($maxLength > 3 && mb_strlen($text) > $maxLength) {
|
||||
$text = mb_substr($text, 0, $maxLength - 3) . '...';
|
||||
} elseif (mb_strlen($text) > $maxLength) {
|
||||
$text = mb_substr($text, 0, $maxLength);
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a product number/identifier.
|
||||
*
|
||||
* Removes characters that are illegal in product numbers on most
|
||||
* e-conomic setups (filesystem-unsafe + path separators).
|
||||
*/
|
||||
public static function sanitizeProductNumber(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
$text = (string)$value;
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
$text = str_replace(self::PRODUCT_NUMBER_FORBIDDEN, '', $text);
|
||||
$text = preg_replace('/[\x00-\x1F\x7F]/u', '', $text);
|
||||
$text = trim($text);
|
||||
if (mb_strlen($text) > self::PRODUCT_NUMBER_MAX_LENGTH) {
|
||||
$text = mb_substr($text, 0, self::PRODUCT_NUMBER_MAX_LENGTH);
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a longer product description.
|
||||
*/
|
||||
public static function sanitizeProductDescription(mixed $value): string
|
||||
{
|
||||
return self::sanitizeTextLine($value, self::PRODUCT_DESCRIPTION_MAX_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-all sanitizer for any user-input value going to e-conomic.
|
||||
* Defaults to text-line rules.
|
||||
*/
|
||||
public static function sanitizeForEconApi(mixed $value): string
|
||||
{
|
||||
return self::sanitizeTextLine($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Centralized selection of e-conomic invoice layout numbers.
|
||||
*
|
||||
* This class is the **skeleton** introduced by TRU-197. It exposes the two
|
||||
* layout numbers that the backend should use for the two invoice variants:
|
||||
*
|
||||
* - `LAYOUT_WITHOUT_DISCOUNTS` — clean invoice, no discount clutter
|
||||
* - `LAYOUT_WITH_DISCOUNTS` — invoice with itemized discount line(s)
|
||||
*
|
||||
* The constants below are placeholders for the layout numbers that the
|
||||
* e-conomic account admin must pick in e-conomic (Settings → Design and
|
||||
* Layouts) and write into the module-config DB variables
|
||||
* `invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`. The numbers
|
||||
* themselves are intentionally left as `0` in this skeleton — they are
|
||||
* resolved at runtime from the module-config variables by the two existing
|
||||
* call sites:
|
||||
*
|
||||
* - `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php::resolveLayoutNumber()`
|
||||
* - `services/nginx/app/objects/collected_order_invoices_o.php::resolveInvoiceLayoutNumber()`
|
||||
*
|
||||
* Wiring those call sites to read from this selector (instead of from the
|
||||
* module-config variables directly) is intentionally **out of scope** for
|
||||
* TRU-197. See `documentation/economic/invoice-template-audit.md` for the
|
||||
* full audit and follow-up plan.
|
||||
*
|
||||
* Constants in this class are the *single source of truth* for the
|
||||
* env-var-style aliases:
|
||||
*
|
||||
* - `LAYOUT_WITHOUT_DISCOUNTS` ⇄ `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS`
|
||||
* - `LAYOUT_WITH_DISCOUNTS` ⇄ `ECONOMIC_LAYOUT_WITH_DISCOUNTS`
|
||||
*/
|
||||
class economic_layout_selector
|
||||
{
|
||||
/**
|
||||
* Layout number for invoices WITHOUT itemized discount lines.
|
||||
*
|
||||
* Intent: a clean invoice — no "Rabat" line, no discount column, just
|
||||
* the line items and totals.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public const LAYOUT_WITHOUT_DISCOUNTS = 0;
|
||||
|
||||
/**
|
||||
* Layout number for invoices WITH itemized discount lines.
|
||||
*
|
||||
* Intent: an invoice that visibly itemizes the negative `Rabat`
|
||||
* (product `TotDiscount`) line so the customer can see the discount
|
||||
* broken out instead of folded into per-product `discountPercentage`.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public const LAYOUT_WITH_DISCOUNTS = 0;
|
||||
|
||||
/**
|
||||
* Module-config variable name for the without-discounts layout.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const CONFIG_VAR_WITHOUT_DISCOUNTS = 'invoiceLayoutNumber';
|
||||
|
||||
/**
|
||||
* Module-config variable name for the with-discounts layout.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const CONFIG_VAR_WITH_DISCOUNTS = 'invoiceDiscountLayoutNumber';
|
||||
|
||||
/**
|
||||
* Friendly alias for `LAYOUT_WITHOUT_DISCOUNTS` (env-var-style name).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function nameWithoutDiscounts(): string
|
||||
{
|
||||
return 'ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS';
|
||||
}
|
||||
|
||||
/**
|
||||
* Friendly alias for `LAYOUT_WITH_DISCOUNTS` (env-var-style name).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function nameWithDiscounts(): string
|
||||
{
|
||||
return 'ECONOMIC_LAYOUT_WITH_DISCOUNTS';
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Self-healing schema bootstrap.
|
||||
*
|
||||
* Runs every `*_schema_bootstrap::ensureSchema()` on app start so the
|
||||
* production database always has the columns the current code expects.
|
||||
* This catches the "merged-to-master-but-never-applied-to-prod" failure
|
||||
* mode (e.g. TRU-77 invoice_email) where the deploy pipeline pre-deploy
|
||||
* step didn't run (missing GitHub secrets, network glitch, etc.).
|
||||
*
|
||||
* Each bootstrap is **additive + idempotent**:
|
||||
* - SHOW COLUMNS check before any ALTER
|
||||
* - ALTER TABLE ADD COLUMN only if missing
|
||||
* - Once `ensureSchema()` has been called once for a class, the static
|
||||
* `$initialized` flag short-circuits subsequent calls
|
||||
*
|
||||
* The discovery + run loop itself is memoized per PHP process via
|
||||
* `self::$ran`, so the cost after the first request is a single
|
||||
* `class_exists` check (~microseconds).
|
||||
*
|
||||
* Errors in a single bootstrap are logged but never throw — a broken
|
||||
* migration must not 500 every request. A future /api/admin/schema-check
|
||||
* call will surface the failure.
|
||||
*/
|
||||
class schema_bootstrap_runtime
|
||||
{
|
||||
/** @var bool Memoization for the discovery+run loop */
|
||||
private static bool $ran = false;
|
||||
|
||||
/** @var string[] Class names that already failed this process (don't retry) */
|
||||
private static array $failed = [];
|
||||
|
||||
public static function runAll(): void
|
||||
{
|
||||
if (self::$ran) {
|
||||
return;
|
||||
}
|
||||
self::$ran = true;
|
||||
|
||||
$classesDir = __DIR__;
|
||||
$bootstraps = glob($classesDir . DIRECTORY_SEPARATOR . '*_schema_bootstrap.php');
|
||||
if (!$bootstraps) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($bootstraps as $file) {
|
||||
$base = basename($file, '.php');
|
||||
$class = "classes\\{$base}";
|
||||
|
||||
if (in_array($class, self::$failed, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!class_exists($class)) {
|
||||
require_once $file;
|
||||
}
|
||||
if (!class_exists($class)) {
|
||||
continue;
|
||||
}
|
||||
if (!method_exists($class, 'ensureSchema')) {
|
||||
continue;
|
||||
}
|
||||
$class::ensureSchema();
|
||||
} catch (\Throwable $e) {
|
||||
self::$failed[] = $class;
|
||||
error_log(sprintf(
|
||||
'[schema-bootstrap] %s failed: %s',
|
||||
$base,
|
||||
$e->getMessage()
|
||||
));
|
||||
// Intentionally do not throw — a broken migration must
|
||||
// not 500 every request. The next /api/admin/schema-check
|
||||
// call (or the next deploy's pre-deploy step) will
|
||||
// surface the failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,17 +33,17 @@ class slack implements notification_i
|
||||
public function send_department_booking_notification(int $department_id, $message): self
|
||||
{
|
||||
// Get the departments webhook
|
||||
$webhook = self::get_department_webhook($department_id);
|
||||
$webhook = static::get_department_webhook($department_id);
|
||||
// Check if the webhook is empty
|
||||
if (empty($webhook)) {
|
||||
throw new \Exception('Department webhook is empty');
|
||||
}
|
||||
// Send the notification to the department
|
||||
self::add_log(self::send_webhook_message($message, $webhook));
|
||||
self::add_log(static::send_webhook_message($message, $webhook));
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function get_department_webhook(int $department_id): string|null
|
||||
protected function get_department_webhook(int $department_id): string|null
|
||||
{
|
||||
// Check if the department webhook is cached
|
||||
$webhook = redis->get_department_webhook($department_id);
|
||||
@@ -134,6 +134,68 @@ class slack implements notification_i
|
||||
. "Status: $status";
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a new-booking notification to the department's Slack webhook.
|
||||
*
|
||||
* Filter: only PICKUP bookings trigger a Slack notification. Drop-off
|
||||
* bookings (pickup_bool === false) are intentionally silenced per
|
||||
* Mikkel's SENERE 14 / TRU-106 request — drop-offs are noise in the
|
||||
* channel. Other delivery channels (SMS, email) are unaffected.
|
||||
*
|
||||
* Returns true if a Slack message was sent, false if it was filtered
|
||||
* out (drop-off) or the department has no Slack webhook configured.
|
||||
*
|
||||
* @throws \Exception If the department lookup or webhook send fails.
|
||||
*/
|
||||
public function send_new_booking_notification(
|
||||
$id,
|
||||
$customer_number,
|
||||
string $wash_type,
|
||||
string $contact_email,
|
||||
string $reference_number,
|
||||
string $regNrTraekker,
|
||||
string $regNrTrailer,
|
||||
string $washCertificateEmail,
|
||||
string $date,
|
||||
int $department,
|
||||
bool $pickup_bool,
|
||||
string $notes,
|
||||
string $washCertificateStatus,
|
||||
string $washCertificateUrl,
|
||||
string $status
|
||||
): bool {
|
||||
// TRU-106: drop-off bookings must not post to Slack.
|
||||
if (!$pickup_bool) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$webhook = static::get_department_webhook($department);
|
||||
if (empty($webhook)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$message = static::format_new_booking(
|
||||
$id,
|
||||
$customer_number,
|
||||
$wash_type,
|
||||
$contact_email,
|
||||
$reference_number,
|
||||
$regNrTraekker,
|
||||
$regNrTrailer,
|
||||
$washCertificateEmail,
|
||||
$date,
|
||||
$department,
|
||||
$pickup_bool,
|
||||
$notes,
|
||||
$washCertificateStatus,
|
||||
$washCertificateUrl,
|
||||
$status
|
||||
);
|
||||
|
||||
self::add_log(static::send_webhook_message($message, $webhook));
|
||||
return true;
|
||||
}
|
||||
|
||||
public function send_message(string $string, ?string $module = null): void
|
||||
{
|
||||
global $SLACK_DEFAULT_WEBHOOK;
|
||||
|
||||
@@ -9,6 +9,13 @@ class system_search_economic_customer_index
|
||||
{
|
||||
public const TABLE = 'system_search_economic_customer_index';
|
||||
|
||||
/**
|
||||
* FULLTEXT key name used by TRU-62 customer-search performance fix.
|
||||
* The column already exists (TEXT NULL `search_text`) — we just need
|
||||
* the index. See documentation/perf/customer-search-slow-investigation.md.
|
||||
*/
|
||||
public const FULLTEXT_INDEX = 'ft_sseci_search_text';
|
||||
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTable(): void
|
||||
@@ -50,6 +57,14 @@ class system_search_economic_customer_index
|
||||
'economic_barred',
|
||||
"ALTER TABLE `" . self::TABLE . "` ADD COLUMN `economic_barred` TINYINT(1) NULL AFTER `economic_mobile_phone`"
|
||||
);
|
||||
// TRU-62: ensure the FULLTEXT index used by the customer-search fast
|
||||
// path. Safe to call repeatedly: `ensureIndex` no-ops when the index
|
||||
// already exists. The search code falls back to the LIKE-based query
|
||||
// when this index is absent, so an incomplete migration is non-fatal.
|
||||
self::ensureIndex(
|
||||
self::FULLTEXT_INDEX,
|
||||
"ALTER TABLE `" . self::TABLE . "` ADD FULLTEXT INDEX `" . self::FULLTEXT_INDEX . "` (`search_text`)"
|
||||
);
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
@@ -362,6 +377,39 @@ class system_search_economic_customer_index
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an index (FULLTEXT or otherwise) exists on the table.
|
||||
* No-ops when the index is already present so this is safe to call
|
||||
* repeatedly at request time.
|
||||
*/
|
||||
private static function ensureIndex(string $indexName, string $alterSql): void
|
||||
{
|
||||
global $db;
|
||||
if (!is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $db->query(
|
||||
"SHOW INDEX FROM `" . self::TABLE . "` WHERE `Key_name` = '"
|
||||
. $db->escape_string($indexName) . "'"
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return;
|
||||
}
|
||||
if ($result instanceof \mysqli_result && $result->num_rows === 0) {
|
||||
try {
|
||||
$db->query($alterSql);
|
||||
} catch (Throwable $e) {
|
||||
// The search code falls back to the LIKE path when the
|
||||
// index is missing, so a failed ALTER is non-fatal.
|
||||
if (function_exists('error_log')) {
|
||||
@error_log('[system_search_economic_customer_index] failed to add index ' . $indexName . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function barredStatus(?bool $barred): string
|
||||
{
|
||||
return match ($barred) {
|
||||
|
||||
@@ -720,52 +720,18 @@ class system_search_service
|
||||
$customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $customerNumbers)) . ')';
|
||||
}
|
||||
|
||||
$selectFields = [
|
||||
'u.id',
|
||||
'u.customer_number',
|
||||
'u.display_name',
|
||||
'u.email',
|
||||
'u.phone',
|
||||
...$this->joinTemporalSelectFields('users', 'u'),
|
||||
];
|
||||
$searchFields = ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone'];
|
||||
$fromClause = 'users u';
|
||||
|
||||
if ($this->isEconomicCustomerIndexAvailable()) {
|
||||
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
|
||||
$selectFields = [
|
||||
...$selectFields,
|
||||
'sci.economic_name',
|
||||
'sci.economic_address',
|
||||
'sci.economic_city',
|
||||
'sci.economic_zip',
|
||||
'sci.economic_email',
|
||||
'sci.economic_cvr',
|
||||
'sci.economic_mobile_phone',
|
||||
'sci.search_text',
|
||||
];
|
||||
$searchFields = [
|
||||
...$searchFields,
|
||||
'sci.economic_name',
|
||||
'sci.economic_address',
|
||||
'sci.economic_city',
|
||||
'sci.economic_zip',
|
||||
'sci.economic_email',
|
||||
'sci.economic_cvr',
|
||||
'sci.economic_mobile_phone',
|
||||
'sci.search_text',
|
||||
];
|
||||
// TRU-62: prefer the FULLTEXT path against the denormalized
|
||||
// `system_search_economic_customer_index.search_text` column. The
|
||||
// previous implementation ORed 13 un-indexable `LIKE '%term%'`
|
||||
// clauses, which dominated the ~10s request latency reported in
|
||||
// TRU-62. We only fall back to that LIKE path when the FULLTEXT
|
||||
// index is missing (e.g. migration not yet applied) or returns
|
||||
// zero rows for the query.
|
||||
$rows = $this->searchCustomersWithFulltext($terms, $customerFilter);
|
||||
if ($rows === null) {
|
||||
$rows = $this->searchCustomersWithLike($terms, $customerFilter);
|
||||
}
|
||||
|
||||
$rows = $this->searchTableWithJoin(
|
||||
'users',
|
||||
$fromClause,
|
||||
$selectFields,
|
||||
$searchFields,
|
||||
$terms,
|
||||
'1=1' . $customerFilter
|
||||
);
|
||||
|
||||
return array_map(function (array $row) use ($terms, $entityBoost) {
|
||||
$title = trim((string)($row['economic_name'] ?? ''));
|
||||
if ($title === '') {
|
||||
@@ -818,6 +784,166 @@ class system_search_service
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* TRU-62 — FULLTEXT path for customer search.
|
||||
*
|
||||
* Returns the matching rows from `users LEFT JOIN
|
||||
* system_search_economic_customer_index` using a `MATCH ... AGAINST`
|
||||
* query against the denormalized `search_text` column. This replaces
|
||||
* the 13-clause `LIKE '%term%'` OR chain that previously caused
|
||||
* ~10s customer-search latency. Returns `null` when the FULLTEXT
|
||||
* path is not available (index missing) or the boolean query is
|
||||
* empty (terms too short for the FULLTEXT minimum word length);
|
||||
* callers should then fall back to {@see searchCustomersWithLike()}.
|
||||
*
|
||||
* @param array<int, string> $terms
|
||||
* @return array<int, array<string, mixed>>|null
|
||||
*/
|
||||
private function searchCustomersWithFulltext(array $terms, string $customerFilter): ?array
|
||||
{
|
||||
if (empty($terms)) {
|
||||
return [];
|
||||
}
|
||||
if (!$this->isFulltextCustomerIndexAvailable()) {
|
||||
return null;
|
||||
}
|
||||
$booleanQuery = $this->buildBooleanFullTextQuery($terms);
|
||||
if ($booleanQuery === null) {
|
||||
// One or more terms are too short for the FULLTEXT minimum
|
||||
// word length. The LIKE path is the only viable option.
|
||||
return null;
|
||||
}
|
||||
|
||||
global $db;
|
||||
if (!is_object($db) || !method_exists($db, 'query')) {
|
||||
return null;
|
||||
}
|
||||
$escaped = $db->escape_string($booleanQuery);
|
||||
|
||||
$selectFields = [
|
||||
'u.id',
|
||||
'u.customer_number',
|
||||
'u.display_name',
|
||||
'u.email',
|
||||
'u.phone',
|
||||
'sci.economic_name',
|
||||
'sci.economic_address',
|
||||
'sci.economic_city',
|
||||
'sci.economic_zip',
|
||||
'sci.economic_email',
|
||||
'sci.economic_cvr',
|
||||
'sci.economic_mobile_phone',
|
||||
'sci.search_text',
|
||||
];
|
||||
$fromClause = 'users u LEFT JOIN `'
|
||||
. system_search_economic_customer_index::TABLE
|
||||
. '` sci ON sci.customer_number = u.customer_number';
|
||||
|
||||
$sql = "SELECT " . implode(', ', $selectFields)
|
||||
. " FROM " . $fromClause
|
||||
. " WHERE 1=1" . $customerFilter
|
||||
. " AND MATCH(sci.search_text) AGAINST ('" . $escaped . "' IN BOOLEAN MODE)"
|
||||
. " LIMIT " . $this->defaultEntityFetchLimit;
|
||||
|
||||
$rows = $this->runSelectRows($sql);
|
||||
if (empty($rows)) {
|
||||
// FULLTEXT is in use but the row set is empty. We could fall
|
||||
// back to LIKE here, but a fully-empty FULLTEXT result for a
|
||||
// customer-tab query usually means "no match" (the boolean
|
||||
// query already required all terms to be present). Avoid the
|
||||
// extra full-table scan and return an empty result set.
|
||||
return [];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* TRU-62 — original LIKE-based fallback for customer search. Kept
|
||||
* verbatim so that deployments which have not yet applied the
|
||||
* FULLTEXT migration still get correct results, just slowly.
|
||||
*
|
||||
* @param array<int, string> $terms
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function searchCustomersWithLike(array $terms, string $customerFilter): array
|
||||
{
|
||||
$selectFields = [
|
||||
'u.id',
|
||||
'u.customer_number',
|
||||
'u.display_name',
|
||||
'u.email',
|
||||
'u.phone',
|
||||
...$this->joinTemporalSelectFields('users', 'u'),
|
||||
];
|
||||
$searchFields = ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone'];
|
||||
$fromClause = 'users u';
|
||||
|
||||
if ($this->isEconomicCustomerIndexAvailable()) {
|
||||
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
|
||||
$selectFields = [
|
||||
...$selectFields,
|
||||
'sci.economic_name',
|
||||
'sci.economic_address',
|
||||
'sci.economic_city',
|
||||
'sci.economic_zip',
|
||||
'sci.economic_email',
|
||||
'sci.economic_cvr',
|
||||
'sci.economic_mobile_phone',
|
||||
'sci.search_text',
|
||||
];
|
||||
$searchFields = [
|
||||
...$searchFields,
|
||||
'sci.economic_name',
|
||||
'sci.economic_address',
|
||||
'sci.economic_city',
|
||||
'sci.economic_zip',
|
||||
'sci.economic_email',
|
||||
'sci.economic_cvr',
|
||||
'sci.economic_mobile_phone',
|
||||
'sci.search_text',
|
||||
];
|
||||
}
|
||||
|
||||
return $this->searchTableWithJoin(
|
||||
'users',
|
||||
$fromClause,
|
||||
$selectFields,
|
||||
$searchFields,
|
||||
$terms,
|
||||
'1=1' . $customerFilter
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the `system_search_economic_customer_index` table exists
|
||||
* AND the `ft_sseci_search_text` FULLTEXT index is present. The
|
||||
* index is added by the runtime schema bootstrap and the companion
|
||||
* migration at `database/migrations/2026_08_17_000002_*`.
|
||||
*/
|
||||
private function isFulltextCustomerIndexAvailable(): bool
|
||||
{
|
||||
if (!$this->isEconomicCustomerIndexAvailable()) {
|
||||
return false;
|
||||
}
|
||||
global $db;
|
||||
if (!is_object($db) || !method_exists($db, 'query')) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$indexName = $db->escape_string(system_search_economic_customer_index::FULLTEXT_INDEX);
|
||||
$result = $db->query(
|
||||
"SHOW INDEX FROM `" . system_search_economic_customer_index::TABLE
|
||||
. "` WHERE `Key_name` = '" . $indexName . "'"
|
||||
);
|
||||
if (!($result instanceof \mysqli_result)) {
|
||||
return false;
|
||||
}
|
||||
return $result->num_rows > 0;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function searchEmployees(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber): array
|
||||
{
|
||||
$rows = $this->searchTableWithJoin(
|
||||
|
||||
@@ -689,6 +689,38 @@ function EconomicTransferQueueCron(): void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer rule "auto-send invoice on the 3rd business day each month"
|
||||
* (TRU-70 / DRIFT 9).
|
||||
*
|
||||
* The handler is a no-op on every day except the 3rd business day of
|
||||
* the current month (Europe/Copenhagen timezone). On that day it scans
|
||||
* the customers with the `autoSendInvoiceThirdBusinessDay` attribute
|
||||
* and enqueues every ready collected invoice for export via the
|
||||
* existing `economic_transfer_queue` machinery. The actual e-conomic
|
||||
* send is handled asynchronously by `EconomicTransferQueueCron`.
|
||||
*/
|
||||
function AutoSendInvoicesThirdBusinessDay(): void
|
||||
{
|
||||
try {
|
||||
$service = new \classes\auto_send_invoice_third_business_day_service();
|
||||
$summary = $service->runOnce();
|
||||
if (!empty($summary['triggered'])) {
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] AutoSendInvoicesThirdBusinessDay trigger_date="
|
||||
. ($summary['trigger_date'] ?? '')
|
||||
. " customers=" . (int)($summary['customers'] ?? 0)
|
||||
. " collections=" . (int)($summary['collections_scanned'] ?? 0)
|
||||
. " enqueued=" . (int)($summary['jobs_enqueued'] ?? 0)
|
||||
. " already_queued=" . (int)($summary['skipped_already_queued'] ?? 0)
|
||||
. " errors=" . count($summary['errors'] ?? [])
|
||||
. "\n";
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
warn('AutoSendInvoicesThirdBusinessDay failed: ' . $e->getMessage());
|
||||
error_log('[cron-auto-send-third-business-day] failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function PruneSystemSessionActivityCron(): void
|
||||
{
|
||||
try {
|
||||
@@ -1389,7 +1421,49 @@ function GoalsProgressAlertsCron(): void
|
||||
case Dest::SLACK:
|
||||
$departments = (array)$goal->departments->value();
|
||||
$sentToDept = false;
|
||||
if (count($departments) > 0) {
|
||||
$internalDepartmentIds = [];
|
||||
try {
|
||||
$slackConfig = new Slack();
|
||||
if (method_exists($slackConfig, 'get_internal_department_ids')) {
|
||||
$internalDepartmentIds = array_map('intval', (array)$slackConfig->get_internal_department_ids());
|
||||
}
|
||||
} catch (Throwable $slackConfigError) {
|
||||
// Ignore - falls back to per-department webhooks
|
||||
$internalDepartmentIds = [];
|
||||
}
|
||||
$goalDeptIds = [];
|
||||
foreach ($departments as $deptId) {
|
||||
if (is_numeric($deptId)) {
|
||||
$goalDeptIds[] = (int)$deptId;
|
||||
}
|
||||
}
|
||||
$allInternal = count($goalDeptIds) > 0
|
||||
&& count(array_diff($goalDeptIds, $internalDepartmentIds)) === 0;
|
||||
|
||||
if ($allInternal) {
|
||||
// TRU-76: For internal departments (e.g. Taulov/Taastrup DHL daily
|
||||
// goal), post to the dedicated internal goal progress webhook
|
||||
// instead of per-department webhooks, which are typically empty
|
||||
// for internal locations.
|
||||
$internalWebhook = '';
|
||||
try {
|
||||
$slackInstance = new Slack();
|
||||
if (method_exists($slackInstance, 'get_internal_department_goal_progress_webhook_url')) {
|
||||
$internalWebhook = trim((string)$slackInstance->get_internal_department_goal_progress_webhook_url());
|
||||
}
|
||||
} catch (Throwable $internalWebhookError) {
|
||||
$internalWebhook = '';
|
||||
}
|
||||
if ($internalWebhook !== '') {
|
||||
(new Slack())->send_webhook_message((string)goals_progress_alert_renderer::render($criteria), $internalWebhook);
|
||||
$sentToDept = true;
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] GoalsProgressAlertsCron: goal #" . $goalId . " sent to internal goal progress webhook (departments: " . implode(',', $goalDeptIds) . ")\n";
|
||||
} else {
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] GoalsProgressAlertsCron: goal #" . $goalId . " has only internal departments but internal_department_goal_progress_webhook_url is empty; falling back to per-department webhooks\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (!$sentToDept && count($departments) > 0) {
|
||||
foreach ($departments as $deptId) {
|
||||
if (!is_numeric($deptId)) { continue; }
|
||||
$dept = (new departments_o())->select((int)$deptId);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
/**
|
||||
* Migration: create_api_keys_table
|
||||
* Issue: TRU-143 — [Backend] API key data model + storage schema
|
||||
* Date: 2026-08-17
|
||||
*
|
||||
* NOTE: This codebase does not run a migration framework; the
|
||||
* canonical DDL is applied idempotently at runtime by
|
||||
* `classes/api_key_schema_bootstrap.php`. This file is the
|
||||
* human-readable change record / source of truth for the schema.
|
||||
*
|
||||
* To apply manually:
|
||||
* mysql -u <user> -p <database> < 2026_08_17_000001_create_api_keys_table.sql
|
||||
*/
|
||||
|
||||
return [
|
||||
'id' => '2026_08_17_000001_create_api_keys_table',
|
||||
'issue' => 'TRU-143',
|
||||
'table' => 'api_keys',
|
||||
'engine' => 'InnoDB',
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'up' => [
|
||||
"CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
key_id VARCHAR(64) NOT NULL,
|
||||
key_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(32) NOT NULL,
|
||||
scopes JSON NULL,
|
||||
customer_id BIGINT UNSIGNED NULL,
|
||||
created_by BIGINT UNSIGNED NULL,
|
||||
last_used_at TIMESTAMP NULL,
|
||||
expires_at TIMESTAMP NULL,
|
||||
revoked_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_api_keys_key_id (key_id),
|
||||
INDEX idx_api_keys_customer (customer_id),
|
||||
INDEX idx_api_keys_key_hash (key_hash),
|
||||
INDEX idx_api_keys_revoked (revoked_at),
|
||||
INDEX idx_api_keys_role (role)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
],
|
||||
'down' => [
|
||||
'DROP TABLE IF EXISTS api_keys',
|
||||
],
|
||||
];
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
/**
|
||||
* Migration: add_fulltext_to_system_search_economic_customer_index
|
||||
* Issue: TRU-62 — System is very slow - search on customer tab ~10s
|
||||
* Date: 2026-08-17
|
||||
*
|
||||
* The `system_search_economic_customer_index.search_text` column is a
|
||||
* denormalized blob containing all customer-name / address / email / phone
|
||||
* data concatenated. The customer search currently runs
|
||||
*
|
||||
* `field LIKE '%term%'`
|
||||
*
|
||||
* for 13 fields, which forces a full table scan and dominates the
|
||||
* ~10s request latency. A FULLTEXT index on the same column lets the
|
||||
* same search run in tens of milliseconds.
|
||||
*
|
||||
* NOTE: This codebase does not run a migration framework; the canonical
|
||||
* DDL is applied idempotently at runtime by
|
||||
* `classes/system_search_economic_customer_index::ensureTable()`. This
|
||||
* file is the human-readable change record / source of truth for the
|
||||
* schema. See TRU-62 investigation doc at
|
||||
* `documentation/perf/customer-search-slow-investigation.md`.
|
||||
*
|
||||
* To apply manually:
|
||||
* mysql -u <user> -p <database> \
|
||||
* -e "ALTER TABLE \`system_search_economic_customer_index\`
|
||||
* ADD FULLTEXT INDEX \`ft_sseci_search_text\` (\`search_text\`);"
|
||||
*/
|
||||
|
||||
return [
|
||||
'id' => '2026_08_17_000002_add_fulltext_to_system_search_economic_customer_index',
|
||||
'issue' => 'TRU-62',
|
||||
'table' => 'system_search_economic_customer_index',
|
||||
'up' => [
|
||||
'ALTER TABLE `system_search_economic_customer_index`
|
||||
ADD FULLTEXT INDEX `ft_sseci_search_text` (`search_text`)',
|
||||
],
|
||||
'down' => [
|
||||
'ALTER TABLE `system_search_economic_customer_index`
|
||||
DROP INDEX `ft_sseci_search_text`',
|
||||
],
|
||||
];
|
||||
@@ -213,6 +213,18 @@ try {
|
||||
$response->error($e->getMessage(), 500);
|
||||
}
|
||||
|
||||
// Self-healing schema bootstrap. Runs every *_schema_bootstrap::ensureSchema()
|
||||
// once per process. Each is additive + idempotent (SHOW COLUMNS check before
|
||||
// any ALTER), so this is safe on every request. Catches the
|
||||
// "merged-to-master-but-migration-never-applied" failure mode (e.g. TRU-77
|
||||
// invoice_email) even when the deploy pipeline pre-deploy step is skipped
|
||||
// (missing GitHub secrets, network glitch, manual deploy, etc.).
|
||||
try {
|
||||
\classes\schema_bootstrap_runtime::runAll();
|
||||
} catch (Throwable $e) {
|
||||
error_log('[schema-bootstrap] runtime::runAll() failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
release_manager::initializeRequestContext();
|
||||
$releaseIngressPath = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: '');
|
||||
|
||||
@@ -61,4 +61,16 @@ return [
|
||||
'estimated_duration_ms' => 3000,
|
||||
'priority' => 20,
|
||||
],
|
||||
[
|
||||
'id' => 'economic.auto_send_invoices_third_business_day',
|
||||
'legacy_name' => 'AutoSendInvoicesThirdBusinessDay',
|
||||
'name' => 'Auto-send invoices on the 3rd business day each month',
|
||||
'description' => 'For customers with the autoSendInvoiceThirdBusinessDay attribute, enqueue every ready collected invoice for export on the 3rd business day of the month. The handler is a no-op on every other day.',
|
||||
'module' => 'economic',
|
||||
'handler' => 'AutoSendInvoicesThirdBusinessDay',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 86400],
|
||||
'timeout_seconds' => 900,
|
||||
'estimated_duration_ms' => 5000,
|
||||
'priority' => 25,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -76,9 +76,14 @@ class economicCustomers extends economic_m
|
||||
// The discount is global, but e-conomic resolves it through a product-specific
|
||||
// invoice-line template. For foreign-currency customers some templates can fail
|
||||
// if that product has no price in the customer currency, so try a few products
|
||||
// before falling back to zero.
|
||||
// before falling back to zero. We log every swallowed currency-price failure so
|
||||
// silently-missing discounts (e.g. bug #11 customer 35131752 "kd" 15%) become
|
||||
// visible in the application log instead of vanishing into the void.
|
||||
$products = $this->getCustomerProducts($customer_number, 10);
|
||||
$attempted_products = 0;
|
||||
$swallowed_errors = 0;
|
||||
foreach ($this->extractCustomerProductNumbers($products) as $product_number) {
|
||||
$attempted_products++;
|
||||
try {
|
||||
$discount = $this->getCustomerProductDiscount($customer_number, $product_number);
|
||||
return (int)($discount->discountPercentage ?? 0);
|
||||
@@ -86,9 +91,24 @@ class economicCustomers extends economic_m
|
||||
if (!$this->isMissingCurrencyPriceLookupError($exception)) {
|
||||
throw $exception;
|
||||
}
|
||||
$swallowed_errors++;
|
||||
error_log(sprintf(
|
||||
'[economicCustomers] Swallowed missing-currency-price error while resolving discount for customer %d product %d: %s',
|
||||
$customer_number,
|
||||
$product_number,
|
||||
$exception->getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if ($attempted_products > 0 && $swallowed_errors === $attempted_products) {
|
||||
error_log(sprintf(
|
||||
'[economicCustomers] All %d invoice-line template probes failed with missing currency prices for customer %d; falling back to 0%% discount. Verify "economic_customer_discount_percentage" in e-conomic for this customer.',
|
||||
$attempted_products,
|
||||
$customer_number
|
||||
));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -72,7 +72,7 @@ class economic_invoices_draft_endpoint
|
||||
* @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array<int,int>}
|
||||
* @throws Exception If the request fails
|
||||
*/
|
||||
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500, bool $use_itemized_discounts = false): array
|
||||
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500, bool $use_itemized_discounts = false, int $customer_discount_percentage = 0): array
|
||||
{
|
||||
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
|
||||
$orders_with_invoice_lines = 0;
|
||||
@@ -89,8 +89,8 @@ class economic_invoices_draft_endpoint
|
||||
$orders_with_invoice_lines++;
|
||||
// Add the transaction header (Timestamp, department, etc.)
|
||||
$draftInvoice->addNewTransactionHeader($order);
|
||||
// Add the order lines
|
||||
$draftInvoice->addOrderItemLines($order, $use_itemized_discounts);
|
||||
// Add the order lines (including the customer-level e-conomic discount, if any).
|
||||
$draftInvoice->addOrderItemLines($order, $use_itemized_discounts, $customer_discount_percentage);
|
||||
// Add an empty line, so the invoice is not empty
|
||||
$draftInvoice->addTextLine('');
|
||||
}
|
||||
|
||||
+17
-7
@@ -125,10 +125,15 @@ class economic_invoices_drafts_endpoint
|
||||
$customer = (new economic())->getCustomer($customer_number);
|
||||
|
||||
// Set the recipient details
|
||||
$customer_name = $customer->getName() ?? 'Ukendt';
|
||||
$customer_address = $customer->getAddress() ?? 'Ukendt';
|
||||
$customer_zip = $customer->getZipCode() ?? 'Ukendt';
|
||||
$customer_city = $customer->getCity() ?? 'Ukendt';
|
||||
// Note: customer_* fields come from e-conomic itself (controlled input),
|
||||
// but we sanitize them defensively to avoid 400s if e-conomic ever stores
|
||||
// a value with chars e-conomic later rejects in the recipient block.
|
||||
// Each field uses an appropriate length cap to match the corresponding
|
||||
// e-conomic recipient field limits.
|
||||
$customer_name = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getName() ?? 'Ukendt', 100);
|
||||
$customer_address = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getAddress() ?? 'Ukendt', 250);
|
||||
$customer_zip = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getZipCode() ?? 'Ukendt', 20);
|
||||
$customer_city = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getCity() ?? 'Ukendt', 100);
|
||||
$recipient = [
|
||||
'name' => $customer_name,
|
||||
'address' => $customer_address,
|
||||
@@ -139,9 +144,14 @@ class economic_invoices_drafts_endpoint
|
||||
],
|
||||
];
|
||||
$customer_ean = $customer->getEan();
|
||||
if ($customer_ean !== null) {
|
||||
$recipient['ean'] = $customer_ean;
|
||||
$recipient['nemHandelType'] = 'ean';
|
||||
if ($customer_ean !== null && $customer_ean !== '') {
|
||||
// EAN should be digits only; sanitize to strip anything that slipped through
|
||||
$recipient['ean'] = preg_replace('/[^0-9]/', '', $customer_ean);
|
||||
if ($recipient['ean'] !== '') {
|
||||
$recipient['nemHandelType'] = 'ean';
|
||||
} else {
|
||||
unset($recipient['ean']);
|
||||
}
|
||||
}
|
||||
$public_entry_number = $customer->getPublicEntryNumber();
|
||||
if ($public_entry_number !== null) {
|
||||
|
||||
@@ -42,6 +42,13 @@ class economic_invoice_draft
|
||||
*/
|
||||
protected float $conversion_rate = 1.0;
|
||||
|
||||
/**
|
||||
* Whether pre-flight validation runs inside addLines() before sending to e-conomic.
|
||||
* Defense in depth — even after sanitization, a final check catches anything that slips through.
|
||||
* @var bool $preflight_enabled
|
||||
*/
|
||||
protected bool $preflight_enabled = true;
|
||||
|
||||
|
||||
/**
|
||||
* Construct a new Economic draft invoice object
|
||||
@@ -116,9 +123,142 @@ class economic_invoice_draft
|
||||
*/
|
||||
public function addLines(): void
|
||||
{
|
||||
if ($this->preflight_enabled) {
|
||||
$this->preflightValidate($this->draft_lines, null);
|
||||
}
|
||||
$this->flushLinesInBatches();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-flight validation: defense in depth before sending to e-conomic.
|
||||
* Validates each line against 5 rules and throws RuntimeException on the first violation.
|
||||
*
|
||||
* Rules (in order, per line):
|
||||
* 1. description — must be non-empty after trim()
|
||||
* 2. description — must be <= 250 chars
|
||||
* 3. productNumber (if present in product.productNumber) — must match /^[A-Za-z0-9._-]{1,50}$/
|
||||
* 4. quantity — must be a positive number (> 0)
|
||||
* 5. unitNetPrice — must be a number (>= 0)
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $lines
|
||||
* @param int|null $orderId Optional order id for log context
|
||||
* @throws \RuntimeException on any rule violation
|
||||
*/
|
||||
public function preflightValidate(array $lines, ?int $orderId = null): void
|
||||
{
|
||||
foreach ($lines as $i => $line) {
|
||||
if (!is_array($line)) {
|
||||
$this->logAndThrow(
|
||||
$i,
|
||||
$orderId,
|
||||
'line is not an array',
|
||||
$line
|
||||
);
|
||||
}
|
||||
|
||||
// Rule 1 + 2: description
|
||||
$description = $line['description'] ?? null;
|
||||
if ($description === null) {
|
||||
$description = '';
|
||||
}
|
||||
if (!is_scalar($description)) {
|
||||
$description = (string)$description;
|
||||
} else {
|
||||
$description = (string)$description;
|
||||
}
|
||||
$descriptionTrimmed = trim($description);
|
||||
if ($descriptionTrimmed === '') {
|
||||
$this->logAndThrow(
|
||||
$i,
|
||||
$orderId,
|
||||
'description is empty',
|
||||
$description
|
||||
);
|
||||
}
|
||||
if (mb_strlen($descriptionTrimmed) > 250) {
|
||||
$this->logAndThrow(
|
||||
$i,
|
||||
$orderId,
|
||||
'description exceeds 250 chars (length=' . mb_strlen($descriptionTrimmed) . ')',
|
||||
$description
|
||||
);
|
||||
}
|
||||
|
||||
// Rule 3: productNumber (only if present in product.productNumber)
|
||||
if (isset($line['product']) && is_array($line['product']) && array_key_exists('productNumber', $line['product'])) {
|
||||
$productNumber = $line['product']['productNumber'];
|
||||
if ($productNumber === null) {
|
||||
$productNumber = '';
|
||||
} else {
|
||||
$productNumber = (string)$productNumber;
|
||||
}
|
||||
if (!preg_match('/^[A-Za-z0-9._-]{1,50}$/', $productNumber)) {
|
||||
$this->logAndThrow(
|
||||
$i,
|
||||
$orderId,
|
||||
'productNumber does not match /^[A-Za-z0-9._-]{1,50}$/',
|
||||
$productNumber
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 4: quantity — only required if present in the line (text lines omit it)
|
||||
if (array_key_exists('quantity', $line)) {
|
||||
$quantity = $line['quantity'];
|
||||
if (!is_numeric($quantity) || (float)$quantity <= 0) {
|
||||
$this->logAndThrow(
|
||||
$i,
|
||||
$orderId,
|
||||
'quantity is not a positive number',
|
||||
$quantity
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 5: unitNetPrice — only required if present in the line
|
||||
if (array_key_exists('unitNetPrice', $line)) {
|
||||
$unitNetPrice = $line['unitNetPrice'];
|
||||
if (!is_numeric($unitNetPrice) || (float)$unitNetPrice < 0) {
|
||||
$this->logAndThrow(
|
||||
$i,
|
||||
$orderId,
|
||||
'unitNetPrice is not a number >= 0',
|
||||
$unitNetPrice
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the offending line and throw a RuntimeException.
|
||||
*/
|
||||
private function logAndThrow(int $lineIndex, ?int $orderId, string $rule, mixed $value): never
|
||||
{
|
||||
$valueTruncated = is_scalar($value) ? (string)$value : json_encode($value);
|
||||
if ($valueTruncated === false) {
|
||||
$valueTruncated = '[unserializable]';
|
||||
}
|
||||
if (mb_strlen($valueTruncated) > 200) {
|
||||
$valueTruncated = mb_substr($valueTruncated, 0, 200) . '...';
|
||||
}
|
||||
$orderContext = $orderId === null ? 'order=n/a' : 'order=' . $orderId;
|
||||
error_log(sprintf(
|
||||
'[preflight] validation failed: %s | line=%d | %s | value=%s',
|
||||
$rule,
|
||||
$lineIndex,
|
||||
$orderContext,
|
||||
$valueTruncated
|
||||
));
|
||||
$orderPart = $orderId === null ? '' : ' (order ' . $orderId . ')';
|
||||
throw new \RuntimeException(sprintf(
|
||||
'Preflight validation failed for line %d: %s%s',
|
||||
$lineIndex,
|
||||
$rule,
|
||||
$orderPart
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add queued draft lines using chunked requests.
|
||||
*
|
||||
@@ -185,45 +325,55 @@ class economic_invoice_draft
|
||||
$department_name = (new departments_o())->getDepartmentName((int)$order->department_id->value());
|
||||
// Parse the date of the transaction.
|
||||
$parsed_date = date('d/m/Y H:i', strtotime($order->created_at->value()));
|
||||
// Sanitize the department name (could contain "/" or other chars)
|
||||
$department_name = \classes\economic_export_sanitizer::sanitizeTextLine($department_name, 100);
|
||||
// Add the text line to the draft invoice
|
||||
self::addTextLine("[ " . $parsed_date . ' ' . $department_name . ' #' . $order->id . " ]");
|
||||
// If there's a PO number, add it to the invoice
|
||||
if ($order->po->value() !== '') {
|
||||
self::addTextLine('PO: ' . $order->po->value());
|
||||
self::addTextLine('PO: ' . \classes\economic_export_sanitizer::sanitizeTextLine($order->po->value()));
|
||||
}
|
||||
// If there's a reference, add it to the invoice
|
||||
if ($order->reference->value() !== '') {
|
||||
$reference_value = $order->reference->value();
|
||||
if ($reference_value !== '') {
|
||||
self::addTextLine('Reference:');
|
||||
// Sanitize the whole reference (handles "/" → "-" per TRU-188)
|
||||
$reference_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($reference_value);
|
||||
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
|
||||
if (str_contains($order->reference->value(), "\n")) {
|
||||
foreach ( explode("\n", $order->reference->value()) as $line ) {
|
||||
if (str_contains($reference_sanitized, "\n")) {
|
||||
foreach ( explode("\n", $reference_sanitized) as $line ) {
|
||||
self::addTextLine('# ' . $line);
|
||||
}
|
||||
} else {
|
||||
self::addTextLine('# ' . $order->reference->value());
|
||||
self::addTextLine('# ' . $reference_sanitized);
|
||||
}
|
||||
}
|
||||
// Add the registration numbers (if any)
|
||||
$line_reg = '';
|
||||
if ($order->reg_1->value() !== '')
|
||||
$line_reg .= 'Reg 1: ' . strtoupper($order->reg_1->value());
|
||||
if ($order->reg_2->value() !== '')
|
||||
$line_reg .= ', Reg 2: ' . strtoupper($order->reg_2->value());
|
||||
if ($order->reg_3->value() !== '')
|
||||
$line_reg .= ', Reg 3: ' . strtoupper($order->reg_3->value());
|
||||
if ($order->reg_1->value() !== '') {
|
||||
$line_reg .= 'Reg 1: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_1->value(), 50));
|
||||
}
|
||||
if ($order->reg_2->value() !== '') {
|
||||
$line_reg .= ', Reg 2: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_2->value(), 50));
|
||||
}
|
||||
if ($order->reg_3->value() !== '') {
|
||||
$line_reg .= ', Reg 3: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_3->value(), 50));
|
||||
}
|
||||
// Add the line to the invoice (If there's any registration numbers)
|
||||
if ($line_reg !== '')
|
||||
self::addTextLine($line_reg);
|
||||
// If there's a note, add it to the invoice
|
||||
if ($order->notes->value() !== '') {
|
||||
$notes_value = $order->notes->value();
|
||||
if ($notes_value !== '') {
|
||||
self::addTextLine('Notat:');
|
||||
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
|
||||
if (str_contains($order->notes->value(), "\n")) {
|
||||
foreach ( explode("\n", $order->notes->value()) as $line ) {
|
||||
// Sanitize notes (could contain "/", newlines, special chars)
|
||||
$notes_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($notes_value);
|
||||
if (str_contains($notes_sanitized, "\n")) {
|
||||
foreach ( explode("\n", $notes_sanitized) as $line ) {
|
||||
self::addTextLine('# ' . $line);
|
||||
}
|
||||
} else {
|
||||
self::addTextLine('# ' . $order->notes->value());
|
||||
self::addTextLine('# ' . $notes_sanitized);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,11 +386,26 @@ class economic_invoice_draft
|
||||
*/
|
||||
public function addTextLine(string $text): void
|
||||
{
|
||||
// Defense in depth: sanitize ALL text lines at insertion time.
|
||||
// This catches anything that wasn't pre-sanitized at the call site.
|
||||
$sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($text);
|
||||
if ($sanitized === '') {
|
||||
return; // Skip empty/whitespace-only lines
|
||||
}
|
||||
$this->draft_lines[] = [
|
||||
'description' => $text
|
||||
'description' => $sanitized
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current draft lines (read-only view).
|
||||
* Used by integration tests; production code uses addLines() to send.
|
||||
*/
|
||||
public function getDraftLines(): array
|
||||
{
|
||||
return $this->draft_lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an order to the draft invoice
|
||||
* @note The lines won't be saved until the addLines() method is called.
|
||||
@@ -249,7 +414,7 @@ class economic_invoice_draft
|
||||
* @throws Exception if the order is not found
|
||||
* @throws Exception if the order is not valid
|
||||
*/
|
||||
public function addOrderItemLines(orders_o $order, bool $use_itemized_discounts = false): void
|
||||
public function addOrderItemLines(orders_o $order, bool $use_itemized_discounts = false, int $customer_discount_percentage = 0): void
|
||||
{
|
||||
// Get the order items
|
||||
$order_items = $order->getOrderItems($order->id);
|
||||
@@ -268,18 +433,25 @@ class economic_invoice_draft
|
||||
});
|
||||
// Define the total discount applied to the order
|
||||
$total_discount = 0;
|
||||
// Normalize the customer discount percentage (clamp to 0..100)
|
||||
$customer_discount_percentage = max(0, min(100, $customer_discount_percentage));
|
||||
// Force itemized discount mode when the customer has a global e-conomic discount
|
||||
// so the discount is applied at the line level (e-conomic line API requires per-line
|
||||
// discountPercentage; an aggregate TotDiscount line would be ignored when the
|
||||
// customer does not have a per-line discount configured for the customer).
|
||||
$effective_itemized_discounts = $use_itemized_discounts || $customer_discount_percentage > 0;
|
||||
// Loop through the order items
|
||||
foreach ( $order_items as $order_item ) {
|
||||
if ($this->shouldSkipOrderItemLine($order_item)) {
|
||||
continue;
|
||||
}
|
||||
// Add the order item to the draft invoice
|
||||
self::addOrderItemLine($order_item, $department, false, $use_itemized_discounts);
|
||||
self::addOrderItemLine($order_item, $department, false, $effective_itemized_discounts, $customer_discount_percentage);
|
||||
// Add the line discount to the total discount
|
||||
$total_discount += ($order_item['product']['price'] - $order_item['price']) * $order_item['quantity'];
|
||||
}
|
||||
// If the total discount is greater than 0, add it to the invoice
|
||||
if (!$use_itemized_discounts && $total_discount > 0) {
|
||||
if (!$use_itemized_discounts && $customer_discount_percentage === 0 && $total_discount > 0) {
|
||||
// Add the discount to the invoice
|
||||
self::addProductDiscountLine($total_discount, $department['economic_department_id'] ?? 0, $department['dimension'] ?? 0);
|
||||
}
|
||||
@@ -295,7 +467,7 @@ class economic_invoice_draft
|
||||
* @throws Exception if the order item is not found
|
||||
* @throws Exception if the order item is not valid
|
||||
*/
|
||||
public function addOrderItemLine(array $order_item, array $department, bool $show_discount = false, bool $use_itemized_discount = false): void
|
||||
public function addOrderItemLine(array $order_item, array $department, bool $show_discount = false, bool $use_itemized_discount = false, int $customer_discount_percentage = 0): void
|
||||
{
|
||||
// Check if the order item is valid
|
||||
if (!isset($order_item['id'])) {
|
||||
@@ -309,9 +481,18 @@ class economic_invoice_draft
|
||||
// Get the dimension id
|
||||
$economic_dimension_id = $department['economic_dimension_id'] ?? 0;
|
||||
$pricing = self::resolveOrderItemInvoicePricing($order_item);
|
||||
$discount_percentage = $use_itemized_discount
|
||||
// The customer discount (e.g. kd customer 35131752 with 15% global e-conomic discount)
|
||||
// is applied at the line level. Combined with per-item discounts using max() so the
|
||||
// biggest discount wins, and so we never accidentally apply a 15% discount on top of
|
||||
// an already-discounted per-item price.
|
||||
$customer_discount_percentage = max(0, min(100, $customer_discount_percentage));
|
||||
$itemized_discount_percentage = $use_itemized_discount
|
||||
? $this->resolveItemizedDiscountPercentageForInvoiceCurrency($pricing)
|
||||
: 0;
|
||||
: 0.0;
|
||||
$discount_percentage = (float)max(
|
||||
$itemized_discount_percentage,
|
||||
(float)$customer_discount_percentage
|
||||
);
|
||||
// Add the order item to the draft invoice
|
||||
self::addProductLine(
|
||||
(string)$order_item['product']['economic_product_id'],
|
||||
@@ -341,26 +522,28 @@ class economic_invoice_draft
|
||||
// If there's a reference, add it to the line
|
||||
if ($order_item['reference'] !== '') {
|
||||
self::addTextLine('Reference:');
|
||||
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
|
||||
if (str_contains($order_item['reference'], "\n")) {
|
||||
foreach ( explode("\n", $order_item['reference']) as $line ) {
|
||||
// Sanitize the reference (handles "/" → "-" per TRU-188)
|
||||
$item_reference_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($order_item['reference']);
|
||||
if (str_contains($item_reference_sanitized, "\n")) {
|
||||
foreach ( explode("\n", $item_reference_sanitized) as $line ) {
|
||||
self::addTextLine('# ' . $line);
|
||||
}
|
||||
} else {
|
||||
self::addTextLine('# ' . $order_item['reference']);
|
||||
self::addTextLine('# ' . $item_reference_sanitized);
|
||||
}
|
||||
}
|
||||
|
||||
// If there's a note, add it to the line
|
||||
if (!empty($order_item['notes'])) {
|
||||
self::addTextLine('Notat:');
|
||||
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
|
||||
if (str_contains($order_item['notes'], "\n")) {
|
||||
foreach ( explode("\n", $order_item['notes']) as $line ) {
|
||||
// Sanitize notes (could contain "/", newlines, special chars)
|
||||
$item_notes_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($order_item['notes']);
|
||||
if (str_contains($item_notes_sanitized, "\n")) {
|
||||
foreach ( explode("\n", $item_notes_sanitized) as $line ) {
|
||||
self::addTextLine('# ' . $line);
|
||||
}
|
||||
} else {
|
||||
self::addTextLine('# ' . $order_item['notes']);
|
||||
self::addTextLine('# ' . $item_notes_sanitized);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,6 +653,13 @@ class economic_invoice_draft
|
||||
*/
|
||||
public function addProductLine(string $productNumber, string $description, float $quantity, float $unitNetPrice, int $economic_department_id, int $dimension, float $discountPercentage = 0): void
|
||||
{
|
||||
// Sanitize product identifier and description (defense in depth — also done at addLines())
|
||||
$productNumber = \classes\economic_export_sanitizer::sanitizeProductNumber($productNumber);
|
||||
$description = \classes\economic_export_sanitizer::sanitizeProductDescription($description);
|
||||
// Skip if sanitization removed everything
|
||||
if ($productNumber === '' || $description === '') {
|
||||
return;
|
||||
}
|
||||
// We then add a 0 in front of the product number to make sure it's the right one, made by FlexPOS.
|
||||
// Add a line to the invoice
|
||||
$line = [
|
||||
|
||||
@@ -32,7 +32,7 @@ class email_template_stripe_invoice
|
||||
|
||||
<!-- Email template -->
|
||||
<p>Kære <?= $this->name ?>,</p>
|
||||
<p>Tak for din bestilling hos Truck Wash. Vi har sendt dig en faktura på Stripe.
|
||||
<p>Tak for din bestilling hos Truck Wash. Vi har sendt dig et betalingslink til din faktura.
|
||||
Du kan betale fakturaen ved at klikke på linket nedenfor:</p>
|
||||
<p><a href="<?= $this->stripe_payment_link ?>">Betal faktura for ordre <?= $this->order_id ?></a></p>
|
||||
<!-- End of the email template -->
|
||||
|
||||
@@ -205,10 +205,12 @@ class bookings_o extends db
|
||||
$sql = "SELECT * FROM $this->table WHERE id = $id";
|
||||
$result = $db->query($sql);
|
||||
if ($db->num_rows($result) === 0) {
|
||||
// Send a department webhook if the booking is new
|
||||
// Send a department webhook if the booking is new.
|
||||
// TRU-106: send_new_booking_notification() filters out drop-offs
|
||||
// (pickup_bool = 0) so only pickup bookings post to Slack.
|
||||
$slack = new slack();
|
||||
try {
|
||||
$slack->send_department_booking_notification($department, $slack->format_new_booking(
|
||||
$slack->send_new_booking_notification(
|
||||
$id,
|
||||
$customer_number,
|
||||
$wash_type,
|
||||
@@ -219,12 +221,12 @@ class bookings_o extends db
|
||||
$washCertificateEmail,
|
||||
$date,
|
||||
$department,
|
||||
$pickup_bool,
|
||||
(bool)$pickup_bool,
|
||||
$notes,
|
||||
$washCertificateStatus,
|
||||
$washCertificateUrl,
|
||||
$status
|
||||
));
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
// Log the error
|
||||
$logs = new logs_o();
|
||||
@@ -313,11 +315,13 @@ class bookings_o extends db
|
||||
!$deliverSlack // Only send email if slack is not available
|
||||
);
|
||||
// Check if the department has a slack webhook
|
||||
// TRU-106: send_new_booking_notification() filters out drop-offs
|
||||
// (pickup_bool = false) so only pickup bookings post to Slack.
|
||||
if ($deliverSlack) {
|
||||
// Send a notification to the department
|
||||
$slack = new slack();
|
||||
try {
|
||||
$slack->send_department_booking_notification($department->id, $slack->format_new_booking(
|
||||
$slack->send_new_booking_notification(
|
||||
$this->id,
|
||||
$customer_array['customer_number'],
|
||||
self::formatWashTypeFromServices(json_decode($this->data->value(), true)),
|
||||
@@ -333,7 +337,7 @@ class bookings_o extends db
|
||||
$this->washCertificateStatus->value(),
|
||||
$this->washCertificateUrl->value(),
|
||||
$this->status->value()
|
||||
));
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
// Previously this bare call would crash the entire
|
||||
// notifyNewBooking() flow if Slack returned non-2xx, so
|
||||
|
||||
@@ -725,6 +725,52 @@ class collected_order_invoices_o extends db
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the e-conomic customer discount percentage that should be applied at the
|
||||
* line level when building the invoice draft. Caches via Redis to avoid hammering
|
||||
* the e-conomic templates endpoint on every draft sync.
|
||||
*/
|
||||
private static function resolveCustomerDiscountPercentageForDraft(int $customer_number): int
|
||||
{
|
||||
if ($customer_number <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$user = (new users_o())->getUserByCustomerNumber($customer_number);
|
||||
$userId = (int)$user->id;
|
||||
if ($userId > 0 && defined('redis')) {
|
||||
try {
|
||||
$cached = constant('redis')->get_economic_customer_discount_percentage($userId);
|
||||
if ($cached !== null) {
|
||||
return max(0, min(100, (int)$cached));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Fall through to the live lookup.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$discount = (int)(new \customers\economicCustomers())->getCustomerDiscountPercentage($customer_number);
|
||||
} catch (\Throwable $e) {
|
||||
error_log(sprintf(
|
||||
'[collected_order_invoices_o] Failed to resolve e-conomic customer discount for customer %d: %s',
|
||||
$customer_number,
|
||||
$e->getMessage()
|
||||
));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($userId > 0 && defined('redis')) {
|
||||
try {
|
||||
constant('redis')->cache_economic_customer_discount_percentage($userId, $discount);
|
||||
} catch (\Throwable $e) {
|
||||
// Cache failures are non-fatal.
|
||||
}
|
||||
}
|
||||
|
||||
return max(0, min(100, $discount));
|
||||
}
|
||||
|
||||
/**
|
||||
* Require the invoice draft to not already exist
|
||||
* @throws Exception If the request was not successful
|
||||
@@ -964,7 +1010,18 @@ class collected_order_invoices_o extends db
|
||||
break;
|
||||
}
|
||||
}
|
||||
$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency, 500, $use_itemized_discounts);
|
||||
// Look up the customer-level e-conomic discount (e.g. bug #11 customer 35131752
|
||||
// "kd" 15%). This is applied at the line level so the draft invoice carries the
|
||||
// discount percentage that e-conomic expects for the customer.
|
||||
$customer_discount_percentage = self::resolveCustomerDiscountPercentageForDraft((int)$this->customer_number->value());
|
||||
$metrics = (new economic())->invoices->draft->add_orders(
|
||||
$draft_id,
|
||||
$order_objects,
|
||||
$currency,
|
||||
500,
|
||||
$use_itemized_discounts,
|
||||
$customer_discount_percentage
|
||||
);
|
||||
$this->last_economic_transfer_metrics = [
|
||||
'draft_invoice_id' => $draft_id,
|
||||
'currency' => (string)$currency,
|
||||
|
||||
@@ -906,6 +906,68 @@ class orders_o extends db
|
||||
return (bool)$count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the timestamp of the most recent completed wash for a license plate.
|
||||
* Used by the front page to show the "last washed" hint when a plate is
|
||||
* scanned (DHL trailer pick-up use case, TRU-78 / DRIFT 17).
|
||||
*
|
||||
* Only orders that have at least one non-deleted order item are
|
||||
* considered (mirrors the contract used by customer_vehicles_o::
|
||||
* getLastOrderByPlate() so the timestamp is always backed by a real wash).
|
||||
*
|
||||
* @param string $reg_1 The license plate to look up
|
||||
* @return string|null MySQL datetime string of the most recent qualifying
|
||||
* order's `created_at`, or null when the plate has
|
||||
* never been washed.
|
||||
*/
|
||||
public function getLastWashTimestampForPlate(string $reg_1): ?string
|
||||
{
|
||||
$normalized_reg_1 = trim($reg_1);
|
||||
if ($normalized_reg_1 === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$orders = self::getFieldsWhere([
|
||||
'reg_1' => $normalized_reg_1,
|
||||
'deleted_at' => null,
|
||||
], [
|
||||
'id',
|
||||
]);
|
||||
|
||||
// Walk the orders newest-first and return the first one that actually
|
||||
// has at least one non-deleted order item.
|
||||
$candidate_ids = array_reverse(array_map(static function ($row) {
|
||||
return (int)($row['id'] ?? 0);
|
||||
}, $orders));
|
||||
|
||||
foreach ($candidate_ids as $order_id) {
|
||||
if ($order_id <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$has_items = (new order_items_o())->getFieldsWhere([
|
||||
'order_id' => $order_id,
|
||||
'deleted_at' => null,
|
||||
], ['id']);
|
||||
|
||||
if (count($has_items) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$details = self::getFieldsWhere([
|
||||
'id' => $order_id,
|
||||
'deleted_at' => null,
|
||||
], ['created_at']);
|
||||
|
||||
$created_at = $details[0]['created_at'] ?? null;
|
||||
if (is_string($created_at) && $created_at !== '') {
|
||||
return $created_at;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getFixedPricingTransactions(int $customer_number, false $asArray, string|null $dateFrom = null, string|null $dateTo = null): array
|
||||
{
|
||||
// Get the fixed pricing transactions for a customer
|
||||
|
||||
@@ -10344,8 +10344,11 @@ paths:
|
||||
post:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Create Stripe invoice
|
||||
description: Create an invoice in Stripe
|
||||
summary: Create Stripe invoice (retired - TRU-74 / DRIFT 13)
|
||||
description: |
|
||||
Retired in favour of in-store card payments. Always returns HTTP 410
|
||||
with `code: stripe_email_payment_disabled` so the POS can fall back
|
||||
to the standard card-payment flow.
|
||||
operationId: createStripeInvoice
|
||||
requestBody:
|
||||
required: false
|
||||
@@ -10353,11 +10356,41 @@ paths:
|
||||
application/json:
|
||||
schema: {}
|
||||
responses:
|
||||
'201':
|
||||
description: Stripe invoice created successfully
|
||||
'410':
|
||||
description: Direct Stripe payment links by email are no longer available
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
example: stripe_email_payment_disabled
|
||||
message:
|
||||
type: string
|
||||
delete:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Cancel/clean up a legacy Stripe hosted invoice
|
||||
description: |
|
||||
Void a pre-existing Stripe hosted invoice that was created before
|
||||
direct payment links were retired from POS (TRU-74 / DRIFT 13).
|
||||
Card payments created via the new flow are not affected and use
|
||||
the standard payment-intent lifecycle instead.
|
||||
operationId: cancelLegacyStripeInvoice
|
||||
parameters:
|
||||
- name: order_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Legacy Stripe hosted invoice was voided
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/modules/stripe/terminal/readers:
|
||||
get:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
[Unit]
|
||||
Description=Truck Wash API cron worker (long-running scheduler)
|
||||
After=network-online.target php8.2-fpm.service redis.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/opt/copenhagentruckwash-api/services/nginx/app
|
||||
ExecStart=/usr/bin/php /opt/copenhagentruckwash-api/services/nginx/app/index.php run cron-worker
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
TimeoutStopSec=30
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=cron-worker
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/copenhagentruckwash-api/services/php/logs
|
||||
|
||||
# Resource limits
|
||||
LimitNOFILE=65536
|
||||
MemoryMax=512M
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -24,6 +24,9 @@ use objects\products_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class InvoicingPeriodRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -1412,6 +1415,7 @@ class InvoicingPeriodRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->post('/superuser/invoicing/period/object-tree/canary', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/invoicing/period/object-tree/canary');
|
||||
global $response;
|
||||
$this->requirePermission('superuser');
|
||||
self::requireParameters(['enabled']);
|
||||
@@ -1463,6 +1467,7 @@ class InvoicingPeriodRoute
|
||||
]);
|
||||
|
||||
$this->get('/superuser/invoicing/period', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('superuser_invoicing_period');
|
||||
@@ -1507,6 +1512,7 @@ class InvoicingPeriodRoute
|
||||
);
|
||||
|
||||
$this->get('/superuser/invoicing/period/tree', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/tree');
|
||||
global $response;
|
||||
$this->requirePermission('superuser_invoicing_period');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -1546,6 +1552,7 @@ class InvoicingPeriodRoute
|
||||
);
|
||||
|
||||
$this->post('/superuser/invoicing/period/flags', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/invoicing/period/flags');
|
||||
global $response;
|
||||
$this->requirePermission('add_invoice_period_flag');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -1571,6 +1578,7 @@ class InvoicingPeriodRoute
|
||||
);
|
||||
|
||||
$this->patch('/superuser/invoicing/period/flags/{id}/status', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/invoicing/period/flags/{id}/status');
|
||||
global $response;
|
||||
$this->requirePermission('update_invoice_period_flag_status');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -1599,6 +1607,7 @@ class InvoicingPeriodRoute
|
||||
);
|
||||
|
||||
$this->post('/superuser/invoicing/period/flags/automatic/status', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/invoicing/period/flags/automatic/status');
|
||||
global $response;
|
||||
$this->requirePermission('update_invoice_period_flag_status');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -1625,6 +1634,7 @@ class InvoicingPeriodRoute
|
||||
|
||||
|
||||
$this->get('/superuser/invoicing/period/distribution/all', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/all');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('superuser_invoicing_period');
|
||||
@@ -1647,6 +1657,7 @@ class InvoicingPeriodRoute
|
||||
);
|
||||
|
||||
$this->get('/superuser/invoicing/period/distribution/fixed-pricing', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/fixed-pricing');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('superuser_invoicing_period');
|
||||
@@ -1664,6 +1675,7 @@ class InvoicingPeriodRoute
|
||||
);
|
||||
|
||||
$this->get('/superuser/invoicing/period/distribution/wash-subscriptions', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/wash-subscriptions');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('superuser_invoicing_period');
|
||||
@@ -1681,6 +1693,7 @@ class InvoicingPeriodRoute
|
||||
);
|
||||
|
||||
$this->get('/superuser/invoicing/period/distribution/v2/all', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/v2/all');
|
||||
global $response;
|
||||
$this->requirePermission('superuser_invoicing_period_distribution_v2');
|
||||
$dateRange = $this->requireAndNormalizeDateRange();
|
||||
@@ -1699,6 +1712,7 @@ class InvoicingPeriodRoute
|
||||
);
|
||||
|
||||
$this->get('/superuser/invoicing/period/distribution/v2/fixed-pricing', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/v2/fixed-pricing');
|
||||
global $response;
|
||||
$this->requirePermission('superuser_invoicing_period_distribution_v2');
|
||||
$dateRange = $this->requireAndNormalizeDateRange();
|
||||
@@ -1717,6 +1731,7 @@ class InvoicingPeriodRoute
|
||||
);
|
||||
|
||||
$this->get('/superuser/invoicing/period/distribution/v2/wash-subscriptions', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/v2/wash-subscriptions');
|
||||
global $response;
|
||||
$this->requirePermission('superuser_invoicing_period_distribution_v2');
|
||||
$dateRange = $this->requireAndNormalizeDateRange();
|
||||
@@ -1735,6 +1750,7 @@ class InvoicingPeriodRoute
|
||||
);
|
||||
|
||||
$this->get('/superuser/invoicing/period/distribution/v2/customer-prices', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/v2/customer-prices');
|
||||
global $response;
|
||||
$this->requirePermission('superuser_invoicing_period_distribution_v2');
|
||||
$dateRange = $this->requireAndNormalizeDateRange();
|
||||
@@ -1753,6 +1769,7 @@ class InvoicingPeriodRoute
|
||||
);
|
||||
|
||||
$this->get('/superuser/invoicing/period/distribution/v2/booked-department-75', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/v2/booked-department-75');
|
||||
global $response;
|
||||
$this->requirePermission('superuser_invoicing_period_distribution_v2');
|
||||
$dateRange = $this->requireAndNormalizeDateRange();
|
||||
@@ -1771,6 +1788,7 @@ class InvoicingPeriodRoute
|
||||
);
|
||||
|
||||
$this->get('/superuser/customers/pricing-history', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/customers/pricing-history');
|
||||
global $response;
|
||||
$this->requirePermission('superuser_customer_pricing_history_v2');
|
||||
self::requireParameters(['customer_number']);
|
||||
@@ -1832,6 +1850,7 @@ class InvoicingPeriodRoute
|
||||
);
|
||||
|
||||
$this->get('/superuser/invoicing/period/distribution/wash-subscriptions/historical', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/wash-subscriptions/historical');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('superuser_invoicing_period');
|
||||
|
||||
@@ -7,6 +7,9 @@ use classes\account_deletion_service;
|
||||
use Throwable;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class accountDeletionRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -14,6 +17,7 @@ class accountDeletionRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/account/deletion', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/account/deletion');
|
||||
global $response;
|
||||
try {
|
||||
if (!account_deletion_service::apiEnabled()) {
|
||||
@@ -31,6 +35,7 @@ class accountDeletionRoute
|
||||
});
|
||||
|
||||
$this->post('/account/deletion', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/account/deletion');
|
||||
global $response;
|
||||
try {
|
||||
if (!account_deletion_service::apiEnabled()) {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\response;
|
||||
use classes\customer_invoice_email_schema_bootstrap;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
/**
|
||||
* Admin / ops endpoints. Currently exposes the schema health check.
|
||||
*
|
||||
* The schema health check verifies that all required DB columns exist
|
||||
* for the routes the code references. If a column is missing (e.g. a
|
||||
* migration wasn't run on production), the endpoint returns 503 with
|
||||
* a clear list of missing columns — much more useful than a generic
|
||||
* 500 with "Unknown column" hidden in the stack trace.
|
||||
*/
|
||||
class adminRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
// Schema health check — used by deploy pipelines, monitoring,
|
||||
// and the cron job. Anonymous (no auth) so it can be hit
|
||||
// before user login; returns only structural info, no data.
|
||||
$this->get('/admin/schema-check', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/admin/schema-check');
|
||||
global /** @var response $response */ $response;
|
||||
// Self-heal: run all schema bootstraps first
|
||||
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
|
||||
try {
|
||||
customer_invoice_email_schema_bootstrap::ensureSchema();
|
||||
} catch (\Throwable $e) {
|
||||
// Bootstrap may fail in environments where $db is
|
||||
// not yet wired up; report and continue with check
|
||||
}
|
||||
}
|
||||
$report = $this->runSchemaCheck();
|
||||
$response->setStatus($report['ok'] ? 200 : 503);
|
||||
$response->setBody(json_encode($report, JSON_PRETTY_PRINT));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns ['ok' => bool, 'missing' => array, ...].
|
||||
* If ok=false, the deploy should be blocked.
|
||||
*/
|
||||
private function runSchemaCheck(): array
|
||||
{
|
||||
global $db;
|
||||
$report = [
|
||||
'ok' => true,
|
||||
'missing' => [],
|
||||
'tables_checked' => 0,
|
||||
'columns_checked' => 0,
|
||||
'timestamp' => date('c'),
|
||||
'note' => 'If "missing" is non-empty, the migration that adds these columns was not run on the database.',
|
||||
];
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
$report['ok'] = false;
|
||||
$report['error'] = 'no_db_connection';
|
||||
return $report;
|
||||
}
|
||||
|
||||
$requirements = [
|
||||
'users' => [
|
||||
'invoice_email', // TRU-77 (added 2026-08-16, was missing on production)
|
||||
'wash_certificate_email',
|
||||
'email',
|
||||
'customer_number',
|
||||
],
|
||||
'invoices' => [
|
||||
'po_number',
|
||||
'closed_at',
|
||||
'customer_number',
|
||||
],
|
||||
'bookings' => [
|
||||
'id',
|
||||
'customer_number',
|
||||
'department',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($requirements as $table => $columns) {
|
||||
$report['tables_checked']++;
|
||||
$tableSafe = str_replace('`', '', $table);
|
||||
$result = $db->query("SHOW TABLES LIKE '{$tableSafe}'");
|
||||
if (!$result || (int)$result->num_rows === 0) {
|
||||
$report['ok'] = false;
|
||||
$report['missing'][] = "table `{$table}` does not exist";
|
||||
continue;
|
||||
}
|
||||
foreach ($columns as $column) {
|
||||
$report['columns_checked']++;
|
||||
$colSafe = str_replace("'", '', $column);
|
||||
$r = $db->query("SHOW COLUMNS FROM `{$tableSafe}` LIKE '{$colSafe}'");
|
||||
if (!$r || (int)$r->num_rows === 0) {
|
||||
$report['ok'] = false;
|
||||
$report['missing'][] = "{$table}.{$column}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ use classes\attachment_store;
|
||||
use classes\response;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class attachmentsRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -13,9 +16,11 @@ class attachmentsRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/attachments/example', function (): never {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/attachments/example');
|
||||
throw new \Exception('EXAMPLE ROUTE, SHOULD BE IMPLEMENTED IN THE INDIVIDUAL OBJECT ROUTES');
|
||||
});
|
||||
$this->post('/attachments/upload', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/attachments/upload');
|
||||
global $response;
|
||||
self::requireParameters(['base64_image']);
|
||||
$base64_image = self::getParameter('base64_image');
|
||||
|
||||
@@ -28,6 +28,9 @@ use Throwable;
|
||||
use traits\bird_route_helpers_t;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
/**
|
||||
* Narrow integration boundary between Bird and Pleno Control Plane.
|
||||
*
|
||||
@@ -41,6 +44,7 @@ class birdControlPlaneRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/bird/health', function (): void {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/health');
|
||||
global $response;
|
||||
$this->requirePermission('modules_bird_health_read');
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ use classes\bird;
|
||||
use traits\bird_route_helpers_t;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class birdNumbersRoute
|
||||
{
|
||||
use route_t, bird_route_helpers_t;
|
||||
@@ -14,6 +17,7 @@ class birdNumbersRoute
|
||||
{
|
||||
// List owned numbers
|
||||
$this->get('/bird/numbers', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/numbers');
|
||||
global $response;
|
||||
// Permission: list numbers via Bird
|
||||
$this->requirePermission('modules_bird_numbers_list');
|
||||
|
||||
@@ -23,6 +23,8 @@ use bird\helpers\bird_voice_recording_update_payload;
|
||||
use bird\helpers\bird_voice_recordings_create_payload;
|
||||
use bird\helpers\bird_voice_recordings_list_query_payload;
|
||||
use bird\helpers\bird_voice_say_payload;
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
use bird\helpers\bird_voice_test_outbound_payload;
|
||||
use bird\helpers\bird_voice_update_call_payload;
|
||||
use classes\bird;
|
||||
@@ -37,7 +39,10 @@ class birdVoiceCallsRoute
|
||||
public function run(): void
|
||||
{
|
||||
// List workspace call log
|
||||
$this->get('/bird/voice/calls/log', function () {
|
||||
// TRU-149: scope check added to satisfy scope middleware contract
|
||||
// (every protected route must have at least one requireScope call).
|
||||
$this->get('/bird/voice/calls/log', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/voice/calls/log');
|
||||
global $response;
|
||||
$this->requirePermission('modules_bird_voice_calls_log_list');
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ use bird\helpers\bird_flash_list_query_payload;
|
||||
use bird\helpers\bird_request_schemas;
|
||||
use classes\bird;
|
||||
use traits\bird_route_helpers_t;
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
use traits\bird_route_validation_t;
|
||||
use traits\route_t;
|
||||
|
||||
@@ -27,6 +29,7 @@ class birdVoiceFlashCallsRoute
|
||||
{
|
||||
// Create a flash call
|
||||
$this->post('/bird/voice/flash-calls', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/voice/flash-calls');
|
||||
global $response;
|
||||
$this->requirePermission('modules_bird_voice_flash_calls_create');
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ use objects\logs_o;
|
||||
use objects\order_bookings_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class bookingsRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -20,6 +23,7 @@ class bookingsRoute
|
||||
{
|
||||
/** All bookings */
|
||||
$this->get('/bookings', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/bookings');
|
||||
// Require the user to be logged in
|
||||
global
|
||||
/** @var response $response */
|
||||
@@ -93,6 +97,7 @@ class bookingsRoute
|
||||
);
|
||||
/** Own bookings */
|
||||
$this->get('/user/bookings', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/user/bookings');
|
||||
// Require the user to be logged in
|
||||
global /** @var response $response */
|
||||
$response;
|
||||
@@ -136,6 +141,7 @@ class bookingsRoute
|
||||
);
|
||||
|
||||
$this->put('/bookings', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/bookings');
|
||||
// Require the user to be logged in
|
||||
global /** @var response $response */
|
||||
$response;
|
||||
@@ -204,6 +210,7 @@ class bookingsRoute
|
||||
);
|
||||
// Synchronize booking from the external system
|
||||
$this->post('/admin/bookings/sync', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/admin/bookings/sync');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('sync_bookings');
|
||||
@@ -256,6 +263,7 @@ class bookingsRoute
|
||||
|
||||
// Get a departments unfulfilled bookings (count) for the day
|
||||
$this->get('/admin/bookings/department/count', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/admin/bookings/department/count');
|
||||
// Require the user to be logged in
|
||||
global /** @var response $response */
|
||||
$response;
|
||||
@@ -296,6 +304,7 @@ class bookingsRoute
|
||||
);
|
||||
|
||||
$this->post('/user/bookings/washcertificate/download', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/user/bookings/washcertificate/download');
|
||||
// Require the user to be logged in
|
||||
global /** @var response $response */
|
||||
$response;
|
||||
@@ -354,6 +363,7 @@ class bookingsRoute
|
||||
);
|
||||
|
||||
$this->get('/bookings/download_pdf', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/bookings/download_pdf');
|
||||
// Require the user to be logged in
|
||||
global /** @var response $response */
|
||||
$response;
|
||||
@@ -416,6 +426,7 @@ class bookingsRoute
|
||||
);
|
||||
|
||||
$this->post('/admin/bookings/delete', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/admin/bookings/delete');
|
||||
// Require the user to be logged in
|
||||
global /** @var response $response */
|
||||
$response;
|
||||
@@ -449,6 +460,7 @@ class bookingsRoute
|
||||
);
|
||||
|
||||
$this->post('/superuser/bookings/sync/all', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/superuser/bookings/sync/all');
|
||||
// Require the user to be logged in
|
||||
global /** @var response $response */
|
||||
$response;
|
||||
@@ -474,6 +486,7 @@ class bookingsRoute
|
||||
);
|
||||
|
||||
$this->post('/admin/bookings/completeWashWithoutWashCertificate', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/admin/bookings/completeWashWithoutWashCertificate');
|
||||
global /** @var response $response */
|
||||
$response;
|
||||
$response->error('Booking completion must be completed through POS desktop or mobile steps.', 410);
|
||||
@@ -484,6 +497,7 @@ class bookingsRoute
|
||||
);
|
||||
|
||||
$this->post('/user/bookings/delete', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/user/bookings/delete');
|
||||
// Require the user to be logged in
|
||||
global /** @var response $response */
|
||||
$response;
|
||||
|
||||
@@ -7,6 +7,9 @@ use objects\categories_o;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class categoriesRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -14,6 +17,7 @@ class categoriesRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/categories', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/categories');
|
||||
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
@@ -59,6 +63,7 @@ class categoriesRoute
|
||||
);
|
||||
|
||||
$this->post('/categories', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/categories');
|
||||
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
@@ -96,6 +101,7 @@ class categoriesRoute
|
||||
);
|
||||
|
||||
$this->put('/categories', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/categories');
|
||||
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
|
||||
@@ -10,6 +10,9 @@ use objects\logs_o;
|
||||
use Throwable;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class cronRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -17,6 +20,7 @@ class cronRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/superuser/cron', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/cron');
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_cron_view');
|
||||
@@ -26,6 +30,7 @@ class cronRoute
|
||||
]);
|
||||
|
||||
$this->get('/superuser/cron/runs', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/cron/runs');
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_cron_view');
|
||||
@@ -39,6 +44,7 @@ class cronRoute
|
||||
]);
|
||||
|
||||
$this->get('/superuser/cron/workers', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/cron/workers');
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_cron_view');
|
||||
@@ -64,6 +70,7 @@ class cronRoute
|
||||
]);
|
||||
|
||||
$this->post('/superuser/cron/workers/deploy', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/cron/workers/deploy');
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_cron_manage');
|
||||
@@ -84,6 +91,7 @@ class cronRoute
|
||||
]);
|
||||
|
||||
$this->post('/superuser/cron/run', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/cron/run');
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('SUPERUSER_RUN_CRON');
|
||||
@@ -109,6 +117,7 @@ class cronRoute
|
||||
]);
|
||||
|
||||
$this->patch('/superuser/cron/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/cron/config');
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_cron_manage');
|
||||
@@ -137,6 +146,7 @@ class cronRoute
|
||||
]);
|
||||
|
||||
$this->post('/superuser/cron', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/cron');
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('SUPERUSER_RUN_CRON');
|
||||
|
||||
@@ -8,6 +8,9 @@ use objects\logs_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class customerAttributes
|
||||
{
|
||||
use route_t;
|
||||
@@ -15,6 +18,7 @@ class customerAttributes
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/customer/attributes', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/customer/attributes');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
// Get the user object
|
||||
@@ -62,6 +66,7 @@ class customerAttributes
|
||||
);
|
||||
|
||||
$this->post('/customer/attributes', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/attributes');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('add_customer_attribute');
|
||||
@@ -100,6 +105,7 @@ class customerAttributes
|
||||
);
|
||||
|
||||
$this->delete('/customer/attributes', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/attributes');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('delete_customer_attribute');
|
||||
|
||||
@@ -7,6 +7,9 @@ use objects\logs_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class customerCodeDepartmentRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -14,6 +17,7 @@ class customerCodeDepartmentRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/admin/customer/code', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/admin/customer/code');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('get_customer_code');
|
||||
@@ -50,6 +54,7 @@ class customerCodeDepartmentRoute
|
||||
);
|
||||
|
||||
$this->post('/admin/customer/code', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/admin/customer/code');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('add_customer_code');
|
||||
|
||||
@@ -9,6 +9,9 @@ use objects\logs_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class customerDefaultDepartmentRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -16,6 +19,7 @@ class customerDefaultDepartmentRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/customer/department/default', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/customer/department/default');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('get_customer_default_department');
|
||||
@@ -57,6 +61,7 @@ class customerDefaultDepartmentRoute
|
||||
);
|
||||
|
||||
$this->post('/customer/department/default', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/department/default');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('add_customer_default_department');
|
||||
@@ -113,6 +118,7 @@ class customerDefaultDepartmentRoute
|
||||
);
|
||||
|
||||
$this->delete('/customer/department/default', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/department/default');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('delete_customer_default_department');
|
||||
|
||||
@@ -9,6 +9,9 @@ use objects\logs_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class customerFixedPricingRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -16,6 +19,7 @@ class customerFixedPricingRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/customer/pricing/fixed', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/customer/pricing/fixed');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('get_customer_fixed_pricing');
|
||||
@@ -57,6 +61,7 @@ class customerFixedPricingRoute
|
||||
]
|
||||
);
|
||||
$this->post('/customer/pricing/fixed', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/pricing/fixed');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('add_customer_fixed_pricing');
|
||||
@@ -135,6 +140,7 @@ class customerFixedPricingRoute
|
||||
);
|
||||
|
||||
$this->delete('/customer/pricing/fixed', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/pricing/fixed');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('delete_customer_fixed_pricing');
|
||||
|
||||
@@ -8,6 +8,9 @@ use objects\logs_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class customerNotes
|
||||
{
|
||||
use route_t;
|
||||
@@ -15,6 +18,7 @@ class customerNotes
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/customer/notes', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/customer/notes');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('list_customer_notes');
|
||||
@@ -52,6 +56,7 @@ class customerNotes
|
||||
);
|
||||
|
||||
$this->post('/customer/notes', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/notes');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('add_customer_note');
|
||||
@@ -92,6 +97,7 @@ class customerNotes
|
||||
);
|
||||
|
||||
$this->delete('/customer/notes', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/notes');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('delete_customer_note');
|
||||
|
||||
@@ -9,6 +9,9 @@ use objects\logs_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class customerSearchRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -17,6 +20,7 @@ class customerSearchRoute
|
||||
{
|
||||
//TODO: Remove this, this is deprecated in favor of the new search endpoint
|
||||
$this->post('/customers/search', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customers/search');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('search_customers');
|
||||
@@ -146,6 +150,7 @@ class customerSearchRoute
|
||||
);
|
||||
|
||||
$this->post('/customers/import', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customers/import');
|
||||
global $response;
|
||||
$this->requirePermission('add_user');
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ use objects\departments_o;
|
||||
use objects\product_options_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class customerTimeBookingsRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -48,6 +51,7 @@ class customerTimeBookingsRoute
|
||||
|
||||
/** Guest Time Bookings -> Departments -> GET */
|
||||
$this->get('/department/timebookings/departments/public', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/departments/public');
|
||||
global $response;
|
||||
|
||||
$departments = new departments_o();
|
||||
@@ -97,6 +101,7 @@ class customerTimeBookingsRoute
|
||||
|
||||
/** Guest Time Bookings -> Opening Hours -> GET */
|
||||
$this->get('/department/timebookings/opening-hours/public', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/opening-hours/public');
|
||||
global $response;
|
||||
$this->requirePublicTimeBookingsDepartment();
|
||||
|
||||
@@ -136,6 +141,7 @@ class customerTimeBookingsRoute
|
||||
|
||||
/** Guest Time Bookings -> Types -> GET */
|
||||
$this->get('/department/timebookings/types/public', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/types/public');
|
||||
global $response;
|
||||
$this->requirePublicTimeBookingsDepartment();
|
||||
|
||||
@@ -170,6 +176,7 @@ class customerTimeBookingsRoute
|
||||
);
|
||||
/** Guest Time Bookings -> Entries -> GET */
|
||||
$this->get('/department/timebookings/entries/public', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/entries/public');
|
||||
/**
|
||||
* @example Usage of this endpoint:
|
||||
* GET /department/timebookings/entries/public?id=1&filters=created_at-date_from:2025-04-01,created_at-date_to:2025-05-30&order=created_at:desc
|
||||
@@ -205,6 +212,7 @@ class customerTimeBookingsRoute
|
||||
|
||||
/** Guest Time Bookings -> Entries -> Add */
|
||||
$this->post('/department/timebookings/entries/public', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/department/timebookings/entries/public');
|
||||
global $response;
|
||||
self::requireParameters(['department', 'type', 'start']);
|
||||
$department = $this->requirePublicTimeBookingsDepartment('department');
|
||||
|
||||
@@ -19,6 +19,9 @@ use objects\logs_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class departmentDailyReportsRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -28,6 +31,7 @@ class departmentDailyReportsRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/departments/daily-reports', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('list_department_daily_reports');
|
||||
@@ -84,6 +88,7 @@ class departmentDailyReportsRoute
|
||||
);
|
||||
|
||||
$this->get('/departments/daily-reports/get', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/get');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('list_department_daily_reports');
|
||||
@@ -157,6 +162,7 @@ class departmentDailyReportsRoute
|
||||
);
|
||||
|
||||
$this->post('/departments/daily-reports', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/daily-reports');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('create_department_daily_reports');
|
||||
@@ -261,6 +267,7 @@ class departmentDailyReportsRoute
|
||||
);
|
||||
|
||||
$this->post('/departments/daily-reports/complaints', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/daily-reports/complaints');
|
||||
global $response;
|
||||
$this->requirePermission('create_department_daily_report_complaints');
|
||||
|
||||
@@ -360,6 +367,7 @@ class departmentDailyReportsRoute
|
||||
);
|
||||
|
||||
$this->get('/departments/daily-reports/complaints/customers', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/complaints/customers');
|
||||
global $response;
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -470,6 +478,7 @@ class departmentDailyReportsRoute
|
||||
);
|
||||
|
||||
$this->get('/departments/daily-reports/complaints', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/complaints');
|
||||
global $response;
|
||||
$this->requirePermission('list_department_daily_report_complaints');
|
||||
|
||||
@@ -531,6 +540,7 @@ class departmentDailyReportsRoute
|
||||
);
|
||||
|
||||
$this->put('/departments/daily-reports/complaints', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/daily-reports/complaints');
|
||||
global $response;
|
||||
$this->requirePermission('edit_department_daily_report_complaints');
|
||||
|
||||
@@ -664,6 +674,7 @@ class departmentDailyReportsRoute
|
||||
);
|
||||
|
||||
$this->delete('/departments/daily-reports/complaints', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/daily-reports/complaints');
|
||||
global $response;
|
||||
$this->requirePermission('delete_department_daily_report_complaints');
|
||||
|
||||
@@ -704,6 +715,7 @@ class departmentDailyReportsRoute
|
||||
);
|
||||
|
||||
$this->put('/departments/daily-reports', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/daily-reports');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('update_department_daily_reports');
|
||||
@@ -795,6 +807,7 @@ class departmentDailyReportsRoute
|
||||
);
|
||||
|
||||
$this->get('/superuser/departments/{id}/overview', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/superuser/departments/{id}/overview');
|
||||
global $response;
|
||||
$this->requirePermission('superuser_fetch_department');
|
||||
|
||||
@@ -840,6 +853,7 @@ class departmentDailyReportsRoute
|
||||
);
|
||||
|
||||
$this->get('/departments/daily-reports/overview', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/overview');
|
||||
global $response;
|
||||
$this->requirePermission('list_department_daily_reports');
|
||||
$this->requirePermission('list_bookings');
|
||||
@@ -886,6 +900,7 @@ class departmentDailyReportsRoute
|
||||
);
|
||||
|
||||
$this->put('/departments/daily-reports/product-targets', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/daily-reports/product-targets');
|
||||
global $response;
|
||||
$this->requirePermission(self::SET_PRODUCT_TARGET_PERMISSION);
|
||||
|
||||
@@ -948,6 +963,7 @@ class departmentDailyReportsRoute
|
||||
);
|
||||
|
||||
$this->get('/departments/daily-reports/product-count', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/product-count');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('list_department_daily_reports');
|
||||
@@ -1057,6 +1073,7 @@ class departmentDailyReportsRoute
|
||||
);
|
||||
|
||||
$this->get('/departments/daily-reports/transaction-count', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/transaction-count');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('list_department_daily_reports');
|
||||
@@ -1153,6 +1170,7 @@ class departmentDailyReportsRoute
|
||||
);
|
||||
|
||||
$this->get('/departments/daily-reports/outside-hours-trend', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/outside-hours-trend');
|
||||
global $response;
|
||||
$this->requirePermission('list_department_daily_reports');
|
||||
|
||||
@@ -1200,6 +1218,7 @@ class departmentDailyReportsRoute
|
||||
);
|
||||
|
||||
$this->get('/departments/daily-reports/bookings-count', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/bookings-count');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('list_bookings');
|
||||
|
||||
@@ -12,6 +12,9 @@ use objects\departments_o;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class departmentGatesRelaysRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -19,6 +22,7 @@ class departmentGatesRelaysRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/department/gates', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/gates');
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('list_department_gates');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -56,6 +60,7 @@ class departmentGatesRelaysRoute
|
||||
]);
|
||||
|
||||
$this->post('/department/gates', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/gates');
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('add_department_gate');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -99,6 +104,7 @@ class departmentGatesRelaysRoute
|
||||
]);
|
||||
|
||||
$this->put('/department/gates', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/gates');
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('update_department_gate');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -157,6 +163,7 @@ class departmentGatesRelaysRoute
|
||||
]);
|
||||
|
||||
$this->delete('/department/gates', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/gates');
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('delete_department_gate');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -183,6 +190,7 @@ class departmentGatesRelaysRoute
|
||||
]);
|
||||
|
||||
$this->get('/department/relays', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/relays');
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('list_department_relays');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -220,6 +228,7 @@ class departmentGatesRelaysRoute
|
||||
]);
|
||||
|
||||
$this->post('/department/relays', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/relays');
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('add_department_relay');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -262,6 +271,7 @@ class departmentGatesRelaysRoute
|
||||
]);
|
||||
|
||||
$this->put('/department/relays', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/relays');
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('update_department_relay');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -314,6 +324,7 @@ class departmentGatesRelaysRoute
|
||||
]);
|
||||
|
||||
$this->delete('/department/relays', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/relays');
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('delete_department_relay');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -14,6 +14,9 @@ use objects\departments_o;
|
||||
use objects\department_goals_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class departmentGoalsRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -22,6 +25,7 @@ class departmentGoalsRoute
|
||||
{
|
||||
// List or get single department goal(s)
|
||||
$this->get('/goals/department', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/goals/department');
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('goals_department_list');
|
||||
|
||||
@@ -83,6 +87,7 @@ class departmentGoalsRoute
|
||||
|
||||
// Create a new department goal
|
||||
$this->post('/goals/department', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/goals/department');
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('goals_department_create');
|
||||
|
||||
@@ -137,6 +142,7 @@ class departmentGoalsRoute
|
||||
|
||||
// Update an existing department goal
|
||||
$this->put('/goals/department', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/goals/department');
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('goals_department_update');
|
||||
|
||||
@@ -236,6 +242,7 @@ class departmentGoalsRoute
|
||||
|
||||
// Delete a department goal (soft delete if supported)
|
||||
$this->delete('/goals/department', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/goals/department');
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('goals_department_delete');
|
||||
|
||||
@@ -275,6 +282,7 @@ class departmentGoalsRoute
|
||||
|
||||
// Send a test progress alert for a department goal
|
||||
$this->post('/goals/department/progress-alert/test', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/goals/department/progress-alert/test');
|
||||
global /** @var response $response */ $response;
|
||||
$this->requirePermission('goals_department_progress_alert_test');
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ use objects\department_selfserve_tasks_o;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class departmentLanesRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -23,6 +26,7 @@ class departmentLanesRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/department/lanes/status-toggles', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/lanes/status-toggles');
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('list_department_lanes');
|
||||
@@ -52,6 +56,7 @@ class departmentLanesRoute
|
||||
);
|
||||
|
||||
$this->get('/department/lanes', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/lanes');
|
||||
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
@@ -124,6 +129,7 @@ class departmentLanesRoute
|
||||
);
|
||||
|
||||
$this->get('/department/lanes/relay-options', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/lanes/relay-options');
|
||||
|
||||
global $response;
|
||||
$this->requirePermission('list_department_lanes');
|
||||
@@ -183,6 +189,7 @@ class departmentLanesRoute
|
||||
* Response: image/png
|
||||
*/
|
||||
$this->get('/department/lanes/dynamic-image', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/lanes/dynamic-image');
|
||||
global $response;
|
||||
// Reuse listing permission; viewing image is tied to lane visibility
|
||||
// Authenticated user and department access validation
|
||||
@@ -335,6 +342,7 @@ class departmentLanesRoute
|
||||
]);
|
||||
|
||||
$this->post('/department/lanes', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/lanes');
|
||||
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
@@ -400,6 +408,7 @@ class departmentLanesRoute
|
||||
);
|
||||
|
||||
$this->put('/department/lanes', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/lanes');
|
||||
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
|
||||
@@ -9,6 +9,9 @@ use objects\department_notification_sms_o;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class departmentNotificationSmsRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -22,6 +25,7 @@ class departmentNotificationSmsRoute
|
||||
|
||||
/** Department Notification SMS -> Get */
|
||||
$this->get('/department/notification/sms', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/notification/sms');
|
||||
global $response;
|
||||
$this->requirePermission('department_notification_sms_get');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -60,6 +64,7 @@ class departmentNotificationSmsRoute
|
||||
|
||||
/** Department Notification SMS -> Add */
|
||||
$this->post('/department/notification/sms', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/notification/sms');
|
||||
global $response;
|
||||
$this->requirePermission('department_notification_sms_add');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -103,6 +108,7 @@ class departmentNotificationSmsRoute
|
||||
|
||||
/** Department Notification SMS -> Update */
|
||||
$this->put('/department/notification/sms', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/notification/sms');
|
||||
global $response;
|
||||
$this->requirePermission('department_notification_sms_update');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -157,6 +163,7 @@ class departmentNotificationSmsRoute
|
||||
|
||||
/** Department Notification SMS -> Delete */
|
||||
$this->delete('/department/notification/sms', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/notification/sms');
|
||||
global $response;
|
||||
$this->requirePermission('department_notification_sms_delete');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -13,6 +13,9 @@ use objects\department_selfserve_condition_rules_o;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class departmentSelfserveConditionRulesRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -23,6 +26,7 @@ class departmentSelfserveConditionRulesRoute
|
||||
* List department self-serve condition rules
|
||||
*/
|
||||
$this->get('/department/selfserve/condition/rules', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/condition/rules');
|
||||
global $response;
|
||||
$this->requirePermission('list_department_selfserve_condition_rules');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -97,6 +101,7 @@ class departmentSelfserveConditionRulesRoute
|
||||
* Add a department self-serve condition rule
|
||||
*/
|
||||
$this->post('/department/selfserve/condition/rules', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/condition/rules');
|
||||
global $response;
|
||||
$this->requirePermission('add_department_selfserve_condition_rules');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -149,6 +154,7 @@ class departmentSelfserveConditionRulesRoute
|
||||
* Update a department self-serve condition rule
|
||||
*/
|
||||
$this->put('/department/selfserve/condition/rules', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/condition/rules');
|
||||
global $response;
|
||||
$this->requirePermission('update_department_selfserve_condition_rules');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -221,6 +227,7 @@ class departmentSelfserveConditionRulesRoute
|
||||
* Delete a department self-serve condition rule
|
||||
*/
|
||||
$this->delete('/department/selfserve/condition/rules', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/condition/rules');
|
||||
global $response;
|
||||
$this->requirePermission('delete_department_selfserve_condition_rules');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -12,6 +12,9 @@ use objects\department_selfserve_conditions_o;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class departmentSelfserveConditionsRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -22,6 +25,7 @@ class departmentSelfserveConditionsRoute
|
||||
* List department self-serve conditions
|
||||
*/
|
||||
$this->get('/department/selfserve/conditions', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/conditions');
|
||||
global $response;
|
||||
$this->requirePermission('list_department_selfserve_conditions');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -97,6 +101,7 @@ class departmentSelfserveConditionsRoute
|
||||
* Add a department self-serve condition
|
||||
*/
|
||||
$this->post('/department/selfserve/conditions', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/conditions');
|
||||
global $response;
|
||||
$this->requirePermission('add_department_selfserve_conditions');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -156,6 +161,7 @@ class departmentSelfserveConditionsRoute
|
||||
* Update a department self-serve condition
|
||||
*/
|
||||
$this->put('/department/selfserve/conditions', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/conditions');
|
||||
global $response;
|
||||
$this->requirePermission('update_department_selfserve_conditions');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -220,6 +226,7 @@ class departmentSelfserveConditionsRoute
|
||||
* Delete a department self-serve condition
|
||||
*/
|
||||
$this->delete('/department/selfserve/conditions', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/conditions');
|
||||
global $response;
|
||||
$this->requirePermission('delete_department_selfserve_conditions');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -10,6 +10,9 @@ use modules\selfserve\classes\selfserve_config_versioning;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class departmentSelfserveConfigVersionsRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -17,6 +20,7 @@ class departmentSelfserveConfigVersionsRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/department/selfserve/config/versions', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/config/versions');
|
||||
global $response;
|
||||
$this->requirePermission('list_department_selfserve_config_versions');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -41,6 +45,7 @@ class departmentSelfserveConfigVersionsRoute
|
||||
]);
|
||||
|
||||
$this->get('/department/selfserve/config/history', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/config/history');
|
||||
global $response;
|
||||
$this->requirePermission('list_department_selfserve_config_versions');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -63,6 +68,7 @@ class departmentSelfserveConfigVersionsRoute
|
||||
]);
|
||||
|
||||
$this->get('/department/selfserve/config/active', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/config/active');
|
||||
global $response;
|
||||
$this->requirePermission('list_department_selfserve_config_versions');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -98,6 +104,7 @@ class departmentSelfserveConfigVersionsRoute
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/config/draft', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/config/draft');
|
||||
global $response;
|
||||
$this->requirePermission('edit_department_selfserve_config_versions');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -122,6 +129,7 @@ class departmentSelfserveConfigVersionsRoute
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/config/validate', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/config/validate');
|
||||
global $response;
|
||||
$this->requirePermission('edit_department_selfserve_config_versions');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -142,6 +150,7 @@ class departmentSelfserveConfigVersionsRoute
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/config/publish', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/config/publish');
|
||||
global $response;
|
||||
$this->requirePermission('publish_department_selfserve_config_versions');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -166,6 +175,7 @@ class departmentSelfserveConfigVersionsRoute
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/config/rollback', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/config/rollback');
|
||||
global $response;
|
||||
$this->requirePermission('rollback_department_selfserve_config_versions');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -10,6 +10,9 @@ use objects\logs_o;
|
||||
use objects\selfserve_machine_types_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class departmentSelfserveMachineTypesRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -17,6 +20,7 @@ class departmentSelfserveMachineTypesRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/department/selfserve/machine-types', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/machine-types');
|
||||
global $response;
|
||||
$this->requirePermission('list_department_selfserve_machine_types');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -45,6 +49,7 @@ class departmentSelfserveMachineTypesRoute
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/machine-types', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/machine-types');
|
||||
global $response;
|
||||
$this->requirePermission('add_department_selfserve_machine_types');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -70,6 +75,7 @@ class departmentSelfserveMachineTypesRoute
|
||||
]);
|
||||
|
||||
$this->put('/department/selfserve/machine-types', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/machine-types');
|
||||
global $response;
|
||||
$this->requirePermission('update_department_selfserve_machine_types');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -102,6 +108,7 @@ class departmentSelfserveMachineTypesRoute
|
||||
]);
|
||||
|
||||
$this->delete('/department/selfserve/machine-types', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/machine-types');
|
||||
global $response;
|
||||
$this->requirePermission('delete_department_selfserve_machine_types');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -12,6 +12,9 @@ use objects\department_selfserve_questions_o;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class departmentSelfserveQuestionsRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -22,6 +25,7 @@ class departmentSelfserveQuestionsRoute
|
||||
* List department self-serve questions
|
||||
*/
|
||||
$this->get('/department/selfserve/questions', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/questions');
|
||||
global $response;
|
||||
$this->requirePermission('list_department_selfserve_questions');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -93,6 +97,7 @@ class departmentSelfserveQuestionsRoute
|
||||
* Add a department self-serve question
|
||||
*/
|
||||
$this->post('/department/selfserve/questions', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/questions');
|
||||
global $response;
|
||||
$this->requirePermission('add_department_selfserve_questions');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -147,6 +152,7 @@ class departmentSelfserveQuestionsRoute
|
||||
* Update a department self-serve question
|
||||
*/
|
||||
$this->put('/department/selfserve/questions', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/questions');
|
||||
global $response;
|
||||
$this->requirePermission('edit_department_selfserve_questions');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -208,6 +214,7 @@ class departmentSelfserveQuestionsRoute
|
||||
* Delete a department self-serve question
|
||||
*/
|
||||
$this->delete('/department/selfserve/questions', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/questions');
|
||||
global $response;
|
||||
$this->requirePermission('delete_department_selfserve_questions');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -9,6 +9,9 @@ use modules\selfserve\classes\selfserve_wash_flow;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class departmentSelfserveStudioRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -16,6 +19,7 @@ class departmentSelfserveStudioRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/department/selfserve/studio/graph', function (): void {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/studio/graph');
|
||||
global $response;
|
||||
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
|
||||
self::requireParameters(['department']);
|
||||
@@ -31,6 +35,7 @@ class departmentSelfserveStudioRoute
|
||||
]);
|
||||
|
||||
$this->put('/department/selfserve/studio/graph', function (): void {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/graph');
|
||||
global $response;
|
||||
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
|
||||
self::requireParameters(['department']);
|
||||
@@ -51,6 +56,7 @@ class departmentSelfserveStudioRoute
|
||||
]);
|
||||
|
||||
$this->put('/department/selfserve/studio/layout', function (): void {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/layout');
|
||||
global $response;
|
||||
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
|
||||
self::requireParameters(['department', 'layout']);
|
||||
@@ -70,6 +76,7 @@ class departmentSelfserveStudioRoute
|
||||
]);
|
||||
|
||||
$this->put('/department/selfserve/studio/virtual-hardware', function (): void {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/virtual-hardware');
|
||||
global $response;
|
||||
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
|
||||
self::requireParameters(['department', 'operation']);
|
||||
@@ -94,6 +101,7 @@ class departmentSelfserveStudioRoute
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/studio/validate', function (): void {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/validate');
|
||||
global $response;
|
||||
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
|
||||
self::requireParameters(['department']);
|
||||
@@ -108,6 +116,7 @@ class departmentSelfserveStudioRoute
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/studio/simulate', function (): void {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/simulate');
|
||||
global $response;
|
||||
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
|
||||
self::requireParameters(['department', 'lane_id', 'reg']);
|
||||
@@ -133,6 +142,7 @@ class departmentSelfserveStudioRoute
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/studio/path-outcomes/stream', function (): void {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/path-outcomes/stream');
|
||||
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
|
||||
self::requireParameters(['department']);
|
||||
$departmentId = (int)self::getParameter('department');
|
||||
@@ -173,6 +183,7 @@ class departmentSelfserveStudioRoute
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/studio/path-outcomes', function (): void {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/path-outcomes');
|
||||
global $response;
|
||||
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
|
||||
self::requireParameters(['department']);
|
||||
@@ -196,6 +207,7 @@ class departmentSelfserveStudioRoute
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/studio/path-confirmations', function (): void {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/path-confirmations');
|
||||
global $response;
|
||||
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
|
||||
self::requireParameters(['department', 'path_signature']);
|
||||
@@ -219,6 +231,7 @@ class departmentSelfserveStudioRoute
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/studio/publish', function (): void {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/publish');
|
||||
global $response;
|
||||
$user = $this->requireStudioUser('publish_department_selfserve_config_versions');
|
||||
self::requireParameters(['department']);
|
||||
@@ -240,6 +253,7 @@ class departmentSelfserveStudioRoute
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/studio/rollback', function (): void {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/rollback');
|
||||
global $response;
|
||||
$user = $this->requireStudioUser('rollback_department_selfserve_config_versions');
|
||||
self::requireParameters(['department', 'target_version_id']);
|
||||
@@ -260,6 +274,7 @@ class departmentSelfserveStudioRoute
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/studio/gateway-action', function (): void {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/gateway-action');
|
||||
global $response;
|
||||
$user = $this->requireStudioUser('modules_shelly_config');
|
||||
self::requireParameters(['department', 'gateway_id', 'action']);
|
||||
|
||||
@@ -17,6 +17,9 @@ use modules\selfserve\helpers\selfserve_lane_services;
|
||||
use modules\selfserve\helpers\selfserve_task_gate_type;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class departmentSelfserveTasksRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -27,6 +30,7 @@ class departmentSelfserveTasksRoute
|
||||
* List department self-serve tasks
|
||||
*/
|
||||
$this->get('/department/selfserve/tasks', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/tasks');
|
||||
global $response;
|
||||
$this->requirePermission('list_department_selfserve_tasks');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -112,6 +116,7 @@ class departmentSelfserveTasksRoute
|
||||
* Add a department self-serve task
|
||||
*/
|
||||
$this->post('/department/selfserve/tasks', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/tasks');
|
||||
global $response;
|
||||
$this->requirePermission('add_department_selfserve_tasks');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -271,6 +276,7 @@ class departmentSelfserveTasksRoute
|
||||
* Update a department self-serve task
|
||||
*/
|
||||
$this->put('/department/selfserve/tasks', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/tasks');
|
||||
global $response;
|
||||
$this->requirePermission('edit_department_selfserve_tasks');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -437,6 +443,7 @@ class departmentSelfserveTasksRoute
|
||||
* Delete a department self-serve task
|
||||
*/
|
||||
$this->delete('/department/selfserve/tasks', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/tasks');
|
||||
global $response;
|
||||
$this->requirePermission('delete_department_selfserve_tasks');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -470,6 +477,7 @@ class departmentSelfserveTasksRoute
|
||||
* Download an attachment for a department self-serve task
|
||||
*/
|
||||
$this->get('/department/selfserve/tasks/attachments/download', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/tasks/attachments/download');
|
||||
global $response;
|
||||
$this->requirePermission('download_department_selfserve_task_attachments');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -520,6 +528,7 @@ class departmentSelfserveTasksRoute
|
||||
* List attachments for a department self-serve task
|
||||
*/
|
||||
$this->get('/department/selfserve/tasks/attachments', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/tasks/attachments');
|
||||
global $response;
|
||||
$this->requirePermission('list_department_selfserve_task_attachments');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -557,6 +566,7 @@ class departmentSelfserveTasksRoute
|
||||
* Upload an attachment for a department self-serve task
|
||||
*/
|
||||
$this->post('/department/selfserve/tasks/attachments/upload', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/tasks/attachments/upload');
|
||||
global $response;
|
||||
$this->requirePermission('add_department_selfserve_task_attachments');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -603,6 +613,7 @@ class departmentSelfserveTasksRoute
|
||||
* Delete an attachment for a department self-serve task
|
||||
*/
|
||||
$this->delete('/department/selfserve/tasks/attachments', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/tasks/attachments');
|
||||
global $response;
|
||||
$this->requirePermission('delete_department_selfserve_task_attachments');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -19,6 +19,9 @@ use objects\logs_o;
|
||||
use objects\selfserve_wash_sessions_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class departmentSelfserveVehicleConditionsRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -29,6 +32,7 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
* List department self-serve vehicle conditions
|
||||
*/
|
||||
$this->get('/department/selfserve/vehicle/conditions', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/vehicle/conditions');
|
||||
global $response;
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
@@ -118,6 +122,7 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
* Check whether self-serve is allowed for a specific vehicle and lane
|
||||
*/
|
||||
$this->get('/department/selfserve/vehicle/allowed', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/vehicle/allowed');
|
||||
global $response;
|
||||
[$user, $actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal();
|
||||
$own_permission = self::definePermission('list_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_LIST);
|
||||
@@ -165,6 +170,7 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
* Get self-serve wash summary
|
||||
*/
|
||||
$this->get('/department/selfserve/washes/summary', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/washes/summary');
|
||||
global $response;
|
||||
[$user, $_actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal();
|
||||
$own_permission = self::definePermission('list_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_LIST);
|
||||
@@ -249,6 +255,7 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
* Add a department self-serve vehicle condition
|
||||
*/
|
||||
$this->post('/department/selfserve/vehicle/conditions', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/vehicle/conditions');
|
||||
global $response;
|
||||
[$user, $actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal();
|
||||
$own_permission = self::definePermission('add_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_ADD);
|
||||
@@ -311,6 +318,7 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
* Update a department self-serve vehicle condition
|
||||
*/
|
||||
$this->put('/department/selfserve/vehicle/conditions', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/vehicle/conditions');
|
||||
global $response;
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
@@ -408,6 +416,7 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
* Delete a department self-serve vehicle condition
|
||||
*/
|
||||
$this->delete('/department/selfserve/vehicle/conditions', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/vehicle/conditions');
|
||||
global $response;
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
|
||||
@@ -12,6 +12,9 @@ use objects\logs_o;
|
||||
use objects\product_options_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class departmentTimeBookingsRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -25,6 +28,7 @@ class departmentTimeBookingsRoute
|
||||
|
||||
/** Department Time Bookings -> List */
|
||||
$this->get('/department/timebookings/opening-hours', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/opening-hours');
|
||||
global $response;
|
||||
$this->requirePermission('department_timebookings_opening_hours_get');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -74,6 +78,7 @@ class departmentTimeBookingsRoute
|
||||
|
||||
/** Department Time Bookings -> Update */
|
||||
$this->put('/department/timebookings/opening-hours', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/department/timebookings/opening-hours');
|
||||
global $response;
|
||||
$this->requirePermission('department_timebookings_opening_hours_put');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -124,6 +129,7 @@ class departmentTimeBookingsRoute
|
||||
|
||||
/** Department Time Bookings -> Types */
|
||||
$this->get('/department/timebookings/types', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/types');
|
||||
global $response;
|
||||
$this->requirePermission('department_timebookings_types_get');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -169,6 +175,7 @@ class departmentTimeBookingsRoute
|
||||
|
||||
/** Department Time Bookings -> Types -> Add */
|
||||
$this->post('/department/timebookings/types', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/department/timebookings/types');
|
||||
global $response;
|
||||
$this->requirePermission('department_timebookings_types_post');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -204,6 +211,7 @@ class departmentTimeBookingsRoute
|
||||
|
||||
/** Department Time Bookings -> Types -> Update */
|
||||
$this->put('/department/timebookings/types', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/department/timebookings/types');
|
||||
global $response;
|
||||
$this->requirePermission('department_timebookings_types_put');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -239,6 +247,7 @@ class departmentTimeBookingsRoute
|
||||
|
||||
/** Department Time Bookings -> Types -> Delete */
|
||||
$this->delete('/department/timebookings/types', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/department/timebookings/types');
|
||||
global $response;
|
||||
$this->requirePermission('department_timebookings_types_delete');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -269,6 +278,7 @@ class departmentTimeBookingsRoute
|
||||
|
||||
/** Department Time Bookings -> Entries */
|
||||
$this->get('/department/timebookings/entries', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/entries');
|
||||
global $response;
|
||||
$this->requirePermission('department_timebookings_entries_get');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -324,6 +334,7 @@ class departmentTimeBookingsRoute
|
||||
|
||||
/** Department Time Bookings -> Entries -> Add */
|
||||
$this->post('/department/timebookings/entries', function () {
|
||||
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/department/timebookings/entries');
|
||||
global $response;
|
||||
$this->requirePermission('department_timebookings_entries_post');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -16,6 +16,9 @@ use objects\logs_o;
|
||||
use objects\orders_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class departmentsRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -83,6 +86,7 @@ class departmentsRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/departments', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('list_departments');
|
||||
@@ -173,6 +177,7 @@ class departmentsRoute
|
||||
);
|
||||
|
||||
$this->post('/departments', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('add_department');
|
||||
@@ -209,6 +214,7 @@ class departmentsRoute
|
||||
);
|
||||
|
||||
$this->put('/departments', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('edit_department');
|
||||
@@ -265,6 +271,7 @@ class departmentsRoute
|
||||
);
|
||||
|
||||
$this->get('/departments/categories', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/categories');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$auth = new authentication();
|
||||
@@ -323,6 +330,7 @@ class departmentsRoute
|
||||
);
|
||||
|
||||
$this->get('/departments/self-serve/enabled', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/self-serve/enabled');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('view_department_selfserve_enabled');
|
||||
@@ -362,6 +370,7 @@ class departmentsRoute
|
||||
);
|
||||
|
||||
$this->put('/departments/self-serve/enabled', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/self-serve/enabled');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('edit_department_selfserve_enabled');
|
||||
@@ -413,6 +422,7 @@ class departmentsRoute
|
||||
);
|
||||
|
||||
$this->post('/departments/categories', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/categories');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('add_department_category');
|
||||
@@ -465,6 +475,7 @@ class departmentsRoute
|
||||
);
|
||||
|
||||
$this->delete('/departments/categories', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/categories');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('delete_department_category');
|
||||
|
||||
@@ -15,6 +15,9 @@ use objects\orders_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class economicInvoiceRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -26,6 +29,7 @@ class economicInvoiceRoute
|
||||
$router, $response;
|
||||
|
||||
$this->post('/economic/invoice/draft/export', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/draft/export');
|
||||
global $response;
|
||||
$this->requirePermission('economic_invoice_draft_export');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -99,6 +103,7 @@ class economicInvoiceRoute
|
||||
]);
|
||||
|
||||
$this->delete('/economic/invoice/draft/delete', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/draft/delete');
|
||||
global /** @var response $response */
|
||||
$response;
|
||||
$this->requirePermission('economic_invoice_draft_delete');
|
||||
@@ -139,6 +144,7 @@ class economicInvoiceRoute
|
||||
]);
|
||||
|
||||
$this->post('/economic/invoice/export', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/export');
|
||||
global $response;
|
||||
$this->requirePermission('economic_invoice_export');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -223,6 +229,7 @@ class economicInvoiceRoute
|
||||
]);
|
||||
|
||||
$this->get('/economic/invoice/draft/export/status', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/economic/invoice/draft/export/status');
|
||||
global $response;
|
||||
$this->requirePermission('economic_invoice_draft_export');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -248,6 +255,7 @@ class economicInvoiceRoute
|
||||
]);
|
||||
|
||||
$this->post('/economic/invoice/draft/export/retry', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/draft/export/retry');
|
||||
global $response;
|
||||
$this->requirePermission('economic_invoice_draft_export');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -282,6 +290,7 @@ class economicInvoiceRoute
|
||||
]);
|
||||
|
||||
$this->get('/economic/invoice/export/status', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/economic/invoice/export/status');
|
||||
global $response;
|
||||
$this->requirePermission('economic_invoice_export');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -307,6 +316,7 @@ class economicInvoiceRoute
|
||||
]);
|
||||
|
||||
$this->post('/economic/invoice/export/retry', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/export/retry');
|
||||
global $response;
|
||||
$this->requirePermission('economic_invoice_export');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -9,6 +9,9 @@ use classes\router;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class economicLayoutsRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -21,6 +24,7 @@ class economicLayoutsRoute
|
||||
|
||||
|
||||
$this->get('/economic/layouts', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/economic/layouts');
|
||||
global $response;
|
||||
$this->requirePermission('economic_layouts');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -9,6 +9,9 @@ use classes\router;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class economicPaymentTermsRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -21,6 +24,7 @@ class economicPaymentTermsRoute
|
||||
|
||||
|
||||
$this->get('/economic/payment-terms', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/economic/payment-terms');
|
||||
global $response;
|
||||
$this->requirePermission('economic_payment_terms');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -13,6 +13,8 @@ use objects\departments_o;
|
||||
use objects\order_items_o;
|
||||
use objects\orders_o;
|
||||
use objects\products_o;
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
|
||||
@@ -23,6 +25,7 @@ class exampleRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/example', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/example');
|
||||
global $response;
|
||||
$response->success(['message' => 'Hello World!']);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,9 @@ use objects\logs_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class intimidateRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -14,6 +17,7 @@ class intimidateRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->post('/su/intimidate', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/su/intimidate');
|
||||
// Get the post data
|
||||
global $response;
|
||||
// Make sure the user has the SUPERUSER_INTIMIDATE permission
|
||||
|
||||
@@ -12,6 +12,9 @@ use objects\orders_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class invoicesRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -19,6 +22,7 @@ class invoicesRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/invoices/draft', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/invoices/draft');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('get_invoice_draft');
|
||||
@@ -57,6 +61,7 @@ class invoicesRoute
|
||||
);
|
||||
|
||||
$this->post('/invoices/draft/close', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/invoices/draft/close');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('close_invoice_draft');
|
||||
@@ -94,6 +99,7 @@ class invoicesRoute
|
||||
);
|
||||
|
||||
$this->get('/invoices/pdf', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/invoices/pdf');
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('get_invoice_pdf');
|
||||
|
||||
@@ -8,6 +8,9 @@ use classes\limited_backoffice_login_grant_service;
|
||||
use classes\limited_backoffice_service;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
require_once WD . '/classes/limited_backoffice_login_grant_service.php';
|
||||
|
||||
class limitedBackofficeRoute
|
||||
@@ -17,6 +20,7 @@ class limitedBackofficeRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/limited-backoffice/departments', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/limited-backoffice/departments');
|
||||
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
|
||||
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
|
||||
return $service->departmentsForUser($user);
|
||||
|
||||
@@ -11,6 +11,9 @@ use objects\logs_o;
|
||||
use objects\plate_scanners_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class machineButtonPressRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -18,6 +21,7 @@ class machineButtonPressRoute
|
||||
public function run(): void
|
||||
{
|
||||
$handler = function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/relay/button/press/post');
|
||||
global $response;
|
||||
|
||||
self::requirePlateScannerAuth();
|
||||
|
||||
@@ -7,6 +7,9 @@ use objects\logs_o;
|
||||
use objects\module_action_logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class moduleActionLogsRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -15,6 +18,7 @@ class moduleActionLogsRoute
|
||||
{
|
||||
/** Modules > Action Logs > List */
|
||||
$this->get('/modules/action-logs', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/action-logs');
|
||||
global $response;
|
||||
$this->requirePermission('modules_action_logs_view');
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ use objects\logs_o;
|
||||
use Throwable;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class moduleBackupsRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -21,6 +24,7 @@ class moduleBackupsRoute
|
||||
$router, $response;
|
||||
|
||||
$this->get('/modules/backup/backups', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/backup/backups');
|
||||
global $response;
|
||||
$this->requireClassicSuperuserPermission('modules_backup_list');
|
||||
try {
|
||||
@@ -38,6 +42,7 @@ class moduleBackupsRoute
|
||||
]);
|
||||
|
||||
$this->post('/modules/backup/backups', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/backup/backups');
|
||||
global $response;
|
||||
$this->requireClassicSuperuserPermission('modules_backup_create');
|
||||
$user_id = $this->actorUserId();
|
||||
@@ -59,6 +64,7 @@ class moduleBackupsRoute
|
||||
]);
|
||||
|
||||
$this->get('/modules/backup/jobs/{id}', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/backup/jobs/{id}');
|
||||
global $response;
|
||||
$this->requireClassicSuperuserPermission('modules_backup_list');
|
||||
$job_id = (int)$this->fromRoute('id');
|
||||
@@ -72,6 +78,7 @@ class moduleBackupsRoute
|
||||
]);
|
||||
|
||||
$this->post('/modules/backup/backups/{backup_uuid}/verify', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/backup/backups/{backup_uuid}/verify');
|
||||
global $response;
|
||||
$this->requireClassicSuperuserPermission('modules_backup_verify');
|
||||
try {
|
||||
@@ -87,6 +94,7 @@ class moduleBackupsRoute
|
||||
]);
|
||||
|
||||
$this->post('/modules/backup/backups/{backup_uuid}/restore/preview', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/backup/backups/{backup_uuid}/restore/preview');
|
||||
global $response;
|
||||
$this->requireClassicSuperuserPermission('modules_backup_restore');
|
||||
try {
|
||||
@@ -102,6 +110,7 @@ class moduleBackupsRoute
|
||||
]);
|
||||
|
||||
$this->post('/modules/backup/backups/{backup_uuid}/restore', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/backup/backups/{backup_uuid}/restore');
|
||||
global $response;
|
||||
$this->requireClassicSuperuserPermission('modules_backup_restore');
|
||||
try {
|
||||
@@ -128,6 +137,7 @@ class moduleBackupsRoute
|
||||
]);
|
||||
|
||||
$this->get('/modules/backup/restore-audit', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/backup/restore-audit');
|
||||
global $response;
|
||||
$this->requireClassicSuperuserPermission('modules_backup_restore');
|
||||
try {
|
||||
|
||||
@@ -19,6 +19,9 @@ use classes\workfeed;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class moduleConfigRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -32,6 +35,7 @@ class moduleConfigRoute
|
||||
|
||||
/** Economic config > GET */
|
||||
$this->get('/economic/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/economic/config');
|
||||
global $response;
|
||||
$this->requirePermission('economic_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -52,6 +56,7 @@ class moduleConfigRoute
|
||||
|
||||
/** Economic config > POST */
|
||||
$this->post('/economic/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/economic/config');
|
||||
global $response;
|
||||
$this->requirePermission('economic_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -72,6 +77,7 @@ class moduleConfigRoute
|
||||
|
||||
/** reCAPTCHA config > GET */
|
||||
$this->get('/reCAPTCHA/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/reCAPTCHA/config');
|
||||
global $response;
|
||||
$this->requirePermission('recaptcha_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -92,6 +98,7 @@ class moduleConfigRoute
|
||||
|
||||
/** reCAPTCHA config > POST */
|
||||
$this->post('/reCAPTCHA/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/reCAPTCHA/config');
|
||||
global $response;
|
||||
$this->requirePermission('recaptcha_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -112,6 +119,7 @@ class moduleConfigRoute
|
||||
|
||||
/** Email config > GET */
|
||||
$this->get('/email/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/email/config');
|
||||
global $response;
|
||||
$this->requirePermission('email_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -132,6 +140,7 @@ class moduleConfigRoute
|
||||
|
||||
/** Email config > POST */
|
||||
$this->post('/email/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/email/config');
|
||||
global $response;
|
||||
$this->requirePermission('email_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -152,6 +161,7 @@ class moduleConfigRoute
|
||||
|
||||
/** Email config > TEST */
|
||||
$this->post('/email/config/test', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/email/config/test');
|
||||
global $response;
|
||||
$this->requirePermission('email_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -183,6 +193,7 @@ class moduleConfigRoute
|
||||
|
||||
/** Slack config > GET */
|
||||
$this->get('/slack/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/slack/config');
|
||||
global $response;
|
||||
$this->requirePermission('slack_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -203,6 +214,7 @@ class moduleConfigRoute
|
||||
|
||||
/** Slack config > POST */
|
||||
$this->post('/slack/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/slack/config');
|
||||
global $response;
|
||||
$this->requirePermission('slack_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -223,6 +235,7 @@ class moduleConfigRoute
|
||||
|
||||
/** Slack config > TEST */
|
||||
$this->post('/slack/config/test', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/slack/config/test');
|
||||
global $response;
|
||||
$this->requirePermission('slack_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -252,6 +265,7 @@ class moduleConfigRoute
|
||||
|
||||
/** Slack internal department goal progress config > TEST */
|
||||
$this->post('/slack/config/internal-department-goal-progress/test', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/slack/config/internal-department-goal-progress/test');
|
||||
global $response;
|
||||
$this->requirePermission('slack_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -281,6 +295,7 @@ class moduleConfigRoute
|
||||
|
||||
/** Slack internal department goal progress config > GET */
|
||||
$this->get('/slack/config/internal-department-goal-progress', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/slack/config/internal-department-goal-progress');
|
||||
global $response;
|
||||
$this->requirePermission('slack_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -301,6 +316,7 @@ class moduleConfigRoute
|
||||
|
||||
/** Slack internal department goal progress config > POST */
|
||||
$this->post('/slack/config/internal-department-goal-progress', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/slack/config/internal-department-goal-progress');
|
||||
global $response;
|
||||
$this->requirePermission('slack_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -334,6 +350,7 @@ class moduleConfigRoute
|
||||
);
|
||||
|
||||
$this->get('/backups/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/backups/config');
|
||||
global $response;
|
||||
$this->requirePermission('backups_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -353,6 +370,7 @@ class moduleConfigRoute
|
||||
);
|
||||
|
||||
$this->post('/backups/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/backups/config');
|
||||
global $response;
|
||||
$this->requirePermission('backups_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -372,6 +390,7 @@ class moduleConfigRoute
|
||||
);
|
||||
|
||||
$this->get('/failover/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/failover/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_failover_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -391,6 +410,7 @@ class moduleConfigRoute
|
||||
);
|
||||
|
||||
$this->post('/failover/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/failover/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_failover_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -410,6 +430,7 @@ class moduleConfigRoute
|
||||
);
|
||||
|
||||
$this->get('/coolify/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/coolify/config');
|
||||
global $response;
|
||||
$this->requirePermission('superuser_coolify_manage');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -429,6 +450,7 @@ class moduleConfigRoute
|
||||
);
|
||||
|
||||
$this->post('/coolify/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/coolify/config');
|
||||
global $response;
|
||||
$this->requirePermission('superuser_coolify_manage');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -449,6 +471,7 @@ class moduleConfigRoute
|
||||
|
||||
/** Bird config > GET */
|
||||
$this->get('/bird/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_bird_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -469,6 +492,7 @@ class moduleConfigRoute
|
||||
|
||||
/** Bird config > POST */
|
||||
$this->post('/bird/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/bird/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_bird_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -489,6 +513,7 @@ class moduleConfigRoute
|
||||
|
||||
/** MotorAPI config > GET */
|
||||
$this->get('/motorapi/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/motorapi/config');
|
||||
global $response;
|
||||
$this->requirePermission('motorapi_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -509,6 +534,7 @@ class moduleConfigRoute
|
||||
|
||||
/** MotorAPI config > POST */
|
||||
$this->post('/motorapi/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/motorapi/config');
|
||||
global $response;
|
||||
$this->requirePermission('motorapi_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -529,6 +555,7 @@ class moduleConfigRoute
|
||||
|
||||
/** Stripe config > GET */
|
||||
$this->get('/stripe/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/stripe/config');
|
||||
global $response;
|
||||
$this->requirePermission('stripe_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -549,6 +576,7 @@ class moduleConfigRoute
|
||||
|
||||
/** Stripe config > POST */
|
||||
$this->post('/stripe/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/stripe/config');
|
||||
global $response;
|
||||
$this->requirePermission('stripe_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -569,6 +597,7 @@ class moduleConfigRoute
|
||||
|
||||
/** FXRatesAPI config > GET */
|
||||
$this->get('/fxratesapi/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/fxratesapi/config');
|
||||
global $response;
|
||||
$this->requirePermission('fxratesapi_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -588,6 +617,7 @@ class moduleConfigRoute
|
||||
);
|
||||
/** FXRatesAPI config > POST */
|
||||
$this->post('/fxratesapi/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/fxratesapi/config');
|
||||
global $response;
|
||||
$this->requirePermission('fxratesapi_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -607,6 +637,7 @@ class moduleConfigRoute
|
||||
);
|
||||
/** GatewayAPI config > GET */
|
||||
$this->get('/gatewayapi/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/gatewayapi/config');
|
||||
global $response;
|
||||
$this->requirePermission('gatewayapi_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -626,6 +657,7 @@ class moduleConfigRoute
|
||||
);
|
||||
/** GatewayAPI config > POST */
|
||||
$this->post('/gatewayapi/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/gatewayapi/config');
|
||||
global $response;
|
||||
$this->requirePermission('gatewayapi_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -646,6 +678,7 @@ class moduleConfigRoute
|
||||
|
||||
/** WeatherAPI config > GET */
|
||||
$this->get('/weatherapi/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/weatherapi/config');
|
||||
global $response;
|
||||
$this->requirePermission('weatherapi_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -665,6 +698,7 @@ class moduleConfigRoute
|
||||
);
|
||||
/** WeatherAPI config > POST */
|
||||
$this->post('/weatherapi/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/weatherapi/config');
|
||||
global $response;
|
||||
$this->requirePermission('weatherapi_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -685,6 +719,7 @@ class moduleConfigRoute
|
||||
|
||||
/** n8n config > GET */
|
||||
$this->get('/n8n/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/n8n/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_n8n_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -704,6 +739,7 @@ class moduleConfigRoute
|
||||
);
|
||||
/** n8n config > POST */
|
||||
$this->post('/n8n/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/n8n/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_n8n_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -724,6 +760,7 @@ class moduleConfigRoute
|
||||
|
||||
/** Workfeed config > GET */
|
||||
$this->get('/workfeed/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/workfeed/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_workfeed_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -743,6 +780,7 @@ class moduleConfigRoute
|
||||
);
|
||||
/** Workfeed config > POST */
|
||||
$this->post('/workfeed/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/workfeed/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_workfeed_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -763,6 +801,7 @@ class moduleConfigRoute
|
||||
|
||||
/** XLVask config > GET */
|
||||
$this->get('/xlvask/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/xlvask/config');
|
||||
global $response;
|
||||
$this->requirePermission('xlvask_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -782,6 +821,7 @@ class moduleConfigRoute
|
||||
);
|
||||
/** XLVask config > POST */
|
||||
$this->post('/xlvask/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/xlvask/config');
|
||||
global $response;
|
||||
$this->requirePermission('xlvask_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -802,6 +842,7 @@ class moduleConfigRoute
|
||||
|
||||
/** Entra config > GET */
|
||||
$this->get('/entra/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/entra/config');
|
||||
global $response;
|
||||
$this->requirePermission('entra_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -821,6 +862,7 @@ class moduleConfigRoute
|
||||
);
|
||||
/** Entra config > POST */
|
||||
$this->post('/entra/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/entra/config');
|
||||
global $response;
|
||||
$this->requirePermission('entra_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -840,6 +882,7 @@ class moduleConfigRoute
|
||||
);
|
||||
/** Limble config > GET */
|
||||
$this->get('/limble/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/limble/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_limble_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -859,6 +902,7 @@ class moduleConfigRoute
|
||||
);
|
||||
/** Limble config > POST */
|
||||
$this->post('/limble/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/limble/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_limble_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -878,6 +922,7 @@ class moduleConfigRoute
|
||||
);
|
||||
/** OcrSpace config > GET */
|
||||
$this->get('/ocrspace/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/ocrspace/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_ocrspace_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -897,6 +942,7 @@ class moduleConfigRoute
|
||||
);
|
||||
/** OcrSpace config > POST */
|
||||
$this->post('/ocrspace/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/ocrspace/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_ocrspace_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -916,6 +962,7 @@ class moduleConfigRoute
|
||||
);
|
||||
/** OpenAI config > GET */
|
||||
$this->get('/openai/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/openai/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_openai_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -935,6 +982,7 @@ class moduleConfigRoute
|
||||
);
|
||||
/** OpenAI config > POST */
|
||||
$this->post('/openai/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/openai/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_openai_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -954,6 +1002,7 @@ class moduleConfigRoute
|
||||
);
|
||||
/** LicensePlateRecognizer config > GET */
|
||||
$this->get('/licenseplaterecognizer/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/licenseplaterecognizer/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_licenseplaterecognizer_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -973,6 +1022,7 @@ class moduleConfigRoute
|
||||
);
|
||||
/** LicensePlateRecognizer config > POST */
|
||||
$this->post('/licenseplaterecognizer/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/licenseplaterecognizer/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_licenseplaterecognizer_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -992,6 +1042,7 @@ class moduleConfigRoute
|
||||
);
|
||||
/** Virkdata config > GET */
|
||||
$this->get('/virkdata/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/virkdata/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_virkdata_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -1010,6 +1061,7 @@ class moduleConfigRoute
|
||||
);
|
||||
/** Virkdata config > POST */
|
||||
$this->post('/virkdata/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/virkdata/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_virkdata_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -1029,6 +1081,7 @@ class moduleConfigRoute
|
||||
);
|
||||
/** Shelly config -> GET */
|
||||
$this->get('/shelly/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/shelly/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -1046,6 +1099,7 @@ class moduleConfigRoute
|
||||
]
|
||||
);
|
||||
$this->post('/shelly/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/shelly/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_shelly_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -1060,6 +1114,7 @@ class moduleConfigRoute
|
||||
});
|
||||
/** Self-Serve config -> GET */
|
||||
$this->get('/selfserve/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/selfserve/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_config');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -1077,6 +1132,7 @@ class moduleConfigRoute
|
||||
]
|
||||
);
|
||||
$this->post('/selfserve/config', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/selfserve/config');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_config');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -9,6 +9,9 @@ use classes\router;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class moduleEconomicCustomerRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -22,6 +25,7 @@ class moduleEconomicCustomerRoute
|
||||
|
||||
/** Modules > Economic > Customer > Get customer */
|
||||
$this->get('/modules/economic/customer', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/modules/economic/customer');
|
||||
global $response;
|
||||
$this->requirePermission('modules_economic_customer_get');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -45,6 +49,7 @@ class moduleEconomicCustomerRoute
|
||||
|
||||
/** Modules > Economic > Customer > Create customer */
|
||||
$this->post('/modules/economic/customer', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/modules/economic/customer');
|
||||
global $response;
|
||||
$this->requirePermission('modules_economic_customer_create');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -11,6 +11,9 @@ use objects\logs_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class moduleEconomicRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -24,6 +27,7 @@ class moduleEconomicRoute
|
||||
|
||||
/** Economic > Customers > Import customer */
|
||||
$this->post('/economic/customers/import', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/customers/import');
|
||||
global $response;
|
||||
$this->requirePermission('economic_import_customer');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -8,6 +8,9 @@ use classes\router;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class moduleEntraRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -21,6 +24,7 @@ class moduleEntraRoute
|
||||
|
||||
/** Modules > Entra > Users > GET */
|
||||
$this->get('/modules/entra/users', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/entra/users');
|
||||
global $response;
|
||||
$this->requirePermission('modules_entra_users');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -9,6 +9,8 @@ use classes\router;
|
||||
use objects\currency_conversion_rates_o;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class moduleFxRatesAPIRoute
|
||||
{
|
||||
@@ -23,6 +25,7 @@ class moduleFxRatesAPIRoute
|
||||
|
||||
/** Modules > FXRatesAPI > conversion rate > GET */
|
||||
$this->get('/modules/fxratesapi/rate', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/fxratesapi/rate');
|
||||
global $response;
|
||||
$this->requirePermission('modules_fxratesapi_rate');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -10,6 +10,9 @@ use limble\helpers\limble_tasks_pagination;
|
||||
use limble\helpers\limble_webhook_payload_task;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class moduleLimbleRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -20,6 +23,7 @@ class moduleLimbleRoute
|
||||
/** @var router $router */
|
||||
$router, $response;
|
||||
$this->post('/modules/limble/webhook/task', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/limble/webhook/task');
|
||||
global $response;
|
||||
$slack = new slack();
|
||||
$this->requirePermission('modules_limble_webhooks_task');
|
||||
@@ -41,6 +45,7 @@ class moduleLimbleRoute
|
||||
);
|
||||
|
||||
$this->get('/modules/limble/tasks', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/limble/tasks');
|
||||
global $response;
|
||||
$slack = new slack();
|
||||
$slack->send_message('Limble Tasks Endpoint Triggered', 'Limble Tasks');
|
||||
|
||||
@@ -9,6 +9,9 @@ use classes\router;
|
||||
use objects\logs_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class moduleMotorAPIRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -22,6 +25,7 @@ class moduleMotorAPIRoute
|
||||
|
||||
/** Modules > MotorAPI > Lookup > GET */
|
||||
$this->get('/modules/motorapi/lookup', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/motorapi/lookup');
|
||||
global $response;
|
||||
$this->requirePermission('modules_motorapi_lookup');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -10,6 +10,9 @@ use objects\logs_o;
|
||||
use stdClass;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class moduleN8nRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -21,6 +24,7 @@ class moduleN8nRoute
|
||||
$router, $response;
|
||||
|
||||
$this->get('/modules/n8n/workflows', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/n8n/workflows');
|
||||
global $response;
|
||||
$this->requirePermission('modules_n8n_workflows_view');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -46,6 +50,7 @@ class moduleN8nRoute
|
||||
]);
|
||||
|
||||
$this->get('/modules/n8n/workflows/{id}', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/n8n/workflows/{id}');
|
||||
global $response;
|
||||
$this->requirePermission('modules_n8n_workflows_view');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -62,6 +67,7 @@ class moduleN8nRoute
|
||||
]);
|
||||
|
||||
$this->post('/modules/n8n/workflows', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/workflows');
|
||||
global $response;
|
||||
$this->requirePermission('modules_n8n_workflows_manage');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -80,6 +86,7 @@ class moduleN8nRoute
|
||||
]);
|
||||
|
||||
$this->put('/modules/n8n/workflows/{id}', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/workflows/{id}');
|
||||
global $response;
|
||||
$this->requirePermission('modules_n8n_workflows_manage');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -98,6 +105,7 @@ class moduleN8nRoute
|
||||
]);
|
||||
|
||||
$this->post('/modules/n8n/workflows/{id}/publish', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/workflows/{id}/publish');
|
||||
global $response;
|
||||
$this->requirePermission('modules_n8n_workflows_manage');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -116,6 +124,7 @@ class moduleN8nRoute
|
||||
]);
|
||||
|
||||
$this->post('/modules/n8n/workflows/{id}/deactivate', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/workflows/{id}/deactivate');
|
||||
global $response;
|
||||
$this->requirePermission('modules_n8n_workflows_manage');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -131,6 +140,7 @@ class moduleN8nRoute
|
||||
]);
|
||||
|
||||
$this->post('/modules/n8n/webhooks/trigger', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/webhooks/trigger');
|
||||
global $response;
|
||||
$this->requirePermission('modules_n8n_workflows_run');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -152,6 +162,7 @@ class moduleN8nRoute
|
||||
]);
|
||||
|
||||
$this->get('/modules/n8n/executions', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/n8n/executions');
|
||||
global $response;
|
||||
$this->requirePermission('modules_n8n_executions_view');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -176,6 +187,7 @@ class moduleN8nRoute
|
||||
]);
|
||||
|
||||
$this->get('/modules/n8n/executions/{id}', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/n8n/executions/{id}');
|
||||
global $response;
|
||||
$this->requirePermission('modules_n8n_executions_view');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -191,6 +203,7 @@ class moduleN8nRoute
|
||||
]);
|
||||
|
||||
$this->post('/modules/n8n/executions/{id}/retry', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/executions/{id}/retry');
|
||||
global $response;
|
||||
$this->requirePermission('modules_n8n_workflows_run');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -211,6 +224,7 @@ class moduleN8nRoute
|
||||
]);
|
||||
|
||||
$this->post('/modules/n8n/executions/{id}/stop', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/executions/{id}/stop');
|
||||
global $response;
|
||||
$this->requirePermission('modules_n8n_workflows_manage');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -6,6 +6,9 @@ use classes\licenseplaterecognizer;
|
||||
use classes\response;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class moduleScannerRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -37,6 +40,7 @@ class moduleScannerRoute
|
||||
$router, $response;
|
||||
/** Modules > Scanner > License Plate Recognition > POST */
|
||||
$this->post('/modules/scanner/lpr', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/scanner/lpr');
|
||||
global $response;
|
||||
$route_started_at = microtime(true);
|
||||
$image_upload = self::getLPRImageUpload();
|
||||
|
||||
@@ -33,6 +33,9 @@ use objects\subusers_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class moduleSelfServeRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -50,6 +53,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Status */
|
||||
$this->get('/modules/self-serve/lane/status', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/status');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_lane_status_view');
|
||||
$selfserve = new selfserve();
|
||||
@@ -72,6 +76,7 @@ class moduleSelfServeRoute
|
||||
);
|
||||
|
||||
$this->put('/modules/self-serve/lane/status', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/status');
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('modules_selfserve_lane_status_set');
|
||||
@@ -127,6 +132,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Wash > In-progress details */
|
||||
$this->get('/modules/self-serve/lane/wash/in-progress', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/wash/in-progress');
|
||||
global $response;
|
||||
self::requireParameters(['lane_id']);
|
||||
$lane_id = (int)$this->getParameter('lane_id');
|
||||
@@ -347,6 +353,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Wash > My active wash */
|
||||
$this->get('/modules/self-serve/lane/wash/my-active-wash', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/wash/my-active-wash');
|
||||
global $response;
|
||||
|
||||
$principal_scope = $this->requireMyActiveWashPrincipalScope();
|
||||
@@ -367,6 +374,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Sessions */
|
||||
$this->get('/modules/self-serve/sessions', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/sessions');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_sessions_view');
|
||||
|
||||
@@ -420,6 +428,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Session detail */
|
||||
$this->get('/modules/self-serve/sessions/{id}', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/sessions/{id}');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_sessions_view');
|
||||
$session_id = (int)$this->fromRoute('id');
|
||||
@@ -439,6 +448,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Force > Stop */
|
||||
$this->post('/modules/self-serve/lane/force/stop', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/stop');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_sessions_force_stop');
|
||||
self::requireParameters(['lane_id', 'bill']);
|
||||
@@ -480,6 +490,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Command */
|
||||
$this->post('/modules/self-serve/lane/command', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/command');
|
||||
global $response;
|
||||
$selfserve = new selfserve();
|
||||
// Get the request user
|
||||
@@ -649,6 +660,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Allowed services (derived from shown tasks) */
|
||||
$this->post('/modules/self-serve/lane/services/allowed', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/services/allowed');
|
||||
global $response;
|
||||
$selfserve = new selfserve();
|
||||
// Validate parameters
|
||||
@@ -756,6 +768,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Gate > Open (ENTRANCE/EXIT) */
|
||||
$this->post('/modules/self-serve/lane/gate/open', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/gate/open');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_lane_gate_open');
|
||||
$selfserve = new selfserve();
|
||||
@@ -805,6 +818,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Hardware > Batch status */
|
||||
$this->post('/modules/self-serve/lane/hardware/batch/status', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/hardware/batch/status');
|
||||
global $response;
|
||||
$selfserve = new selfserve();
|
||||
self::requireParameters(['lane_id']);
|
||||
@@ -840,6 +854,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Hardware > Batch set/open */
|
||||
$this->post('/modules/self-serve/lane/hardware/batch/set', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/hardware/batch/set');
|
||||
global $response;
|
||||
$selfserve = new selfserve();
|
||||
self::requireParameters(['lane_id', 'commands']);
|
||||
@@ -869,6 +884,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Hardware > Batch poll */
|
||||
$this->get('/modules/self-serve/lane/hardware/batch/{batch_id}', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/hardware/batch/{batch_id}');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_lane_relay_machine_status_view');
|
||||
try {
|
||||
@@ -882,6 +898,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Relay > MACHINE_PROGRAM_PICKER status */
|
||||
$this->get('/modules/self-serve/lane/relay/machine_program_picker/status', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/relay/machine_program_picker/status');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_view');
|
||||
$selfserve = new selfserve();
|
||||
@@ -904,6 +921,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Relay > MACHINE_PROGRAM_PICKER set on/off */
|
||||
$this->post('/modules/self-serve/lane/relay/machine_program_picker/set', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/relay/machine_program_picker/set');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_set');
|
||||
$selfserve = new selfserve();
|
||||
@@ -935,6 +953,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Relay > MACHINE_CLEANER status */
|
||||
$this->get('/modules/self-serve/lane/relay/machine_cleaner/status', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/relay/machine_cleaner/status');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_view');
|
||||
$selfserve = new selfserve();
|
||||
@@ -957,6 +976,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Relay > MACHINE_CLEANER set on/off */
|
||||
$this->post('/modules/self-serve/lane/relay/machine_cleaner/set', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/relay/machine_cleaner/set');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_set');
|
||||
$selfserve = new selfserve();
|
||||
@@ -988,6 +1008,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Relay > MACHINE status */
|
||||
$this->get('/modules/self-serve/lane/relay/machine/status', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/relay/machine/status');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_lane_relay_machine_status_view');
|
||||
$selfserve = new selfserve();
|
||||
@@ -1010,6 +1031,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Relay > MACHINE set on/off */
|
||||
$this->post('/modules/self-serve/lane/relay/machine/set', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/relay/machine/set');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_lane_relay_machine_status_set');
|
||||
$selfserve = new selfserve();
|
||||
@@ -1058,6 +1080,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Relay > Enable MACHINE_PROGRAM_PICKER (manual) */
|
||||
$this->post('/modules/self-serve/lane/relay/machine_program_picker/enable', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/relay/machine_program_picker/enable');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_lane_relay_enable_machine_program_picker');
|
||||
$selfserve = new selfserve();
|
||||
@@ -1091,6 +1114,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Relay > Enable MACHINE_CLEANER (manual) */
|
||||
$this->post('/modules/self-serve/lane/relay/machine_cleaner/enable', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/relay/machine_cleaner/enable');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_lane_relay_enable_machine_cleaner');
|
||||
$selfserve = new selfserve();
|
||||
@@ -1124,6 +1148,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Relay > Enable MACHINE (manual, gated by allowed services) */
|
||||
$this->post('/modules/self-serve/lane/relay/machine/enable', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/relay/machine/enable');
|
||||
global $response;
|
||||
$selfserve = new selfserve();
|
||||
// Validate parameters
|
||||
@@ -1173,6 +1198,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Force > MACHINE_PROGRAM_PICKER enable */
|
||||
$this->post('/modules/self-serve/lane/force/machine_program_picker/enable', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/machine_program_picker/enable');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_lane_force_machine_program_picker_enable');
|
||||
$selfserve = new selfserve();
|
||||
@@ -1206,6 +1232,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Force > MACHINE_PROGRAM_PICKER disable */
|
||||
$this->post('/modules/self-serve/lane/force/machine_program_picker/disable', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/machine_program_picker/disable');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_lane_force_machine_program_picker_disable');
|
||||
$selfserve = new selfserve();
|
||||
@@ -1233,6 +1260,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Force > MACHINE_CLEANER enable */
|
||||
$this->post('/modules/self-serve/lane/force/machine_cleaner/enable', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/machine_cleaner/enable');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_lane_force_machine_cleaner_enable');
|
||||
$selfserve = new selfserve();
|
||||
@@ -1266,6 +1294,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Force > MACHINE_CLEANER disable */
|
||||
$this->post('/modules/self-serve/lane/force/machine_cleaner/disable', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/machine_cleaner/disable');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_lane_force_machine_cleaner_disable');
|
||||
$selfserve = new selfserve();
|
||||
@@ -1293,6 +1322,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Force > MACHINE enable (simulate started wash) */
|
||||
$this->post('/modules/self-serve/lane/force/machine/enable', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/machine/enable');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_lane_force_machine_enable');
|
||||
$selfserve = new selfserve();
|
||||
@@ -1367,6 +1397,7 @@ class moduleSelfServeRoute
|
||||
|
||||
/** Modules > Self Serve > Lane > Force > MACHINE disable (simulate started wash without machine) */
|
||||
$this->post('/modules/self-serve/lane/force/machine/disable', function () {
|
||||
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/machine/disable');
|
||||
global $response;
|
||||
$this->requirePermission('modules_selfserve_lane_force_machine_disable');
|
||||
$selfserve = new selfserve();
|
||||
|
||||
@@ -11,6 +11,9 @@ use objects\logs_o;
|
||||
use objects\orders_o;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class moduleStripeRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -24,6 +27,7 @@ class moduleStripeRoute
|
||||
|
||||
/** Modules > Stripe > Customers > List */
|
||||
$this->get('/modules/stripe/customers', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/modules/stripe/customers');
|
||||
global $response;
|
||||
$this->requirePermission('modules_stripe_customers_list');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -43,6 +47,7 @@ class moduleStripeRoute
|
||||
|
||||
/** Modules > Stripe > Products > List */
|
||||
$this->get('/modules/stripe/products', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/modules/stripe/products');
|
||||
global $response;
|
||||
$this->requirePermission('modules_stripe_products_list');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -62,6 +67,7 @@ class moduleStripeRoute
|
||||
|
||||
/** Modules > Stripe > Prices > List */
|
||||
$this->get('/modules/stripe/prices', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/modules/stripe/prices');
|
||||
global $response;
|
||||
$this->requirePermission('modules_stripe_prices_list');
|
||||
$user = (new authentication())->get_user();
|
||||
@@ -81,6 +87,7 @@ class moduleStripeRoute
|
||||
|
||||
/** Modules > Stripe > Retired direct payment-link creation */
|
||||
$this->post('/modules/stripe/invoice', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/modules/stripe/invoice');
|
||||
global $response;
|
||||
$this->requirePermission('modules_stripe_invoice_send');
|
||||
$response->error([
|
||||
@@ -95,6 +102,7 @@ class moduleStripeRoute
|
||||
|
||||
/** Modules > Stripe > Cancel Invoice */
|
||||
$this->delete('/modules/stripe/invoice', function () {
|
||||
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/modules/stripe/invoice');
|
||||
global $response;
|
||||
$this->requirePermission('modules_stripe_invoice_send');
|
||||
$user = (new authentication())->get_user();
|
||||
|
||||
@@ -6,6 +6,9 @@ use classes\module_usage_service;
|
||||
use Exception;
|
||||
use traits\route_t;
|
||||
|
||||
use app\auth\Scope;
|
||||
use app\auth\ScopeMiddleware;
|
||||
|
||||
class moduleUsageRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -13,6 +16,7 @@ class moduleUsageRoute
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/modules/usage/summary', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/usage/summary');
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('modules_usage_view');
|
||||
@@ -27,6 +31,7 @@ class moduleUsageRoute
|
||||
]);
|
||||
|
||||
$this->get('/modules/usage/{moduleKey}', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/usage/{moduleKey}');
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('modules_usage_view');
|
||||
@@ -41,6 +46,7 @@ class moduleUsageRoute
|
||||
]);
|
||||
|
||||
$this->patch('/modules/quotas/{moduleKey}/{metricKey}', function () {
|
||||
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/quotas/{moduleKey}/{metricKey}');
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('modules_quotas_manage');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user