Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10814e68ae | ||
|
|
fefe18a719 | ||
|
|
9b2d5d5291 | ||
|
|
e1fb79d9b6 | ||
|
|
879dfcf79a | ||
|
|
0feb705059 | ||
|
|
69b3bf83c4 | ||
|
|
5bac316e4b | ||
|
|
233133365d | ||
|
|
026492c3bd | ||
|
|
403f93e62c | ||
|
|
582edd3e6c | ||
|
|
a4fafaf7fb | ||
|
|
327e9cf817 | ||
|
|
012e5366ba | ||
|
|
fa1ade555f |
@@ -0,0 +1,49 @@
|
||||
# Default branch protection
|
||||
|
||||
`master` is changed through pull requests. Do not push or publish directly to
|
||||
the default branch, including through automation or the Git Data API.
|
||||
|
||||
## Normal publishing flow
|
||||
|
||||
1. Create a scoped `agent/*` or feature branch from the current `origin/master`.
|
||||
2. Commit and push only the intended changes.
|
||||
3. Open a pull request targeting `master`.
|
||||
4. Wait for the `Required CI` check. If `master` moves, update the branch and
|
||||
wait for the strict check to rerun.
|
||||
5. Resolve every review conversation and squash-merge the pull request.
|
||||
6. Confirm the post-merge `Release Manager gate` completes on `master`.
|
||||
|
||||
The aggregate check covers the PHP unit, integration, API, and legacy matrix,
|
||||
plus Edge Agent, Edge Broker, and Edge Gateway Backend. Qodana is advisory and
|
||||
the Release Manager gate is intentionally post-merge.
|
||||
|
||||
## Desired ruleset
|
||||
|
||||
[`rulesets/protect-default-branch.json`](rulesets/protect-default-branch.json)
|
||||
is the importable final desired-state repository-ruleset request body. For the
|
||||
initial POST, copy the file and override `enforcement` to `disabled`. Inspect
|
||||
the normalized ruleset and verify a green preparation PR and post-merge run,
|
||||
then PUT the exact committed file to activate it.
|
||||
|
||||
The desired rule targets `~DEFAULT_BRANCH`, requires pull requests with zero
|
||||
approvals, conversation resolution, strict `Required CI` from GitHub Actions
|
||||
integration `15368`, squash-only linear history, and blocks deletion and force
|
||||
pushes. Repository administrators receive pull-request-only bypass; they do not
|
||||
receive a standing direct-push bypass.
|
||||
|
||||
When the ruleset is activated, align repository settings at the same time:
|
||||
retain squash merging, disable merge commits and rebase merging, enable
|
||||
auto-merge and branch-update suggestions, delete merged branches automatically,
|
||||
keep the Actions token read-only, and prevent Actions from approving reviews.
|
||||
|
||||
## Break glass
|
||||
|
||||
When an incident cannot wait for the normal gate:
|
||||
|
||||
1. Open a pull request and describe the incident, risk, and reason for bypass.
|
||||
2. Have a repository administrator use the pull-request-only bypass.
|
||||
3. Monitor `Required CI` and the post-merge Release Manager workflow.
|
||||
4. Open a follow-up pull request for any deferred validation or remediation.
|
||||
|
||||
Never bypass by updating `refs/heads/master` directly. Ruleset changes and
|
||||
emergency bypasses must remain visible in GitHub's audit trail.
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "Protect default branch",
|
||||
"target": "branch",
|
||||
"enforcement": "active",
|
||||
"bypass_actors": [
|
||||
{
|
||||
"actor_id": 5,
|
||||
"actor_type": "RepositoryRole",
|
||||
"bypass_mode": "pull_request"
|
||||
}
|
||||
],
|
||||
"conditions": {
|
||||
"ref_name": {
|
||||
"exclude": [],
|
||||
"include": [
|
||||
"~DEFAULT_BRANCH"
|
||||
]
|
||||
}
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"type": "deletion"
|
||||
},
|
||||
{
|
||||
"type": "non_fast_forward"
|
||||
},
|
||||
{
|
||||
"type": "required_linear_history"
|
||||
},
|
||||
{
|
||||
"type": "pull_request",
|
||||
"parameters": {
|
||||
"allowed_merge_methods": [
|
||||
"squash"
|
||||
],
|
||||
"dismiss_stale_reviews_on_push": false,
|
||||
"require_code_owner_review": false,
|
||||
"require_last_push_approval": false,
|
||||
"required_approving_review_count": 0,
|
||||
"required_review_thread_resolution": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "required_status_checks",
|
||||
"parameters": {
|
||||
"do_not_enforce_on_create": false,
|
||||
"required_status_checks": [
|
||||
{
|
||||
"context": "Required CI",
|
||||
"integration_id": 15368
|
||||
}
|
||||
],
|
||||
"strict_required_status_checks_policy": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,9 +3,11 @@ on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
branches: # Specify your branches here
|
||||
- main # The 'main' branch
|
||||
- 'releases/*' # The release branches
|
||||
branches:
|
||||
- master
|
||||
- beta
|
||||
- canary
|
||||
- internal
|
||||
|
||||
jobs:
|
||||
qodana:
|
||||
|
||||
@@ -3,6 +3,18 @@ name: Tests
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- beta
|
||||
- canary
|
||||
- internal
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
php:
|
||||
@@ -336,11 +348,42 @@ jobs:
|
||||
if: always()
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
|
||||
|
||||
required-ci:
|
||||
name: Required CI
|
||||
runs-on: ubuntu-latest
|
||||
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
||||
if: ${{ always() }}
|
||||
|
||||
steps:
|
||||
- name: Verify required jobs succeeded
|
||||
env:
|
||||
PHP_RESULT: ${{ needs.php.result }}
|
||||
EDGE_AGENT_RESULT: ${{ needs.edge-agent.result }}
|
||||
EDGE_BROKER_RESULT: ${{ needs.edge-broker.result }}
|
||||
EDGE_GATEWAY_BACKEND_RESULT: ${{ needs.edge-gateway-backend.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
for dependency in \
|
||||
"php=${PHP_RESULT}" \
|
||||
"edge-agent=${EDGE_AGENT_RESULT}" \
|
||||
"edge-broker=${EDGE_BROKER_RESULT}" \
|
||||
"edge-gateway-backend=${EDGE_GATEWAY_BACKEND_RESULT}"
|
||||
do
|
||||
name="${dependency%%=*}"
|
||||
result="${dependency#*=}"
|
||||
if [ "$result" != "success" ]; then
|
||||
echo "Required dependency ${name} completed with result: ${result:-missing}" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
release-manager-gate:
|
||||
name: Release Manager gate
|
||||
runs-on: [self-hosted, Linux, X64, pleno, backend]
|
||||
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||
needs: [required-ci]
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.required-ci.result == 'success' }}
|
||||
|
||||
steps:
|
||||
- name: Record Release Manager API gate
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
Backend API for Copenhagen Truck Wash services.
|
||||
|
||||
Changes are published from a scoped feature branch through a pull request to
|
||||
`master`; direct default-branch pushes are not part of the release workflow.
|
||||
See [default branch protection](.github/BRANCH_PROTECTION.md) for the CI gate
|
||||
and emergency procedure.
|
||||
|
||||
## Architecture & Stack
|
||||
- **Edge Proxy:** [Traefik 2.11](https://doc.traefik.io/traefik/) (Handles TLS termination, routing, and rate limiting).
|
||||
- **Web Server:** [Caddy 2.7](https://caddyserver.com/) (Serves the PHP application via FastCGI).
|
||||
|
||||
+533
-19
@@ -1460,7 +1460,7 @@ paths:
|
||||
tags:
|
||||
- Subusers
|
||||
summary: List subusers for a superuser customer account
|
||||
description: Returns paginated driver access grants for the customer number resolved from the selected user.
|
||||
description: Returns paginated chauffeur accounts for the customer number resolved from the selected user. Each chauffeur appears once; visible customer access grants are returned in `grants`.
|
||||
operationId: listSuperuserUserSubusers
|
||||
security:
|
||||
- BearerAuth: []
|
||||
@@ -3801,13 +3801,19 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OrderItemCreate'
|
||||
responses:
|
||||
'201':
|
||||
'200':
|
||||
description: Order item added successfully
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
description: Invalid order item or product blocked by an active customer rule
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/CustomerRuleProductRestrictedResponse'
|
||||
- $ref: '#/components/schemas/Error'
|
||||
put:
|
||||
tags:
|
||||
- Order Items
|
||||
@@ -7196,6 +7202,45 @@ paths:
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
/collected-invoices/economic/pdf:
|
||||
get:
|
||||
tags:
|
||||
- Invoices
|
||||
summary: Download a collected invoice e-conomic PDF
|
||||
description: |
|
||||
Resolves the requested collected invoice context and returns a presigned URL for the
|
||||
draft or booked e-conomic invoice PDF.
|
||||
operationId: downloadCollectedInvoiceEconomicPdf
|
||||
parameters:
|
||||
- name: collected_invoice_id
|
||||
in: query
|
||||
required: true
|
||||
description: The internal collected invoice ID
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: type
|
||||
in: query
|
||||
required: true
|
||||
description: Which e-conomic invoice PDF to download
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- draft
|
||||
- booked
|
||||
responses:
|
||||
'200':
|
||||
description: PDF URL resolved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CollectedInvoiceEconomicPdfResponse'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
/collected-invoices/economic/v2/details:
|
||||
get:
|
||||
tags:
|
||||
@@ -7426,7 +7471,8 @@ paths:
|
||||
description: Invoicing periods retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
schema:
|
||||
$ref: '#/components/schemas/InvoicingPeriodResponseEnvelope'
|
||||
|
||||
/superuser/invoicing/period/distribution/fixed-pricing:
|
||||
get:
|
||||
@@ -10877,23 +10923,89 @@ paths:
|
||||
get:
|
||||
tags:
|
||||
- Attachments
|
||||
summary: Download order attachment
|
||||
description: Download a specific order attachment
|
||||
summary: Get order attachment download link
|
||||
description: Return a legacy HTTPS download link for a specific order attachment
|
||||
operationId: downloadOrderAttachment
|
||||
parameters:
|
||||
- name: id
|
||||
- name: order_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: attachment_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Attachment downloaded successfully
|
||||
description: Attachment download link resolved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OrderAttachmentDownloadLinkResponse'
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'502':
|
||||
description: Attachment storage is unavailable
|
||||
|
||||
/orders/attachments/content:
|
||||
get:
|
||||
tags:
|
||||
- Attachments
|
||||
summary: Stream authenticated order attachment content
|
||||
description: Streams an attachment after validating order scope and attachment ownership
|
||||
operationId: streamOrderAttachmentContent
|
||||
parameters:
|
||||
- name: order_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: attachment_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
- name: disposition
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: [inline, attachment]
|
||||
default: inline
|
||||
responses:
|
||||
'200':
|
||||
description: Attachment content streamed successfully
|
||||
headers:
|
||||
Content-Disposition:
|
||||
schema:
|
||||
type: string
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
image/*:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
application/pdf:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
'400': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'502':
|
||||
description: Attachment storage is unavailable
|
||||
|
||||
# Forms Endpoints
|
||||
/form:
|
||||
@@ -10963,15 +11075,63 @@ paths:
|
||||
$ref: '#/components/schemas/Permission'
|
||||
|
||||
# Customer Management Endpoints
|
||||
/superuser/customer-rules/product-restrictions:
|
||||
get:
|
||||
tags: [Superuser, Users]
|
||||
summary: List global customer-rule product restrictions
|
||||
operationId: listCustomerRuleProductRestrictions
|
||||
responses:
|
||||
'200':
|
||||
description: Rule collections and product catalog retrieved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerRuleProductRestrictionListResponse'
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/superuser/customer-rules/product-restrictions/{attribute}:
|
||||
put:
|
||||
tags: [Superuser, Users]
|
||||
summary: Atomically replace one customer rule's product collections
|
||||
operationId: replaceCustomerRuleProductRestriction
|
||||
parameters:
|
||||
- name: attribute
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerProductImpactAttribute'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerRuleProductRestrictionUpdateRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Rule configuration replaced
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerRuleProductRestrictionResponse'
|
||||
'409': { $ref: '#/components/responses/Conflict' }
|
||||
'422': { $ref: '#/components/responses/BadRequest' }
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
|
||||
/customer/attributes:
|
||||
get:
|
||||
tags:
|
||||
- Users
|
||||
summary: Get customer attributes
|
||||
description: Get custom attributes for a customer
|
||||
description: Get custom attributes for a customer. Authenticated customer accounts may read their own attributes without list_customer_attributes.
|
||||
operationId: getCustomerAttributes
|
||||
parameters:
|
||||
- name: customer_id
|
||||
- name: customer_number
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
- name: user_id
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
@@ -10980,7 +11140,8 @@ paths:
|
||||
description: Customer attributes retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerAttributesResponse'
|
||||
post:
|
||||
tags:
|
||||
- Users
|
||||
@@ -10988,12 +11149,13 @@ paths:
|
||||
description: Add a custom attribute to a customer
|
||||
operationId: addCustomerAttribute
|
||||
requestBody:
|
||||
required: false
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerAttributeMutationRequest'
|
||||
responses:
|
||||
'201':
|
||||
'200':
|
||||
description: Customer attribute added successfully
|
||||
content:
|
||||
application/json:
|
||||
@@ -11004,11 +11166,17 @@ paths:
|
||||
summary: Delete customer attribute
|
||||
description: Remove a custom attribute from a customer
|
||||
operationId: deleteCustomerAttribute
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
parameters:
|
||||
- name: user_id
|
||||
in: query
|
||||
schema: { type: integer }
|
||||
- name: customer_number
|
||||
in: query
|
||||
schema: { type: integer }
|
||||
- name: attribute
|
||||
in: query
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Customer attribute deleted successfully
|
||||
@@ -13946,6 +14114,142 @@ components:
|
||||
type: integer
|
||||
description: HTTP status code
|
||||
|
||||
CustomerProductImpactAttribute:
|
||||
type: string
|
||||
enum: [restrictAdditionalServices, restrictTankCleaning, restrictSpotFree, restrictInteriorCleaning, onlyTankCleaning]
|
||||
|
||||
CustomerRuleProductCollection:
|
||||
type: object
|
||||
required: [id, name, sort_order, product_ids]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string, minLength: 1, maxLength: 191 }
|
||||
sort_order: { type: integer }
|
||||
product_ids:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
items: { type: integer }
|
||||
|
||||
CustomerRuleProductCollectionInput:
|
||||
type: object
|
||||
required: [name, sort_order, product_ids]
|
||||
properties:
|
||||
id: { type: integer, nullable: true }
|
||||
name: { type: string, minLength: 1, maxLength: 191 }
|
||||
sort_order: { type: integer }
|
||||
product_ids:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
items: { type: integer }
|
||||
|
||||
CustomerRuleProductRestriction:
|
||||
type: object
|
||||
required: [attribute, version, collections, disabled_product_ids]
|
||||
properties:
|
||||
attribute: { $ref: '#/components/schemas/CustomerProductImpactAttribute' }
|
||||
version: { type: integer, minimum: 1 }
|
||||
collections:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/CustomerRuleProductCollection' }
|
||||
disabled_product_ids:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
items: { type: integer }
|
||||
|
||||
CustomerRuleProductRestrictionUpdateRequest:
|
||||
type: object
|
||||
required: [version, collections]
|
||||
properties:
|
||||
version: { type: integer, minimum: 1 }
|
||||
collections:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/CustomerRuleProductCollectionInput' }
|
||||
|
||||
CustomerRuleProduct:
|
||||
type: object
|
||||
required: [id, name, category_id, category_name, active]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
name: { type: string }
|
||||
category_id: { type: integer }
|
||||
category_name: { type: string }
|
||||
active: { type: boolean }
|
||||
|
||||
CustomerRuleProductRestrictionListResponse:
|
||||
type: object
|
||||
required: [success, data]
|
||||
properties:
|
||||
success: { type: boolean }
|
||||
data:
|
||||
type: object
|
||||
required: [rules, products]
|
||||
properties:
|
||||
rules:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/CustomerRuleProductRestriction' }
|
||||
products:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/CustomerRuleProduct' }
|
||||
|
||||
CustomerRuleProductRestrictionResponse:
|
||||
type: object
|
||||
required: [success, data]
|
||||
properties:
|
||||
success: { type: boolean }
|
||||
data: { $ref: '#/components/schemas/CustomerRuleProductRestriction' }
|
||||
|
||||
CustomerAttribute:
|
||||
type: object
|
||||
required: [id, user_id, attribute, product_restriction]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
user_id: { type: integer }
|
||||
attribute: { type: string }
|
||||
created_at: { type: string, nullable: true }
|
||||
product_restriction:
|
||||
nullable: true
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/CustomerRuleProductRestriction'
|
||||
|
||||
CustomerAttributesResponse:
|
||||
type: object
|
||||
required: [success, data]
|
||||
properties:
|
||||
success: { type: boolean }
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/CustomerAttribute' }
|
||||
|
||||
CustomerAttributeMutationRequest:
|
||||
type: object
|
||||
required: [attribute]
|
||||
properties:
|
||||
user_id: { type: integer }
|
||||
customer_number: { type: integer }
|
||||
attribute: { type: string }
|
||||
anyOf:
|
||||
- required: [user_id]
|
||||
- required: [customer_number]
|
||||
|
||||
CustomerRuleProductRestrictedResponse:
|
||||
type: object
|
||||
required: [success, data]
|
||||
properties:
|
||||
success: { type: boolean, enum: [false] }
|
||||
data:
|
||||
type: object
|
||||
required: [code, message, product_id, rules, collections]
|
||||
properties:
|
||||
code: { type: string, enum: [CUSTOMER_RULE_PRODUCT_RESTRICTED] }
|
||||
message: { type: string }
|
||||
product_id: { type: integer }
|
||||
rules:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/CustomerProductImpactAttribute' }
|
||||
collections:
|
||||
type: array
|
||||
items: { type: integer }
|
||||
|
||||
DepartmentCustomerPricingUpdateRequest:
|
||||
type: object
|
||||
required:
|
||||
@@ -15409,6 +15713,35 @@ components:
|
||||
type: string
|
||||
enum: [disabled, not_configured, configured, ok, degraded, down]
|
||||
|
||||
SuperuserModuleUsage:
|
||||
type: object
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
enum: [licenseplaterecognizer]
|
||||
calls_used:
|
||||
type: integer
|
||||
minimum: 0
|
||||
quota_calls:
|
||||
type: integer
|
||||
minimum: 1
|
||||
calls_remaining:
|
||||
type: integer
|
||||
minimum: 0
|
||||
usage_percent:
|
||||
type: number
|
||||
format: float
|
||||
minimum: 0
|
||||
version:
|
||||
type: string
|
||||
nullable: true
|
||||
required:
|
||||
- provider
|
||||
- calls_used
|
||||
- quota_calls
|
||||
- calls_remaining
|
||||
- usage_percent
|
||||
|
||||
SuperuserRuntimeMetric:
|
||||
type: object
|
||||
properties:
|
||||
@@ -15518,6 +15851,14 @@ components:
|
||||
status_reason:
|
||||
type: string
|
||||
nullable: true
|
||||
status_reason_key:
|
||||
type: string
|
||||
nullable: true
|
||||
status_reason_params:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
usage:
|
||||
$ref: '#/components/schemas/SuperuserModuleUsage'
|
||||
checked_at:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -16873,6 +17214,7 @@ components:
|
||||
|
||||
SubuserManagementRow:
|
||||
type: object
|
||||
description: Chauffeur management row. Superuser list endpoints return one row per chauffeur account; grant-specific fields mirror a primary grant for backwards compatibility, and all visible customer grants are listed in `grants`.
|
||||
properties:
|
||||
id: { type: integer }
|
||||
username: { type: string, nullable: true }
|
||||
@@ -16910,6 +17252,7 @@ components:
|
||||
grant_updated_at: { type: string, format: date-time, nullable: true }
|
||||
grants:
|
||||
type: array
|
||||
description: Visible customer access grants for this chauffeur, grouped under the single chauffeur row.
|
||||
items:
|
||||
$ref: '#/components/schemas/SubuserManagementGrant'
|
||||
grant_count: { type: integer }
|
||||
@@ -17659,6 +18002,142 @@ components:
|
||||
- meta
|
||||
- includes
|
||||
|
||||
OrderAttachmentDownloadLinkResponse:
|
||||
type: object
|
||||
required: [success, data, meta, includes]
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
enum: [true]
|
||||
data:
|
||||
type: object
|
||||
required: [download_link]
|
||||
properties:
|
||||
download_link:
|
||||
type: string
|
||||
format: uri
|
||||
pattern: '^https://'
|
||||
meta:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
includes:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
InvoicingPeriodResponseEnvelope:
|
||||
type: object
|
||||
required: [success, data, meta, includes]
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
enum: [true]
|
||||
data:
|
||||
$ref: '#/components/schemas/InvoicingPeriodData'
|
||||
meta:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
includes:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
|
||||
InvoicingPeriodData:
|
||||
type: object
|
||||
required: [dateFrom, dateTo, types]
|
||||
properties:
|
||||
dateFrom:
|
||||
type: string
|
||||
format: date
|
||||
dateTo:
|
||||
type: string
|
||||
format: date
|
||||
types:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/InvoicingPeriodCustomer'
|
||||
|
||||
InvoicingPeriodCustomer:
|
||||
type: object
|
||||
required: [customer_number, customer_name, transactions, invoice_collections]
|
||||
additionalProperties: true
|
||||
properties:
|
||||
customer_number:
|
||||
type: integer
|
||||
customer_name:
|
||||
type: string
|
||||
transactions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/InvoicingPeriodTransaction'
|
||||
invoice_collections:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/InvoicingPeriodInvoiceCollection'
|
||||
|
||||
InvoicingPeriodTransaction:
|
||||
type: object
|
||||
required: [id, booked, invoice_state]
|
||||
additionalProperties: true
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
invoice_collection_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
booked:
|
||||
type: boolean
|
||||
invoice_state:
|
||||
type: string
|
||||
enum: [open, closed, economic_draft, economic_booked]
|
||||
completed_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
amount:
|
||||
type: number
|
||||
format: float
|
||||
|
||||
InvoicingPeriodInvoiceCollection:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- invoice_collection_id
|
||||
- customer_number
|
||||
- state
|
||||
- order_ids
|
||||
- order_count
|
||||
- total_net_amount
|
||||
additionalProperties: true
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
invoice_collection_id:
|
||||
type: integer
|
||||
customer_number:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
external_id:
|
||||
type: string
|
||||
nullable: true
|
||||
booked_invoice_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
state:
|
||||
type: string
|
||||
enum: [open, closed, economic_draft, economic_booked]
|
||||
order_ids:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
order_count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
total_net_amount:
|
||||
type: number
|
||||
format: float
|
||||
|
||||
CollectedInvoiceEconomicCompareResponse:
|
||||
type: object
|
||||
description: Result of comparing a collected invoice with its E-conomic counterpart
|
||||
@@ -17711,6 +18190,31 @@ components:
|
||||
- collected_invoice_id
|
||||
- internal_total
|
||||
|
||||
CollectedInvoiceEconomicPdfResponse:
|
||||
type: object
|
||||
description: Presigned PDF URL for a draft or booked e-conomic invoice attached to a collected invoice
|
||||
properties:
|
||||
collected_invoice_id:
|
||||
type: integer
|
||||
example: 123
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- draft
|
||||
- booked
|
||||
example: booked
|
||||
economic_invoice_id:
|
||||
type: integer
|
||||
example: 28368
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
required:
|
||||
- collected_invoice_id
|
||||
- type
|
||||
- economic_invoice_id
|
||||
- url
|
||||
|
||||
CollectedInvoiceEconomicV2DetailsResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -17724,6 +18228,7 @@ components:
|
||||
type: integer
|
||||
economic:
|
||||
type: object
|
||||
required: [draft_id, booked_id, state, available_pdf_type]
|
||||
properties:
|
||||
draft_id:
|
||||
type: integer
|
||||
@@ -17731,6 +18236,15 @@ components:
|
||||
booked_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
state:
|
||||
type: string
|
||||
enum: [draft, booked, none]
|
||||
description: Current authoritative e-conomic target state
|
||||
available_pdf_type:
|
||||
type: string
|
||||
enum: [draft, booked]
|
||||
nullable: true
|
||||
description: PDF target that callers should request
|
||||
customer:
|
||||
$ref: '#/components/schemas/CollectedInvoiceEconomicV2CustomerSummary'
|
||||
internal:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -37,8 +37,21 @@ class attachment_store implements minio_uploads_i
|
||||
*/
|
||||
public function isValidFilePath(string $filePath): bool
|
||||
{
|
||||
// Check if the file path is valid
|
||||
return preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $filePath) === 1;
|
||||
if (
|
||||
$filePath === ''
|
||||
|| str_starts_with($filePath, '/')
|
||||
|| preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $filePath) !== 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (explode('/', $filePath) as $segment) {
|
||||
if ($segment === '' || $segment === '.' || $segment === '..') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +95,10 @@ class attachment_store implements minio_uploads_i
|
||||
{
|
||||
$host = 'https://api.truckwash.io';
|
||||
|
||||
$this->requireValidFilePath($fileName);
|
||||
$encodedPath = implode('/', array_map('rawurlencode', explode('/', $fileName)));
|
||||
|
||||
// Generate a direct download URL for the given file name
|
||||
return $host . '/files/' . $fileName;
|
||||
return $host . '/files/' . $encodedPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class backup_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS backup_records (
|
||||
backup_uuid VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(191) NOT NULL,
|
||||
description TEXT NULL,
|
||||
source VARCHAR(32) NOT NULL DEFAULT 'manual',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
schema_version INT UNSIGNED NOT NULL DEFAULT 2,
|
||||
storage_bucket VARCHAR(191) NOT NULL DEFAULT 'backups',
|
||||
storage_prefix VARCHAR(255) NOT NULL,
|
||||
manifest_key VARCHAR(255) NULL,
|
||||
manifest_sha256 CHAR(64) NULL,
|
||||
encryption_key_id VARCHAR(191) NULL,
|
||||
component_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
object_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
total_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
requested_by_user_id INT NULL,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
verified_at DATETIME NULL,
|
||||
expires_at DATETIME NULL,
|
||||
last_error TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_backup_records_status_created (status, created_at),
|
||||
KEY idx_backup_records_verified (verified_at),
|
||||
KEY idx_backup_records_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS backup_components (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
backup_uuid VARCHAR(64) NOT NULL,
|
||||
component_type VARCHAR(32) NOT NULL,
|
||||
logical_name VARCHAR(191) NOT NULL,
|
||||
source_bucket VARCHAR(191) NULL,
|
||||
source_prefix VARCHAR(255) NULL,
|
||||
storage_key VARCHAR(255) NULL,
|
||||
manifest_key VARCHAR(255) NULL,
|
||||
object_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
byte_size BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
content_sha256 CHAR(64) NULL,
|
||||
encrypted_sha256 CHAR(64) NULL,
|
||||
encryption_key_id VARCHAR(191) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
error_message TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_backup_components_backup (backup_uuid),
|
||||
KEY idx_backup_components_status (status),
|
||||
KEY idx_backup_components_type_name (component_type, logical_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
job_type VARCHAR(32) NOT NULL,
|
||||
backup_uuid VARCHAR(64) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
progress_percent TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
progress_message VARCHAR(255) NULL,
|
||||
payload_json LONGTEXT NULL,
|
||||
result_json LONGTEXT NULL,
|
||||
actor_user_id INT NULL,
|
||||
locked_at DATETIME NULL,
|
||||
lock_owner VARCHAR(191) NULL,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
error_message TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_backup_jobs_status_created (status, created_at),
|
||||
KEY idx_backup_jobs_backup (backup_uuid),
|
||||
KEY idx_backup_jobs_type_status (job_type, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS backup_restore_audit (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
restore_job_id BIGINT UNSIGNED NULL,
|
||||
preview_job_id BIGINT UNSIGNED NULL,
|
||||
backup_uuid VARCHAR(64) NOT NULL,
|
||||
actor_user_id INT NULL,
|
||||
target_environment VARCHAR(64) NOT NULL DEFAULT 'production',
|
||||
confirmation_fingerprint CHAR(64) NULL,
|
||||
reason TEXT NULL,
|
||||
ip_address VARCHAR(64) NULL,
|
||||
user_agent VARCHAR(255) NULL,
|
||||
pre_restore_backup_uuid VARCHAR(64) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
error_message TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_backup_restore_audit_backup (backup_uuid),
|
||||
KEY idx_backup_restore_audit_job (restore_job_id),
|
||||
KEY idx_backup_restore_audit_created (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,11 @@ class coolify_api_client
|
||||
return $this->request('GET', '/services');
|
||||
}
|
||||
|
||||
public function listApplications(): array
|
||||
{
|
||||
return $this->request('GET', '/applications');
|
||||
}
|
||||
|
||||
public function listGithubApps(): array
|
||||
{
|
||||
return $this->request('GET', '/github-apps');
|
||||
@@ -169,6 +174,11 @@ class coolify_api_client
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
|
||||
}
|
||||
|
||||
public function stopService(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/services/' . rawurlencode($uuid) . '/stop');
|
||||
}
|
||||
|
||||
public function stopApplication(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/stop');
|
||||
@@ -179,6 +189,11 @@ class coolify_api_client
|
||||
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
|
||||
}
|
||||
|
||||
public function deleteApplication(string $uuid): array
|
||||
{
|
||||
return $this->request('DELETE', '/applications/' . rawurlencode($uuid));
|
||||
}
|
||||
|
||||
public function listDeployments(): array
|
||||
{
|
||||
return $this->request('GET', '/deployments');
|
||||
|
||||
@@ -67,15 +67,79 @@ class cron_scheduler
|
||||
);
|
||||
}
|
||||
|
||||
public function queueTaskRun(string $task_id_or_legacy_name, ?int $actor_user_id = null, bool $force = false): array
|
||||
{
|
||||
$this->ensureReady();
|
||||
$this->syncDefinitions();
|
||||
|
||||
$definition = $this->registry->get($task_id_or_legacy_name);
|
||||
if ($definition === null) {
|
||||
throw new RuntimeException('Cron task not found.');
|
||||
}
|
||||
|
||||
$state = $this->stateRows()[$definition->id] ?? [];
|
||||
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
|
||||
if (!$enabled && !$force) {
|
||||
throw new RuntimeException('Cron task is disabled.');
|
||||
}
|
||||
if ($this->taskIsLocked($state)) {
|
||||
throw new RuntimeException('Cron task is already running.');
|
||||
}
|
||||
|
||||
$existing = $this->fetchOne(
|
||||
"SELECT * FROM cron_task_runs
|
||||
WHERE task_id = " . $this->sql($definition->id) . " AND status = 'queued'
|
||||
ORDER BY id DESC LIMIT 1"
|
||||
);
|
||||
if ($existing !== null) {
|
||||
$this->markTaskQueued($definition);
|
||||
return $this->publicRun($existing);
|
||||
}
|
||||
|
||||
$scheduled_for = date('Y-m-d H:i:s');
|
||||
$this->query(
|
||||
"INSERT INTO cron_task_runs
|
||||
(task_id, module, source, status, actor_user_id, scheduled_for, force_run)
|
||||
VALUES ("
|
||||
. $this->sql($definition->id) . ', '
|
||||
. $this->sql($definition->module) . ", 'manual', 'queued', "
|
||||
. ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', '
|
||||
. $this->sql($scheduled_for) . ', '
|
||||
. ($force ? '1' : '0')
|
||||
. ")"
|
||||
);
|
||||
|
||||
$run_id = (int)$this->insertId();
|
||||
$this->markTaskQueued($definition);
|
||||
|
||||
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
|
||||
}
|
||||
|
||||
public function runDue(string $source = 'automatic'): array
|
||||
{
|
||||
$this->ensureReady();
|
||||
$this->syncDefinitions();
|
||||
|
||||
$ran = [];
|
||||
foreach ($this->queuedRuns() as $queuedRun) {
|
||||
try {
|
||||
$run = $this->runQueuedRun($queuedRun);
|
||||
if ($run !== null) {
|
||||
$ran[] = $run;
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
$ran[] = [
|
||||
'task_id' => (string)($queuedRun['task_id'] ?? ''),
|
||||
'module' => (string)($queuedRun['module'] ?? ''),
|
||||
'source' => (string)($queuedRun['source'] ?? 'manual'),
|
||||
'status' => 'skipped',
|
||||
'error_message' => $throwable->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$states = $this->stateRows();
|
||||
|
||||
foreach ($this->registry->definitions() as $definition) {
|
||||
$state = $states[$definition->id] ?? [];
|
||||
if (!(bool)($state['enabled'] ?? $definition->enabled)) {
|
||||
@@ -106,6 +170,31 @@ class cron_scheduler
|
||||
];
|
||||
}
|
||||
|
||||
public function markExpiredRunningRuns(): int
|
||||
{
|
||||
$this->ensureReady();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$message = 'Task lock expired before completion.';
|
||||
|
||||
$this->query(
|
||||
"UPDATE cron_task_runs r
|
||||
INNER JOIN cron_task_state s ON s.task_id = r.task_id AND s.current_run_id = r.id
|
||||
SET r.status = 'timed_out',
|
||||
r.completed_at = COALESCE(s.locked_until, " . $this->sql($now) . "),
|
||||
r.error_message = COALESCE(r.error_message, " . $this->sql($message) . "),
|
||||
s.current_run_id = NULL,
|
||||
s.locked_until = NULL,
|
||||
s.lock_owner = NULL,
|
||||
s.last_status = 'timed_out',
|
||||
s.last_error = " . $this->sql($message) . "
|
||||
WHERE r.status = 'running'
|
||||
AND s.locked_until IS NOT NULL
|
||||
AND s.locked_until < " . $this->sql($now)
|
||||
);
|
||||
|
||||
return $this->affectedRows();
|
||||
}
|
||||
|
||||
public function runTask(
|
||||
string $task_id_or_legacy_name,
|
||||
string $source = 'manual',
|
||||
@@ -133,11 +222,16 @@ class cron_scheduler
|
||||
|
||||
$started = microtime(true);
|
||||
$started_at = date('Y-m-d H:i:s', (int)$started);
|
||||
$run_id = $this->createRun($definition, $source, $actor_user_id, $scheduled_for, $started_at);
|
||||
$run_id = $this->createRun($definition, $source, $actor_user_id, $scheduled_for, $started_at, $force);
|
||||
$this->query(
|
||||
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
|
||||
return $this->executeClaimedRun($definition, $run_id, $started);
|
||||
}
|
||||
|
||||
private function executeClaimedRun(cron_task_definition $definition, int $run_id, float $started): array
|
||||
{
|
||||
$status = 'succeeded';
|
||||
$summary = [];
|
||||
$error_message = null;
|
||||
@@ -180,9 +274,7 @@ class cron_scheduler
|
||||
$this->completeRun($run_id, $status, $completed_at, $duration_ms, $summary, $error_message);
|
||||
$this->releaseLock($definition, $status, $error_message, $completed_at);
|
||||
|
||||
$run = $this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? [];
|
||||
$run['summary'] = $this->decodeJson($run['summary_json'] ?? null);
|
||||
return $run;
|
||||
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
|
||||
}
|
||||
|
||||
public function updateTaskConfig(string $task_id, array $config): array
|
||||
@@ -275,6 +367,118 @@ class cron_scheduler
|
||||
return $this->affectedRows() === 1;
|
||||
}
|
||||
|
||||
private function taskIsLocked(array $state): bool
|
||||
{
|
||||
$lockedUntil = (string)($state['locked_until'] ?? '');
|
||||
return $lockedUntil !== ''
|
||||
&& strtotime($lockedUntil) !== false
|
||||
&& strtotime($lockedUntil) >= time();
|
||||
}
|
||||
|
||||
private function markTaskQueued(cron_task_definition $definition): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$this->query(
|
||||
"UPDATE cron_task_state
|
||||
SET last_status = 'queued',
|
||||
last_error = NULL,
|
||||
next_run_at = CASE
|
||||
WHEN next_run_at IS NULL OR next_run_at > " . $this->sql($now) . " THEN " . $this->sql($now) . "
|
||||
ELSE next_run_at
|
||||
END
|
||||
WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function queuedRuns(): array
|
||||
{
|
||||
return $this->fetchAll("SELECT * FROM cron_task_runs WHERE status = 'queued' ORDER BY id ASC LIMIT 50");
|
||||
}
|
||||
|
||||
private function runQueuedRun(array $queuedRun): ?array
|
||||
{
|
||||
$run_id = (int)($queuedRun['id'] ?? 0);
|
||||
$definition = $this->registry->get((string)($queuedRun['task_id'] ?? ''));
|
||||
if ($run_id < 1 || $definition === null) {
|
||||
if ($run_id > 0) {
|
||||
$this->skipQueuedRun($run_id, 'Cron task not found.');
|
||||
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$force = (bool)($queuedRun['force_run'] ?? false);
|
||||
$state = $this->stateRows()[$definition->id] ?? [];
|
||||
$enabled = (bool)($state['enabled'] ?? $definition->enabled);
|
||||
if (!$enabled && !$force) {
|
||||
$this->skipQueuedRun($run_id, 'Cron task is disabled.', $definition);
|
||||
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
|
||||
}
|
||||
|
||||
if (!$this->claimLock($definition)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$started = microtime(true);
|
||||
$started_at = date('Y-m-d H:i:s', (int)$started);
|
||||
$this->query(
|
||||
"UPDATE cron_task_runs
|
||||
SET status = 'running',
|
||||
started_at = " . $this->sql($started_at) . ",
|
||||
lock_owner = " . $this->sql($this->lock_owner) . "
|
||||
WHERE id = $run_id AND status = 'queued'"
|
||||
);
|
||||
|
||||
if ($this->affectedRows() !== 1) {
|
||||
$this->clearClaimedLock($definition);
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->query(
|
||||
"UPDATE cron_task_state SET current_run_id = $run_id WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
|
||||
return $this->executeClaimedRun($definition, $run_id, $started);
|
||||
}
|
||||
|
||||
private function skipQueuedRun(int $run_id, string $message, ?cron_task_definition $definition = null): void
|
||||
{
|
||||
$completed_at = date('Y-m-d H:i:s');
|
||||
$this->query(
|
||||
"UPDATE cron_task_runs
|
||||
SET status = 'skipped',
|
||||
completed_at = " . $this->sql($completed_at) . ",
|
||||
duration_ms = 0,
|
||||
error_message = " . $this->sql($message) . "
|
||||
WHERE id = $run_id AND status = 'queued'"
|
||||
);
|
||||
$updated = $this->affectedRows() === 1;
|
||||
if (!$updated || $definition === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->query(
|
||||
"UPDATE cron_task_state
|
||||
SET last_status = 'skipped',
|
||||
last_error = " . $this->sql($message) . "
|
||||
WHERE task_id = " . $this->sql($definition->id)
|
||||
);
|
||||
}
|
||||
|
||||
private function clearClaimedLock(cron_task_definition $definition): void
|
||||
{
|
||||
$this->query(
|
||||
"UPDATE cron_task_state
|
||||
SET locked_until = NULL,
|
||||
lock_owner = NULL
|
||||
WHERE task_id = " . $this->sql($definition->id) . "
|
||||
AND lock_owner = " . $this->sql($this->lock_owner)
|
||||
);
|
||||
}
|
||||
|
||||
private function releaseLock(cron_task_definition $definition, string $status, ?string $error_message, string $completed_at): void
|
||||
{
|
||||
$state = $this->fetchOne("SELECT schedule_json FROM cron_task_state WHERE task_id = " . $this->sql($definition->id));
|
||||
@@ -315,11 +519,12 @@ class cron_scheduler
|
||||
string $source,
|
||||
?int $actor_user_id,
|
||||
?string $scheduled_for,
|
||||
string $started_at
|
||||
string $started_at,
|
||||
bool $force = false
|
||||
): int {
|
||||
$this->query(
|
||||
"INSERT INTO cron_task_runs
|
||||
(task_id, module, source, status, actor_user_id, scheduled_for, started_at, lock_owner)
|
||||
(task_id, module, source, status, actor_user_id, scheduled_for, started_at, lock_owner, force_run)
|
||||
VALUES ("
|
||||
. $this->sql($definition->id) . ', '
|
||||
. $this->sql($definition->module) . ', '
|
||||
@@ -327,7 +532,8 @@ class cron_scheduler
|
||||
. ($actor_user_id === null ? 'NULL' : (string)(int)$actor_user_id) . ', '
|
||||
. $this->nullableSql($scheduled_for) . ', '
|
||||
. $this->sql($started_at) . ', '
|
||||
. $this->sql($this->lock_owner)
|
||||
. $this->sql($this->lock_owner) . ', '
|
||||
. ($force ? '1' : '0')
|
||||
. ")"
|
||||
);
|
||||
|
||||
@@ -418,6 +624,17 @@ class cron_scheduler
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function publicRun(array $run): array
|
||||
{
|
||||
if ($run === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$run['force_run'] = (bool)($run['force_run'] ?? false);
|
||||
$run['summary'] = $this->decodeJson($run['summary_json'] ?? null);
|
||||
return $run;
|
||||
}
|
||||
|
||||
private function fetchOne(string $sql): ?array
|
||||
{
|
||||
$rows = $this->fetchAll($sql);
|
||||
|
||||
@@ -53,6 +53,7 @@ class cron_schema_bootstrap
|
||||
summary_json LONGTEXT NULL,
|
||||
error_message TEXT NULL,
|
||||
lock_owner VARCHAR(191) NULL,
|
||||
force_run TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_cron_task_runs_task_created (task_id, created_at),
|
||||
@@ -60,7 +61,57 @@ class cron_schema_bootstrap
|
||||
KEY idx_cron_task_runs_module_created (module, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
self::ensureColumn('cron_task_runs', 'force_run', 'TINYINT(1) NOT NULL DEFAULT 0 AFTER lock_owner');
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS cron_worker_state (
|
||||
worker_id VARCHAR(191) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(191) NOT NULL,
|
||||
hostname VARCHAR(191) NULL,
|
||||
pid INT UNSIGNED NULL,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'coolify_worker',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'starting',
|
||||
release_channel_id BIGINT UNSIGNED NULL,
|
||||
release_target_id BIGINT UNSIGNED NULL,
|
||||
coolify_resource_uuid VARCHAR(128) NULL,
|
||||
coolify_resource_type VARCHAR(32) NULL,
|
||||
commit_sha VARCHAR(64) NULL,
|
||||
poll_seconds INT UNSIGNED NOT NULL DEFAULT 15,
|
||||
last_run_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_stale_run_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_error TEXT NULL,
|
||||
started_at DATETIME NULL,
|
||||
last_heartbeat_at DATETIME NULL,
|
||||
last_loop_started_at DATETIME NULL,
|
||||
last_loop_finished_at DATETIME NULL,
|
||||
stopped_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_cron_worker_state_heartbeat (last_heartbeat_at),
|
||||
KEY idx_cron_worker_state_status (status),
|
||||
KEY idx_cron_worker_state_release_target (release_target_id),
|
||||
KEY idx_cron_worker_state_coolify_resource (coolify_resource_uuid)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||
if ($table === '' || $column === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
||||
if ($result && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Throwable;
|
||||
|
||||
class cron_worker
|
||||
{
|
||||
private cron_scheduler $scheduler;
|
||||
private string $worker_id;
|
||||
private string $name;
|
||||
private string $source;
|
||||
private int $poll_seconds;
|
||||
private int $heartbeat_seconds;
|
||||
private int $max_runtime_seconds;
|
||||
private bool $should_stop = false;
|
||||
private int $last_heartbeat = 0;
|
||||
|
||||
public function __construct(?cron_scheduler $scheduler = null, array $options = [])
|
||||
{
|
||||
$this->scheduler = $scheduler ?? new cron_scheduler();
|
||||
$this->name = $this->stringOption($options, 'name', 'CRON_WORKER_NAME', 'cron-worker');
|
||||
$this->worker_id = $this->stringOption($options, 'worker_id', 'CRON_WORKER_ID', $this->name);
|
||||
$this->source = $this->stringOption($options, 'source', 'CRON_WORKER_SOURCE', 'coolify_worker');
|
||||
$this->poll_seconds = $this->intOption($options, 'poll_seconds', 'CRON_WORKER_POLL_SECONDS', 15, 1, 300);
|
||||
$this->heartbeat_seconds = $this->intOption($options, 'heartbeat_seconds', 'CRON_WORKER_HEARTBEAT_SECONDS', 30, 5, 300);
|
||||
$this->max_runtime_seconds = $this->intOption($options, 'max_runtime_seconds', 'CRON_WORKER_MAX_RUNTIME_SECONDS', 0, 0, 86400);
|
||||
}
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
if (!$this->boolOption('CRON_WORKER_ENABLED', true)) {
|
||||
$this->heartbeat('disabled', 0, 0, null, true);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->registerSignalHandlers();
|
||||
$started = time();
|
||||
$this->heartbeat('starting', 0, 0, null, true);
|
||||
|
||||
while (!$this->should_stop) {
|
||||
$result = $this->tick();
|
||||
$this->writeStatusLine($result);
|
||||
|
||||
if ($this->max_runtime_seconds > 0 && time() - $started >= $this->max_runtime_seconds) {
|
||||
$this->should_stop = true;
|
||||
break;
|
||||
}
|
||||
|
||||
$this->sleepUntilNextPoll();
|
||||
}
|
||||
|
||||
$this->heartbeat('stopped', 0, 0, null, true, true);
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function tick(): array
|
||||
{
|
||||
$this->heartbeat('running');
|
||||
$loopStartedAt = date('Y-m-d H:i:s');
|
||||
$staleRuns = 0;
|
||||
$ran = ['count' => 0, 'ran' => []];
|
||||
$error = null;
|
||||
$status = 'running';
|
||||
|
||||
try {
|
||||
$staleRuns = $this->scheduler->markExpiredRunningRuns();
|
||||
$ran = $this->scheduler->runDue($this->source);
|
||||
} catch (Throwable $throwable) {
|
||||
$status = 'failed';
|
||||
$error = $throwable->getMessage();
|
||||
}
|
||||
|
||||
$this->heartbeat($status, (int)($ran['count'] ?? 0), $staleRuns, $error, true, false, $loopStartedAt);
|
||||
|
||||
return [
|
||||
'worker_id' => $this->worker_id,
|
||||
'status' => $status,
|
||||
'ran' => (int)($ran['count'] ?? 0),
|
||||
'stale_runs' => $staleRuns,
|
||||
'error' => $error,
|
||||
];
|
||||
}
|
||||
|
||||
public function listWorkers(): array
|
||||
{
|
||||
cron_schema_bootstrap::ensureTables();
|
||||
$rows = $this->fetchAll('SELECT * FROM cron_worker_state ORDER BY last_heartbeat_at DESC, worker_id');
|
||||
$workers = [];
|
||||
foreach ($rows as $row) {
|
||||
$workers[] = $this->publicWorker($row);
|
||||
}
|
||||
|
||||
return [
|
||||
'workers' => $workers,
|
||||
'summary' => [
|
||||
'total' => count($workers),
|
||||
'running' => count(array_filter($workers, static fn(array $worker): bool => ($worker['status'] ?? '') === 'running')),
|
||||
'stale' => count(array_filter($workers, static fn(array $worker): bool => (bool)($worker['stale'] ?? false))),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function registerSignalHandlers(): void
|
||||
{
|
||||
if (!function_exists('pcntl_signal')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (function_exists('pcntl_async_signals')) {
|
||||
pcntl_async_signals(true);
|
||||
}
|
||||
|
||||
pcntl_signal(SIGTERM, function (): void {
|
||||
$this->should_stop = true;
|
||||
});
|
||||
pcntl_signal(SIGINT, function (): void {
|
||||
$this->should_stop = true;
|
||||
});
|
||||
}
|
||||
|
||||
private function sleepUntilNextPoll(): void
|
||||
{
|
||||
$remaining = $this->poll_seconds;
|
||||
while ($remaining > 0 && !$this->should_stop) {
|
||||
$sleep = min(1, $remaining);
|
||||
sleep($sleep);
|
||||
$remaining -= $sleep;
|
||||
if (time() - $this->last_heartbeat >= $this->heartbeat_seconds) {
|
||||
$this->heartbeat('running');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function heartbeat(
|
||||
string $status,
|
||||
int $runCount = 0,
|
||||
int $staleRunCount = 0,
|
||||
?string $error = null,
|
||||
bool $force = false,
|
||||
bool $stopped = false,
|
||||
?string $loopStartedAt = null
|
||||
): void {
|
||||
if (!$force && time() - $this->last_heartbeat < $this->heartbeat_seconds) {
|
||||
return;
|
||||
}
|
||||
|
||||
cron_schema_bootstrap::ensureTables();
|
||||
$this->last_heartbeat = time();
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$workerId = $this->sql($this->worker_id);
|
||||
$name = $this->sql($this->name);
|
||||
$hostname = $this->nullableSql(gethostname() ?: null);
|
||||
$pid = getmypid() ?: 0;
|
||||
$source = $this->sql($this->source);
|
||||
$statusSql = $this->sql($status);
|
||||
$releaseChannelId = $this->nullableInt($this->env('CRON_WORKER_RELEASE_CHANNEL_ID'));
|
||||
$releaseTargetId = $this->nullableInt($this->env('CRON_WORKER_RELEASE_TARGET_ID'));
|
||||
$resourceUuid = $this->nullableSql($this->env('COOLIFY_RESOURCE_UUID') ?: $this->env('CRON_WORKER_COOLIFY_RESOURCE_UUID'));
|
||||
$resourceType = $this->nullableSql($this->env('COOLIFY_RESOURCE_TYPE') ?: $this->env('CRON_WORKER_COOLIFY_RESOURCE_TYPE') ?: 'application');
|
||||
$commitSha = $this->nullableSql($this->commitSha());
|
||||
$errorSql = $this->nullableSql($error);
|
||||
$loopStarted = $this->nullableSql($loopStartedAt);
|
||||
$stoppedAt = $stopped ? $this->sql($now) : 'NULL';
|
||||
|
||||
$this->query(
|
||||
"INSERT INTO cron_worker_state (
|
||||
worker_id, name, hostname, pid, source, status, release_channel_id, release_target_id,
|
||||
coolify_resource_uuid, coolify_resource_type, commit_sha, poll_seconds, last_run_count,
|
||||
last_stale_run_count, last_error, started_at, last_heartbeat_at, last_loop_started_at,
|
||||
last_loop_finished_at, stopped_at
|
||||
) VALUES (
|
||||
$workerId, $name, $hostname, $pid, $source, $statusSql, $releaseChannelId, $releaseTargetId,
|
||||
$resourceUuid, $resourceType, $commitSha, $this->poll_seconds, $runCount,
|
||||
$staleRunCount, $errorSql, $this->sql($now), $this->sql($now), $loopStarted,
|
||||
$this->sql($now), $stoppedAt
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
hostname = VALUES(hostname),
|
||||
pid = VALUES(pid),
|
||||
source = VALUES(source),
|
||||
status = VALUES(status),
|
||||
release_channel_id = VALUES(release_channel_id),
|
||||
release_target_id = VALUES(release_target_id),
|
||||
coolify_resource_uuid = VALUES(coolify_resource_uuid),
|
||||
coolify_resource_type = VALUES(coolify_resource_type),
|
||||
commit_sha = VALUES(commit_sha),
|
||||
poll_seconds = VALUES(poll_seconds),
|
||||
last_run_count = VALUES(last_run_count),
|
||||
last_stale_run_count = VALUES(last_stale_run_count),
|
||||
last_error = VALUES(last_error),
|
||||
last_heartbeat_at = VALUES(last_heartbeat_at),
|
||||
last_loop_started_at = COALESCE(VALUES(last_loop_started_at), last_loop_started_at),
|
||||
last_loop_finished_at = VALUES(last_loop_finished_at),
|
||||
stopped_at = VALUES(stopped_at)"
|
||||
);
|
||||
}
|
||||
|
||||
private function publicWorker(array $row): array
|
||||
{
|
||||
$heartbeatAt = (string)($row['last_heartbeat_at'] ?? '');
|
||||
$heartbeatTs = strtotime($heartbeatAt);
|
||||
$threshold = max(60, ((int)($row['poll_seconds'] ?? 15) * 4) + 30);
|
||||
$age = $heartbeatTs !== false ? max(0, time() - $heartbeatTs) : null;
|
||||
|
||||
return [
|
||||
'worker_id' => (string)($row['worker_id'] ?? ''),
|
||||
'name' => (string)($row['name'] ?? ''),
|
||||
'hostname' => $row['hostname'] ?? null,
|
||||
'pid' => isset($row['pid']) ? (int)$row['pid'] : null,
|
||||
'source' => (string)($row['source'] ?? ''),
|
||||
'status' => (string)($row['status'] ?? 'unknown'),
|
||||
'release_channel_id' => isset($row['release_channel_id']) ? (int)$row['release_channel_id'] : null,
|
||||
'release_target_id' => isset($row['release_target_id']) ? (int)$row['release_target_id'] : null,
|
||||
'coolify_resource_uuid' => $row['coolify_resource_uuid'] ?? null,
|
||||
'coolify_resource_type' => $row['coolify_resource_type'] ?? null,
|
||||
'commit_sha' => $row['commit_sha'] ?? null,
|
||||
'poll_seconds' => (int)($row['poll_seconds'] ?? 0),
|
||||
'last_run_count' => (int)($row['last_run_count'] ?? 0),
|
||||
'last_stale_run_count' => (int)($row['last_stale_run_count'] ?? 0),
|
||||
'last_error' => $row['last_error'] ?? null,
|
||||
'started_at' => $row['started_at'] ?? null,
|
||||
'last_heartbeat_at' => $heartbeatAt !== '' ? $heartbeatAt : null,
|
||||
'last_heartbeat_age_seconds' => $age,
|
||||
'last_loop_started_at' => $row['last_loop_started_at'] ?? null,
|
||||
'last_loop_finished_at' => $row['last_loop_finished_at'] ?? null,
|
||||
'stopped_at' => $row['stopped_at'] ?? null,
|
||||
'stale' => $age === null || $age > $threshold,
|
||||
'stale_after_seconds' => $threshold,
|
||||
];
|
||||
}
|
||||
|
||||
private function writeStatusLine(array $result): void
|
||||
{
|
||||
echo '[' . date('Y-m-d H:i:s') . '][CRON_WORKER] '
|
||||
. json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
. PHP_EOL;
|
||||
}
|
||||
|
||||
private function stringOption(array $options, string $key, string $env, string $default): string
|
||||
{
|
||||
$value = trim((string)($options[$key] ?? $this->env($env) ?? ''));
|
||||
return $value !== '' ? $value : $default;
|
||||
}
|
||||
|
||||
private function intOption(array $options, string $key, string $env, int $default, int $min, int $max): int
|
||||
{
|
||||
$value = (int)($options[$key] ?? $this->env($env) ?? $default);
|
||||
return max($min, min($max, $value));
|
||||
}
|
||||
|
||||
private function boolOption(string $env, bool $default): bool
|
||||
{
|
||||
$value = $this->env($env);
|
||||
if ($value === null || trim($value) === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private function commitSha(): string
|
||||
{
|
||||
foreach (['CRON_WORKER_COMMIT_SHA', 'API_COMMIT_SHA', 'RELEASE_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA'] as $key) {
|
||||
$value = trim((string)($this->env($key) ?? ''));
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function env(string $key): ?string
|
||||
{
|
||||
$value = getenv($key);
|
||||
if ($value !== false) {
|
||||
return (string)$value;
|
||||
}
|
||||
|
||||
return isset($_SERVER[$key]) ? (string)$_SERVER[$key] : null;
|
||||
}
|
||||
|
||||
private function nullableInt(?string $value): string
|
||||
{
|
||||
$value = trim((string)$value);
|
||||
if ($value === '' || filter_var($value, FILTER_VALIDATE_INT) === false) {
|
||||
return 'NULL';
|
||||
}
|
||||
|
||||
return (string)max(0, (int)$value);
|
||||
}
|
||||
|
||||
private function nullableSql(?string $value): string
|
||||
{
|
||||
$value = $value !== null ? trim($value) : '';
|
||||
return $value === '' ? 'NULL' : $this->sql($value);
|
||||
}
|
||||
|
||||
private function fetchAll(string $sql): array
|
||||
{
|
||||
$result = $this->query($sql);
|
||||
if ($result === false || $result === true) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $result->fetch_all(MYSQLI_ASSOC);
|
||||
}
|
||||
|
||||
private function query(string $sql): \mysqli_result|bool
|
||||
{
|
||||
global $db;
|
||||
return $db->query($sql);
|
||||
}
|
||||
|
||||
private function sql(string $value): string
|
||||
{
|
||||
global $db;
|
||||
return "'" . $db->escape_string($value) . "'";
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,6 @@ use RuntimeException;
|
||||
|
||||
class customer_order_product_policy
|
||||
{
|
||||
public const ONLY_TANKCLEANING_ATTRIBUTE = 'onlyTankCleaning';
|
||||
public const ONLY_TANKCLEANING_MESSAGE = 'Only tankcleaning customers can only have tankcleaning products in their orders.';
|
||||
|
||||
public static function assertOrderAllowsProduct(int $orderId, int $productId): void
|
||||
{
|
||||
$message = self::orderProductViolationMessage($orderId, $productId);
|
||||
@@ -19,80 +16,31 @@ class customer_order_product_policy
|
||||
|
||||
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
|
||||
{
|
||||
$context = self::loadOrderProductContext($orderId, $productId);
|
||||
if ($context === null) {
|
||||
$customerNumber = self::loadOrderCustomerNumber($orderId);
|
||||
if ($customerNumber === null) {
|
||||
return null;
|
||||
}
|
||||
if ((int)($context['product_id'] ?? 0) < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::onlyTankCleaningViolation((bool)((int)($context['has_only_tank_cleaning'] ?? 0)), $context)
|
||||
? self::ONLY_TANKCLEANING_MESSAGE
|
||||
: null;
|
||||
$violation = (new customer_rule_product_restriction_service())
|
||||
->violationForCustomerProduct($customerNumber, $productId);
|
||||
return $violation === null ? null : (string)$violation['message'];
|
||||
}
|
||||
|
||||
public static function onlyTankCleaningViolation(bool $customerHasOnlyTankCleaning, array $productRow): bool
|
||||
{
|
||||
return $customerHasOnlyTankCleaning && !self::isTankCleaningProductRow($productRow);
|
||||
}
|
||||
|
||||
public static function isTankCleaningProductRow(array $row): bool
|
||||
{
|
||||
return (int)($row['product_category'] ?? $row['category'] ?? 0) === 5
|
||||
|| self::rowMatchesProductTerms($row, ['tank cleaning', 'tankcleaning', 'tankrens']);
|
||||
}
|
||||
|
||||
private static function loadOrderProductContext(int $orderId, int $productId): ?array
|
||||
private static function loadOrderCustomerNumber(int $orderId): ?int
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($orderId < 1 || $productId < 1) {
|
||||
if ($orderId < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
o.id AS order_id,
|
||||
o.customer_id AS customer_number,
|
||||
p.id AS product_id,
|
||||
p.name AS product_name,
|
||||
p.category AS product_category,
|
||||
c.name AS category_name,
|
||||
MAX(CASE WHEN ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "' THEN 1 ELSE 0 END) AS has_only_tank_cleaning
|
||||
FROM orders o
|
||||
LEFT JOIN products p ON p.id = {$productId}
|
||||
LEFT JOIN categories c ON c.id = p.category
|
||||
LEFT JOIN users u ON u.customer_number = o.customer_id
|
||||
LEFT JOIN customer_attributes ca ON ca.user_id = u.id
|
||||
AND ca.attribute = '" . self::ONLY_TANKCLEANING_ATTRIBUTE . "'
|
||||
WHERE o.id = {$orderId}
|
||||
GROUP BY o.id, o.customer_id, p.id, p.name, p.category, c.name
|
||||
LIMIT 1
|
||||
";
|
||||
|
||||
$result = $db->query($sql);
|
||||
$result = $db->query("SELECT customer_id FROM orders WHERE id = {$orderId} LIMIT 1");
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
return is_array($row) ? $row : null;
|
||||
$customerNumber = (int)($row['customer_id'] ?? 0);
|
||||
return $customerNumber > 0 ? $customerNumber : null;
|
||||
}
|
||||
|
||||
private static function rowMatchesProductTerms(array $row, array $terms): bool
|
||||
{
|
||||
$haystack = strtolower(trim(
|
||||
(string)($row['product_name'] ?? $row['name'] ?? '') . ' ' .
|
||||
(string)($row['category_name'] ?? '')
|
||||
));
|
||||
|
||||
foreach ($terms as $term) {
|
||||
if ($term !== '' && str_contains($haystack, strtolower($term))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,13 @@
|
||||
namespace classes;
|
||||
|
||||
use objects\orders_o;
|
||||
use objects\products_o;
|
||||
use objects\users_o;
|
||||
|
||||
class customer_product_rule_service
|
||||
{
|
||||
public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer';
|
||||
|
||||
private const ADDON_CATEGORY_ID = 4;
|
||||
private const TANK_CLEANING_CATEGORY_ID = 5;
|
||||
private const SPOT_FREE_PRODUCT_IDS = [23, 24];
|
||||
|
||||
/**
|
||||
* @return array{rule:string,message:string}|null
|
||||
* @return array{rule:string,rules:list<string>,collections:list<int>,product_id:int,code:string,message:string}|null
|
||||
*/
|
||||
public function firstViolationForOrderItem(int $orderId, int $productId, ?int $relatedItemId): ?array
|
||||
{
|
||||
@@ -23,146 +17,17 @@ class customer_product_rule_service
|
||||
if (!$order->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$product = (new products_o())->getProductById($productId);
|
||||
if (!$product->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$customer = (new users_o())->getUserByCustomerNumber((int)$order->customer_id->value());
|
||||
if (!$customer->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$categoryId = (int)$product->category->value();
|
||||
$categoryName = $this->categoryName($categoryId);
|
||||
$searchableProduct = $this->searchableProductText($product, $categoryName);
|
||||
$isTankCleaningProduct = $this->isTankCleaningProduct($categoryId, $searchableProduct);
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictAdditionalServices')
|
||||
&& $this->isAdditionalServiceProduct($orderId, $relatedItemId, $categoryId, $searchableProduct)) {
|
||||
return $this->violation('restrictAdditionalServices');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictTankCleaning') && $isTankCleaningProduct) {
|
||||
return $this->violation('restrictTankCleaning');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('onlyTankCleaning') && !$isTankCleaningProduct) {
|
||||
return $this->violation('onlyTankCleaning');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictSpotFree')
|
||||
&& $this->isSpotFreeProduct((int)$product->id, $searchableProduct)) {
|
||||
return $this->violation('restrictSpotFree');
|
||||
}
|
||||
|
||||
if ($customer->doesUserHaveAttribute('restrictInteriorCleaning')
|
||||
&& $this->containsAny($searchableProduct, ['interior', 'indvendig'])) {
|
||||
return $this->violation('restrictInteriorCleaning');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{rule:string,message:string}
|
||||
*/
|
||||
private function violation(string $rule): array
|
||||
{
|
||||
return [
|
||||
'rule' => $rule,
|
||||
'message' => self::BLOCK_MESSAGE,
|
||||
];
|
||||
}
|
||||
|
||||
private function isAdditionalServiceProduct(int $orderId, ?int $relatedItemId, int $categoryId, string $searchableProduct): bool
|
||||
{
|
||||
if ($relatedItemId !== null && $relatedItemId > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($categoryId === self::ADDON_CATEGORY_ID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->containsAny($searchableProduct, ['add-on', 'add on', 'addon', 'tilvalg'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->countStandaloneOrderItems($orderId) > 0;
|
||||
}
|
||||
|
||||
private function isTankCleaningProduct(int $categoryId, string $searchableProduct): bool
|
||||
{
|
||||
if ($categoryId === self::TANK_CLEANING_CATEGORY_ID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->containsAny($searchableProduct, ['tank cleaning', 'tankcleaning', 'tankrens', 'tank rens']);
|
||||
}
|
||||
|
||||
private function isSpotFreeProduct(int $productId, string $searchableProduct): bool
|
||||
{
|
||||
if (in_array($productId, self::SPOT_FREE_PRODUCT_IDS, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->containsAny($searchableProduct, ['spot free', 'spotfree', 'skylning med ro']);
|
||||
}
|
||||
|
||||
private function searchableProductText(products_o $product, string $categoryName): string
|
||||
{
|
||||
return strtolower(trim((string)$product->name->value() . ' ' . $categoryName));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $terms
|
||||
*/
|
||||
private function containsAny(string $value, array $terms): bool
|
||||
{
|
||||
foreach ($terms as $term) {
|
||||
if ($term !== '' && str_contains($value, $term)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function categoryName(int $categoryId): string
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($categoryId <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$result = $db->query('SELECT name FROM categories WHERE id = ' . $categoryId . ' LIMIT 1');
|
||||
if (!$result || $result->num_rows === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
return strtolower((string)($row['name'] ?? ''));
|
||||
}
|
||||
|
||||
private function countStandaloneOrderItems(int $orderId): int
|
||||
{
|
||||
global $db;
|
||||
|
||||
$result = $db->query(
|
||||
'SELECT COUNT(*) AS item_count
|
||||
FROM order_items
|
||||
WHERE order_id = ' . $orderId . '
|
||||
AND deleted_at IS NULL
|
||||
AND (related_item_id IS NULL OR related_item_id = 0)'
|
||||
$violation = (new customer_rule_product_restriction_service())->violationForCustomerProduct(
|
||||
(int)$order->customer_id->value(),
|
||||
$productId
|
||||
);
|
||||
if (!$result) {
|
||||
return 0;
|
||||
if ($violation === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
return (int)($row['item_count'] ?? 0);
|
||||
// Keep the singular key during the API migration for existing invoice
|
||||
// and logging consumers while also returning every matching rule.
|
||||
return ['rule' => (string)$violation['rules'][0]] + $violation;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Additive schema and the one-time legacy-to-exact-product migration for
|
||||
* customer-rule product restrictions.
|
||||
*/
|
||||
class customer_rule_product_restriction_schema_bootstrap
|
||||
{
|
||||
public const LEGACY_SEED_KEY = 'legacy_exact_product_sets_v1';
|
||||
|
||||
private static bool $initialized = false;
|
||||
|
||||
/** @var array<string, string> */
|
||||
private const RULES = [
|
||||
'restrictAdditionalServices' => 'Additional services',
|
||||
'restrictTankCleaning' => 'Tank cleaning',
|
||||
'restrictSpotFree' => 'SpotFree',
|
||||
'restrictInteriorCleaning' => 'Interior cleaning',
|
||||
'onlyTankCleaning' => 'Non-tank products',
|
||||
];
|
||||
|
||||
public static function ensureSchema(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::createTables($db);
|
||||
self::deduplicateCustomerAttributes($db);
|
||||
self::seedLegacyProductSets($db);
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function createTables(object $db): void
|
||||
{
|
||||
$statements = [
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_restrictions (
|
||||
attribute VARCHAR(191) NOT NULL,
|
||||
version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (attribute)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_collections (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
attribute VARCHAR(191) NOT NULL,
|
||||
name VARCHAR(191) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_customer_rule_collection_name (attribute, name),
|
||||
KEY idx_customer_rule_collection_attribute_order (attribute, sort_order, id),
|
||||
CONSTRAINT fk_customer_rule_collection_attribute
|
||||
FOREIGN KEY (attribute) REFERENCES customer_rule_product_restrictions(attribute)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_collection_products (
|
||||
collection_id INT UNSIGNED NOT NULL,
|
||||
product_id INT UNSIGNED NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (collection_id, product_id),
|
||||
KEY idx_customer_rule_collection_product (product_id, collection_id),
|
||||
CONSTRAINT fk_customer_rule_collection_product_collection
|
||||
FOREIGN KEY (collection_id) REFERENCES customer_rule_product_collections(id)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_migrations (
|
||||
migration_key VARCHAR(191) NOT NULL,
|
||||
details_json LONGTEXT NULL,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (migration_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS customer_rule_product_audit_logs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
actor_user_id INT UNSIGNED NULL,
|
||||
attribute VARCHAR(191) NOT NULL,
|
||||
old_version INT UNSIGNED NOT NULL,
|
||||
new_version INT UNSIGNED NOT NULL,
|
||||
changes_json LONGTEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_customer_rule_product_audit_attribute (attribute, created_at),
|
||||
KEY idx_customer_rule_product_audit_actor (actor_user_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($statements as $statement) {
|
||||
if ($db->query($statement) === false) {
|
||||
throw new RuntimeException('Unable to initialize customer-rule product restriction schema');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function deduplicateCustomerAttributes(object $db): void
|
||||
{
|
||||
if (!self::tableExists($db, 'customer_attributes')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (self::indexExists($db, 'customer_attributes', 'uniq_customer_attributes_user_attribute')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($db->query(
|
||||
'DELETE duplicate_row FROM customer_attributes duplicate_row
|
||||
INNER JOIN customer_attributes keep_row
|
||||
ON keep_row.user_id = duplicate_row.user_id
|
||||
AND keep_row.attribute = duplicate_row.attribute
|
||||
AND keep_row.id < duplicate_row.id'
|
||||
) === false) {
|
||||
throw new RuntimeException('Unable to deduplicate customer attributes');
|
||||
}
|
||||
|
||||
if ($db->query(
|
||||
'ALTER TABLE customer_attributes
|
||||
ADD UNIQUE KEY uniq_customer_attributes_user_attribute (user_id, attribute)'
|
||||
) === false) {
|
||||
throw new RuntimeException('Unable to enforce unique customer attributes');
|
||||
}
|
||||
}
|
||||
|
||||
private static function seedLegacyProductSets(object $db): void
|
||||
{
|
||||
if (!self::tableExists($db, 'products') || !self::tableExists($db, 'categories')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$migrationKey = self::escape($db, self::LEGACY_SEED_KEY);
|
||||
$existing = $db->query(
|
||||
"SELECT migration_key FROM customer_rule_product_migrations WHERE migration_key = '{$migrationKey}' LIMIT 1"
|
||||
);
|
||||
if ($existing && (int)$existing->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($db->query('START TRANSACTION') === false) {
|
||||
throw new RuntimeException('Unable to start customer-rule product migration');
|
||||
}
|
||||
try {
|
||||
if ($db->query(
|
||||
"INSERT IGNORE INTO customer_rule_product_migrations (migration_key, details_json)
|
||||
VALUES ('{$migrationKey}', '{\"status\":\"in_progress\"}')"
|
||||
) === false) {
|
||||
throw new RuntimeException('Unable to claim customer-rule product migration');
|
||||
}
|
||||
if (self::affectedRows($db) === 0) {
|
||||
$db->query('ROLLBACK');
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (array_keys(self::RULES) as $attribute) {
|
||||
$safeAttribute = self::escape($db, $attribute);
|
||||
if ($db->query(
|
||||
"INSERT IGNORE INTO customer_rule_product_restrictions (attribute, version)
|
||||
VALUES ('{$safeAttribute}', 1)"
|
||||
) === false) {
|
||||
throw new RuntimeException("Unable to initialize restriction {$attribute}");
|
||||
}
|
||||
}
|
||||
|
||||
$counts = [];
|
||||
$seededProductIds = [];
|
||||
foreach (self::RULES as $attribute => $collectionName) {
|
||||
$safeAttribute = self::escape($db, $attribute);
|
||||
$safeName = self::escape($db, 'Legacy migration: ' . $collectionName);
|
||||
if ($db->query(
|
||||
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
|
||||
VALUES ('{$safeAttribute}', '{$safeName}', 0)"
|
||||
) === false) {
|
||||
throw new RuntimeException("Unable to create seed collection for {$attribute}");
|
||||
}
|
||||
$collectionId = (int)$db->insert_id();
|
||||
if ($collectionId < 1) {
|
||||
throw new RuntimeException("Unable to create seed collection for {$attribute}");
|
||||
}
|
||||
|
||||
$predicate = self::legacyPredicate($db, $attribute);
|
||||
$activePredicate = self::columnExists($db, 'products', 'deleted_at')
|
||||
? 'p.deleted_at IS NULL'
|
||||
: '1 = 1';
|
||||
$insert = $db->query(
|
||||
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
|
||||
SELECT {$collectionId}, p.id
|
||||
FROM products p
|
||||
LEFT JOIN categories c ON c.id = p.category
|
||||
WHERE ({$activePredicate}) AND ({$predicate})"
|
||||
);
|
||||
if ($insert === false) {
|
||||
throw new RuntimeException("Unable to seed products for {$attribute}");
|
||||
}
|
||||
$counts[$attribute] = self::affectedRows($db);
|
||||
$seeded = $db->query(
|
||||
"SELECT product_id FROM customer_rule_product_collection_products
|
||||
WHERE collection_id = {$collectionId} ORDER BY product_id"
|
||||
);
|
||||
$seededProductIds[$attribute] = [];
|
||||
if ($seeded) {
|
||||
while ($row = $seeded->fetch_assoc()) {
|
||||
$seededProductIds[$attribute][] = (int)$row['product_id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$details = self::escape($db, (string)json_encode([
|
||||
'counts' => $counts,
|
||||
'product_ids' => $seededProductIds,
|
||||
'seeded_at' => gmdate(DATE_ATOM),
|
||||
], JSON_UNESCAPED_SLASHES));
|
||||
if ($db->query(
|
||||
"UPDATE customer_rule_product_migrations
|
||||
SET details_json = '{$details}', applied_at = NOW()
|
||||
WHERE migration_key = '{$migrationKey}'"
|
||||
) === false) {
|
||||
throw new RuntimeException('Unable to record customer-rule product migration');
|
||||
}
|
||||
if ($db->query('COMMIT') === false) {
|
||||
throw new RuntimeException('Unable to commit customer-rule product migration');
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
$db->query('ROLLBACK');
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
private static function legacyPredicate(object $db, string $attribute): string
|
||||
{
|
||||
$text = "LOWER(CONCAT(COALESCE(p.name, ''), ' ', COALESCE(c.name, '')))";
|
||||
|
||||
return match ($attribute) {
|
||||
'restrictAdditionalServices' => "p.category = 8 OR LOWER(COALESCE(c.name, '')) IN ('tillægsydelser', 'tillaegsydelser')" .
|
||||
(self::tableExists($db, 'products_options') && self::columnExists($db, 'products_options', 'option_id')
|
||||
? ' OR EXISTS (SELECT 1 FROM products_options po WHERE po.option_id = p.id)'
|
||||
: ''),
|
||||
'restrictTankCleaning' => "p.category = 5 OR {$text} LIKE '%tank cleaning%' OR {$text} LIKE '%tankcleaning%' OR {$text} LIKE '%tankrens%' OR {$text} LIKE '%tank rens%'",
|
||||
'restrictSpotFree' => "p.id IN (23, 24) OR {$text} LIKE '%spot free%' OR {$text} LIKE '%spotfree%' OR {$text} LIKE '%skylning med ro%'",
|
||||
'restrictInteriorCleaning' => "{$text} LIKE '%interior%' OR {$text} LIKE '%indvendig%'",
|
||||
'onlyTankCleaning' => "NOT (p.category = 5 OR {$text} LIKE '%tank cleaning%' OR {$text} LIKE '%tankcleaning%' OR {$text} LIKE '%tankrens%' OR {$text} LIKE '%tank rens%')",
|
||||
default => '0 = 1',
|
||||
};
|
||||
}
|
||||
|
||||
private static function tableExists(object $db, string $table): bool
|
||||
{
|
||||
$safeTable = self::escape($db, $table);
|
||||
$result = $db->query("SHOW TABLES LIKE '{$safeTable}'");
|
||||
return $result && (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function indexExists(object $db, string $table, string $index): bool
|
||||
{
|
||||
$safeTable = str_replace('`', '', $table);
|
||||
$safeIndex = self::escape($db, $index);
|
||||
$result = $db->query("SHOW INDEX FROM `{$safeTable}` WHERE Key_name = '{$safeIndex}'");
|
||||
return $result && (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function columnExists(object $db, string $table, string $column): bool
|
||||
{
|
||||
$safeTable = str_replace('`', '', $table);
|
||||
$safeColumn = self::escape($db, $column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
|
||||
return $result && (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function escape(object $db, string $value): string
|
||||
{
|
||||
return method_exists($db, 'escape_string')
|
||||
? $db->escape_string($value)
|
||||
: addslashes($value);
|
||||
}
|
||||
|
||||
private static function affectedRows(object $db): int
|
||||
{
|
||||
if (method_exists($db, 'conn')) {
|
||||
$connection = $db->conn();
|
||||
return (int)($connection->affected_rows ?? 0);
|
||||
}
|
||||
return (int)($db->affected_rows ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class customer_rule_product_restriction_exception extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, private readonly int $httpStatus = 422, string $code = 'INVALID_CUSTOMER_RULE_CONFIGURATION')
|
||||
{
|
||||
parent::__construct($message);
|
||||
$this->restrictionCode = $code;
|
||||
}
|
||||
|
||||
private string $restrictionCode;
|
||||
|
||||
public function httpStatus(): int
|
||||
{
|
||||
return $this->httpStatus;
|
||||
}
|
||||
|
||||
public function restrictionCode(): string
|
||||
{
|
||||
return $this->restrictionCode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Source of truth for globally configured customer-rule product collections.
|
||||
*/
|
||||
class customer_rule_product_restriction_service
|
||||
{
|
||||
/** @var list<string> */
|
||||
public const PRODUCT_IMPACT_ATTRIBUTES = [
|
||||
'restrictAdditionalServices',
|
||||
'restrictTankCleaning',
|
||||
'restrictSpotFree',
|
||||
'restrictInteriorCleaning',
|
||||
'onlyTankCleaning',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
public const SUPPORTED_ATTRIBUTES = [
|
||||
'restrictAdditionalServices',
|
||||
'restrictTankCleaning',
|
||||
'restrictSpotFree',
|
||||
'restrictInteriorCleaning',
|
||||
'onlyTankCleaning',
|
||||
'requiresReferenceNumber',
|
||||
'requiresRegistrationNumbersInvoice',
|
||||
'invoiceAllOrdersIndividually',
|
||||
'invoiceWithStripe',
|
||||
'showPricesOnBookingPage',
|
||||
'usePONumbers',
|
||||
'exemptFromAdministrationFee',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
|
||||
}
|
||||
|
||||
/** @return array{rules:list<array<string,mixed>>,products:list<array<string,mixed>>} */
|
||||
public function listConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'rules' => array_map(fn(string $attribute): array => $this->ruleConfiguration($attribute), self::PRODUCT_IMPACT_ATTRIBUTES),
|
||||
'products' => $this->productCatalog(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function ruleConfiguration(string $attribute): array
|
||||
{
|
||||
$this->assertSupportedAttribute($attribute);
|
||||
global $db;
|
||||
|
||||
$safeAttribute = $this->escape($attribute);
|
||||
$versionResult = $db->query(
|
||||
"SELECT version FROM customer_rule_product_restrictions WHERE attribute = '{$safeAttribute}' LIMIT 1"
|
||||
);
|
||||
if (!$versionResult || $versionResult->num_rows < 1) {
|
||||
throw new RuntimeException("Unable to load customer-rule restriction version for {$attribute}");
|
||||
}
|
||||
$versionRow = $versionResult->fetch_assoc();
|
||||
|
||||
$result = $db->query(
|
||||
"SELECT c.id AS collection_id, c.name, c.sort_order, cp.product_id
|
||||
FROM customer_rule_product_collections c
|
||||
LEFT JOIN customer_rule_product_collection_products cp ON cp.collection_id = c.id
|
||||
WHERE c.attribute = '{$safeAttribute}'
|
||||
ORDER BY c.sort_order ASC, c.id ASC, cp.product_id ASC"
|
||||
);
|
||||
|
||||
if (!$result) {
|
||||
throw new RuntimeException("Unable to load customer-rule restriction collections for {$attribute}");
|
||||
}
|
||||
|
||||
$collections = [];
|
||||
$disabled = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$collectionId = (int)$row['collection_id'];
|
||||
if (!isset($collections[$collectionId])) {
|
||||
$collections[$collectionId] = [
|
||||
'id' => $collectionId,
|
||||
'name' => (string)$row['name'],
|
||||
'sort_order' => (int)$row['sort_order'],
|
||||
'product_ids' => [],
|
||||
];
|
||||
}
|
||||
if ($row['product_id'] !== null) {
|
||||
$productId = (int)$row['product_id'];
|
||||
$collections[$collectionId]['product_ids'][] = $productId;
|
||||
$disabled[$productId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'attribute' => $attribute,
|
||||
'version' => max(1, (int)($versionRow['version'] ?? 1)),
|
||||
'collections' => array_values($collections),
|
||||
'disabled_product_ids' => array_map('intval', array_keys($disabled)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function replaceRuleConfiguration(string $attribute, array $payload, int $actorUserId): array
|
||||
{
|
||||
$this->assertSupportedAttribute($attribute);
|
||||
$expectedVersion = $this->positiveInt($payload['version'] ?? null, 'version');
|
||||
$collections = $this->validateCollections($attribute, $payload['collections'] ?? null);
|
||||
|
||||
global $db;
|
||||
$safeAttribute = $this->escape($attribute);
|
||||
if ($db->query('START TRANSACTION') === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to start configuration transaction', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
try {
|
||||
$versionResult = $db->query(
|
||||
"SELECT version FROM customer_rule_product_restrictions
|
||||
WHERE attribute = '{$safeAttribute}' FOR UPDATE"
|
||||
);
|
||||
if (!$versionResult || $versionResult->num_rows < 1) {
|
||||
throw new customer_rule_product_restriction_exception('Customer rule configuration was not found', 404, 'CUSTOMER_RULE_CONFIGURATION_NOT_FOUND');
|
||||
}
|
||||
$versionRow = $versionResult->fetch_assoc();
|
||||
$currentVersion = (int)$versionRow['version'];
|
||||
if ($currentVersion !== $expectedVersion) {
|
||||
throw new customer_rule_product_restriction_exception(
|
||||
'Customer rule configuration has changed; reload before saving',
|
||||
409,
|
||||
'CUSTOMER_RULE_CONFIGURATION_CONFLICT'
|
||||
);
|
||||
}
|
||||
|
||||
$old = $this->ruleConfiguration($attribute);
|
||||
$existingIds = $this->existingCollectionIds($attribute);
|
||||
foreach ($collections as $collection) {
|
||||
if ($collection['id'] !== null && !isset($existingIds[$collection['id']])) {
|
||||
throw new customer_rule_product_restriction_exception('A collection does not belong to this customer rule');
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid temporary unique-name collisions while two collections swap names.
|
||||
foreach ($existingIds as $collectionId => $_) {
|
||||
$temporaryName = $this->escape('__pending_' . $collectionId . '_' . bin2hex(random_bytes(6)));
|
||||
if ($db->query(
|
||||
"UPDATE customer_rule_product_collections
|
||||
SET name = '{$temporaryName}'
|
||||
WHERE id = {$collectionId} AND attribute = '{$safeAttribute}'"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to prepare collection update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
$keptIds = [];
|
||||
foreach ($collections as $collection) {
|
||||
$name = $this->escape($collection['name']);
|
||||
$sortOrder = (int)$collection['sort_order'];
|
||||
$collectionId = $collection['id'];
|
||||
if ($collectionId === null) {
|
||||
if ($db->query(
|
||||
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
|
||||
VALUES ('{$safeAttribute}', '{$name}', {$sortOrder})"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to create collection');
|
||||
}
|
||||
$collectionId = (int)$db->insert_id();
|
||||
} else {
|
||||
if ($db->query(
|
||||
"UPDATE customer_rule_product_collections
|
||||
SET name = '{$name}', sort_order = {$sortOrder}
|
||||
WHERE id = {$collectionId} AND attribute = '{$safeAttribute}'"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to update collection');
|
||||
}
|
||||
}
|
||||
|
||||
$keptIds[$collectionId] = true;
|
||||
if ($db->query("DELETE FROM customer_rule_product_collection_products WHERE collection_id = {$collectionId}") === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to replace collection products', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
foreach ($collection['product_ids'] as $productId) {
|
||||
if ($db->query(
|
||||
"INSERT INTO customer_rule_product_collection_products (collection_id, product_id)
|
||||
VALUES ({$collectionId}, {$productId})"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to save collection products');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$removeIds = array_values(array_diff(array_keys($existingIds), array_keys($keptIds)));
|
||||
if ($removeIds !== []) {
|
||||
if ($db->query(
|
||||
'DELETE FROM customer_rule_product_collections WHERE attribute = \'' . $safeAttribute . '\' AND id IN (' .
|
||||
implode(',', array_map('intval', $removeIds)) . ')'
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to remove collections', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
}
|
||||
|
||||
$newVersion = $currentVersion + 1;
|
||||
if ($db->query(
|
||||
"UPDATE customer_rule_product_restrictions
|
||||
SET version = {$newVersion}, updated_at = NOW()
|
||||
WHERE attribute = '{$safeAttribute}'"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to update configuration version', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
$new = $this->ruleConfiguration($attribute);
|
||||
$changes = $this->escape((string)json_encode([
|
||||
'before' => $old,
|
||||
'after' => $new,
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
if ($db->query(
|
||||
"INSERT INTO customer_rule_product_audit_logs
|
||||
(actor_user_id, attribute, old_version, new_version, changes_json)
|
||||
VALUES ({$actorUserId}, '{$safeAttribute}', {$currentVersion}, {$newVersion}, '{$changes}')"
|
||||
) === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to audit configuration update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
if ($db->query('COMMIT') === false) {
|
||||
throw new customer_rule_product_restriction_exception('Unable to commit configuration update', 500, 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED');
|
||||
}
|
||||
return $new;
|
||||
} catch (Throwable $throwable) {
|
||||
$db->query('ROLLBACK');
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return configured product restrictions for all active product-impact
|
||||
* attributes belonging to any account with the customer number.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function restrictionsForCustomerNumber(int $customerNumber): array
|
||||
{
|
||||
if ($customerNumber < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
"SELECT DISTINCT ca.attribute
|
||||
FROM users u
|
||||
INNER JOIN customer_attributes ca ON ca.user_id = u.id
|
||||
WHERE u.customer_number = {$customerNumber}"
|
||||
);
|
||||
if (!$result) {
|
||||
throw new RuntimeException('Unable to load active customer-rule product restrictions');
|
||||
}
|
||||
$activeAttributes = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$attribute = (string)$row['attribute'];
|
||||
if (in_array($attribute, self::PRODUCT_IMPACT_ATTRIBUTES, true)) {
|
||||
$activeAttributes[$attribute] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$active = [];
|
||||
foreach (self::PRODUCT_IMPACT_ATTRIBUTES as $attribute) {
|
||||
if (isset($activeAttributes[$attribute])) {
|
||||
$active[] = $this->ruleConfiguration($attribute);
|
||||
}
|
||||
}
|
||||
return $active;
|
||||
}
|
||||
|
||||
/** @return array{rules:list<string>,collections:list<int>,message:string,code:string,product_id:int}|null */
|
||||
public function violationForCustomerProduct(int $customerNumber, int $productId): ?array
|
||||
{
|
||||
if ($productId < 1) {
|
||||
return null;
|
||||
}
|
||||
$rules = [];
|
||||
$collections = [];
|
||||
foreach ($this->restrictionsForCustomerNumber($customerNumber) as $restriction) {
|
||||
if (!in_array($productId, $restriction['disabled_product_ids'], true)) {
|
||||
continue;
|
||||
}
|
||||
$rules[] = (string)$restriction['attribute'];
|
||||
foreach ($restriction['collections'] as $collection) {
|
||||
if (in_array($productId, $collection['product_ids'], true)) {
|
||||
$collections[] = (int)$collection['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($rules === []) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'code' => 'CUSTOMER_RULE_PRODUCT_RESTRICTED',
|
||||
'message' => customer_product_rule_service::BLOCK_MESSAGE,
|
||||
'product_id' => $productId,
|
||||
'rules' => array_values(array_unique($rules)),
|
||||
'collections' => array_values(array_unique($collections)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $attributes
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function enrichAttributes(int $customerNumber, array $attributes): array
|
||||
{
|
||||
$restrictions = [];
|
||||
foreach ($this->restrictionsForCustomerNumber($customerNumber) as $restriction) {
|
||||
$restrictions[(string)$restriction['attribute']] = [
|
||||
'attribute' => (string)$restriction['attribute'],
|
||||
'version' => (int)$restriction['version'],
|
||||
'collections' => $restriction['collections'],
|
||||
'disabled_product_ids' => $restriction['disabled_product_ids'],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($attributes as &$attribute) {
|
||||
$key = (string)($attribute['attribute'] ?? '');
|
||||
$attribute['product_restriction'] = $restrictions[$key] ?? null;
|
||||
}
|
||||
unset($attribute);
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function productCatalog(): array
|
||||
{
|
||||
global $db;
|
||||
$activeExpression = $this->columnExists('products', 'deleted_at')
|
||||
? 'CASE WHEN p.deleted_at IS NULL THEN 1 ELSE 0 END'
|
||||
: '1';
|
||||
$result = $db->query(
|
||||
"SELECT p.id, p.name, p.category AS category_id, c.name AS category_name,
|
||||
{$activeExpression} AS active
|
||||
FROM products p
|
||||
LEFT JOIN categories c ON c.id = p.category
|
||||
ORDER BY c.name ASC, p.name ASC, p.id ASC"
|
||||
);
|
||||
if (!$result) {
|
||||
throw new RuntimeException('Unable to load the customer-rule product catalog');
|
||||
}
|
||||
$products = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$products[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'name' => (string)$row['name'],
|
||||
'category_id' => (int)$row['category_id'],
|
||||
'category_name' => (string)($row['category_name'] ?? ''),
|
||||
'active' => (bool)$row['active'],
|
||||
];
|
||||
}
|
||||
return $products;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,true>
|
||||
*/
|
||||
private function existingCollectionIds(string $attribute): array
|
||||
{
|
||||
global $db;
|
||||
$safeAttribute = $this->escape($attribute);
|
||||
$result = $db->query("SELECT id FROM customer_rule_product_collections WHERE attribute = '{$safeAttribute}'");
|
||||
if (!$result) {
|
||||
throw new RuntimeException("Unable to load existing collections for {$attribute}");
|
||||
}
|
||||
$ids = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$ids[(int)$row['id']] = true;
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/** @return list<array{id:?int,name:string,sort_order:int,product_ids:list<int>}> */
|
||||
private function validateCollections(string $attribute, mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
throw new customer_rule_product_restriction_exception('collections must be an array');
|
||||
}
|
||||
$normalized = [];
|
||||
$names = [];
|
||||
$collectionIds = [];
|
||||
$allProductIds = [];
|
||||
foreach (array_values($value) as $index => $collection) {
|
||||
if (!is_array($collection)) {
|
||||
throw new customer_rule_product_restriction_exception("Collection {$index} must be an object");
|
||||
}
|
||||
$name = trim((string)($collection['name'] ?? ''));
|
||||
if ($name === '' || mb_strlen($name) > 191) {
|
||||
throw new customer_rule_product_restriction_exception('Collection names must be between 1 and 191 characters');
|
||||
}
|
||||
$nameKey = mb_strtolower($name);
|
||||
if (isset($names[$nameKey])) {
|
||||
throw new customer_rule_product_restriction_exception('Collection names must be unique within a rule');
|
||||
}
|
||||
$names[$nameKey] = true;
|
||||
if (!isset($collection['product_ids']) || !is_array($collection['product_ids'])) {
|
||||
throw new customer_rule_product_restriction_exception('product_ids must be an array');
|
||||
}
|
||||
$productIds = [];
|
||||
foreach ($collection['product_ids'] as $productId) {
|
||||
$id = $this->positiveInt($productId, 'product_id');
|
||||
$productIds[$id] = true;
|
||||
$allProductIds[$id] = true;
|
||||
}
|
||||
$id = isset($collection['id']) && $collection['id'] !== null
|
||||
? $this->positiveInt($collection['id'], 'collection id')
|
||||
: null;
|
||||
if ($id !== null && isset($collectionIds[$id])) {
|
||||
throw new customer_rule_product_restriction_exception('Collection IDs must be unique within a rule');
|
||||
}
|
||||
if ($id !== null) {
|
||||
$collectionIds[$id] = true;
|
||||
}
|
||||
$normalized[] = [
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'sort_order' => isset($collection['sort_order']) && is_numeric($collection['sort_order'])
|
||||
? (int)$collection['sort_order']
|
||||
: $index,
|
||||
'product_ids' => array_map('intval', array_keys($productIds)),
|
||||
];
|
||||
}
|
||||
|
||||
$this->assertProductsExist(array_map('intval', array_keys($allProductIds)));
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/** @param list<int> $productIds */
|
||||
private function assertProductsExist(array $productIds): void
|
||||
{
|
||||
if ($productIds === []) {
|
||||
return;
|
||||
}
|
||||
global $db;
|
||||
$result = $db->query('SELECT id FROM products WHERE id IN (' . implode(',', $productIds) . ')');
|
||||
if (!$result) {
|
||||
throw new customer_rule_product_restriction_exception(
|
||||
'Unable to validate collection products',
|
||||
500,
|
||||
'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED'
|
||||
);
|
||||
}
|
||||
$found = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$found[(int)$row['id']] = true;
|
||||
}
|
||||
$missing = array_values(array_diff($productIds, array_keys($found)));
|
||||
if ($missing !== []) {
|
||||
throw new customer_rule_product_restriction_exception('Unknown product IDs: ' . implode(', ', $missing));
|
||||
}
|
||||
}
|
||||
|
||||
private function assertSupportedAttribute(string $attribute): void
|
||||
{
|
||||
if (!in_array($attribute, self::PRODUCT_IMPACT_ATTRIBUTES, true)) {
|
||||
throw new customer_rule_product_restriction_exception('Unsupported product-impact customer rule');
|
||||
}
|
||||
}
|
||||
|
||||
private function positiveInt(mixed $value, string $field): int
|
||||
{
|
||||
if (!is_numeric($value) || (int)$value < 1 || (string)(int)$value !== trim((string)$value)) {
|
||||
throw new customer_rule_product_restriction_exception("{$field} must be a positive integer");
|
||||
}
|
||||
return (int)$value;
|
||||
}
|
||||
|
||||
private function escape(string $value): string
|
||||
{
|
||||
global $db;
|
||||
return method_exists($db, 'escape_string') ? $db->escape_string($value) : addslashes($value);
|
||||
}
|
||||
|
||||
private function columnExists(string $table, string $column): bool
|
||||
{
|
||||
global $db;
|
||||
$safeTable = str_replace('`', '', $table);
|
||||
$safeColumn = $this->escape($column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$safeTable}` LIKE '{$safeColumn}'");
|
||||
return $result && (int)$result->num_rows > 0;
|
||||
}
|
||||
}
|
||||
@@ -177,15 +177,19 @@ class db
|
||||
return $this->database;
|
||||
}
|
||||
|
||||
public function getPort(): int
|
||||
{
|
||||
return $this->port;
|
||||
}
|
||||
|
||||
public function getSslMode(): string
|
||||
{
|
||||
return $this->ssl_mode;
|
||||
}
|
||||
|
||||
public function backupDatabase(string $path): bool
|
||||
{
|
||||
// Save the database to the path
|
||||
// Build a safe mysqldump command with configurable SSL (MariaDB-compatible flags)
|
||||
$mode = strtoupper(trim($this->ssl_mode));
|
||||
// Map ssl_mode to MariaDB client flags
|
||||
// DISABLED => --skip-ssl (no TLS)
|
||||
// PREFERRED => (no flag; client decides)
|
||||
// REQUIRED/VERIFY_* => --ssl (enable TLS without strict verification unless CA materials provided)
|
||||
$sslFlag = '';
|
||||
switch ($mode) {
|
||||
case 'DISABLED':
|
||||
@@ -201,17 +205,42 @@ class db
|
||||
$sslFlag = '--ssl';
|
||||
break;
|
||||
}
|
||||
|
||||
$host = escapeshellarg($this->host);
|
||||
$user = escapeshellarg($this->user);
|
||||
$pass = escapeshellarg($this->password);
|
||||
$db = escapeshellarg($this->database);
|
||||
$port = (int)$this->port;
|
||||
$outfile = escapeshellarg($path);
|
||||
$sslPart = $sslFlag !== '' ? ($sslFlag . ' ') : '';
|
||||
$command = "mysqldump {$sslPart}-h $host -P $port -u $user --password=$pass $db > $outfile 2>&1";
|
||||
exec($command, $output, $return);
|
||||
// Check if the command was successful
|
||||
return $return === 0;
|
||||
$command = "mysqldump {$sslPart}--single-transaction --quick --routines --triggers --events --hex-blob -h $host -P $port -u $user $db";
|
||||
|
||||
$directory = dirname($path);
|
||||
if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$environment = array_merge(getenv() ?: [], $_ENV);
|
||||
$environment['MYSQL_PWD'] = $this->password;
|
||||
$descriptors = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['file', $path, 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
|
||||
$process = proc_open($command, $descriptors, $pipes, null, $environment);
|
||||
if (!is_resource($process)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
fclose($pipes[0]);
|
||||
$stderr = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[2]);
|
||||
$return = proc_close($process);
|
||||
|
||||
if ($return !== 0 && is_string($stderr) && $stderr !== '') {
|
||||
@file_put_contents($path . '.error.log', $stderr);
|
||||
}
|
||||
|
||||
return $return === 0 && is_file($path) && filesize($path) !== false;
|
||||
}
|
||||
|
||||
public function getView(string $view): array
|
||||
@@ -238,4 +267,4 @@ class db
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use fxratesapi\actions\convert_rate_a;
|
||||
use fxratesapi\fxratesapi_c;
|
||||
use interfaces\fxratesapi_i;
|
||||
use objects\fxratesapi_conversion_rates_o;
|
||||
use Throwable;
|
||||
|
||||
class fxratesapi implements fxratesapi_i
|
||||
{
|
||||
@@ -117,10 +118,10 @@ class fxratesapi implements fxratesapi_i
|
||||
// Validate the base and target currencies
|
||||
self::requireValidCurrency($base);
|
||||
self::requireValidCurrency($target);
|
||||
// Validate the daily limit
|
||||
self::requireDailyLimitNotExceeded();
|
||||
// Validate the secret key
|
||||
self::requireValidSecretKey();
|
||||
// Reserve quota for the outbound provider call. Cached conversion reads return before this point.
|
||||
$this->reserveRateFetchQuota($base, $target, $endpoint, $method);
|
||||
// Send the request
|
||||
$response = match ($method) {
|
||||
'GET' => self::sendGetRequest($base, $target, $endpoint, $data),
|
||||
@@ -167,7 +168,7 @@ class fxratesapi implements fxratesapi_i
|
||||
function requireDailyLimitNotExceeded(): void
|
||||
{
|
||||
// Check if the daily limit is exceeded
|
||||
if ($this->getDailyRequestCounter() >= $this->config->daily_limit->getVariableValue()) {
|
||||
if ($this->getDailyRequestCounter() >= (int)$this->config->daily_limit->getVariableValue()) {
|
||||
throw new Exception('Daily limit exceeded');
|
||||
}
|
||||
}
|
||||
@@ -177,12 +178,30 @@ class fxratesapi implements fxratesapi_i
|
||||
*/
|
||||
function getDailyRequestCounter(): int
|
||||
{
|
||||
try {
|
||||
return (new module_usage_service())->currentUsedQuantity('fxratesapi', 'rate_fetch_calls');
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
// Count the rows from the fxratesapi request log that was made today
|
||||
$fxratesapi_lookups = new fxratesapi_conversion_rates_o();
|
||||
$fxratesapi_lookups->getTodayCount();
|
||||
return $fxratesapi_lookups->getTodayCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function reserveRateFetchQuota(string $base, string $target, string $endpoint, string $method): void
|
||||
{
|
||||
(new module_usage_service())->reserveOrFail('fxratesapi', 'rate_fetch_calls', 1, [
|
||||
'base' => $base,
|
||||
'target' => $target,
|
||||
'endpoint' => $endpoint,
|
||||
'method' => strtoupper($method),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
@@ -515,4 +534,4 @@ class fxratesapi implements fxratesapi_i
|
||||
throw new Exception('Failed to add request to log');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ class invoice_period_flag_service
|
||||
private const ORDER_FIELDS = ['customer', 'reference', 'po', 'notes'];
|
||||
private const ORDER_ITEM_FIELDS = ['notes', 'quantity', 'reference', 'price'];
|
||||
private const WASH_CERTIFICATE_PRODUCT_ID = 41;
|
||||
private const SPOT_FREE_PRODUCT_IDS = [23, 24];
|
||||
private array $economicCustomerDiscountCache = [];
|
||||
private array $userDisplayNameCache = [];
|
||||
private array $orderItemsPreviewCache = [];
|
||||
@@ -848,11 +847,16 @@ class invoice_period_flag_service
|
||||
{
|
||||
global $db;
|
||||
|
||||
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
|
||||
|
||||
$customerFilter = $this->customerFilterSql('u.customer_number', $onlyCustomerNumbers);
|
||||
$result = $db->query(
|
||||
"SELECT u.customer_number, ca.attribute
|
||||
"SELECT u.customer_number, ca.attribute, cp.product_id
|
||||
FROM customer_attributes ca
|
||||
JOIN users u ON u.id = ca.user_id
|
||||
LEFT JOIN customer_rule_product_restrictions r ON r.attribute = ca.attribute
|
||||
LEFT JOIN customer_rule_product_collections c ON c.attribute = r.attribute
|
||||
LEFT JOIN customer_rule_product_collection_products cp ON cp.collection_id = c.id
|
||||
WHERE 1=1 {$customerFilter}"
|
||||
);
|
||||
|
||||
@@ -863,7 +867,11 @@ class invoice_period_flag_service
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$customerNumber = (int)$row['customer_number'];
|
||||
$attributes[$customerNumber][(string)$row['attribute']] = true;
|
||||
$attribute = (string)$row['attribute'];
|
||||
$attributes[$customerNumber][$attribute] = true;
|
||||
if ($row['product_id'] !== null && in_array($attribute, customer_rule_product_restriction_service::PRODUCT_IMPACT_ATTRIBUTES, true)) {
|
||||
$attributes[$customerNumber]['__disabled_products'][(int)$row['product_id']][$attribute] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
@@ -874,6 +882,8 @@ class invoice_period_flag_service
|
||||
$flags = [];
|
||||
$orders = [];
|
||||
$collectionOrders = [];
|
||||
$matchingRules = static fn(int $customerNumber, int $productId): array =>
|
||||
array_keys($attributes[$customerNumber]['__disabled_products'][$productId] ?? []);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$customerNumber = (int)$row['customer_number'];
|
||||
@@ -899,57 +909,17 @@ class invoice_period_flag_service
|
||||
continue;
|
||||
}
|
||||
|
||||
$isTankCleaningProduct = $this->rowIsTankCleaningProduct($row);
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'restrictAdditionalServices')
|
||||
&& (int)($row['related_item_id'] ?? 0) > 0
|
||||
&& (int)($row['item_price'] ?? 0) > 0) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'customer_rule_restrict_addon_services',
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
$row,
|
||||
['product' => $this->productLabel($row)],
|
||||
$this->orderItemContext($row)
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'restrictTankCleaning') && $isTankCleaningProduct) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'customer_rule_restrict_tank_cleaning',
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
$row,
|
||||
['product' => $this->productLabel($row)],
|
||||
$this->orderItemContext($row)
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'onlyTankCleaning') && !$isTankCleaningProduct) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'customer_rule_only_tank_cleaning',
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
$row,
|
||||
['product' => $this->productLabel($row)],
|
||||
$this->orderItemContext($row)
|
||||
);
|
||||
}
|
||||
|
||||
$restrictedProducts = [
|
||||
'restrictSpotFree' => ['customer_rule_restrict_spot_free', ['spot free', 'spotfree', 'skylning med ro']],
|
||||
'restrictInteriorCleaning' => ['customer_rule_restrict_interior_cleaning', ['interior', 'indvendig']],
|
||||
'exemptFromAdministrationFee' => ['customer_rule_exempt_from_administration_fees', ['administration fee', 'administrationsgebyr', 'administration']],
|
||||
$productRuleDefinitions = [
|
||||
'restrictAdditionalServices' => 'customer_rule_restrict_addon_services',
|
||||
'restrictTankCleaning' => 'customer_rule_restrict_tank_cleaning',
|
||||
'onlyTankCleaning' => 'customer_rule_only_tank_cleaning',
|
||||
'restrictSpotFree' => 'customer_rule_restrict_spot_free',
|
||||
'restrictInteriorCleaning' => 'customer_rule_restrict_interior_cleaning',
|
||||
];
|
||||
|
||||
foreach ($restrictedProducts as $attribute => [$definitionKey, $terms]) {
|
||||
if ($this->hasAttribute($attributes, $customerNumber, $attribute)
|
||||
&& $this->rowMatchesProductTerms($row, $terms)) {
|
||||
foreach ($matchingRules($customerNumber, (int)($row['product_id'] ?? 0)) as $attribute) {
|
||||
if (isset($productRuleDefinitions[$attribute])) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
$definitionKey,
|
||||
$productRuleDefinitions[$attribute],
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
@@ -959,6 +929,19 @@ class invoice_period_flag_service
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->hasAttribute($attributes, $customerNumber, 'exemptFromAdministrationFee')
|
||||
&& $this->rowMatchesProductTerms($row, ['administration fee', 'administrationsgebyr', 'administration'])) {
|
||||
$flags[] = $this->automaticFlag(
|
||||
'customer_rule_exempt_from_administration_fees',
|
||||
'order_item',
|
||||
(int)$row['order_item_id'],
|
||||
null,
|
||||
$row,
|
||||
['product' => $this->productLabel($row)],
|
||||
$this->orderItemContext($row)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($orders as $orderId => $row) {
|
||||
@@ -2098,11 +2081,6 @@ class invoice_period_flag_service
|
||||
|
||||
private function rowMatchesProductTerms(array $row, array $terms): bool
|
||||
{
|
||||
if (in_array((int)($row['product_id'] ?? 0), self::SPOT_FREE_PRODUCT_IDS, true)
|
||||
&& in_array('spotfree', array_map('strtolower', $terms), true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$haystack = strtolower(trim(
|
||||
(string)($row['product_name'] ?? '') . ' ' .
|
||||
(string)($row['category_name'] ?? '')
|
||||
@@ -2115,11 +2093,6 @@ class invoice_period_flag_service
|
||||
return false;
|
||||
}
|
||||
|
||||
private function rowIsTankCleaningProduct(array $row): bool
|
||||
{
|
||||
return customer_order_product_policy::isTankCleaningProductRow($row);
|
||||
}
|
||||
|
||||
private function isIncludedOrderItem(array $row): bool
|
||||
{
|
||||
$value = $row['item_include_in_invoice'] ?? 1;
|
||||
|
||||
@@ -27,7 +27,7 @@ class invoice_store implements minio_invoices_i
|
||||
return count($objects['Contents'] ?? []) > 0;
|
||||
}
|
||||
|
||||
public function getInvoiceDownloadUrl(int $id): string
|
||||
public function getInvoiceDownloadUrl(int|string $id): string
|
||||
{
|
||||
return self::getPresignedUrl('invoice_' . $id . '.pdf');
|
||||
}
|
||||
@@ -51,4 +51,4 @@ class invoice_store implements minio_invoices_i
|
||||
{
|
||||
return self::getS3Client()->doesObjectExist(self::getBucket(), $file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +244,11 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
return (string)$configured;
|
||||
}
|
||||
|
||||
public static function configuredApiBaseUrl(): string
|
||||
{
|
||||
return self::normalizeApiUrl(self::configuredApiUrl());
|
||||
}
|
||||
|
||||
private static function normalizeApiUrl(string $api_url): string
|
||||
{
|
||||
$api_url = trim($api_url);
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class module_usage_registry
|
||||
{
|
||||
public const DEFAULT_SOFT_LIMIT_PERCENT = 90.0;
|
||||
|
||||
public function all(): array
|
||||
{
|
||||
return array_map(
|
||||
fn(array $descriptor): array => $this->withDefaults($descriptor),
|
||||
[
|
||||
[
|
||||
'module_key' => 'motorapi',
|
||||
'module_label' => 'MotorAPI',
|
||||
'metric_key' => 'lookup_calls',
|
||||
'metric_label' => 'License plate lookups',
|
||||
'unit' => 'calls',
|
||||
'period' => 'day',
|
||||
'source' => 'internal_counter',
|
||||
'default_enforce_mode' => 'block',
|
||||
'config_module' => 'motorapi',
|
||||
'config_variable' => 'daily_limit',
|
||||
'config_type' => 'int',
|
||||
'legacy_count_table' => 'motorapi_lookups',
|
||||
'primary' => true,
|
||||
'writable_limit' => true,
|
||||
],
|
||||
[
|
||||
'module_key' => 'motorapi',
|
||||
'module_label' => 'MotorAPI',
|
||||
'metric_key' => 'provider_usage',
|
||||
'metric_label' => 'Provider usage',
|
||||
'unit' => 'calls',
|
||||
'period' => 'provider',
|
||||
'source' => 'provider_snapshot',
|
||||
],
|
||||
[
|
||||
'module_key' => 'fxratesapi',
|
||||
'module_label' => 'FXRatesAPI',
|
||||
'metric_key' => 'rate_fetch_calls',
|
||||
'metric_label' => 'Currency rate fetches',
|
||||
'unit' => 'calls',
|
||||
'period' => 'day',
|
||||
'source' => 'internal_counter',
|
||||
'default_enforce_mode' => 'block',
|
||||
'config_module' => 'fxratesapi',
|
||||
'config_variable' => 'daily_limit',
|
||||
'config_type' => 'int',
|
||||
'legacy_count_table' => 'fxratesapi_conversion_rates',
|
||||
'primary' => true,
|
||||
'writable_limit' => true,
|
||||
],
|
||||
[
|
||||
'module_key' => 'virkdata',
|
||||
'module_label' => 'VirkData',
|
||||
'metric_key' => 'company_search_calls',
|
||||
'metric_label' => 'Company searches',
|
||||
'unit' => 'calls',
|
||||
'period' => 'month',
|
||||
'source' => 'internal_counter',
|
||||
'default_enforce_mode' => 'observe',
|
||||
'config_module' => 'virkdata',
|
||||
'config_variable' => 'monthly_limit',
|
||||
'config_type' => 'int',
|
||||
'legacy_log_module' => 'VIRKDATA',
|
||||
'legacy_log_action' => 'VIRKDATA_SEARCH',
|
||||
'primary' => true,
|
||||
'writable_limit' => true,
|
||||
],
|
||||
[
|
||||
'module_key' => 'licenseplaterecognizer',
|
||||
'module_label' => 'License Plate Recognizer',
|
||||
'metric_key' => 'plate_recognition_calls',
|
||||
'metric_label' => 'Plate recognition calls',
|
||||
'unit' => 'calls',
|
||||
'period' => 'provider',
|
||||
'source' => 'provider_snapshot',
|
||||
'default_enforce_mode' => 'observe',
|
||||
'primary' => true,
|
||||
],
|
||||
[
|
||||
'module_key' => 'email',
|
||||
'module_label' => 'Email',
|
||||
'metric_key' => 'mailersend_messages',
|
||||
'metric_label' => 'MailerSend messages',
|
||||
'unit' => 'messages',
|
||||
'period' => 'provider',
|
||||
'source' => 'provider_snapshot',
|
||||
],
|
||||
['module_key' => 'openai', 'module_label' => 'OpenAI', 'metric_key' => 'api_calls', 'metric_label' => 'API calls', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'openai', 'module_label' => 'OpenAI', 'metric_key' => 'total_tokens', 'metric_label' => 'Total tokens', 'unit' => 'tokens', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'weatherapi', 'module_label' => 'WeatherAPI', 'metric_key' => 'requests', 'metric_label' => 'Weather requests', 'unit' => 'calls', 'period' => 'day', 'source' => 'internal_counter'],
|
||||
['module_key' => 'gatewayapi', 'module_label' => 'GatewayAPI', 'metric_key' => 'sms_messages', 'metric_label' => 'SMS messages', 'unit' => 'messages', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'bird', 'module_label' => 'Bird', 'metric_key' => 'messages_and_calls', 'metric_label' => 'Messages and calls', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'ocrspace', 'module_label' => 'OCRSpace', 'metric_key' => 'ocr_requests', 'metric_label' => 'OCR requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'limble', 'module_label' => 'Limble', 'metric_key' => 'api_requests', 'metric_label' => 'API requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'workfeed', 'module_label' => 'Workfeed', 'metric_key' => 'api_requests', 'metric_label' => 'API requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'stripe', 'module_label' => 'Stripe', 'metric_key' => 'payment_events', 'metric_label' => 'Payment events', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'coolify', 'module_label' => 'Coolify', 'metric_key' => 'operations', 'metric_label' => 'Operations', 'unit' => 'events', 'period' => 'month', 'source' => 'derived'],
|
||||
['module_key' => 'backups', 'module_label' => 'Backups', 'metric_key' => 'backup_jobs', 'metric_label' => 'Backup jobs', 'unit' => 'jobs', 'period' => 'month', 'source' => 'derived'],
|
||||
['module_key' => 'backups', 'module_label' => 'Backups', 'metric_key' => 'stored_bytes', 'metric_label' => 'Stored backup data', 'unit' => 'bytes', 'period' => 'all_time', 'source' => 'derived'],
|
||||
['module_key' => 'selfserve', 'module_label' => 'Self Serve', 'metric_key' => 'wash_sessions', 'metric_label' => 'Wash sessions', 'unit' => 'sessions', 'period' => 'month', 'source' => 'derived'],
|
||||
['module_key' => 'selfserve', 'module_label' => 'Self Serve', 'metric_key' => 'lane_commands', 'metric_label' => 'Lane commands', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter', 'legacy_log_module' => 'SELFSERVE'],
|
||||
['module_key' => 'xlvask', 'module_label' => 'XLVask', 'metric_key' => 'usage_rows', 'metric_label' => 'Usage rows', 'unit' => 'rows', 'period' => 'month', 'source' => 'derived'],
|
||||
['module_key' => 'attachments', 'module_label' => 'Attachments', 'metric_key' => 'stored_files', 'metric_label' => 'Stored files', 'unit' => 'files', 'period' => 'all_time', 'source' => 'derived'],
|
||||
['module_key' => 'dynamicimages', 'module_label' => 'Dynamic Images', 'metric_key' => 'renders', 'metric_label' => 'Image renders', 'unit' => 'renders', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'html2pdf', 'module_label' => 'HTML2PDF', 'metric_key' => 'pdf_jobs', 'metric_label' => 'PDF jobs', 'unit' => 'jobs', 'period' => 'month', 'source' => 'internal_counter'],
|
||||
['module_key' => 'forms', 'module_label' => 'Forms', 'metric_key' => 'submissions', 'metric_label' => 'Submissions', 'unit' => 'submissions', 'period' => 'month', 'source' => 'derived'],
|
||||
['module_key' => 'notifications', 'module_label' => 'Notifications', 'metric_key' => 'notification_sends', 'metric_label' => 'Notification sends', 'unit' => 'notifications', 'period' => 'month', 'source' => 'derived'],
|
||||
['module_key' => 'shelly', 'module_label' => 'Shelly', 'metric_key' => 'relay_commands', 'metric_label' => 'Relay commands', 'unit' => 'commands', 'period' => 'month', 'source' => 'internal_counter', 'legacy_log_module' => 'SHELLY'],
|
||||
['module_key' => 'edgegateway', 'module_label' => 'Edge Gateway', 'metric_key' => 'relay_commands', 'metric_label' => 'Relay commands', 'unit' => 'commands', 'period' => 'month', 'source' => 'derived'],
|
||||
['module_key' => 'system', 'module_label' => 'System', 'metric_key' => 'cron_runs', 'metric_label' => 'Cron runs', 'unit' => 'runs', 'period' => 'day', 'source' => 'derived'],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function find(string $moduleKey, string $metricKey): ?array
|
||||
{
|
||||
$moduleKey = $this->normalizeModuleKey($moduleKey);
|
||||
$metricKey = $this->normalizeMetricKey($metricKey);
|
||||
|
||||
foreach ($this->all() as $descriptor) {
|
||||
if ($descriptor['module_key'] === $moduleKey && $descriptor['metric_key'] === $metricKey) {
|
||||
return $descriptor;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function forModule(string $moduleKey): array
|
||||
{
|
||||
$moduleKey = $this->normalizeModuleKey($moduleKey);
|
||||
return array_values(array_filter(
|
||||
$this->all(),
|
||||
static fn(array $descriptor): bool => $descriptor['module_key'] === $moduleKey
|
||||
));
|
||||
}
|
||||
|
||||
public function normalizeModuleKey(string $moduleKey): string
|
||||
{
|
||||
return strtolower(trim($moduleKey));
|
||||
}
|
||||
|
||||
public function normalizeMetricKey(string $metricKey): string
|
||||
{
|
||||
return strtolower(trim($metricKey));
|
||||
}
|
||||
|
||||
private function withDefaults(array $descriptor): array
|
||||
{
|
||||
$descriptor['module_key'] = $this->normalizeModuleKey((string)$descriptor['module_key']);
|
||||
$descriptor['metric_key'] = $this->normalizeMetricKey((string)$descriptor['metric_key']);
|
||||
$descriptor['module_label'] = (string)($descriptor['module_label'] ?? $descriptor['module_key']);
|
||||
$descriptor['metric_label'] = (string)($descriptor['metric_label'] ?? $descriptor['metric_key']);
|
||||
$descriptor['unit'] = (string)($descriptor['unit'] ?? 'count');
|
||||
$descriptor['period'] = (string)($descriptor['period'] ?? 'all_time');
|
||||
$descriptor['scope_type'] = (string)($descriptor['scope_type'] ?? 'global');
|
||||
$descriptor['scope_id'] = (string)($descriptor['scope_id'] ?? '');
|
||||
$descriptor['source'] = (string)($descriptor['source'] ?? 'internal_counter');
|
||||
$descriptor['default_enforce_mode'] = (string)($descriptor['default_enforce_mode'] ?? 'observe');
|
||||
$descriptor['soft_limit_percent'] = (float)($descriptor['soft_limit_percent'] ?? self::DEFAULT_SOFT_LIMIT_PERCENT);
|
||||
$descriptor['writable_limit'] = (bool)($descriptor['writable_limit'] ?? false);
|
||||
$descriptor['primary'] = (bool)($descriptor['primary'] ?? false);
|
||||
return $descriptor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class module_usage_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS module_usage_counters (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
module_key VARCHAR(64) NOT NULL,
|
||||
metric_key VARCHAR(128) NOT NULL,
|
||||
scope_type VARCHAR(32) NOT NULL DEFAULT 'global',
|
||||
scope_id VARCHAR(191) NOT NULL DEFAULT '',
|
||||
period_key VARCHAR(32) NOT NULL DEFAULT 'all_time',
|
||||
period_start DATETIME NOT NULL,
|
||||
period_end DATETIME NULL,
|
||||
unit VARCHAR(32) NOT NULL DEFAULT 'count',
|
||||
used_quantity DECIMAL(20,4) NOT NULL DEFAULT 0,
|
||||
limit_quantity DECIMAL(20,4) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ok',
|
||||
metadata_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_module_usage_counter (module_key, metric_key, scope_type, scope_id, period_key, period_start),
|
||||
KEY idx_module_usage_counters_module_period (module_key, period_key, period_start),
|
||||
KEY idx_module_usage_counters_status (status, updated_at),
|
||||
KEY idx_module_usage_counters_scope (scope_type, scope_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS module_usage_snapshots (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
module_key VARCHAR(64) NOT NULL,
|
||||
metric_key VARCHAR(128) NOT NULL,
|
||||
source VARCHAR(32) NOT NULL DEFAULT 'provider',
|
||||
period_key VARCHAR(32) NOT NULL DEFAULT 'provider',
|
||||
period_start DATETIME NULL,
|
||||
period_end DATETIME NULL,
|
||||
unit VARCHAR(32) NOT NULL DEFAULT 'count',
|
||||
used_quantity DECIMAL(20,4) NULL,
|
||||
limit_quantity DECIMAL(20,4) NULL,
|
||||
remaining_quantity DECIMAL(20,4) NULL,
|
||||
usage_percent DECIMAL(8,4) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'unknown',
|
||||
raw_payload_json LONGTEXT NULL,
|
||||
checked_at DATETIME NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_module_usage_snapshots_module_checked (module_key, metric_key, checked_at),
|
||||
KEY idx_module_usage_snapshots_status (status, checked_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS module_quota_settings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
module_key VARCHAR(64) NOT NULL,
|
||||
metric_key VARCHAR(128) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
enforce_mode VARCHAR(16) NOT NULL DEFAULT 'observe',
|
||||
soft_limit_percent DECIMAL(6,2) NOT NULL DEFAULT 90.00,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_module_quota_setting (module_key, metric_key),
|
||||
KEY idx_module_quota_settings_mode (enforce_mode, enabled)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
$db->query(
|
||||
"CREATE TABLE IF NOT EXISTS module_usage_logs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
module VARCHAR(64) NOT NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
status_code INT NOT NULL DEFAULT 0,
|
||||
data LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_module_usage_logs_module_created (module, created_at),
|
||||
KEY idx_module_usage_logs_action_created (action, created_at),
|
||||
KEY idx_module_usage_logs_status_created (status_code, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
|
||||
self::ensureColumn('module_quota_settings', 'enabled', "TINYINT(1) NOT NULL DEFAULT 1 AFTER metric_key");
|
||||
self::ensureColumn('module_quota_settings', 'soft_limit_percent', "DECIMAL(6,2) NOT NULL DEFAULT 90.00 AFTER enforce_mode");
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||
if ($table === '' || $column === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
||||
if ($result && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
use mysqli_result;
|
||||
use Throwable;
|
||||
|
||||
class module_usage_service
|
||||
{
|
||||
private module_usage_registry $registry;
|
||||
|
||||
public function __construct(?module_usage_registry $registry = null)
|
||||
{
|
||||
module_usage_schema_bootstrap::ensureTables();
|
||||
$this->registry = $registry ?? new module_usage_registry();
|
||||
}
|
||||
|
||||
public function summary(array $filters = []): array
|
||||
{
|
||||
$moduleFilter = isset($filters['module']) ? $this->registry->normalizeModuleKey((string)$filters['module']) : '';
|
||||
$periodFilter = isset($filters['period']) ? strtolower(trim((string)$filters['period'])) : '';
|
||||
$statusFilter = isset($filters['status']) ? strtolower(trim((string)$filters['status'])) : '';
|
||||
$date = isset($filters['date']) ? (string)$filters['date'] : null;
|
||||
|
||||
$metrics = [];
|
||||
foreach ($this->registry->all() as $descriptor) {
|
||||
if ($moduleFilter !== '' && $descriptor['module_key'] !== $moduleFilter) {
|
||||
continue;
|
||||
}
|
||||
if ($periodFilter !== '' && $periodFilter !== 'all' && $descriptor['period'] !== $periodFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$metric = $this->currentMetric($descriptor, $date);
|
||||
if ($statusFilter !== '' && $metric['status'] !== $statusFilter) {
|
||||
continue;
|
||||
}
|
||||
$metrics[] = $metric;
|
||||
}
|
||||
|
||||
$modules = [];
|
||||
foreach ($metrics as $metric) {
|
||||
$moduleKey = $metric['module_key'];
|
||||
if (!isset($modules[$moduleKey])) {
|
||||
$modules[$moduleKey] = [
|
||||
'key' => $moduleKey,
|
||||
'label' => $metric['module_label'],
|
||||
'status' => 'ok',
|
||||
'metrics' => [],
|
||||
];
|
||||
}
|
||||
$modules[$moduleKey]['metrics'][] = $metric;
|
||||
$modules[$moduleKey]['status'] = $this->worseStatus($modules[$moduleKey]['status'], $metric['status']);
|
||||
}
|
||||
|
||||
return [
|
||||
'generated_at' => date('c'),
|
||||
'filters' => [
|
||||
'module' => $moduleFilter !== '' ? $moduleFilter : null,
|
||||
'period' => $periodFilter !== '' ? $periodFilter : null,
|
||||
'status' => $statusFilter !== '' ? $statusFilter : null,
|
||||
'date' => $date,
|
||||
],
|
||||
'modules' => array_values($modules),
|
||||
'metrics' => $metrics,
|
||||
];
|
||||
}
|
||||
|
||||
public function moduleDetail(string $moduleKey, array $filters = []): array
|
||||
{
|
||||
$moduleKey = $this->registry->normalizeModuleKey($moduleKey);
|
||||
$descriptors = $this->registry->forModule($moduleKey);
|
||||
$date = isset($filters['date']) ? (string)$filters['date'] : null;
|
||||
$metrics = array_map(fn(array $descriptor): array => $this->currentMetric($descriptor, $date), $descriptors);
|
||||
|
||||
return [
|
||||
'generated_at' => date('c'),
|
||||
'module_key' => $moduleKey,
|
||||
'metrics' => $metrics,
|
||||
'history' => $this->historyForModule($moduleKey, $filters),
|
||||
];
|
||||
}
|
||||
|
||||
public function metricsForModule(string $moduleKey): array
|
||||
{
|
||||
$moduleKey = $this->registry->normalizeModuleKey($moduleKey);
|
||||
return array_map(
|
||||
fn(array $descriptor): array => $this->currentMetric($descriptor),
|
||||
$this->registry->forModule($moduleKey)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically records usage and enforces blocking quotas when the metric is configured for block mode.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function reserveOrFail(string $moduleKey, string $metricKey, float $quantity = 1.0, array $metadata = []): array
|
||||
{
|
||||
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
|
||||
$quantity = max(0.0, $quantity);
|
||||
if ($quantity <= 0.0) {
|
||||
return $this->currentMetric($descriptor);
|
||||
}
|
||||
|
||||
$setting = $this->settingFor($descriptor);
|
||||
if (($setting['enabled'] ?? true) !== true || ($setting['enforce_mode'] ?? 'observe') !== 'block') {
|
||||
return $this->recordUsage($moduleKey, $metricKey, $quantity, $metadata);
|
||||
}
|
||||
|
||||
$limit = $this->resolveLimitQuantity($descriptor);
|
||||
if ($limit === null) {
|
||||
return $this->recordUsage($moduleKey, $metricKey, $quantity, $metadata);
|
||||
}
|
||||
|
||||
$period = $this->periodWindow((string)$descriptor['period']);
|
||||
$this->insertCounterIfMissing($descriptor, $period, $limit);
|
||||
|
||||
global $db;
|
||||
$where = $this->counterWhereSql($descriptor, $period);
|
||||
$quantitySql = $this->numberSql($quantity);
|
||||
$limitSql = $this->numberSql($limit);
|
||||
$metadataSql = $this->jsonSql($metadata);
|
||||
|
||||
$db->query(
|
||||
"UPDATE module_usage_counters
|
||||
SET used_quantity = used_quantity + {$quantitySql},
|
||||
limit_quantity = {$limitSql},
|
||||
metadata_json = {$metadataSql},
|
||||
status = CASE
|
||||
WHEN {$limitSql} <= 0 OR ((used_quantity + {$quantitySql}) >= {$limitSql}) THEN 'exhausted'
|
||||
WHEN ((used_quantity + {$quantitySql}) / {$limitSql}) * 100 >= " . module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT . " THEN 'near_limit'
|
||||
ELSE 'ok'
|
||||
END
|
||||
WHERE {$where} AND (used_quantity + {$quantitySql}) <= {$limitSql}"
|
||||
);
|
||||
|
||||
if ((int)$db->conn()->affected_rows <= 0) {
|
||||
throw new Exception($this->quotaExceededMessage($descriptor));
|
||||
}
|
||||
|
||||
return $this->currentMetric($descriptor);
|
||||
}
|
||||
|
||||
public function recordUsage(string $moduleKey, string $metricKey, float $quantity = 1.0, array $metadata = []): array
|
||||
{
|
||||
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
|
||||
$quantity = max(0.0, $quantity);
|
||||
if ($quantity <= 0.0 || !$this->databaseReady()) {
|
||||
return $this->currentMetric($descriptor);
|
||||
}
|
||||
|
||||
$limit = $this->resolveLimitQuantity($descriptor);
|
||||
$setting = $this->settingFor($descriptor);
|
||||
$period = $this->periodWindow((string)$descriptor['period']);
|
||||
$this->insertCounterIfMissing($descriptor, $period, $limit);
|
||||
|
||||
global $db;
|
||||
$where = $this->counterWhereSql($descriptor, $period);
|
||||
$quantitySql = $this->numberSql($quantity);
|
||||
$limitSql = $this->nullableNumberSql($limit);
|
||||
$metadataSql = $this->jsonSql($metadata);
|
||||
$softLimitSql = $this->numberSql((float)$setting['soft_limit_percent']);
|
||||
$statusSql = (($setting['enabled'] ?? true) !== true)
|
||||
? $this->sqlString('disabled')
|
||||
: "CASE
|
||||
WHEN {$limitSql} IS NULL THEN 'unlimited'
|
||||
WHEN {$limitSql} <= 0 AND (used_quantity + {$quantitySql}) > 0 THEN 'exhausted'
|
||||
WHEN {$limitSql} <= 0 THEN 'ok'
|
||||
WHEN (used_quantity + {$quantitySql}) >= {$limitSql} THEN 'exhausted'
|
||||
WHEN ((used_quantity + {$quantitySql}) / {$limitSql}) * 100 >= {$softLimitSql} THEN 'near_limit'
|
||||
ELSE 'ok'
|
||||
END";
|
||||
|
||||
$db->query(
|
||||
"UPDATE module_usage_counters
|
||||
SET used_quantity = used_quantity + {$quantitySql},
|
||||
limit_quantity = {$limitSql},
|
||||
metadata_json = {$metadataSql},
|
||||
status = {$statusSql}
|
||||
WHERE {$where}"
|
||||
);
|
||||
|
||||
return $this->currentMetric($descriptor);
|
||||
}
|
||||
|
||||
public function currentUsedQuantity(string $moduleKey, string $metricKey): int
|
||||
{
|
||||
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
|
||||
$metric = $this->currentMetric($descriptor);
|
||||
return (int)floor((float)($metric['used'] ?? 0));
|
||||
}
|
||||
|
||||
public function updateQuotaSetting(string $moduleKey, string $metricKey, array $payload): array
|
||||
{
|
||||
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
|
||||
$setting = $this->settingFor($descriptor);
|
||||
|
||||
$enabled = array_key_exists('enabled', $payload) ? $this->toBool($payload['enabled']) : (bool)$setting['enabled'];
|
||||
$enforceMode = array_key_exists('enforce_mode', $payload) ? strtolower(trim((string)$payload['enforce_mode'])) : (string)$setting['enforce_mode'];
|
||||
if (!in_array($enforceMode, ['observe', 'block'], true)) {
|
||||
throw new Exception('Invalid enforce mode.');
|
||||
}
|
||||
|
||||
$softLimitPercent = array_key_exists('soft_limit_percent', $payload)
|
||||
? (float)$payload['soft_limit_percent']
|
||||
: (float)$setting['soft_limit_percent'];
|
||||
if ($softLimitPercent < 1.0 || $softLimitPercent > 100.0) {
|
||||
throw new Exception('Soft limit percent must be between 1 and 100.');
|
||||
}
|
||||
|
||||
if (array_key_exists('limit', $payload) || array_key_exists('hard_limit', $payload) || array_key_exists('hard_limit_quantity', $payload)) {
|
||||
if (empty($descriptor['writable_limit']) || empty($descriptor['config_module']) || empty($descriptor['config_variable'])) {
|
||||
throw new Exception('quota_not_writable');
|
||||
}
|
||||
$limitValue = $payload['limit'] ?? $payload['hard_limit'] ?? $payload['hard_limit_quantity'];
|
||||
if (!is_numeric($limitValue) || (int)$limitValue < 0) {
|
||||
throw new Exception('Limit must be a non-negative integer.');
|
||||
}
|
||||
$this->writeConfigLimit($descriptor, (int)$limitValue);
|
||||
}
|
||||
|
||||
if ($this->databaseReady()) {
|
||||
global $db;
|
||||
$moduleKeySql = $this->sqlString((string)$descriptor['module_key']);
|
||||
$metricKeySql = $this->sqlString((string)$descriptor['metric_key']);
|
||||
$enabledSql = $enabled ? '1' : '0';
|
||||
$modeSql = $this->sqlString($enforceMode);
|
||||
$softLimitSql = $this->numberSql($softLimitPercent);
|
||||
$db->query(
|
||||
"INSERT INTO module_quota_settings (module_key, metric_key, enabled, enforce_mode, soft_limit_percent)
|
||||
VALUES ({$moduleKeySql}, {$metricKeySql}, {$enabledSql}, {$modeSql}, {$softLimitSql})
|
||||
ON DUPLICATE KEY UPDATE
|
||||
enabled = VALUES(enabled),
|
||||
enforce_mode = VALUES(enforce_mode),
|
||||
soft_limit_percent = VALUES(soft_limit_percent)"
|
||||
);
|
||||
}
|
||||
|
||||
return $this->currentMetric($descriptor);
|
||||
}
|
||||
|
||||
public function recordProviderSnapshotFromLegacyUsage(string $moduleKey, array $usage, array $rawPayload = []): ?array
|
||||
{
|
||||
$moduleKey = $this->registry->normalizeModuleKey($moduleKey);
|
||||
$descriptor = null;
|
||||
foreach ($this->registry->forModule($moduleKey) as $candidate) {
|
||||
if (($candidate['source'] ?? '') === 'provider_snapshot') {
|
||||
$descriptor = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($descriptor === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$used = $this->firstNumeric($usage, ['used', 'calls_used', 'messages_used']);
|
||||
$limit = $this->firstNumeric($usage, ['limit', 'quota', 'quota_calls', 'total_calls']);
|
||||
$remaining = $this->firstNumeric($usage, ['remaining', 'calls_remaining', 'messages_remaining']);
|
||||
$percent = $this->firstNumeric($usage, ['usage_percent', 'percent']);
|
||||
$usageAvailable = !array_key_exists('usage_available', $usage) || $this->toBool($usage['usage_available']);
|
||||
$unavailableReason = trim((string)($usage['unavailable_reason'] ?? ''));
|
||||
|
||||
if ($used === null && $limit === null && $usageAvailable && $unavailableReason === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($remaining === null && $used !== null && $limit !== null) {
|
||||
$remaining = max(0.0, $limit - $used);
|
||||
}
|
||||
if ($percent === null && $used !== null && $limit !== null && $limit > 0) {
|
||||
$percent = round(($used / $limit) * 100, 4);
|
||||
}
|
||||
|
||||
$status = (!$usageAvailable || $unavailableReason !== '')
|
||||
? 'unknown'
|
||||
: $this->statusForUsage($used, $limit, $this->settingFor($descriptor));
|
||||
$payload = array_merge($rawPayload, ['usage' => $this->redactPayload($usage)]);
|
||||
|
||||
if ($this->databaseReady()) {
|
||||
try {
|
||||
global $db;
|
||||
$db->query(
|
||||
"INSERT INTO module_usage_snapshots
|
||||
(module_key, metric_key, source, period_key, unit, used_quantity, limit_quantity, remaining_quantity, usage_percent, status, raw_payload_json, checked_at)
|
||||
VALUES (
|
||||
" . $this->sqlString((string)$descriptor['module_key']) . ",
|
||||
" . $this->sqlString((string)$descriptor['metric_key']) . ",
|
||||
'provider',
|
||||
'provider',
|
||||
" . $this->sqlString((string)$descriptor['unit']) . ",
|
||||
" . $this->nullableNumberSql($used) . ",
|
||||
" . $this->nullableNumberSql($limit) . ",
|
||||
" . $this->nullableNumberSql($remaining) . ",
|
||||
" . $this->nullableNumberSql($percent) . ",
|
||||
" . $this->sqlString($status) . ",
|
||||
" . $this->jsonSql($payload) . ",
|
||||
" . $this->sqlString(date('Y-m-d H:i:s')) . "
|
||||
)"
|
||||
);
|
||||
} catch (Throwable) {
|
||||
// Provider snapshots are observability data. They must never break probes.
|
||||
}
|
||||
}
|
||||
|
||||
return $this->providerMetricFromValues($descriptor, $used, $limit, $remaining, $percent, $status, date('c'), $usage);
|
||||
}
|
||||
|
||||
public function primarySystemUsage(array $metrics): ?array
|
||||
{
|
||||
$metrics = array_values(array_filter(
|
||||
$metrics,
|
||||
static fn(array $metric): bool => ($metric['limit'] ?? null) !== null || ($metric['used'] ?? null) !== null
|
||||
));
|
||||
if ($metrics === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($metrics, function (array $left, array $right): int {
|
||||
if (($left['primary'] ?? false) !== ($right['primary'] ?? false)) {
|
||||
return ($right['primary'] ?? false) <=> ($left['primary'] ?? false);
|
||||
}
|
||||
$leftRank = $this->statusRank((string)($left['status'] ?? 'unknown'));
|
||||
$rightRank = $this->statusRank((string)($right['status'] ?? 'unknown'));
|
||||
return $rightRank <=> $leftRank;
|
||||
});
|
||||
|
||||
$metric = $metrics[0];
|
||||
return [
|
||||
'provider' => $metric['module_key'],
|
||||
'metric_key' => $metric['metric_key'],
|
||||
'unit' => $metric['unit'],
|
||||
'period' => $metric['period'],
|
||||
'calls_used' => $metric['used'],
|
||||
'quota_calls' => $metric['limit'],
|
||||
'calls_remaining' => $metric['remaining'],
|
||||
'usage_percent' => $metric['usage_percent'],
|
||||
'status' => $metric['status'],
|
||||
'source' => $metric['source'],
|
||||
'enforce_mode' => $metric['enforce_mode'],
|
||||
];
|
||||
}
|
||||
|
||||
public function currentMetric(array $descriptor, ?string $date = null): array
|
||||
{
|
||||
$descriptor = $this->normalizeDescriptor($descriptor);
|
||||
$setting = $this->settingFor($descriptor);
|
||||
$window = $this->periodWindow((string)$descriptor['period'], $date);
|
||||
$limit = $this->resolveLimitQuantity($descriptor);
|
||||
$used = null;
|
||||
$updatedAt = null;
|
||||
$historyAvailable = false;
|
||||
$snapshotExtra = [];
|
||||
|
||||
if (($descriptor['source'] ?? '') === 'provider_snapshot') {
|
||||
$snapshot = $this->latestProviderSnapshot($descriptor);
|
||||
if ($snapshot !== null) {
|
||||
$used = $snapshot['used_quantity'];
|
||||
$limit = $snapshot['limit_quantity'];
|
||||
$updatedAt = $snapshot['checked_at'];
|
||||
$snapshotExtra = $snapshot['extra'];
|
||||
$historyAvailable = true;
|
||||
}
|
||||
} else {
|
||||
$counter = $this->counterFor($descriptor, $window);
|
||||
if ($counter !== null) {
|
||||
$used = $counter['used_quantity'];
|
||||
$limit = $counter['limit_quantity'] ?? $limit;
|
||||
$updatedAt = $counter['updated_at'] ?? $counter['created_at'] ?? null;
|
||||
$historyAvailable = true;
|
||||
} else {
|
||||
$derived = $this->derivedOrLegacyUsage($descriptor, $window);
|
||||
if ($derived !== null) {
|
||||
$used = $derived;
|
||||
$historyAvailable = true;
|
||||
} elseif (($descriptor['source'] ?? '') === 'internal_counter') {
|
||||
$used = 0.0;
|
||||
$historyAvailable = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$status = $this->statusForUsage($used, $limit, $setting);
|
||||
$remaining = ($used !== null && $limit !== null) ? max(0.0, $limit - $used) : null;
|
||||
$usagePercent = ($used !== null && $limit !== null && $limit > 0) ? round(($used / $limit) * 100, 2) : null;
|
||||
|
||||
return array_merge([
|
||||
'module_key' => $descriptor['module_key'],
|
||||
'module_label' => $descriptor['module_label'],
|
||||
'metric_key' => $descriptor['metric_key'],
|
||||
'metric_label' => $descriptor['metric_label'],
|
||||
'unit' => $descriptor['unit'],
|
||||
'period' => $descriptor['period'],
|
||||
'scope_type' => $descriptor['scope_type'],
|
||||
'scope_id' => $descriptor['scope_id'],
|
||||
'source' => $descriptor['source'],
|
||||
'primary' => (bool)$descriptor['primary'],
|
||||
'writable_limit' => (bool)$descriptor['writable_limit'],
|
||||
'limit_source' => isset($descriptor['config_variable']) ? 'module_config' : (($descriptor['source'] ?? '') === 'provider_snapshot' ? 'provider' : null),
|
||||
'config_module' => $descriptor['config_module'] ?? null,
|
||||
'config_variable' => $descriptor['config_variable'] ?? null,
|
||||
'used' => $used,
|
||||
'limit' => $limit,
|
||||
'remaining' => $remaining,
|
||||
'usage_percent' => $usagePercent,
|
||||
'status' => $status,
|
||||
'enabled' => (bool)$setting['enabled'],
|
||||
'enforce_mode' => $setting['enforce_mode'],
|
||||
'soft_limit_percent' => (float)$setting['soft_limit_percent'],
|
||||
'window' => [
|
||||
'start' => $window['start_c'],
|
||||
'end' => $window['end_c'],
|
||||
'timezone' => date_default_timezone_get(),
|
||||
],
|
||||
'updated_at' => $updatedAt,
|
||||
'history_available' => $historyAvailable,
|
||||
], $snapshotExtra);
|
||||
}
|
||||
|
||||
private function requireDescriptor(string $moduleKey, string $metricKey): array
|
||||
{
|
||||
$descriptor = $this->registry->find($moduleKey, $metricKey);
|
||||
if ($descriptor === null) {
|
||||
throw new Exception('Unknown module usage metric.');
|
||||
}
|
||||
return $descriptor;
|
||||
}
|
||||
|
||||
private function normalizeDescriptor(array $descriptor): array
|
||||
{
|
||||
$found = $this->registry->find((string)$descriptor['module_key'], (string)$descriptor['metric_key']);
|
||||
return $found ?? $descriptor;
|
||||
}
|
||||
|
||||
private function settingFor(array $descriptor): array
|
||||
{
|
||||
$default = [
|
||||
'enabled' => true,
|
||||
'enforce_mode' => (string)($descriptor['default_enforce_mode'] ?? 'observe'),
|
||||
'soft_limit_percent' => (float)($descriptor['soft_limit_percent'] ?? module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT),
|
||||
];
|
||||
|
||||
if (!$this->databaseReady()) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
try {
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
"SELECT enabled, enforce_mode, soft_limit_percent
|
||||
FROM module_quota_settings
|
||||
WHERE module_key = " . $this->sqlString((string)$descriptor['module_key']) . "
|
||||
AND metric_key = " . $this->sqlString((string)$descriptor['metric_key']) . "
|
||||
LIMIT 1"
|
||||
);
|
||||
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||
if (!is_array($row)) {
|
||||
return $default;
|
||||
}
|
||||
return [
|
||||
'enabled' => (bool)((int)($row['enabled'] ?? 1)),
|
||||
'enforce_mode' => in_array((string)($row['enforce_mode'] ?? ''), ['observe', 'block'], true)
|
||||
? (string)$row['enforce_mode']
|
||||
: $default['enforce_mode'],
|
||||
'soft_limit_percent' => is_numeric($row['soft_limit_percent'] ?? null)
|
||||
? (float)$row['soft_limit_percent']
|
||||
: $default['soft_limit_percent'],
|
||||
];
|
||||
} catch (Throwable) {
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveLimitQuantity(array $descriptor): ?float
|
||||
{
|
||||
if (empty($descriptor['config_module']) || empty($descriptor['config_variable']) || !$this->databaseReady()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
"SELECT value
|
||||
FROM module_config
|
||||
WHERE module = " . $this->sqlString((string)$descriptor['config_module']) . "
|
||||
AND variable = " . $this->sqlString((string)$descriptor['config_variable']) . "
|
||||
LIMIT 1"
|
||||
);
|
||||
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||
if (!is_array($row) || !is_numeric($row['value'] ?? null)) {
|
||||
return null;
|
||||
}
|
||||
return max(0.0, (float)$row['value']);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function writeConfigLimit(array $descriptor, int $limit): void
|
||||
{
|
||||
if (!$this->databaseReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
$module = (string)$descriptor['config_module'];
|
||||
$variable = (string)$descriptor['config_variable'];
|
||||
$type = (string)($descriptor['config_type'] ?? 'int');
|
||||
|
||||
$existing = $db->query(
|
||||
"SELECT id
|
||||
FROM module_config
|
||||
WHERE module = " . $this->sqlString($module) . "
|
||||
AND variable = " . $this->sqlString($variable) . "
|
||||
LIMIT 1"
|
||||
);
|
||||
if ($existing instanceof mysqli_result && $existing->num_rows > 0) {
|
||||
$db->query(
|
||||
"UPDATE module_config
|
||||
SET value = " . $this->sqlString((string)$limit) . ", type = " . $this->sqlString($type) . "
|
||||
WHERE module = " . $this->sqlString($module) . "
|
||||
AND variable = " . $this->sqlString($variable)
|
||||
);
|
||||
} else {
|
||||
$db->query(
|
||||
"INSERT INTO module_config (module, variable, value, type)
|
||||
VALUES (" . $this->sqlString($module) . ", " . $this->sqlString($variable) . ", " . $this->sqlString((string)$limit) . ", " . $this->sqlString($type) . ")"
|
||||
);
|
||||
}
|
||||
system_search_cache::markDirtyTable('module_config');
|
||||
}
|
||||
|
||||
private function insertCounterIfMissing(array $descriptor, array $period, ?float $limit): void
|
||||
{
|
||||
if (!$this->databaseReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
$baseline = $this->derivedOrLegacyUsage($descriptor, $period);
|
||||
$baseline = $baseline === null ? 0.0 : max(0.0, (float)$baseline);
|
||||
$metadata = [
|
||||
'created_from' => $baseline > 0 ? 'legacy_or_derived_baseline' : 'counter',
|
||||
];
|
||||
|
||||
$db->query(
|
||||
"INSERT IGNORE INTO module_usage_counters
|
||||
(module_key, metric_key, scope_type, scope_id, period_key, period_start, period_end, unit, used_quantity, limit_quantity, status, metadata_json)
|
||||
VALUES (
|
||||
" . $this->sqlString((string)$descriptor['module_key']) . ",
|
||||
" . $this->sqlString((string)$descriptor['metric_key']) . ",
|
||||
" . $this->sqlString((string)$descriptor['scope_type']) . ",
|
||||
" . $this->sqlString((string)$descriptor['scope_id']) . ",
|
||||
" . $this->sqlString($period['key']) . ",
|
||||
" . $this->sqlString($period['start_sql']) . ",
|
||||
" . ($period['end_sql'] === null ? 'NULL' : $this->sqlString($period['end_sql'])) . ",
|
||||
" . $this->sqlString((string)$descriptor['unit']) . ",
|
||||
" . $this->numberSql($baseline) . ",
|
||||
" . $this->nullableNumberSql($limit) . ",
|
||||
" . $this->sqlString($this->statusForUsage($baseline, $limit, $this->settingFor($descriptor))) . ",
|
||||
" . $this->jsonSql($metadata) . "
|
||||
)"
|
||||
);
|
||||
}
|
||||
|
||||
private function counterFor(array $descriptor, array $period): ?array
|
||||
{
|
||||
if (!$this->databaseReady()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
"SELECT used_quantity, limit_quantity, status, created_at, updated_at
|
||||
FROM module_usage_counters
|
||||
WHERE " . $this->counterWhereSql($descriptor, $period) . "
|
||||
LIMIT 1"
|
||||
);
|
||||
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||
if (!is_array($row)) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'used_quantity' => (float)$row['used_quantity'],
|
||||
'limit_quantity' => $row['limit_quantity'] === null ? null : (float)$row['limit_quantity'],
|
||||
'status' => (string)$row['status'],
|
||||
'created_at' => $row['created_at'] ?? null,
|
||||
'updated_at' => $row['updated_at'] ?? null,
|
||||
];
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function latestProviderSnapshot(array $descriptor): ?array
|
||||
{
|
||||
if (!$this->databaseReady()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
"SELECT used_quantity, limit_quantity, remaining_quantity, usage_percent, status, raw_payload_json, checked_at
|
||||
FROM module_usage_snapshots
|
||||
WHERE module_key = " . $this->sqlString((string)$descriptor['module_key']) . "
|
||||
AND metric_key = " . $this->sqlString((string)$descriptor['metric_key']) . "
|
||||
ORDER BY checked_at DESC, id DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||
if (!is_array($row)) {
|
||||
return null;
|
||||
}
|
||||
$raw = json_decode((string)($row['raw_payload_json'] ?? ''), true);
|
||||
$usage = is_array($raw) && isset($raw['usage']) && is_array($raw['usage']) ? $raw['usage'] : [];
|
||||
$extra = [];
|
||||
if (isset($usage['version'])) {
|
||||
$extra['version'] = (string)$usage['version'];
|
||||
}
|
||||
if (array_key_exists('usage_available', $usage)) {
|
||||
$extra['usage_available'] = $this->toBool($usage['usage_available']);
|
||||
}
|
||||
if (isset($usage['unavailable_reason'])) {
|
||||
$extra['unavailable_reason'] = (string)$usage['unavailable_reason'];
|
||||
}
|
||||
if (isset($usage['detected_keys']) && is_array($usage['detected_keys'])) {
|
||||
$extra['detected_keys'] = array_values(array_map('strval', $usage['detected_keys']));
|
||||
}
|
||||
|
||||
return [
|
||||
'used_quantity' => $row['used_quantity'] === null ? null : (float)$row['used_quantity'],
|
||||
'limit_quantity' => $row['limit_quantity'] === null ? null : (float)$row['limit_quantity'],
|
||||
'remaining_quantity' => $row['remaining_quantity'] === null ? null : (float)$row['remaining_quantity'],
|
||||
'usage_percent' => $row['usage_percent'] === null ? null : (float)$row['usage_percent'],
|
||||
'status' => (string)$row['status'],
|
||||
'checked_at' => $row['checked_at'] ? date('c', strtotime((string)$row['checked_at'])) : null,
|
||||
'extra' => $extra,
|
||||
];
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function providerMetricFromValues(array $descriptor, ?float $used, ?float $limit, ?float $remaining, ?float $percent, string $status, string $checkedAt, array $usage): array
|
||||
{
|
||||
return [
|
||||
'module_key' => $descriptor['module_key'],
|
||||
'module_label' => $descriptor['module_label'],
|
||||
'metric_key' => $descriptor['metric_key'],
|
||||
'metric_label' => $descriptor['metric_label'],
|
||||
'unit' => $descriptor['unit'],
|
||||
'period' => $descriptor['period'],
|
||||
'scope_type' => $descriptor['scope_type'],
|
||||
'scope_id' => $descriptor['scope_id'],
|
||||
'source' => $descriptor['source'],
|
||||
'primary' => (bool)$descriptor['primary'],
|
||||
'writable_limit' => false,
|
||||
'limit_source' => 'provider',
|
||||
'used' => $used,
|
||||
'limit' => $limit,
|
||||
'remaining' => $remaining,
|
||||
'usage_percent' => $percent,
|
||||
'status' => $status,
|
||||
'enabled' => true,
|
||||
'enforce_mode' => 'observe',
|
||||
'soft_limit_percent' => module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT,
|
||||
'window' => ['start' => null, 'end' => null, 'timezone' => date_default_timezone_get()],
|
||||
'updated_at' => $checkedAt,
|
||||
'history_available' => true,
|
||||
'version' => isset($usage['version']) ? (string)$usage['version'] : null,
|
||||
'usage_available' => array_key_exists('usage_available', $usage) ? $this->toBool($usage['usage_available']) : true,
|
||||
'unavailable_reason' => isset($usage['unavailable_reason']) ? (string)$usage['unavailable_reason'] : null,
|
||||
'detected_keys' => isset($usage['detected_keys']) && is_array($usage['detected_keys'])
|
||||
? array_values(array_map('strval', $usage['detected_keys']))
|
||||
: [],
|
||||
];
|
||||
}
|
||||
|
||||
private function derivedOrLegacyUsage(array $descriptor, array $period): ?float
|
||||
{
|
||||
try {
|
||||
if (isset($descriptor['legacy_count_table'])) {
|
||||
return $this->countRowsInPeriod((string)$descriptor['legacy_count_table'], 'created_at', $period);
|
||||
}
|
||||
|
||||
if (isset($descriptor['legacy_log_module'])) {
|
||||
return $this->countActionLogs(
|
||||
(string)$descriptor['legacy_log_module'],
|
||||
isset($descriptor['legacy_log_action']) ? (string)$descriptor['legacy_log_action'] : null,
|
||||
$period
|
||||
);
|
||||
}
|
||||
|
||||
return match ($descriptor['module_key'] . '.' . $descriptor['metric_key']) {
|
||||
'backups.backup_jobs' => $this->countRowsInPeriod('backup_jobs', 'created_at', $period),
|
||||
'backups.stored_bytes' => $this->sumColumn('backup_records', 'total_bytes'),
|
||||
'coolify.operations' => $this->countRowsInPeriod('coolify_operations', 'created_at', $period),
|
||||
'selfserve.wash_sessions' => $this->countRowsInPeriod('selfserve_wash_sessions', 'created_at', $period),
|
||||
'xlvask.usage_rows' => $this->countRowsInPeriod('xlvask_usage_logs', 'StartTime', $period),
|
||||
'attachments.stored_files' => $this->countRowsInPeriod('object_attachments', null, $period),
|
||||
'forms.submissions' => $this->countRowsInPeriod('form_submissions', 'created_at', $period),
|
||||
'notifications.notification_sends' => $this->countRowsInPeriod('notifications', 'created_at', $period),
|
||||
'edgegateway.relay_commands' => $this->countRowsInPeriod('edge_gateway_operations', 'created_at', $period),
|
||||
'system.cron_runs' => $this->countRowsInPeriod('cron_task_runs', 'created_at', $period),
|
||||
default => null,
|
||||
};
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function countActionLogs(string $module, ?string $action, array $period): ?float
|
||||
{
|
||||
if (!$this->tableExists('module_usage_logs')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
global $db;
|
||||
$where = "UPPER(module) = " . $this->sqlString(strtoupper($module));
|
||||
if ($action !== null && $action !== '') {
|
||||
$where .= " AND UPPER(action) = " . $this->sqlString(strtoupper($action));
|
||||
}
|
||||
$where .= $this->periodWhereSql('created_at', $period);
|
||||
|
||||
$result = $db->query("SELECT COUNT(*) AS usage_count FROM module_usage_logs WHERE {$where}");
|
||||
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||
return is_array($row) ? (float)$row['usage_count'] : null;
|
||||
}
|
||||
|
||||
private function countRowsInPeriod(string $table, ?string $dateColumn, array $period): ?float
|
||||
{
|
||||
if (!$this->tableExists($table)) {
|
||||
return null;
|
||||
}
|
||||
if ($dateColumn !== null && !$this->columnExists($table, $dateColumn)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
global $db;
|
||||
$where = '1=1';
|
||||
if ($dateColumn !== null) {
|
||||
$where .= $this->periodWhereSql($dateColumn, $period);
|
||||
} elseif ($period['key'] !== 'all_time') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = $db->query("SELECT COUNT(*) AS usage_count FROM `{$table}` WHERE {$where}");
|
||||
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||
return is_array($row) ? (float)$row['usage_count'] : null;
|
||||
}
|
||||
|
||||
private function sumColumn(string $table, string $column): ?float
|
||||
{
|
||||
if (!$this->tableExists($table) || !$this->columnExists($table, $column)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
global $db;
|
||||
$result = $db->query("SELECT COALESCE(SUM(`{$column}`), 0) AS usage_sum FROM `{$table}`");
|
||||
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
|
||||
return is_array($row) ? (float)$row['usage_sum'] : null;
|
||||
}
|
||||
|
||||
private function historyForModule(string $moduleKey, array $filters): array
|
||||
{
|
||||
if (!$this->databaseReady()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$limit = isset($filters['limit']) && is_numeric($filters['limit']) ? max(1, min(200, (int)$filters['limit'])) : 100;
|
||||
$rows = [];
|
||||
|
||||
try {
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
"SELECT module_key, metric_key, period_key, period_start, period_end, used_quantity, limit_quantity, status, updated_at, created_at
|
||||
FROM module_usage_counters
|
||||
WHERE module_key = " . $this->sqlString($moduleKey) . "
|
||||
ORDER BY period_start DESC, id DESC
|
||||
LIMIT {$limit}"
|
||||
);
|
||||
if ($result instanceof mysqli_result) {
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function statusForUsage(?float $used, ?float $limit, array $setting): string
|
||||
{
|
||||
if (($setting['enabled'] ?? true) !== true) {
|
||||
return 'disabled';
|
||||
}
|
||||
if ($used === null) {
|
||||
return 'unknown';
|
||||
}
|
||||
if ($limit === null) {
|
||||
return 'unlimited';
|
||||
}
|
||||
if ($limit <= 0.0) {
|
||||
return $used > 0.0 ? 'exhausted' : 'ok';
|
||||
}
|
||||
|
||||
$percent = ($used / $limit) * 100;
|
||||
if ($used >= $limit || $percent >= 100.0) {
|
||||
return 'exhausted';
|
||||
}
|
||||
if ($percent >= (float)($setting['soft_limit_percent'] ?? module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT)) {
|
||||
return 'near_limit';
|
||||
}
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
private function worseStatus(string $current, string $candidate): string
|
||||
{
|
||||
return $this->statusRank($candidate) > $this->statusRank($current) ? $candidate : $current;
|
||||
}
|
||||
|
||||
private function statusRank(string $status): int
|
||||
{
|
||||
return match ($status) {
|
||||
'exhausted' => 5,
|
||||
'near_limit' => 4,
|
||||
'unknown' => 3,
|
||||
'disabled' => 2,
|
||||
'unlimited' => 1,
|
||||
'ok' => 0,
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
|
||||
private function quotaExceededMessage(array $descriptor): string
|
||||
{
|
||||
return match ((string)$descriptor['period']) {
|
||||
'day' => 'Daily limit exceeded',
|
||||
'month' => 'Monthly limit exceeded',
|
||||
default => 'Quota limit exceeded',
|
||||
};
|
||||
}
|
||||
|
||||
private function periodWindow(string $period, ?string $date = null): array
|
||||
{
|
||||
$timestamp = $date ? strtotime($date) : time();
|
||||
if ($timestamp === false) {
|
||||
$timestamp = time();
|
||||
}
|
||||
|
||||
return match ($period) {
|
||||
'day' => $this->periodFromTimestamps('day', strtotime(date('Y-m-d 00:00:00', $timestamp)), strtotime(date('Y-m-d 00:00:00', $timestamp) . ' +1 day')),
|
||||
'month' => $this->periodFromTimestamps('month', strtotime(date('Y-m-01 00:00:00', $timestamp)), strtotime(date('Y-m-01 00:00:00', $timestamp) . ' +1 month')),
|
||||
'provider' => ['key' => 'provider', 'start_sql' => date('Y-m-d 00:00:00', $timestamp), 'end_sql' => null, 'start_c' => null, 'end_c' => null],
|
||||
default => ['key' => 'all_time', 'start_sql' => '1970-01-01 00:00:00', 'end_sql' => null, 'start_c' => null, 'end_c' => null],
|
||||
};
|
||||
}
|
||||
|
||||
private function periodFromTimestamps(string $key, int $start, int $end): array
|
||||
{
|
||||
return [
|
||||
'key' => $key,
|
||||
'start_sql' => date('Y-m-d H:i:s', $start),
|
||||
'end_sql' => date('Y-m-d H:i:s', $end),
|
||||
'start_c' => date('c', $start),
|
||||
'end_c' => date('c', $end),
|
||||
];
|
||||
}
|
||||
|
||||
private function periodWhereSql(string $dateColumn, array $period): string
|
||||
{
|
||||
if ($period['key'] === 'all_time' || $period['key'] === 'provider') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$dateColumn = preg_replace('/[^a-zA-Z0-9_]/', '', $dateColumn);
|
||||
if ($dateColumn === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return " AND `{$dateColumn}` >= " . $this->sqlString($period['start_sql']) . " AND `{$dateColumn}` < " . $this->sqlString((string)$period['end_sql']);
|
||||
}
|
||||
|
||||
private function counterWhereSql(array $descriptor, array $period): string
|
||||
{
|
||||
return "module_key = " . $this->sqlString((string)$descriptor['module_key'])
|
||||
. " AND metric_key = " . $this->sqlString((string)$descriptor['metric_key'])
|
||||
. " AND scope_type = " . $this->sqlString((string)$descriptor['scope_type'])
|
||||
. " AND scope_id = " . $this->sqlString((string)$descriptor['scope_id'])
|
||||
. " AND period_key = " . $this->sqlString($period['key'])
|
||||
. " AND period_start = " . $this->sqlString($period['start_sql']);
|
||||
}
|
||||
|
||||
private function databaseReady(): bool
|
||||
{
|
||||
global $db;
|
||||
return isset($db) && is_object($db) && method_exists($db, 'query') && method_exists($db, 'conn');
|
||||
}
|
||||
|
||||
private function tableExists(string $table): bool
|
||||
{
|
||||
if (!$this->databaseReady()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
global $db;
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
if ($table === '') {
|
||||
return false;
|
||||
}
|
||||
$result = $db->query("SHOW TABLES LIKE " . $this->sqlString($table));
|
||||
return $result instanceof mysqli_result && $result->num_rows > 0;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function columnExists(string $table, string $column): bool
|
||||
{
|
||||
if (!$this->databaseReady()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
global $db;
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||
if ($table === '' || $column === '') {
|
||||
return false;
|
||||
}
|
||||
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE " . $this->sqlString($column));
|
||||
return $result instanceof mysqli_result && $result->num_rows > 0;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function firstNumeric(array $payload, array $keys): ?float
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
if (isset($payload[$key]) && is_numeric($payload[$key])) {
|
||||
return (float)$payload[$key];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function redactPayload(array $payload): array
|
||||
{
|
||||
$redacted = [];
|
||||
foreach ($payload as $key => $value) {
|
||||
$normalized = strtolower((string)$key);
|
||||
if (str_contains($normalized, 'key') || str_contains($normalized, 'token') || str_contains($normalized, 'secret')) {
|
||||
$redacted[$key] = '[redacted]';
|
||||
continue;
|
||||
}
|
||||
$redacted[$key] = is_array($value) ? $this->redactPayload($value) : $value;
|
||||
}
|
||||
return $redacted;
|
||||
}
|
||||
|
||||
private function toBool(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private function sqlString(string $value): string
|
||||
{
|
||||
global $db;
|
||||
return "'" . $db->escape_string($value) . "'";
|
||||
}
|
||||
|
||||
private function numberSql(float $value): string
|
||||
{
|
||||
return rtrim(rtrim(sprintf('%.4F', $value), '0'), '.') ?: '0';
|
||||
}
|
||||
|
||||
private function nullableNumberSql(?float $value): string
|
||||
{
|
||||
return $value === null ? 'NULL' : $this->numberSql($value);
|
||||
}
|
||||
|
||||
private function jsonSql(array $value): string
|
||||
{
|
||||
return $this->sqlString(json_encode($this->redactPayload($value), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use motorapi\actions\license_plate_lookup_a;
|
||||
use motorapi\helpers\motorapi_vehicle_types;
|
||||
use motorapi\motorapi_c;
|
||||
use objects\motorapi_lookups_o;
|
||||
use Throwable;
|
||||
|
||||
class motorapi implements motorapi_i
|
||||
{
|
||||
@@ -182,10 +183,10 @@ class motorapi implements motorapi_i
|
||||
self::requireModuleEnabled();
|
||||
// Validate the license plate
|
||||
self::requireValidLicensePlate($licensePlate);
|
||||
// Validate the daily limit
|
||||
self::requireDailyLimitNotExceeded();
|
||||
// Validate the secret key
|
||||
self::requireValidSecretKey();
|
||||
// Reserve quota for the outbound provider call. Cache hits return before this point.
|
||||
$this->reserveLookupQuota($licensePlate, $endpoint, $method);
|
||||
// Send the request
|
||||
$response = match ($method) {
|
||||
'GET' => self::sendGetRequest($licensePlate, $endpoint, $data),
|
||||
@@ -217,7 +218,7 @@ class motorapi implements motorapi_i
|
||||
function requireDailyLimitNotExceeded(): void
|
||||
{
|
||||
// Check if the daily limit is exceeded
|
||||
if ($this->getDailyRequestCounter() >= $this->config->daily_limit->getVariableValue()) {
|
||||
if ($this->getDailyRequestCounter() >= (int)$this->config->daily_limit->getVariableValue()) {
|
||||
throw new Exception('Daily limit exceeded');
|
||||
}
|
||||
}
|
||||
@@ -227,12 +228,29 @@ class motorapi implements motorapi_i
|
||||
*/
|
||||
function getDailyRequestCounter(): int
|
||||
{
|
||||
try {
|
||||
return (new module_usage_service())->currentUsedQuantity('motorapi', 'lookup_calls');
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
// Count the rows from the motorapi request log that was made today
|
||||
$motorapi_lookups = new motorapi_lookups_o();
|
||||
$motorapi_lookups->getTodayCount();
|
||||
return $motorapi_lookups->getTodayCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function reserveLookupQuota(string $licensePlate, string $endpoint, string $method): void
|
||||
{
|
||||
(new module_usage_service())->reserveOrFail('motorapi', 'lookup_calls', 1, [
|
||||
'license_plate' => $licensePlate,
|
||||
'endpoint' => $endpoint,
|
||||
'method' => strtoupper($method),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
|
||||
@@ -62,7 +62,7 @@ class pdf_store implements minio_pdfs_i
|
||||
|
||||
public function isFileInStore(string $file): bool
|
||||
{
|
||||
return self::getS3Client()->doesObjectExist(self::getBucket(), $file);
|
||||
return self::doesObjectExist($file);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,4 +76,4 @@ class pdf_store implements minio_pdfs_i
|
||||
$file_path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class security_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
$queries = [
|
||||
"CREATE TABLE IF NOT EXISTS security_firewall_rules (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
action VARCHAR(16) NOT NULL,
|
||||
target_type VARCHAR(32) NOT NULL,
|
||||
target_value VARCHAR(255) NOT NULL,
|
||||
route_pattern VARCHAR(255) NULL,
|
||||
priority INT NOT NULL DEFAULT 100,
|
||||
reason TEXT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
expires_at DATETIME NULL,
|
||||
metadata_json LONGTEXT NULL,
|
||||
created_by INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
INDEX idx_security_firewall_rules_active (enabled, deleted_at, expires_at),
|
||||
INDEX idx_security_firewall_rules_target (target_type, target_value),
|
||||
INDEX idx_security_firewall_rules_priority (priority)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS security_policy_rules (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
rule_key VARCHAR(64) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
threshold_count INT NOT NULL,
|
||||
window_seconds INT NOT NULL,
|
||||
mode VARCHAR(16) NOT NULL DEFAULT 'observe',
|
||||
exempt_permission_nodes_json LONGTEXT NULL,
|
||||
updated_by INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_security_policy_rules_key (rule_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS security_policy_events (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
rule_key VARCHAR(64) NOT NULL,
|
||||
subject_type VARCHAR(32) NOT NULL,
|
||||
subject_key VARCHAR(191) NOT NULL,
|
||||
route_path VARCHAR(255) NULL,
|
||||
route_template VARCHAR(255) NULL,
|
||||
method VARCHAR(16) NULL,
|
||||
source_ip VARCHAR(64) NULL,
|
||||
customer_number INT NULL,
|
||||
user_id INT NULL,
|
||||
subuser_id INT NULL,
|
||||
metadata_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_security_policy_events_window (rule_key, subject_type, subject_key, created_at),
|
||||
INDEX idx_security_policy_events_created (created_at),
|
||||
INDEX idx_security_policy_events_customer (customer_number, created_at),
|
||||
INDEX idx_security_policy_events_ip (source_ip, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS security_incidents (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
incident_key VARCHAR(191) NOT NULL,
|
||||
type VARCHAR(64) NOT NULL,
|
||||
severity VARCHAR(16) NOT NULL DEFAULT 'medium',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'open',
|
||||
title VARCHAR(255) NOT NULL,
|
||||
source_ip VARCHAR(64) NULL,
|
||||
customer_number INT NULL,
|
||||
user_id INT NULL,
|
||||
subuser_id INT NULL,
|
||||
route_path VARCHAR(255) NULL,
|
||||
route_template VARCHAR(255) NULL,
|
||||
method VARCHAR(16) NULL,
|
||||
related_rule_id BIGINT UNSIGNED NULL,
|
||||
related_firewall_rule_id BIGINT UNSIGNED NULL,
|
||||
occurrence_count INT NOT NULL DEFAULT 1,
|
||||
metadata_json LONGTEXT NULL,
|
||||
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_by INT NULL,
|
||||
resolved_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_security_incidents_key (incident_key),
|
||||
INDEX idx_security_incidents_status_seen (status, last_seen_at),
|
||||
INDEX idx_security_incidents_type_seen (type, last_seen_at),
|
||||
INDEX idx_security_incidents_customer_seen (customer_number, last_seen_at),
|
||||
INDEX idx_security_incidents_ip_seen (source_ip, last_seen_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS security_incident_notes (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
incident_id BIGINT UNSIGNED NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
created_by INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_security_incident_notes_incident (incident_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $query) {
|
||||
$db->query($query);
|
||||
}
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
public static function tablesExist(): bool
|
||||
{
|
||||
global $db;
|
||||
|
||||
$database = $db->escape_string($db->getDatabase());
|
||||
$result = $db->query(
|
||||
"SELECT COUNT(*) AS count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = '{$database}'
|
||||
AND table_name IN (
|
||||
'security_firewall_rules',
|
||||
'security_policy_rules',
|
||||
'security_policy_events',
|
||||
'security_incidents',
|
||||
'security_incident_notes'
|
||||
)"
|
||||
);
|
||||
$row = $result ? $result->fetch_assoc() : ['count' => 0];
|
||||
return (int)($row['count'] ?? 0) === 5;
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ class selfserve_schema_bootstrap
|
||||
department_id INT NOT NULL,
|
||||
machine_type_id INT NULL,
|
||||
customer_number INT NULL,
|
||||
subuser_id INT NULL,
|
||||
vehicle_id INT NULL,
|
||||
vehicle_type_id INT NULL,
|
||||
reg VARCHAR(255) NOT NULL,
|
||||
@@ -73,6 +74,8 @@ class selfserve_schema_bootstrap
|
||||
INDEX idx_selfserve_wash_sessions_lane_reg (lane_id, reg),
|
||||
INDEX idx_selfserve_wash_sessions_status (status),
|
||||
INDEX idx_selfserve_wash_sessions_customer (customer_number),
|
||||
INDEX idx_selfserve_wash_sessions_subuser_active (subuser_id, completed_at),
|
||||
INDEX idx_selfserve_wash_sessions_customer_subuser_active (customer_number, subuser_id, completed_at),
|
||||
INDEX idx_selfserve_wash_sessions_created_at (created_at),
|
||||
INDEX idx_selfserve_wash_sessions_department_completed (department_id, completed_at),
|
||||
INDEX idx_selfserve_wash_sessions_order (order_id)
|
||||
@@ -216,11 +219,36 @@ class selfserve_schema_bootstrap
|
||||
'dynamic_images_vehicle_type',
|
||||
'ALTER TABLE selfserve_wash_session_tasks ADD COLUMN dynamic_images_vehicle_type INT NULL AFTER buttons'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'selfserve_wash_sessions',
|
||||
'customer_number',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD COLUMN customer_number INT NULL AFTER department_id'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'selfserve_wash_sessions',
|
||||
'subuser_id',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD COLUMN subuser_id INT NULL AFTER customer_number'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'selfserve_wash_sessions',
|
||||
'wash_started_at',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at'
|
||||
);
|
||||
self::ensureIndex(
|
||||
'selfserve_wash_sessions',
|
||||
'idx_selfserve_wash_sessions_customer',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_customer (customer_number)'
|
||||
);
|
||||
self::ensureIndex(
|
||||
'selfserve_wash_sessions',
|
||||
'idx_selfserve_wash_sessions_subuser_active',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_subuser_active (subuser_id, completed_at)'
|
||||
);
|
||||
self::ensureIndex(
|
||||
'selfserve_wash_sessions',
|
||||
'idx_selfserve_wash_sessions_customer_subuser_active',
|
||||
'ALTER TABLE selfserve_wash_sessions ADD INDEX idx_selfserve_wash_sessions_customer_subuser_active (customer_number, subuser_id, completed_at)'
|
||||
);
|
||||
self::ensureIndex(
|
||||
'selfserve_wash_sessions',
|
||||
'idx_selfserve_wash_sessions_department_completed',
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
use objects\logs_o;
|
||||
use objects\subusers_o;
|
||||
|
||||
class subuser_contact_verification_service
|
||||
{
|
||||
public const CHANNEL_EMAIL = 'email';
|
||||
public const CHANNEL_PHONE = 'phone';
|
||||
public const CODE_TTL_SECONDS = 600;
|
||||
public const RESEND_COOLDOWN_SECONDS = 60;
|
||||
public const MAX_ATTEMPTS = 5;
|
||||
|
||||
private ?object $redis;
|
||||
private $codeGenerator;
|
||||
private $timeProvider;
|
||||
private $smsSender;
|
||||
private $emailSender;
|
||||
|
||||
public function __construct(
|
||||
?object $redis = null,
|
||||
?callable $codeGenerator = null,
|
||||
?callable $timeProvider = null,
|
||||
?callable $smsSender = null,
|
||||
?callable $emailSender = null
|
||||
) {
|
||||
$this->redis = $redis ?? (defined('redis') ? constant('redis') : null);
|
||||
$this->codeGenerator = $codeGenerator;
|
||||
$this->timeProvider = $timeProvider;
|
||||
$this->smsSender = $smsSender;
|
||||
$this->emailSender = $emailSender;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{state:string,email:array<string,mixed>,phone:array<string,mixed>}
|
||||
*/
|
||||
public function status(subusers_o $subuser): array
|
||||
{
|
||||
$email = $this->emailDestination($subuser);
|
||||
$phone = $this->phoneDestination($subuser);
|
||||
$emailVerifiedAt = $this->verifiedAtValue($subuser, self::CHANNEL_EMAIL);
|
||||
$phoneVerifiedAt = $this->verifiedAtValue($subuser, self::CHANNEL_PHONE);
|
||||
|
||||
$emailStatus = [
|
||||
'channel' => self::CHANNEL_EMAIL,
|
||||
'value' => $email,
|
||||
'masked_value' => $email !== null ? $this->maskEmail($email) : null,
|
||||
'available' => $email !== null,
|
||||
'verified' => $email !== null && $emailVerifiedAt !== null,
|
||||
'verified_at' => $emailVerifiedAt,
|
||||
];
|
||||
$phoneStatus = [
|
||||
'channel' => self::CHANNEL_PHONE,
|
||||
'country_code' => $subuser->phone_country_code->value() !== null ? (int)$subuser->phone_country_code->value() : null,
|
||||
'phone' => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null,
|
||||
'value' => $phone,
|
||||
'masked_value' => $phone !== null ? $this->maskPhone($phone) : null,
|
||||
'available' => $phone !== null,
|
||||
'verified' => $phone !== null && $phoneVerifiedAt !== null,
|
||||
'verified_at' => $phoneVerifiedAt,
|
||||
];
|
||||
|
||||
return [
|
||||
'state' => $this->verificationState($emailStatus, $phoneStatus),
|
||||
'email' => $emailStatus,
|
||||
'phone' => $phoneStatus,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function sendCode(subusers_o $subuser, string $channel, array $actor = []): array
|
||||
{
|
||||
$channel = $this->normalizeChannel($channel);
|
||||
$destination = $this->destinationFor($subuser, $channel);
|
||||
if ($destination === null) {
|
||||
return $this->delivery($channel, 'missing_destination', 'No contact value is available for verification.');
|
||||
}
|
||||
|
||||
if ($this->redis === null) {
|
||||
return $this->delivery($channel, 'unavailable', 'Verification delivery is not available.');
|
||||
}
|
||||
|
||||
$now = $this->now();
|
||||
$existing = $this->readChallenge($subuser, $channel);
|
||||
if ($existing !== null) {
|
||||
$sentAt = (int)($existing['sent_at'] ?? 0);
|
||||
$retryAfter = self::RESEND_COOLDOWN_SECONDS - ($now - $sentAt);
|
||||
if ($retryAfter > 0) {
|
||||
return $this->delivery($channel, 'throttled', 'Please wait before requesting another code.', [
|
||||
'retry_after' => $retryAfter,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$code = $this->generateCode();
|
||||
$destinationKey = $this->destinationKey($subuser, $channel);
|
||||
$nonce = bin2hex(random_bytes(16));
|
||||
$payload = [
|
||||
'hash' => $this->hashCode($code, $destinationKey, $nonce),
|
||||
'nonce' => $nonce,
|
||||
'destination' => $destinationKey,
|
||||
'attempts' => 0,
|
||||
'sent_at' => $now,
|
||||
'expires_at' => $now + self::CODE_TTL_SECONDS,
|
||||
];
|
||||
|
||||
try {
|
||||
$this->deliverCode($subuser, $channel, $destination, $code);
|
||||
$this->writeChallenge($subuser, $channel, $payload);
|
||||
$this->logEvent('SUBUSER_CONTACT_VERIFICATION_SENT', $subuser, $channel, $actor);
|
||||
} catch (Exception $exception) {
|
||||
$this->logEvent('SUBUSER_CONTACT_VERIFICATION_FAILED', $subuser, $channel, $actor);
|
||||
return $this->delivery($channel, 'failed', 'Verification delivery failed.');
|
||||
}
|
||||
|
||||
return $this->delivery($channel, 'sent', 'Verification code sent.', [
|
||||
'masked_destination' => $channel === self::CHANNEL_EMAIL
|
||||
? $this->maskEmail($destination)
|
||||
: $this->maskPhone($destination),
|
||||
'expires_in' => self::CODE_TTL_SECONDS,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function verifyCode(subusers_o $subuser, string $channel, string $code, array $actor = []): array
|
||||
{
|
||||
$channel = $this->normalizeChannel($channel);
|
||||
$code = trim($code);
|
||||
if (!preg_match('/^[0-9]{6}$/', $code)) {
|
||||
return $this->verificationResult($channel, 'invalid_code', 'Invalid verification code.');
|
||||
}
|
||||
|
||||
$challenge = $this->readChallenge($subuser, $channel);
|
||||
if ($challenge === null || (int)($challenge['expires_at'] ?? 0) < $this->now()) {
|
||||
$this->deleteChallenge($subuser, $channel);
|
||||
return $this->verificationResult($channel, 'expired', 'Verification code expired.');
|
||||
}
|
||||
|
||||
if (($challenge['destination'] ?? null) !== $this->destinationKey($subuser, $channel)) {
|
||||
$this->deleteChallenge($subuser, $channel);
|
||||
return $this->verificationResult($channel, 'destination_changed', 'Contact value changed. Request a new code.');
|
||||
}
|
||||
|
||||
$attempts = (int)($challenge['attempts'] ?? 0);
|
||||
if ($attempts >= self::MAX_ATTEMPTS) {
|
||||
$this->deleteChallenge($subuser, $channel);
|
||||
return $this->verificationResult($channel, 'too_many_attempts', 'Too many verification attempts.');
|
||||
}
|
||||
|
||||
$expectedHash = (string)($challenge['hash'] ?? '');
|
||||
$nonce = (string)($challenge['nonce'] ?? '');
|
||||
if (!hash_equals($expectedHash, $this->hashCode($code, (string)$challenge['destination'], $nonce))) {
|
||||
$nextAttempts = $attempts + 1;
|
||||
if ($nextAttempts >= self::MAX_ATTEMPTS) {
|
||||
$this->deleteChallenge($subuser, $channel);
|
||||
return $this->verificationResult($channel, 'too_many_attempts', 'Too many verification attempts.');
|
||||
}
|
||||
$challenge['attempts'] = $nextAttempts;
|
||||
$remainingTtl = max(1, (int)($challenge['expires_at'] ?? $this->now()) - $this->now());
|
||||
$this->writeChallenge($subuser, $channel, $challenge, $remainingTtl);
|
||||
return $this->verificationResult($channel, 'invalid_code', 'Invalid verification code.');
|
||||
}
|
||||
|
||||
$this->markVerified($subuser, $channel);
|
||||
$this->deleteChallenge($subuser, $channel);
|
||||
$this->logEvent('SUBUSER_CONTACT_VERIFICATION_VERIFIED', $subuser, $channel, $actor);
|
||||
|
||||
return $this->verificationResult($channel, 'verified', 'Contact value verified.', [
|
||||
'verified_at' => $this->verifiedAtValue($subuser, $channel),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function setVerificationState(subusers_o $subuser, string $channel, bool $verified, array $actor = []): array
|
||||
{
|
||||
$channel = $this->normalizeChannel($channel);
|
||||
if ($verified && $this->destinationFor($subuser, $channel) === null) {
|
||||
return $this->verificationResult($channel, 'missing_destination', 'No contact value is available for verification.');
|
||||
}
|
||||
|
||||
if ($verified) {
|
||||
$this->markVerified($subuser, $channel);
|
||||
} else {
|
||||
$this->markUnverified($subuser, $channel);
|
||||
}
|
||||
|
||||
$this->deleteChallenge($subuser, $channel);
|
||||
$this->logEvent(
|
||||
$verified
|
||||
? 'SUBUSER_CONTACT_VERIFICATION_MARKED_VERIFIED'
|
||||
: 'SUBUSER_CONTACT_VERIFICATION_MARKED_UNVERIFIED',
|
||||
$subuser,
|
||||
$channel,
|
||||
$actor
|
||||
);
|
||||
|
||||
return $this->verificationResult($channel, $verified ? 'verified' : 'unverified', 'Contact verification state updated.', [
|
||||
'verified_at' => $this->verifiedAtValue($subuser, $channel),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $updates
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function clearVerificationForChangedContacts(subusers_o $subuser, array $updates): array
|
||||
{
|
||||
if (array_key_exists('email', $updates)) {
|
||||
$current = $this->normalizeNullableString($subuser->email->value());
|
||||
$next = $this->normalizeNullableString($updates['email']);
|
||||
if ($current !== $next) {
|
||||
$updates['email_verified_at'] = null;
|
||||
$this->deleteChallenge($subuser, self::CHANNEL_EMAIL);
|
||||
}
|
||||
}
|
||||
|
||||
$phoneChanged = false;
|
||||
if (array_key_exists('phone_country_code', $updates)) {
|
||||
$phoneChanged = (int)$subuser->phone_country_code->value() !== (int)$updates['phone_country_code'];
|
||||
}
|
||||
if (array_key_exists('phone', $updates)) {
|
||||
$phoneChanged = $phoneChanged || (int)$subuser->phone->value() !== (int)$updates['phone'];
|
||||
}
|
||||
if ($phoneChanged) {
|
||||
$updates['phone_verified_at'] = null;
|
||||
$this->deleteChallenge($subuser, self::CHANNEL_PHONE);
|
||||
}
|
||||
|
||||
return $updates;
|
||||
}
|
||||
|
||||
public function normalizeChannel(string $channel): string
|
||||
{
|
||||
$channel = strtolower(trim($channel));
|
||||
if (!in_array($channel, [self::CHANNEL_EMAIL, self::CHANNEL_PHONE], true)) {
|
||||
throw new Exception('Invalid verification channel');
|
||||
}
|
||||
|
||||
return $channel;
|
||||
}
|
||||
|
||||
private function now(): int
|
||||
{
|
||||
if ($this->timeProvider !== null) {
|
||||
return (int)call_user_func($this->timeProvider);
|
||||
}
|
||||
|
||||
return time();
|
||||
}
|
||||
|
||||
private function generateCode(): string
|
||||
{
|
||||
if ($this->codeGenerator !== null) {
|
||||
$code = (string)call_user_func($this->codeGenerator);
|
||||
if (preg_match('/^[0-9]{6}$/', $code)) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
return (string)random_int(100000, 999999);
|
||||
}
|
||||
|
||||
private function hashCode(string $code, string $destination, string $nonce): string
|
||||
{
|
||||
$secret = (string)(getenv('APP_KEY') ?: getenv('JWT_SECRET') ?: __FILE__);
|
||||
return hash('sha256', $secret . ':' . $nonce . ':' . $destination . ':' . $code);
|
||||
}
|
||||
|
||||
private function destinationFor(subusers_o $subuser, string $channel): ?string
|
||||
{
|
||||
return $channel === self::CHANNEL_EMAIL
|
||||
? $this->emailDestination($subuser)
|
||||
: $this->phoneDestination($subuser);
|
||||
}
|
||||
|
||||
private function emailDestination(subusers_o $subuser): ?string
|
||||
{
|
||||
$email = $this->normalizeNullableString($subuser->email->value());
|
||||
return $email !== null && filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null;
|
||||
}
|
||||
|
||||
private function phoneDestination(subusers_o $subuser): ?string
|
||||
{
|
||||
$countryCode = $subuser->phone_country_code->value();
|
||||
$phone = $subuser->phone->value();
|
||||
if ($countryCode === null || $phone === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = preg_replace('/[^0-9]/', '', (string)$countryCode . (string)$phone);
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
|
||||
private function destinationKey(subusers_o $subuser, string $channel): string
|
||||
{
|
||||
return $channel . ':' . (string)($this->destinationFor($subuser, $channel) ?? '');
|
||||
}
|
||||
|
||||
private function key(subusers_o $subuser, string $channel): string
|
||||
{
|
||||
return 'subuser_contact_verification:' . (int)$subuser->id . ':' . $channel;
|
||||
}
|
||||
|
||||
private function readChallenge(subusers_o $subuser, string $channel): ?array
|
||||
{
|
||||
if ($this->redis === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = $this->redis->get($this->key($subuser, $channel));
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
private function writeChallenge(subusers_o $subuser, string $channel, array $payload, ?int $ttl = null): void
|
||||
{
|
||||
if ($this->redis === null) {
|
||||
throw new Exception('Verification cache is unavailable');
|
||||
}
|
||||
|
||||
$encoded = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
if ($encoded === false) {
|
||||
throw new Exception('Failed to encode verification challenge');
|
||||
}
|
||||
|
||||
$this->redis->setEx($this->key($subuser, $channel), $encoded, $ttl ?? self::CODE_TTL_SECONDS);
|
||||
}
|
||||
|
||||
private function deleteChallenge(subusers_o $subuser, string $channel): void
|
||||
{
|
||||
if ($this->redis !== null) {
|
||||
$this->redis->delete($this->key($subuser, $channel));
|
||||
}
|
||||
}
|
||||
|
||||
private function deliverCode(subusers_o $subuser, string $channel, string $destination, string $code): void
|
||||
{
|
||||
if ($channel === self::CHANNEL_PHONE) {
|
||||
if ($this->smsSender !== null) {
|
||||
call_user_func($this->smsSender, $destination, $code, $subuser);
|
||||
return;
|
||||
}
|
||||
|
||||
$gateway = new gatewayapi();
|
||||
if (!$gateway->isEnabled()) {
|
||||
throw new Exception('SMS delivery is not configured.');
|
||||
}
|
||||
$gateway->send([$destination], 'Truck Wash verifikationskode: ' . $code . '. Den udløber om 10 minutter.');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->emailSender !== null) {
|
||||
call_user_func($this->emailSender, $destination, $code, $subuser);
|
||||
return;
|
||||
}
|
||||
|
||||
$recipientName = $this->normalizeNullableString($subuser->name->value()) ?? 'Chauffør';
|
||||
$message = '<p>Din verifikationskode til Truck Wash er <strong>' . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '</strong>.</p>'
|
||||
. '<p>Koden udløber om 10 minutter.</p>';
|
||||
(new email())->sendEmail($destination, $recipientName, 'Truck Wash verifikationskode', $message);
|
||||
}
|
||||
|
||||
private function markVerified(subusers_o $subuser, string $channel): void
|
||||
{
|
||||
$timestamp = date('Y-m-d H:i:s', $this->now());
|
||||
if ($channel === self::CHANNEL_EMAIL) {
|
||||
$subuser->email_verified_at->set($timestamp);
|
||||
return;
|
||||
}
|
||||
|
||||
$subuser->phone_verified_at->set($timestamp);
|
||||
}
|
||||
|
||||
private function markUnverified(subusers_o $subuser, string $channel): void
|
||||
{
|
||||
if ($channel === self::CHANNEL_EMAIL) {
|
||||
$subuser->email_verified_at->set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
$subuser->phone_verified_at->set(null);
|
||||
}
|
||||
|
||||
private function verifiedAtValue(subusers_o $subuser, string $channel): ?string
|
||||
{
|
||||
$value = $channel === self::CHANNEL_EMAIL
|
||||
? $subuser->email_verified_at->value()
|
||||
: $subuser->phone_verified_at->value();
|
||||
$value = $this->normalizeNullableString($value);
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function verificationState(array $emailStatus, array $phoneStatus): string
|
||||
{
|
||||
if (!$phoneStatus['available'] && !$emailStatus['available']) {
|
||||
return 'missing_contacts';
|
||||
}
|
||||
if (!$phoneStatus['available']) {
|
||||
return 'missing_phone';
|
||||
}
|
||||
if (!$emailStatus['available']) {
|
||||
return 'missing_email';
|
||||
}
|
||||
if ($phoneStatus['verified'] && $emailStatus['verified']) {
|
||||
return 'verified';
|
||||
}
|
||||
if ($phoneStatus['verified'] || $emailStatus['verified']) {
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
return 'unverified';
|
||||
}
|
||||
|
||||
private function normalizeNullableString(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = trim((string)$value);
|
||||
return $normalized === '' ? null : $normalized;
|
||||
}
|
||||
|
||||
private function maskEmail(string $email): string
|
||||
{
|
||||
[$local, $domain] = array_pad(explode('@', $email, 2), 2, '');
|
||||
$prefix = substr($local, 0, 2);
|
||||
return $prefix . str_repeat('*', max(2, strlen($local) - 2)) . '@' . $domain;
|
||||
}
|
||||
|
||||
private function maskPhone(string $phone): string
|
||||
{
|
||||
$suffix = substr($phone, -4);
|
||||
return str_repeat('*', max(0, strlen($phone) - 4)) . $suffix;
|
||||
}
|
||||
|
||||
private function delivery(string $channel, string $status, string $message, array $extra = []): array
|
||||
{
|
||||
return [
|
||||
'channel' => $channel,
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
...$extra,
|
||||
];
|
||||
}
|
||||
|
||||
private function verificationResult(string $channel, string $status, string $message, array $extra = []): array
|
||||
{
|
||||
return [
|
||||
'channel' => $channel,
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
...$extra,
|
||||
];
|
||||
}
|
||||
|
||||
private function logEvent(string $action, subusers_o $subuser, string $channel, array $actor): void
|
||||
{
|
||||
try {
|
||||
if (!defined('redis')) {
|
||||
return;
|
||||
}
|
||||
$actorId = isset($actor['id']) ? (int)$actor['id'] : 0;
|
||||
(new logs_o())->add(
|
||||
'subusers',
|
||||
'global',
|
||||
1,
|
||||
$actorId,
|
||||
$action,
|
||||
'Chauffeur contact verification ' . $channel . ': ' . (int)$subuser->id
|
||||
);
|
||||
} catch (Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,23 @@ class subuser_permission_templates_service
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{enabled:bool,permissions:array<int,string>}|null
|
||||
*/
|
||||
public function expandTemplateForWritePayload(?string $templateKey): ?array
|
||||
{
|
||||
$key = $this->normalizeTemplateKey($templateKey);
|
||||
if ($key === self::TEMPLATE_CUSTOM) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($key === null) {
|
||||
throw new \InvalidArgumentException('Unknown driver access template.');
|
||||
}
|
||||
|
||||
return $this->expandTemplate($key);
|
||||
}
|
||||
|
||||
public function normalizeTemplateKey(?string $templateKey): ?string
|
||||
{
|
||||
if ($templateKey === null) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class subusers_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::ensureColumn(
|
||||
'subuser_grants',
|
||||
'assigned_vehicle_id',
|
||||
'INT NULL AFTER `subuser`'
|
||||
);
|
||||
self::ensureIndex(
|
||||
'subuser_grants',
|
||||
'idx_subuser_grants_assigned_vehicle_id',
|
||||
'`assigned_vehicle_id`'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'subusers',
|
||||
'phone_verified_at',
|
||||
'DATETIME NULL AFTER `phone`'
|
||||
);
|
||||
self::ensureColumn(
|
||||
'subusers',
|
||||
'email_verified_at',
|
||||
'DATETIME NULL AFTER `email`'
|
||||
);
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||
if ($table === '' || $column === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$columnSql = $db->escape_string($column);
|
||||
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$columnSql'");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
|
||||
}
|
||||
|
||||
private static function ensureIndex(string $table, string $index, string $columns): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$index = preg_replace('/[^a-zA-Z0-9_]/', '', $index);
|
||||
if ($table === '' || $index === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$indexSql = $db->escape_string($index);
|
||||
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,6 @@ class superuser_system_status_service
|
||||
$dependencies['database']['status'] ?? 'down',
|
||||
$dependencies['redis']['status'] ?? 'down',
|
||||
$dependencies['minio']['status'] ?? 'down',
|
||||
$dependencies['database']['replication']['status'] ?? 'not_configured',
|
||||
$dependencies['redis']['replication']['status'] ?? 'not_configured',
|
||||
$dependencies['minio']['replication']['status'] ?? 'not_configured',
|
||||
];
|
||||
foreach ($modules as $module) {
|
||||
if (($module['enabled'] ?? false) === true) {
|
||||
@@ -298,16 +295,6 @@ class superuser_system_status_service
|
||||
$database = $this->probeDatabase();
|
||||
$redis = $this->probeRedis();
|
||||
$minio = $this->probeMinio();
|
||||
try {
|
||||
$replicationManager = new replication_manager();
|
||||
$database['replication'] = $replicationManager->dependencyReplication('database');
|
||||
$redis['replication'] = $replicationManager->dependencyReplication('redis');
|
||||
$minio['replication'] = $replicationManager->dependencyReplication('minio');
|
||||
} catch (Throwable $throwable) {
|
||||
$database['replication'] = $this->replicationStatusFallback('database', $throwable);
|
||||
$redis['replication'] = $this->replicationStatusFallback('redis', $throwable);
|
||||
$minio['replication'] = $this->replicationStatusFallback('minio', $throwable);
|
||||
}
|
||||
|
||||
if (($redis['status'] ?? '') === 'down') {
|
||||
$this->pushWarning(
|
||||
@@ -333,19 +320,6 @@ class superuser_system_status_service
|
||||
];
|
||||
}
|
||||
|
||||
private function replicationStatusFallback(string $kind, Throwable $throwable): array
|
||||
{
|
||||
return [
|
||||
'status' => 'degraded',
|
||||
'min_percent' => 0.0,
|
||||
'average_percent' => 0.0,
|
||||
'replicas' => [],
|
||||
'blockers' => [
|
||||
'Replication status for ' . $kind . ' could not be loaded: ' . $throwable->getMessage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function probeCpu(array &$warnings): array
|
||||
{
|
||||
$checkedAt = date('c');
|
||||
@@ -563,6 +537,7 @@ class superuser_system_status_service
|
||||
$result['configured'] = false;
|
||||
$result['status'] = 'disabled';
|
||||
$result = array_merge($result, $this->moduleReason('module_disabled', [], 'Module is disabled.'));
|
||||
$result = $this->attachModuleUsageMetrics($result);
|
||||
$results[] = $result;
|
||||
continue;
|
||||
}
|
||||
@@ -574,6 +549,7 @@ class superuser_system_status_service
|
||||
$result['status_reason_params'] = isset($configuration['reason_params']) && is_array($configuration['reason_params'])
|
||||
? $configuration['reason_params']
|
||||
: ['variables' => implode(', ', $missingRequired), 'variables_list' => $missingRequired];
|
||||
$result = $this->attachModuleUsageMetrics($result);
|
||||
$results[] = $result;
|
||||
continue;
|
||||
}
|
||||
@@ -589,6 +565,7 @@ class superuser_system_status_service
|
||||
'Configuration is present, but no safe read-only probe is available.'
|
||||
)
|
||||
);
|
||||
$result = $this->attachModuleUsageMetrics($result);
|
||||
$results[] = $result;
|
||||
continue;
|
||||
}
|
||||
@@ -601,6 +578,10 @@ class superuser_system_status_service
|
||||
? $probeResult['status_reason_params']
|
||||
: [];
|
||||
$result['checked_at'] = (string)($probeResult['checked_at'] ?? $result['checked_at']);
|
||||
if (isset($probeResult['usage']) && is_array($probeResult['usage'])) {
|
||||
$result['usage'] = $probeResult['usage'];
|
||||
}
|
||||
$result = $this->attachModuleUsageMetrics($result, isset($probeResult['usage']) && is_array($probeResult['usage']) ? $probeResult['usage'] : null);
|
||||
$results[] = $result;
|
||||
}
|
||||
|
||||
@@ -619,6 +600,60 @@ class superuser_system_status_service
|
||||
return $results;
|
||||
}
|
||||
|
||||
protected function attachModuleUsageMetrics(array $moduleResult, ?array $probeUsage = null): array
|
||||
{
|
||||
try {
|
||||
$service = new module_usage_service();
|
||||
$metrics = $service->metricsForModule((string)($moduleResult['key'] ?? ''));
|
||||
|
||||
if ($probeUsage !== null) {
|
||||
$probeMetric = $service->recordProviderSnapshotFromLegacyUsage(
|
||||
(string)($moduleResult['key'] ?? ''),
|
||||
$probeUsage,
|
||||
['source' => 'system_status_probe']
|
||||
);
|
||||
if ($probeMetric !== null) {
|
||||
$metrics = $this->replaceModuleUsageMetric($metrics, $probeMetric);
|
||||
}
|
||||
}
|
||||
|
||||
if ($metrics !== []) {
|
||||
$moduleResult['usage_metrics'] = $metrics;
|
||||
if (!isset($moduleResult['usage'])) {
|
||||
$primaryUsage = $service->primarySystemUsage($metrics);
|
||||
if ($primaryUsage !== null) {
|
||||
$moduleResult['usage'] = $primaryUsage;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
$moduleResult['usage_metrics_error'] = $throwable->getMessage();
|
||||
}
|
||||
|
||||
return $moduleResult;
|
||||
}
|
||||
|
||||
protected function replaceModuleUsageMetric(array $metrics, array $replacement): array
|
||||
{
|
||||
$replaced = false;
|
||||
foreach ($metrics as $index => $metric) {
|
||||
if (
|
||||
($metric['module_key'] ?? null) === ($replacement['module_key'] ?? null)
|
||||
&& ($metric['metric_key'] ?? null) === ($replacement['metric_key'] ?? null)
|
||||
) {
|
||||
$metrics[$index] = $replacement;
|
||||
$replaced = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$replaced) {
|
||||
$metrics[] = $replacement;
|
||||
}
|
||||
|
||||
return $metrics;
|
||||
}
|
||||
|
||||
protected function resolveModuleProbeResult(array $descriptor, array $moduleConfig, bool $force, array &$warnings): array
|
||||
{
|
||||
$cacheKey = self::MODULE_PROBE_CACHE_KEY_PREFIX . $descriptor['key'];
|
||||
@@ -824,7 +859,7 @@ class superuser_system_status_service
|
||||
protected function moduleDescriptors(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'economic', 'module' => 'economic', 'always_enabled' => true, 'required' => ['invoiceLayoutNumber', 'paymentTermsNumber', 'adminFeeMonthly', 'adminFeeOrder', 'feeProductId'], 'probe' => fn(array $config): array => $this->probeEconomicModule($config)],
|
||||
['key' => 'economic', 'module' => 'economic', 'always_enabled' => true, 'required' => ['invoiceLayoutNumber', 'invoiceDiscountLayoutNumber', 'paymentTermsNumber', 'adminFeeMonthly', 'adminFeeOrder', 'feeProductId'], 'probe' => fn(array $config): array => $this->probeEconomicModule($config)],
|
||||
['key' => 'reCAPTCHA', 'module' => 'reCAPTCHA', 'enabled_variable' => 'enabled', 'required' => ['site_key_v2', 'secret_key_v2'], 'probe' => fn(array $config): array => $this->probeRecaptchaModule($config)],
|
||||
['key' => 'email', 'module' => 'Email', 'enabled_variable' => 'enabled', 'required' => ['smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', 'smtp_encryption', 'smtp_from', 'smtp_from_name', 'smtp_reply_to', 'smtp_reply_to_name'], 'probe' => fn(array $config): array => $this->probeEmailModule($config)],
|
||||
['key' => 'backups', 'module' => 'Backups', 'enabled_variable' => 'enabled', 'required' => [], 'probe' => fn(array $config): array => $this->probeBackupsModule($config)],
|
||||
@@ -979,7 +1014,11 @@ class superuser_system_status_service
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
'Accept: application/json',
|
||||
],
|
||||
'MailerSend API'
|
||||
'MailerSend API',
|
||||
null,
|
||||
'GET',
|
||||
null,
|
||||
fn(array $httpResponse, string $label): array => $this->evaluateProviderQuotaProbeResponse($httpResponse, $label, 'email')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -990,12 +1029,50 @@ class superuser_system_status_service
|
||||
|
||||
try {
|
||||
$this->validateBackupsStore();
|
||||
$health = $this->backupHealthSummary();
|
||||
|
||||
if (empty($health['encryption']['available'])) {
|
||||
$error = (string)($health['encryption']['error'] ?? 'Backup encryption key is missing.');
|
||||
return [
|
||||
'status' => 'down',
|
||||
'status_reason' => 'Backup encryption is not ready: ' . $error,
|
||||
'status_reason_key' => 'backup_encryption_key_missing',
|
||||
'status_reason_params' => ['error' => $error],
|
||||
'checked_at' => $checkedAt,
|
||||
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($health['latest_verified_backup'])) {
|
||||
return [
|
||||
'status' => 'degraded',
|
||||
'status_reason' => 'No verified backup is available for restore.',
|
||||
'status_reason_key' => 'backup_no_verified_backup',
|
||||
'status_reason_params' => [],
|
||||
'checked_at' => $checkedAt,
|
||||
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($health['fresh'])) {
|
||||
return [
|
||||
'status' => 'degraded',
|
||||
'status_reason' => 'Latest verified backup is stale.',
|
||||
'status_reason_key' => 'backup_latest_verified_stale',
|
||||
'status_reason_params' => ['age_seconds' => (string)($health['latest_verified_age_seconds'] ?? '')],
|
||||
'checked_at' => $checkedAt,
|
||||
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'status_reason' => 'Backup store connectivity confirmed.',
|
||||
'status_reason_key' => 'backup_connectivity_confirmed',
|
||||
'status_reason_params' => [],
|
||||
'status_reason_params' => [
|
||||
'latest_verified_age_seconds' => (string)($health['latest_verified_age_seconds'] ?? ''),
|
||||
'encryption_key_id' => (string)($health['encryption']['key_id'] ?? ''),
|
||||
],
|
||||
'checked_at' => $checkedAt,
|
||||
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||
];
|
||||
@@ -1018,7 +1095,11 @@ class superuser_system_status_service
|
||||
return $this->performHttpProbe(
|
||||
$this->buildUrlWithQuery('https://v1.motorapi.dk', '/usage'),
|
||||
['X-AUTH-TOKEN: ' . $secretKey],
|
||||
'MotorAPI'
|
||||
'MotorAPI',
|
||||
null,
|
||||
'GET',
|
||||
null,
|
||||
fn(array $httpResponse, string $label): array => $this->evaluateProviderQuotaProbeResponse($httpResponse, $label, 'motorapi')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1151,12 +1232,16 @@ class superuser_system_status_service
|
||||
$apiKey = trim((string)($config['api_key']['parsed'] ?? ''));
|
||||
|
||||
return $this->performHttpProbe(
|
||||
$this->buildUrlWithQuery('https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk', '/info/'),
|
||||
$this->buildUrlWithQuery(licenseplaterecognizer::configuredApiBaseUrl(), '/info/'),
|
||||
[
|
||||
'Authorization: Token ' . $apiKey,
|
||||
'Accept: application/json',
|
||||
],
|
||||
'License Plate Recognizer'
|
||||
'License Plate Recognizer',
|
||||
null,
|
||||
'GET',
|
||||
null,
|
||||
fn(array $httpResponse, string $label): array => $this->evaluateLicensePlateRecognizerProbeResponse($httpResponse, $label)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1474,6 +1559,270 @@ class superuser_system_status_service
|
||||
];
|
||||
}
|
||||
|
||||
protected function evaluateLicensePlateRecognizerProbeResponse(array $httpResponse, string $label): array
|
||||
{
|
||||
$classified = $this->classifyHttpProbeResult($httpResponse, $label);
|
||||
if (($classified['status'] ?? 'down') !== 'ok') {
|
||||
return $classified;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)($httpResponse['body'] ?? ''), true);
|
||||
if (!is_array($decoded)) {
|
||||
return $this->providerQuotaUnavailableProbeResult(
|
||||
$httpResponse,
|
||||
$label,
|
||||
'licenseplaterecognizer',
|
||||
'unreadable_payload',
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
$usage = is_array($decoded['usage'] ?? null) ? $decoded['usage'] : [];
|
||||
$callsUsedRaw = $usage['calls'] ?? null;
|
||||
$quotaCallsRaw = $decoded['total_calls'] ?? null;
|
||||
if (!is_numeric($callsUsedRaw) || !is_numeric($quotaCallsRaw) || (int)$quotaCallsRaw <= 0) {
|
||||
$reason = !is_numeric($quotaCallsRaw) ? 'missing_limit' : 'missing_usage';
|
||||
if (is_numeric($quotaCallsRaw) && (int)$quotaCallsRaw <= 0) {
|
||||
$reason = 'invalid_limit';
|
||||
}
|
||||
|
||||
return $this->providerQuotaUnavailableProbeResult(
|
||||
$httpResponse,
|
||||
$label,
|
||||
'licenseplaterecognizer',
|
||||
$reason,
|
||||
$this->payloadKeyPaths($decoded)
|
||||
);
|
||||
}
|
||||
|
||||
$callsUsed = max(0, (int)$callsUsedRaw);
|
||||
$quotaCalls = max(1, (int)$quotaCallsRaw);
|
||||
$callsRemaining = max(0, $quotaCalls - $callsUsed);
|
||||
$usagePercent = round(($callsUsed / $quotaCalls) * 100, 2);
|
||||
$usagePayload = [
|
||||
'provider' => 'licenseplaterecognizer',
|
||||
'calls_used' => $callsUsed,
|
||||
'quota_calls' => $quotaCalls,
|
||||
'calls_remaining' => $callsRemaining,
|
||||
'usage_percent' => $usagePercent,
|
||||
'version' => trim((string)($decoded['version'] ?? '')),
|
||||
];
|
||||
$reasonParams = [
|
||||
'label' => $label,
|
||||
'used' => (string)$callsUsed,
|
||||
'quota' => (string)$quotaCalls,
|
||||
'remaining' => (string)$callsRemaining,
|
||||
'percent' => number_format($usagePercent, 1, '.', ''),
|
||||
];
|
||||
|
||||
if ($callsRemaining <= 0 || $usagePercent >= 100.0) {
|
||||
return [
|
||||
'status' => 'down',
|
||||
'status_reason' => $label . ' quota is exhausted.',
|
||||
'status_reason_key' => 'licenseplaterecognizer_quota_exhausted',
|
||||
'status_reason_params' => $reasonParams,
|
||||
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||
'http_status' => $httpResponse['http_status'] ?? null,
|
||||
'usage' => $usagePayload,
|
||||
];
|
||||
}
|
||||
|
||||
if ($usagePercent >= 90.0) {
|
||||
return [
|
||||
'status' => 'degraded',
|
||||
'status_reason' => $label . ' quota usage is near the limit.',
|
||||
'status_reason_key' => 'licenseplaterecognizer_quota_near_limit',
|
||||
'status_reason_params' => $reasonParams,
|
||||
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||
'http_status' => $httpResponse['http_status'] ?? null,
|
||||
'usage' => $usagePayload,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'status_reason' => $label . ' usage and quota are available.',
|
||||
'status_reason_key' => 'licenseplaterecognizer_usage_available',
|
||||
'status_reason_params' => $reasonParams,
|
||||
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||
'http_status' => $httpResponse['http_status'] ?? null,
|
||||
'usage' => $usagePayload,
|
||||
];
|
||||
}
|
||||
|
||||
protected function evaluateProviderQuotaProbeResponse(array $httpResponse, string $label, string $moduleKey): array
|
||||
{
|
||||
$classified = $this->classifyHttpProbeResult($httpResponse, $label);
|
||||
if (($classified['status'] ?? 'down') !== 'ok') {
|
||||
return $classified;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)($httpResponse['body'] ?? ''), true);
|
||||
if (!is_array($decoded)) {
|
||||
return $this->providerQuotaUnavailableProbeResult(
|
||||
$httpResponse,
|
||||
$label,
|
||||
$moduleKey,
|
||||
'unreadable_payload',
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
$used = $this->findFirstNumericPayloadValue($decoded, ['used', 'usage', 'calls', 'messages_used', 'used_messages', 'sent', 'total_used']);
|
||||
$limit = $this->findFirstNumericPayloadValue($decoded, ['limit', 'quota', 'total', 'total_calls', 'messages_limit', 'max', 'allowed']);
|
||||
$remaining = $this->findFirstNumericPayloadValue($decoded, ['remaining', 'left', 'available', 'calls_remaining', 'messages_remaining']);
|
||||
|
||||
if ($used === null && $limit !== null && $remaining !== null) {
|
||||
$used = max(0.0, $limit - $remaining);
|
||||
}
|
||||
if ($remaining === null && $used !== null && $limit !== null) {
|
||||
$remaining = max(0.0, $limit - $used);
|
||||
}
|
||||
|
||||
if ($limit === null) {
|
||||
return $this->providerQuotaUnavailableProbeResult(
|
||||
$httpResponse,
|
||||
$label,
|
||||
$moduleKey,
|
||||
'missing_limit',
|
||||
$this->payloadKeyPaths($decoded)
|
||||
);
|
||||
}
|
||||
|
||||
if ($limit <= 0) {
|
||||
return $this->providerQuotaUnavailableProbeResult(
|
||||
$httpResponse,
|
||||
$label,
|
||||
$moduleKey,
|
||||
'invalid_limit',
|
||||
$this->payloadKeyPaths($decoded)
|
||||
);
|
||||
}
|
||||
|
||||
if ($used === null) {
|
||||
return $this->providerQuotaUnavailableProbeResult(
|
||||
$httpResponse,
|
||||
$label,
|
||||
$moduleKey,
|
||||
'missing_usage',
|
||||
$this->payloadKeyPaths($decoded)
|
||||
);
|
||||
}
|
||||
|
||||
$usagePercent = round(($used / $limit) * 100, 2);
|
||||
$usagePayload = [
|
||||
'provider' => $moduleKey,
|
||||
'calls_used' => $used,
|
||||
'quota_calls' => $limit,
|
||||
'calls_remaining' => $remaining,
|
||||
'usage_percent' => $usagePercent,
|
||||
];
|
||||
|
||||
if (($remaining !== null && $remaining <= 0) || $usagePercent >= 100.0) {
|
||||
return [
|
||||
'status' => 'down',
|
||||
'status_reason' => $label . ' quota is exhausted.',
|
||||
'status_reason_key' => 'provider_quota_exhausted',
|
||||
'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')],
|
||||
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||
'http_status' => $httpResponse['http_status'] ?? null,
|
||||
'usage' => $usagePayload,
|
||||
];
|
||||
}
|
||||
|
||||
if ($usagePercent >= 90.0) {
|
||||
return [
|
||||
'status' => 'degraded',
|
||||
'status_reason' => $label . ' quota usage is near the limit.',
|
||||
'status_reason_key' => 'provider_quota_near_limit',
|
||||
'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')],
|
||||
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||
'http_status' => $httpResponse['http_status'] ?? null,
|
||||
'usage' => $usagePayload,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'ok',
|
||||
'status_reason' => $label . ' quota usage is available.',
|
||||
'status_reason_key' => 'provider_quota_available',
|
||||
'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')],
|
||||
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||
'http_status' => $httpResponse['http_status'] ?? null,
|
||||
'usage' => $usagePayload,
|
||||
];
|
||||
}
|
||||
|
||||
protected function providerQuotaUnavailableProbeResult(array $httpResponse, string $label, string $moduleKey, string $reason, array $detectedKeys = []): array
|
||||
{
|
||||
$usagePayload = [
|
||||
'provider' => $moduleKey,
|
||||
'usage_available' => false,
|
||||
'status' => 'unknown',
|
||||
'calls_used' => null,
|
||||
'quota_calls' => null,
|
||||
'calls_remaining' => null,
|
||||
'usage_percent' => null,
|
||||
'unavailable_reason' => $reason,
|
||||
'detected_keys' => $detectedKeys,
|
||||
];
|
||||
|
||||
return [
|
||||
'status' => 'degraded',
|
||||
'status_reason' => $label . ' responded, but quota usage could not be read.',
|
||||
'status_reason_key' => 'provider_quota_unavailable',
|
||||
'status_reason_params' => [
|
||||
'label' => $label,
|
||||
'reason' => $reason,
|
||||
'detected_keys' => implode(', ', array_slice($detectedKeys, 0, 12)),
|
||||
],
|
||||
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
|
||||
'latency_ms' => $httpResponse['latency_ms'] ?? null,
|
||||
'http_status' => $httpResponse['http_status'] ?? null,
|
||||
'usage' => $usagePayload,
|
||||
];
|
||||
}
|
||||
|
||||
protected function findFirstNumericPayloadValue(array $payload, array $keys): ?float
|
||||
{
|
||||
foreach ($payload as $key => $value) {
|
||||
$normalizedKey = strtolower((string)$key);
|
||||
if (in_array($normalizedKey, $keys, true) && is_numeric($value)) {
|
||||
return (float)$value;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
$nested = $this->findFirstNumericPayloadValue($value, $keys);
|
||||
if ($nested !== null) {
|
||||
return $nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function payloadKeyPaths(array $payload, string $prefix = ''): array
|
||||
{
|
||||
$paths = [];
|
||||
foreach ($payload as $key => $value) {
|
||||
$path = $prefix === '' ? (string)$key : $prefix . '.' . (string)$key;
|
||||
$paths[] = $path;
|
||||
|
||||
if (is_array($value)) {
|
||||
array_push($paths, ...$this->payloadKeyPaths($value, $path));
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($paths));
|
||||
}
|
||||
|
||||
protected function evaluateRecaptchaProbeResponse(array $httpResponse, string $label): array
|
||||
{
|
||||
$classified = $this->classifyHttpProbeResult($httpResponse, $label);
|
||||
@@ -1539,6 +1888,11 @@ class superuser_system_status_service
|
||||
new backup_store();
|
||||
}
|
||||
|
||||
protected function backupHealthSummary(): array
|
||||
{
|
||||
return (new backup_store())->healthSummary();
|
||||
}
|
||||
|
||||
protected function bootstrapSelfserveSchema(): void
|
||||
{
|
||||
selfserve_schema_bootstrap::ensureTables();
|
||||
|
||||
@@ -72,6 +72,8 @@ class virkdata implements virkdata_i
|
||||
self::requireModuleEnabled();
|
||||
// Validate the secret key
|
||||
self::requireValidSecretKey();
|
||||
// VirkData has a monthly limit config, but defaults to observe mode until explicitly switched to block.
|
||||
$this->reserveCompanySearchQuota($search, $endpoint, $method);
|
||||
// Send the request
|
||||
$response = match ($method) {
|
||||
'GET' => self::sendGetRequest($search, $endpoint, $data),
|
||||
@@ -128,6 +130,18 @@ class virkdata implements virkdata_i
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function reserveCompanySearchQuota(string $search, string $endpoint, string $method): void
|
||||
{
|
||||
(new module_usage_service())->reserveOrFail('virkdata', 'company_search_calls', 1, [
|
||||
'search' => $search,
|
||||
'endpoint' => $endpoint,
|
||||
'method' => strtoupper($method),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -235,4 +249,4 @@ class virkdata implements virkdata_i
|
||||
{
|
||||
return $this->sendRequest($query, 'search', [], 'GET');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,10 @@ if ($args[1] === 'run') {
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] Running the cron script\n";
|
||||
require_once 'cron/Cron.php';
|
||||
break;
|
||||
case 'cron-worker':
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON_WORKER] Starting cron worker\n";
|
||||
(new \classes\cron_worker())->run();
|
||||
break;
|
||||
default:
|
||||
echo "Invalid script name";
|
||||
break;
|
||||
|
||||
@@ -40,6 +40,8 @@ require_once __DIR__ . '/../classes/economic_transfer_executor.php';
|
||||
const DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH = 1600;
|
||||
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
|
||||
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
|
||||
require_once __DIR__ . '/../classes/backup_schema_bootstrap.php';
|
||||
require_once __DIR__ . '/../classes/backup_store.php';
|
||||
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
|
||||
require_once __DIR__ . '/../classes/cron_schedule.php';
|
||||
require_once __DIR__ . '/../classes/cron_task_definition.php';
|
||||
@@ -538,12 +540,34 @@ function backup(): void
|
||||
{
|
||||
try {
|
||||
$backup = new backup_store();
|
||||
$backup->createBackup();
|
||||
$backup->enqueueCreateBackup(null, null, null, 'scheduled');
|
||||
} catch (Exception $e) {
|
||||
warn('Backup failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function processBackupJobs(): array
|
||||
{
|
||||
try {
|
||||
return (new backup_store())->processPendingJobs(3);
|
||||
} catch (Exception $e) {
|
||||
warn('Backup job processing failed: ' . $e->getMessage());
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
function pruneBackupRetention(): array
|
||||
{
|
||||
try {
|
||||
$backup = new backup_store();
|
||||
$queued = $backup->enqueueRetentionPrune();
|
||||
return $backup->runJobById((int)$queued['job_id']);
|
||||
} catch (Exception $e) {
|
||||
warn('Backup retention prune failed: ' . $e->getMessage());
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
function SyncUserEconomicCustomerDiscounts(): void
|
||||
{
|
||||
$users_o = new users_o();
|
||||
|
||||
@@ -31,7 +31,7 @@ interface minio_backups_i
|
||||
* Create a backup, upload it to the Minio bucket and return the UUID
|
||||
* @return string The UUID of the backup
|
||||
*/
|
||||
public function createBackup(): string;
|
||||
public function createBackup($backup_name = null, $backup_description = null): string;
|
||||
|
||||
/**
|
||||
* Create the metadata for a backup
|
||||
@@ -98,4 +98,4 @@ interface minio_backups_i
|
||||
*/
|
||||
public function backupEnvironmentVariables(string $backup_uuid): string;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,22 @@
|
||||
|
||||
namespace backups;
|
||||
require_once WD . '/modules/backups/config/backups_enabled_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_retention_recent_hours_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_retention_daily_days_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_retention_weekly_weeks_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_retention_monthly_months_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_app_data_enabled_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_verification_required_c.php';
|
||||
require_once WD . '/modules/backups/config/backups_restore_enabled_c.php';
|
||||
|
||||
use backups\config\backups_app_data_enabled_c;
|
||||
use backups\config\backups_enabled_c;
|
||||
use backups\config\backups_restore_enabled_c;
|
||||
use backups\config\backups_retention_daily_days_c;
|
||||
use backups\config\backups_retention_monthly_months_c;
|
||||
use backups\config\backups_retention_recent_hours_c;
|
||||
use backups\config\backups_retention_weekly_weeks_c;
|
||||
use backups\config\backups_verification_required_c;
|
||||
use traits\module_config_t;
|
||||
|
||||
class backups_c
|
||||
@@ -15,6 +29,13 @@ class backups_c
|
||||
* @var backups_enabled_c
|
||||
*/
|
||||
public backups_enabled_c $enabled;
|
||||
public backups_retention_recent_hours_c $retention_recent_hours;
|
||||
public backups_retention_daily_days_c $retention_daily_days;
|
||||
public backups_retention_weekly_weeks_c $retention_weekly_weeks;
|
||||
public backups_retention_monthly_months_c $retention_monthly_months;
|
||||
public backups_app_data_enabled_c $app_data_enabled;
|
||||
public backups_verification_required_c $verification_required;
|
||||
public backups_restore_enabled_c $restore_enabled;
|
||||
|
||||
|
||||
public function __construct()
|
||||
@@ -22,8 +43,22 @@ class backups_c
|
||||
$this->setupConfig('Backups');
|
||||
$this->allowUpdate([
|
||||
backups_enabled_c::class,
|
||||
backups_retention_recent_hours_c::class,
|
||||
backups_retention_daily_days_c::class,
|
||||
backups_retention_weekly_weeks_c::class,
|
||||
backups_retention_monthly_months_c::class,
|
||||
backups_app_data_enabled_c::class,
|
||||
backups_verification_required_c::class,
|
||||
backups_restore_enabled_c::class,
|
||||
]);
|
||||
$this->enabled = new backups_enabled_c();
|
||||
$this->retention_recent_hours = new backups_retention_recent_hours_c();
|
||||
$this->retention_daily_days = new backups_retention_daily_days_c();
|
||||
$this->retention_weekly_weeks = new backups_retention_weekly_weeks_c();
|
||||
$this->retention_monthly_months = new backups_retention_monthly_months_c();
|
||||
$this->app_data_enabled = new backups_app_data_enabled_c();
|
||||
$this->verification_required = new backups_verification_required_c();
|
||||
$this->restore_enabled = new backups_restore_enabled_c();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_app_data_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'app_data_enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether app-owned object storage buckets are included in backups.',
|
||||
'1',
|
||||
false,
|
||||
'true'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_restore_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'restore_enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether direct production restore execution is enabled for superusers.',
|
||||
'0',
|
||||
false,
|
||||
'false'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_retention_daily_days_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'retention_daily_days',
|
||||
'int',
|
||||
true,
|
||||
null,
|
||||
'How many days of daily backup representatives are retained.',
|
||||
'30',
|
||||
false,
|
||||
'30'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_retention_monthly_months_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'retention_monthly_months',
|
||||
'int',
|
||||
true,
|
||||
null,
|
||||
'How many months of monthly backup representatives are retained.',
|
||||
'3',
|
||||
false,
|
||||
'3'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_retention_recent_hours_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'retention_recent_hours',
|
||||
'int',
|
||||
true,
|
||||
null,
|
||||
'How many hours of recent recovery points are protected before tiered pruning.',
|
||||
'48',
|
||||
false,
|
||||
'48'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_retention_weekly_weeks_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'retention_weekly_weeks',
|
||||
'int',
|
||||
true,
|
||||
null,
|
||||
'How many weeks of weekly backup representatives are retained.',
|
||||
'8',
|
||||
false,
|
||||
'8'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace backups\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class backups_verification_required_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Backups',
|
||||
'verification_required',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether a backup must pass verification before it is marked available for restore.',
|
||||
'1',
|
||||
false,
|
||||
'true'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,37 @@ return [
|
||||
[
|
||||
'id' => 'backups.create_backup',
|
||||
'legacy_name' => 'backup',
|
||||
'name' => 'Create backup',
|
||||
'description' => 'Creates the scheduled backup bundle through the configured backup store.',
|
||||
'name' => 'Enqueue backup',
|
||||
'description' => 'Queues the scheduled production recovery backup through the configured backup store.',
|
||||
'module' => 'backups',
|
||||
'handler' => 'backup',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 43200],
|
||||
'timeout_seconds' => 1800,
|
||||
'estimated_duration_ms' => 60000,
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 3600],
|
||||
'timeout_seconds' => 120,
|
||||
'estimated_duration_ms' => 5000,
|
||||
'priority' => 115,
|
||||
],
|
||||
[
|
||||
'id' => 'backups.process_jobs',
|
||||
'legacy_name' => null,
|
||||
'name' => 'Process backup jobs',
|
||||
'description' => 'Processes queued backup create, verify, restore, and prune jobs.',
|
||||
'module' => 'backups',
|
||||
'handler' => 'processBackupJobs',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 60],
|
||||
'timeout_seconds' => 3600,
|
||||
'estimated_duration_ms' => 60000,
|
||||
'priority' => 116,
|
||||
],
|
||||
[
|
||||
'id' => 'backups.prune_retention',
|
||||
'legacy_name' => null,
|
||||
'name' => 'Prune backup retention',
|
||||
'description' => 'Queues tiered retention cleanup for old backup artifacts.',
|
||||
'module' => 'backups',
|
||||
'handler' => 'pruneBackupRetention',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 86400],
|
||||
'timeout_seconds' => 1800,
|
||||
'estimated_duration_ms' => 30000,
|
||||
'priority' => 117,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -213,6 +213,18 @@
|
||||
}
|
||||
},
|
||||
|
||||
"/department/timebookings/departments/public": {
|
||||
"get": {
|
||||
"tags": ["Public Time Bookings"],
|
||||
"summary": "List public departments with time bookings enabled",
|
||||
"description": "Returns only visible, active departments where `bookingsystem_time_based_enabled` is `true`.",
|
||||
"parameters": [ { "$ref": "#/components/parameters/page" }, { "$ref": "#/components/parameters/limit" }, { "$ref": "#/components/parameters/search" }, { "$ref": "#/components/parameters/filters" }, { "$ref": "#/components/parameters/order" } ],
|
||||
"responses": {
|
||||
"200": { "description": "Departments", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EnvelopePublicTimeBookingDepartmentsList" } } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"/department/timebookings/opening-hours/public": {
|
||||
"get": {
|
||||
"tags": ["Public Time Bookings"],
|
||||
@@ -339,6 +351,12 @@
|
||||
{ "type": "object", "properties": { "data": { "$ref": "#/components/schemas/OpeningHours" } } }
|
||||
]
|
||||
},
|
||||
"EnvelopePublicTimeBookingDepartmentsList": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/components/schemas/Envelope" },
|
||||
{ "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/PublicTimeBookingDepartment" } } } }
|
||||
]
|
||||
},
|
||||
"EnvelopeBookingTypesList": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/components/schemas/Envelope" },
|
||||
@@ -396,6 +414,21 @@
|
||||
"required": ["link"]
|
||||
},
|
||||
|
||||
"PublicTimeBookingDepartment": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"address": { "type": "string" },
|
||||
"longitude": { "type": "number", "format": "float" },
|
||||
"latitude": { "type": "number", "format": "float" },
|
||||
"order_priority": { "type": "integer" },
|
||||
"time_booking_enabled": { "type": "boolean" }
|
||||
},
|
||||
"required": ["id", "name", "description", "address", "longitude", "latitude", "order_priority", "time_booking_enabled"]
|
||||
},
|
||||
|
||||
"OpeningHours": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace config;
|
||||
|
||||
use traits\module_config_variable;
|
||||
|
||||
class economic_invoice_discount_layout_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'economic',
|
||||
'invoiceDiscountLayoutNumber',
|
||||
'int',
|
||||
false,
|
||||
null,
|
||||
'The duplicated invoice layout number used when collected invoices include itemized discounts',
|
||||
'1',
|
||||
false,
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
require_once WD . '/modules/economic/config/economic_invoice_layout_c.php';
|
||||
require_once WD . '/modules/economic/config/economic_invoice_discount_layout_c.php';
|
||||
require_once WD . '/modules/economic/config/economic_payment_terms_c.php';
|
||||
require_once WD . '/modules/economic/config/economic_admin_fee_monthly_c.php';
|
||||
require_once WD . '/modules/economic/config/economic_admin_fee_order_c.php';
|
||||
@@ -8,6 +9,7 @@ require_once WD . '/modules/economic/config/economic_transaction_draft_customer_
|
||||
require_once WD . '/modules/economic/config/economic_default_department_id_c.php';
|
||||
|
||||
use config\economic_invoice_layout_c;
|
||||
use config\economic_invoice_discount_layout_c;
|
||||
use config\economic_payment_terms_c;
|
||||
use config\economic_admin_fee_monthly_c;
|
||||
use config\economic_admin_fee_order_c;
|
||||
@@ -21,6 +23,7 @@ class economic_c
|
||||
use module_config_t;
|
||||
|
||||
public economic_invoice_layout_c $invoice_layout;
|
||||
public economic_invoice_discount_layout_c $invoice_discount_layout;
|
||||
public economic_payment_terms_c $payment_terms;
|
||||
public economic_admin_fee_monthly_c $admin_fee_monthly;
|
||||
public economic_admin_fee_order_c $admin_fee_order;
|
||||
@@ -33,6 +36,7 @@ class economic_c
|
||||
$this->setupConfig('economic');
|
||||
$this->allowUpdate([
|
||||
economic_invoice_layout_c::class,
|
||||
economic_invoice_discount_layout_c::class,
|
||||
economic_payment_terms_c::class,
|
||||
economic_admin_fee_monthly_c::class,
|
||||
economic_admin_fee_order_c::class,
|
||||
@@ -41,6 +45,7 @@ class economic_c
|
||||
economic_transaction_draft_customer_number_c::class,
|
||||
]);
|
||||
$this->invoice_layout = new economic_invoice_layout_c();
|
||||
$this->invoice_discount_layout = new economic_invoice_discount_layout_c();
|
||||
$this->payment_terms = new economic_payment_terms_c();
|
||||
$this->admin_fee_monthly = new economic_admin_fee_monthly_c();
|
||||
$this->admin_fee_order = new economic_admin_fee_order_c();
|
||||
|
||||
+2
-2
@@ -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): array
|
||||
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500, bool $use_itemized_discounts = false): array
|
||||
{
|
||||
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
|
||||
$orders_with_invoice_lines = 0;
|
||||
@@ -90,7 +90,7 @@ class economic_invoices_draft_endpoint
|
||||
// Add the transaction header (Timestamp, department, etc.)
|
||||
$draftInvoice->addNewTransactionHeader($order);
|
||||
// Add the order lines
|
||||
$draftInvoice->addOrderItemLines($order);
|
||||
$draftInvoice->addOrderItemLines($order, $use_itemized_discounts);
|
||||
// Add an empty line, so the invoice is not empty
|
||||
$draftInvoice->addTextLine('');
|
||||
}
|
||||
|
||||
+4
-2
@@ -104,10 +104,12 @@ class economic_invoices_drafts_endpoint
|
||||
* @return object {id: number, getExternalId: string}
|
||||
* @throws Exception If the request fails
|
||||
*/
|
||||
public function add(int $customer_number, string $external_id = '', string|null $date = null): object
|
||||
public function add(int $customer_number, string $external_id = '', string|null $date = null, ?int $layout_number = null): object
|
||||
{
|
||||
// Get the layout number
|
||||
$layout_number = (int)(new economic())->config->invoice_layout->getVariableValue();
|
||||
$layout_number = $layout_number !== null && $layout_number > 0
|
||||
? $layout_number
|
||||
: (int)(new economic())->config->invoice_layout->getVariableValue();
|
||||
|
||||
// Check if the date is set
|
||||
if (is_null($date)) {
|
||||
|
||||
+33
-5
@@ -9,21 +9,49 @@ class economic_invoices_pdf_endpoint
|
||||
use economic_endpoint_t;
|
||||
|
||||
/**
|
||||
* Get a PDF of a booked invoice
|
||||
* Get a PDF of a booked invoice.
|
||||
*
|
||||
* Kept as the legacy alias used by existing invoice download flows.
|
||||
* @param int $id
|
||||
* @return string
|
||||
*/
|
||||
public function get(int $id): string
|
||||
{
|
||||
return $this->getBooked($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a PDF of a booked invoice.
|
||||
* @param int $id
|
||||
* @return string
|
||||
*/
|
||||
public function getBooked(int $id): string
|
||||
{
|
||||
return $this->downloadPdf('/invoices/booked/' . $id . '/pdf', 'booked_invoice_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a PDF of a draft invoice.
|
||||
* @param int $id
|
||||
* @return string
|
||||
*/
|
||||
public function getDraft(int $id): string
|
||||
{
|
||||
return $this->downloadPdf('/invoices/drafts/' . $id . '/pdf', 'draft_invoice_');
|
||||
}
|
||||
|
||||
private function downloadPdf(string $path, string $prefix): string
|
||||
{
|
||||
$unique_id = uniqid();
|
||||
$file_path = '/tmp/' . $prefix . $unique_id . '.pdf';
|
||||
$this->send_file_download_request(
|
||||
'/invoices/booked/' . $id . '/pdf',
|
||||
$path,
|
||||
'GET',
|
||||
'',
|
||||
false,
|
||||
'/tmp/invoice_' . $unique_id . '.pdf'
|
||||
$file_path
|
||||
);
|
||||
return '/tmp/invoice_' . $unique_id . '.pdf';
|
||||
return $file_path;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +249,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): void
|
||||
public function addOrderItemLines(orders_o $order, bool $use_itemized_discounts = false): void
|
||||
{
|
||||
// Get the order items
|
||||
$order_items = $order->getOrderItems($order->id);
|
||||
@@ -274,12 +274,12 @@ class economic_invoice_draft
|
||||
continue;
|
||||
}
|
||||
// Add the order item to the draft invoice
|
||||
self::addOrderItemLine($order_item, $department);
|
||||
self::addOrderItemLine($order_item, $department, false, $use_itemized_discounts);
|
||||
// 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 ($total_discount > 0) {
|
||||
if (!$use_itemized_discounts && $total_discount > 0) {
|
||||
// Add the discount to the invoice
|
||||
self::addProductDiscountLine($total_discount, $department['economic_department_id'] ?? 0, $department['dimension'] ?? 0);
|
||||
}
|
||||
@@ -295,7 +295,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): void
|
||||
public function addOrderItemLine(array $order_item, array $department, bool $show_discount = false, bool $use_itemized_discount = false): void
|
||||
{
|
||||
// Check if the order item is valid
|
||||
if (!isset($order_item['id'])) {
|
||||
@@ -308,14 +308,21 @@ class economic_invoice_draft
|
||||
$economic_department_id = $department['economic_department_id'] ?? 0;
|
||||
// Get the dimension id
|
||||
$economic_dimension_id = $department['economic_dimension_id'] ?? 0;
|
||||
$pricing = self::resolveOrderItemInvoicePricing($order_item);
|
||||
$discount_percentage = $use_itemized_discount
|
||||
? $this->resolveItemizedDiscountPercentageForInvoiceCurrency($pricing)
|
||||
: 0;
|
||||
// Add the order item to the draft invoice
|
||||
self::addProductLine(
|
||||
(string)$order_item['product']['economic_product_id'],
|
||||
(string)$order_item['product']['name'],
|
||||
(int)$order_item['quantity'] ?? 1,
|
||||
(int)($order_item['product']['price'] == 0 ? $order_item['price'] : $order_item['product']['price']),
|
||||
$use_itemized_discount
|
||||
? $pricing['invoice_unit_price']
|
||||
: (int)($order_item['product']['price'] == 0 ? $order_item['price'] : $order_item['product']['price']),
|
||||
(int)$economic_department_id,
|
||||
$economic_dimension_id
|
||||
$economic_dimension_id,
|
||||
$discount_percentage
|
||||
);
|
||||
|
||||
// Calculate the discount percentage (If the final price is 0, set the discount percentage to 100)
|
||||
@@ -378,11 +385,90 @@ class economic_invoice_draft
|
||||
return abs($price) < 0.00001;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return exact line discount data from the original department price and stored final item price.
|
||||
*
|
||||
* @return array{
|
||||
* original_unit_price:float,
|
||||
* final_unit_price:float,
|
||||
* invoice_unit_price:float,
|
||||
* quantity:float,
|
||||
* discount_unit_amount:float,
|
||||
* discount_total_amount:float,
|
||||
* discount_percentage:float,
|
||||
* has_discount:bool
|
||||
* }
|
||||
*/
|
||||
public static function resolveOrderItemInvoicePricing(array $order_item): array
|
||||
{
|
||||
$quantity = isset($order_item['quantity']) && is_numeric($order_item['quantity'])
|
||||
? (float)$order_item['quantity']
|
||||
: 0.0;
|
||||
$final_unit_price = isset($order_item['price']) && is_numeric($order_item['price'])
|
||||
? (float)$order_item['price']
|
||||
: 0.0;
|
||||
$original_unit_price = isset($order_item['product']['price']) && is_numeric($order_item['product']['price'])
|
||||
? (float)$order_item['product']['price']
|
||||
: 0.0;
|
||||
|
||||
if (abs($original_unit_price) < 0.00001) {
|
||||
$original_unit_price = $final_unit_price;
|
||||
}
|
||||
|
||||
$has_discount = $quantity > 0.0
|
||||
&& $final_unit_price > 0.0
|
||||
&& $original_unit_price > 0.0
|
||||
&& $final_unit_price < ($original_unit_price - 0.00001);
|
||||
|
||||
$discount_unit_amount = $has_discount
|
||||
? round($original_unit_price - $final_unit_price, 2)
|
||||
: 0.0;
|
||||
$discount_total_amount = round($discount_unit_amount * $quantity, 2);
|
||||
|
||||
return [
|
||||
'original_unit_price' => $original_unit_price,
|
||||
'final_unit_price' => $final_unit_price,
|
||||
'invoice_unit_price' => $has_discount ? $original_unit_price : $final_unit_price,
|
||||
'quantity' => $quantity,
|
||||
'discount_unit_amount' => $discount_unit_amount,
|
||||
'discount_total_amount' => $discount_total_amount,
|
||||
'discount_percentage' => $has_discount
|
||||
? round((1 - ($final_unit_price / $original_unit_price)) * 100, 10)
|
||||
: 0.0,
|
||||
'has_discount' => $has_discount,
|
||||
];
|
||||
}
|
||||
|
||||
public static function orderItemHasBillableDiscount(array $order_item): bool
|
||||
{
|
||||
return self::resolveOrderItemInvoicePricing($order_item)['has_discount'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the hidden e-conomic percentage from converted amounts so the rendered monetary discount stays exact.
|
||||
*
|
||||
* @param array{invoice_unit_price:float,final_unit_price:float,has_discount:bool,discount_percentage:float} $pricing
|
||||
*/
|
||||
private function resolveItemizedDiscountPercentageForInvoiceCurrency(array $pricing): float
|
||||
{
|
||||
if (!$pricing['has_discount']) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$converted_original = self::convertCurrency($pricing['invoice_unit_price']);
|
||||
$converted_final = self::convertCurrency($pricing['final_unit_price']);
|
||||
if ($converted_original <= 0.0 || $converted_final <= 0.0) {
|
||||
return $pricing['discount_percentage'];
|
||||
}
|
||||
|
||||
return round((1 - ($converted_final / $converted_original)) * 100, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a product line to the draft invoice
|
||||
* @note The lines won't be saved until the addLines() method is called.
|
||||
*/
|
||||
public function addProductLine(string $productNumber, string $description, float $quantity, float $unitNetPrice, int $economic_department_id, int $dimension): void
|
||||
public function addProductLine(string $productNumber, string $description, float $quantity, float $unitNetPrice, int $economic_department_id, int $dimension, float $discountPercentage = 0): void
|
||||
{
|
||||
// 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
|
||||
@@ -392,7 +478,7 @@ class economic_invoice_draft
|
||||
],
|
||||
'quantity' => $quantity,
|
||||
'unitNetPrice' => self::convertCurrency($unitNetPrice),
|
||||
'discountPercentage' => 0,
|
||||
'discountPercentage' => $discountPercentage,
|
||||
'description' => $description,
|
||||
];
|
||||
// If the department is set, add it to the line
|
||||
|
||||
+16
-7
@@ -22,12 +22,21 @@ class licenseplaterecognizer_info
|
||||
public array $webhooks;
|
||||
public int $total_calls;
|
||||
|
||||
public function __construct($data)
|
||||
public function __construct(mixed $data)
|
||||
{
|
||||
$this->version = $data->version;
|
||||
$this->usage = $data->usage ?? (object) ['calls' => 0];
|
||||
$this->license_key = $data->license_key;
|
||||
$this->webhooks = $data->webhooks;
|
||||
$this->total_calls = $data->total_calls;
|
||||
$values = is_array($data) ? $data : (is_object($data) ? get_object_vars($data) : []);
|
||||
$usage = $values['usage'] ?? (object)['calls' => 0];
|
||||
if (is_array($usage)) {
|
||||
$usage = (object)$usage;
|
||||
} elseif (!is_object($usage)) {
|
||||
$usage = (object)['calls' => 0];
|
||||
}
|
||||
$usage->calls = is_numeric($usage->calls ?? null) ? (int)$usage->calls : 0;
|
||||
|
||||
$this->version = (string)($values['version'] ?? '');
|
||||
$this->usage = $usage;
|
||||
$this->license_key = (string)($values['license_key'] ?? '');
|
||||
$this->webhooks = is_array($values['webhooks'] ?? null) ? $values['webhooks'] : [];
|
||||
$this->total_calls = is_numeric($values['total_calls'] ?? null) ? (int)$values['total_calls'] : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ require_once WD . '/objects/selfserve_wash_session_answers_o.php';
|
||||
require_once WD . '/objects/selfserve_wash_session_events_o.php';
|
||||
require_once WD . '/objects/selfserve_wash_session_tasks_o.php';
|
||||
require_once WD . '/objects/selfserve_wash_sessions_o.php';
|
||||
require_once WD . '/objects/subusers_o.php';
|
||||
|
||||
use classes\selfserve;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
@@ -63,6 +64,7 @@ use objects\selfserve_wash_session_answers_o;
|
||||
use objects\selfserve_wash_session_events_o;
|
||||
use objects\selfserve_wash_session_tasks_o;
|
||||
use objects\selfserve_wash_sessions_o;
|
||||
use objects\subusers_o;
|
||||
|
||||
class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
{
|
||||
@@ -76,7 +78,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
public function previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null, ?int $vehicleTypeIdOverride = null, array $options = []): array
|
||||
{
|
||||
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
|
||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
|
||||
$actorSubuserId = $this->resolveActorSubuserId($options);
|
||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number'], $actorSubuserId);
|
||||
|
||||
return $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null);
|
||||
}
|
||||
@@ -89,7 +92,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
{
|
||||
$options['debug'] = true;
|
||||
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
|
||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
|
||||
$actorSubuserId = $this->resolveActorSubuserId($options);
|
||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number'], $actorSubuserId);
|
||||
$response = $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null);
|
||||
$response['simulator_version'] = 2;
|
||||
$response['dry_run'] = true;
|
||||
@@ -103,12 +107,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null, bool $syncRelayState = true, array $options = []): array
|
||||
{
|
||||
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
|
||||
$actorSubuserId = $this->resolveActorSubuserId($options);
|
||||
$mutationResult = $this->withSessionMutationLock(
|
||||
$laneId,
|
||||
$snapshot['reg'],
|
||||
$snapshot['customer_number'],
|
||||
function () use ($laneId, $snapshot, $options): array {
|
||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
|
||||
$actorSubuserId,
|
||||
function () use ($laneId, $snapshot, $options, $actorSubuserId): array {
|
||||
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number'], $actorSubuserId);
|
||||
$createSession = (bool)($options['create_session'] ?? true);
|
||||
|
||||
if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
|
||||
@@ -139,10 +145,17 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$this->deriveBaseStatus($snapshot),
|
||||
(bool)$snapshot['allowed'],
|
||||
$this->buildSessionMetadata($snapshot),
|
||||
$actorSubuserId,
|
||||
);
|
||||
} else {
|
||||
$session->machine_type_id->set($snapshot['machine_type']['id'] ?? null);
|
||||
$session->customer_number->set($snapshot['customer_number']);
|
||||
if ($actorSubuserId !== null) {
|
||||
$currentSubuserId = $this->nullableInt($session->subuser_id->value());
|
||||
if ($currentSubuserId === null || $currentSubuserId === $actorSubuserId) {
|
||||
$session->subuser_id->set($actorSubuserId);
|
||||
}
|
||||
}
|
||||
$session->vehicle_id->set($snapshot['vehicle']['id'] ?? null);
|
||||
$session->vehicle_type_id->set($snapshot['vehicle_type_id']);
|
||||
$session->reg->set($snapshot['reg']);
|
||||
@@ -158,6 +171,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
|
||||
'allowed_services' => $snapshot['allowed_services'],
|
||||
'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
|
||||
'subuser_id' => $actorSubuserId,
|
||||
]);
|
||||
|
||||
return [
|
||||
@@ -202,6 +216,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$lane = (new selfserve())->lane($laneId);
|
||||
$effectiveReg = $normalizedReg ?? (string)$session->reg->value();
|
||||
$customerNumber = $session->customer_number->value() === null ? null : (int)$session->customer_number->value();
|
||||
$subuserId = $session->subuser_id->value() === null ? null : (int)$session->subuser_id->value();
|
||||
|
||||
if ($lane->getLaneStatus()->equals(selfserve_lane_status::AVAILABLE)) {
|
||||
$lane->setLaneStatus(selfserve_lane_status::OCCUPIED);
|
||||
@@ -227,11 +242,13 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'lane_id' => $laneId,
|
||||
'reg' => $effectiveReg,
|
||||
'customer_number' => $customerNumber,
|
||||
'subuser_id' => $subuserId,
|
||||
]);
|
||||
$actionContext = [
|
||||
'lane_id' => $laneId,
|
||||
'reg' => $effectiveReg,
|
||||
'customer_number' => $customerNumber,
|
||||
'subuser_id' => $subuserId,
|
||||
'session_id' => (int)$session->id,
|
||||
'source_payload' => $payload,
|
||||
];
|
||||
@@ -269,12 +286,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return $this->getSessionSummary((int)$session->id);
|
||||
}
|
||||
|
||||
public function hasMachineStartTriggeredForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null): bool
|
||||
public function hasMachineStartTriggeredForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $subuserId = null): bool
|
||||
{
|
||||
$normalizedReg = $reg === null || trim($reg) === '' ? null : selfserve::standardize_registration($reg);
|
||||
$session = $normalizedReg !== null
|
||||
? $this->findLatestOpenSession($laneId, $normalizedReg, $customerNumber)
|
||||
: $this->findLatestOpenSessionByLane($laneId, $customerNumber);
|
||||
? $this->findLatestOpenSession($laneId, $normalizedReg, $customerNumber, $subuserId)
|
||||
: $this->findLatestOpenSessionByLane($laneId, $customerNumber, $subuserId);
|
||||
|
||||
return $session->exists() && (bool)$session->machine_start_triggered->value();
|
||||
}
|
||||
@@ -403,10 +420,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
];
|
||||
}, (new selfserve_wash_session_events_o())->listBySession($sessionId));
|
||||
|
||||
$sessionPayload = $session->asArray();
|
||||
$sessionPayload['subuser'] = $this->formatSessionSubuser($this->nullableInt($session->subuser_id->value()));
|
||||
|
||||
return [
|
||||
'session' => $session->asArray(),
|
||||
'session' => $sessionPayload,
|
||||
'lane' => $lane->exists() ? $lane->asArray() : null,
|
||||
'machine_type' => $machineType,
|
||||
'subuser' => $sessionPayload['subuser'],
|
||||
'questions' => $answers,
|
||||
'tasks' => $tasks,
|
||||
'events' => $events,
|
||||
@@ -430,15 +451,16 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return $this->getSessionSummary((int)$session->id);
|
||||
}
|
||||
|
||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array
|
||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true, ?int $subuserId = null): ?array
|
||||
{
|
||||
$session = $reg !== null
|
||||
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber)
|
||||
: $this->findLatestOpenSessionByLane($laneId, $customerNumber);
|
||||
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber, $subuserId)
|
||||
: $this->findLatestOpenSessionByLane($laneId, $customerNumber, $subuserId);
|
||||
|
||||
if (!$session->exists()) {
|
||||
return null;
|
||||
}
|
||||
$resolvedSubuserId = $subuserId ?? $this->nullableInt($session->subuser_id->value());
|
||||
|
||||
$this->fillMissingWashStartedAtFromLaneRuntime($session, $laneId);
|
||||
|
||||
@@ -452,6 +474,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'lane_id' => $laneId,
|
||||
'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg),
|
||||
'customer_number' => $customerNumber ?? ($session->customer_number->value() === null ? null : (int)$session->customer_number->value()),
|
||||
'subuser_id' => $resolvedSubuserId,
|
||||
'order_id' => $orderId,
|
||||
]);
|
||||
|
||||
@@ -555,6 +578,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'vehicle' => $vehicleData,
|
||||
'reg' => $normalizedReg,
|
||||
'customer_number' => $resolvedCustomerNumber,
|
||||
'subuser_id' => $this->resolveActorSubuserId($options),
|
||||
'vehicle_type_id' => $vehicleTypeId,
|
||||
'answers' => [],
|
||||
'persisted_answers' => [],
|
||||
@@ -746,6 +770,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'vehicle' => $vehicleData,
|
||||
'reg' => $normalizedReg,
|
||||
'customer_number' => $resolvedCustomerNumber,
|
||||
'subuser_id' => $this->resolveActorSubuserId($options),
|
||||
'vehicle_type_id' => $vehicleTypeId,
|
||||
'answers' => $answers,
|
||||
'persisted_answers' => $persistedAnswers,
|
||||
@@ -854,6 +879,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'vehicle' => $snapshot['vehicle'],
|
||||
'reg' => $snapshot['reg'],
|
||||
'customer_number' => $snapshot['customer_number'],
|
||||
'subuser_id' => $snapshot['subuser_id'] ?? null,
|
||||
'vehicle_type_id' => $snapshot['vehicle_type_id'],
|
||||
'questions' => $snapshot['questions'],
|
||||
'tasks' => $snapshot['tasks'],
|
||||
@@ -876,6 +902,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'session' => null,
|
||||
'lane' => $snapshot['lane'],
|
||||
'machine_type' => $snapshot['machine_type'],
|
||||
'subuser_id' => $snapshot['subuser_id'] ?? null,
|
||||
'questions' => [],
|
||||
'tasks' => [],
|
||||
'events' => [],
|
||||
@@ -3259,13 +3286,47 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
(new selfserve_wash_session_events_o())->add($sessionId, $eventType, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $options
|
||||
*/
|
||||
protected function resolveActorSubuserId(array $options): ?int
|
||||
{
|
||||
return $this->nullableInt($options['subuser_id'] ?? null);
|
||||
}
|
||||
|
||||
protected function formatSessionSubuser(?int $subuserId): ?array
|
||||
{
|
||||
if ($subuserId === null || $subuserId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$subuser = (new subusers_o())->select($subuserId);
|
||||
if (!$subuser->exists()) {
|
||||
return [
|
||||
'id' => $subuserId,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)$subuser->id,
|
||||
'name' => $subuser->name->value() === null ? null : (string)$subuser->name->value(),
|
||||
'username' => $subuser->username->value() === null ? null : (string)$subuser->username->value(),
|
||||
];
|
||||
} catch (\Throwable) {
|
||||
return [
|
||||
'id' => $subuserId,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable():array<string,mixed> $callback
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
protected function withSessionMutationLock(int $laneId, string $reg, ?int $customerNumber, callable $callback): array
|
||||
protected function withSessionMutationLock(int $laneId, string $reg, ?int $customerNumber, ?int $subuserId, callable $callback): array
|
||||
{
|
||||
$lockKey = $this->sessionMutationLockKey($laneId, $reg, $customerNumber);
|
||||
$lockKey = $this->sessionMutationLockKey($laneId, $reg, $customerNumber, $subuserId);
|
||||
$lock = $this->acquireSessionMutationLock($lockKey);
|
||||
|
||||
try {
|
||||
@@ -3332,10 +3393,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
}
|
||||
}
|
||||
|
||||
protected function sessionMutationLockKey(int $laneId, string $reg, ?int $customerNumber): string
|
||||
protected function sessionMutationLockKey(int $laneId, string $reg, ?int $customerNumber, ?int $subuserId = null): string
|
||||
{
|
||||
return 'selfserve_session_mutation:' . (int)$laneId . ':' . sha1(
|
||||
selfserve::standardize_registration($reg) . ':' . ($customerNumber === null ? 'anon' : (string)(int)$customerNumber)
|
||||
selfserve::standardize_registration($reg)
|
||||
. ':' . ($customerNumber === null ? 'anon' : (string)(int)$customerNumber)
|
||||
. ':' . ($subuserId === null ? 'customer' : 'subuser:' . (int)$subuserId)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3345,6 +3408,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'allowed_services' => $snapshot['allowed_services'],
|
||||
'machine_available' => (bool)$snapshot['machine_available'],
|
||||
'machine_wash_enabled' => (bool)($snapshot['machine_wash_enabled'] ?? true),
|
||||
'subuser_id' => $snapshot['subuser_id'] ?? null,
|
||||
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
|
||||
'config_version_id' => $snapshot['config_version_id'] ?? null,
|
||||
'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
|
||||
@@ -3495,14 +3559,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return $vehicleTypeId > 0 ? $vehicleTypeId : null;
|
||||
}
|
||||
|
||||
protected function findLatestOpenSession(int $laneId, string $reg, ?int $customerNumber = null): selfserve_wash_sessions_o
|
||||
protected function findLatestOpenSession(int $laneId, string $reg, ?int $customerNumber = null, ?int $subuserId = null): selfserve_wash_sessions_o
|
||||
{
|
||||
$session = new selfserve_wash_sessions_o();
|
||||
$session->selectLatestOpenByLaneAndReg($laneId, $reg, $customerNumber);
|
||||
$session->selectLatestOpenByLaneAndReg($laneId, $reg, $customerNumber, $subuserId);
|
||||
return $session;
|
||||
}
|
||||
|
||||
protected function findLatestOpenSessionByLane(int $laneId, ?int $customerNumber = null): selfserve_wash_sessions_o
|
||||
protected function findLatestOpenSessionByLane(int $laneId, ?int $customerNumber = null, ?int $subuserId = null): selfserve_wash_sessions_o
|
||||
{
|
||||
$rows = (new selfserve_wash_sessions_o())->getFieldsWhere(
|
||||
[
|
||||
@@ -3510,6 +3574,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'completed_at' => null,
|
||||
'deleted_at' => null,
|
||||
...($customerNumber !== null ? ['customer_number' => $customerNumber] : []),
|
||||
...($subuserId !== null ? ['subuser_id' => $subuserId] : []),
|
||||
],
|
||||
['id', 'status']
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ interface selfserve_wash_flow_i
|
||||
|
||||
public function getLatestSessionSummary(int $laneId, string $reg): array;
|
||||
|
||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array;
|
||||
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true, ?int $subuserId = null): ?array;
|
||||
|
||||
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array;
|
||||
}
|
||||
|
||||
@@ -356,8 +356,8 @@ Purpose: operational lane control and relay management.
|
||||
| Method | Required params | Permissions | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /modules/self-serve/lane/status` | optional `lane_id`, default `1` | `modules_selfserve_lane_status_view` | Returns lane status, mode, state, wash timer, reg, and customer number. |
|
||||
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission, or customer `list_own_department_selfserve_vehicle_conditions` for scoped `START`, scoped `STOP`, and property gate commands | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`, `OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`. Customer `START` requires an enabled self-serve lane. Customer `STOP` and property gate commands require the customer's active wash in the lane department. |
|
||||
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed`, or customer `list_own_department_selfserve_vehicle_conditions` on an enabled self-serve lane | Writes allowed service names to the lane cache. This is still read-from-visible-tasks only; it does not activate relays. |
|
||||
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission, customer `add_own_department_selfserve_vehicle_conditions` for scoped `START`, or customer `list_own_department_selfserve_vehicle_conditions` for scoped `STOP` and property gate commands | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`, `OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`. Customer `START` requires an enabled self-serve lane. Customer `STOP` and property gate commands require the customer's active wash in the lane department. |
|
||||
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed`, or customer `add_own_department_selfserve_vehicle_conditions` on an enabled self-serve lane | Writes allowed service names to the lane cache. This is still read-from-visible-tasks only; it does not activate relays. |
|
||||
| `GET /modules/self-serve/lane/relay/machine_program_picker/status` | `lane_id` | `modules_selfserve_lane_relay_machine_program_picker_status_view` | Reads the Shelly MACHINE_PROGRAM_PICKER relay state (`on`/`off`) for the lane. |
|
||||
| `POST /modules/self-serve/lane/relay/machine_program_picker/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_program_picker_status_set` | Sets Shelly MACHINE_PROGRAM_PICKER relay state directly (`on=true/false`) and returns updated status. |
|
||||
| `GET /modules/self-serve/lane/relay/machine_cleaner/status` | `lane_id` | `modules_selfserve_lane_relay_machine_cleaner_status_view` | Reads the Shelly MACHINE_CLEANER relay state (`on`/`off`) for the lane. |
|
||||
@@ -439,7 +439,7 @@ Public methods:
|
||||
| `recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = [])` | The machine button or hardware event fired. | Full session summary after the machine-start event. |
|
||||
| `getSessionSummary(int $sessionId)` | You have a session id already. | Full session summary. |
|
||||
| `getLatestSessionSummary(int $laneId, string $reg)` | You want the latest session for a lane and vehicle. | Full session summary. |
|
||||
| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true)` | STOP has finished and you want to close the latest open session. Normal STOP passes `false` because it already disabled relays before opening the exit port. | Full summary, or `null` if no open session exists. |
|
||||
| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true, ?int $subuserId = null)` | STOP has finished and you want to close the latest open session. Normal STOP passes `false` because it already disabled relays before opening the exit port. Driver flows pass `subuserId` so same-customer drivers close only their own session. | Full summary, or `null` if no open session exists. |
|
||||
|
||||
Key implementation details:
|
||||
|
||||
|
||||
@@ -120,14 +120,15 @@ trait selfserve_lane_command_t
|
||||
/**
|
||||
* Machine-wash billing is based on the physical machine ON signal, not selector relay status.
|
||||
*/
|
||||
protected function hasMachineStartSignalForStop(): bool
|
||||
protected function hasMachineStartSignalForStop(?int $subuser_id = null): bool
|
||||
{
|
||||
try {
|
||||
$customerNumber = method_exists($this, 'getCustomerNumber') ? (int)$this->getCustomerNumber() : null;
|
||||
return (new selfserve_wash_flow())->hasMachineStartTriggeredForLane(
|
||||
(int)$this->id,
|
||||
method_exists($this, 'getLicensePlate') ? ($this->getLicensePlate() ?: null) : null,
|
||||
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null
|
||||
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null,
|
||||
$subuser_id
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
@@ -240,7 +241,9 @@ trait selfserve_lane_command_t
|
||||
$snapshot = (new selfserve_wash_flow())->previewVehicleEligibility(
|
||||
(int)$this->id,
|
||||
$reg,
|
||||
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null
|
||||
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null,
|
||||
null,
|
||||
['subuser_id' => $arguments?->subuser_id]
|
||||
);
|
||||
|
||||
return (bool)($snapshot['allowed'] ?? false);
|
||||
@@ -531,7 +534,7 @@ trait selfserve_lane_command_t
|
||||
/**
|
||||
* Finalize any active self-serve wash session before resetting lane state.
|
||||
*/
|
||||
protected function completeLatestSessionForStop(): void
|
||||
protected function completeLatestSessionForStop(?int $subuser_id = null): void
|
||||
{
|
||||
try {
|
||||
(new \modules\selfserve\classes\selfserve_wash_flow())->completeLatestSessionForLane(
|
||||
@@ -539,7 +542,8 @@ trait selfserve_lane_command_t
|
||||
$this->getLicensePlate() ?: null,
|
||||
$this->getCustomerNumber() ?: null,
|
||||
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null,
|
||||
false
|
||||
false,
|
||||
$subuser_id
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Session completion must not block STOP flow.
|
||||
@@ -711,7 +715,7 @@ trait selfserve_lane_command_t
|
||||
throw new \InvalidArgumentException("Customer number mismatch: Lane customer number " . $this->getCustomerNumber() . " does not match argument customer number " . $arguments->customer_number);
|
||||
}
|
||||
// Snapshot the physical machine ON signal before session completion/reset.
|
||||
$machine_start_triggered = $this->hasMachineStartSignalForStop();
|
||||
$machine_start_triggered = $this->hasMachineStartSignalForStop($arguments->subuser_id);
|
||||
// Turn off relays before any configured or default exit gate opens.
|
||||
$this->turnOffRelaysAfterStop();
|
||||
$this->runPublishedStudioActions(
|
||||
@@ -733,7 +737,7 @@ trait selfserve_lane_command_t
|
||||
// Only bill the machine wash product when the physical machine start signal was recorded.
|
||||
$this->addVehicleTypeProductToInvoiceIfNeeded($machine_start_triggered);
|
||||
// Finalize any active self-serve wash session for this lane
|
||||
$this->completeLatestSessionForStop();
|
||||
$this->completeLatestSessionForStop($arguments->subuser_id);
|
||||
// Reset the lane
|
||||
self::execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments());
|
||||
break;
|
||||
|
||||
@@ -260,8 +260,9 @@ trait selfserve_lane_invoice_t
|
||||
?int $draft_customer_number,
|
||||
?selfserve_lane_command_arguments $arguments = null
|
||||
): array {
|
||||
$session = $this->findOpenSelfServeSessionForAttachment($billing_customer_number);
|
||||
$subuser_id = $arguments?->subuser_id;
|
||||
$session = $this->findOpenSelfServeSessionForAttachment($billing_customer_number, $arguments?->subuser_id);
|
||||
$session_subuser_id = $session?->subuser_id->value() === null ? null : (int)$session->subuser_id->value();
|
||||
$subuser_id = $arguments?->subuser_id ?? $session_subuser_id;
|
||||
|
||||
return [
|
||||
'type' => attachment_content::OTHER_TYPE_SELF_SERVE_WASH,
|
||||
@@ -282,7 +283,7 @@ trait selfserve_lane_invoice_t
|
||||
];
|
||||
}
|
||||
|
||||
protected function findOpenSelfServeSessionForAttachment(int $billing_customer_number): ?selfserve_wash_sessions_o
|
||||
protected function findOpenSelfServeSessionForAttachment(int $billing_customer_number, ?int $subuser_id = null): ?selfserve_wash_sessions_o
|
||||
{
|
||||
$license_plate = trim((string)$this->getLicensePlate());
|
||||
if ($license_plate === '') {
|
||||
@@ -290,12 +291,31 @@ trait selfserve_lane_invoice_t
|
||||
}
|
||||
|
||||
try {
|
||||
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLaneAndReg(
|
||||
(int)$this->id,
|
||||
selfserve::standardize_registration($license_plate),
|
||||
$billing_customer_number > 0 ? $billing_customer_number : null,
|
||||
$subuser_id
|
||||
);
|
||||
if ($session->exists()) {
|
||||
return $session;
|
||||
}
|
||||
|
||||
if ($subuser_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLaneAndReg(
|
||||
(int)$this->id,
|
||||
selfserve::standardize_registration($license_plate),
|
||||
$billing_customer_number > 0 ? $billing_customer_number : null
|
||||
);
|
||||
return $session->exists() ? $session : null;
|
||||
if (!$session->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sessionSubuserId = $session->subuser_id->value() === null ? null : (int)$session->subuser_id->value();
|
||||
return $sessionSubuserId === null || $sessionSubuserId === $subuser_id ? $session : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use config\economic_admin_fee_monthly_c;
|
||||
use config\economic_admin_fee_order_c;
|
||||
use config\economic_fee_product_id_c;
|
||||
use Exception;
|
||||
use helpers\economic_invoice_draft;
|
||||
use objects\order_items_o;
|
||||
use objects\orders_o;
|
||||
use objects\users_o;
|
||||
@@ -427,7 +428,8 @@ class collected_order_invoices_o extends db
|
||||
if (empty($this->customer_number->value())) {
|
||||
throw new Exception('Customer number is not set');
|
||||
}
|
||||
(new economic())->assertCustomerNumberIsNotDraft((int)$this->customer_number->value());
|
||||
$economic = new economic();
|
||||
$economic->assertCustomerNumberIsNotDraft((int)$this->customer_number->value());
|
||||
if (!$ignore_closed) {
|
||||
// Require the invoice collection to be open
|
||||
self::requireOpen();
|
||||
@@ -481,7 +483,8 @@ class collected_order_invoices_o extends db
|
||||
{
|
||||
// Require the invoice collection to be selected
|
||||
self::requireSelected();
|
||||
(new economic())->assertCustomerNumberIsNotDraft((int)$this->customer_number->value());
|
||||
$economic = new economic();
|
||||
$economic->assertCustomerNumberIsNotDraft((int)$this->customer_number->value());
|
||||
// Check if the invoice draft already exists
|
||||
if (self::isDraftExisting() || self::isBooked()) {
|
||||
throw new Exception('Invoice draft already exists, or invoice collection is already booked');
|
||||
@@ -497,11 +500,12 @@ class collected_order_invoices_o extends db
|
||||
// Convert the date to the correct format
|
||||
$date = date('Y-m-d', strtotime($date));
|
||||
// Create the invoice draft
|
||||
$economic = new economic();
|
||||
$layout_number = $this->resolveInvoiceLayoutNumber($economic);
|
||||
$response = $economic->invoices->drafts->add(
|
||||
$this->customer_number->value(),
|
||||
self::getExternalId(),
|
||||
$date
|
||||
$date,
|
||||
$layout_number
|
||||
);
|
||||
// Validate the response, by checking if the external id is set
|
||||
if (empty($response->references->other)) {
|
||||
@@ -515,6 +519,70 @@ class collected_order_invoices_o extends db
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the e-conomic layout before draft creation.
|
||||
*
|
||||
* The default layout is the current non-discount layout. The discount layout
|
||||
* must be the manually duplicated e-conomic layout configured to show exact
|
||||
* monetary discounts in the Rabat column.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function resolveInvoiceLayoutNumber(economic $economic): int
|
||||
{
|
||||
if (!$this->hasDiscountedIncludedInvoiceItems()) {
|
||||
return (int)$economic->config->invoice_layout->getVariableValue();
|
||||
}
|
||||
|
||||
$layout_number = (int)$economic->config->invoice_discount_layout->getVariableValue();
|
||||
if ($layout_number <= 0) {
|
||||
throw new Exception('Discount invoice layout is not configured');
|
||||
}
|
||||
|
||||
return $layout_number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether any billable included product line should use the discount invoice layout.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function hasDiscountedIncludedInvoiceItems(): bool
|
||||
{
|
||||
foreach ( self::getOrders() as $order ) {
|
||||
$order_object = new orders_o();
|
||||
$order_object->select((int)$order['id']);
|
||||
$order_object->requireSelected();
|
||||
if (self::orderHasDiscountedIncludedInvoiceItems($order_object)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private static function orderHasDiscountedIncludedInvoiceItems(orders_o $order): bool
|
||||
{
|
||||
$order_items = $order->applyDepartmentPrices(
|
||||
$order->getOrderItems((int)$order->id),
|
||||
(int)$order->department_id->value()
|
||||
);
|
||||
|
||||
foreach ( $order_items as $order_item ) {
|
||||
if (empty($order_item['include_in_invoice'])) {
|
||||
continue;
|
||||
}
|
||||
if (economic_invoice_draft::orderItemHasBillableDiscount($order_item)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require the invoice draft to not already exist
|
||||
* @throws Exception If the request was not successful
|
||||
@@ -746,7 +814,14 @@ class collected_order_invoices_o extends db
|
||||
$order_object->requireSelected();
|
||||
$order_objects[] = $order_object;
|
||||
}
|
||||
$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency);
|
||||
$use_itemized_discounts = false;
|
||||
foreach ( $order_objects as $order_object ) {
|
||||
if (self::orderHasDiscountedIncludedInvoiceItems($order_object)) {
|
||||
$use_itemized_discounts = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency, 500, $use_itemized_discounts);
|
||||
$this->last_economic_transfer_metrics = [
|
||||
'draft_invoice_id' => $draft_id,
|
||||
'currency' => (string)$currency,
|
||||
|
||||
@@ -393,7 +393,7 @@ class departments_o extends db
|
||||
public function isModuleTimeBookingsEnabled(): bool
|
||||
{
|
||||
self::requireSelected();
|
||||
return (bool)$this->variables->getVariable('bookingsystem_time_based_enabled');
|
||||
return $this->variables->getVariable('bookingsystem_time_based_enabled') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\module_usage_schema_bootstrap;
|
||||
use classes\object_property;
|
||||
use Exception;
|
||||
use traits\db_object_t;
|
||||
@@ -40,6 +41,7 @@ class module_action_logs_o extends db
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
module_usage_schema_bootstrap::ensureTables();
|
||||
$this->setTable('module_usage_logs');
|
||||
}
|
||||
|
||||
@@ -131,4 +133,4 @@ class module_action_logs_o extends db
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use classes\db;
|
||||
use classes\customer_order_product_policy;
|
||||
use classes\object_property;
|
||||
use Exception;
|
||||
use RuntimeException;
|
||||
use traits\db_object_t;
|
||||
|
||||
class order_items_o extends db
|
||||
@@ -90,10 +91,42 @@ class order_items_o extends db
|
||||
$this->include_in_invoice = new object_property($this->table, $this->id, 'include_in_invoice', 'bool', false);
|
||||
}
|
||||
|
||||
private static function validatedRelatedItemId(int $orderId, mixed $relatedItemId): ?int
|
||||
{
|
||||
if ($relatedItemId === null || $relatedItemId === '' || (int)$relatedItemId === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedRelatedItemId = (int)$relatedItemId;
|
||||
if ($normalizedRelatedItemId < 1) {
|
||||
throw new RuntimeException('Related item ID must be a positive integer');
|
||||
}
|
||||
|
||||
global $db;
|
||||
$result = $db->query(
|
||||
"SELECT order_id
|
||||
FROM order_items
|
||||
WHERE id = {$normalizedRelatedItemId}
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1"
|
||||
);
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
throw new RuntimeException('Related order item not found');
|
||||
}
|
||||
|
||||
$row = $result->fetch_assoc();
|
||||
if ((int)($row['order_id'] ?? 0) !== $orderId) {
|
||||
throw new RuntimeException('Related order item must belong to the same order');
|
||||
}
|
||||
|
||||
return $normalizedRelatedItemId;
|
||||
}
|
||||
|
||||
public function add(int $order_id, int $product_id, string $reference, string $notes, int $cashier_id, int $price, int $quantity, $related_item_id = null): void
|
||||
{
|
||||
global $db, $response;
|
||||
try {
|
||||
$related_item_id = self::validatedRelatedItemId($order_id, $related_item_id);
|
||||
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||
// Avoid SQL injection
|
||||
$reference = $db->escape_string($reference);
|
||||
@@ -169,6 +202,7 @@ class order_items_o extends db
|
||||
try {
|
||||
// Get the order
|
||||
$order = (new orders_o())->getOrderById($order_id);
|
||||
$related_item_id = self::validatedRelatedItemId($order_id, $related_item_id);
|
||||
customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id);
|
||||
// Get the product price
|
||||
$product = (new products_o())->getProductById($product_id);
|
||||
|
||||
@@ -1259,6 +1259,7 @@ class orders_o extends db
|
||||
o.reg_1,
|
||||
o.reg_2,
|
||||
o.reg_3,
|
||||
o.completed_at,
|
||||
o.invoice_collection_id,
|
||||
CASE
|
||||
WHEN o.include_in_invoice IS NOT NULL THEN o.include_in_invoice
|
||||
@@ -1299,6 +1300,7 @@ class orders_o extends db
|
||||
o.reg_1,
|
||||
o.reg_2,
|
||||
o.reg_3,
|
||||
o.completed_at,
|
||||
o.invoice_collection_id,
|
||||
o.include_in_invoice,
|
||||
department_flags.exclude_from_invoicing
|
||||
@@ -1329,6 +1331,7 @@ class orders_o extends db
|
||||
'reg_1' => (string)($row['reg_1'] ?? ''),
|
||||
'reg_2' => (string)($row['reg_2'] ?? ''),
|
||||
'reg_3' => (string)($row['reg_3'] ?? ''),
|
||||
'completed_at' => !empty($row['completed_at']) ? (string)$row['completed_at'] : null,
|
||||
'excluded' => (int)($row['include_in_invoice_effective'] ?? 1) !== 1,
|
||||
'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null,
|
||||
'queue_status' => null,
|
||||
|
||||
@@ -23,6 +23,7 @@ class selfserve_wash_sessions_o extends db
|
||||
public object_property $department_id;
|
||||
public object_property $machine_type_id;
|
||||
public object_property $customer_number;
|
||||
public object_property $subuser_id;
|
||||
public object_property $vehicle_id;
|
||||
public object_property $vehicle_type_id;
|
||||
public object_property $reg;
|
||||
@@ -56,13 +57,15 @@ class selfserve_wash_sessions_o extends db
|
||||
?int $vehicleTypeId,
|
||||
selfserve_wash_session_status $status,
|
||||
bool $allowed = false,
|
||||
?array $metadata = null
|
||||
?array $metadata = null,
|
||||
?int $subuserId = null
|
||||
): self {
|
||||
$this->id = self::add_object([
|
||||
'lane_id' => $laneId,
|
||||
'department_id' => $departmentId,
|
||||
'machine_type_id' => $machineTypeId,
|
||||
'customer_number' => $customerNumber,
|
||||
'subuser_id' => $subuserId !== null && $subuserId > 0 ? $subuserId : null,
|
||||
'vehicle_id' => $vehicleId,
|
||||
'vehicle_type_id' => $vehicleTypeId,
|
||||
'reg' => selfserve::standardize_registration($reg),
|
||||
@@ -82,6 +85,7 @@ class selfserve_wash_sessions_o extends db
|
||||
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int', false);
|
||||
$this->machine_type_id = new object_property($this->table, $this->id, 'machine_type_id', 'int', false);
|
||||
$this->customer_number = new object_property($this->table, $this->id, 'customer_number', 'int', false);
|
||||
$this->subuser_id = new object_property($this->table, $this->id, 'subuser_id', 'int', false);
|
||||
$this->vehicle_id = new object_property($this->table, $this->id, 'vehicle_id', 'int', false);
|
||||
$this->vehicle_type_id = new object_property($this->table, $this->id, 'vehicle_type_id', 'int', false);
|
||||
$this->reg = new object_property($this->table, $this->id, 'reg', 'string', false);
|
||||
@@ -252,7 +256,7 @@ class selfserve_wash_sessions_o extends db
|
||||
return isset($this->id) && (int)$this->id > 0 && $this->exists();
|
||||
}
|
||||
|
||||
public function selectLatestOpenByLane(int $laneId, ?int $customerNumber = null): self
|
||||
public function selectLatestOpenByLane(int $laneId, ?int $customerNumber = null, ?int $subuserId = null): self
|
||||
{
|
||||
$filters = [
|
||||
'lane_id' => $laneId,
|
||||
@@ -262,6 +266,9 @@ class selfserve_wash_sessions_o extends db
|
||||
if ($customerNumber !== null) {
|
||||
$filters['customer_number'] = $customerNumber;
|
||||
}
|
||||
if ($subuserId !== null) {
|
||||
$filters['subuser_id'] = $subuserId;
|
||||
}
|
||||
$rows = $this->getFieldsWhere($filters, ['id', 'status']);
|
||||
$rows = array_values(array_filter(
|
||||
$rows,
|
||||
@@ -275,7 +282,7 @@ class selfserve_wash_sessions_o extends db
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function selectLatestOpenByLaneAndReg(int $laneId, string $reg, ?int $customerNumber = null): self
|
||||
public function selectLatestOpenByLaneAndReg(int $laneId, string $reg, ?int $customerNumber = null, ?int $subuserId = null): self
|
||||
{
|
||||
$filters = [
|
||||
'lane_id' => $laneId,
|
||||
@@ -286,6 +293,9 @@ class selfserve_wash_sessions_o extends db
|
||||
if ($customerNumber !== null) {
|
||||
$filters['customer_number'] = $customerNumber;
|
||||
}
|
||||
if ($subuserId !== null) {
|
||||
$filters['subuser_id'] = $subuserId;
|
||||
}
|
||||
$rows = $this->getFieldsWhere($filters, ['id', 'status']);
|
||||
$rows = array_values(array_filter(
|
||||
$rows,
|
||||
@@ -322,6 +332,7 @@ class selfserve_wash_sessions_o extends db
|
||||
'department_id' => (int)$this->department_id->value(),
|
||||
'machine_type_id' => $this->machine_type_id->value() === null ? null : (int)$this->machine_type_id->value(),
|
||||
'customer_number' => $this->customer_number->value() === null ? null : (int)$this->customer_number->value(),
|
||||
'subuser_id' => $this->subuser_id->value() === null ? null : (int)$this->subuser_id->value(),
|
||||
'vehicle_id' => $this->vehicle_id->value() === null ? null : (int)$this->vehicle_id->value(),
|
||||
'vehicle_type_id' => $this->vehicle_type_id->value() === null ? null : (int)$this->vehicle_type_id->value(),
|
||||
'reg' => (string)$this->reg->value(),
|
||||
|
||||
@@ -15,6 +15,7 @@ class subuser_grants_o extends db
|
||||
|
||||
public object_property $billing_customer_number;
|
||||
public object_property $subuser;
|
||||
public object_property $assigned_vehicle_id;
|
||||
public object_property $enabled;
|
||||
public object_property $note;
|
||||
public object_property $permissions;
|
||||
@@ -94,6 +95,7 @@ class subuser_grants_o extends db
|
||||
{
|
||||
$this->billing_customer_number = new object_property($this->table, $this->id, 'billing_customer_number', 'int');
|
||||
$this->subuser = new object_property($this->table, $this->id, 'subuser', 'int');
|
||||
$this->assigned_vehicle_id = new object_property($this->table, $this->id, 'assigned_vehicle_id', 'int');
|
||||
$this->enabled = new object_property($this->table, $this->id, 'enabled', 'bool');
|
||||
$this->note = new object_property($this->table, $this->id, 'note', 'string');
|
||||
$this->permissions = new object_property($this->table, $this->id, 'permissions', 'json');
|
||||
@@ -109,6 +111,7 @@ class subuser_grants_o extends db
|
||||
'id' => (int)$this->id,
|
||||
'billing_customer_number' => (int)$this->billing_customer_number->value(),
|
||||
'subuser' => (int)$this->subuser->value(),
|
||||
'assigned_vehicle_id' => $this->assigned_vehicle_id->value() !== null ? (int)$this->assigned_vehicle_id->value() : null,
|
||||
'enabled' => (bool)$this->enabled->value(),
|
||||
'note' => $this->note->value(),
|
||||
'permissions' => self::normalizePermissionsValue($this->permissions->value()),
|
||||
|
||||
@@ -24,8 +24,10 @@ class subusers_o extends db
|
||||
public object_property $password;
|
||||
public object_property $name;
|
||||
public object_property $email;
|
||||
public object_property $email_verified_at;
|
||||
public object_property $phone_country_code;
|
||||
public object_property $phone;
|
||||
public object_property $phone_verified_at;
|
||||
public object_property $two_factor_secret;
|
||||
public object_property $two_factor_enabled;
|
||||
public object_property $created_at;
|
||||
@@ -49,8 +51,10 @@ class subusers_o extends db
|
||||
$this->password = new object_property($this->table, $this->id, 'password', 'string');
|
||||
$this->name = new object_property($this->table, $this->id, 'name', 'string');
|
||||
$this->email = new object_property($this->table, $this->id, 'email', 'string');
|
||||
$this->email_verified_at = new object_property($this->table, $this->id, 'email_verified_at', 'timestamp', false);
|
||||
$this->phone_country_code = new object_property($this->table, $this->id, 'phone_country_code', 'int');
|
||||
$this->phone = new object_property($this->table, $this->id, 'phone', 'int');
|
||||
$this->phone_verified_at = new object_property($this->table, $this->id, 'phone_verified_at', 'timestamp', false);
|
||||
$this->two_factor_secret = new object_property($this->table, $this->id, 'two_factor_secret', 'string', false);
|
||||
$this->two_factor_enabled = new object_property($this->table, $this->id, 'two_factor_enabled', 'bool', false);
|
||||
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp');
|
||||
@@ -280,6 +284,21 @@ class subusers_o extends db
|
||||
}
|
||||
}
|
||||
|
||||
public function invalidateCurrentSetupToken(): void
|
||||
{
|
||||
self::requireSelected();
|
||||
|
||||
$object_id = 'subuser_setup_token';
|
||||
$reverse_cache_key = 'setup_token_for_subuser:' . (int)$this->id;
|
||||
$currentToken = $this->getCached($reverse_cache_key, $object_id);
|
||||
|
||||
if (is_string($currentToken) && $currentToken !== '') {
|
||||
$this->deleteCached('setup_token:' . $currentToken, $object_id);
|
||||
}
|
||||
|
||||
$this->deleteCached($reverse_cache_key, $object_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace objects;
|
||||
|
||||
use classes\db;
|
||||
use classes\customer_rule_product_restriction_schema_bootstrap;
|
||||
use classes\customer_name_cache_payload_builder;
|
||||
use classes\object_property;
|
||||
use classes\redis;
|
||||
@@ -268,15 +269,19 @@ class users_o extends db
|
||||
public function addAttribute(string $attribute, ?int $user_id = null): void
|
||||
{
|
||||
global $db;
|
||||
customer_rule_product_restriction_schema_bootstrap::ensureSchema();
|
||||
if ($user_id === null) {
|
||||
self::requireSelected();
|
||||
$user_id = $this->id;
|
||||
}
|
||||
$attribute = $db->escape_string($attribute);
|
||||
// To prevent two attributes with the same name for the same user, we will delete the old one if it exists
|
||||
$this->deleteAttribute($attribute, $user_id);
|
||||
$sql = "INSERT INTO customer_attributes (user_id, attribute) VALUES ($user_id, '$attribute')";
|
||||
$db->query($sql);
|
||||
$userIds = $this->attributeSiblingUserIds((int)$user_id);
|
||||
foreach ($userIds as $targetUserId) {
|
||||
$db->query(
|
||||
"INSERT IGNORE INTO customer_attributes (user_id, attribute)
|
||||
VALUES ({$targetUserId}, '{$attribute}')"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteAttribute(string $attribute, ?int $user_id = null): void
|
||||
@@ -286,29 +291,9 @@ class users_o extends db
|
||||
$user_id = $this->id;
|
||||
}
|
||||
$attribute = $db->escape_string($attribute);
|
||||
// If the user has a customer number, we will delete the attribute from all associated customers
|
||||
$customer_number = $this->customer_number->value();
|
||||
if (!empty($customer_number)) {
|
||||
$sql = "SELECT * FROM users WHERE customer_number = $customer_number";
|
||||
$result = $db->query($sql);
|
||||
$user_ids = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$user_ids[] = (int)$row['id'];
|
||||
}
|
||||
// Delete the attribute from all users
|
||||
foreach ($user_ids as $user_id) {
|
||||
$sql = "DELETE FROM customer_attributes WHERE user_id = $user_id AND attribute = '$attribute' LIMIT 1";
|
||||
$db->query($sql);
|
||||
}
|
||||
return;
|
||||
foreach ($this->attributeSiblingUserIds((int)$user_id) as $targetUserId) {
|
||||
$db->query("DELETE FROM customer_attributes WHERE user_id = {$targetUserId} AND attribute = '{$attribute}'");
|
||||
}
|
||||
// Make sure the attribute exists
|
||||
if (!$this->doesUserHaveAttribute((string)$attribute, (int)$user_id)) {
|
||||
return;
|
||||
}
|
||||
// If the attribute exists, delete it, if it does not exist, nothing will happen
|
||||
$sql = "DELETE FROM customer_attributes WHERE user_id = $user_id AND attribute = '$attribute' LIMIT 1";
|
||||
$db->query($sql);
|
||||
}
|
||||
|
||||
public function doesUserHaveAttribute(string $attribute, ?int $user_id = null): bool
|
||||
@@ -318,7 +303,8 @@ class users_o extends db
|
||||
$user_id = $this->id;
|
||||
}
|
||||
$attribute = $db->escape_string($attribute);
|
||||
$sql = "SELECT * FROM customer_attributes WHERE user_id = $user_id AND attribute = '$attribute' LIMIT 1";
|
||||
$userIds = $this->attributeSiblingUserIds((int)$user_id);
|
||||
$sql = "SELECT id FROM customer_attributes WHERE user_id IN (" . implode(',', $userIds) . ") AND attribute = '$attribute' LIMIT 1";
|
||||
$result = $db->query($sql);
|
||||
if ($result->num_rows > 0) {
|
||||
return true;
|
||||
@@ -736,13 +722,44 @@ class users_o extends db
|
||||
if ($user_id === null) {
|
||||
$user_id = $this->id;
|
||||
}
|
||||
$sql = "SELECT * FROM customer_attributes WHERE user_id = $user_id";
|
||||
$userIds = $this->attributeSiblingUserIds((int)$user_id);
|
||||
$sql = "SELECT MIN(id) AS id, MIN(user_id) AS user_id, attribute, NULL AS created_at
|
||||
FROM customer_attributes
|
||||
WHERE user_id IN (" . implode(',', $userIds) . ")
|
||||
GROUP BY attribute
|
||||
ORDER BY attribute";
|
||||
$result = $db->query($sql);
|
||||
$array = $db->fetch_all($result);
|
||||
$this->attributes = $array;
|
||||
return $this->attributes;
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private function attributeSiblingUserIds(int $userId): array
|
||||
{
|
||||
global $db;
|
||||
if ($userId < 1) {
|
||||
return [$userId];
|
||||
}
|
||||
$result = $db->query("SELECT customer_number FROM users WHERE id = {$userId} LIMIT 1");
|
||||
if (!$result || $result->num_rows < 1) {
|
||||
return [$userId];
|
||||
}
|
||||
$row = $result->fetch_assoc();
|
||||
$customerNumber = (int)($row['customer_number'] ?? 0);
|
||||
if ($customerNumber < 1) {
|
||||
return [$userId];
|
||||
}
|
||||
$siblings = $db->query("SELECT id FROM users WHERE customer_number = {$customerNumber}");
|
||||
$ids = [];
|
||||
if ($siblings) {
|
||||
while ($sibling = $siblings->fetch_assoc()) {
|
||||
$ids[] = (int)$sibling['id'];
|
||||
}
|
||||
}
|
||||
return $ids !== [] ? array_values(array_unique($ids)) : [$userId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all discounts for the user (Include)
|
||||
* @return void
|
||||
|
||||
+2427
-113
File diff suppressed because it is too large
Load Diff
@@ -1890,6 +1890,10 @@ class InvoicingPeriodRoute
|
||||
$draftOverlay['by_collection_id'] ?? [],
|
||||
$draftOverlay['by_customer_number'] ?? [],
|
||||
);
|
||||
$invoiceCollectionMetadata = self::debugGetTime(function () use ($types) {
|
||||
return self::getPeriodInvoiceCollectionMetadata($types);
|
||||
}, 'invoice_collection_metadata');
|
||||
$types = self::applyPeriodInvoiceStateOverlayToTypes($types, $invoiceCollectionMetadata);
|
||||
if ($includeInvoicePeriodFlags) {
|
||||
$types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) {
|
||||
return (new invoice_period_flag_service())->applyFlagsToPeriodTypes(
|
||||
@@ -2029,6 +2033,7 @@ class InvoicingPeriodRoute
|
||||
'meta' => $meta ?? [],
|
||||
'queue' => self::getDefaultQueueSummary(),
|
||||
'draft' => self::getDefaultDraftSummary(),
|
||||
'invoice_collections' => [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2046,11 +2051,15 @@ class InvoicingPeriodRoute
|
||||
|
||||
$departmentId = (int)$transaction->department_id->value();
|
||||
$invoiceCollectionId = (int)$transaction->invoice_collection_id->value();
|
||||
$booked = self::isTransactionBookedFromLocalState($transaction);
|
||||
$completedAt = !empty($transaction->completed_at->value())
|
||||
? (string)$transaction->completed_at->value()
|
||||
: null;
|
||||
return [
|
||||
'id' => $transaction->id,
|
||||
'date' => $transaction->created_at->value(),
|
||||
'amount' => $transaction->temporary_net_amount, // Use the temporary net amount set earlier
|
||||
'booked' => self::isTransactionBookedFromLocalState($transaction),
|
||||
'booked' => $booked,
|
||||
'department_id' => $departmentId,
|
||||
'customer_number' => (int)$transaction->customer_id->value(),
|
||||
'reference' => (string)$transaction->reference->value(),
|
||||
@@ -2059,8 +2068,10 @@ class InvoicingPeriodRoute
|
||||
'reg_1' => (string)$transaction->reg_1->value(),
|
||||
'reg_2' => (string)$transaction->reg_2->value(),
|
||||
'reg_3' => (string)$transaction->reg_3->value(),
|
||||
'completed_at' => $completedAt,
|
||||
'excluded' => !$transaction->isIncludedInInvoicing(),
|
||||
'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null,
|
||||
'invoice_state' => self::periodOrderState($booked, $completedAt),
|
||||
'queue_status' => null,
|
||||
'queue_job_id' => null,
|
||||
];
|
||||
@@ -2082,8 +2093,13 @@ class InvoicingPeriodRoute
|
||||
'reg_1' => (string)($transaction['reg_1'] ?? ''),
|
||||
'reg_2' => (string)($transaction['reg_2'] ?? ''),
|
||||
'reg_3' => (string)($transaction['reg_3'] ?? ''),
|
||||
'completed_at' => !empty($transaction['completed_at']) ? (string)$transaction['completed_at'] : null,
|
||||
'excluded' => (bool)($transaction['excluded'] ?? ((int)($transaction['include_in_invoice_effective'] ?? 1) !== 1)),
|
||||
'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null,
|
||||
'invoice_state' => self::periodOrderState(
|
||||
(bool)($transaction['booked'] ?? false),
|
||||
!empty($transaction['completed_at']) ? (string)$transaction['completed_at'] : null
|
||||
),
|
||||
'queue_status' => $transaction['queue_status'] ?? null,
|
||||
'queue_job_id' => $transaction['queue_job_id'] ?? null,
|
||||
];
|
||||
@@ -2121,6 +2137,15 @@ class InvoicingPeriodRoute
|
||||
return self::$periodOrderBookedCache[$orderId] = !empty($row['invoice_id'] ?? null);
|
||||
}
|
||||
|
||||
private static function periodOrderState(bool $booked, ?string $completedAt): string
|
||||
{
|
||||
if ($booked) {
|
||||
return 'economic_booked';
|
||||
}
|
||||
|
||||
return !empty($completedAt) ? 'closed' : 'open';
|
||||
}
|
||||
|
||||
private static function checkRequiresAction(array $parsed_transactions, bool $requires_action): bool
|
||||
{
|
||||
// If requires_action is already set to true, return true
|
||||
@@ -2625,6 +2650,199 @@ class InvoicingPeriodRoute
|
||||
return $customer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the collected-invoice rows referenced by the complete period payload in one local query.
|
||||
* No e-conomic calls are allowed from the period endpoint.
|
||||
*
|
||||
* @return array<int,array<string,mixed>> Rows keyed by collected invoice ID.
|
||||
*/
|
||||
private static function getPeriodInvoiceCollectionMetadata(array $types): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$invoiceCollectionIds = [];
|
||||
foreach ($types as $customers) {
|
||||
if (!is_array($customers)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($customers as $customer) {
|
||||
if (!is_array($customer)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (($customer['transactions'] ?? []) as $transaction) {
|
||||
$invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0);
|
||||
if ($invoiceCollectionId > 0) {
|
||||
$invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['queue', 'draft'] as $summaryKey) {
|
||||
foreach (($customer[$summaryKey]['invoice_collection_ids'] ?? []) as $invoiceCollectionId) {
|
||||
$invoiceCollectionId = (int)$invoiceCollectionId;
|
||||
if ($invoiceCollectionId > 0) {
|
||||
$invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($invoiceCollectionIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sql = 'SELECT id, customer_number, name, notes, processor, external_id, booked_invoice_id, '
|
||||
. 'po_number, error_message, closed_at, created_at, updated_at '
|
||||
. 'FROM collected_order_invoices '
|
||||
. 'WHERE id IN (' . implode(',', array_map('intval', array_values($invoiceCollectionIds))) . ') '
|
||||
. 'ORDER BY id';
|
||||
|
||||
try {
|
||||
$result = $db->query($sql);
|
||||
if (!$result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$metadata = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$invoiceCollectionId = (int)($row['id'] ?? 0);
|
||||
if ($invoiceCollectionId < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row['id'] = $invoiceCollectionId;
|
||||
$row['invoice_collection_id'] = $invoiceCollectionId;
|
||||
$row['customer_number'] = (int)($row['customer_number'] ?? 0);
|
||||
$row['processor'] = (int)($row['processor'] ?? 0);
|
||||
$row['booked_invoice_id'] = !empty($row['booked_invoice_id'])
|
||||
? (int)$row['booked_invoice_id']
|
||||
: null;
|
||||
$row['state'] = self::periodInvoiceCollectionState($row);
|
||||
$metadata[$invoiceCollectionId] = $row;
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static function periodInvoiceCollectionState(array $invoiceCollection): string
|
||||
{
|
||||
if (!empty($invoiceCollection['booked_invoice_id'])) {
|
||||
return 'economic_booked';
|
||||
}
|
||||
|
||||
if (!defined('\\objects\\ECONOMIC_PROCESSOR')) {
|
||||
class_exists(collected_order_invoices_o::class);
|
||||
}
|
||||
$economicProcessor = defined('\\objects\\ECONOMIC_PROCESSOR')
|
||||
? (int)constant('\\objects\\ECONOMIC_PROCESSOR')
|
||||
: 1;
|
||||
if (
|
||||
(int)($invoiceCollection['processor'] ?? 0) === $economicProcessor
|
||||
&& trim((string)($invoiceCollection['external_id'] ?? '')) !== ''
|
||||
&& trim((string)($invoiceCollection['error_message'] ?? '')) === ''
|
||||
) {
|
||||
return 'economic_draft';
|
||||
}
|
||||
|
||||
return !empty($invoiceCollection['closed_at']) ? 'closed' : 'open';
|
||||
}
|
||||
|
||||
private static function applyPeriodInvoiceStateOverlayToTypes(array $types, array $invoiceCollectionsById): array
|
||||
{
|
||||
foreach ($types as $type => $customers) {
|
||||
if (!is_array($customers)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$types[$type] = array_map(static function ($customer) use ($invoiceCollectionsById) {
|
||||
if (!is_array($customer)) {
|
||||
return $customer;
|
||||
}
|
||||
|
||||
return self::applyPeriodInvoiceStateOverlayToCustomer($customer, $invoiceCollectionsById);
|
||||
}, $customers);
|
||||
}
|
||||
|
||||
return $types;
|
||||
}
|
||||
|
||||
private static function applyPeriodInvoiceStateOverlayToCustomer(array $customer, array $invoiceCollectionsById): array
|
||||
{
|
||||
$customerNumber = (int)($customer['customer_number'] ?? 0);
|
||||
$collectionStats = [];
|
||||
$invoiceCollectionIds = [];
|
||||
$transactions = [];
|
||||
|
||||
foreach (($customer['transactions'] ?? []) as $transaction) {
|
||||
if (!is_array($transaction)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0);
|
||||
$invoiceCollection = $invoiceCollectionsById[$invoiceCollectionId] ?? null;
|
||||
$canUseCollection = is_array($invoiceCollection)
|
||||
&& (int)($invoiceCollection['customer_number'] ?? 0) === $customerNumber;
|
||||
|
||||
if ($canUseCollection) {
|
||||
$transaction['invoice_state'] = (string)$invoiceCollection['state'];
|
||||
$transaction['booked'] = $transaction['invoice_state'] === 'economic_booked';
|
||||
$invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId;
|
||||
$collectionStats[$invoiceCollectionId] = $collectionStats[$invoiceCollectionId] ?? [
|
||||
'order_ids' => [],
|
||||
'total_net_amount' => 0.0,
|
||||
];
|
||||
$orderId = (int)($transaction['id'] ?? 0);
|
||||
if ($orderId > 0) {
|
||||
$collectionStats[$invoiceCollectionId]['order_ids'][$orderId] = $orderId;
|
||||
}
|
||||
$collectionStats[$invoiceCollectionId]['total_net_amount'] += (float)($transaction['amount'] ?? 0);
|
||||
} else {
|
||||
$transaction['invoice_state'] = (string)($transaction['invoice_state'] ?? self::periodOrderState(
|
||||
(bool)($transaction['booked'] ?? false),
|
||||
!empty($transaction['completed_at']) ? (string)$transaction['completed_at'] : null
|
||||
));
|
||||
}
|
||||
|
||||
$transactions[] = $transaction;
|
||||
}
|
||||
|
||||
foreach (['queue', 'draft'] as $summaryKey) {
|
||||
foreach (($customer[$summaryKey]['invoice_collection_ids'] ?? []) as $invoiceCollectionId) {
|
||||
$invoiceCollectionId = (int)$invoiceCollectionId;
|
||||
if (
|
||||
$invoiceCollectionId > 0
|
||||
&& isset($invoiceCollectionsById[$invoiceCollectionId])
|
||||
&& (int)($invoiceCollectionsById[$invoiceCollectionId]['customer_number'] ?? 0) === $customerNumber
|
||||
) {
|
||||
$invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$invoiceCollections = [];
|
||||
foreach ($invoiceCollectionIds as $invoiceCollectionId) {
|
||||
$invoiceCollection = $invoiceCollectionsById[$invoiceCollectionId];
|
||||
$stats = $collectionStats[$invoiceCollectionId] ?? [
|
||||
'order_ids' => [],
|
||||
'total_net_amount' => 0.0,
|
||||
];
|
||||
$orderIds = array_values($stats['order_ids']);
|
||||
$invoiceCollection['order_ids'] = $orderIds;
|
||||
$invoiceCollection['order_count'] = count($orderIds);
|
||||
$invoiceCollection['total_net_amount'] = (float)$stats['total_net_amount'];
|
||||
$invoiceCollections[] = $invoiceCollection;
|
||||
}
|
||||
|
||||
$customer['transactions'] = $transactions;
|
||||
$customer['invoice_collections'] = $invoiceCollections;
|
||||
return $customer;
|
||||
}
|
||||
|
||||
private static function customerSupportsCustomerLevelQueueBlocking(array $customer): bool
|
||||
{
|
||||
$meta = $customer['meta'] ?? [];
|
||||
|
||||
@@ -7,6 +7,7 @@ use classes\economic;
|
||||
use classes\email;
|
||||
use classes\release_manager;
|
||||
use classes\recaptcha;
|
||||
use classes\security_policy_service;
|
||||
use classes\slack;
|
||||
use classes\totp;
|
||||
use classes\virkdata;
|
||||
@@ -20,6 +21,8 @@ use objects\subusers_o;
|
||||
use objects\passkeys_o;
|
||||
use traits\route_t;
|
||||
|
||||
require_once WD . '/classes/security_policy_service.php';
|
||||
|
||||
class authRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -119,6 +122,7 @@ class authRoute
|
||||
$user = (new users_o())->getUserByCustomerNumber($data['customer_number']);
|
||||
if (!$user->exists() || !$user->hasPassword()) {
|
||||
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Customer number: ' . $data['customer_number']);
|
||||
$this->observeLoginFailure('customer', $data['customer_number'], ['reason' => 'missing_user_or_password']);
|
||||
$response->error('Invalid credentials', 401);
|
||||
}
|
||||
|
||||
@@ -134,6 +138,7 @@ class authRoute
|
||||
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_SUCCESS', 'Customer number: ' . $data['customer_number']);
|
||||
} else {
|
||||
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Customer number: ' . $data['customer_number']);
|
||||
$this->observeLoginFailure('customer', $data['customer_number'], ['reason' => 'invalid_password']);
|
||||
$response->error('Invalid credentials', 401);
|
||||
}
|
||||
|
||||
@@ -346,6 +351,7 @@ class authRoute
|
||||
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_SUCCESS', 'Employee number: ' . $data['user_id']);
|
||||
} else {
|
||||
(new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Employee number: ' . $data['user_id']);
|
||||
$this->observeLoginFailure('employee', (string)$data['user_id'], ['reason' => 'invalid_credentials']);
|
||||
$response->error('Invalid credentials', 401);
|
||||
}
|
||||
|
||||
@@ -850,6 +856,7 @@ class authRoute
|
||||
}
|
||||
if ($passkey === null) {
|
||||
(new logs_o())->add('auth', 'global', 1, $user_id_hint, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Unknown credential');
|
||||
$this->observeLoginFailure('passkey', $credentialId, ['reason' => 'unknown_credential', 'user_id_hint' => $user_id_hint]);
|
||||
$response->error('Invalid credential', 404);
|
||||
}
|
||||
|
||||
@@ -858,6 +865,7 @@ class authRoute
|
||||
$ok = $wa->verifyAssertion($credentialJson, $challenge_token, $passkey, $host);
|
||||
if (!$ok) {
|
||||
(new logs_o())->add('auth', 'global', 1, (int)$passkey->user_id->value(), 'AUTH_PASSKEY_VERIFY_FAILURE', 'Assertion verification failed');
|
||||
$this->observeLoginFailure('passkey', $credentialId, ['reason' => 'assertion_failed', 'user_id_hint' => $user_id_hint]);
|
||||
$response->error('Invalid passkey assertion', 401);
|
||||
}
|
||||
|
||||
@@ -869,6 +877,7 @@ class authRoute
|
||||
|| ($challengePrincipalType === 'user' && $is_subuser)
|
||||
) {
|
||||
(new logs_o())->add('auth', 'global', 1, $user_id_hint, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Credential principal mismatch');
|
||||
$this->observeLoginFailure('passkey', $credentialId, ['reason' => 'principal_mismatch', 'user_id_hint' => $user_id_hint]);
|
||||
$token_o->delete($challenge_token);
|
||||
$this->clearPasskeyChallengePrincipal($challenge_token);
|
||||
$response->error('Invalid credential', 401);
|
||||
@@ -1054,6 +1063,15 @@ class authRoute
|
||||
(new logs_o())->add('auth', 'global', 0, 0, $action, $message);
|
||||
}
|
||||
|
||||
private function observeLoginFailure(string $principalType, string|int $identifier, array $metadata = []): void
|
||||
{
|
||||
try {
|
||||
(new security_policy_service())->observeLoginFailure($principalType, $identifier, $metadata);
|
||||
} catch (\Throwable) {
|
||||
// Security observation must not change authentication responses.
|
||||
}
|
||||
}
|
||||
|
||||
private function appendRuntimeConfig(array $payload): array
|
||||
{
|
||||
$payload['runtime_config'] = array_replace_recursive(
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\cron_scheduler;
|
||||
use classes\cron_worker;
|
||||
use classes\release_manager;
|
||||
use objects\logs_o;
|
||||
use Throwable;
|
||||
use traits\route_t;
|
||||
@@ -36,6 +38,51 @@ class cronRoute
|
||||
'superuser_cron_view' => 'View cron task schedule, run status, estimates, and history',
|
||||
]);
|
||||
|
||||
$this->get('/superuser/cron/workers', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_cron_view');
|
||||
$parameters = $this->getParametersAsArray();
|
||||
try {
|
||||
$response->success((new release_manager())->cronWorkerStatus($parameters));
|
||||
} catch (Throwable $throwable) {
|
||||
$workers = (new cron_worker())->listWorkers();
|
||||
$workers['deployment'] = [
|
||||
'ok' => false,
|
||||
'error' => $throwable->getMessage(),
|
||||
];
|
||||
$workers['state'] = 'failed';
|
||||
$workers['issues'] = [[
|
||||
'code' => 'status_load_failed',
|
||||
'severity' => 'danger',
|
||||
'message' => $throwable->getMessage(),
|
||||
]];
|
||||
$response->success($workers);
|
||||
}
|
||||
}, [
|
||||
'superuser_cron_view' => 'View cron worker deployment and heartbeat state',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/cron/workers/deploy', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_cron_manage');
|
||||
$this->requirePermission('superuser_coolify_manage');
|
||||
try {
|
||||
$result = (new release_manager())->deployCronWorker(
|
||||
$this->getParametersAsArray(),
|
||||
$this->actorUserId()
|
||||
);
|
||||
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_WORKER_DEPLOY', 'Deployed cron worker');
|
||||
$response->success($result, 202);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
}, [
|
||||
'superuser_cron_manage' => 'Deploy and configure cron workers',
|
||||
'superuser_coolify_manage' => 'Deploy Coolify-managed infrastructure resources',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/cron/run', function () {
|
||||
global $response;
|
||||
|
||||
@@ -47,14 +94,13 @@ class cronRoute
|
||||
}
|
||||
|
||||
try {
|
||||
$run = (new cron_scheduler())->runTask(
|
||||
$run = (new cron_scheduler())->queueTaskRun(
|
||||
$task_id,
|
||||
'manual',
|
||||
$this->actorUserId(),
|
||||
$this->toBool($parameters['force'] ?? false, false)
|
||||
);
|
||||
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_JOB_RUN', 'Ran cron task: ' . $task_id);
|
||||
$response->success($run);
|
||||
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_JOB_QUEUED', 'Queued cron task: ' . $task_id);
|
||||
$response->success($run, 202);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\customer_rule_product_restriction_service;
|
||||
use objects\logs_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
@@ -44,9 +45,10 @@ class customerAttributes
|
||||
$actor_id = $user !== false ? (int)$user->id : (int)($subuser->id ?? 0);
|
||||
(new logs_o())->add('customer_attributes', 'global', 1, $actor_id, 'LIST_CUSTOMER_ATTRIBUTES', 'Successfully listed customer attributes');
|
||||
// Return the list of customer notes
|
||||
$response->success(
|
||||
$response->success((new customer_rule_product_restriction_service())->enrichAttributes(
|
||||
(int)$target_user->customer_number->value(),
|
||||
$target_user->getUserAttributes()
|
||||
);
|
||||
));
|
||||
} else {
|
||||
// Log the incident
|
||||
(new logs_o())->add('customer_attributes', 'global', 1, 0, 'LIST_CUSTOMER_ATTRIBUTES', 'No user found, or invalid session');
|
||||
@@ -76,6 +78,9 @@ class customerAttributes
|
||||
if (!isset($data['attribute'])) {
|
||||
$response->error('Attribute is required', 400);
|
||||
}
|
||||
if (!in_array((string)$data['attribute'], customer_rule_product_restriction_service::SUPPORTED_ATTRIBUTES, true)) {
|
||||
$response->error('Unsupported customer attribute', 422);
|
||||
}
|
||||
// Add the note to the customer
|
||||
(new users_o())->automaticGetTargetUserFromRequest()->addAttribute((string)$data['attribute']);
|
||||
// Log the incident
|
||||
@@ -111,6 +116,9 @@ class customerAttributes
|
||||
if (!isset($data['attribute'])) {
|
||||
$response->error('Attribute is required', 400);
|
||||
}
|
||||
if (!in_array((string)$data['attribute'], customer_rule_product_restriction_service::SUPPORTED_ATTRIBUTES, true)) {
|
||||
$response->error('Unsupported customer attribute', 422);
|
||||
}
|
||||
// Check if the user exists
|
||||
if (!(new users_o())->automaticGetTargetUserFromRequest()->exists()) {
|
||||
$response->error('Customer not found', 400);
|
||||
|
||||
@@ -15,6 +15,30 @@ class customerTimeBookingsRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
private function requirePublicTimeBookingsDepartment(string $parameterName = 'id'): departments_o
|
||||
{
|
||||
global $response;
|
||||
|
||||
if (!$this->isParametersSet([$parameterName])) {
|
||||
$response->error('Missing ' . $parameterName . ' parameter', 400);
|
||||
}
|
||||
$this->requireType((int)$this->getParameter($parameterName), $this->type_int());
|
||||
$this->requireMinValue((int)$this->getParameter($parameterName), 1);
|
||||
$this->requireSameLength($this->getParameter($parameterName), (int)$this->getParameter($parameterName));
|
||||
|
||||
$department = new departments_o();
|
||||
$department->select((int)$this->getParameter($parameterName));
|
||||
if (!$department->exists()) {
|
||||
$response->error('Department not found', 404);
|
||||
}
|
||||
|
||||
if (!$department->isModuleTimeBookingsEnabled()) {
|
||||
$response->error('Department time bookings are not enabled', 404);
|
||||
}
|
||||
|
||||
return $department;
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
global /** @var response $response */
|
||||
@@ -22,26 +46,60 @@ class customerTimeBookingsRoute
|
||||
$router, $response;
|
||||
|
||||
|
||||
/** Guest Time Bookings -> Departments -> GET */
|
||||
$this->get('/department/timebookings/departments/public', function () {
|
||||
global $response;
|
||||
|
||||
$departments = new departments_o();
|
||||
$response->success(
|
||||
$departments
|
||||
->setSearchableFields([
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'visible',
|
||||
'archived',
|
||||
'longitude',
|
||||
'latitude',
|
||||
])
|
||||
->listObjectsWithPaginationIfSet(
|
||||
function ($department): array {
|
||||
return [
|
||||
'id' => (int)$department['id'],
|
||||
'name' => (string)$department['name'],
|
||||
'description' => (string)$department['description'],
|
||||
'address' => (string)$department['description'],
|
||||
'longitude' => (float)$department['longitude'],
|
||||
'latitude' => (float)$department['latitude'],
|
||||
'order_priority' => (int)$department['order_priority'],
|
||||
'time_booking_enabled' => true,
|
||||
];
|
||||
},
|
||||
$departments->forceRestrictFilters([
|
||||
'visible' => 1,
|
||||
'archived' => 0,
|
||||
]),
|
||||
[],
|
||||
"EXISTS (
|
||||
SELECT 1
|
||||
FROM department_variables time_booking_variables
|
||||
WHERE time_booking_variables.department_id = departments.id
|
||||
AND time_booking_variables.variable = 'bookingsystem_time_based_enabled'
|
||||
AND time_booking_variables.value = 'true'
|
||||
)"
|
||||
)
|
||||
);
|
||||
},
|
||||
[
|
||||
// No permissions required for this endpoint, as it is for guests
|
||||
]
|
||||
);
|
||||
|
||||
/** Guest Time Bookings -> Opening Hours -> GET */
|
||||
$this->get('/department/timebookings/opening-hours/public', function () {
|
||||
global $response;
|
||||
if (!self::isParametersSet(['id'])) {
|
||||
$response->error('Missing id parameter', 400);
|
||||
}
|
||||
self::requireType((int)self::getParameter('id'), self::type_int());
|
||||
self::requireMinValue((int)self::getParameter('id'), 1);
|
||||
self::requireSameLength(self::getParameter('id'), (int)self::getParameter('id'));
|
||||
// Check if the department exists
|
||||
$department = new \objects\departments_o();
|
||||
$department->select((int)self::getParameter('id'));
|
||||
if (!$department->exists()) {
|
||||
$response->error('Department not found', 404);
|
||||
}
|
||||
// Check if the department has time bookings enabled
|
||||
$variable = $department->isModuleTimeBookingsEnabled();
|
||||
if (!$variable) {
|
||||
$response->error('Department time bookings are not enabled', 404);
|
||||
}
|
||||
$this->requirePublicTimeBookingsDepartment();
|
||||
|
||||
$department_time_bookings_opening_hours = new department_time_bookings_opening_hours_o();
|
||||
$department_time_bookings_opening_hours->selectByDepartment(
|
||||
(int)self::getParameter('id')
|
||||
@@ -79,23 +137,8 @@ class customerTimeBookingsRoute
|
||||
/** Guest Time Bookings -> Types -> GET */
|
||||
$this->get('/department/timebookings/types/public', function () {
|
||||
global $response;
|
||||
if (!self::isParametersSet(['id'])) {
|
||||
$response->error('Missing id parameter', 400);
|
||||
}
|
||||
self::requireType((int)self::getParameter('id'), self::type_int());
|
||||
self::requireMinValue((int)self::getParameter('id'), 1);
|
||||
self::requireSameLength(self::getParameter('id'), (int)self::getParameter('id'));
|
||||
// Check if the department exists
|
||||
$department = new \objects\departments_o();
|
||||
$department->select((int)self::getParameter('id'));
|
||||
if (!$department->exists()) {
|
||||
$response->error('Department not found', 404);
|
||||
}
|
||||
// Check if the department has time bookings enabled
|
||||
$variable = $department->isModuleTimeBookingsEnabled();
|
||||
if (!$variable) {
|
||||
$response->error('Department time bookings are not enabled', 404);
|
||||
}
|
||||
$this->requirePublicTimeBookingsDepartment();
|
||||
|
||||
$department_time_bookings_types = new department_time_bookings_types_o();
|
||||
$booking_types_array = $department_time_bookings_types->getFieldsWhere(
|
||||
[
|
||||
@@ -134,23 +177,8 @@ class customerTimeBookingsRoute
|
||||
* https://api.truckwash.dk:4433/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
|
||||
*/
|
||||
global $response;
|
||||
if (!self::isParametersSet(['id'])) {
|
||||
$response->error('Missing id parameter', 400);
|
||||
}
|
||||
self::requireType((int)self::getParameter('id'), self::type_int());
|
||||
self::requireMinValue((int)self::getParameter('id'), 1);
|
||||
self::requireSameLength(self::getParameter('id'), (int)self::getParameter('id'));
|
||||
// Check if the department exists
|
||||
$department = new \objects\departments_o();
|
||||
$department->select((int)self::getParameter('id'));
|
||||
if (!$department->exists()) {
|
||||
$response->error('Department not found', 404);
|
||||
}
|
||||
// Check if the department has time bookings enabled
|
||||
$variable = $department->isModuleTimeBookingsEnabled();
|
||||
if (!$variable) {
|
||||
$response->error('Department time bookings are not enabled', 404);
|
||||
}
|
||||
$department = $this->requirePublicTimeBookingsDepartment();
|
||||
|
||||
$department_time_bookings_entries = new department_time_bookings_entries_o();
|
||||
$booking_entries_array = $department_time_bookings_entries->listObjectsWithPaginationIfSet(
|
||||
function ($booking_entry): array {
|
||||
@@ -179,9 +207,7 @@ class customerTimeBookingsRoute
|
||||
$this->post('/department/timebookings/entries/public', function () {
|
||||
global $response;
|
||||
self::requireParameters(['department', 'type', 'start']);
|
||||
self::requireType((int)self::getParameter('department'), self::type_int());
|
||||
self::requireMinValue((int)self::getParameter('department'), 1);
|
||||
self::requireSameLength(self::getParameter('department'), (int)self::getParameter('department'));
|
||||
$department = $this->requirePublicTimeBookingsDepartment('department');
|
||||
|
||||
// Get the type
|
||||
self::requireType((int)self::getParameter('type'), self::type_int());
|
||||
@@ -195,17 +221,6 @@ class customerTimeBookingsRoute
|
||||
self::requireSameLength(self::getParameter('start'), (string)self::getParameter('start'));
|
||||
self::requireDateFormat((string)self::getParameter('start'), 'Y-m-d H:i:s');
|
||||
|
||||
// Check if the department exists
|
||||
$department = new departments_o();
|
||||
$department->select((int)self::getParameter('department'));
|
||||
if (!$department->exists()) {
|
||||
$response->error('Department not found', 404);
|
||||
}
|
||||
// Check if the department has time bookings enabled
|
||||
$variable = $department->isModuleTimeBookingsEnabled();
|
||||
if (!$variable) {
|
||||
$response->error('Department time bookings are not enabled', 404);
|
||||
}
|
||||
// Check if the type exists
|
||||
$department_time_bookings_types = new department_time_bookings_types_o();
|
||||
$department_time_bookings_types->select((int)self::getParameter('type'));
|
||||
@@ -269,4 +284,4 @@ class customerTimeBookingsRoute
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,11 +119,12 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
*/
|
||||
$this->get('/department/selfserve/vehicle/allowed', function () {
|
||||
global $response;
|
||||
[$user, $actor_id] = $this->getAuthenticatedSelfServePrincipal();
|
||||
[$user, $actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal();
|
||||
$own_permission = self::definePermission('list_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_LIST);
|
||||
|
||||
$customer_number_context = $this->resolveEffectiveCustomerNumber();
|
||||
$has_global = $user !== null && $this->hasPermission('list_department_selfserve_vehicle_conditions');
|
||||
$has_own = $this->hasPermission($own_permission);
|
||||
$has_own = $this->hasPermission($own_permission, $customer_number_context);
|
||||
if (!$has_global && !$has_own) {
|
||||
$response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']);
|
||||
}
|
||||
@@ -147,11 +148,14 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
if ($vehicle_type_id !== null) {
|
||||
$flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false, [
|
||||
'create_session' => false,
|
||||
'subuser_id' => $subuser_id,
|
||||
]);
|
||||
}
|
||||
|
||||
(new logs_o())->add('department_selfserve_vehicle_conditions', (int)$lane->department->value(), 1, $actor_id, 'CHECK_VEHICLE_ALLOWED', 'User checked self-serve eligibility for lane ' . $lane_id . ' and vehicle ' . $reg);
|
||||
$response->success($flow->previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id));
|
||||
$response->success($flow->previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id, [
|
||||
'subuser_id' => $subuser_id,
|
||||
]));
|
||||
}, [
|
||||
'list_department_selfserve_vehicle_conditions' => 'Check whether self-serve is allowed for a specific vehicle',
|
||||
'list_own_department_selfserve_vehicle_conditions' => 'Check whether self-serve is allowed for a customer-scoped vehicle'
|
||||
@@ -162,11 +166,12 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
*/
|
||||
$this->get('/department/selfserve/washes/summary', function () {
|
||||
global $response;
|
||||
[$user] = $this->getAuthenticatedSelfServePrincipal();
|
||||
[$user, $_actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal();
|
||||
$own_permission = self::definePermission('list_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_LIST);
|
||||
|
||||
$customer_number_context = $this->resolveEffectiveCustomerNumber();
|
||||
$has_global = $user !== null && $this->hasPermission('list_department_selfserve_vehicle_conditions');
|
||||
$has_own = $this->hasPermission($own_permission);
|
||||
$has_own = $this->hasPermission($own_permission, $customer_number_context);
|
||||
if (!$has_global && !$has_own) {
|
||||
$response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']);
|
||||
}
|
||||
@@ -192,7 +197,10 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
false,
|
||||
$vehicle_type_id,
|
||||
false,
|
||||
['create_session' => false]
|
||||
[
|
||||
'create_session' => false,
|
||||
'subuser_id' => $subuser_id,
|
||||
]
|
||||
);
|
||||
if (!empty($refreshed_summary['session']['id'])) {
|
||||
$summary = $refreshed_summary;
|
||||
@@ -215,6 +223,7 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
if ($vehicle_type_id !== null) {
|
||||
$summary = $flow->synchronizeSession($lane_id, $reg, $customer_number, false, $vehicle_type_id, false, [
|
||||
'create_session' => false,
|
||||
'subuser_id' => $subuser_id,
|
||||
]);
|
||||
if (empty($summary['session']['id'])) {
|
||||
try {
|
||||
@@ -241,11 +250,12 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
*/
|
||||
$this->post('/department/selfserve/vehicle/conditions', function () {
|
||||
global $response;
|
||||
[$user, $actor_id] = $this->getAuthenticatedSelfServePrincipal();
|
||||
[$user, $actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal();
|
||||
$own_permission = self::definePermission('add_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_ADD);
|
||||
|
||||
$customer_number_context = $this->resolveEffectiveCustomerNumber();
|
||||
$has_global = $user !== null && $this->hasPermission('add_department_selfserve_vehicle_conditions');
|
||||
$has_own = $this->hasPermission($own_permission);
|
||||
$has_own = $this->hasPermission($own_permission, $customer_number_context);
|
||||
|
||||
if (!$has_global && !$has_own) {
|
||||
$response->forbidden(['add_department_selfserve_vehicle_conditions', 'add_own_department_selfserve_vehicle_conditions']);
|
||||
@@ -281,7 +291,9 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
try {
|
||||
$condition_o = new department_selfserve_vehicle_conditions_o();
|
||||
$condition_o->add($department, $lane, $reg, $question, $value, $customer_id);
|
||||
$summary = $this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);
|
||||
$summary = $this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state, [
|
||||
'subuser_id' => $subuser_id,
|
||||
]);
|
||||
(new logs_o())->add('department_selfserve_vehicle_conditions', 'global', 1, $actor_id, 'ADD_VEHICLE_CONDITION', 'User added department self-serve vehicle condition ' . $condition_o->id);
|
||||
$response->success([
|
||||
'condition' => $condition_o->asArray(),
|
||||
@@ -469,12 +481,12 @@ class departmentSelfserveVehicleConditionsRoute
|
||||
$auth = new authentication();
|
||||
$user = $auth->get_user();
|
||||
if ($user !== false) {
|
||||
return [$user, (int)$user->id];
|
||||
return [$user, (int)$user->id, null];
|
||||
}
|
||||
|
||||
$subuser = $auth->get_subuser();
|
||||
if ($subuser !== false) {
|
||||
return [null, (int)$subuser->id];
|
||||
return [null, (int)$subuser->id, (int)$subuser->id];
|
||||
}
|
||||
|
||||
$response->error('Invalid session', 400);
|
||||
|
||||
@@ -7,6 +7,7 @@ use classes\backup_store;
|
||||
use classes\response;
|
||||
use classes\router;
|
||||
use objects\logs_o;
|
||||
use Throwable;
|
||||
use traits\route_t;
|
||||
|
||||
class moduleBackupsRoute
|
||||
@@ -19,49 +20,144 @@ class moduleBackupsRoute
|
||||
/** @var router $router */
|
||||
$router, $response;
|
||||
|
||||
|
||||
/** Modules > Backups > GET */
|
||||
$this->get('/modules/backup/backups', function () {
|
||||
global $response;
|
||||
$this->requirePermission('modules_backup_list');
|
||||
$user = (new authentication())->get_user();
|
||||
if ($user) {
|
||||
(new logs_o())->add('modules_backup', 'global', 1, $user->id, 'MODULES_BACKUP', 'Successfully fetched backup modules');
|
||||
$this->requireClassicSuperuserPermission('modules_backup_list');
|
||||
try {
|
||||
$response->success(
|
||||
(new backup_store())->listBackups()
|
||||
(new backup_store())->listBackups(
|
||||
(int)($this->fromQuery('limit') ?? 50),
|
||||
(int)($this->fromQuery('offset') ?? 0)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
(new logs_o())->add('modules_backup', 'global', 1, 0, 'MODULES_BACKUP', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
},
|
||||
[
|
||||
'modules_backup_list' => 'List all backup modules'
|
||||
]
|
||||
);
|
||||
}, [
|
||||
'modules_backup_list' => 'List backup records, components, and legacy metadata',
|
||||
]);
|
||||
|
||||
/** Modules > Backups > POST */
|
||||
$this->post('/modules/backup/backups', function () {
|
||||
global $response;
|
||||
$this->requirePermission('modules_backup_create');
|
||||
$user = (new authentication())->get_user();
|
||||
if ($user) {
|
||||
(new logs_o())->add('modules_backup', 'global', 1, $user->id, 'MODULES_BACKUP', 'Successfully created backup module');
|
||||
// Check if the request contains a name, and description parameter
|
||||
$name = $this->fromRequest('name');
|
||||
$description = $this->fromRequest('description');
|
||||
// Create the backup
|
||||
$response->success(
|
||||
(new backup_store())->createBackup($name, $description)
|
||||
$this->requireClassicSuperuserPermission('modules_backup_create');
|
||||
$user_id = $this->actorUserId();
|
||||
try {
|
||||
$parameters = $this->getParametersAsArray();
|
||||
$result = (new backup_store())->enqueueCreateBackup(
|
||||
$parameters['name'] ?? null,
|
||||
$parameters['description'] ?? null,
|
||||
$user_id,
|
||||
'manual'
|
||||
);
|
||||
} else {
|
||||
(new logs_o())->add('modules_backup', 'global', 1, 0, 'MODULES_BACKUP', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
(new logs_o())->add('modules_backup', 'global', 1, $user_id ?? 0, 'MODULES_BACKUP', 'Queued backup: ' . $result['backup_uuid']);
|
||||
$response->success($result);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
},
|
||||
[
|
||||
'modules_backup_create' => 'Create a backup module'
|
||||
]
|
||||
);
|
||||
}, [
|
||||
'modules_backup_create' => 'Queue a backup job',
|
||||
]);
|
||||
|
||||
$this->get('/modules/backup/jobs/{id}', function () {
|
||||
global $response;
|
||||
$this->requireClassicSuperuserPermission('modules_backup_list');
|
||||
$job_id = (int)$this->fromRoute('id');
|
||||
$job = (new backup_store())->getJob($job_id);
|
||||
if ($job === null) {
|
||||
$response->error('Backup job not found.', 404);
|
||||
}
|
||||
$response->success($job);
|
||||
}, [
|
||||
'modules_backup_list' => 'View backup job status',
|
||||
]);
|
||||
|
||||
$this->post('/modules/backup/backups/{backup_uuid}/verify', function () {
|
||||
global $response;
|
||||
$this->requireClassicSuperuserPermission('modules_backup_verify');
|
||||
try {
|
||||
$backup_uuid = (string)$this->fromRoute('backup_uuid');
|
||||
$result = (new backup_store())->enqueueVerifyBackup($backup_uuid, $this->actorUserId());
|
||||
(new logs_o())->add('modules_backup', 'global', 1, $this->actorUserId() ?? 0, 'MODULES_BACKUP_VERIFY', 'Queued backup verification: ' . $backup_uuid);
|
||||
$response->success($result);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
}, [
|
||||
'modules_backup_verify' => 'Verify a backup',
|
||||
]);
|
||||
|
||||
$this->post('/modules/backup/backups/{backup_uuid}/restore/preview', function () {
|
||||
global $response;
|
||||
$this->requireClassicSuperuserPermission('modules_backup_restore');
|
||||
try {
|
||||
$backup_uuid = (string)$this->fromRoute('backup_uuid');
|
||||
$result = (new backup_store())->previewRestore($backup_uuid, $this->actorUserId());
|
||||
(new logs_o())->add('modules_backup', 'global', 1, $this->actorUserId() ?? 0, 'MODULES_BACKUP_RESTORE_PREVIEW', 'Previewed backup restore: ' . $backup_uuid);
|
||||
$response->success($result);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
}, [
|
||||
'modules_backup_restore' => 'Preview production restore from a backup',
|
||||
]);
|
||||
|
||||
$this->post('/modules/backup/backups/{backup_uuid}/restore', function () {
|
||||
global $response;
|
||||
$this->requireClassicSuperuserPermission('modules_backup_restore');
|
||||
try {
|
||||
$parameters = $this->getParametersAsArray();
|
||||
$backup_uuid = (string)$this->fromRoute('backup_uuid');
|
||||
$result = (new backup_store())->enqueueRestore(
|
||||
$backup_uuid,
|
||||
(int)($parameters['preview_id'] ?? 0),
|
||||
(string)($parameters['confirmation_phrase'] ?? ''),
|
||||
(string)($parameters['reason'] ?? ''),
|
||||
$this->actorUserId(),
|
||||
[
|
||||
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
]
|
||||
);
|
||||
(new logs_o())->add('modules_backup', 'global', 1, $this->actorUserId() ?? 0, 'MODULES_BACKUP_RESTORE', 'Queued backup restore: ' . $backup_uuid);
|
||||
$response->success($result);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
}, [
|
||||
'modules_backup_restore' => 'Execute production restore from a backup',
|
||||
]);
|
||||
|
||||
$this->get('/modules/backup/restore-audit', function () {
|
||||
global $response;
|
||||
$this->requireClassicSuperuserPermission('modules_backup_restore');
|
||||
try {
|
||||
$response->success((new backup_store())->restoreAudit((int)($this->fromQuery('limit') ?? 50)));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
}, [
|
||||
'modules_backup_restore' => 'View backup restore audit log',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function requireClassicSuperuserPermission(string $permission): bool
|
||||
{
|
||||
global $response;
|
||||
|
||||
if ((new authentication())->get_subuser() !== false) {
|
||||
$response->error('Subuser sessions cannot manage backup disaster recovery.', 403);
|
||||
}
|
||||
|
||||
return $this->requirePermission($permission);
|
||||
}
|
||||
|
||||
private function actorUserId(): ?int
|
||||
{
|
||||
try {
|
||||
$user = (new authentication())->get_user();
|
||||
return $user !== false && isset($user->id) ? (int)$user->id : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ use objects\customer_vehicles_o;
|
||||
use objects\selfserve_wash_sessions_o;
|
||||
use objects\selfserve_wash_session_tasks_o;
|
||||
use objects\stripe_module_customers_o;
|
||||
use objects\subusers_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
|
||||
@@ -38,6 +39,7 @@ class moduleSelfServeRoute
|
||||
|
||||
private const MAX_GATE_OPEN_TOGGLE_AFTER_SECONDS = 5;
|
||||
private const CUSTOMER_SELFSERVE_PERMISSION = 'list_own_department_selfserve_vehicle_conditions';
|
||||
private const CUSTOMER_SELFSERVE_USE_PERMISSION = 'add_own_department_selfserve_vehicle_conditions';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
@@ -262,6 +264,7 @@ class moduleSelfServeRoute
|
||||
'status' => $lane_state->name,
|
||||
'reg' => $runtime_reg,
|
||||
'customer_number' => $runtime_customer_number,
|
||||
'subuser_id' => null,
|
||||
'vehicle_id' => $vehicle['id'] ?? null,
|
||||
'vehicle_type_id' => $vehicle['type'] ?? null,
|
||||
'included_minutes' => $included_minutes,
|
||||
@@ -273,14 +276,16 @@ class moduleSelfServeRoute
|
||||
'wash_started_at' => $wash_started_at,
|
||||
'created_at' => null,
|
||||
'updated_at' => null,
|
||||
],
|
||||
'customer' => $customer,
|
||||
'vehicle' => $vehicle,
|
||||
], $customer_scope));
|
||||
],
|
||||
'customer' => $customer,
|
||||
'subuser' => null,
|
||||
'vehicle' => $vehicle,
|
||||
], $customer_scope));
|
||||
return;
|
||||
}
|
||||
|
||||
$customer_number = $session->customer_number->value() === null ? null : (int)$session->customer_number->value();
|
||||
$subuser_id = $session->subuser_id->value() === null ? null : (int)$session->subuser_id->value();
|
||||
$vehicle_id = $session->vehicle_id->value() === null ? null : (int)$session->vehicle_id->value();
|
||||
$session_reg = trim((string)$session->reg->value());
|
||||
if ($session_reg === '') {
|
||||
@@ -318,6 +323,7 @@ class moduleSelfServeRoute
|
||||
'status' => (string)$session->status->value(),
|
||||
'reg' => $session_reg,
|
||||
'customer_number' => $customer_number,
|
||||
'subuser_id' => $subuser_id,
|
||||
'vehicle_id' => $vehicle_id,
|
||||
'vehicle_type_id' => $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(),
|
||||
'included_minutes' => $included_minutes ?? 0,
|
||||
@@ -331,12 +337,13 @@ class moduleSelfServeRoute
|
||||
'updated_at' => $session->updated_at->value() === null ? null : (string)$session->updated_at->value(),
|
||||
],
|
||||
'customer' => $customer,
|
||||
'subuser' => $this->buildInProgressWashSubuserPayload($subuser_id),
|
||||
'vehicle' => $vehicle,
|
||||
], $customer_scope));
|
||||
},
|
||||
[
|
||||
'modules_selfserve_lane_wash_in_progress_view' => 'View customer and vehicle details for an in-progress self-serve wash on a lane',
|
||||
'list_own_department_selfserve_vehicle_conditions' => 'View in-progress self-serve wash details for the authenticated customer',
|
||||
'add_own_department_selfserve_vehicle_conditions' => 'Use in-progress self-serve wash details for the authenticated customer',
|
||||
]
|
||||
);
|
||||
|
||||
@@ -344,8 +351,11 @@ class moduleSelfServeRoute
|
||||
$this->get('/modules/self-serve/lane/wash/my-active-wash', function () {
|
||||
global $response;
|
||||
|
||||
$customer_number = $this->requireMyActiveWashCustomerNumber();
|
||||
$session = $this->findLatestActiveSelfServeSessionForCustomer($customer_number);
|
||||
$principal_scope = $this->requireMyActiveWashPrincipalScope();
|
||||
$session = $this->findLatestActiveSelfServeSessionForCustomer(
|
||||
(int)$principal_scope['customer_number'],
|
||||
$principal_scope['subuser_id']
|
||||
);
|
||||
if (!$session->exists()) {
|
||||
$response->error('No active self-serve wash found.', 404);
|
||||
}
|
||||
@@ -353,7 +363,7 @@ class moduleSelfServeRoute
|
||||
$response->success($this->buildActiveSelfServeSessionResponse($session));
|
||||
},
|
||||
[
|
||||
'list_own_department_selfserve_vehicle_conditions' => 'View the authenticated customer\'s active self-serve wash',
|
||||
'add_own_department_selfserve_vehicle_conditions' => 'Use the authenticated customer\'s active self-serve wash',
|
||||
]
|
||||
);
|
||||
|
||||
@@ -369,6 +379,7 @@ class moduleSelfServeRoute
|
||||
'department_id',
|
||||
'machine_type_id',
|
||||
'customer_number',
|
||||
'subuser_id',
|
||||
'vehicle_id',
|
||||
'vehicle_type_id',
|
||||
'reg',
|
||||
@@ -392,6 +403,9 @@ class moduleSelfServeRoute
|
||||
|
||||
return [
|
||||
...$session->asArray(),
|
||||
'subuser' => $this->buildInProgressWashSubuserPayload(
|
||||
$session->subuser_id->value() === null ? null : (int)$session->subuser_id->value()
|
||||
),
|
||||
'elapsed_minutes' => $session->getElapsedMinutes(),
|
||||
'open' => $session->isOpen(),
|
||||
];
|
||||
@@ -585,7 +599,8 @@ class moduleSelfServeRoute
|
||||
$customer_number > 0 ? $customer_number : null,
|
||||
false,
|
||||
null,
|
||||
false
|
||||
false,
|
||||
['subuser_id' => $args->subuser_id]
|
||||
);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
@@ -1421,7 +1436,7 @@ class moduleSelfServeRoute
|
||||
]);
|
||||
}
|
||||
|
||||
private function requireInProgressWashDetailsAccess(int $lane_id): ?int
|
||||
private function requireInProgressWashDetailsAccess(int $lane_id): ?array
|
||||
{
|
||||
global $response;
|
||||
|
||||
@@ -1438,16 +1453,21 @@ class moduleSelfServeRoute
|
||||
return null;
|
||||
}
|
||||
|
||||
if (self::hasPermission($this->customerSelfServePermission())) {
|
||||
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||
if ($customer_number !== null && $customer_number > 0) {
|
||||
return (int)$customer_number;
|
||||
}
|
||||
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||
if (
|
||||
$customer_number !== null
|
||||
&& $customer_number > 0
|
||||
&& self::hasPermission($this->customerSelfServeUsePermission(), $customer_number)
|
||||
) {
|
||||
return [
|
||||
'customer_number' => (int)$customer_number,
|
||||
'subuser_id' => $this->authenticatedSubuserId(),
|
||||
];
|
||||
}
|
||||
|
||||
$this->emitForbidden([
|
||||
'modules_selfserve_lane_wash_in_progress_view',
|
||||
$this->customerSelfServePermission(),
|
||||
$this->customerSelfServeUsePermission(),
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
@@ -1459,14 +1479,19 @@ class moduleSelfServeRoute
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
protected function scopeInProgressWashResponseForCustomer(array $payload, ?int $customer_number): array
|
||||
protected function scopeInProgressWashResponseForCustomer(array $payload, mixed $customer_scope): array
|
||||
{
|
||||
$scope = $this->normalizeCustomerScope($customer_scope);
|
||||
$customer_number = $scope['customer_number'];
|
||||
$subuser_id = $scope['subuser_id'];
|
||||
|
||||
if ($customer_number === null || $customer_number <= 0 || ($payload['in_progress'] ?? false) !== true) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$session_customer_number = $this->extractInProgressWashCustomerNumber($payload);
|
||||
if ($session_customer_number === $customer_number) {
|
||||
$session_subuser_id = $this->extractInProgressWashSubuserId($payload);
|
||||
if ($session_customer_number === $customer_number && ($subuser_id === null || $session_subuser_id === $subuser_id)) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
@@ -1479,6 +1504,24 @@ class moduleSelfServeRoute
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{customer_number:?int,subuser_id:?int}
|
||||
*/
|
||||
private function normalizeCustomerScope(mixed $customer_scope): array
|
||||
{
|
||||
if (is_array($customer_scope)) {
|
||||
return [
|
||||
'customer_number' => $this->normalizePositiveInt($customer_scope['customer_number'] ?? null),
|
||||
'subuser_id' => $this->normalizePositiveInt($customer_scope['subuser_id'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'customer_number' => $this->normalizePositiveInt($customer_scope),
|
||||
'subuser_id' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
@@ -1502,7 +1545,31 @@ class moduleSelfServeRoute
|
||||
return null;
|
||||
}
|
||||
|
||||
private function requireMyActiveWashCustomerNumber(): int
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
private function extractInProgressWashSubuserId(array $payload): ?int
|
||||
{
|
||||
$candidates = [
|
||||
$payload['session']['subuser_id'] ?? null,
|
||||
$payload['session']['subuser']['id'] ?? null,
|
||||
$payload['subuser']['id'] ?? null,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$subuser_id = $this->normalizePositiveInt($candidate);
|
||||
if ($subuser_id !== null) {
|
||||
return $subuser_id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{customer_number:int,subuser_id:?int}
|
||||
*/
|
||||
private function requireMyActiveWashPrincipalScope(): array
|
||||
{
|
||||
global $response;
|
||||
|
||||
@@ -1510,16 +1577,19 @@ class moduleSelfServeRoute
|
||||
$response->error('Authentication failed. Invalid or missing token.', 401);
|
||||
}
|
||||
|
||||
if (!self::hasPermission($this->customerSelfServePermission())) {
|
||||
$this->emitForbidden([$this->customerSelfServePermission()]);
|
||||
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||
if (!self::hasPermission($this->customerSelfServeUsePermission(), $customer_number)) {
|
||||
$this->emitForbidden([$this->customerSelfServeUsePermission()]);
|
||||
}
|
||||
|
||||
$customer_number = $this->resolveEffectiveCustomerNumber();
|
||||
if ($customer_number === null || $customer_number <= 0) {
|
||||
$response->error('No customer number found for authenticated user.', 404);
|
||||
}
|
||||
|
||||
return (int)$customer_number;
|
||||
return [
|
||||
'customer_number' => (int)$customer_number,
|
||||
'subuser_id' => $this->authenticatedSubuserId(),
|
||||
];
|
||||
}
|
||||
|
||||
private function customerSelfServePermission(): \classes\permission_node
|
||||
@@ -1527,23 +1597,53 @@ class moduleSelfServeRoute
|
||||
return self::definePermission(self::CUSTOMER_SELFSERVE_PERMISSION, subusers_permission_node_key::SELFSERVE_LIST);
|
||||
}
|
||||
|
||||
private function customerSelfServeUsePermission(): \classes\permission_node
|
||||
{
|
||||
return self::definePermission(self::CUSTOMER_SELFSERVE_USE_PERMISSION, subusers_permission_node_key::SELFSERVE_ADD);
|
||||
}
|
||||
|
||||
private function authenticatedSubuserId(): ?int
|
||||
{
|
||||
try {
|
||||
$subuser = (new authentication())->get_subuser();
|
||||
return $subuser === false ? null : (int)$subuser->id;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizePositiveInt(mixed $value): ?int
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = (int)$value;
|
||||
return $normalized > 0 ? $normalized : null;
|
||||
}
|
||||
|
||||
private function hasAuthenticatedUserOrSubuser(): bool
|
||||
{
|
||||
$auth = new authentication();
|
||||
return $auth->get_user() !== false || $auth->get_subuser() !== false;
|
||||
}
|
||||
|
||||
private function findLatestActiveSelfServeSessionForCustomer(int $customer_number): selfserve_wash_sessions_o
|
||||
private function findLatestActiveSelfServeSessionForCustomer(int $customer_number, ?int $subuser_id = null): selfserve_wash_sessions_o
|
||||
{
|
||||
if ($customer_number <= 0) {
|
||||
return new selfserve_wash_sessions_o();
|
||||
}
|
||||
|
||||
$rows = (new selfserve_wash_sessions_o())->getFieldsWhere([
|
||||
$filters = [
|
||||
'customer_number' => $customer_number,
|
||||
'completed_at' => null,
|
||||
'deleted_at' => null,
|
||||
], ['id', 'status']);
|
||||
];
|
||||
if ($subuser_id !== null) {
|
||||
$filters['subuser_id'] = $subuser_id;
|
||||
}
|
||||
|
||||
$rows = (new selfserve_wash_sessions_o())->getFieldsWhere($filters, ['id', 'status']);
|
||||
|
||||
$active_statuses = $this->activeSelfServeWashSessionStatusValues();
|
||||
$rows = array_values(array_filter(
|
||||
@@ -1582,6 +1682,7 @@ class moduleSelfServeRoute
|
||||
{
|
||||
$lane_id = (int)$session->lane_id->value();
|
||||
$customer_number = $session->customer_number->value() === null ? null : (int)$session->customer_number->value();
|
||||
$subuser_id = $session->subuser_id->value() === null ? null : (int)$session->subuser_id->value();
|
||||
$vehicle_id = $session->vehicle_id->value() === null ? null : (int)$session->vehicle_id->value();
|
||||
$session_reg = trim((string)$session->reg->value());
|
||||
if ($session_reg === '') {
|
||||
@@ -1615,6 +1716,7 @@ class moduleSelfServeRoute
|
||||
'lane_id' => $lane_id,
|
||||
'reg' => $session_reg,
|
||||
'customer_number' => $customer_number,
|
||||
'subuser_id' => $subuser_id,
|
||||
'vehicle_id' => $vehicle_id,
|
||||
'vehicle_type_id' => $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(),
|
||||
'included_minutes' => $included_minutes ?? 0,
|
||||
@@ -1628,6 +1730,7 @@ class moduleSelfServeRoute
|
||||
'updated_at' => $session->updated_at->value() === null ? null : (string)$session->updated_at->value(),
|
||||
],
|
||||
'customer' => $this->buildInProgressWashCustomerPayload($customer_number),
|
||||
'subuser' => $this->buildInProgressWashSubuserPayload($subuser_id),
|
||||
'vehicle' => $this->buildInProgressWashVehiclePayload($vehicle_id, $session_reg),
|
||||
];
|
||||
}
|
||||
@@ -1663,6 +1766,35 @@ class moduleSelfServeRoute
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function buildInProgressWashSubuserPayload(?int $subuser_id): ?array
|
||||
{
|
||||
if ($subuser_id === null || $subuser_id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$subuser = (new subusers_o())->select($subuser_id);
|
||||
if (!$subuser->exists()) {
|
||||
return [
|
||||
'id' => $subuser_id,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)$subuser->id,
|
||||
'name' => $subuser->name->value() === null ? null : (string)$subuser->name->value(),
|
||||
'username' => $subuser->username->value() === null ? null : (string)$subuser->username->value(),
|
||||
];
|
||||
} catch (\Throwable) {
|
||||
return [
|
||||
'id' => $subuser_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
@@ -2088,7 +2220,9 @@ class moduleSelfServeRoute
|
||||
|
||||
$missing_permissions = $department_id > 0 ? ['department_access_' . $department_id] : [];
|
||||
if ($allow_customer_self_serve) {
|
||||
$missing_permissions[] = $this->customerSelfServePermission();
|
||||
$missing_permissions[] = $requires_active_wash
|
||||
? $this->customerSelfServePermission()
|
||||
: $this->customerSelfServeUsePermission();
|
||||
}
|
||||
$this->emitForbidden($missing_permissions);
|
||||
}
|
||||
@@ -2132,7 +2266,10 @@ class moduleSelfServeRoute
|
||||
return;
|
||||
}
|
||||
|
||||
$this->emitForbidden([...$elevated_permissions, $this->customerSelfServePermission()]);
|
||||
$this->emitForbidden([
|
||||
...$elevated_permissions,
|
||||
$requires_active_wash ? $this->customerSelfServePermission() : $this->customerSelfServeUsePermission(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function requireSelfServeLaneCommandPermission(
|
||||
@@ -2163,7 +2300,10 @@ class moduleSelfServeRoute
|
||||
|
||||
$this->emitForbidden(
|
||||
$allow_customer_self_serve
|
||||
? [...$elevated_permissions, $this->customerSelfServePermission()]
|
||||
? [
|
||||
...$elevated_permissions,
|
||||
$requires_active_wash ? $this->customerSelfServePermission() : $this->customerSelfServeUsePermission(),
|
||||
]
|
||||
: $elevated_permissions
|
||||
);
|
||||
}
|
||||
@@ -2218,18 +2358,19 @@ class moduleSelfServeRoute
|
||||
protected function canCustomerUseSelfServeLane(selfserve_lane $lane, int $customer_number): bool
|
||||
{
|
||||
return $customer_number > 0
|
||||
&& $this->hasPermission($this->customerSelfServePermission())
|
||||
&& $this->hasPermission($this->customerSelfServeUsePermission(), $customer_number)
|
||||
&& $this->isLaneSelfServeOperationallyEnabled($lane);
|
||||
}
|
||||
|
||||
protected function canCustomerUseActiveSelfServeLane(selfserve_lane $lane, int $customer_number): bool
|
||||
{
|
||||
if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission())) {
|
||||
if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission(), $customer_number)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subuser_id = $this->authenticatedSubuserId();
|
||||
try {
|
||||
if ((int)$lane->getCustomerNumber() === $customer_number) {
|
||||
if ($subuser_id === null && (int)$lane->getCustomerNumber() === $customer_number) {
|
||||
return true;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
@@ -2238,7 +2379,7 @@ class moduleSelfServeRoute
|
||||
|
||||
$department_id = $this->departmentIdForLane($lane);
|
||||
return $department_id > 0
|
||||
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
|
||||
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number, $subuser_id);
|
||||
}
|
||||
|
||||
protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool
|
||||
@@ -2253,7 +2394,7 @@ class moduleSelfServeRoute
|
||||
|
||||
$department_id = $this->departmentIdForLane($lane);
|
||||
return $department_id > 0
|
||||
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
|
||||
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number, $this->authenticatedSubuserId());
|
||||
}
|
||||
|
||||
protected function canCustomerUseActiveOperationalSelfServeLane(
|
||||
@@ -2271,12 +2412,13 @@ class moduleSelfServeRoute
|
||||
|
||||
protected function canCustomerUseActiveSelfServeLaneSession(selfserve_lane $lane, int $customer_number): bool
|
||||
{
|
||||
if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission())) {
|
||||
if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission(), $customer_number)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$subuser_id = $this->authenticatedSubuserId();
|
||||
try {
|
||||
if ((int)$lane->getCustomerNumber() === $customer_number) {
|
||||
if ($subuser_id === null && (int)$lane->getCustomerNumber() === $customer_number) {
|
||||
return true;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
@@ -2294,12 +2436,17 @@ class moduleSelfServeRoute
|
||||
]
|
||||
);
|
||||
|
||||
$sessions = (new selfserve_wash_sessions_o())->getFieldsWhere([
|
||||
$filters = [
|
||||
'lane_id' => (int)$lane->id,
|
||||
'customer_number' => $customer_number,
|
||||
'completed_at' => null,
|
||||
'deleted_at' => null,
|
||||
], ['id', 'status']);
|
||||
];
|
||||
if ($subuser_id !== null) {
|
||||
$filters['subuser_id'] = $subuser_id;
|
||||
}
|
||||
|
||||
$sessions = (new selfserve_wash_sessions_o())->getFieldsWhere($filters, ['id', 'status']);
|
||||
|
||||
foreach ($sessions as $session) {
|
||||
if (in_array((string)($session['status'] ?? ''), $active_statuses, true)) {
|
||||
@@ -2342,7 +2489,7 @@ class moduleSelfServeRoute
|
||||
}
|
||||
}
|
||||
|
||||
protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool
|
||||
protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number, ?int $subuser_id = null): bool
|
||||
{
|
||||
if ($department_id <= 0 || $customer_number <= 0) {
|
||||
return false;
|
||||
@@ -2359,12 +2506,17 @@ class moduleSelfServeRoute
|
||||
]
|
||||
);
|
||||
|
||||
$sessions = (new selfserve_wash_sessions_o())->getFieldsWhere([
|
||||
$filters = [
|
||||
'department_id' => $department_id,
|
||||
'customer_number' => $customer_number,
|
||||
'completed_at' => null,
|
||||
'deleted_at' => null,
|
||||
], ['id', 'status']);
|
||||
];
|
||||
if ($subuser_id !== null) {
|
||||
$filters['subuser_id'] = $subuser_id;
|
||||
}
|
||||
|
||||
$sessions = (new selfserve_wash_sessions_o())->getFieldsWhere($filters, ['id', 'status']);
|
||||
|
||||
foreach ($sessions as $session) {
|
||||
if (in_array((string)($session['status'] ?? ''), $active_statuses, true)) {
|
||||
@@ -2373,6 +2525,9 @@ class moduleSelfServeRoute
|
||||
}
|
||||
|
||||
foreach ((new department_lanes_o())->getDepartmentLanes($department_id) as $department_lane) {
|
||||
if ($subuser_id !== null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$lane = (new selfserve())->lane((int)$department_lane->id);
|
||||
if ((int)$lane->getCustomerNumber() !== $customer_number) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\module_usage_service;
|
||||
use Exception;
|
||||
use traits\route_t;
|
||||
|
||||
class moduleUsageRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/modules/usage/summary', function () {
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('modules_usage_view');
|
||||
$response->success((new module_usage_service())->summary([
|
||||
'module' => $this->getParameter('module'),
|
||||
'period' => $this->getParameter('period'),
|
||||
'status' => $this->getParameter('status'),
|
||||
'date' => $this->getParameter('date'),
|
||||
]));
|
||||
}, [
|
||||
'modules_usage_view' => 'View module usage, quota, and statistics summaries',
|
||||
]);
|
||||
|
||||
$this->get('/modules/usage/{moduleKey}', function () {
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('modules_usage_view');
|
||||
$moduleKey = (string)($this->fromRoute('moduleKey') ?? '');
|
||||
$response->success((new module_usage_service())->moduleDetail($moduleKey, [
|
||||
'period' => $this->getParameter('period'),
|
||||
'date' => $this->getParameter('date'),
|
||||
'limit' => $this->getParameter('limit'),
|
||||
]));
|
||||
}, [
|
||||
'modules_usage_view' => 'View detailed module usage, quota, and statistics history',
|
||||
]);
|
||||
|
||||
$this->patch('/modules/quotas/{moduleKey}/{metricKey}', function () {
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('modules_quotas_manage');
|
||||
$moduleKey = (string)($this->fromRoute('moduleKey') ?? '');
|
||||
$metricKey = (string)($this->fromRoute('metricKey') ?? '');
|
||||
|
||||
try {
|
||||
$response->success((new module_usage_service())->updateQuotaSetting(
|
||||
$moduleKey,
|
||||
$metricKey,
|
||||
$response->getAllRequestParameters()
|
||||
));
|
||||
} catch (Exception $exception) {
|
||||
if ($exception->getMessage() === 'quota_not_writable') {
|
||||
$response->error([
|
||||
'message' => 'Quota limit is not writable for this provider or derived metric.',
|
||||
'code' => 'quota_not_writable',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$response->error($exception->getMessage(), 422);
|
||||
}
|
||||
}, [
|
||||
'modules_quotas_manage' => 'Manage module quota enforcement and writable hard limits',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use classes\email;
|
||||
use classes\order_bookings_counts_cache;
|
||||
use classes\order_bookings_list_cache;
|
||||
use classes\redis;
|
||||
use classes\security_policy_service;
|
||||
use Exception;
|
||||
use modules\subusers\helpers\subusers_permission_node_key;
|
||||
use objects\departments_o;
|
||||
@@ -18,6 +19,8 @@ use objects\products_o;
|
||||
use objects\users_o;
|
||||
use traits\route_t;
|
||||
|
||||
require_once WD . '/classes/security_policy_service.php';
|
||||
|
||||
class orderBookingRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -84,6 +87,10 @@ class orderBookingRoute
|
||||
try {
|
||||
$order_bookings_o->add($data);
|
||||
$this->storeBookingIdempotencyResult($fingerprint, (int)$order_bookings_o->id);
|
||||
(new security_policy_service())->observeBookingCreated((int)$customer_number->customer_number->value(), [
|
||||
'booking_id' => (int)$order_bookings_o->id,
|
||||
'route' => '/order-bookings',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->clearBookingCreationSlot($fingerprint);
|
||||
throw $e;
|
||||
|
||||
@@ -9,6 +9,7 @@ use classes\economic_transfer_queue_details_summary;
|
||||
use classes\economic_v2_compare_engine;
|
||||
use classes\economic_v2_line_normalizer;
|
||||
use classes\economic_v2_revenue_statistics_service;
|
||||
use classes\invoice_store;
|
||||
use classes\invoice_collection_bulk_action_service;
|
||||
use classes\invoicing_period_utils;
|
||||
use classes\response;
|
||||
@@ -249,6 +250,26 @@ class orderInvoicesRoute
|
||||
]
|
||||
);
|
||||
|
||||
/** Collected order invoices > E-conomic PDF > GET */
|
||||
$this->get('/collected-invoices/economic/pdf', function () {
|
||||
global $response;
|
||||
self::requirePermission('download_collected_invoice_economic_pdf');
|
||||
$collected_invoice_id = $this->requireCollectedInvoiceId();
|
||||
self::requireParameters(['type']);
|
||||
|
||||
$type = strtolower((string)self::getParameter('type'));
|
||||
if (!in_array($type, ['draft', 'booked'], true)) {
|
||||
$response->error('type must be either draft or booked', 400);
|
||||
}
|
||||
|
||||
$payload = $this->downloadCollectedEconomicInvoicePdf($collected_invoice_id, $type);
|
||||
$response->success($payload);
|
||||
},
|
||||
[
|
||||
'download_collected_invoice_economic_pdf' => 'Download draft/booked e-conomic PDF for a collected order invoice.',
|
||||
]
|
||||
);
|
||||
|
||||
/** Collected order invoices > E-conomic V2 compare > GET */
|
||||
$this->get('/collected-invoices/economic/v2/compare', function () {
|
||||
global $response;
|
||||
@@ -2002,16 +2023,31 @@ class orderInvoicesRoute
|
||||
|
||||
$economic = new economic();
|
||||
|
||||
try {
|
||||
$draft_id = $invoice->getInvoiceDraftId();
|
||||
} catch (Exception $e) {
|
||||
$warnings[] = 'Draft id unavailable: ' . $e->getMessage();
|
||||
}
|
||||
$persisted_booked_id = (int)$invoice->booked_invoice_id->value();
|
||||
if ($persisted_booked_id > 0) {
|
||||
// A booked invoice replaces its draft in e-conomic. Avoid reporting that expected
|
||||
// transition as a missing-draft warning.
|
||||
$booked_id = $persisted_booked_id;
|
||||
} else {
|
||||
$draft_error = null;
|
||||
try {
|
||||
$draft_id = $invoice->getInvoiceDraftId();
|
||||
} catch (Exception $e) {
|
||||
$draft_error = $e;
|
||||
}
|
||||
|
||||
try {
|
||||
$booked_id = $invoice->getInvoiceBookedId();
|
||||
} catch (Exception $e) {
|
||||
$warnings[] = 'Booked id unavailable: ' . $e->getMessage();
|
||||
// Older rows may have an external ID but no persisted booked ID. Only probe the
|
||||
// booked endpoint when the draft is gone, then persist through getInvoiceBookedId().
|
||||
if ($draft_id === null) {
|
||||
try {
|
||||
$booked_id = $invoice->getInvoiceBookedId();
|
||||
} catch (Exception $e) {
|
||||
if ($draft_error !== null) {
|
||||
$warnings[] = 'Draft id unavailable: ' . $draft_error->getMessage();
|
||||
}
|
||||
$warnings[] = 'Booked id unavailable: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($draft_id !== null) {
|
||||
@@ -2072,6 +2108,11 @@ class orderInvoicesRoute
|
||||
? economic_v2_line_normalizer::normalizeBookedInvoice($booked_raw)
|
||||
: null;
|
||||
|
||||
$economic_state = $booked_id !== null
|
||||
? 'booked'
|
||||
: ($draft_id !== null ? 'draft' : 'none');
|
||||
$available_pdf_type = $economic_state === 'none' ? null : $economic_state;
|
||||
|
||||
return [
|
||||
'collected_invoice_id' => $collected_invoice_id,
|
||||
'external_id' => (string)$invoice->external_id->value(),
|
||||
@@ -2081,6 +2122,8 @@ class orderInvoicesRoute
|
||||
'economic' => [
|
||||
'draft_id' => $draft_id !== null ? (int)$draft_id : null,
|
||||
'booked_id' => $booked_id !== null ? (int)$booked_id : null,
|
||||
'state' => $economic_state,
|
||||
'available_pdf_type' => $available_pdf_type,
|
||||
],
|
||||
'customer' => $customer,
|
||||
'internal' => [
|
||||
@@ -2105,6 +2148,80 @@ class orderInvoicesRoute
|
||||
];
|
||||
}
|
||||
|
||||
private function downloadCollectedEconomicInvoicePdf(int $collected_invoice_id, string $type): array
|
||||
{
|
||||
global $response;
|
||||
|
||||
$invoice = (new collected_order_invoices_o())->select($collected_invoice_id);
|
||||
$invoice->requireSelected();
|
||||
$this->requireCollectedInvoiceContextAccess($invoice);
|
||||
|
||||
$resolved_type = $type;
|
||||
$economic_invoice_id = null;
|
||||
$persisted_booked_id = (int)$invoice->booked_invoice_id->value();
|
||||
|
||||
if ($persisted_booked_id > 0) {
|
||||
$resolved_type = 'booked';
|
||||
$economic_invoice_id = $persisted_booked_id;
|
||||
} else {
|
||||
try {
|
||||
$economic_invoice_id = $type === 'draft'
|
||||
? $invoice->getInvoiceDraftId()
|
||||
: $invoice->getInvoiceBookedId();
|
||||
} catch (Exception $e) {
|
||||
// A legacy booked row can have only the former draft external ID persisted.
|
||||
// Fall back to the booked target when a requested draft no longer exists.
|
||||
if ($type === 'draft') {
|
||||
try {
|
||||
$economic_invoice_id = $invoice->getInvoiceBookedId();
|
||||
$resolved_type = 'booked';
|
||||
} catch (Exception $bookedException) {
|
||||
$response->error('No e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404);
|
||||
}
|
||||
} else {
|
||||
$response->error('No booked e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$economic_invoice_id = (int)$economic_invoice_id;
|
||||
if ($economic_invoice_id <= 0) {
|
||||
$response->error('No ' . $resolved_type . ' e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404);
|
||||
}
|
||||
|
||||
$economic = new economic();
|
||||
$invoice_path_file = $resolved_type === 'draft'
|
||||
? $economic->invoices->pdf->getDraft($economic_invoice_id)
|
||||
: $economic->invoices->pdf->getBooked($economic_invoice_id);
|
||||
|
||||
$store_key_id = 'economic_' . $resolved_type . '_' . $economic_invoice_id;
|
||||
$invoice_store = new invoice_store();
|
||||
try {
|
||||
$invoice_store->uploadFile('invoice_' . $store_key_id . '.pdf', $invoice_path_file);
|
||||
} finally {
|
||||
if (is_string($invoice_path_file) && file_exists($invoice_path_file)) {
|
||||
unlink($invoice_path_file);
|
||||
}
|
||||
}
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
(new logs_o())->add(
|
||||
'orderInvoices',
|
||||
'global',
|
||||
1,
|
||||
$user ? (int)$user->id : 0,
|
||||
'DOWNLOAD_COLLECTED_ECONOMIC_INVOICE_PDF',
|
||||
'Downloaded ' . $type . ' e-conomic invoice PDF for collected invoice ' . $collected_invoice_id
|
||||
);
|
||||
|
||||
return [
|
||||
'collected_invoice_id' => $collected_invoice_id,
|
||||
'type' => $resolved_type,
|
||||
'economic_invoice_id' => $economic_invoice_id,
|
||||
'url' => $invoice_store->getInvoiceDownloadUrl($store_key_id),
|
||||
];
|
||||
}
|
||||
|
||||
private function requireCollectedInvoiceContextAccess(collected_order_invoices_o $invoice): void
|
||||
{
|
||||
global $response;
|
||||
|
||||
@@ -92,7 +92,13 @@ class orderItemsRoute
|
||||
'ORDER_ITEM_RESTRICTED_BY_CUSTOMER_RULE',
|
||||
'Blocked product ' . (int)$data['product_id'] . ' on order ' . (int)$data['order_id'] . ' by rule ' . $customerRuleViolation['rule']
|
||||
);
|
||||
$response->error($customerRuleViolation['message'], 400);
|
||||
$response->error([
|
||||
'code' => $customerRuleViolation['code'],
|
||||
'message' => $customerRuleViolation['message'],
|
||||
'product_id' => $customerRuleViolation['product_id'],
|
||||
'rules' => $customerRuleViolation['rules'],
|
||||
'collections' => $customerRuleViolation['collections'],
|
||||
], 400);
|
||||
}
|
||||
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
|
||||
$response->error('Notes is required for this product', 400);
|
||||
|
||||
@@ -9,6 +9,7 @@ use classes\authentication;
|
||||
use classes\economic;
|
||||
use classes\order_reference_suggestions_service;
|
||||
use classes\orders_input_normalizer;
|
||||
use classes\pdf_store;
|
||||
use classes\response;
|
||||
use classes\stripe;
|
||||
use JetBrains\PhpStorm\NoReturn;
|
||||
@@ -334,68 +335,13 @@ class ordersRoute
|
||||
);
|
||||
|
||||
$this->get('/orders/attachments/download', function () {
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
// Permissions (subuser-aware)
|
||||
$permission_own = self::definePermission('download_order_attachments_own', subusers_permission_node_key::ORDERS_LIST);
|
||||
$permission_other = self::definePermission('download_order_attachments');
|
||||
$has_permission_other = self::hasPermission($permission_other);
|
||||
if (!$has_permission_other) {
|
||||
self::requirePermission($permission_own);
|
||||
}
|
||||
// Get the user object
|
||||
$user = (new authentication())->get_user();
|
||||
// Check if the request was successful
|
||||
if ($user) {
|
||||
// Get the order ID and attachment ID from the request
|
||||
self::requireParameters([
|
||||
'order_id',
|
||||
'attachment_id'
|
||||
]);
|
||||
$order_id = self::getParameter('order_id');
|
||||
$attachment_id = self::getParameter('attachment_id');
|
||||
if (!is_numeric($order_id) || (int)$order_id < 1) {
|
||||
$response->error('Invalid order ID', 400);
|
||||
}
|
||||
if (!is_numeric($attachment_id) || (int)$attachment_id < 1) {
|
||||
$response->error('Invalid attachment ID', 400);
|
||||
}
|
||||
// Get the current order
|
||||
$order = (new orders_o())->getOrderById((int)$order_id);
|
||||
// Check if the order exists
|
||||
if (!$order->exists()) {
|
||||
$response->error('Order not found', 400);
|
||||
}
|
||||
// If operating under own-scope (classic user or subuser), ensure the order belongs to the effective customer context
|
||||
if (!$has_permission_other) {
|
||||
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
|
||||
if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) {
|
||||
$response->forbidden([$permission_other->permission]);
|
||||
}
|
||||
}
|
||||
// Get the attachment
|
||||
$attachment = $order->getAttachment((int)$attachment_id);
|
||||
if (!$attachment->exists()) {
|
||||
$response->error('Attachment not found', 400);
|
||||
}
|
||||
// Create a download link
|
||||
$attachment_store = new attachment_store();
|
||||
$attachments = new attachments();
|
||||
$attachment_formatted = $attachments->format($attachment);
|
||||
$download_link = $attachment_store->generateDirectDownloadUrl(
|
||||
$attachment_formatted->content->document
|
||||
);
|
||||
// Log the incident
|
||||
(new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DOWNLOAD_ORDER_ATTACHMENT', 'Successfully downloaded an attachment for an order (Order ID: ' . $order_id . ', Attachment ID: ' . $attachment_id . ')');
|
||||
// Return the download link
|
||||
$response->success(['download_link' => $download_link]);
|
||||
|
||||
} else {
|
||||
// Log the incident
|
||||
(new logs_o())->add('orders', 'global', 1, 0, 'DOWNLOAD_ORDER_ATTACHMENT', 'No user found, or invalid session');
|
||||
// Return an error
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
$context = $this->requireOrderAttachmentDownloadContext();
|
||||
$downloadLink = $context['store'] instanceof pdf_store
|
||||
? $context['store']->getPresignedUrl($context['object_key'])
|
||||
: $context['store']->generateDirectDownloadUrl($context['object_key']);
|
||||
$this->logOrderAttachmentDownload($context, 'DOWNLOAD_ORDER_ATTACHMENT');
|
||||
$response->success(['download_link' => $downloadLink]);
|
||||
},
|
||||
[
|
||||
'download_order_attachments' => 'Download attachments for an order',
|
||||
@@ -403,6 +349,49 @@ class ordersRoute
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/orders/attachments/content', function () {
|
||||
global $response;
|
||||
|
||||
$context = $this->requireOrderAttachmentDownloadContext();
|
||||
$disposition = strtolower(trim((string)(self::getParameter('disposition') ?? 'inline')));
|
||||
if (!in_array($disposition, ['inline', 'attachment'], true)) {
|
||||
$response->error('disposition must be either inline or attachment', 400);
|
||||
}
|
||||
|
||||
$temporaryPath = null;
|
||||
try {
|
||||
$temporaryPath = $context['store']->downloadToTemporaryFile($context['object_key']);
|
||||
$mimeType = $this->detectAttachmentMimeType($temporaryPath);
|
||||
$fileName = $this->sanitizeAttachmentDownloadFileName(
|
||||
$context['file_name'],
|
||||
$context['object_key']
|
||||
);
|
||||
$fileSize = filesize($temporaryPath);
|
||||
if ($fileSize === false) {
|
||||
throw new \RuntimeException('Unable to determine attachment size');
|
||||
}
|
||||
|
||||
$this->logOrderAttachmentDownload($context, 'STREAM_ORDER_ATTACHMENT');
|
||||
header('Content-Type: ' . $mimeType);
|
||||
header('Content-Disposition: ' . $disposition . '; filename="' . addcslashes($fileName, "\\\"") . '"; filename*=UTF-8\'\'' . rawurlencode($fileName));
|
||||
header('Content-Length: ' . $fileSize);
|
||||
header('Cache-Control: private, no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
http_response_code(200);
|
||||
readfile($temporaryPath);
|
||||
} finally {
|
||||
if (is_string($temporaryPath) && file_exists($temporaryPath)) {
|
||||
unlink($temporaryPath);
|
||||
}
|
||||
}
|
||||
exit;
|
||||
},
|
||||
[
|
||||
'download_order_attachments' => 'Stream attachments for an order',
|
||||
'download_order_attachments_own' => 'Stream attachments for an order (Only for own orders). Subusers require node: ORDERS_LIST and X-Customer-Number header.'
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/orders/attachments', function () {
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
@@ -1669,6 +1658,146 @@ class ordersRoute
|
||||
return $flags;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{user:object,order:orders_o,order_id:int,attachment_id:int,object_key:string,file_name:string,store:attachment_store|pdf_store}
|
||||
*/
|
||||
private function requireOrderAttachmentDownloadContext(): array
|
||||
{
|
||||
global $response;
|
||||
|
||||
$permissionOwn = self::definePermission('download_order_attachments_own', subusers_permission_node_key::ORDERS_LIST);
|
||||
$permissionOther = self::definePermission('download_order_attachments');
|
||||
$hasPermissionOther = self::hasPermission($permissionOther);
|
||||
if (!$hasPermissionOther) {
|
||||
self::requirePermission($permissionOwn);
|
||||
}
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('orders', 'global', 1, 0, 'DOWNLOAD_ORDER_ATTACHMENT', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
self::requireParameters(['order_id', 'attachment_id']);
|
||||
$orderId = self::getParameter('order_id');
|
||||
$attachmentId = self::getParameter('attachment_id');
|
||||
if (!is_numeric($orderId) || (int)$orderId < 1) {
|
||||
$response->error('Invalid order ID', 400);
|
||||
}
|
||||
if (!is_numeric($attachmentId) || (int)$attachmentId < 1) {
|
||||
$response->error('Invalid attachment ID', 400);
|
||||
}
|
||||
$orderId = (int)$orderId;
|
||||
$attachmentId = (int)$attachmentId;
|
||||
|
||||
$order = (new orders_o())->getOrderById($orderId);
|
||||
if (!$order->exists()) {
|
||||
$response->error('Order not found', 404);
|
||||
}
|
||||
if (!$hasPermissionOther) {
|
||||
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
|
||||
if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) {
|
||||
$response->forbidden([$permissionOther->permission]);
|
||||
}
|
||||
}
|
||||
|
||||
$attachment = $order->getAttachment($attachmentId);
|
||||
$attachmentType = $attachment->exists()
|
||||
? trim((string)$attachment->object_type->value(), '`')
|
||||
: '';
|
||||
if (
|
||||
!$attachment->exists()
|
||||
|| $attachmentType !== 'orders'
|
||||
|| (int)$attachment->object_id->value() !== $orderId
|
||||
|| !empty($attachment->deleted_at->value())
|
||||
) {
|
||||
$response->error('Attachment not found for order', 404);
|
||||
}
|
||||
|
||||
$formattedAttachment = (new attachments())->format($attachment);
|
||||
$objectKey = trim((string)(
|
||||
$formattedAttachment->content->document
|
||||
?? $formattedAttachment->content->image
|
||||
?? ''
|
||||
));
|
||||
if ($objectKey === '') {
|
||||
$response->error('Attachment has no stored file', 404);
|
||||
}
|
||||
|
||||
$attachmentStore = new attachment_store();
|
||||
if (!$attachmentStore->isValidFilePath($objectKey)) {
|
||||
$response->error('Attachment contains an invalid stored file path', 400);
|
||||
}
|
||||
$isWashCertificate = $formattedAttachment->isWashCertificate();
|
||||
$store = $isWashCertificate ? new pdf_store() : $attachmentStore;
|
||||
try {
|
||||
if (!$store->doesObjectExist($objectKey)) {
|
||||
$response->error('Attachment file not found', 404);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$response->error('Attachment storage is unavailable', 502);
|
||||
}
|
||||
|
||||
$originalFileName = is_string($formattedAttachment->content->other)
|
||||
? $formattedAttachment->content->other
|
||||
: '';
|
||||
if ($isWashCertificate) {
|
||||
$originalFileName = 'wash_certificate.pdf';
|
||||
}
|
||||
|
||||
return [
|
||||
'user' => $user,
|
||||
'order' => $order,
|
||||
'order_id' => $orderId,
|
||||
'attachment_id' => $attachmentId,
|
||||
'object_key' => $objectKey,
|
||||
'file_name' => $originalFileName,
|
||||
'store' => $store,
|
||||
];
|
||||
}
|
||||
|
||||
private function logOrderAttachmentDownload(array $context, string $action): void
|
||||
{
|
||||
(new logs_o())->add(
|
||||
'orders',
|
||||
$context['order']->department_id->value(),
|
||||
1,
|
||||
$context['user']->id,
|
||||
$action,
|
||||
'Successfully accessed an attachment for an order (Order ID: '
|
||||
. $context['order_id']
|
||||
. ', Attachment ID: '
|
||||
. $context['attachment_id']
|
||||
. ')'
|
||||
);
|
||||
}
|
||||
|
||||
private function detectAttachmentMimeType(string $path): string
|
||||
{
|
||||
$mimeType = false;
|
||||
if (class_exists(\finfo::class)) {
|
||||
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->file($path);
|
||||
}
|
||||
if ((!is_string($mimeType) || $mimeType === '') && function_exists('mime_content_type')) {
|
||||
$mimeType = mime_content_type($path);
|
||||
}
|
||||
|
||||
return is_string($mimeType) && preg_match('#^[a-z0-9.+-]+/[a-z0-9.+-]+$#i', $mimeType) === 1
|
||||
? $mimeType
|
||||
: 'application/octet-stream';
|
||||
}
|
||||
|
||||
private function sanitizeAttachmentDownloadFileName(string $fileName, string $objectKey): string
|
||||
{
|
||||
$fileName = trim(str_replace(["\r", "\n", "\0"], '', basename($fileName)));
|
||||
if ($fileName === '' || $fileName === '.' || $fileName === '..') {
|
||||
$fileName = basename($objectKey);
|
||||
}
|
||||
|
||||
$fileName = preg_replace('/[\\x00-\\x1F\\x7F\\/\\\\]/u', '_', $fileName) ?? 'attachment';
|
||||
return trim($fileName) !== '' ? $fileName : 'attachment';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
* @param response $response
|
||||
|
||||
@@ -104,6 +104,32 @@ class releaseManagerRoute
|
||||
'superuser_release_manager_deploy' => 'Run Release Manager tests with operation diagnostics',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/releases/coolify-cleanup/preview', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_release_manager_deploy');
|
||||
try {
|
||||
$response->success((new release_manager())->previewCoolifyCleanup($this->requestPayload(), $this->actorUserId()));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 400);
|
||||
}
|
||||
}, [
|
||||
'superuser_release_manager_deploy' => 'Preview Release Manager Coolify resource cleanup',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/releases/coolify-cleanup/apply', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_release_manager_deploy');
|
||||
$this->requirePermission('superuser_coolify_manage');
|
||||
try {
|
||||
$response->success((new release_manager())->applyCoolifyCleanup($this->requestPayload(), $this->actorUserId()), 202);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 400);
|
||||
}
|
||||
}, [
|
||||
'superuser_release_manager_deploy' => 'Apply Release Manager Coolify resource cleanup',
|
||||
'superuser_coolify_manage' => 'Stop or delete Coolify resources managed by Release Manager',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/releases/config', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_release_manager_manage');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\customer_rule_product_restriction_exception;
|
||||
use classes\customer_rule_product_restriction_service;
|
||||
use Throwable;
|
||||
use traits\route_t;
|
||||
|
||||
class superuserCustomerRuleProductRestrictionsRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/superuser/customer-rules/product-restrictions', function () {
|
||||
global $response;
|
||||
$this->requireClassicSuperuserAnyPermission([
|
||||
'superuser_customer_rules_view',
|
||||
'superuser_customer_rules_manage',
|
||||
]);
|
||||
$response->success((new customer_rule_product_restriction_service())->listConfiguration());
|
||||
}, [
|
||||
'superuser_customer_rules_view' => 'View global customer-rule product restrictions',
|
||||
]);
|
||||
|
||||
$this->put('/superuser/customer-rules/product-restrictions/{attribute}', function () {
|
||||
global $response;
|
||||
$this->requireClassicSuperuserPermission('superuser_customer_rules_manage');
|
||||
$attribute = trim((string)$this->fromRoute('attribute'));
|
||||
try {
|
||||
$response->success((new customer_rule_product_restriction_service())->replaceRuleConfiguration(
|
||||
$attribute,
|
||||
$this->getParametersAsArray(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
} catch (customer_rule_product_restriction_exception $exception) {
|
||||
$response->error([
|
||||
'code' => $exception->restrictionCode(),
|
||||
'message' => $exception->getMessage(),
|
||||
], $exception->httpStatus());
|
||||
} catch (Throwable $throwable) {
|
||||
error_log('[customer_rule_product_restrictions] save failed: ' . $throwable->getMessage());
|
||||
$response->error([
|
||||
'code' => 'CUSTOMER_RULE_CONFIGURATION_SAVE_FAILED',
|
||||
'message' => 'Unable to save customer rule configuration',
|
||||
], 500);
|
||||
}
|
||||
}, [
|
||||
'superuser_customer_rules_manage' => 'Manage global customer-rule product restrictions',
|
||||
]);
|
||||
}
|
||||
|
||||
private function requireClassicSuperuserPermission(string $permission): bool
|
||||
{
|
||||
global $response;
|
||||
if ((new authentication())->get_subuser() !== false) {
|
||||
$response->error('Subuser sessions cannot manage customer rules.', 403);
|
||||
}
|
||||
$this->requirePermission('superuser');
|
||||
return $this->requirePermission($permission);
|
||||
}
|
||||
|
||||
/** @param list<string> $permissions */
|
||||
private function requireClassicSuperuserAnyPermission(array $permissions): bool
|
||||
{
|
||||
global $response;
|
||||
if ((new authentication())->get_subuser() !== false) {
|
||||
$response->error('Subuser sessions cannot manage customer rules.', 403);
|
||||
}
|
||||
$this->requirePermission('superuser');
|
||||
foreach ($permissions as $permission) {
|
||||
if ($this->hasPermission($permission)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return $this->requirePermission($permissions[0]);
|
||||
}
|
||||
|
||||
private function actorUserId(): int
|
||||
{
|
||||
$user = (new authentication())->get_user();
|
||||
return $user !== false && $user->exists() ? (int)$user->id : 0;
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,14 @@ namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\replication_manager;
|
||||
use Throwable;
|
||||
use traits\route_t;
|
||||
|
||||
class superuserReplicationRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
private const RETIRED_MANAGEMENT_MESSAGE = 'Replication management has been retired. Use System -> Database, System -> Redis, and System -> MinIO for read-only status.';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/superuser/replication', function () {
|
||||
@@ -24,140 +25,71 @@ class superuserReplicationRoute
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/databases', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_manage');
|
||||
$host = (new replication_manager())->addHost('database', $this->getParametersAsArray(), $this->actorUserId());
|
||||
$response->success($host, 201);
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Add and manage database replication host credentials',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/redis', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_manage');
|
||||
$host = (new replication_manager())->addHost('redis', $this->getParametersAsArray(), $this->actorUserId());
|
||||
$response->success($host, 201);
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Add and manage Redis replication host credentials',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/minio', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_manage');
|
||||
$host = (new replication_manager())->addHost('minio', $this->getParametersAsArray(), $this->actorUserId());
|
||||
$response->success($host, 201);
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Add and manage MinIO replication host credentials',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/compose-template', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_manage');
|
||||
$response->success(replication_manager::composeTemplate($this->getParametersAsArray()));
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Generate Docker Compose templates for replication-ready database, Redis, and MinIO hosts',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/test-credentials', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_manage');
|
||||
$parameters = $this->getParametersAsArray();
|
||||
$response->success((new replication_manager())->testCredentials(
|
||||
(string)($parameters['kind'] ?? ''),
|
||||
$parameters
|
||||
));
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Test database, Redis, and MinIO replication host credentials before saving them',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/{kind}/{id}/test', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_manage');
|
||||
$response->success((new replication_manager())->testHost(
|
||||
(string)$this->fromRoute('kind'),
|
||||
$this->routeId(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Validate database, Redis, and MinIO replication host connectivity and privileges',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/{kind}/{id}/provision', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_manage');
|
||||
try {
|
||||
$result = (new replication_manager())->provisionHost(
|
||||
(string)$this->fromRoute('kind'),
|
||||
$this->routeId(),
|
||||
$this->actorUserId(),
|
||||
true
|
||||
);
|
||||
if (($result['ok'] ?? false) !== true) {
|
||||
$response->error($result, 409);
|
||||
}
|
||||
$response->success($result);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Provision a database, Redis, or MinIO host as a replica of the current primary',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/replication/{kind}/{id}/promote', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_promote');
|
||||
try {
|
||||
$response->success((new replication_manager())->promoteHost(
|
||||
(string)$this->fromRoute('kind'),
|
||||
$this->routeId(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_promote' => 'Promote a healthy caught-up database, Redis, or MinIO replica to primary',
|
||||
]);
|
||||
|
||||
$this->patch('/superuser/replication/{kind}/{id}', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_manage');
|
||||
try {
|
||||
$response->success((new replication_manager())->renameHost(
|
||||
(string)$this->fromRoute('kind'),
|
||||
$this->routeId(),
|
||||
$this->getParametersAsArray(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 400);
|
||||
}
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_manage' => 'Rename database, Redis, and MinIO replication hosts',
|
||||
]);
|
||||
|
||||
$this->delete('/superuser/replication/{kind}/{id}', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_replication_remove');
|
||||
try {
|
||||
$response->success((new replication_manager())->removeHost(
|
||||
(string)$this->fromRoute('kind'),
|
||||
$this->routeId(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 409);
|
||||
}
|
||||
$this->rejectRetiredManagement();
|
||||
}, [
|
||||
'superuser_replication_remove' => 'Remove inactive prior hosts and unhealthy database, Redis, or MinIO replicas',
|
||||
]);
|
||||
@@ -180,21 +112,11 @@ class superuserReplicationRoute
|
||||
return $this->requirePermission($permission);
|
||||
}
|
||||
|
||||
private function routeId(): int
|
||||
private function rejectRetiredManagement(): void
|
||||
{
|
||||
$id = (int)$this->fromRoute('id');
|
||||
$this->requireParameterIntPositive($id, 'id');
|
||||
return $id;
|
||||
}
|
||||
global $response;
|
||||
|
||||
private function actorUserId(): ?int
|
||||
{
|
||||
try {
|
||||
$user = (new authentication())->get_user();
|
||||
return $user !== false && isset($user->id) ? (int)$user->id : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
$response->error(['message' => self::RETIRED_MANAGEMENT_MESSAGE], 410);
|
||||
}
|
||||
|
||||
private function toBool(mixed $value, bool $default): bool
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\security_policy_service;
|
||||
use Throwable;
|
||||
use traits\route_t;
|
||||
|
||||
require_once WD . '/classes/security_policy_service.php';
|
||||
|
||||
class superuserSecurityRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/superuser/system/security/summary', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_view');
|
||||
$response->success((new security_policy_service())->summary());
|
||||
}, [
|
||||
'superuser_security_view' => 'View superuser security settings, firewall rules, and incidents',
|
||||
]);
|
||||
|
||||
$this->get('/superuser/system/security/settings', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_view');
|
||||
$response->success((new security_policy_service())->settings());
|
||||
}, [
|
||||
'superuser_security_view' => 'View superuser security settings, firewall rules, and incidents',
|
||||
'superuser_security_limits_exempt' => 'Exempt requests from observe-mode security limit incidents',
|
||||
]);
|
||||
|
||||
$this->patch('/superuser/system/security/settings', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_settings_manage');
|
||||
try {
|
||||
$response->success((new security_policy_service())->updateSettings(
|
||||
$this->getParametersAsArray(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 400);
|
||||
}
|
||||
}, [
|
||||
'superuser_security_settings_manage' => 'Configure observe-mode security thresholds and exemptions',
|
||||
]);
|
||||
|
||||
$this->get('/superuser/system/security/firewall-rules', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_view');
|
||||
$response->success((new security_policy_service())->listFirewallRules($this->getParametersAsArray()));
|
||||
}, [
|
||||
'superuser_security_view' => 'View superuser security settings, firewall rules, and incidents',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/system/security/firewall-rules', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_firewall_manage');
|
||||
try {
|
||||
$response->success((new security_policy_service())->createFirewallRule(
|
||||
$this->getParametersAsArray(),
|
||||
$this->actorUserId()
|
||||
), 201);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 400);
|
||||
}
|
||||
}, [
|
||||
'superuser_security_firewall_manage' => 'Create, update, and delete application firewall rules',
|
||||
]);
|
||||
|
||||
$this->patch('/superuser/system/security/firewall-rules/{id}', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_firewall_manage');
|
||||
try {
|
||||
$response->success((new security_policy_service())->updateFirewallRule(
|
||||
$this->routeId(),
|
||||
$this->getParametersAsArray(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 400);
|
||||
}
|
||||
}, [
|
||||
'superuser_security_firewall_manage' => 'Create, update, and delete application firewall rules',
|
||||
]);
|
||||
|
||||
$this->delete('/superuser/system/security/firewall-rules/{id}', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_firewall_manage');
|
||||
try {
|
||||
$response->success((new security_policy_service())->deleteFirewallRule(
|
||||
$this->routeId(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 400);
|
||||
}
|
||||
}, [
|
||||
'superuser_security_firewall_manage' => 'Create, update, and delete application firewall rules',
|
||||
]);
|
||||
|
||||
$this->get('/superuser/system/security/incidents', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_view');
|
||||
$response->success((new security_policy_service())->listIncidents($this->getParametersAsArray()));
|
||||
}, [
|
||||
'superuser_security_view' => 'View superuser security settings, firewall rules, and incidents',
|
||||
]);
|
||||
|
||||
$this->get('/superuser/system/security/incidents/{id}', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_view');
|
||||
try {
|
||||
$response->success((new security_policy_service())->incidentDetail($this->routeId()));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 404);
|
||||
}
|
||||
}, [
|
||||
'superuser_security_view' => 'View superuser security settings, firewall rules, and incidents',
|
||||
]);
|
||||
|
||||
$this->patch('/superuser/system/security/incidents/{id}', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_incidents_manage');
|
||||
try {
|
||||
$response->success((new security_policy_service())->updateIncident(
|
||||
$this->routeId(),
|
||||
$this->getParametersAsArray(),
|
||||
$this->actorUserId()
|
||||
));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 400);
|
||||
}
|
||||
}, [
|
||||
'superuser_security_incidents_manage' => 'Acknowledge, resolve, reopen, and note security incidents',
|
||||
]);
|
||||
|
||||
$this->post('/superuser/system/security/incidents/{id}/notes', function () {
|
||||
global $response;
|
||||
|
||||
$this->requireClassicSuperuserPermission('superuser_security_incidents_manage');
|
||||
try {
|
||||
$parameters = $this->getParametersAsArray();
|
||||
$response->success((new security_policy_service())->addIncidentNote(
|
||||
$this->routeId(),
|
||||
(string)($parameters['note'] ?? ''),
|
||||
$this->actorUserId()
|
||||
), 201);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 400);
|
||||
}
|
||||
}, [
|
||||
'superuser_security_incidents_manage' => 'Acknowledge, resolve, reopen, and note security incidents',
|
||||
]);
|
||||
}
|
||||
|
||||
private function requireClassicSuperuserPermission(string $permission): bool
|
||||
{
|
||||
global $response;
|
||||
|
||||
if ((new authentication())->get_subuser() !== false) {
|
||||
$response->error('Subuser sessions cannot manage security controls.', 403);
|
||||
}
|
||||
|
||||
return $this->requirePermission($permission);
|
||||
}
|
||||
|
||||
private function routeId(): int
|
||||
{
|
||||
$id = (int)$this->fromRoute('id');
|
||||
$this->requireParameterIntPositive($id, 'id');
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function actorUserId(): ?int
|
||||
{
|
||||
try {
|
||||
$user = (new authentication())->get_user();
|
||||
return $user !== false && isset($user->id) ? (int)$user->id : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\economic_v2_versioning_service;
|
||||
use classes\security_policy_service;
|
||||
use customers\economic_customer_mo;
|
||||
use objects\bookings_o;
|
||||
use objects\customer_vehicles_o;
|
||||
@@ -17,6 +18,8 @@ use objects\users_o;
|
||||
use traits\route_t;
|
||||
use modules\subusers\helpers\subusers_permission_node_key;
|
||||
|
||||
require_once WD . '/classes/security_policy_service.php';
|
||||
|
||||
class vehiclesRoute
|
||||
{
|
||||
use route_t;
|
||||
@@ -208,9 +211,23 @@ class vehiclesRoute
|
||||
(new logs_o())->add('vehicles', 'global', 0, (int)((new authentication())->get_user()->id ?? 0), 'ADD_VEHICLE_VERSIONING_FAILED', $exception->getMessage());
|
||||
}
|
||||
|
||||
$this->observeVehicleCreated($customerNumber, (int)$vehicle->id, '/superuser/users/{user_id}/vehicles');
|
||||
|
||||
return $vehicle->asArray();
|
||||
}
|
||||
|
||||
private function observeVehicleCreated(int $customerNumber, int $vehicleId, string $route): void
|
||||
{
|
||||
try {
|
||||
(new security_policy_service())->observeVehicleCreated($customerNumber, [
|
||||
'vehicle_id' => $vehicleId,
|
||||
'route' => $route,
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
// Security observation must not change vehicle creation responses.
|
||||
}
|
||||
}
|
||||
|
||||
private function updateScopedVehicle(customer_vehicles_o $vehicle, int $customerNumber): array
|
||||
{
|
||||
global $response;
|
||||
@@ -599,6 +616,7 @@ class vehiclesRoute
|
||||
}
|
||||
|
||||
(new logs_o())->add('vehicles', 'global', 1, (int)($user->id ?? 0), 'ADD_VEHICLE', 'Successfully added vehicle');
|
||||
$this->observeVehicleCreated((int)$targetCustomer, (int)$vehicle->id, '/vehicles');
|
||||
$response->success($vehicle->asArray());
|
||||
},
|
||||
[
|
||||
|
||||
@@ -106,6 +106,7 @@ it('includes economic runtime config for uncached auth sessions', function (): v
|
||||
$session = api_fixtures()->createUserSession(['list_departments'], [
|
||||
'display_name' => 'Fresh Session User',
|
||||
]);
|
||||
api_fixtures()->addCustomerAttribute((int)$session['user']['id'], 'restrictSpotFree');
|
||||
|
||||
$response = api_client()->get('/auth/session', $session['headers']);
|
||||
|
||||
@@ -120,7 +121,9 @@ it('includes economic runtime config for uncached auth sessions', function (): v
|
||||
->and($response->data()['runtime_config']['economic']['transaction_draft_customer_number'] ?? null)
|
||||
->toBe(556677)
|
||||
->and($response->data()['runtime_config']['economic']['default_distribution_department_id'] ?? null)
|
||||
->toBe(65);
|
||||
->toBe(65)
|
||||
->and($response->data()['permissions'] ?? [])
|
||||
->toContain('has_attribute_restrictSpotFree');
|
||||
});
|
||||
|
||||
it('rejects invalid auth session tokens', function (): void {
|
||||
|
||||
@@ -16,6 +16,25 @@ function bulk_action_order_invoice_collection_id(int $orderId): int
|
||||
return (int)($row['invoice_collection_id'] ?? 0);
|
||||
}
|
||||
|
||||
function bulk_action_configure_rule_product(string $attribute, int $productId): void
|
||||
{
|
||||
new \classes\customer_rule_product_restriction_service();
|
||||
$db = api_test_runtime()->db();
|
||||
$safeAttribute = $db->real_escape_string($attribute);
|
||||
$result = $db->query(
|
||||
"SELECT id FROM customer_rule_product_collections WHERE attribute = '{$safeAttribute}' ORDER BY sort_order, id LIMIT 1"
|
||||
);
|
||||
$collectionId = (int)$result->fetch_assoc()['id'];
|
||||
$db->query(
|
||||
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
|
||||
VALUES ({$collectionId}, {$productId})"
|
||||
);
|
||||
api_fixtures()->cleanupDeleteWhere('customer_rule_product_collection_products', [
|
||||
'collection_id' => $collectionId,
|
||||
'product_id' => $productId,
|
||||
]);
|
||||
}
|
||||
|
||||
it('previews and applies customer rule cleanup only after exact typed confirmation', function (): void {
|
||||
api_test_covers('POST /collected-invoices/bulk-actions/preview', 'customer-rule-cleanup');
|
||||
api_test_covers('POST /collected-invoices/bulk-actions/apply', 'customer-rule-cleanup');
|
||||
@@ -35,6 +54,7 @@ it('previews and applies customer rule cleanup only after exact typed confirmati
|
||||
'name' => 'Spot Free rinse',
|
||||
'price' => 80,
|
||||
]);
|
||||
bulk_action_configure_rule_product('restrictSpotFree', (int)$product['id']);
|
||||
$orderItem = api_fixtures()->createOrderItem([
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $product['id'],
|
||||
@@ -120,6 +140,8 @@ it('previews and applies customer rule cleanup for both spotfree addon products'
|
||||
'category' => 4,
|
||||
'price' => 39,
|
||||
]);
|
||||
bulk_action_configure_rule_product('restrictSpotFree', (int)$spotfreeVanProduct['id']);
|
||||
bulk_action_configure_rule_product('restrictSpotFree', (int)$spotfreeTruckProduct['id']);
|
||||
$vanOrderItem = api_fixtures()->createOrderItem([
|
||||
'order_id' => $order['id'],
|
||||
'product_id' => $spotfreeVanProduct['id'],
|
||||
|
||||
@@ -72,3 +72,80 @@ it('still lets attribute managers read another customer attributes', function ()
|
||||
|
||||
expect($attributes)->toContain('onlyTankCleaning');
|
||||
});
|
||||
|
||||
it('returns exact product restrictions for product-impact attributes and null for workflow attributes', function (): void {
|
||||
api_test_covers('GET /customer/attributes', 'product_restrictions');
|
||||
|
||||
$session = api_fixtures()->createUserSession(['list_customer_attributes']);
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Configured Attribute Customer']);
|
||||
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'restrictSpotFree');
|
||||
api_fixtures()->addCustomerAttribute((int)$customer['id'], 'exemptFromAdministrationFee');
|
||||
$product = api_fixtures()->createProduct(['name' => 'Configured exact rinse']);
|
||||
|
||||
new \classes\customer_rule_product_restriction_service();
|
||||
$db = api_test_runtime()->db();
|
||||
$collectionResult = $db->query(
|
||||
"SELECT id FROM customer_rule_product_collections
|
||||
WHERE attribute = 'restrictSpotFree' ORDER BY sort_order, id LIMIT 1"
|
||||
);
|
||||
$collectionId = (int)$collectionResult->fetch_assoc()['id'];
|
||||
$db->query(
|
||||
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
|
||||
VALUES ({$collectionId}, " . (int)$product['id'] . ')'
|
||||
);
|
||||
api_fixtures()->cleanupDeleteWhere('customer_rule_product_collection_products', [
|
||||
'collection_id' => $collectionId,
|
||||
'product_id' => (int)$product['id'],
|
||||
]);
|
||||
|
||||
$response = api_client()->get(
|
||||
'/customer/attributes?customer_number=' . (int)$customer['customer_number'],
|
||||
$session['headers']
|
||||
);
|
||||
$response->assertStatus(200)->assertEnvelope()->assertSuccess();
|
||||
|
||||
$byAttribute = [];
|
||||
foreach ($response->data() as $attribute) {
|
||||
$byAttribute[(string)$attribute['attribute']] = $attribute;
|
||||
}
|
||||
expect($byAttribute['restrictSpotFree']['product_restriction']['disabled_product_ids'] ?? [])
|
||||
->toContain((int)$product['id'])
|
||||
->and($byAttribute['restrictSpotFree']['product_restriction']['collections'] ?? [])->not->toBeEmpty()
|
||||
->and($byAttribute['exemptFromAdministrationFee'])->toHaveKey('product_restriction')
|
||||
->and($byAttribute['exemptFromAdministrationFee']['product_restriction'])->toBeNull();
|
||||
});
|
||||
|
||||
it('keeps workflow-only customer attribute activation compatible', function (): void {
|
||||
api_test_covers('POST /customer/attributes', 'workflow_compatibility');
|
||||
api_test_covers('DELETE /customer/attributes', 'workflow_compatibility');
|
||||
|
||||
$session = api_fixtures()->createUserSession([
|
||||
'list_customer_attributes',
|
||||
'add_customer_attribute',
|
||||
'delete_customer_attribute',
|
||||
]);
|
||||
$customer = api_fixtures()->createUser(['display_name' => 'Workflow Attribute Customer']);
|
||||
|
||||
api_client()->post('/customer/attributes', [
|
||||
'user_id' => (int)$customer['id'],
|
||||
'attribute' => 'exemptFromAdministrationFee',
|
||||
], $session['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$listed = api_client()->get(
|
||||
'/customer/attributes?customer_number=' . (int)$customer['customer_number'],
|
||||
$session['headers']
|
||||
);
|
||||
expect(array_column($listed->data(), 'attribute'))->toContain('exemptFromAdministrationFee');
|
||||
|
||||
api_client()->delete(
|
||||
'/customer/attributes?user_id=' . (int)$customer['id'] . '&attribute=exemptFromAdministrationFee',
|
||||
null,
|
||||
$session['headers']
|
||||
)
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
it('requires classic superuser view and manage permissions for global rule configuration', function (): void {
|
||||
api_test_covers('GET /superuser/customer-rules/product-restrictions', 'permissions');
|
||||
api_test_covers('PUT /superuser/customer-rules/product-restrictions/{attribute}', 'permissions');
|
||||
|
||||
$withoutView = api_fixtures()->createUserSession(['superuser']);
|
||||
api_client()->get('/superuser/customer-rules/product-restrictions', $withoutView['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['superuser_customer_rules_view']);
|
||||
|
||||
$viewOnly = api_fixtures()->createUserSession(['superuser', 'superuser_customer_rules_view']);
|
||||
$configuration = api_client()->get('/superuser/customer-rules/product-restrictions', $viewOnly['headers']);
|
||||
$configuration->assertStatus(200)->assertEnvelope()->assertSuccess();
|
||||
expect($configuration->data()['rules'] ?? [])->toHaveCount(5)
|
||||
->and($configuration->data()['products'] ?? null)->toBeArray();
|
||||
|
||||
$rule = $configuration->data()['rules'][0];
|
||||
api_client()->put(
|
||||
'/superuser/customer-rules/product-restrictions/' . $rule['attribute'],
|
||||
['version' => $rule['version'], 'collections' => $rule['collections']],
|
||||
$viewOnly['headers']
|
||||
)
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMissingPermissions(['superuser_customer_rules_manage']);
|
||||
|
||||
$manageOnly = api_fixtures()->createUserSession(['superuser', 'superuser_customer_rules_manage']);
|
||||
api_client()->get('/superuser/customer-rules/product-restrictions', $manageOnly['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
it('atomically replaces collections and rejects stale versions and invalid products', function (): void {
|
||||
api_test_covers('PUT /superuser/customer-rules/product-restrictions/{attribute}', 'versioned_atomic_replace');
|
||||
|
||||
$session = api_fixtures()->createUserSession([
|
||||
'superuser',
|
||||
'superuser_customer_rules_view',
|
||||
'superuser_customer_rules_manage',
|
||||
]);
|
||||
$product = api_fixtures()->createProduct(['name' => 'Managed exact customer-rule product']);
|
||||
$get = api_client()->get('/superuser/customer-rules/product-restrictions', $session['headers']);
|
||||
$rules = $get->data()['rules'];
|
||||
$rule = array_values(array_filter(
|
||||
$rules,
|
||||
static fn(array $candidate): bool => $candidate['attribute'] === 'restrictInteriorCleaning'
|
||||
))[0];
|
||||
$collectionName = 'API managed ' . bin2hex(random_bytes(5));
|
||||
$collections = $rule['collections'];
|
||||
$collections[] = [
|
||||
'name' => $collectionName,
|
||||
'sort_order' => 999,
|
||||
'product_ids' => [(int)$product['id']],
|
||||
];
|
||||
|
||||
$saved = api_client()->put(
|
||||
'/superuser/customer-rules/product-restrictions/restrictInteriorCleaning',
|
||||
['version' => $rule['version'], 'collections' => $collections],
|
||||
$session['headers']
|
||||
);
|
||||
$saved->assertStatus(200)->assertEnvelope()->assertSuccess();
|
||||
expect($saved->data()['version'])->toBe((int)$rule['version'] + 1)
|
||||
->and($saved->data()['disabled_product_ids'])->toContain((int)$product['id']);
|
||||
|
||||
$managedCollection = array_values(array_filter(
|
||||
$saved->data()['collections'],
|
||||
static fn(array $collection): bool => $collection['name'] === $collectionName
|
||||
))[0];
|
||||
$collectionId = (int)$managedCollection['id'];
|
||||
|
||||
try {
|
||||
api_client()->put(
|
||||
'/superuser/customer-rules/product-restrictions/restrictInteriorCleaning',
|
||||
['version' => $rule['version'], 'collections' => $collections],
|
||||
$session['headers']
|
||||
)
|
||||
->assertStatus(409)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Customer rule configuration has changed; reload before saving');
|
||||
|
||||
$invalidCollections = $saved->data()['collections'];
|
||||
$invalidCollections[0]['product_ids'][] = 2147483647;
|
||||
api_client()->put(
|
||||
'/superuser/customer-rules/product-restrictions/restrictInteriorCleaning',
|
||||
['version' => $saved->data()['version'], 'collections' => $invalidCollections],
|
||||
$session['headers']
|
||||
)
|
||||
->assertStatus(422)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
|
||||
$unchanged = api_client()->get('/superuser/customer-rules/product-restrictions', $session['headers']);
|
||||
$current = array_values(array_filter(
|
||||
$unchanged->data()['rules'],
|
||||
static fn(array $candidate): bool => $candidate['attribute'] === 'restrictInteriorCleaning'
|
||||
))[0];
|
||||
expect($current['version'])->toBe($saved->data()['version'])
|
||||
->and($current['disabled_product_ids'])->not->toContain(2147483647);
|
||||
} finally {
|
||||
$db = api_test_runtime()->db();
|
||||
$db->query("DELETE FROM customer_rule_product_collection_products WHERE collection_id = {$collectionId}");
|
||||
$db->query("DELETE FROM customer_rule_product_collections WHERE id = {$collectionId}");
|
||||
$db->query(
|
||||
"UPDATE customer_rule_product_restrictions SET version = " . (int)$rule['version'] .
|
||||
" WHERE attribute = 'restrictInteriorCleaning'"
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
it('lists only visible active departments with public time bookings enabled', function (): void {
|
||||
api_test_covers('GET /department/timebookings/departments/public', 'happy');
|
||||
|
||||
$enabledDepartment = api_fixtures()->createDepartment([
|
||||
'name' => 'Public Time Booking Enabled ' . uniqid('', false),
|
||||
'description' => 'Enabled booking department address',
|
||||
'visible' => 1,
|
||||
'archived' => 0,
|
||||
'latitude' => 55.1,
|
||||
'longitude' => 12.1,
|
||||
'order_priority' => 7,
|
||||
]);
|
||||
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$enabledDepartment['id'], true);
|
||||
|
||||
$disabledDepartment = api_fixtures()->createDepartment([
|
||||
'name' => 'Public Time Booking Disabled ' . uniqid('', false),
|
||||
'visible' => 1,
|
||||
'archived' => 0,
|
||||
]);
|
||||
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$disabledDepartment['id'], false);
|
||||
|
||||
$missingVariableDepartment = api_fixtures()->createDepartment([
|
||||
'name' => 'Public Time Booking Missing Variable ' . uniqid('', false),
|
||||
'visible' => 1,
|
||||
'archived' => 0,
|
||||
]);
|
||||
|
||||
$legacyTruthyDepartment = api_fixtures()->createDepartment([
|
||||
'name' => 'Public Time Booking Legacy Truthy ' . uniqid('', false),
|
||||
'visible' => 1,
|
||||
'archived' => 0,
|
||||
]);
|
||||
api_fixtures()->setDepartmentVariable(
|
||||
(int)$legacyTruthyDepartment['id'],
|
||||
'bookingsystem_time_based_enabled',
|
||||
'1'
|
||||
);
|
||||
|
||||
$hiddenDepartment = api_fixtures()->createDepartment([
|
||||
'name' => 'Public Time Booking Hidden ' . uniqid('', false),
|
||||
'visible' => 0,
|
||||
'archived' => 0,
|
||||
]);
|
||||
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$hiddenDepartment['id'], true);
|
||||
|
||||
$archivedDepartment = api_fixtures()->createDepartment([
|
||||
'name' => 'Public Time Booking Archived ' . uniqid('', false),
|
||||
'visible' => 1,
|
||||
'archived' => 1,
|
||||
]);
|
||||
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$archivedDepartment['id'], true);
|
||||
|
||||
$response = api_client()->get('/department/timebookings/departments/public');
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$departmentsById = [];
|
||||
foreach ($response->data() as $department) {
|
||||
$departmentsById[(int)($department['id'] ?? 0)] = $department;
|
||||
}
|
||||
|
||||
expect($departmentsById)
|
||||
->toHaveKey((int)$enabledDepartment['id'])
|
||||
->not->toHaveKey((int)$disabledDepartment['id'])
|
||||
->not->toHaveKey((int)$missingVariableDepartment['id'])
|
||||
->not->toHaveKey((int)$legacyTruthyDepartment['id'])
|
||||
->not->toHaveKey((int)$hiddenDepartment['id'])
|
||||
->not->toHaveKey((int)$archivedDepartment['id']);
|
||||
|
||||
$returnedEnabledDepartment = $departmentsById[(int)$enabledDepartment['id']];
|
||||
expect($returnedEnabledDepartment)
|
||||
->toHaveKey('name')
|
||||
->toHaveKey('description', 'Enabled booking department address')
|
||||
->toHaveKey('address', 'Enabled booking department address')
|
||||
->toHaveKey('time_booking_enabled', true)
|
||||
->not->toHaveKey('slack_webhook')
|
||||
->not->toHaveKey('custom_pricing_only')
|
||||
->not->toHaveKey('variables')
|
||||
->not->toHaveKey('bookingsystem_time_based_enabled');
|
||||
});
|
||||
|
||||
it('returns an empty public time-booking department list when no matching department is enabled', function (): void {
|
||||
api_test_covers('GET /department/timebookings/departments/public', 'empty');
|
||||
|
||||
$uniqueName = 'Public Time Booking Empty ' . uniqid('', false);
|
||||
$disabledDepartment = api_fixtures()->createDepartment([
|
||||
'name' => $uniqueName,
|
||||
'visible' => 1,
|
||||
'archived' => 0,
|
||||
]);
|
||||
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$disabledDepartment['id'], false);
|
||||
|
||||
$response = api_client()->get(
|
||||
'/department/timebookings/departments/public?search=' . rawurlencode($uniqueName)
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($response->data())->toBe([]);
|
||||
});
|
||||
|
||||
it('rejects public time-booking detail requests for disabled departments', function (): void {
|
||||
api_test_covers('GET /department/timebookings/types/public', 'disabled');
|
||||
|
||||
$disabledDepartment = api_fixtures()->createDepartment([
|
||||
'name' => 'Disabled Public Time Booking Detail ' . uniqid('', false),
|
||||
'visible' => 1,
|
||||
'archived' => 0,
|
||||
]);
|
||||
api_fixtures()->setDepartmentTimeBookingsEnabled((int)$disabledDepartment['id'], false);
|
||||
|
||||
api_client()->get('/department/timebookings/types/public?id=' . (int)$disabledDepartment['id'])
|
||||
->assertStatus(404)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Department time bookings are not enabled');
|
||||
});
|
||||
@@ -36,6 +36,33 @@ function post_order_item(array $order, array $product, array $headers, array $ov
|
||||
], $overrides), $headers);
|
||||
}
|
||||
|
||||
function configure_customer_rule_product(string $attribute, int $productId): void
|
||||
{
|
||||
new \classes\customer_rule_product_restriction_service();
|
||||
$db = api_test_runtime()->db();
|
||||
$safeAttribute = $db->real_escape_string($attribute);
|
||||
$result = $db->query(
|
||||
"SELECT id FROM customer_rule_product_collections
|
||||
WHERE attribute = '{$safeAttribute}' ORDER BY sort_order, id LIMIT 1"
|
||||
);
|
||||
$collectionId = $result && $result->num_rows > 0 ? (int)$result->fetch_assoc()['id'] : 0;
|
||||
if ($collectionId < 1) {
|
||||
$db->query(
|
||||
"INSERT INTO customer_rule_product_collections (attribute, name, sort_order)
|
||||
VALUES ('{$safeAttribute}', 'API exact restriction', 0)"
|
||||
);
|
||||
$collectionId = (int)$db->insert_id;
|
||||
}
|
||||
$db->query(
|
||||
"INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id)
|
||||
VALUES ({$collectionId}, {$productId})"
|
||||
);
|
||||
api_fixtures()->cleanupDeleteWhere('customer_rule_product_collection_products', [
|
||||
'collection_id' => $collectionId,
|
||||
'product_id' => $productId,
|
||||
]);
|
||||
}
|
||||
|
||||
function custom_pricing_only_price_override(int $userId, int $productId, int $percentage): void
|
||||
{
|
||||
$statement = api_test_runtime()->db()->prepare(
|
||||
@@ -198,6 +225,7 @@ it('only allows tankcleaning products for only tankcleaning customers', function
|
||||
'category' => 5,
|
||||
]);
|
||||
$session = api_fixtures()->createUserSession([], ['group_id' => 1]);
|
||||
configure_customer_rule_product('onlyTankCleaning', (int)$washProduct['id']);
|
||||
|
||||
api_client()
|
||||
->post('/order/items', [
|
||||
@@ -317,7 +345,7 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun
|
||||
expect($response->data()['requires_note'] ?? null)->toBeTrue();
|
||||
});
|
||||
|
||||
it('blocks addon products added as standalone additional order items for customers restricted from additional services', function (): void {
|
||||
it('blocks standalone category 8 products for customers restricted from additional services', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
|
||||
@@ -326,10 +354,11 @@ it('blocks addon products added as standalone additional order items for custome
|
||||
'price' => 200,
|
||||
]);
|
||||
$addonProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Drying add-on',
|
||||
'category' => 4,
|
||||
'name' => 'Extra detergent',
|
||||
'category' => 8,
|
||||
'price' => 50,
|
||||
]);
|
||||
configure_customer_rule_product('restrictAdditionalServices', (int)$addonProduct['id']);
|
||||
|
||||
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
|
||||
->assertStatus(200)
|
||||
@@ -345,7 +374,7 @@ it('blocks addon products added as standalone additional order items for custome
|
||||
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||
});
|
||||
|
||||
it('allows standalone additional order items when the customer is not restricted from additional services', function (): void {
|
||||
it('allows standalone category 8 products when the customer is not restricted from additional services', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture();
|
||||
@@ -354,8 +383,8 @@ it('allows standalone additional order items when the customer is not restricted
|
||||
'price' => 200,
|
||||
]);
|
||||
$addonProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Unrestricted add-on',
|
||||
'category' => 4,
|
||||
'name' => 'Unrestricted additional service',
|
||||
'category' => 8,
|
||||
'price' => 50,
|
||||
]);
|
||||
|
||||
@@ -370,7 +399,7 @@ it('allows standalone additional order items when the customer is not restricted
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
it('blocks related addon order items for customers restricted from additional services', function (): void {
|
||||
it('blocks configured related addon order items for customers restricted from additional services', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
|
||||
@@ -381,6 +410,7 @@ it('blocks related addon order items for customers restricted from additional se
|
||||
]);
|
||||
$addonProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Related extra brush',
|
||||
'category' => 8,
|
||||
'price' => 35,
|
||||
]);
|
||||
$primaryItem = api_fixtures()->createOrderItem([
|
||||
@@ -389,6 +419,7 @@ it('blocks related addon order items for customers restricted from additional se
|
||||
'cashier_id' => $cashier['id'],
|
||||
'price' => 200,
|
||||
]);
|
||||
configure_customer_rule_product('restrictAdditionalServices', (int)$addonProduct['id']);
|
||||
|
||||
post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [
|
||||
'related_item_id' => $primaryItem['id'],
|
||||
@@ -399,11 +430,138 @@ it('blocks related addon order items for customers restricted from additional se
|
||||
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||
});
|
||||
|
||||
it('still blocks related add-ons covered by a specific customer product rule', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture(['restrictInteriorCleaning']);
|
||||
$cashier = api_fixtures()->createUser(['display_name' => 'Specific Related Rule Cashier']);
|
||||
$primaryProduct = api_fixtures()->createProduct(['name' => 'Primary truck wash', 'price' => 200]);
|
||||
$interiorProduct = api_fixtures()->createProduct(['name' => 'Indvendig vask Forvogn', 'category' => 4, 'price' => 35]);
|
||||
$primaryItem = api_fixtures()->createOrderItem([
|
||||
'order_id' => $fixture['order']['id'],
|
||||
'product_id' => $primaryProduct['id'],
|
||||
'cashier_id' => $cashier['id'],
|
||||
'price' => 200,
|
||||
]);
|
||||
configure_customer_rule_product('restrictInteriorCleaning', (int)$interiorProduct['id']);
|
||||
|
||||
post_order_item($fixture['order'], $interiorProduct, $fixture['session']['headers'], [
|
||||
'related_item_id' => $primaryItem['id'],
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
|
||||
});
|
||||
|
||||
it('does not infer additional-service restrictions from category 4, product names, or existing order items', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
|
||||
$primaryProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Primary wash',
|
||||
'category' => 4,
|
||||
'price' => 200,
|
||||
]);
|
||||
$namedAddonProduct = api_fixtures()->createProduct([
|
||||
'name' => 'Trailer add-on',
|
||||
'category' => 4,
|
||||
'price' => 35,
|
||||
]);
|
||||
|
||||
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
post_order_item($fixture['order'], $namedAddonProduct, $fixture['session']['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
it('does not infer additional-service restrictions from the legacy Tillægsydelser category after migration', function (): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture(['restrictAdditionalServices']);
|
||||
$category = api_fixtures()->createCategory(['name' => 'Tillægsydelser']);
|
||||
$product = api_fixtures()->createProduct([
|
||||
'name' => 'Legacy additional service',
|
||||
'category' => $category['id'],
|
||||
'price' => 35,
|
||||
]);
|
||||
|
||||
post_order_item($fixture['order'], $product, $fixture['session']['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
it('validates that related order items exist, are active, and belong to the target order', function (): void {
|
||||
api_test_covers('POST /order/items', 'related-item-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture();
|
||||
$cashier = api_fixtures()->createUser(['display_name' => 'Related Item Cashier']);
|
||||
$product = api_fixtures()->createProduct(['name' => 'Related item validation product', 'price' => 35]);
|
||||
$otherOrder = api_fixtures()->createOrder([
|
||||
'customer_id' => $fixture['customer']['customer_number'],
|
||||
'department_id' => $fixture['department']['id'],
|
||||
'cashier_id' => $cashier['id'],
|
||||
]);
|
||||
$validParent = api_fixtures()->createOrderItem([
|
||||
'order_id' => $fixture['order']['id'],
|
||||
'product_id' => $product['id'],
|
||||
'cashier_id' => $cashier['id'],
|
||||
'price' => 35,
|
||||
]);
|
||||
$otherOrderParent = api_fixtures()->createOrderItem([
|
||||
'order_id' => $otherOrder['id'],
|
||||
'product_id' => $product['id'],
|
||||
'cashier_id' => $cashier['id'],
|
||||
'price' => 35,
|
||||
]);
|
||||
$deletedParent = api_fixtures()->createOrderItem([
|
||||
'order_id' => $fixture['order']['id'],
|
||||
'product_id' => $product['id'],
|
||||
'cashier_id' => $cashier['id'],
|
||||
'price' => 35,
|
||||
'deleted_at' => '2026-07-15 12:00:00',
|
||||
]);
|
||||
|
||||
foreach ([$deletedParent['id'], 999999999] as $missingParentId) {
|
||||
post_order_item($fixture['order'], $product, $fixture['session']['headers'], [
|
||||
'related_item_id' => $missingParentId,
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Related order item not found');
|
||||
}
|
||||
|
||||
post_order_item($fixture['order'], $product, $fixture['session']['headers'], [
|
||||
'related_item_id' => $otherOrderParent['id'],
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Related order item must belong to the same order');
|
||||
|
||||
$response = post_order_item($fixture['order'], $product, $fixture['session']['headers'], [
|
||||
'related_item_id' => $validParent['id'],
|
||||
]);
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
expect((int)($response->data()['related_item_id'] ?? 0))->toBe((int)$validParent['id']);
|
||||
});
|
||||
|
||||
it('blocks named restricted service products for the selected customer', function (string $attribute, array $productAttributes): void {
|
||||
api_test_covers('POST /order/items', 'customer-rule-validation');
|
||||
|
||||
$fixture = create_order_item_rule_fixture([$attribute]);
|
||||
$product = api_fixtures()->createProduct($productAttributes);
|
||||
configure_customer_rule_product($attribute, (int)$product['id']);
|
||||
|
||||
post_order_item($fixture['order'], $product, $fixture['session']['headers'])
|
||||
->assertStatus(400)
|
||||
@@ -429,6 +587,7 @@ it('only allows tank cleaning products when the customer has the only tank clean
|
||||
'category' => 5,
|
||||
'price' => 300,
|
||||
]);
|
||||
configure_customer_rule_product('onlyTankCleaning', (int)$nonTankProduct['id']);
|
||||
|
||||
post_order_item($fixture['order'], $nonTankProduct, $fixture['session']['headers'])
|
||||
->assertStatus(400)
|
||||
|
||||
@@ -36,6 +36,7 @@ function selfserve_customer_start_make_available(int $laneId): void
|
||||
it('allows the customer self-serve start sequence without department access', function (): void {
|
||||
$group = api_fixtures()->createGroup([], [
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
$scenario = api_fixtures()->createSelfServeScenario([
|
||||
'customer' => ['group_id' => $group['id']],
|
||||
@@ -90,6 +91,7 @@ it('allows the customer self-serve start sequence without department access', fu
|
||||
it('marks the active customer session relay-enabled after machine relay enable', function (): void {
|
||||
$group = api_fixtures()->createGroup([], [
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
'modules_selfserve_lane_relay_enable_machine',
|
||||
]);
|
||||
$scenario = api_fixtures()->createSelfServeScenario([
|
||||
@@ -158,6 +160,7 @@ it('marks the active customer session relay-enabled after machine relay enable',
|
||||
it('derives allowed services from v2 session task snapshots when task rows are not legacy records', function (): void {
|
||||
$group = api_fixtures()->createGroup([], [
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
$scenario = api_fixtures()->createSelfServeScenario([
|
||||
'customer' => ['group_id' => $group['id']],
|
||||
@@ -201,6 +204,7 @@ it('derives allowed services from v2 session task snapshots when task rows are n
|
||||
it('derives allowed services from published v2 config task snapshots when no session exists yet', function (): void {
|
||||
$group = api_fixtures()->createGroup([], [
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
$scenario = api_fixtures()->createSelfServeScenario([
|
||||
'customer' => ['group_id' => $group['id']],
|
||||
@@ -267,6 +271,7 @@ it('derives allowed services from published v2 config task snapshots when no ses
|
||||
it('keeps questions visible but disables machine services and tasks when machine wash is globally disabled', function (): void {
|
||||
$group = api_fixtures()->createGroup([], [
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
$scenario = api_fixtures()->createSelfServeScenario([
|
||||
'customer' => ['group_id' => $group['id']],
|
||||
@@ -316,6 +321,7 @@ it('keeps questions visible but disables machine services and tasks when machine
|
||||
it('keeps long generated task descriptions when refreshing vehicle eligibility snapshots', function (): void {
|
||||
$group = api_fixtures()->createGroup([], [
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
$scenario = api_fixtures()->createSelfServeScenario([
|
||||
'customer' => ['group_id' => $group['id']],
|
||||
@@ -360,6 +366,7 @@ it('keeps long generated task descriptions when refreshing vehicle eligibility s
|
||||
it('does not create an active wash preview session from read-only eligibility checks', function (): void {
|
||||
$group = api_fixtures()->createGroup([], [
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
$scenario = api_fixtures()->createSelfServeScenario([
|
||||
'customer' => ['group_id' => $group['id']],
|
||||
@@ -414,6 +421,7 @@ it('does not create an active wash preview session from read-only eligibility ch
|
||||
it('creates a durable wash session from customer start after read-only eligibility', function (): void {
|
||||
$group = api_fixtures()->createGroup([], [
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
$scenario = api_fixtures()->createSelfServeScenario([
|
||||
'customer' => ['group_id' => $group['id']],
|
||||
@@ -482,6 +490,7 @@ it('creates a durable wash session from customer start after read-only eligibili
|
||||
it('refreshes an active wash summary with vehicle type without a namespace error', function (): void {
|
||||
$group = api_fixtures()->createGroup([], [
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
$scenario = api_fixtures()->createSelfServeScenario([
|
||||
'customer' => ['group_id' => $group['id']],
|
||||
|
||||
@@ -51,6 +51,7 @@ function selfserve_lane_command_make_occupied(int $laneId, int $customerNumber,
|
||||
it('allows customer self-serve permission to execute START without department access when department and lane are enabled', function (): void {
|
||||
$group = api_fixtures()->createGroup([], [
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
$scenario = api_fixtures()->createSelfServeScenario([
|
||||
'customer' => ['group_id' => $group['id']],
|
||||
@@ -79,6 +80,7 @@ it('allows customer self-serve permission to execute START without department ac
|
||||
it('denies customer START when department self-serve is disabled', function (): void {
|
||||
$group = api_fixtures()->createGroup([], [
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
$scenario = api_fixtures()->createSelfServeScenario([
|
||||
'customer' => ['group_id' => $group['id']],
|
||||
@@ -98,13 +100,14 @@ it('denies customer START when department self-serve is disabled', function ():
|
||||
->assertStatus(403)
|
||||
->assertMissingPermissions([
|
||||
'department_access_' . (int)$scenario['department']['id'],
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
});
|
||||
|
||||
it('denies customer START when lane self-serve is disabled', function (): void {
|
||||
$group = api_fixtures()->createGroup([], [
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
$scenario = api_fixtures()->createSelfServeScenario([
|
||||
'customer' => ['group_id' => $group['id']],
|
||||
@@ -124,7 +127,7 @@ it('denies customer START when lane self-serve is disabled', function (): void {
|
||||
->assertStatus(403)
|
||||
->assertMissingPermissions([
|
||||
'department_access_' . (int)$scenario['department']['id'],
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ it('reports both elevated and customer self-serve permissions when lane polling
|
||||
->assertStatus(403)
|
||||
->assertMissingPermissions([
|
||||
'modules_selfserve_lane_wash_in_progress_view',
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -42,13 +42,29 @@ it('requires customer self-serve permission before checking my active wash', fun
|
||||
$response
|
||||
->assertStatus(403)
|
||||
->assertMissingPermissions([
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
});
|
||||
|
||||
it('denies list-only drivers from operational self-serve active wash restore', function (): void {
|
||||
$customer = api_fixtures()->createUser();
|
||||
$driverSession = api_fixtures()->createSubuserSession((int)$customer['customer_number'], [
|
||||
'SELFSERVE_LIST',
|
||||
]);
|
||||
|
||||
$response = api_client()->get('/modules/self-serve/lane/wash/my-active-wash', $driverSession['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(403)
|
||||
->assertMissingPermissions([
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows customer self-serve permission to view their own in-progress wash details', function (): void {
|
||||
$group = api_fixtures()->createGroup([], [
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
$scenario = api_fixtures()->createSelfServeScenario([
|
||||
'customer' => [
|
||||
@@ -74,6 +90,7 @@ it('allows customer self-serve permission to view their own in-progress wash det
|
||||
it('returns the authenticated customers active self-serve wash without requiring a lane id', function (): void {
|
||||
$group = api_fixtures()->createGroup([], [
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
$scenario = api_fixtures()->createSelfServeScenario([
|
||||
'customer' => [
|
||||
@@ -99,9 +116,67 @@ it('returns the authenticated customers active self-serve wash without requiring
|
||||
->and($response->data()['vehicle']['reg'] ?? null)->toBe($scenario['vehicle']['reg']);
|
||||
});
|
||||
|
||||
it('scopes active self-serve wash restore to the authenticated driver under a shared customer number', function (): void {
|
||||
$scenario = api_fixtures()->createSelfServeScenario();
|
||||
$customerNumber = (int)$scenario['customer']['customer_number'];
|
||||
$ownerDriver = api_fixtures()->createSubuserSession($customerNumber, [
|
||||
'SELFSERVE_LIST',
|
||||
'SELFSERVE_ADD',
|
||||
], [
|
||||
'name' => 'Self-Serve Owner Driver',
|
||||
]);
|
||||
$otherDriver = api_fixtures()->createSubuserSession($customerNumber, [
|
||||
'SELFSERVE_LIST',
|
||||
'SELFSERVE_ADD',
|
||||
], [
|
||||
'name' => 'Self-Serve Other Driver',
|
||||
]);
|
||||
|
||||
api_test_runtime()->db()->query(
|
||||
'UPDATE selfserve_wash_sessions SET subuser_id = '
|
||||
. (int)$ownerDriver['subuser']['id']
|
||||
. ' WHERE id = '
|
||||
. (int)$scenario['session']['id']
|
||||
);
|
||||
|
||||
$ownerResponse = api_client()->get(
|
||||
'/modules/self-serve/lane/wash/my-active-wash',
|
||||
$ownerDriver['headers']
|
||||
);
|
||||
$otherResponse = api_client()->get(
|
||||
'/modules/self-serve/lane/wash/my-active-wash',
|
||||
$otherDriver['headers']
|
||||
);
|
||||
$otherLanePoll = api_client()->get(
|
||||
'/modules/self-serve/lane/wash/in-progress?lane_id=' . (int)$scenario['lane']['id'],
|
||||
$otherDriver['headers']
|
||||
);
|
||||
|
||||
$ownerResponse
|
||||
->assertStatus(200)
|
||||
->assertSuccess(true);
|
||||
$otherResponse
|
||||
->assertStatus(404)
|
||||
->assertMessage('No active self-serve wash found.');
|
||||
$otherLanePoll
|
||||
->assertStatus(200)
|
||||
->assertSuccess(true);
|
||||
|
||||
expect($ownerResponse->data()['session']['subuser_id'] ?? null)->toBe((int)$ownerDriver['subuser']['id'])
|
||||
->and($ownerResponse->data()['subuser']['id'] ?? null)->toBe((int)$ownerDriver['subuser']['id'])
|
||||
->and($otherLanePoll->data())->toMatchArray([
|
||||
'lane_id' => (int)$scenario['lane']['id'],
|
||||
'in_progress' => true,
|
||||
'session' => null,
|
||||
'customer' => null,
|
||||
'vehicle' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns 404 when the authenticated customer has no active self-serve wash', function (): void {
|
||||
$session = api_fixtures()->createUserSession([
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
|
||||
$response = api_client()->get('/modules/self-serve/lane/wash/my-active-wash', $session['headers']);
|
||||
@@ -115,6 +190,7 @@ it('redacts another customers in-progress wash from customer self-serve lane pol
|
||||
$scenario = api_fixtures()->createSelfServeScenario();
|
||||
$otherSession = api_fixtures()->createUserSession([
|
||||
'list_own_department_selfserve_vehicle_conditions',
|
||||
'add_own_department_selfserve_vehicle_conditions',
|
||||
]);
|
||||
|
||||
$response = api_client()->get(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user