Compare commits

..
Author SHA1 Message Date
Jeppe Bundgaard dc22976873 Fix self-serve cron registry test 2026-07-09 11:35:41 +02:00
140 changed files with 1313 additions and 18074 deletions
-49
View File
@@ -1,49 +0,0 @@
# 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.
@@ -1,57 +0,0 @@
{
"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 -5
View File
@@ -3,11 +3,9 @@ on:
workflow_dispatch:
pull_request:
push:
branches:
- master
- beta
- canary
- internal
branches: # Specify your branches here
- main # The 'main' branch
- 'releases/*' # The release branches
jobs:
qodana:
+2 -45
View File
@@ -3,18 +3,6 @@ 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:
@@ -348,42 +336,11 @@ 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: [required-ci]
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.required-ci.result == 'success' }}
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
steps:
- name: Record Release Manager API gate
-5
View File
@@ -2,11 +2,6 @@
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).
+19 -533
View File
@@ -1460,7 +1460,7 @@ paths:
tags:
- Subusers
summary: List subusers for a superuser customer account
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`.
description: Returns paginated driver access grants for the customer number resolved from the selected user.
operationId: listSuperuserUserSubusers
security:
- BearerAuth: []
@@ -3801,19 +3801,13 @@ paths:
schema:
$ref: '#/components/schemas/OrderItemCreate'
responses:
'200':
'201':
description: Order item added successfully
content:
application/json:
schema: {}
'400':
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'
$ref: '#/components/responses/BadRequest'
put:
tags:
- Order Items
@@ -7202,45 +7196,6 @@ 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:
@@ -7471,8 +7426,7 @@ paths:
description: Invoicing periods retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/InvoicingPeriodResponseEnvelope'
schema: {}
/superuser/invoicing/period/distribution/fixed-pricing:
get:
@@ -10923,89 +10877,23 @@ paths:
get:
tags:
- Attachments
summary: Get order attachment download link
description: Return a legacy HTTPS download link for a specific order attachment
summary: Download order attachment
description: Download a specific order attachment
operationId: downloadOrderAttachment
parameters:
- name: order_id
- name: 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 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
description: Attachment downloaded successfully
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:
@@ -11075,63 +10963,15 @@ 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. Authenticated customer accounts may read their own attributes without list_customer_attributes.
description: Get custom attributes for a customer
operationId: getCustomerAttributes
parameters:
- name: customer_number
in: query
schema:
type: integer
- name: user_id
- name: customer_id
in: query
schema:
type: integer
@@ -11140,8 +10980,7 @@ paths:
description: Customer attributes retrieved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/CustomerAttributesResponse'
schema: {}
post:
tags:
- Users
@@ -11149,13 +10988,12 @@ paths:
description: Add a custom attribute to a customer
operationId: addCustomerAttribute
requestBody:
required: true
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/CustomerAttributeMutationRequest'
schema: {}
responses:
'200':
'201':
description: Customer attribute added successfully
content:
application/json:
@@ -11166,17 +11004,11 @@ paths:
summary: Delete customer attribute
description: Remove a custom attribute from a customer
operationId: deleteCustomerAttribute
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 }
requestBody:
required: false
content:
application/json:
schema: {}
responses:
'200':
description: Customer attribute deleted successfully
@@ -14114,142 +13946,6 @@ 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:
@@ -15713,35 +15409,6 @@ 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:
@@ -15851,14 +15518,6 @@ 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
@@ -17214,7 +16873,6 @@ 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 }
@@ -17252,7 +16910,6 @@ 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 }
@@ -18002,142 +17659,6 @@ 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
@@ -18190,31 +17711,6 @@ 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:
@@ -18228,7 +17724,6 @@ components:
type: integer
economic:
type: object
required: [draft_id, booked_id, state, available_pdf_type]
properties:
draft_id:
type: integer
@@ -18236,15 +17731,6 @@ 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,21 +37,8 @@ class attachment_store implements minio_uploads_i
*/
public function isValidFilePath(string $filePath): bool
{
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;
// Check if the file path is valid
return preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $filePath) === 1;
}
/**
@@ -95,10 +82,7 @@ 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/' . $encodedPath;
return $host . '/files/' . $fileName;
}
}
}
@@ -1,127 +0,0 @@
<?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,11 +64,6 @@ 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');
@@ -174,11 +169,6 @@ 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');
@@ -189,11 +179,6 @@ 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');
+8 -225
View File
@@ -67,79 +67,15 @@ 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)) {
@@ -170,31 +106,6 @@ 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',
@@ -222,16 +133,11 @@ 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, $force);
$run_id = $this->createRun($definition, $source, $actor_user_id, $scheduled_for, $started_at);
$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;
@@ -274,7 +180,9 @@ class cron_scheduler
$this->completeRun($run_id, $status, $completed_at, $duration_ms, $summary, $error_message);
$this->releaseLock($definition, $status, $error_message, $completed_at);
return $this->publicRun($this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? []);
$run = $this->fetchOne("SELECT * FROM cron_task_runs WHERE id = $run_id") ?? [];
$run['summary'] = $this->decodeJson($run['summary_json'] ?? null);
return $run;
}
public function updateTaskConfig(string $task_id, array $config): array
@@ -367,118 +275,6 @@ 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));
@@ -519,12 +315,11 @@ class cron_scheduler
string $source,
?int $actor_user_id,
?string $scheduled_for,
string $started_at,
bool $force = false
string $started_at
): int {
$this->query(
"INSERT INTO cron_task_runs
(task_id, module, source, status, actor_user_id, scheduled_for, started_at, lock_owner, force_run)
(task_id, module, source, status, actor_user_id, scheduled_for, started_at, lock_owner)
VALUES ("
. $this->sql($definition->id) . ', '
. $this->sql($definition->module) . ', '
@@ -532,8 +327,7 @@ 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) . ', '
. ($force ? '1' : '0')
. $this->sql($this->lock_owner)
. ")"
);
@@ -624,17 +418,6 @@ 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,7 +53,6 @@ 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),
@@ -61,57 +60,7 @@ 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}");
}
}
-322
View File
@@ -1,322 +0,0 @@
<?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,6 +6,9 @@ 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);
@@ -16,31 +19,80 @@ class customer_order_product_policy
public static function orderProductViolationMessage(int $orderId, int $productId): ?string
{
$customerNumber = self::loadOrderCustomerNumber($orderId);
if ($customerNumber === null) {
$context = self::loadOrderProductContext($orderId, $productId);
if ($context === null) {
return null;
}
$violation = (new customer_rule_product_restriction_service())
->violationForCustomerProduct($customerNumber, $productId);
return $violation === null ? null : (string)$violation['message'];
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;
}
private static function loadOrderCustomerNumber(int $orderId): ?int
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
{
global $db;
if ($orderId < 1) {
if ($orderId < 1 || $productId < 1) {
return null;
}
$result = $db->query("SELECT customer_id FROM orders WHERE id = {$orderId} LIMIT 1");
$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);
if (!$result || $result->num_rows < 1) {
return null;
}
$row = $result->fetch_assoc();
$customerNumber = (int)($row['customer_id'] ?? 0);
return $customerNumber > 0 ? $customerNumber : null;
return is_array($row) ? $row : 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,13 +3,19 @@
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,rules:list<string>,collections:list<int>,product_id:int,code:string,message:string}|null
* @return array{rule:string,message:string}|null
*/
public function firstViolationForOrderItem(int $orderId, int $productId, ?int $relatedItemId): ?array
{
@@ -17,17 +23,146 @@ class customer_product_rule_service
if (!$order->exists()) {
return null;
}
$violation = (new customer_rule_product_restriction_service())->violationForCustomerProduct(
(int)$order->customer_id->value(),
$productId
);
if ($violation === null) {
$product = (new products_o())->getProductById($productId);
if (!$product->exists()) {
return null;
}
// Keep the singular key during the API migration for existing invoice
// and logging consumers while also returning every matching rule.
return ['rule' => (string)$violation['rules'][0]] + $violation;
$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)'
);
if (!$result) {
return 0;
}
$row = $result->fetch_assoc();
return (int)($row['item_count'] ?? 0);
}
}
@@ -1,291 +0,0 @@
<?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);
}
}
@@ -1,508 +0,0 @@
<?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;
}
}
+13 -42
View File
@@ -177,19 +177,15 @@ 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':
@@ -205,42 +201,17 @@ 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}--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;
$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;
}
public function getView(string $view): array
@@ -267,4 +238,4 @@ class db
}
}
}
+4 -23
View File
@@ -11,7 +11,6 @@ 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
{
@@ -118,10 +117,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),
@@ -168,7 +167,7 @@ class fxratesapi implements fxratesapi_i
function requireDailyLimitNotExceeded(): void
{
// Check if the daily limit is exceeded
if ($this->getDailyRequestCounter() >= (int)$this->config->daily_limit->getVariableValue()) {
if ($this->getDailyRequestCounter() >= $this->config->daily_limit->getVariableValue()) {
throw new Exception('Daily limit exceeded');
}
}
@@ -178,30 +177,12 @@ 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
*/
@@ -534,4 +515,4 @@ class fxratesapi implements fxratesapi_i
throw new Exception('Failed to add request to log');
}
}
}
}
@@ -22,6 +22,7 @@ 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 = [];
@@ -847,16 +848,11 @@ 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, cp.product_id
"SELECT u.customer_number, ca.attribute
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}"
);
@@ -867,11 +863,7 @@ class invoice_period_flag_service
while ($row = $result->fetch_assoc()) {
$customerNumber = (int)$row['customer_number'];
$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;
}
$attributes[$customerNumber][(string)$row['attribute']] = true;
}
return $attributes;
@@ -882,8 +874,6 @@ 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'];
@@ -909,17 +899,57 @@ class invoice_period_flag_service
continue;
}
$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',
$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']],
];
foreach ($matchingRules($customerNumber, (int)($row['product_id'] ?? 0)) as $attribute) {
if (isset($productRuleDefinitions[$attribute])) {
foreach ($restrictedProducts as $attribute => [$definitionKey, $terms]) {
if ($this->hasAttribute($attributes, $customerNumber, $attribute)
&& $this->rowMatchesProductTerms($row, $terms)) {
$flags[] = $this->automaticFlag(
$productRuleDefinitions[$attribute],
$definitionKey,
'order_item',
(int)$row['order_item_id'],
null,
@@ -929,19 +959,6 @@ 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) {
@@ -2081,6 +2098,11 @@ 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'] ?? '')
@@ -2093,6 +2115,11 @@ 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;
+2 -2
View File
@@ -27,7 +27,7 @@ class invoice_store implements minio_invoices_i
return count($objects['Contents'] ?? []) > 0;
}
public function getInvoiceDownloadUrl(int|string $id): string
public function getInvoiceDownloadUrl(int $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,11 +244,6 @@ 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);
@@ -1,169 +0,0 @@
<?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;
}
}
@@ -1,119 +0,0 @@
<?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}");
}
}
@@ -1,997 +0,0 @@
<?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));
}
}
+3 -21
View File
@@ -13,7 +13,6 @@ 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
{
@@ -183,10 +182,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),
@@ -218,7 +217,7 @@ class motorapi implements motorapi_i
function requireDailyLimitNotExceeded(): void
{
// Check if the daily limit is exceeded
if ($this->getDailyRequestCounter() >= (int)$this->config->daily_limit->getVariableValue()) {
if ($this->getDailyRequestCounter() >= $this->config->daily_limit->getVariableValue()) {
throw new Exception('Daily limit exceeded');
}
}
@@ -228,29 +227,12 @@ 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
*/
+2 -2
View File
@@ -62,7 +62,7 @@ class pdf_store implements minio_pdfs_i
public function isFileInStore(string $file): bool
{
return self::doesObjectExist($file);
return self::getS3Client()->doesObjectExist(self::getBucket(), $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
@@ -1,140 +0,0 @@
<?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,7 +54,6 @@ 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,
@@ -74,8 +73,6 @@ 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)
@@ -219,36 +216,11 @@ 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',
@@ -1,488 +0,0 @@
<?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,23 +178,6 @@ 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) {
@@ -1,82 +0,0 @@
<?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,6 +27,9 @@ 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) {
@@ -295,6 +298,16 @@ 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(
@@ -320,6 +333,19 @@ 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');
@@ -537,7 +563,6 @@ 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;
}
@@ -549,7 +574,6 @@ 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;
}
@@ -565,7 +589,6 @@ class superuser_system_status_service
'Configuration is present, but no safe read-only probe is available.'
)
);
$result = $this->attachModuleUsageMetrics($result);
$results[] = $result;
continue;
}
@@ -578,10 +601,6 @@ 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;
}
@@ -600,60 +619,6 @@ 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'];
@@ -859,7 +824,7 @@ class superuser_system_status_service
protected function moduleDescriptors(): array
{
return [
['key' => 'economic', 'module' => 'economic', 'always_enabled' => true, 'required' => ['invoiceLayoutNumber', 'invoiceDiscountLayoutNumber', 'paymentTermsNumber', 'adminFeeMonthly', 'adminFeeOrder', 'feeProductId'], 'probe' => fn(array $config): array => $this->probeEconomicModule($config)],
['key' => 'economic', 'module' => 'economic', 'always_enabled' => true, 'required' => ['invoiceLayoutNumber', '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)],
@@ -1014,11 +979,7 @@ class superuser_system_status_service
'Authorization: Bearer ' . $apiKey,
'Accept: application/json',
],
'MailerSend API',
null,
'GET',
null,
fn(array $httpResponse, string $label): array => $this->evaluateProviderQuotaProbeResponse($httpResponse, $label, 'email')
'MailerSend API'
);
}
@@ -1029,50 +990,12 @@ 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' => [
'latest_verified_age_seconds' => (string)($health['latest_verified_age_seconds'] ?? ''),
'encryption_key_id' => (string)($health['encryption']['key_id'] ?? ''),
],
'status_reason_params' => [],
'checked_at' => $checkedAt,
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
];
@@ -1095,11 +1018,7 @@ class superuser_system_status_service
return $this->performHttpProbe(
$this->buildUrlWithQuery('https://v1.motorapi.dk', '/usage'),
['X-AUTH-TOKEN: ' . $secretKey],
'MotorAPI',
null,
'GET',
null,
fn(array $httpResponse, string $label): array => $this->evaluateProviderQuotaProbeResponse($httpResponse, $label, 'motorapi')
'MotorAPI'
);
}
@@ -1232,16 +1151,12 @@ class superuser_system_status_service
$apiKey = trim((string)($config['api_key']['parsed'] ?? ''));
return $this->performHttpProbe(
$this->buildUrlWithQuery(licenseplaterecognizer::configuredApiBaseUrl(), '/info/'),
$this->buildUrlWithQuery('https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk', '/info/'),
[
'Authorization: Token ' . $apiKey,
'Accept: application/json',
],
'License Plate Recognizer',
null,
'GET',
null,
fn(array $httpResponse, string $label): array => $this->evaluateLicensePlateRecognizerProbeResponse($httpResponse, $label)
'License Plate Recognizer'
);
}
@@ -1559,270 +1474,6 @@ 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);
@@ -1888,11 +1539,6 @@ 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();
+1 -15
View File
@@ -72,8 +72,6 @@ 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),
@@ -130,18 +128,6 @@ 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
*/
@@ -249,4 +235,4 @@ class virkdata implements virkdata_i
{
return $this->sendRequest($query, 'search', [], 'GET');
}
}
}
-4
View File
@@ -102,10 +102,6 @@ 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;
+1 -25
View File
@@ -40,8 +40,6 @@ 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';
@@ -540,34 +538,12 @@ function backup(): void
{
try {
$backup = new backup_store();
$backup->enqueueCreateBackup(null, null, null, 'scheduled');
$backup->createBackup();
} 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($backup_name = null, $backup_description = null): string;
public function createBackup(): string;
/**
* Create the metadata for a backup
@@ -98,4 +98,4 @@ interface minio_backups_i
*/
public function backupEnvironmentVariables(string $backup_uuid): string;
}
}
@@ -2,22 +2,8 @@
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
@@ -29,13 +15,6 @@ 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()
@@ -43,22 +22,8 @@ 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();
}
}
}
@@ -1,29 +0,0 @@
<?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'
);
}
}
@@ -1,29 +0,0 @@
<?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'
);
}
}
@@ -1,29 +0,0 @@
<?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'
);
}
}
@@ -1,29 +0,0 @@
<?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'
);
}
}
@@ -1,29 +0,0 @@
<?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'
);
}
}
@@ -1,29 +0,0 @@
<?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'
);
}
}
@@ -1,29 +0,0 @@
<?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,37 +4,13 @@ return [
[
'id' => 'backups.create_backup',
'legacy_name' => 'backup',
'name' => 'Enqueue backup',
'description' => 'Queues the scheduled production recovery backup through the configured backup store.',
'name' => 'Create backup',
'description' => 'Creates the scheduled backup bundle through the configured backup store.',
'module' => 'backups',
'handler' => 'backup',
'schedule' => ['type' => 'interval', 'seconds' => 3600],
'timeout_seconds' => 120,
'estimated_duration_ms' => 5000,
'schedule' => ['type' => 'interval', 'seconds' => 43200],
'timeout_seconds' => 1800,
'estimated_duration_ms' => 60000,
'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,18 +213,6 @@
}
},
"/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"],
@@ -351,12 +339,6 @@
{ "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" },
@@ -414,21 +396,6 @@
"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": {
@@ -1,28 +0,0 @@
<?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,6 +1,5 @@
<?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';
@@ -9,7 +8,6 @@ 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;
@@ -23,7 +21,6 @@ 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;
@@ -36,7 +33,6 @@ 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,
@@ -45,7 +41,6 @@ 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();
@@ -72,7 +72,7 @@ class economic_invoices_draft_endpoint
* @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array<int,int>}
* @throws Exception If the request fails
*/
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500, bool $use_itemized_discounts = false): array
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500): 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, $use_itemized_discounts);
$draftInvoice->addOrderItemLines($order);
// Add an empty line, so the invoice is not empty
$draftInvoice->addTextLine('');
}
@@ -104,12 +104,10 @@ 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, ?int $layout_number = null): object
public function add(int $customer_number, string $external_id = '', string|null $date = null): object
{
// Get the layout number
$layout_number = $layout_number !== null && $layout_number > 0
? $layout_number
: (int)(new economic())->config->invoice_layout->getVariableValue();
$layout_number = (int)(new economic())->config->invoice_layout->getVariableValue();
// Check if the date is set
if (is_null($date)) {
@@ -9,49 +9,21 @@ class economic_invoices_pdf_endpoint
use economic_endpoint_t;
/**
* Get a PDF of a booked invoice.
*
* Kept as the legacy alias used by existing invoice download flows.
* Get a PDF of a booked invoice
* @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(
$path,
'/invoices/booked/' . $id . '/pdf',
'GET',
'',
false,
$file_path
'/tmp/invoice_' . $unique_id . '.pdf'
);
return $file_path;
return '/tmp/invoice_' . $unique_id . '.pdf';
}
}
}
@@ -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, bool $use_itemized_discounts = false): void
public function addOrderItemLines(orders_o $order): 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, false, $use_itemized_discounts);
self::addOrderItemLine($order_item, $department);
// Add the line discount to the total discount
$total_discount += ($order_item['product']['price'] - $order_item['price']) * $order_item['quantity'];
}
// If the total discount is greater than 0, add it to the invoice
if (!$use_itemized_discounts && $total_discount > 0) {
if ($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, bool $use_itemized_discount = false): void
public function addOrderItemLine(array $order_item, array $department, bool $show_discount = false): void
{
// Check if the order item is valid
if (!isset($order_item['id'])) {
@@ -308,21 +308,14 @@ 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,
$use_itemized_discount
? $pricing['invoice_unit_price']
: (int)($order_item['product']['price'] == 0 ? $order_item['price'] : $order_item['product']['price']),
(int)($order_item['product']['price'] == 0 ? $order_item['price'] : $order_item['product']['price']),
(int)$economic_department_id,
$economic_dimension_id,
$discount_percentage
$economic_dimension_id
);
// Calculate the discount percentage (If the final price is 0, set the discount percentage to 100)
@@ -385,90 +378,11 @@ 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, float $discountPercentage = 0): void
public function addProductLine(string $productNumber, string $description, float $quantity, float $unitNetPrice, int $economic_department_id, int $dimension): 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
@@ -478,7 +392,7 @@ class economic_invoice_draft
],
'quantity' => $quantity,
'unitNetPrice' => self::convertCurrency($unitNetPrice),
'discountPercentage' => $discountPercentage,
'discountPercentage' => 0,
'description' => $description,
];
// If the department is set, add it to the line
@@ -22,21 +22,12 @@ class licenseplaterecognizer_info
public array $webhooks;
public int $total_calls;
public function __construct(mixed $data)
public function __construct($data)
{
$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;
$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;
}
}
}
@@ -34,7 +34,6 @@ 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;
@@ -64,7 +63,6 @@ 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
{
@@ -78,8 +76,7 @@ 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);
$actorSubuserId = $this->resolveActorSubuserId($options);
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number'], $actorSubuserId);
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
return $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null);
}
@@ -92,8 +89,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
{
$options['debug'] = true;
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
$actorSubuserId = $this->resolveActorSubuserId($options);
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number'], $actorSubuserId);
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
$response = $this->formatSnapshotResponse($snapshot, $session->exists() ? $session->asArray() : null);
$response['simulator_version'] = 2;
$response['dry_run'] = true;
@@ -107,14 +103,12 @@ 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'],
$actorSubuserId,
function () use ($laneId, $snapshot, $options, $actorSubuserId): array {
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number'], $actorSubuserId);
function () use ($laneId, $snapshot, $options): array {
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
$createSession = (bool)($options['create_session'] ?? true);
if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
@@ -145,17 +139,10 @@ 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']);
@@ -171,7 +158,6 @@ 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 [
@@ -216,7 +202,6 @@ 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);
@@ -242,13 +227,11 @@ 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,
];
@@ -286,12 +269,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, ?int $subuserId = null): bool
public function hasMachineStartTriggeredForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null): bool
{
$normalizedReg = $reg === null || trim($reg) === '' ? null : selfserve::standardize_registration($reg);
$session = $normalizedReg !== null
? $this->findLatestOpenSession($laneId, $normalizedReg, $customerNumber, $subuserId)
: $this->findLatestOpenSessionByLane($laneId, $customerNumber, $subuserId);
? $this->findLatestOpenSession($laneId, $normalizedReg, $customerNumber)
: $this->findLatestOpenSessionByLane($laneId, $customerNumber);
return $session->exists() && (bool)$session->machine_start_triggered->value();
}
@@ -420,14 +403,10 @@ 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' => $sessionPayload,
'session' => $session->asArray(),
'lane' => $lane->exists() ? $lane->asArray() : null,
'machine_type' => $machineType,
'subuser' => $sessionPayload['subuser'],
'questions' => $answers,
'tasks' => $tasks,
'events' => $events,
@@ -451,16 +430,15 @@ 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, ?int $subuserId = null): ?array
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array
{
$session = $reg !== null
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber, $subuserId)
: $this->findLatestOpenSessionByLane($laneId, $customerNumber, $subuserId);
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber)
: $this->findLatestOpenSessionByLane($laneId, $customerNumber);
if (!$session->exists()) {
return null;
}
$resolvedSubuserId = $subuserId ?? $this->nullableInt($session->subuser_id->value());
$this->fillMissingWashStartedAtFromLaneRuntime($session, $laneId);
@@ -474,7 +452,6 @@ 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,
]);
@@ -578,7 +555,6 @@ 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' => [],
@@ -770,7 +746,6 @@ 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,
@@ -879,7 +854,6 @@ 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'],
@@ -902,7 +876,6 @@ 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' => [],
@@ -3286,47 +3259,13 @@ 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, ?int $subuserId, callable $callback): array
protected function withSessionMutationLock(int $laneId, string $reg, ?int $customerNumber, callable $callback): array
{
$lockKey = $this->sessionMutationLockKey($laneId, $reg, $customerNumber, $subuserId);
$lockKey = $this->sessionMutationLockKey($laneId, $reg, $customerNumber);
$lock = $this->acquireSessionMutationLock($lockKey);
try {
@@ -3393,12 +3332,10 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
}
}
protected function sessionMutationLockKey(int $laneId, string $reg, ?int $customerNumber, ?int $subuserId = null): string
protected function sessionMutationLockKey(int $laneId, string $reg, ?int $customerNumber): string
{
return 'selfserve_session_mutation:' . (int)$laneId . ':' . sha1(
selfserve::standardize_registration($reg)
. ':' . ($customerNumber === null ? 'anon' : (string)(int)$customerNumber)
. ':' . ($subuserId === null ? 'customer' : 'subuser:' . (int)$subuserId)
selfserve::standardize_registration($reg) . ':' . ($customerNumber === null ? 'anon' : (string)(int)$customerNumber)
);
}
@@ -3408,7 +3345,6 @@ 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,
@@ -3559,14 +3495,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, ?int $subuserId = null): selfserve_wash_sessions_o
protected function findLatestOpenSession(int $laneId, string $reg, ?int $customerNumber = null): selfserve_wash_sessions_o
{
$session = new selfserve_wash_sessions_o();
$session->selectLatestOpenByLaneAndReg($laneId, $reg, $customerNumber, $subuserId);
$session->selectLatestOpenByLaneAndReg($laneId, $reg, $customerNumber);
return $session;
}
protected function findLatestOpenSessionByLane(int $laneId, ?int $customerNumber = null, ?int $subuserId = null): selfserve_wash_sessions_o
protected function findLatestOpenSessionByLane(int $laneId, ?int $customerNumber = null): selfserve_wash_sessions_o
{
$rows = (new selfserve_wash_sessions_o())->getFieldsWhere(
[
@@ -3574,7 +3510,6 @@ 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, ?int $subuserId = null): ?array;
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?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, 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. |
| `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. |
| `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, ?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. |
| `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. |
Key implementation details:
@@ -120,15 +120,14 @@ trait selfserve_lane_command_t
/**
* Machine-wash billing is based on the physical machine ON signal, not selector relay status.
*/
protected function hasMachineStartSignalForStop(?int $subuser_id = null): bool
protected function hasMachineStartSignalForStop(): 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,
$subuser_id
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null
);
} catch (\Throwable) {
return false;
@@ -241,9 +240,7 @@ trait selfserve_lane_command_t
$snapshot = (new selfserve_wash_flow())->previewVehicleEligibility(
(int)$this->id,
$reg,
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null,
null,
['subuser_id' => $arguments?->subuser_id]
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null
);
return (bool)($snapshot['allowed'] ?? false);
@@ -534,7 +531,7 @@ trait selfserve_lane_command_t
/**
* Finalize any active self-serve wash session before resetting lane state.
*/
protected function completeLatestSessionForStop(?int $subuser_id = null): void
protected function completeLatestSessionForStop(): void
{
try {
(new \modules\selfserve\classes\selfserve_wash_flow())->completeLatestSessionForLane(
@@ -542,8 +539,7 @@ trait selfserve_lane_command_t
$this->getLicensePlate() ?: null,
$this->getCustomerNumber() ?: null,
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null,
false,
$subuser_id
false
);
} catch (\Throwable) {
// Session completion must not block STOP flow.
@@ -715,7 +711,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($arguments->subuser_id);
$machine_start_triggered = $this->hasMachineStartSignalForStop();
// Turn off relays before any configured or default exit gate opens.
$this->turnOffRelaysAfterStop();
$this->runPublishedStudioActions(
@@ -737,7 +733,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($arguments->subuser_id);
$this->completeLatestSessionForStop();
// Reset the lane
self::execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments());
break;
@@ -260,9 +260,8 @@ trait selfserve_lane_invoice_t
?int $draft_customer_number,
?selfserve_lane_command_arguments $arguments = null
): array {
$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;
$session = $this->findOpenSelfServeSessionForAttachment($billing_customer_number);
$subuser_id = $arguments?->subuser_id;
return [
'type' => attachment_content::OTHER_TYPE_SELF_SERVE_WASH,
@@ -283,7 +282,7 @@ trait selfserve_lane_invoice_t
];
}
protected function findOpenSelfServeSessionForAttachment(int $billing_customer_number, ?int $subuser_id = null): ?selfserve_wash_sessions_o
protected function findOpenSelfServeSessionForAttachment(int $billing_customer_number): ?selfserve_wash_sessions_o
{
$license_plate = trim((string)$this->getLicensePlate());
if ($license_plate === '') {
@@ -291,31 +290,12 @@ 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
);
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;
return $session->exists() ? $session : null;
} catch (\Throwable) {
return null;
}
@@ -10,7 +10,6 @@ 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;
@@ -428,8 +427,7 @@ class collected_order_invoices_o extends db
if (empty($this->customer_number->value())) {
throw new Exception('Customer number is not set');
}
$economic = new economic();
$economic->assertCustomerNumberIsNotDraft((int)$this->customer_number->value());
(new economic())->assertCustomerNumberIsNotDraft((int)$this->customer_number->value());
if (!$ignore_closed) {
// Require the invoice collection to be open
self::requireOpen();
@@ -483,8 +481,7 @@ class collected_order_invoices_o extends db
{
// Require the invoice collection to be selected
self::requireSelected();
$economic = new economic();
$economic->assertCustomerNumberIsNotDraft((int)$this->customer_number->value());
(new 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');
@@ -500,12 +497,11 @@ 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
$layout_number = $this->resolveInvoiceLayoutNumber($economic);
$economic = new economic();
$response = $economic->invoices->drafts->add(
$this->customer_number->value(),
self::getExternalId(),
$date,
$layout_number
$date
);
// Validate the response, by checking if the external id is set
if (empty($response->references->other)) {
@@ -519,70 +515,6 @@ 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
@@ -814,14 +746,7 @@ class collected_order_invoices_o extends db
$order_object->requireSelected();
$order_objects[] = $order_object;
}
$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);
$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency);
$this->last_economic_transfer_metrics = [
'draft_invoice_id' => $draft_id,
'currency' => (string)$currency,
+1 -1
View File
@@ -393,7 +393,7 @@ class departments_o extends db
public function isModuleTimeBookingsEnabled(): bool
{
self::requireSelected();
return $this->variables->getVariable('bookingsystem_time_based_enabled') === true;
return (bool)$this->variables->getVariable('bookingsystem_time_based_enabled');
}
/**
@@ -3,7 +3,6 @@
namespace objects;
use classes\db;
use classes\module_usage_schema_bootstrap;
use classes\object_property;
use Exception;
use traits\db_object_t;
@@ -41,7 +40,6 @@ class module_action_logs_o extends db
public function structure(): void
{
module_usage_schema_bootstrap::ensureTables();
$this->setTable('module_usage_logs');
}
@@ -133,4 +131,4 @@ class module_action_logs_o extends db
return is_array($decoded) ? $decoded : [];
}
}
}
@@ -6,7 +6,6 @@ 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
@@ -91,42 +90,10 @@ 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);
@@ -202,7 +169,6 @@ 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);
-3
View File
@@ -1259,7 +1259,6 @@ 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
@@ -1300,7 +1299,6 @@ 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
@@ -1331,7 +1329,6 @@ 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,7 +23,6 @@ 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;
@@ -57,15 +56,13 @@ class selfserve_wash_sessions_o extends db
?int $vehicleTypeId,
selfserve_wash_session_status $status,
bool $allowed = false,
?array $metadata = null,
?int $subuserId = null
?array $metadata = 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),
@@ -85,7 +82,6 @@ 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);
@@ -256,7 +252,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, ?int $subuserId = null): self
public function selectLatestOpenByLane(int $laneId, ?int $customerNumber = null): self
{
$filters = [
'lane_id' => $laneId,
@@ -266,9 +262,6 @@ 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,
@@ -282,7 +275,7 @@ class selfserve_wash_sessions_o extends db
return $this;
}
public function selectLatestOpenByLaneAndReg(int $laneId, string $reg, ?int $customerNumber = null, ?int $subuserId = null): self
public function selectLatestOpenByLaneAndReg(int $laneId, string $reg, ?int $customerNumber = null): self
{
$filters = [
'lane_id' => $laneId,
@@ -293,9 +286,6 @@ 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,
@@ -332,7 +322,6 @@ 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,7 +15,6 @@ 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;
@@ -95,7 +94,6 @@ 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');
@@ -111,7 +109,6 @@ 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()),
-19
View File
@@ -24,10 +24,8 @@ 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;
@@ -51,10 +49,8 @@ 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');
@@ -284,21 +280,6 @@ 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
*/
+28 -45
View File
@@ -3,7 +3,6 @@
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;
@@ -269,19 +268,15 @@ 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);
$userIds = $this->attributeSiblingUserIds((int)$user_id);
foreach ($userIds as $targetUserId) {
$db->query(
"INSERT IGNORE INTO customer_attributes (user_id, attribute)
VALUES ({$targetUserId}, '{$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);
}
public function deleteAttribute(string $attribute, ?int $user_id = null): void
@@ -291,9 +286,29 @@ class users_o extends db
$user_id = $this->id;
}
$attribute = $db->escape_string($attribute);
foreach ($this->attributeSiblingUserIds((int)$user_id) as $targetUserId) {
$db->query("DELETE FROM customer_attributes WHERE user_id = {$targetUserId} AND attribute = '{$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;
}
// 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
@@ -303,8 +318,7 @@ class users_o extends db
$user_id = $this->id;
}
$attribute = $db->escape_string($attribute);
$userIds = $this->attributeSiblingUserIds((int)$user_id);
$sql = "SELECT id FROM customer_attributes WHERE user_id IN (" . implode(',', $userIds) . ") AND attribute = '$attribute' LIMIT 1";
$sql = "SELECT * FROM customer_attributes WHERE user_id = $user_id AND attribute = '$attribute' LIMIT 1";
$result = $db->query($sql);
if ($result->num_rows > 0) {
return true;
@@ -722,44 +736,13 @@ class users_o extends db
if ($user_id === null) {
$user_id = $this->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";
$sql = "SELECT * FROM customer_attributes WHERE user_id = $user_id";
$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
File diff suppressed because it is too large Load Diff
@@ -1890,10 +1890,6 @@ 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(
@@ -2033,7 +2029,6 @@ class InvoicingPeriodRoute
'meta' => $meta ?? [],
'queue' => self::getDefaultQueueSummary(),
'draft' => self::getDefaultDraftSummary(),
'invoice_collections' => [],
];
}
@@ -2051,15 +2046,11 @@ 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' => $booked,
'booked' => self::isTransactionBookedFromLocalState($transaction),
'department_id' => $departmentId,
'customer_number' => (int)$transaction->customer_id->value(),
'reference' => (string)$transaction->reference->value(),
@@ -2068,10 +2059,8 @@ 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,
];
@@ -2093,13 +2082,8 @@ 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,
];
@@ -2137,15 +2121,6 @@ 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
@@ -2650,199 +2625,6 @@ 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'] ?? [];
-18
View File
@@ -7,7 +7,6 @@ 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;
@@ -21,8 +20,6 @@ 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;
@@ -122,7 +119,6 @@ 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);
}
@@ -138,7 +134,6 @@ 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);
}
@@ -351,7 +346,6 @@ 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);
}
@@ -856,7 +850,6 @@ 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);
}
@@ -865,7 +858,6 @@ 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);
}
@@ -877,7 +869,6 @@ 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);
@@ -1063,15 +1054,6 @@ 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 -50
View File
@@ -4,8 +4,6 @@ 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;
@@ -38,51 +36,6 @@ 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;
@@ -94,13 +47,14 @@ class cronRoute
}
try {
$run = (new cron_scheduler())->queueTaskRun(
$run = (new cron_scheduler())->runTask(
$task_id,
'manual',
$this->actorUserId(),
$this->toBool($parameters['force'] ?? false, false)
);
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_JOB_QUEUED', 'Queued cron task: ' . $task_id);
$response->success($run, 202);
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_JOB_RUN', 'Ran cron task: ' . $task_id);
$response->success($run);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
@@ -3,7 +3,6 @@
namespace routes;
use classes\authentication;
use classes\customer_rule_product_restriction_service;
use objects\logs_o;
use objects\users_o;
use traits\route_t;
@@ -45,10 +44,9 @@ 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((new customer_rule_product_restriction_service())->enrichAttributes(
(int)$target_user->customer_number->value(),
$response->success(
$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');
@@ -78,9 +76,6 @@ 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
@@ -116,9 +111,6 @@ 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,30 +15,6 @@ 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 */
@@ -46,60 +22,26 @@ 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;
$this->requirePublicTimeBookingsDepartment();
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_time_bookings_opening_hours = new department_time_bookings_opening_hours_o();
$department_time_bookings_opening_hours->selectByDepartment(
(int)self::getParameter('id')
@@ -137,8 +79,23 @@ class customerTimeBookingsRoute
/** Guest Time Bookings -> Types -> GET */
$this->get('/department/timebookings/types/public', function () {
global $response;
$this->requirePublicTimeBookingsDepartment();
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_time_bookings_types = new department_time_bookings_types_o();
$booking_types_array = $department_time_bookings_types->getFieldsWhere(
[
@@ -177,8 +134,23 @@ 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;
$department = $this->requirePublicTimeBookingsDepartment();
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_time_bookings_entries = new department_time_bookings_entries_o();
$booking_entries_array = $department_time_bookings_entries->listObjectsWithPaginationIfSet(
function ($booking_entry): array {
@@ -207,7 +179,9 @@ class customerTimeBookingsRoute
$this->post('/department/timebookings/entries/public', function () {
global $response;
self::requireParameters(['department', 'type', 'start']);
$department = $this->requirePublicTimeBookingsDepartment('department');
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'));
// Get the type
self::requireType((int)self::getParameter('type'), self::type_int());
@@ -221,6 +195,17 @@ 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'));
@@ -284,4 +269,4 @@ class customerTimeBookingsRoute
]
);
}
}
}
@@ -119,12 +119,11 @@ class departmentSelfserveVehicleConditionsRoute
*/
$this->get('/department/selfserve/vehicle/allowed', function () {
global $response;
[$user, $actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal();
[$user, $actor_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, $customer_number_context);
$has_own = $this->hasPermission($own_permission);
if (!$has_global && !$has_own) {
$response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']);
}
@@ -148,14 +147,11 @@ 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, [
'subuser_id' => $subuser_id,
]));
$response->success($flow->previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_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'
@@ -166,12 +162,11 @@ class departmentSelfserveVehicleConditionsRoute
*/
$this->get('/department/selfserve/washes/summary', function () {
global $response;
[$user, $_actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal();
[$user] = $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, $customer_number_context);
$has_own = $this->hasPermission($own_permission);
if (!$has_global && !$has_own) {
$response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']);
}
@@ -197,10 +192,7 @@ class departmentSelfserveVehicleConditionsRoute
false,
$vehicle_type_id,
false,
[
'create_session' => false,
'subuser_id' => $subuser_id,
]
['create_session' => false]
);
if (!empty($refreshed_summary['session']['id'])) {
$summary = $refreshed_summary;
@@ -223,7 +215,6 @@ 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 {
@@ -250,12 +241,11 @@ class departmentSelfserveVehicleConditionsRoute
*/
$this->post('/department/selfserve/vehicle/conditions', function () {
global $response;
[$user, $actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal();
[$user, $actor_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, $customer_number_context);
$has_own = $this->hasPermission($own_permission);
if (!$has_global && !$has_own) {
$response->forbidden(['add_department_selfserve_vehicle_conditions', 'add_own_department_selfserve_vehicle_conditions']);
@@ -291,9 +281,7 @@ 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, [
'subuser_id' => $subuser_id,
]);
$summary = $this->getWashFlow()->synchronizeSession($lane, $reg, $customer_id, $activate_machine, $vehicle_type_id, $sync_relay_state);
(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(),
@@ -481,12 +469,12 @@ class departmentSelfserveVehicleConditionsRoute
$auth = new authentication();
$user = $auth->get_user();
if ($user !== false) {
return [$user, (int)$user->id, null];
return [$user, (int)$user->id];
}
$subuser = $auth->get_subuser();
if ($subuser !== false) {
return [null, (int)$subuser->id, (int)$subuser->id];
return [null, (int)$subuser->id];
}
$response->error('Invalid session', 400);
+36 -132
View File
@@ -7,7 +7,6 @@ use classes\backup_store;
use classes\response;
use classes\router;
use objects\logs_o;
use Throwable;
use traits\route_t;
class moduleBackupsRoute
@@ -20,144 +19,49 @@ class moduleBackupsRoute
/** @var router $router */
$router, $response;
/** Modules > Backups > GET */
$this->get('/modules/backup/backups', function () {
global $response;
$this->requireClassicSuperuserPermission('modules_backup_list');
try {
$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');
$response->success(
(new backup_store())->listBackups(
(int)($this->fromQuery('limit') ?? 50),
(int)($this->fromQuery('offset') ?? 0)
)
(new backup_store())->listBackups()
);
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
} else {
(new logs_o())->add('modules_backup', 'global', 1, 0, 'MODULES_BACKUP', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
}, [
'modules_backup_list' => 'List backup records, components, and legacy metadata',
]);
},
[
'modules_backup_list' => 'List all backup modules'
]
);
/** Modules > Backups > POST */
$this->post('/modules/backup/backups', function () {
global $response;
$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'
);
(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' => '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 {
$this->requirePermission('modules_backup_create');
$user = (new authentication())->get_user();
return $user !== false && isset($user->id) ? (int)$user->id : null;
} catch (Throwable) {
return null;
}
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)
);
} else {
(new logs_o())->add('modules_backup', 'global', 1, 0, 'MODULES_BACKUP', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
},
[
'modules_backup_create' => 'Create a backup module'
]
);
}
}
}
@@ -29,7 +29,6 @@ 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;
@@ -39,7 +38,6 @@ 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
{
@@ -264,7 +262,6 @@ 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,
@@ -276,16 +273,14 @@ class moduleSelfServeRoute
'wash_started_at' => $wash_started_at,
'created_at' => null,
'updated_at' => null,
],
'customer' => $customer,
'subuser' => null,
'vehicle' => $vehicle,
], $customer_scope));
],
'customer' => $customer,
'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 === '') {
@@ -323,7 +318,6 @@ 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,
@@ -337,13 +331,12 @@ 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',
'add_own_department_selfserve_vehicle_conditions' => 'Use in-progress self-serve wash details for the authenticated customer',
'list_own_department_selfserve_vehicle_conditions' => 'View in-progress self-serve wash details for the authenticated customer',
]
);
@@ -351,11 +344,8 @@ class moduleSelfServeRoute
$this->get('/modules/self-serve/lane/wash/my-active-wash', function () {
global $response;
$principal_scope = $this->requireMyActiveWashPrincipalScope();
$session = $this->findLatestActiveSelfServeSessionForCustomer(
(int)$principal_scope['customer_number'],
$principal_scope['subuser_id']
);
$customer_number = $this->requireMyActiveWashCustomerNumber();
$session = $this->findLatestActiveSelfServeSessionForCustomer($customer_number);
if (!$session->exists()) {
$response->error('No active self-serve wash found.', 404);
}
@@ -363,7 +353,7 @@ class moduleSelfServeRoute
$response->success($this->buildActiveSelfServeSessionResponse($session));
},
[
'add_own_department_selfserve_vehicle_conditions' => 'Use the authenticated customer\'s active self-serve wash',
'list_own_department_selfserve_vehicle_conditions' => 'View the authenticated customer\'s active self-serve wash',
]
);
@@ -379,7 +369,6 @@ class moduleSelfServeRoute
'department_id',
'machine_type_id',
'customer_number',
'subuser_id',
'vehicle_id',
'vehicle_type_id',
'reg',
@@ -403,9 +392,6 @@ 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(),
];
@@ -599,8 +585,7 @@ class moduleSelfServeRoute
$customer_number > 0 ? $customer_number : null,
false,
null,
false,
['subuser_id' => $args->subuser_id]
false
);
}
} catch (\Throwable $e) {
@@ -1436,7 +1421,7 @@ class moduleSelfServeRoute
]);
}
private function requireInProgressWashDetailsAccess(int $lane_id): ?array
private function requireInProgressWashDetailsAccess(int $lane_id): ?int
{
global $response;
@@ -1453,21 +1438,16 @@ class moduleSelfServeRoute
return null;
}
$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(),
];
if (self::hasPermission($this->customerSelfServePermission())) {
$customer_number = $this->resolveEffectiveCustomerNumber();
if ($customer_number !== null && $customer_number > 0) {
return (int)$customer_number;
}
}
$this->emitForbidden([
'modules_selfserve_lane_wash_in_progress_view',
$this->customerSelfServeUsePermission(),
$this->customerSelfServePermission(),
]);
return null;
}
@@ -1479,19 +1459,14 @@ class moduleSelfServeRoute
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
protected function scopeInProgressWashResponseForCustomer(array $payload, mixed $customer_scope): array
protected function scopeInProgressWashResponseForCustomer(array $payload, ?int $customer_number): 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);
$session_subuser_id = $this->extractInProgressWashSubuserId($payload);
if ($session_customer_number === $customer_number && ($subuser_id === null || $session_subuser_id === $subuser_id)) {
if ($session_customer_number === $customer_number) {
return $payload;
}
@@ -1504,24 +1479,6 @@ 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
*/
@@ -1545,31 +1502,7 @@ class moduleSelfServeRoute
return null;
}
/**
* @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
private function requireMyActiveWashCustomerNumber(): int
{
global $response;
@@ -1577,19 +1510,16 @@ class moduleSelfServeRoute
$response->error('Authentication failed. Invalid or missing token.', 401);
}
$customer_number = $this->resolveEffectiveCustomerNumber();
if (!self::hasPermission($this->customerSelfServeUsePermission(), $customer_number)) {
$this->emitForbidden([$this->customerSelfServeUsePermission()]);
if (!self::hasPermission($this->customerSelfServePermission())) {
$this->emitForbidden([$this->customerSelfServePermission()]);
}
$customer_number = $this->resolveEffectiveCustomerNumber();
if ($customer_number === null || $customer_number <= 0) {
$response->error('No customer number found for authenticated user.', 404);
}
return [
'customer_number' => (int)$customer_number,
'subuser_id' => $this->authenticatedSubuserId(),
];
return (int)$customer_number;
}
private function customerSelfServePermission(): \classes\permission_node
@@ -1597,53 +1527,23 @@ 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, ?int $subuser_id = null): selfserve_wash_sessions_o
private function findLatestActiveSelfServeSessionForCustomer(int $customer_number): selfserve_wash_sessions_o
{
if ($customer_number <= 0) {
return new selfserve_wash_sessions_o();
}
$filters = [
$rows = (new selfserve_wash_sessions_o())->getFieldsWhere([
'customer_number' => $customer_number,
'completed_at' => null,
'deleted_at' => null,
];
if ($subuser_id !== null) {
$filters['subuser_id'] = $subuser_id;
}
$rows = (new selfserve_wash_sessions_o())->getFieldsWhere($filters, ['id', 'status']);
], ['id', 'status']);
$active_statuses = $this->activeSelfServeWashSessionStatusValues();
$rows = array_values(array_filter(
@@ -1682,7 +1582,6 @@ 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 === '') {
@@ -1716,7 +1615,6 @@ 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,
@@ -1730,7 +1628,6 @@ 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),
];
}
@@ -1766,35 +1663,6 @@ 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
*/
@@ -2220,9 +2088,7 @@ class moduleSelfServeRoute
$missing_permissions = $department_id > 0 ? ['department_access_' . $department_id] : [];
if ($allow_customer_self_serve) {
$missing_permissions[] = $requires_active_wash
? $this->customerSelfServePermission()
: $this->customerSelfServeUsePermission();
$missing_permissions[] = $this->customerSelfServePermission();
}
$this->emitForbidden($missing_permissions);
}
@@ -2266,10 +2132,7 @@ class moduleSelfServeRoute
return;
}
$this->emitForbidden([
...$elevated_permissions,
$requires_active_wash ? $this->customerSelfServePermission() : $this->customerSelfServeUsePermission(),
]);
$this->emitForbidden([...$elevated_permissions, $this->customerSelfServePermission()]);
}
private function requireSelfServeLaneCommandPermission(
@@ -2300,10 +2163,7 @@ class moduleSelfServeRoute
$this->emitForbidden(
$allow_customer_self_serve
? [
...$elevated_permissions,
$requires_active_wash ? $this->customerSelfServePermission() : $this->customerSelfServeUsePermission(),
]
? [...$elevated_permissions, $this->customerSelfServePermission()]
: $elevated_permissions
);
}
@@ -2358,19 +2218,18 @@ class moduleSelfServeRoute
protected function canCustomerUseSelfServeLane(selfserve_lane $lane, int $customer_number): bool
{
return $customer_number > 0
&& $this->hasPermission($this->customerSelfServeUsePermission(), $customer_number)
&& $this->hasPermission($this->customerSelfServePermission())
&& $this->isLaneSelfServeOperationallyEnabled($lane);
}
protected function canCustomerUseActiveSelfServeLane(selfserve_lane $lane, int $customer_number): bool
{
if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission(), $customer_number)) {
if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission())) {
return false;
}
$subuser_id = $this->authenticatedSubuserId();
try {
if ($subuser_id === null && (int)$lane->getCustomerNumber() === $customer_number) {
if ((int)$lane->getCustomerNumber() === $customer_number) {
return true;
}
} catch (\Throwable) {
@@ -2379,7 +2238,7 @@ class moduleSelfServeRoute
$department_id = $this->departmentIdForLane($lane);
return $department_id > 0
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number, $subuser_id);
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
}
protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool
@@ -2394,7 +2253,7 @@ class moduleSelfServeRoute
$department_id = $this->departmentIdForLane($lane);
return $department_id > 0
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number, $this->authenticatedSubuserId());
&& $this->customerHasActiveSelfServeWashInDepartment($department_id, $customer_number);
}
protected function canCustomerUseActiveOperationalSelfServeLane(
@@ -2412,13 +2271,12 @@ class moduleSelfServeRoute
protected function canCustomerUseActiveSelfServeLaneSession(selfserve_lane $lane, int $customer_number): bool
{
if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission(), $customer_number)) {
if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission())) {
return false;
}
$subuser_id = $this->authenticatedSubuserId();
try {
if ($subuser_id === null && (int)$lane->getCustomerNumber() === $customer_number) {
if ((int)$lane->getCustomerNumber() === $customer_number) {
return true;
}
} catch (\Throwable) {
@@ -2436,17 +2294,12 @@ class moduleSelfServeRoute
]
);
$filters = [
$sessions = (new selfserve_wash_sessions_o())->getFieldsWhere([
'lane_id' => (int)$lane->id,
'customer_number' => $customer_number,
'completed_at' => null,
'deleted_at' => null,
];
if ($subuser_id !== null) {
$filters['subuser_id'] = $subuser_id;
}
$sessions = (new selfserve_wash_sessions_o())->getFieldsWhere($filters, ['id', 'status']);
], ['id', 'status']);
foreach ($sessions as $session) {
if (in_array((string)($session['status'] ?? ''), $active_statuses, true)) {
@@ -2489,7 +2342,7 @@ class moduleSelfServeRoute
}
}
protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number, ?int $subuser_id = null): bool
protected function customerHasActiveSelfServeWashInDepartment(int $department_id, int $customer_number): bool
{
if ($department_id <= 0 || $customer_number <= 0) {
return false;
@@ -2506,17 +2359,12 @@ class moduleSelfServeRoute
]
);
$filters = [
$sessions = (new selfserve_wash_sessions_o())->getFieldsWhere([
'department_id' => $department_id,
'customer_number' => $customer_number,
'completed_at' => null,
'deleted_at' => null,
];
if ($subuser_id !== null) {
$filters['subuser_id'] = $subuser_id;
}
$sessions = (new selfserve_wash_sessions_o())->getFieldsWhere($filters, ['id', 'status']);
], ['id', 'status']);
foreach ($sessions as $session) {
if (in_array((string)($session['status'] ?? ''), $active_statuses, true)) {
@@ -2525,9 +2373,6 @@ 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) {
@@ -1,70 +0,0 @@
<?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,7 +7,6 @@ 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;
@@ -19,8 +18,6 @@ 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;
@@ -87,10 +84,6 @@ 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,7 +9,6 @@ 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;
@@ -250,26 +249,6 @@ 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;
@@ -2023,31 +2002,16 @@ class orderInvoicesRoute
$economic = new economic();
$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 {
$draft_id = $invoice->getInvoiceDraftId();
} catch (Exception $e) {
$warnings[] = 'Draft 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();
}
}
try {
$booked_id = $invoice->getInvoiceBookedId();
} catch (Exception $e) {
$warnings[] = 'Booked id unavailable: ' . $e->getMessage();
}
if ($draft_id !== null) {
@@ -2108,11 +2072,6 @@ 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(),
@@ -2122,8 +2081,6 @@ 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' => [
@@ -2148,80 +2105,6 @@ 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,13 +92,7 @@ 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([
'code' => $customerRuleViolation['code'],
'message' => $customerRuleViolation['message'],
'product_id' => $customerRuleViolation['product_id'],
'rules' => $customerRuleViolation['rules'],
'collections' => $customerRuleViolation['collections'],
], 400);
$response->error($customerRuleViolation['message'], 400);
}
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
+61 -190
View File
@@ -9,7 +9,6 @@ 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;
@@ -335,13 +334,68 @@ class ordersRoute
);
$this->get('/orders/attachments/download', function () {
// Require the user to be logged in
global $response;
$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]);
// 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);
}
},
[
'download_order_attachments' => 'Download attachments for an order',
@@ -349,49 +403,6 @@ 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;
@@ -1658,146 +1669,6 @@ 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,32 +104,6 @@ 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
@@ -1,86 +0,0 @@
<?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,14 +4,13 @@ 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 () {
@@ -25,71 +24,140 @@ class superuserReplicationRoute
]);
$this->post('/superuser/replication/databases', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_replication_manage');
$this->rejectRetiredManagement();
$host = (new replication_manager())->addHost('database', $this->getParametersAsArray(), $this->actorUserId());
$response->success($host, 201);
}, [
'superuser_replication_manage' => 'Add and manage database replication host credentials',
]);
$this->post('/superuser/replication/redis', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_replication_manage');
$this->rejectRetiredManagement();
$host = (new replication_manager())->addHost('redis', $this->getParametersAsArray(), $this->actorUserId());
$response->success($host, 201);
}, [
'superuser_replication_manage' => 'Add and manage Redis replication host credentials',
]);
$this->post('/superuser/replication/minio', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_replication_manage');
$this->rejectRetiredManagement();
$host = (new replication_manager())->addHost('minio', $this->getParametersAsArray(), $this->actorUserId());
$response->success($host, 201);
}, [
'superuser_replication_manage' => 'Add and manage MinIO replication host credentials',
]);
$this->post('/superuser/replication/compose-template', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_replication_manage');
$this->rejectRetiredManagement();
$response->success(replication_manager::composeTemplate($this->getParametersAsArray()));
}, [
'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');
$this->rejectRetiredManagement();
$parameters = $this->getParametersAsArray();
$response->success((new replication_manager())->testCredentials(
(string)($parameters['kind'] ?? ''),
$parameters
));
}, [
'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');
$this->rejectRetiredManagement();
$response->success((new replication_manager())->testHost(
(string)$this->fromRoute('kind'),
$this->routeId(),
$this->actorUserId()
));
}, [
'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');
$this->rejectRetiredManagement();
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);
}
}, [
'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');
$this->rejectRetiredManagement();
try {
$response->success((new replication_manager())->promoteHost(
(string)$this->fromRoute('kind'),
$this->routeId(),
$this->actorUserId()
));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'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');
$this->rejectRetiredManagement();
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);
}
}, [
'superuser_replication_manage' => 'Rename database, Redis, and MinIO replication hosts',
]);
$this->delete('/superuser/replication/{kind}/{id}', function () {
global $response;
$this->requireClassicSuperuserPermission('superuser_replication_remove');
$this->rejectRetiredManagement();
try {
$response->success((new replication_manager())->removeHost(
(string)$this->fromRoute('kind'),
$this->routeId(),
$this->actorUserId()
));
} catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409);
}
}, [
'superuser_replication_remove' => 'Remove inactive prior hosts and unhealthy database, Redis, or MinIO replicas',
]);
@@ -112,11 +180,21 @@ class superuserReplicationRoute
return $this->requirePermission($permission);
}
private function rejectRetiredManagement(): void
private function routeId(): int
{
global $response;
$id = (int)$this->fromRoute('id');
$this->requireParameterIntPositive($id, 'id');
return $id;
}
$response->error(['message' => self::RETIRED_MANAGEMENT_MESSAGE], 410);
private function actorUserId(): ?int
{
try {
$user = (new authentication())->get_user();
return $user !== false && isset($user->id) ? (int)$user->id : null;
} catch (Throwable) {
return null;
}
}
private function toBool(mixed $value, bool $default): bool
@@ -1,196 +0,0 @@
<?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,7 +4,6 @@ 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;
@@ -18,8 +17,6 @@ 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;
@@ -211,23 +208,9 @@ 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;
@@ -616,7 +599,6 @@ 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());
},
[
+1 -4
View File
@@ -106,7 +106,6 @@ 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']);
@@ -121,9 +120,7 @@ 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)
->and($response->data()['permissions'] ?? [])
->toContain('has_attribute_restrictSpotFree');
->toBe(65);
});
it('rejects invalid auth session tokens', function (): void {
@@ -16,25 +16,6 @@ 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');
@@ -54,7 +35,6 @@ 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'],
@@ -140,8 +120,6 @@ 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,80 +72,3 @@ 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();
});
@@ -1,118 +0,0 @@
<?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'"
);
}
});
@@ -1,129 +0,0 @@
<?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,33 +36,6 @@ 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(
@@ -225,7 +198,6 @@ 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', [
@@ -345,7 +317,7 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun
expect($response->data()['requires_note'] ?? null)->toBeTrue();
});
it('blocks standalone category 8 products for customers restricted from additional services', function (): void {
it('blocks addon products added as standalone additional 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']);
@@ -354,11 +326,10 @@ it('blocks standalone category 8 products for customers restricted from addition
'price' => 200,
]);
$addonProduct = api_fixtures()->createProduct([
'name' => 'Extra detergent',
'category' => 8,
'name' => 'Drying add-on',
'category' => 4,
'price' => 50,
]);
configure_customer_rule_product('restrictAdditionalServices', (int)$addonProduct['id']);
post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers'])
->assertStatus(200)
@@ -374,7 +345,7 @@ it('blocks standalone category 8 products for customers restricted from addition
->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE);
});
it('allows standalone category 8 products when the customer is not restricted from additional services', function (): void {
it('allows standalone additional order items 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();
@@ -383,8 +354,8 @@ it('allows standalone category 8 products when the customer is not restricted fr
'price' => 200,
]);
$addonProduct = api_fixtures()->createProduct([
'name' => 'Unrestricted additional service',
'category' => 8,
'name' => 'Unrestricted add-on',
'category' => 4,
'price' => 50,
]);
@@ -399,7 +370,7 @@ it('allows standalone category 8 products when the customer is not restricted fr
->assertSuccess();
});
it('blocks configured related addon order items for customers restricted from additional services', function (): void {
it('blocks 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']);
@@ -410,7 +381,6 @@ it('blocks configured related addon order items for customers restricted from ad
]);
$addonProduct = api_fixtures()->createProduct([
'name' => 'Related extra brush',
'category' => 8,
'price' => 35,
]);
$primaryItem = api_fixtures()->createOrderItem([
@@ -419,7 +389,6 @@ it('blocks configured related addon order items for customers restricted from ad
'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'],
@@ -430,138 +399,11 @@ it('blocks configured related addon order items for customers restricted from ad
->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)
@@ -587,7 +429,6 @@ 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,7 +36,6 @@ 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']],
@@ -91,7 +90,6 @@ 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([
@@ -160,7 +158,6 @@ 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']],
@@ -204,7 +201,6 @@ 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']],
@@ -271,7 +267,6 @@ 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']],
@@ -321,7 +316,6 @@ 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']],
@@ -366,7 +360,6 @@ 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']],
@@ -421,7 +414,6 @@ 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']],
@@ -490,7 +482,6 @@ 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,7 +51,6 @@ 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']],
@@ -80,7 +79,6 @@ 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']],
@@ -100,14 +98,13 @@ it('denies customer START when department self-serve is disabled', function ():
->assertStatus(403)
->assertMissingPermissions([
'department_access_' . (int)$scenario['department']['id'],
'add_own_department_selfserve_vehicle_conditions',
'list_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']],
@@ -127,7 +124,7 @@ it('denies customer START when lane self-serve is disabled', function (): void {
->assertStatus(403)
->assertMissingPermissions([
'department_access_' . (int)$scenario['department']['id'],
'add_own_department_selfserve_vehicle_conditions',
'list_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',
'add_own_department_selfserve_vehicle_conditions',
'list_own_department_selfserve_vehicle_conditions',
]);
});
@@ -42,29 +42,13 @@ it('requires customer self-serve permission before checking my active wash', fun
$response
->assertStatus(403)
->assertMissingPermissions([
'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',
'list_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' => [
@@ -90,7 +74,6 @@ 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' => [
@@ -116,67 +99,9 @@ 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']);
@@ -190,7 +115,6 @@ 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