Resolve backend Qodana critical and high findings (#314)

Resolve recommended-profile Critical and High findings, retain narrow analyzer exceptions, and update the edge-broker WebSocket dependency to a non-vulnerable release.
This commit is contained in:
Jeppe B
2026-07-17 05:44:16 +02:00
committed by GitHub
parent 6566027746
commit 2a6a86c9c3
108 changed files with 234 additions and 1283 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ $MINIO = [
'access_key' => '', // Minio access
'secret_key' => '' // Minio secret key
];
$SLACK_DEFAULT_WEBHOOK = ''; // Default Slack webhook URL e.g. https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXX
$SLACK_DEFAULT_WEBHOOK = ''; // Set through SLACK_DEFAULT_WEBHOOK; never commit a production webhook URL.
$REDIS_CONFIG = [
'host' => '', // Redis host (IP address)
'user' => '', // Redis user
+21
View File
@@ -16,6 +16,26 @@ bootstrap: |+
npm --prefix services/edge-broker ci --ignore-scripts
exclude:
# This application is intentionally Composer-classmapped and keeps legacy snake_case
# classes plus multiple local test doubles in single files; PSR path rules do not apply.
- name: PhpIllegalPsrClassPathInspection
paths:
- services/nginx/app
# Unit-test doubles intentionally bypass integration-heavy parent constructors.
- name: PhpMissingParentConstructorInspection
paths:
- services/nginx/app/tests
# These focused tests configure doubles through public fields before invoking behavior.
- name: PhpObjectFieldsAreOnlyWrittenInspection
paths:
- services/nginx/app/tests/Unit/Bird/BirdGateCallFlowTest.php
- services/nginx/app/tests/Unit/Invoicing/EconomicCustomersDiscountFallbackTest.php
- services/nginx/app/tests/Unit/Selfserve/SelfserveCustomerLaneAccessTest.php
# API coverage markers are intentional statement-style calls in the Pest DSL.
# Their return value is irrelevant; the call records route/scenario coverage.
- name: PhpExpressionResultUnusedInspection
paths:
- services/nginx/app/tests/Api
- name: All
paths:
- services/nginx/app/vendor
@@ -30,5 +50,6 @@ exclude:
- documentation/topics/generated
- documentation/_build
- documentation/_site_rebuild_20260317
- docs_bird_voice_calls.html
- .tmp
- .openclaw
+1 -1
View File
@@ -144,7 +144,7 @@ function requestJson({ method = "GET", port, path: requestPath, body = null, hea
raw += chunk;
});
response.on("end", () => {
let decoded = {};
let decoded;
try {
decoded = raw.trim() === "" ? {} : JSON.parse(raw);
} catch {
+6 -9
View File
@@ -95,7 +95,7 @@ function directCaddyBaseUrl(baseUrl) {
}
function isLocalHost(hostname) {
const normalized = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
const normalized = String(hostname || "").toLowerCase().replace(/^\x5b|\x5d$/g, "");
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
}
@@ -227,11 +227,7 @@ async function connectCurrentContainerToComposeNetwork(rootDir, composeProject)
return true;
}
if (/already exists|already connected/i.test(stderr)) {
return true;
}
return false;
return /already exists|already connected/i.test(stderr);
}
async function disconnectCurrentContainerFromComposeNetwork(rootDir, composeProject) {
@@ -833,7 +829,7 @@ async function main() {
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
);
} catch (error) {
let operationSnapshot = null;
let operationSnapshot;
try {
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
token: authToken,
@@ -959,9 +955,10 @@ async function main() {
allowFailure: true,
}).catch(() => {});
if (gatewayId !== null && fixture?.auth_token) {
const fixtureAuthToken = fixture?.auth_token;
if (gatewayId !== null && fixtureAuthToken) {
await apiRequest(baseUrl, "DELETE", `/edge-gateways/${gatewayId}`, {
token: String(fixture.auth_token),
token: String(fixtureAuthToken),
}).catch(() => {});
}
+4 -4
View File
@@ -6,13 +6,13 @@
"": {
"name": "truckwash-edge-broker",
"dependencies": {
"ws": "^8.18.0"
"ws": "^8.21.1"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+1 -1
View File
@@ -7,6 +7,6 @@
"test:live": "node --test live/live-smoke.mjs"
},
"dependencies": {
"ws": "^8.18.0"
"ws": "^8.21.1"
}
}
+2 -2
View File
@@ -49,7 +49,7 @@ function resolveManagerUrl(options = {}) {
return trimTrailingSlash(options.managerUrl || process.env.EDGE_MANAGER_URL || process.env.EDGE_PUBLIC_API_URL || "");
}
function resolveAuthMode(options = {}, managerUrl = "") {
function resolveAuthMode(options = {}) {
if (options.authMode) {
return options.authMode;
}
@@ -179,7 +179,7 @@ function rejectUpgrade(socket, statusCode, errorCode, message, details = {}) {
export function createBrokerServer(options = {}) {
const sharedSecret = resolveSharedSecret(options);
const managerUrl = resolveManagerUrl(options);
const authMode = resolveAuthMode(options, managerUrl);
const authMode = resolveAuthMode(options);
const commandTimeoutMs = options.commandTimeoutMs ?? 10000;
const shellOpenTimeoutMs = options.shellOpenTimeoutMs ?? DEFAULT_SHELL_OPEN_TIMEOUT_MS;
+10 -10
View File
@@ -39,8 +39,8 @@ test("traefik does not expose a dedicated public edge broker port", () => {
test("base docker compose routes edge broker traffic through traefik", () => {
const serviceBlock = readComposeServiceBlock(baseComposeSource, "edge-broker");
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-strict\}/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\x7bEDGE_AUTH_MODE:-strict\x7d/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\x7bEDGE_MANAGER_URL:-http:\/\/caddy\x7d/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/);
@@ -55,8 +55,8 @@ test("base docker compose routes edge broker traffic through traefik", () => {
test("example docker compose routes edge broker traffic through traefik", () => {
const serviceBlock = readComposeServiceBlock(exampleComposeSource, "edge-broker");
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-strict\}/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\x7bEDGE_AUTH_MODE:-strict\x7d/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\x7bEDGE_MANAGER_URL:-http:\/\/caddy\x7d/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.example\.com`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
@@ -65,8 +65,8 @@ test("example docker compose routes edge broker traffic through traefik", () =>
test("standalone production compose routes edge broker traffic through traefik", () => {
const serviceBlock = readComposeServiceBlock(standaloneProdComposeSource, "edge-broker");
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-manager\}/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\x7bEDGE_AUTH_MODE:-manager\x7d/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\x7bEDGE_MANAGER_URL:-http:\/\/caddy\x7d/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/);
@@ -78,8 +78,8 @@ test("standalone production compose routes edge broker traffic through traefik",
test("compose config does not provide insecure broker secret defaults", () => {
for (const composeSource of [baseComposeSource, exampleComposeSource]) {
assert.match(composeSource, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/);
assert.match(composeSource, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/);
assert.match(composeSource, /EDGE_BROKER_URL:\s*\$\x7bEDGE_BROKER_URL:-http:\/\/edge-broker:4300\x7d/);
assert.match(composeSource, /EDGE_BROKER_SHARED_SECRET:\s*\$\x7bEDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\x7d/);
}
});
@@ -87,7 +87,7 @@ test("base docker compose wires the broker into each php worker", () => {
for (const serviceName of ["php1", "php2", "php3", "php4", "php5", "php-staging", "php-cron"]) {
const serviceBlock = readComposeServiceBlock(baseComposeSource, serviceName);
assert.match(serviceBlock, /\n\s+depends_on:\s*\n[\s\S]*?\n\s+- edge-broker/);
assert.match(serviceBlock, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/);
assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/);
assert.match(serviceBlock, /EDGE_BROKER_URL:\s*\$\x7bEDGE_BROKER_URL:-http:\/\/edge-broker:4300\x7d/);
assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\x7bEDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\x7d/);
}
});
+3 -2
View File
@@ -162,6 +162,7 @@ class cron_worker
$errorSql = $this->nullableSql($error);
$loopStarted = $this->nullableSql($loopStartedAt);
$stoppedAt = $stopped ? $this->sql($now) : 'NULL';
$nowSql = $this->sql($now);
$this->query(
"INSERT INTO cron_worker_state (
@@ -172,8 +173,8 @@ class cron_worker
) 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
$staleRunCount, $errorSql, $nowSql, $nowSql, $loopStarted,
$nowSql, $stoppedAt
)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
@@ -396,7 +396,7 @@ class customer_rule_product_restriction_service
return $ids;
}
/** @return list<array{id:?int,name:string,sort_order:int,product_ids:list<int>}> */
/** @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)) {
@@ -302,10 +302,10 @@ class department_outside_hours_statistics_service
* @param array<int,array<string,mixed>> $opening_hours_by_department_id
* @param array<string,array<int,bool>>|null $missing_lookup_by_day
* @return array{
* counted:bool,
* reason:string,
* candidate_date:?string,
* department_id:int
* counted: bool,
* reason: string,
* candidate_date: ?string,
* department_id: int
* }
*/
public function classifyCandidateAgainstOpeningHours(
@@ -756,11 +756,14 @@ class economic_transfer_queue
$normalized_payload['requested_by'] = max(0, (int)$normalized_payload['requested_by']);
}
return match ($transfer_type) {
self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => $this->normalizeOrderPayload($normalized_payload, $created_by),
self::TYPE_COLLECTED_INVOICE_EXPORT => $this->normalizeCollectedInvoicePayload($normalized_payload, $created_by),
default => $this->rejectPayload($created_by, 'Unsupported transfer type payload: ' . $transfer_type),
};
if ($transfer_type === self::TYPE_COLLECTED_INVOICE_EXPORT) {
return $this->normalizeCollectedInvoicePayload($normalized_payload, $created_by);
}
if (!in_array($transfer_type, [self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT], true)) {
$this->rejectPayload($created_by, 'Unsupported transfer type payload: ' . $transfer_type);
}
return $this->normalizeOrderPayload($normalized_payload, $created_by);
}
/**
@@ -770,7 +773,7 @@ class economic_transfer_queue
{
$order_id = $payload['order_id'] ?? null;
if ($order_id === null || !is_numeric($order_id) || (int)$order_id < 1) {
return $this->rejectPayload($created_by, 'order_id is required and must be a positive number');
$this->rejectPayload($created_by, 'order_id is required and must be a positive number');
}
$payload['order_id'] = (int)$order_id;
return $payload;
@@ -783,7 +786,7 @@ class economic_transfer_queue
{
$collected_invoice_id = $payload['collected_invoice_id'] ?? null;
if ($collected_invoice_id === null || !is_numeric($collected_invoice_id) || (int)$collected_invoice_id < 1) {
return $this->rejectPayload($created_by, 'collected_invoice_id is required and must be a positive number');
$this->rejectPayload($created_by, 'collected_invoice_id is required and must be a positive number');
}
$payload['collected_invoice_id'] = (int)$collected_invoice_id;
@@ -804,17 +807,17 @@ class economic_transfer_queue
if ($numeric === 0 || $numeric === 1) {
return $numeric === 1;
}
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
$this->rejectPayload($created_by, $field_name . ' must be a boolean');
}
if (is_string($value)) {
$normalized = strtolower(trim($value));
if (in_array($normalized, ['true', 'false', '1', '0'], true)) {
return in_array($normalized, ['true', '1'], true);
}
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
$this->rejectPayload($created_by, $field_name . ' must be a boolean');
}
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
$this->rejectPayload($created_by, $field_name . ' must be a boolean');
}
private function findActiveJobByTarget(string $transfer_type, array $payload, int $created_by): ?array
@@ -10,7 +10,7 @@ class economic_v2_revenue_statistics_service
private economic $economic;
/** @var array<int, array{customer_number:int,name:?string,barred:?bool,status:string}> */
/** @var array<int, array{customer_number: int, name: ?string, barred: ?bool, status: string}> */
private array $customer_cache = [];
public function __construct(?economic $economic = null)
@@ -44,7 +44,6 @@ class economic_v2_revenue_statistics_service
$summary = [
'invoice_count' => 0,
'line_count' => 0,
'unique_customers' => 0,
'net_amount' => 0.0,
'vat_amount' => 0.0,
'gross_amount' => 0.0,
@@ -398,7 +397,7 @@ class economic_v2_revenue_statistics_service
}
/**
* @return array{customer_number:int,name:?string,barred:?bool,status:string}
* @return array{customer_number: int, name: ?string, barred: ?bool, status: string}
*/
private function resolveCustomerSnapshot(int $customer_number, array &$warnings): array
{
@@ -500,4 +499,3 @@ class economic_v2_revenue_statistics_service
return $data;
}
}
-30
View File
@@ -9,41 +9,11 @@ class encrypt implements encrypt_i
public function encrypt(string $data): string
{
// Debug:
return $data;
// Encrypt data
global $ENCRYPTION_KEY;
// Use AES 256 encryption
$cipher = "aes-256-cbc";
// Use the encryption key
$options = 0;
// Get the initialization vector
$iv_length = openssl_cipher_iv_length($cipher);
$iv = openssl_random_pseudo_bytes($iv_length);
// Use the first 16 bytes of the initialization vector
$iv = substr($iv, 0, 16);
// Encrypt the data
$encrypted = openssl_encrypt($data, $cipher, $ENCRYPTION_KEY, $options, $iv);
// Save the initialization vector for decryption
return $iv . $encrypted;
}
public function decrypt(string $data): string
{
// Debug:
return $data;
// Decrypt data
global $ENCRYPTION_KEY;
// Use AES 256 encryption
$cipher = "aes-256-cbc";
// Use the encryption key and initialization vector
$options = 0;
// Get the initialization vector
$iv_length = openssl_cipher_iv_length($cipher);
$iv = substr($data, 0, $iv_length);
// Get the encrypted data
$encrypted = substr($data, $iv_length);
// Decrypt the data
return openssl_decrypt($encrypted, $cipher, $ENCRYPTION_KEY, $options, $iv);
}
}
+74 -24
View File
@@ -5,9 +5,6 @@ namespace classes;
require_once WD . '/modules/entra/entra_c.php';
use entra\entra_c;
use Microsoft\Graph\GraphServiceClient;
use Microsoft\Kiota\Authentication\Oauth\ClientCredentialContext;
use Microsoft\Kiota\Authentication\Oauth\ClientCredentialContextBuilder;
class entra
@@ -23,42 +20,95 @@ class entra
$this->config = new entra_c();
}
public function get_users($array = false): array|object
public function get_users(bool $array = false): array
{
$graphClient = $this->getGraphClient();
$users = $graphClient->users()
->get()
->wait()
->getValue();
$accessToken = $this->requestAccessToken();
$usersResponse = $this->requestJson(
'https://graph.microsoft.com/v1.0/users?$select=id,displayName,mail,userPrincipalName',
['Authorization: Bearer ' . $accessToken]
);
$users = is_array($usersResponse['value'] ?? null) ? $usersResponse['value'] : [];
if (!$array) {
return $users;
}
$result = [];
foreach ( $users as $user ) {
foreach ($users as $user) {
if (!is_array($user)) {
continue;
}
$result[] = [
'id' => $user->getId(),
'displayName' => $user->getDisplayName(),
'mail' => $user->getMail(),
'userPrincipalName' => $user->getUserPrincipalName(),
'id' => $user['id'] ?? null,
'displayName' => $user['displayName'] ?? null,
'mail' => $user['mail'] ?? null,
'userPrincipalName' => $user['userPrincipalName'] ?? null,
];
}
return $result;
}
public function getGraphClient(): GraphServiceClient
private function requestAccessToken(): string
{
return new GraphServiceClient(
$this->getTokenRequestContext(),
$tenantId = trim((string)$this->config->tenant_id->getVariableValue());
$response = $this->requestJson(
'https://login.microsoftonline.com/' . rawurlencode($tenantId) . '/oauth2/v2.0/token',
['Content-Type: application/x-www-form-urlencoded'],
http_build_query([
'client_id' => (string)$this->config->client_id->getVariableValue(),
'client_secret' => (string)$this->config->client_secret->getVariableValue(),
'scope' => 'https://graph.microsoft.com/.default',
'grant_type' => 'client_credentials',
])
);
$token = trim((string)($response['access_token'] ?? ''));
if ($token === '') {
throw new \RuntimeException('Microsoft Entra token response did not contain an access token.');
}
public function getTokenRequestContext(): ClientCredentialContext
return $token;
}
/**
* @param list<string> $headers
* @return array<string,mixed>
*/
private function requestJson(string $url, array $headers, ?string $postFields = null): array
{
return new ClientCredentialContext(
$this->config->tenant_id->getVariableValue(),
$this->config->client_id->getVariableValue(),
$this->config->client_secret->getVariableValue()
);
$curl = curl_init($url);
if ($curl === false) {
throw new \RuntimeException('Unable to initialize Microsoft Entra request.');
}
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => $headers,
]);
if ($postFields !== null) {
curl_setopt($curl, CURLOPT_POST, true);
curl_setopt($curl, CURLOPT_POSTFIELDS, $postFields);
}
try {
$body = curl_exec($curl);
$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
if ($body === false) {
throw new \RuntimeException('Microsoft Entra request failed: ' . curl_error($curl));
}
} finally {
curl_close($curl);
}
$decoded = json_decode((string)$body, true);
if ($status < 200 || $status >= 300 || !is_array($decoded)) {
$message = is_array($decoded)
? (string)($decoded['error_description'] ?? $decoded['error']['message'] ?? 'Unexpected response')
: 'Invalid JSON response';
throw new \RuntimeException('Microsoft Entra request failed with HTTP ' . $status . ': ' . $message);
}
return $decoded;
}
}
-1
View File
@@ -10,7 +10,6 @@ require_once WD . '/modules/forms/form_helper_c.php';
use Exception;
use forms\form_helper_c;
use forms\objects\book_interior_wash_f;
use forms\objects\book_wash_f;
use objects\form_submissions_o;
use traits\form_t;
@@ -137,7 +137,7 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
$cached_result = $this->readRecognitionResultCache($result_cache, $result_cache_key);
if ($cached_result !== null) {
$this->last_timings['cache_hit'] = 1;
return $cached_result;
return $this->completeRecognition($started_at, $cached_result);
}
}
}
@@ -202,7 +202,7 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
return $recognized_result;
return $this->completeRecognition($started_at, $recognized_result);
}
$recognized_result = [
@@ -211,12 +211,19 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
];
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
return $recognized_result;
} finally {
return $this->completeRecognition($started_at, $recognized_result);
} catch (\Throwable $exception) {
$this->last_timings['total'] = $this->elapsedMs($started_at);
throw $exception;
}
}
private function completeRecognition(float $started_at, array $result): array
{
$this->last_timings['total'] = $this->elapsedMs($started_at);
return $result;
}
private static function clientDisconnectAbortCallback(): callable
{
return static function (): int {
@@ -8,7 +8,6 @@ class object_property
private string $table; // The id of the object in the database
private string $column; // The column name of the field in the database table (e.g. id, name, email)
private string $type; // The data type of the field in the database table (e.g. int, varchar, text)
private bool $required; // Whether the field is required or not
private mixed $default; // The default value of the field
private mixed $fake_value; // The fake value of the field, used for testing purposes (When the object id is -1)
@@ -18,7 +17,7 @@ class object_property
$this->id = $id;
$this->column = $column;
$this->type = $type;
$this->required = $required;
unset($required); // Retained in the constructor for compatibility with existing object definitions.
$this->default = $default;
}
+1 -2
View File
@@ -8,12 +8,11 @@ use objects\ratelimit_o;
class ratelimit implements ratelimit_i
{
private int $limit; // The number of requests allowed in the time period
private int $time; // The time period in seconds
public function __construct(int $defaultLimit, int $defaultTime)
{
$this->limit = $defaultLimit;
$this->time = $defaultTime;
unset($defaultTime); // The reset interval is managed by the rate-limit maintenance task.
}
public function enforceIP(string $ip): bool
+2 -2
View File
@@ -339,10 +339,10 @@ class redis implements redis_i
/**
* Cache auth session payload for a token with TTL
*/
public function cache_auth_session(string $token, array $data, int $ttl = 60): self
public function cache_auth_session(string $token, array $session, int $ttl = 60): self
{
$key = 'auth_session_' . $token;
$this->set_array($key, $data);
$this->set_array($key, $session);
$this->expire($key, $ttl);
return $this;
}
@@ -5680,7 +5680,7 @@ class release_manager
$channelId = $this->nullablePositiveInt($input['channel_id'] ?? null);
$channelSlug = self::safeSlug((string)($input['channel_slug'] ?? $input['channel'] ?? ''));
if ($channelId === null && $channelSlug !== '') {
$channel = $this->channelBySlug($channelSlug);
$channel = $this->findChannelBySlug($channelSlug);
if ($channel === null) {
throw new RuntimeException('Release channel was not found for Coolify cleanup.');
}
@@ -9346,7 +9346,7 @@ class release_manager
}
$raw = trim($raw);
$raw = preg_replace('#[/\s].*$#', '', $raw) ?? '';
if (str_contains($raw, ':') && preg_match('/^\[[^\]]+\]:(\d+)$/', $raw) !== 1) {
if (str_contains($raw, ':') && preg_match('/^\x5b[^\x5d]+\x5d:(\d+)$/', $raw) !== 1) {
$parts = parse_url('https://' . $raw);
if (is_array($parts) && !empty($parts['host'])) {
$raw = (string)$parts['host'];
@@ -526,7 +526,6 @@ class superuser_system_status_service
'enabled' => $enabled,
'configured' => $configured,
'probe_supported' => isset($descriptor['probe']),
'status' => 'configured',
'status_reason' => null,
'status_reason_key' => null,
'status_reason_params' => [],
@@ -671,7 +671,7 @@ class system_search_service
/**
* @param array<int, mixed> $entityIds
* @return array<string, array{name:?string,created_at:?string,closed_at:?string}>
* @return array<string, array{name: ?string, created_at: ?string, closed_at: ?string}>
*/
private function loadInvoiceTitleContexts(array $entityIds): array
{
@@ -2283,7 +2283,7 @@ class system_search_service
/**
* @param array<string, mixed> $row
* @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array<string,mixed>}
* @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array<string,mixed>}
*/
private function resolveObjectSearchContext(array $row): array
{
@@ -2296,7 +2296,7 @@ class system_search_service
/**
* @param array<string, mixed> $row
* @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array<string,mixed>}
* @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array<string,mixed>}
*/
private function resolveOrderObjectSearchContext(array $row): array
{
@@ -2336,7 +2336,7 @@ class system_search_service
/**
* @param array<string, mixed> $row
* @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array<string,mixed>}
* @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array<string,mixed>}
*/
private function resolveTaskObjectSearchContext(array $row): array
{
@@ -2379,7 +2379,7 @@ class system_search_service
/**
* @param array<string, mixed> $row
* @return array{title:string,description:string,customer_number:?int,department_id:?int,payload:array<string,mixed>}
* @return array{title: string, description: string, customer_number: ?int, department_id: ?int, payload: array<string,mixed>}
*/
private function resolveGenericObjectSearchContext(array $row): array
{
-1
View File
@@ -96,7 +96,6 @@ class webauthn
$pk_hex = bin2hex($this->b64urlDecode((string)$passkey->public_key->value()));
error_log("WebAuthn verification failed: " . $e->getMessage() . " (PK Hex: $pk_hex)");
throw new Exception("WebAuthn verification failed: " . $e->getMessage() . " (PK Hex: $pk_hex)");
return false;
} catch (ExceptionInterface $e) {
throw new Exception('Serialization error: ' . $e->getMessage());
}
@@ -81,10 +81,7 @@ class wordpress_bookings_remote implements wordpress_bookings_remote_i
} else {
$booking = $booking["data"]["booking"];
}
} else if (isset($booking["id"])) {
// Check if the booking property is set
} else {
} else if (!isset($booking["id"])) {
return null;
}
@@ -820,45 +820,6 @@ class xlvask_automation_service
private function scoreOrderMatch(array $usageItems, array $orderItems): array
{
return self::scoreItemMatchForAutomation($usageItems, $orderItems);
$usageSignature = $this->itemSignatureParts($usageItems);
$orderSignature = $this->itemSignatureParts($orderItems);
$usageTotal = $this->itemsTotal($usageItems);
$orderTotal = $this->itemsTotal($orderItems);
if ($usageSignature === $orderSignature && $usageTotal === $orderTotal) {
return [
'confidence' => 0.95,
'source' => self::SOURCE_DETERMINISTIC,
'reason' => 'Produkterne og prisen matcher en ordre fra samme dag.',
];
}
$usagePrimary = (int)($usageItems[0]['product_id'] ?? 0);
$orderPrimary = (int)($orderItems[0]['product_id'] ?? 0);
if ($usagePrimary < 1 || $usagePrimary !== $orderPrimary) {
return ['confidence' => 0.0, 'source' => self::SOURCE_DETERMINISTIC, 'reason' => ''];
}
$overlap = $this->productOverlap($usageItems, $orderItems);
$totalDiff = abs($usageTotal - $orderTotal);
if ($overlap >= 0.70 && $totalDiff <= 50) {
return [
'confidence' => 0.93,
'source' => self::SOURCE_FUZZY,
'reason' => 'Samme primære produkt og relaterede tillæg matcher en ordre fra samme dag.',
];
}
if ($overlap >= 0.50 && $totalDiff <= 150) {
return [
'confidence' => 0.80,
'source' => self::SOURCE_FUZZY,
'reason' => 'Vasken ligner en ordre fra samme dag, men kræver manuel godkendelse.',
];
}
return ['confidence' => 0.0, 'source' => self::SOURCE_DETERMINISTIC, 'reason' => ''];
}
private function findSameDayCandidateOrders(xlvask_usage_log $log, array $proposedOrder): array
+1 -10
View File
@@ -381,7 +381,7 @@ function normalizeWorkfeedEmployeeWarmupCollection(mixed $raw): array
}
/**
* @return array{id:?string,name:?string}
* @return array{id: ?string, name: ?string}
*/
function extractWorkfeedEmployeeWarmupIdentity(mixed $employee): array
{
@@ -515,15 +515,6 @@ function normalizeWarmupTextValue(mixed $value): ?string
return null;
}
function checkUnfulfilledBookings(): void
{
// This is deactivated for now, as it is not wanted.
// I'm saving this for later, as it is a good idea to have this in place.
return;
$bookings_o = new bookings_o();
$bookings_o->checkUnfulfilledBookings();
}
function syncBookings(): void
{
$bookings_o = new bookings_o();
@@ -17,19 +17,9 @@ $start = microtime(true);
// Load the XL Vask module
$xlvask = new xlvask;
try {
// Check if the module is enabled
if ($xlvask->config->enabled->isTrue()) {
// Check if synchronization is enabled
if ($xlvask->config->synchronization_enabled->isTrue()) {
if ($xlvask->config->enabled->isTrue() && $xlvask->config->synchronization_enabled->isTrue()) {
$xlvask->getTasks()->runCronTasks();
// TODO: Add synchronization for:
// - usageLogs
// - vehicles
} else {
// Synchronization is not enabled, do nothing
}
} else {
// The module is not enabled, do nothing
// TODO: Add synchronization for usage logs and vehicles.
}
} catch (Exception $e) {
// This is automatically running, so we don't need to log the error
+2
View File
@@ -184,6 +184,7 @@ spl_autoload_register(function (string $class): void {
use classes\application_write_freeze;
use classes\db;
use classes\replication_bootstrap_config;
use classes\replication_manager;
use classes\release_manager;
use classes\redis;
@@ -311,4 +312,5 @@ $load_enabled_module_routes = static function (): void {
$load_enabled_module_routes();
// Autoload all the routes
/** @var router $router */
$router->auto_load_routes(WD . '/routes');
@@ -349,10 +349,6 @@ trait dynamicimages_image_t
if ($this->image instanceof \Imagick) {
$img = clone $this->image;
$img->setImageFormat('png');
// Quality influences compression for PNG differently; keep as hint
if ($format !== null && strtolower($format) !== 'png') {
// For now we only support PNG for composed images as requested
}
// Strip metadata to reduce size
$img->stripImage();
$blob = $img->getImageBlob();
@@ -205,7 +205,6 @@ class economic_tasks
break;
default:
throw new Exception('Unknown error message: ' . $error_message);
break;
}
}
return;
@@ -4967,7 +4967,6 @@ BASH;
'recent_command_timeouts' => 0,
'recent_unknown_gate_outcomes' => 0,
'recent_relay_commands' => 0,
'recent_relay_failure_rate' => 0.0,
'recent_command_avg_latency_seconds' => null,
'last_successful_command_at' => null,
'last_successful_discovery_at' => null,
@@ -5278,7 +5277,7 @@ BASH;
}
/**
* @return array{url:?string,error:?string}
* @return array{url: ?string, error: ?string}
*/
private function normalizeBrokerDiagnosticBaseUrl(mixed $value): array
{
@@ -5307,7 +5306,7 @@ BASH;
}
/**
* @param array{url:?string,error:?string} $baseUrl
* @param array{url: ?string, error: ?string} $baseUrl
* @return array<string,mixed>
*/
private function diagnoseBrokerHttpEndpoint(array $baseUrl, string $label): array
@@ -5353,7 +5352,7 @@ BASH;
}
/**
* @param array{url:?string,error:?string} $baseUrl
* @param array{url: ?string, error: ?string} $baseUrl
* @return array<string,mixed>
*/
private function diagnoseBrokerSharedSecret(array $baseUrl, string $sharedSecret): array
@@ -5413,7 +5412,7 @@ BASH;
}
/**
* @param array{url:?string,error:?string} $baseUrl
* @param array{url: ?string, error: ?string} $baseUrl
* @param array<int,string> $headers
* @return array<string,mixed>
*/
@@ -5464,7 +5463,7 @@ BASH;
}
/**
* @param array{url:?string,error:?string} $baseUrl
* @param array{url: ?string, error: ?string} $baseUrl
* @return array<string,mixed>
*/
private function brokerDiagnosticUrlFailure(array $baseUrl, string $label): array
@@ -35,7 +35,6 @@ class edgeGatewayConfigRoute
if (!$user) {
(new logs_o())->add('edgegateway_config', 'global', 1, 0, 'EDGEGATEWAY_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
(new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_CONFIG', 'Successfully fetched edge gateway config');
@@ -59,7 +58,6 @@ class edgeGatewayConfigRoute
if (!$user) {
(new logs_o())->add('edgegateway_config', 'global', 1, 0, 'EDGEGATEWAY_CONFIG', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
(new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_CONFIG', 'Successfully updated edge gateway config');
@@ -75,7 +73,6 @@ class edgeGatewayConfigRoute
if (!$user) {
(new logs_o())->add('edgegateway_config', 'global', 1, 0, 'EDGEGATEWAY_BROKER_DIAGNOSTICS', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
$payload = self::getParametersAsArray();
@@ -42,74 +42,17 @@ class book_wash_f extends form_helper_c
public function beforeSave(): void
{
throw new \Exception('Deprecated: Please refresh the page, and use the new booking page instead.');
// Set the department id to the department id of the user
self::setDepartmentId(self::getSanitizedData('department_id'));
// Set the customer number to the customer number of the user
self::setCustomerNumber(self::getSanitizedData('customer_number'));
}
/**
* @inheritDoc
* @throws \Exception
* @throws ClientExceptionInterface
*/
public function afterSubmit(): void
{
$email = new email();
// Get the user from the customer number
$user = (new users_o())->getUserByCustomerNumber(self::getSanitizedData('customer_number'));
// Get the department name from the department id
$department = (new departments_o())->selectId((int)self::getSanitizedData('department_id'));
$department->getObjectProperties();
// Get the bookings_new object
throw new \Exception('Deprecated: Use bookings_o instead of bookings_new_o');
$bookings = new bookings_o();
// Check if the wash type contains the interior wash
$wants_wash_certificate = (bool)self::getSanitizedData('wants_wash_certificate');
if (in_array(3, self::getSanitizedData('wash_type'))) {
if ($wants_wash_certificate) {
$wash_certificate_email = self::getSanitizedData('wants_wash_certificate_email');
} else {
$wash_certificate_email = '';
}
} else {
$wash_certificate_email = '';
$wants_wash_certificate = false;
}
// Create a new booking
$bookings->add(
self::getCustomerNumber(),
self::getFormIdentifier(),
self::getSanitizedData('contact_email'),
self::getSanitizedData('reference'),
strtoupper(self::getSanitizedData('registration_number_tractor')),
strtoupper(self::getSanitizedData('registration_number_trailer')),
$wash_certificate_email ?? '',
self::getSanitizedData('date'),
$department->id,
(bool)self::getSanitizedData('wants_pickup'),
self::getSanitizedData('notes'),
($wants_wash_certificate ? 'pending' : 'cancelled'),
'',
'pending',
self::getSanitizedData('wash_type'),
);
// Ad
// Validate the booking actually exists
if (!$bookings->exists()) {
throw new \Exception('Booking could not be created');
}
// Send the booking confirmation email
$email->sendBookingConfirmationEmail(
$bookings->id,
);
$this->booking_object = $bookings;
// TODO: Implement afterSubmit() method.
}
/**
* @inheritDoc
*/
public function setup(): void
{
self::setFormIdentifier('BOOK_WASH');
@@ -1047,7 +1047,7 @@ class goals_criteria implements goals_criteria_i
{
$immutable = $date instanceof \DateTimeImmutable
? $date
: \DateTimeImmutable::createFromMutable($date);
: \DateTimeImmutable::createFromInterface($date);
if ($timezone !== null) {
$immutable = $immutable->setTimezone($timezone);
}
@@ -117,7 +117,6 @@ class goals_progress_alert_renderer
$ref = new \ReflectionClass($criteria);
if ($ref->hasMethod('getProgress')) {
$m = $ref->getMethod('getProgress');
$m->setAccessible(true);
/** @var int $val */
$val = $m->invoke($criteria);
return (int)$val;
@@ -836,7 +836,8 @@ class selfserve_studio_graph
$customerNumber,
$configSource,
$versionId,
$hardwareMode
$hardwareMode,
$confirmationRows
): void {
if ($progressCallback === null) {
return;
@@ -93,7 +93,7 @@ class selfserve_task_attachment_payloads
}
/**
* @return array{image:?string,document:?string,relation:mixed,other:mixed}
* @return array{image: ?string, document: ?string, relation: mixed, other: mixed}
*/
private function contentPayload(mixed $content): array
{
@@ -3337,7 +3337,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
}
/**
* @return array{driver:string,key:string,token:?string}
* @return array{driver: string, key: string, token: ?string}
*/
protected function acquireSessionMutationLock(string $lockKey): array
{
@@ -3369,7 +3369,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
}
/**
* @param array{driver:string,key:string,token:?string} $lock
* @param array{driver: string, key: string, token: ?string} $lock
*/
protected function releaseSessionMutationLock(array $lock): void
{
@@ -145,8 +145,7 @@ trait selfserve_lane_command_t
}
try {
$active_wash = new selfserve_wash_flow();
$active_wash->addVehicleTypeProductToInvoiceForLane($this->id);
$this->addVehicleTypeProductToLastInvoiceOrder();
} catch (\Throwable) {
// Best effort only; invoice correction can be handled manually if needed.
}
@@ -82,66 +82,3 @@ if (isset($_GET['justDownload'])) {
http_response_code(410);
echo 'Booking completion must be completed through POS desktop or mobile steps.';
exit;
// Require the $_GET variables sealOrPlumber, safetySeal, performedBy, and bookingId, regNumber, and regNumberTrailer to be set
if (!isset($_GET['sealOrPlumber']) || !isset($_GET['performedBy']) || !isset($_GET['bookingId']) || !isset($_GET['regNumber']) || !isset($_GET['regNumberTrailer']) || !isset($_GET['department'])) {
// We are missing some required fields in the query string
echo 'Missing required fields';
exit;
}
// Check if the certificate already exists in the bucket
$wash_certificate_store = new wash_certificate_store();
if ($wash_certificate_store->washCertificateExists($_GET['bookingId'])) {
// Return the certificate url
echo $wash_certificate_store->getWashCertificateDownload($_GET['bookingId']);
// Exit the script
exit;
} else {
// Check if the certificate exists in the filesystem (legacy system)
if (file_exists(dirname(__FILE__) . "/output/certificates/wash_certificate_" . $_GET['bookingId'] . ".pdf")) {
// Upload the certificate to the bucket
$success = $wash_certificate_store->uploadFile("wash_certificate_" . $_GET['bookingId'] . ".pdf", dirname(__FILE__) . "/output/certificates/wash_certificate_" . $_GET['bookingId'] . ".pdf");
// If the certificate was uploaded successfully, delete the local copy
if ($success) {
unlink(dirname(__FILE__) . "/output/certificates/wash_certificate_" . $_GET['bookingId'] . ".pdf");
// Return the certificate url
echo $wash_certificate_store->getWashCertificateDownload($_GET['bookingId']);
exit;
}
// If the certificate was not uploaded successfully, return an error
echo 'Failed to upload the certificate';
// Exit the script
exit;
}
}
// Usage example
$template = "templates/template2024julv3.xlsx";
$generator = new WashCertificateGenerator($template, $_GET['department']);
$generator->generateCertificate(dirname(__FILE__) . "/output/certificates/wash_certificate_" . $_GET['bookingId'] . ".pdf", $_GET['sealOrPlumber'], $_GET['regNumber'], $_GET['regNumberTrailer'], $_GET['performedBy']);
// Determine the generated certificate name
$generatedCertificateName = "wash_certificate_" . $_GET['bookingId'] . ".pdf";
// Return the generated certificate path
$generatedCertificatePath = "output/certificates/wash_certificate_" . $_GET['bookingId'] . ".pdf";
// Upload the certificate to the bucket
$wash_certificate_store->uploadFile($generatedCertificateName, dirname(__FILE__) . '/' . $generatedCertificatePath);
// Delete the local copy of the certificate
unlink(dirname(__FILE__) . '/' . $generatedCertificatePath);
// Set the status of the booking to completed
$booking = new bookings_o();
$booking->id = $_GET['bookingId'];
$booking->getObjectProperties();
$booking->status->set('completed');
$booking->washCertificateUrl->set('Protected URL');
$booking->washCertificateStatus->set('completed');
// Return the generated certificate object download URL
echo $wash_certificate_store->getWashCertificateDownload($_GET['bookingId']);
// Exit the script
exit;
@@ -13,10 +13,9 @@ class xlvask_create_customer extends xlvask_helper
* Creates a customer in the XLVask system based on the provided user object.
*
* @param users_o $user The user object containing customer information.
* @return xlvask_customer The created XLVask customer object.
* @throws Exception If the user does not have a customer number or if the customer already exists.
*/
public static function createCustomer(users_o $user): xlvask_customer
public static function createCustomer(users_o $user): never
{
// Validate user object
$user->requireSelected();
@@ -30,55 +29,8 @@ class xlvask_create_customer extends xlvask_helper
if ($user->hasXLVaskCustomerAccount()) {
throw new Exception('User already has an XLVask customer account');
}
// Generate a unique customer GUID
$customer_id = self::generateCustomerId();
// TODO: FINISH THE CUSTOMER CREATION, ONCE THE XLVASK API IS READY
throw new Exception('Customer creation is not implemented yet, delayed until the XLVask API is ready');
// Create the customer
$tmp_result = $xlvask->sendRequest($xlvask->config->api_url . '/Customers', 'POST', [
'customerId' => $customer_id,
'name' => $user->getCustomerName((int)$user->customer_number->value()),
'vendorId' => $xlvask->config->vendor_id,
'customerTypeId' => null,
'discount' => 1,
'phone' => null,
'email' => null,
'address' => null,
'address2' => null,
'zip' => null,
'city' => null,
'userId' => null,
'vatnumber' => null,
'excludeFromAutoInvoice' => true,
'country' => null,
'createDate' => null,
'active' => true,
'language' => null,
'note' => null,
'updated' => null,
'externId' => (string)$user->customer_number->value()
], [
$xlvask->getAuthHeader()
]);
print_r($tmp_result);
}
/**
* Generates a unique customer ID for the XLVask system.
*
* @return string The generated customer ID. (UUID format)
*/
private static function generateCustomerId(): string
{
// Generate a UUID for the customer ID
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
}
@@ -204,303 +204,17 @@ class xlvask_tasks
$user = $users[$customer->externId];
// Cache the customer
$cache->setCustomerCache((int)$customer->externId, $customer);
} else {
//echo 'MISSING USER: ' . $customer->name . ' (' . $customer->externId . ')' . PHP_EOL;
}
}
return $with_external_id; // Return the synchronized customers
}
/**
* Run the sync usage task
* This task is used to synchronize the usage of the XL Vask module.
* It fetches the usage data from XL Vask and adds it to the applicable users.
* The time format XL vask uses is "2025-05-01T00:00:00.000" - this is the ISO 8601 format.
* @return array|null
* @throws Exception
* Usage synchronization is intentionally disabled until the XL Vask integration is completed.
*/
public function runSyncUsage(string $dateFrom = null, string $dateTo = null): array|null
public function runSyncUsage(?string $dateFrom = null, ?string $dateTo = null): void
{
// TODO: Remove this, this is just for testing purposes
return null;
// Define the XL Vask object
$xlvask = new \classes\xlvask();
// Require the module to be enabled
$xlvask->requireModuleEnabled();
// Check if synchronization is enabled
if (!$xlvask->config->synchronization_enabled->isTrue()) {
throw new Exception('XL Vask synchronization is not enabled.');
}
// TODO: Remove this, this is just for testing purposes:
$dateFrom = "2025-06-01 00:00:00";
$dateTo = "2025-07-01 00:00:00";
// Set the date from which to fetch the usage data
// Since this is a cron task, we will fetch the usage data from the last 24 hours
$minutes = 24 * 60; // 24 hours in minutes
// TODO: Remove this, this is just for testing purposes:
$minutes = $minutes * 20;
// If dateFrom is not set, set it to 24 hours ago
if (empty($dateFrom)) {
$dateFrom = date('Y-m-d\TH:i:s.000', strtotime('-' . $minutes . ' minutes'));
} else {
// If dateFrom is set, make sure it is in the correct format
$dateFrom = date('Y-m-d\TH:i:s.000', strtotime($dateFrom));
}
// If dateTo is not set, set it to now
if (empty($dateTo)) {
$dateTo = date('Y-m-d\TH:i:s.000'); // Current time in ISO 8601 format
} else {
// If dateTo is set, make sure it is in the correct format
$dateTo = date('Y-m-d\TH:i:s.000', strtotime($dateTo));
}
echo 'Fetching usage data from: ' . $dateFrom . ' to: ' . $dateTo . PHP_EOL;
//echo $dateFrom; (E.g. 2025-06-11T12:13:16.000)
// If there are no customers, get them. TODO: Remove this in production, this is just for testing
$xlvask->getTasks()->runSyncUsers(true);
// Get the cached customers
$customers = $xlvask->getCache()->getAllCachedCustomers();
// Get all the usage logs from XL Vask
$debug_usage_logs = $xlvask->getUsageLog(
$dateFrom,
null, // regNr
null, // vehicleId
null // customerId
);
$debug_usage_logs = self::formatUsageLogs($debug_usage_logs);
// Filter out the logs that are after the dateTo
$debug_usage_logs = array_filter($debug_usage_logs, function ($log) use ($dateTo) {
/** @var xlvask_usage_log $log */
return strtotime($log->getFormattedDate()) <= strtotime($dateTo);
});
echo 'Found ' . count($debug_usage_logs) . ' usage logs in the date range from ' . $dateFrom . ' to ' . $dateTo . PHP_EOL;
echo "Prepaid: " . count(array_filter($debug_usage_logs, function ($log) {
/** @var xlvask_usage_log $log */
return $log->isPrepaid();
})) . PHP_EOL;
echo "Finished: " . count(array_filter($debug_usage_logs, function ($log) {
/** @var xlvask_usage_log $log */
return $log->isCompleted();
})) . PHP_EOL;
echo 'Not finished (skipped): ' . count(array_filter($debug_usage_logs, function ($log) {
/** @var xlvask_usage_log $log */
return !$log->isCompleted();
})) . PHP_EOL;
echo 'Billed to default customer (skipped): ' . count(array_filter($debug_usage_logs, function ($log) {
/** @var xlvask_usage_log $log */
return $log->hasDefaultCustomer();
})) . PHP_EOL;
echo 'Unique customers: ' . count(array_unique(array_map(function ($log) {
/** @var xlvask_usage_log $log */
return $log->CustomerId;
}, $debug_usage_logs))) . PHP_EOL;
$linked_orders = (new orders_o())->getFieldsWhere([
'wash_id' => array_map(function ($log) {
/** @var xlvask_usage_log $log */
return $log->WashId;
}, $debug_usage_logs),
'deleted_at' => null,
], [
'id',
'wash_id',
]);
echo "Linked to orders: " . count($linked_orders) . PHP_EOL;
$eligible_for_automatic_continuance = array_filter($debug_usage_logs, function ($log) {
/** @var xlvask_usage_log $log */
return $log->isEligibleForAutomaticContinuance(true);
});
$eligible_for_automatic_continuance_without_prepaid = array_filter($debug_usage_logs, function ($log) {
/** @var xlvask_usage_log $log */
return $log->isEligibleForAutomaticContinuance(false);
});
echo "Eligible for automatic continuance: " . count($eligible_for_automatic_continuance) . " (" . count($eligible_for_automatic_continuance_without_prepaid) . " without prepaid)" . PHP_EOL;
// Print a list of customers that do not have an external ID
$customers_without_external_id = array_filter($debug_usage_logs, function ($log) {
/** @var xlvask_usage_log $log */
return !$log->hasExternalId() && !$log->hasDefaultCustomer();
});
echo "Without billable customer: " . count(array_filter($debug_usage_logs, function ($log) {
/** @var xlvask_usage_log $log */
return !$log->hasBillableCustomer();
})) . " (" . count(array_filter($debug_usage_logs, function ($log) {
/** @var xlvask_usage_log $log */
return !$log->hasBillableCustomer() && !$log->hasDefaultCustomer() && !$log->hasExternalId();
})) . " without external ID)" . PHP_EOL;
foreach ( self::sortLogsByDate($customers_without_external_id) as $customer ) {
/** @var xlvask_usage_log $customer */
echo ' - ' . $customer->getFormattedDate() . ' - ' . $customer->Customer . ' - ' . $customer->getDepartment()->name->value() . ', ' . $customer->getLane() . ' - ' . $customer->getTotalPrice() . ' DKK' . ' ( ' . ($customer->isCompleted() ? 'Finished' : 'Not finished') . ', ' . ($customer->isPrepaid() ? 'Prepaid' : 'Not prepaid') . ' )' . PHP_EOL;
}
// Get all unique external IDs from the customers
$unique_external_customer_ids = array_unique(array_map(function ($customer) {
/** @var xlvask_usage_log $customer */
return $customer->CustomerId;
}, $debug_usage_logs));
// Remove the external IDs that are not numeric or empty
$unique_external_customer_ids = array_filter($unique_external_customer_ids, function ($id) {
return !empty($id) && is_numeric($id);
});
echo "Unique external customer IDs: " . count($unique_external_customer_ids) . PHP_EOL;
foreach ( $unique_external_customer_ids as $customer_id ) {
$doesCustomerExistInArray = array_filter($customers, function ($customer) use ($customer_id) {
/** @var xlvask_customer $customer */
return $customer->externId === $customer_id;
});
$tmp_does_customer_exist = count($doesCustomerExistInArray) > 0;
if (!$tmp_does_customer_exist) {
echo ' - ' . $customer_id . ' does not exist in the system (skipped)' . PHP_EOL;
continue; // Skip customers that do not exist in the system
}
$tmp_customer_object = reset($doesCustomerExistInArray); // Get the first customer object that matches the external ID
/** @var xlvask_customer $tmp_customer_object */
echo ' - ' . $tmp_customer_object->name . ' (' . $tmp_customer_object->externId . ') - ' . $tmp_customer_object->customerId . PHP_EOL;
echo " - # Washes: " . count(array_filter($debug_usage_logs, function ($log) use ($tmp_customer_object) {
/** @var xlvask_usage_log $log */
return $log->CustomerId === $tmp_customer_object->externId;
})) . PHP_EOL;
// Echo the eligible for automatic continuance logs
foreach ( self::sortLogsByDate(array_filter($debug_usage_logs, function ($log) use ($tmp_customer_object) {
/** @var xlvask_usage_log $log */
return $log->CustomerId === $tmp_customer_object->externId && $log->isEligibleForAutomaticContinuance(false);
})) as $log ) {
/** @var xlvask_usage_log $log */
$tmp_linked_to_order = array_filter($linked_orders, function ($order) use ($log) {
/** @var array $order */
return $order['wash_id'] === $log->WashId;
});
echo ' - - ' . $log->getFormattedDate() . ' - ' . $log->getDepartment()->name->value() . ', ' . $log->getLane() . ' - ' . $log->getTotalPrice() . ' DKK' . ' ( ' . ($log->isCompleted() ? 'Finished' : 'Not finished') . ', ' . ($log->isPrepaid() ? 'Prepaid' : 'Not prepaid') . ', ' . ($tmp_linked_to_order ? 'Linked to order' : 'Not linked to order') . ' )' . PHP_EOL;
}
// Echo the ineligible for automatic continuance logs
echo " - # Not eligible for automatic continuance: " . count(array_filter($debug_usage_logs, function ($log) use ($tmp_customer_object) {
/** @var xlvask_usage_log $log */
return $log->CustomerId === $tmp_customer_object->externId && !$log->isEligibleForAutomaticContinuance(false);
})) . PHP_EOL;
foreach ( self::sortLogsByDate(array_filter($debug_usage_logs, function ($log) use ($tmp_customer_object) {
/** @var xlvask_usage_log $log */
return $log->CustomerId === $tmp_customer_object->externId && !$log->isEligibleForAutomaticContinuance(false);
})) as $log ) {
/** @var xlvask_usage_log $log */
echo ' - - ' . $log->getFormattedDate() . ' - ' . $log->getDepartment()->name->value() . ', ' . $log->getLane() . ' - ' . $log->getTotalPrice() . ' DKK' . ' ( ' . ($log->isCompleted() ? 'Finished' : 'Not finished') . ', ' . ($log->isPrepaid() ? 'Prepaid' : 'Not prepaid') . ' )' . PHP_EOL;
}
}
echo 'Customers:';
print_r($customers);
echo 'Eligible for automatic continuance:';
print_r($eligible_for_automatic_continuance);
exit;
// Loop through the customers and check if there's any new usage data
foreach ( $customers as $customer ) {
/** @var xlvask_customer $customer */
// Check if the customer has an externId
if (empty($customer->externId)) {
continue; // Skip customers without an externId
}
//echo 'Checking customer: ' . $customer->name . ' (' . $customer->externId . ')' . PHP_EOL;
$tmp_usage_logs = self::formatUsageLogs($xlvask->getUsageLog(
$dateFrom,
null, // regNr
null, // vehicleId
$customer->customerId // customerId
));
foreach ( $tmp_usage_logs as $log ) {
/** @var xlvask_usage_log $log */
// Check if the wash was completed
if (!$log->isCompleted()) {
//echo 'A log is not completed: ' . $log->getFormattedDate() . ' - ' . $log->CustomerId . ' - ' . $log->VehicleId . PHP_EOL;
continue; // Skip logs that are not completed
}
// echo PHP_EOL;
// echo '### ' . $log->getFormattedDate() . ' - ' . $customer->name . ' (' . $customer->externId . ') - ' . $log->getTotalPrice() . PHP_EOL;
// echo '# License Plate: ' . $log->RegistrationNumber . PHP_EOL;
// echo '# Vehicle ID: ' . $log->VehicleId . PHP_EOL;
// echo '# Wash ID: ' . $log->WashId . PHP_EOL;
// echo '# Hall: ' . $log->Hall . PHP_EOL;
// echo '# Hall ID: ' . $log->HallId . PHP_EOL;
// echo '# Department: ' . $log->getDepartment()->id . ' ( ' . $log->getDepartment()->name->value() . ' )' . PHP_EOL;
// echo '# Track / Lane: ' . $log->getLane() . PHP_EOL;
// echo '# Linked to order: ' . ($log->isLinkedToOrder() ? 'Yes' : 'No') . PHP_EOL;
// echo '# Items: ' . PHP_EOL;
$tmp_skipped_items = [];
foreach ( $log->WashItems as $item ) {
/** @var xlvask_wash_item $item */
// Skip items that have the id 64, since these are not relevant to anyone.
$tmp_item_id = $item->getProduct($log)->id;
if ($tmp_item_id === 64 || !$item->isCountAboveZero()) {
$tmp_skipped_items[] = $item;
continue; // Skip items with id 64 (Or items simply not relevant)
}
//echo '## ' . $item->Count . ' x ' . $item->OriginalProductName . ' @ ' . $item->getPriceExVat() . ' as ' . $tmp_item_id . ' ( ' . $item->getProduct($log)->name->value() . ' )' . PHP_EOL;
}
//echo '### Total: ' . $log->getTotalPrice() . PHP_EOL;
//echo '### Skipped items: ' . count($tmp_skipped_items) . PHP_EOL;
if (count($tmp_skipped_items) > 0) {
//echo '### Skipped items details: ' . PHP_EOL;
foreach ( $tmp_skipped_items as $skipped_item ) {
/** @var xlvask_wash_item $skipped_item */
//echo '## ' . $skipped_item->Count . ' x ' . $skipped_item->OriginalProductName . ' @ ' . $skipped_item->getPriceExVat() . ' as ' . $skipped_item->getProduct($log)->id . ' ( ' . $skipped_item->getProduct($log)->name->value() . ' )' . PHP_EOL;
}
}
//echo '#' . PHP_EOL;
if (!$log->isLinkedToOrder()) {
//echo '# Checking for orders that might be addressing this wash.' . PHP_EOL;
// Check if an order that matches this wash exists.
if ($log->getPotentialOrder() !== null) {
$xlvask_potential_order_matches_o = new xlvask_potential_order_matches_o();
// Check if the potential order match is already in the database (Prevent duplicates)
if (!$xlvask_potential_order_matches_o->doesWashPotentialOrderMatchExist(
$log->WashId,
)) {
// There's no match in the database, so we will add it.
//echo '# Adding potential order match for wash: ' . $log->WashId . ' - Order: ' . $log->getPotentialOrder()->id . ' - Customer: ' . $customer->customerId . ' - Customer Number: ' . (int)$customer->getUser()->customer_number->value() . ' - Department: ' . $log->getDepartment()->id . PHP_EOL;
$xlvask_potential_order_matches_o->add(
(string)$log->WashId,
(int)$log->getPotentialOrder()->id,
(string)$customer->customerId,
(int)$customer->getUser()->customer_number->value(),
(int)$log->getDepartment()->id,
);
}
//echo '# This wash might be associated with an order: ' . $log->getPotentialOrder()->id . PHP_EOL;
} else {
// If no order is found, we will generate a new order.
//echo '# No potential order found for this wash.' . PHP_EOL;
// Verify the customer object
if (!$customer instanceof xlvask_customer) {
//echo '# The customer object is not an instance of xlvask_customer.' . PHP_EOL;
continue; // Skip this wash
}
if (!$tmp_order = $this->createOrderFromWash($log, $customer)) {
//echo '# Failed to create an order from this wash.' . PHP_EOL;
} else {
//echo '# Order created successfully.' . PHP_EOL;
//echo '# Order ID: ' . $tmp_order->id . PHP_EOL;
//echo '# Order total: ' . $tmp_order->getNetAmount() . PHP_EOL;
}
}
//echo '# This wash is not linked to an order, generating a new order.' . PHP_EOL;
}
//echo '# ------------------------' . PHP_EOL;
}
//print_r($tmp_usage_logs);
// Check if the customer has any usage data in the last 24 hours
// This is a placeholder, you might want to implement a method to check this
// if (!$xlvask->hasRecentUsageData($customer->externId, $dateFrom)) {
// continue; // Skip customers without recent usage data
// }
}
// Process the usage data and update the users accordingly
//$xlvask->processUsageData($usage_data);
return [];
unset($dateFrom, $dateTo);
}
private static function formatUsageLogs(array $getUsageLog): array
@@ -297,12 +297,7 @@ class xlvask_usage_log extends xlvask_helper
}
// If the washItems property is set, ensure it is an array
if (isset($data['WashItems']) && is_array($data['WashItems'])) {
//echo 'Setting WashItems with ' . count($this->WashItems) . ' items.' . PHP_EOL;
$this->WashItems = self::generateWashItems($data['WashItems']);
//print_r($this->WashItems);
} else {
//echo 'No washItems provided or not an array. Initializing as empty array.' . PHP_EOL;
//print_r($data);
}
// After setting all properties, nullify nullable properties
$this->unsetNullifiableProperties();
@@ -106,11 +106,6 @@ class collected_order_invoices_o extends db
{
// Require the invoice collection to be selected
self::requireSelected();
// Check if the object is cached
$cached = self::getCached('asArray', $this->id);
if ($cached !== null) {
//return (array)$cached;
}
$tmp = [
'id' => (int)$this->id,
'customer_number' => (int)$this->customer_number->value(),
@@ -1154,10 +1149,8 @@ class collected_order_invoices_o extends db
break;
case 2:
throw new Exception('Stripe invoice collections cannot be split');
break;
case 3:
throw new Exception('Due to the stateless nature of the processor, invoice collections cannot be split. Please contact technical support for assistance.');
break;
default:
throw new Exception('Invalid processor type');
}
@@ -1734,11 +1727,6 @@ class collected_order_invoices_o extends db
self::requireSelected();
// Get the orders in the invoice collection
$orders = self::getOrders();
// Check if there are any orders in the invoice collection
if (empty($orders)) {
// This has been removed as it is valid to have an invoice collection with no orders, if the customer only has a fixed price agreement
// throw new Exception('No orders in invoice collection'); DON'T RE-ADD THIS.
}
self::removeVehicleSubscriptionsTransactions();
// Create the fixed prices transaction
$transaction = new orders_o();
@@ -2,6 +2,8 @@
namespace objects;
use Psr\Http\Client\ClientExceptionInterface;
use attachments\helpers\attachment_content;
use classes\db;
use classes\email;
@@ -175,7 +175,6 @@ class subusers_o extends db
return $this;
} catch (Exception $e) {
$response->error($e->getMessage());
throw $e;
}
}
@@ -366,10 +366,6 @@ final class BrokerWebSocketClient
'lastDisconnectedAt' => null,
];
public function __construct(private readonly Logger $logger)
{
}
public function configure(?string $brokerUrl, ?int $gatewayId, ?string $agentToken, ?string $agentInstanceId): void
{
$normalizedUrl = $this->normalizeBrokerBaseUrl($brokerUrl);
@@ -1061,7 +1057,7 @@ final class TruckwashEdgeAgent
$this->controlPlaneStatusPath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'control-plane-status.json';
$this->stagedUpdatePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'staged-update.json';
$this->agentInstanceId = $this->ensureAgentInstanceId();
$this->brokerClient = new BrokerWebSocketClient($this->logger);
$this->brokerClient = new BrokerWebSocketClient();
$this->shellBridge = new AgentShellBridge($this->installDir);
$this->logger->setSink(fn(string $level, string $message): bool => $this->emitLogFrame($level, $message));
$this->configureBrokerClient();
@@ -122,11 +122,6 @@ class InvoicingPeriodRoute
} catch (\InvalidArgumentException $e) {
$response->error($e->getMessage(), 400);
}
return [
'dateFrom' => '',
'dateTo' => '',
];
}
/**
@@ -855,7 +850,6 @@ class InvoicingPeriodRoute
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
try {
@@ -881,7 +875,6 @@ class InvoicingPeriodRoute
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
$id = (int)($this->fromRoute('id') ?? 0);
@@ -910,7 +903,6 @@ class InvoicingPeriodRoute
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
try {
+1 -11
View File
@@ -2,12 +2,8 @@
namespace routes;
use attachments\helpers\attachment;
use attachments\helpers\attachment_content;
use classes\attachment_store;
use classes\attachments;
use classes\response;
use objects\orders_o;
use traits\route_t;
class attachmentsRoute
@@ -16,14 +12,8 @@ class attachmentsRoute
public function run(): void
{
$this->get('/attachments/example', function () {
global $response;
$this->get('/attachments/example', function (): never {
throw new \Exception('EXAMPLE ROUTE, SHOULD BE IMPLEMENTED IN THE INDIVIDUAL OBJECT ROUTES');
$orders_o = new orders_o();
$orders_o->select(22636);
// Debug: Create an attachment
$orders_o->addAttachment((new attachment_content())->setOther('Hello World!'));
$response->success($orders_o->listAttachments());
});
$this->post('/attachments/upload', function () {
global $response;
-3
View File
@@ -532,7 +532,6 @@ class authRoute
'message' => $exception->getMessage(),
]);
$response->error('CVR could not be verified. Please check the CVR number and try again.', 400);
return;
}
$name = trim((string)($companyInformation->name ?? ''));
@@ -543,7 +542,6 @@ class authRoute
'requestedCustomerNumber' => $companyPhone,
]);
$response->error('CVR could not be verified. Please check the CVR number and try again.', 400);
return;
}
if ($localUserExists) {
@@ -1019,7 +1017,6 @@ class authRoute
'customerNumber' => $customerNumber,
]);
$response->error('Customer was created in e-conomic but could not be imported locally.', 500);
throw new Exception('Customer was created in e-conomic but could not be imported locally.');
}
/**
@@ -462,33 +462,9 @@ class bookingsRoute
);
$this->post('/admin/bookings/completeWashWithoutWashCertificate', function () {
// Require the user to be logged in
global /** @var response $response */
$response;
$response->error('Booking completion must be completed through POS desktop or mobile steps.', 410);
$this->requirePermission('complete_wash_without_wash_certificate');
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if (!$user->exists()) {
$response->error('User not found', 400);
}
// Check if the required fields are set
$id = $response->getRequestParameter('id');
// Make sure the id is a number
if (!is_numeric($id)) {
$response->error('id parameter must be a number got: ' . $id, 400);
}
// Make sure the user is allowed to complete the wash without a wash certificate
if (!$user->hasAccessToBooking($id)) {
$response->error('You are not allowed to complete this wash without a wash certificate', 400);
}
// Complete the wash without a wash certificate
(new bookings_o())->completeWashWithoutWashCertificate($id);
// Return success
$response->success(
["message" => "Wash completed without wash certificate"]
);
},
[
'complete_wash_without_wash_certificate' => 'Complete a wash without a wash certificate'
@@ -268,7 +268,6 @@ class departmentDailyReportsRoute
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'CREATE_DEPARTMENT_DAILY_REPORT_COMPLAINT', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
self::requireParameters([
@@ -303,19 +302,16 @@ class departmentDailyReportsRoute
$description = trim((string)self::getParameter('description'));
if ($description === '') {
$response->error('Description is required', 400);
return;
}
$wash_date = $this->requireComplaintWashDateParameter('wash_date');
if ($wash_date === null) {
$response->error('Wash date is required', 400);
return;
}
$category = $this->requireComplaintCategoryParameter('category');
if ($category === null) {
$response->error('Category is required', 400);
return;
}
$customer_number = null;
@@ -337,7 +333,6 @@ class departmentDailyReportsRoute
$customer = (new users_o())->getOrImportCustomerByCustomerNumber($customer_number);
if ($customer === false || !$customer->exists()) {
$response->error('Customer not found', 400);
return;
}
}
@@ -371,7 +366,6 @@ class departmentDailyReportsRoute
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'LIST_DEPARTMENT_DAILY_REPORT_COMPLAINT_CUSTOMERS', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
$has_create_permission = $this->hasPermission('create_department_daily_report_complaints');
@@ -393,7 +387,6 @@ class departmentDailyReportsRoute
$search = trim((string)self::getParameter('search'));
if (mb_strlen($search) < 2) {
$response->error('Search must be at least 2 characters', 400);
return;
}
$limit = 10;
@@ -437,7 +430,6 @@ class departmentDailyReportsRoute
'message' => 'Failed to fetch complaint customers from e-conomic',
'upstream_message' => $upstream_message,
], 502);
return;
}
$matches = array_values(array_filter(array_map(
@@ -485,7 +477,6 @@ class departmentDailyReportsRoute
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'LIST_DEPARTMENT_DAILY_REPORT_COMPLAINTS', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
$repository = $this->dailyReportComplaintsRepository();
@@ -503,7 +494,6 @@ class departmentDailyReportsRoute
$complaint = $repository->select((int)self::getParameter('id'));
if (!$complaint->exists()) {
$response->error('Complaint not found', 404);
return;
}
self::requireDepartmentAccess((int)$complaint->department_id->value());
@@ -511,7 +501,6 @@ class departmentDailyReportsRoute
(new logs_o())->add('departments', 'global', 1, $user->id, 'GET_DEPARTMENT_DAILY_REPORT_COMPLAINT', 'Successfully retrieved department daily report complaint');
$response->success($repository->parseComplaint($complaint->asArray()));
return;
}
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_DAILY_REPORT_COMPLAINTS', 'Successfully listed department daily report complaints');
@@ -549,7 +538,6 @@ class departmentDailyReportsRoute
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'EDIT_DEPARTMENT_DAILY_REPORT_COMPLAINT', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
self::requireParameters(['id']);
@@ -565,7 +553,6 @@ class departmentDailyReportsRoute
$complaint = $this->dailyReportComplaintsRepository()->select((int)self::getParameter('id'));
if (!$complaint->exists()) {
$response->error('Complaint not found', 404);
return;
}
self::requireDepartmentAccess((int)$complaint->department_id->value());
@@ -585,7 +572,6 @@ class departmentDailyReportsRoute
$department = (new departments_o())->select((int)self::getParameter('department_id'));
if (!$department->exists()) {
$response->error('Department not found', 400);
return;
}
self::requireDepartmentAccess((int)self::getParameter('department_id'));
@@ -610,7 +596,6 @@ class departmentDailyReportsRoute
$description = trim((string)self::getParameter('description'));
if ($description === '') {
$response->error('Description is required', 400);
return;
}
$updates['description'] = $description;
@@ -620,7 +605,6 @@ class departmentDailyReportsRoute
$wash_date = $this->requireComplaintWashDateParameter('wash_date');
if ($wash_date === null) {
$response->error('Wash date is required', 400);
return;
}
$updates['wash_date'] = $wash_date;
@@ -630,7 +614,6 @@ class departmentDailyReportsRoute
$category = $this->requireComplaintCategoryParameter('category');
if ($category === null) {
$response->error('Category is required', 400);
return;
}
$updates['category'] = $category;
@@ -655,7 +638,6 @@ class departmentDailyReportsRoute
$customer = (new users_o())->getOrImportCustomerByCustomerNumber($customer_number);
if ($customer === false || !$customer->exists()) {
$response->error('Customer not found', 400);
return;
}
$updates['customer_number'] = $customer_number;
@@ -666,7 +648,6 @@ class departmentDailyReportsRoute
$response->success(
$this->dailyReportComplaintsRepository()->parseComplaint($complaint->asArray())
);
return;
}
$complaint->update($updates);
@@ -690,7 +671,6 @@ class departmentDailyReportsRoute
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'DELETE_DEPARTMENT_DAILY_REPORT_COMPLAINT', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
self::requireParameters(['id']);
@@ -706,7 +686,6 @@ class departmentDailyReportsRoute
$complaint = $this->dailyReportComplaintsRepository()->select((int)self::getParameter('id'));
if (!$complaint->exists()) {
$response->error('Complaint not found', 404);
return;
}
self::requireDepartmentAccess((int)$complaint->department_id->value());
@@ -823,13 +802,11 @@ class departmentDailyReportsRoute
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'SUPERUSER_DEPARTMENT_OVERVIEW', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
$department_id_param = (string)($this->fromRoute('id') ?? '');
if (!ctype_digit($department_id_param) || (int)$department_id_param <= 0) {
$response->error('Parameter id must be a positive integer', 400);
return;
}
self::requireParameters([
@@ -843,7 +820,6 @@ class departmentDailyReportsRoute
if (!$department->exists()) {
$response->error('Department not found', 404);
return;
}
(new logs_o())->add('departments', 'global', 1, $user->id, 'SUPERUSER_DEPARTMENT_OVERVIEW', 'Successfully loaded superuser department overview');
@@ -872,7 +848,6 @@ class departmentDailyReportsRoute
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'LIST_DEPARTMENT_DAILY_REPORTS_OVERVIEW', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
self::requireParameters([
@@ -886,7 +861,6 @@ class departmentDailyReportsRoute
if ($department_ids === []) {
$response->error('At least one department_id must be provided', 400);
return;
}
foreach ($department_ids as $department_id) {
@@ -919,7 +893,6 @@ class departmentDailyReportsRoute
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'SET_DEPARTMENT_DAILY_REPORT_PRODUCT_TARGET', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
self::requireParameters([
@@ -931,13 +904,11 @@ class departmentDailyReportsRoute
$department_id = (int)self::getParameter('department_id');
if ($department_id <= 0) {
$response->error('Parameter department_id must be a positive integer', 400);
return;
}
$department = (new departments_o())->select($department_id);
if (!$department->exists()) {
$response->error('Department not found', 404);
return;
}
self::requireDepartmentAccess($department_id);
@@ -945,13 +916,11 @@ class departmentDailyReportsRoute
$product_id = (int)self::getParameter('product_id');
if (!$this->isDailyReportProductId($product_id)) {
$response->error('Invalid daily report product_id', 400);
return;
}
$parsed_target = $this->parseDailyReportProductTargetPercentage(self::getParameter('target_percentage'));
if (!$parsed_target['valid']) {
$response->error($parsed_target['message'], 400);
return;
}
$target_percentage = $parsed_target['value'];
@@ -1191,7 +1160,6 @@ class departmentDailyReportsRoute
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'LIST_DEPARTMENT_DAILY_REPORTS_OUTSIDE_HOURS_TREND', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
self::requireParameters([
@@ -1209,7 +1177,6 @@ class departmentDailyReportsRoute
$department_ids = $this->normalizeDepartmentIdsParameter(self::getParameter('department_ids'));
if ($department_ids === []) {
$response->error('At least one department_id must be provided', 400);
return;
}
foreach ($department_ids as $department_id) {
@@ -1602,7 +1569,7 @@ class departmentDailyReportsRoute
}
/**
* @return array{valid:bool,value:?float,message:string}
* @return array{valid: bool, value: ?float, message: string}
*/
private function parseDailyReportProductTargetPercentage(mixed $target_percentage): array
{
@@ -1698,37 +1665,6 @@ class departmentDailyReportsRoute
return $this->outsideHoursStatisticsService()->toOverviewMetric(
$this->outsideHoursStatisticsService()->getSummary($date, $department_ids, $date_to)
);
foreach ($department_ids as $department_id) {
if (!isset($opening_hours_by_department_id[$department_id])) {
return $this->metricPayload(
null,
null,
'unavailable',
'Døgnvask kræver åbningstider for alle valgte afdelinger.'
);
}
}
$night_wash_count = 0;
foreach ($wash_transactions as $wash_transaction) {
$department_id = (int)($wash_transaction['department_id'] ?? 0);
$created_at = (string)($wash_transaction['created_at'] ?? '');
if ($created_at === '') {
continue;
}
$opening_hours = $opening_hours_by_department_id[$department_id] ?? null;
if (!is_array($opening_hours)) {
continue;
}
if ($this->isOutsideOpeningHours($created_at, $opening_hours)) {
$night_wash_count++;
}
}
return $this->metricPayload($night_wash_count);
}
/**
@@ -72,7 +72,6 @@ class economicInvoiceRoute
'mode' => 'synchronous_fallback',
'result' => $result,
]);
return;
}
$queue = new economic_transfer_queue();
@@ -197,7 +196,6 @@ class economicInvoiceRoute
'mode' => 'synchronous_fallback',
'result' => $result,
]);
return;
}
$queue = new economic_transfer_queue();
-120
View File
@@ -30,134 +30,15 @@ class exampleRoute
$this->get('/tmp-send-email', function () {
global $response;
$response->error(['message' => 'This route is deprecated.']);
$emailAddress = "my@truckwash.dk";
$customer_name = "John Doe";
/**
* Tillykke med din nye kredit konto!
*
* Du kan nu vaske i alle vores afdelinger.
* Vedhæftet finder du en liste over vores afdelinger, som kan viderebringes til dine chauffører.
*
* Vi har oprettet en kredit konto til dig, med følgende informationer:
*
* Virksomhed: AT Kloakservice ApS
* Tlf: 77302200
* CVR: 40650717
*/
$email = new email();
$email->sendWelcomeEmailToCustomer($customer_number = 43632122, $emailAddress);
$response->success(['message' => 'This route is deprecated.']);
});
$this->get('/tmp-washes-in-time', function () {
global $response;
$response->error(['message' => 'This route is deprecated.']);
$date_from = date('2026-01-01 00:00:00');
$date_to = date('2026-01-31 23:59:59');
// Set the hours on the date range to be from 17:00-23:59
$daily_start_time = '17:00:00';
$daily_end_time = '23:59:59';
// Loop through each day in the date range, and get the washes that were done in the time range
$washes = [];
$current_date = $date_from;
while (strtotime($current_date) <= strtotime($date_to)) {
$daily_start = date('Y-m-d', strtotime($current_date)) . ' ' . $daily_start_time;
$daily_end = date('Y-m-d', strtotime($current_date)) . ' ' . $daily_end_time;
$daily_washes = (new orders_o())->getWashesInTimeRange($daily_start, $daily_end, ['department_id' => 7]);
$washes = array_merge($washes, $daily_washes);
$current_date = date('Y-m-d H:i:s', strtotime($current_date . ' +1 day'));
}
$wash_arrays = array_map(function ($wash) {
return $wash->asArray();
}, $washes);
// Create a CSV file from the washes $csv = "Order ID,Customer ID,Department ID,Created At\n";
foreach ($wash_arrays as $wash) {
$csv .= "{$wash['id']},{$wash['customer_id']},{$wash['department_id']},{$wash['created_at']}\n";
}
// Output the CSV file
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename="washes_in_time_range.csv"');
echo $csv;
exit;
});
$this->get('/tmp-customer-list-overcharged', function () {
global $response;
$response->error(['message' => 'This route is deprecated.']);
/**
* Steps:
* 1. Get a list of all collected order invoices in january
* 2. Extract the customers, and make sure they haven't been charged more than once for product ID: 78.
* 3. Get a list of the amount of times, at what price and for what order IDs they have been charged.
* 4. If they have been charged more than once, add them to a list of overcharged customers.
* 5. Return the list of overcharged customers.
*/
// Step 1: Get a list of all collected order invoices in january
$invoices = [];
$product = (new products_o())->select(78);
$all_invoices = (new collected_order_invoices_o())->getObjectsWhereClause("closed_at BETWEEN '2026-01-31 00:00:00' AND '2026-02-01 23:59:59'");
// Filter out empty invoices
foreach ($all_invoices as $invoice) {
if ($invoice->isEmpty()) {
continue;
}
$invoices[] = $invoice;
}
// Get all orders related to the invoices
$overcharged_customers = [];
$order_ids = (new orders_o())->getFieldsWhereIn([
'deleted_at' => null,
'invoice_collection_id' => array_map(function ($invoice) {
return $invoice->id;
}, $invoices)],
['id', 'customer_id']);
$order_id_to_customer_id = array_column($order_ids, 'customer_id', 'id');
$order_ids = array_map(function ($order) {
return (int)$order['id'];
}, $order_ids);
// Get all the products in the orders
$order_items = (new order_items_o())->getFieldsWhereIn(['order_id' => $order_ids, 'product_id' => [$product->id], 'deleted_at' => null], ['price', 'quantity', 'order_id', 'id']);
// Define the customers => items map
$customer_items_map = [];
foreach ($order_items as $order_item) {
$customer_items_map[(string)$order_id_to_customer_id[(string)$order_item['order_id']]][] = $order_item;
}
// Ignore the first item for each customer (as that is correct)
foreach ($customer_items_map as $customer_id => $items) {
array_shift($customer_items_map[$customer_id]);
}
// Create a total per customer number of items and price map
foreach ($customer_items_map as $customer_id => $items) {
$total_quantity = 0;
$total_price = 0.0;
foreach ($items as $item) {
$total_quantity += (int)$item['quantity'];
$total_price += (float)$item['price'] * (int)$item['quantity'];
}
// If the total quantity is more than 1, add to overcharged customers
if ($total_quantity > 1) {
$overcharged_customers[$customer_id] = [
'total_quantity' => $total_quantity,
'total_price' => $total_price,
'items' => $items,
];
}
}
// Format message
foreach ($overcharged_customers as $customer_id => $data) {
$customer = (new users_o())->getUserByCustomerNumber((int)$customer_id);
$message = "Customer number: {$customer->customer_number->value()} - x{$data['total_quantity']} items for a total of {$data['total_price']} DKK\n";
//$message .= "Items:\n";
foreach ($data['items'] as $item) {
//$message .= "- Order Item ID: {$item['id']}, Order ID: {$item['order_id']}, Price: {$item['price']}, Quantity: {$item['quantity']}\n";
}
echo $message . "\n";
}
$response->success(['message' => 'Customer list overcharged', 'inv_count' => count($invoices), 'ord_count' => count($order_ids), 'order_ids' => $order_ids, 'order_items' => count($order_items), 'customer_items_map' => $customer_items_map, 'overcharged_customers' => $overcharged_customers]);
});
$this->get('/debug', function () {
global $response;
//$response->success(['message' => 'Debugging route!']);
@@ -171,7 +52,6 @@ class exampleRoute
$machine_1->setup();
$machine_1->servePicture();
exit;
$response->success(['message' => 'Debugging route!', 'base64_image' => $machine_1->exportAsBase64()]);
});
}
}
@@ -242,7 +242,6 @@ class moduleSelfServeRoute
'customer' => null,
'vehicle' => null,
], $customer_scope));
return;
}
$runtime_customer_number = (int)$lane->getCustomerNumber();
@@ -281,7 +280,6 @@ class moduleSelfServeRoute
'subuser' => null,
'vehicle' => $vehicle,
], $customer_scope));
return;
}
$customer_number = $session->customer_number->value() === null ? null : (int)$session->customer_number->value();
@@ -1505,7 +1503,7 @@ class moduleSelfServeRoute
}
/**
* @return array{customer_number:?int,subuser_id:?int}
* @return array{customer_number: ?int, subuser_id: ?int}
*/
private function normalizeCustomerScope(mixed $customer_scope): array
{
@@ -1567,7 +1565,7 @@ class moduleSelfServeRoute
}
/**
* @return array{customer_number:int,subuser_id:?int}
* @return array{customer_number: int, subuser_id: ?int}
*/
private function requireMyActiveWashPrincipalScope(): array
{
@@ -51,7 +51,6 @@ class moduleWeatherAPIRoute
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
self::requireParameters(['q']);
@@ -69,7 +68,6 @@ class moduleWeatherAPIRoute
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
self::requireParameters(['q']);
@@ -88,7 +86,6 @@ class moduleWeatherAPIRoute
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
self::requireParameters(['q']);
@@ -106,7 +103,6 @@ class moduleWeatherAPIRoute
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
$department_ids = self::parseDepartmentIdsFromRequest();
@@ -163,7 +159,6 @@ class moduleWeatherAPIRoute
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
$department_ids = self::parseDepartmentIdsFromRequest();
@@ -199,7 +194,6 @@ class moduleWeatherAPIRoute
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
$department_ids = self::parseDepartmentIdsFromRequest();
@@ -230,7 +224,6 @@ class moduleWeatherAPIRoute
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
self::requireParameters(['department_id', 'degraded_threshold', 'healthy_threshold']);
@@ -894,7 +894,6 @@ class orderInvoicesRoute
'mode' => 'synchronous_fallback',
'result' => $result,
]);
return;
}
$queue = new economic_transfer_queue();
@@ -1422,7 +1421,6 @@ class orderInvoicesRoute
'mode' => 'synchronous_fallback',
'result' => $result,
]);
return;
}
$queue = new economic_transfer_queue();
+1 -1
View File
@@ -2,6 +2,7 @@
namespace routes;
use Exception;
use attachments\helpers\attachment_content;
use classes\attachment_store;
use classes\attachments;
@@ -1104,7 +1105,6 @@ class ordersRoute
foreach ( $data as $key => $value ) {
if (!in_array($key, $allowed_to_edit)) {
$response->error('You do not have permission to edit this order field (key: ' . $key . ')', 400);
break;
}
};
$shouldRefreshAttachedWashCertificate = false;
@@ -6,7 +6,6 @@ use classes\pdf_generator;
use classes\pdf_store;
use classes\response;
use classes\router;
use objects\logs_o;
use traits\route_t;
class pdfGeneratorRoute
@@ -22,7 +21,6 @@ class pdfGeneratorRoute
/** PDF Generator > GET */
$this->get('/modules/pdf-generator/test', function () {
global $response;
if (true) {
// Check if the required parameters are present
self::requireParameters([
'id',
@@ -118,10 +116,6 @@ class pdfGeneratorRoute
$pdf_path
),
]);
} else {
(new logs_o())->add('orderInvoices', 'global', 0, 0, 'LIST_COLLECTED_INVOICES', 'User tried to access the list of collected order invoices without a valid session');
$response->error('Invalid session', 400);
}
},
[
'list_collected_invoices' => 'List ALL collected order invoices. This is a superuser-only route.'
@@ -22,6 +22,7 @@ class permissionsRoute
/** Permissions > List */
$this->get('/permissions', function () {
global $response, $router;
/** @var router $router */
$this->requirePermission('permissions_list');
$user = (new authentication())->get_user();
if ($user) {
@@ -50,7 +50,6 @@ class releaseManagerRoute
$manager = new release_manager();
if (!$manager->verifyReleaseGateToken($this->releaseGateToken())) {
$response->error(['message' => 'Invalid release gate token.'], 401);
return;
}
try {
@@ -93,7 +93,6 @@ class subusersRoute
}
$response->error('Unauthorized', 401);
return 0;
}
private function parsePermissionsPayload(mixed $raw, ?array $default = null): ?array
@@ -145,7 +144,6 @@ class subusersRoute
$response->error($exception->getMessage(), 400);
}
return null;
}
private function normalizeOptionalString(mixed $value): ?string
+5 -168
View File
@@ -40,89 +40,8 @@ class workerRoute
$response->success(['message' => 'Version update functionality is not yet implemented.']);
});
$this->get('/worker/test', function () {
global /** @var router $router */
$response, $router;
global $response;
$response->error('This endpoint is disabled for security reasons.', 403);
$department = (new departments_o())->select((int)6);
$days = 7;
// The time should be from 00:00:00 of the start date to 23:59:59 of the end date
$date_end = date('Y-m-d 23:59:59', strtotime("-1 days")); // Yesterday (Sunday) at 23:59:59
$date_start = date('Y-m-d 00:00:00', strtotime("-$days days")); // $days ago at 00:00:00
$department->sendSlackInternalStatisticNotification($date_start, $date_end);
$response->success(['message' => 'Test message sent to Slack (not really, this is a placeholder).' ]);
exit;
// Configuration
$department_id = 6;
/**
* Weekly results for Roskilde
*
* Period: 29.01.2026 - 04.02.2026
* Washes: 67
* Fælg Flex: 55%
* Spot Free: 22%
* Special Sæbe: 11%
* 10 min ekstra: 15%
* Undervognsskyl: 44%
* Voks: 66%
*/
$max_addons = [];
$sold_addons = [];
$percentages = [];
$department = (new departments_o())->select($department_id);
echo "Testing addon sales calculation for department ID: $department_id from $date_start to $date_end\n";
$tmp = "*Weekly results for {$department->name->value()}*\n";
$tmp .= "Period: " . date('d.m.Y', strtotime($date_start)) . " - " . date('d.m.Y', strtotime($date_end)) . "\n";
// Washes
$wash_count = (new orders_o())->countWashesInDateRange($date_start, $date_end, $department_id);
$tmp .= "Washes: $wash_count\n";
// Get the percentage of addons sold out of max
foreach ($product_ids as $product_id) {
// Handle merged products
if (is_array($product_id)) {
$addon_sold_count = 0;
$addon_max_count = 0;
foreach ($product_id as $pid) {
$pid = (int)$pid;
$addon_sold_count += (new product_options_o())->countSoldAddonsInDateRange($pid, $date_start, $date_end, $department_id);
$addon_max_count += (new product_options_o())->getMaxAddonsInDateRange($pid, $date_start, $date_end, $department_id);
}
} else {
$pid = $product_id;
$addon_sold_count = (new product_options_o())->countSoldAddonsInDateRange($pid, $date_start, $date_end, $department_id);
$addon_max_count = (new product_options_o())->getMaxAddonsInDateRange($pid, $date_start, $date_end, $department_id);
}
// Prevent division by zero
if ($addon_max_count === 0) {
$addon_percentage_sold = 0;
} else {
$addon_percentage_sold = ($addon_sold_count / $addon_max_count) * 100;
}
$max_addons[is_array($product_id) ? implode('_', $product_id) : $product_id] = $addon_max_count;
$sold_addons[is_array($product_id) ? implode('_', $product_id) : $product_id] = $addon_sold_count;
$percentages[is_array($product_id) ? implode('_', $product_id) : $product_id] = number_format($addon_percentage_sold, 2);
}
foreach ($product_ids as $product_id) {
if (is_array($product_id)) {
// Merged product names
$product_names = [];
foreach ($product_id as $pid) {
$product_names[] = (new products_o())->select($pid)->name->value();
}
// Switch to joined names
$product_name = match (true) {
in_array(23, $product_id) && in_array(24, $product_id) => 'Spot Free',
default => implode(' + ', $product_names),
};
} else {
$product_name = (new products_o())->select($product_id)->name->value();
}
$tmp .= "{$product_name}: {$percentages[is_array($product_id) ? implode('_', $product_id) : $product_id]}%\n";
}
// Send test message to Slack
$slack = new slack();
$department = (new departments_o())->select($department_id);
$slack->send_message($tmp);
$response->success(['message' => 'Test message sent to Slack (not really, this is a placeholder).' ]);
});
$this->get('/worker/status', function () {
global /** @var router $router */
@@ -153,102 +72,20 @@ class workerRoute
]);
});
$this->get('/worker/debug', function () {
global /** @var router $router */
$response, $router;
global $response;
$response->error('This endpoint is disabled for security reasons.', 403);
$shelly = new shelly();
$shelly->requireModuleEnabled();
$shelly->requireValidSecretKey();
$parameters = new shelly_request_body_get_states();
$parameters->ids = ['e4b323243f90'];
$parameters->select = ['status'];
$result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters);
// Format the result
$result = array_map(function ($device) {
return (new shelly_device_switch())->populate($device);
}, $result);
//TODO: fetch statuses
$response->success($result);
});
$this->get('/worker/debug/on', function () {
global /** @var router $router */
$response, $router;
global $response;
$response->error('This endpoint is disabled for security reasons.', 403);
$shelly = new shelly();
$shelly->requireModuleEnabled();
$shelly->requireValidSecretKey();
$parameters = new shelly_request_body_get_states();
$parameters->ids = ['e4b323243f90'];
$parameters->select = ['status'];
$result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters);
// Wait 1 second
sleep(1);
// Format the result
$result = array_map(function ($device) {
return (new shelly_device_switch())->populate($device);
}, $result);
// Open the switch
foreach ($result as $device) {
$device->switch(true);
}
//TODO: fetch statuses
$response->success($result);
});
$this->get('/worker/debug/off', function () {
global /** @var router $router */
$response, $router;
global $response;
$response->error('This endpoint is disabled for security reasons.', 403);
$shelly = new shelly();
$shelly->requireModuleEnabled();
$shelly->requireValidSecretKey();
$parameters = new shelly_request_body_get_states();
$parameters->ids = ['e4b323243f90'];
$parameters->select = ['status'];
$result = $shelly->sendPostRequest('/v2/devices/api/get', (array)$parameters);
// Wait 1 second
sleep(1);
// Format the result
$result = array_map(function ($device) {
return (new shelly_device_switch())->populate($device);
}, $result);
// Open the switch
foreach ($result as $device) {
$device->switch(false);
}
//TODO: fetch statuses
$response->success($result);
});
$this->get('/worker/licenseplates', function () {
global /** @var router $router */
$response, $db;
global $response;
$response->error('This endpoint is disabled for security reasons.', 403);
$counted_plates = []; // Array to hold counted license plates
// This is used to fetch all UNIQUE license plates from the database tables:
// 'customer_vehicles' -> 'reg' column
// 'orders' -> 'reg_1', 'reg_2', 'reg_3' columns
// 'bookings' -> 'regNrTraekker', 'regNrTrailer' columns
// 'plate_scans' -> 'plate' column
$tables_and_columns = [
'customer_vehicles' => ['reg'],
'orders' => ['reg_1', 'reg_2', 'reg_3'],
'bookings' => ['regNrTraekker', 'regNrTrailer'],
'plate_scans' => ['plate'],
];
foreach ($tables_and_columns as $table => $columns) {
foreach ($columns as $column) {
$results = $db->query("SELECT DISTINCT $column FROM $table WHERE $column IS NOT NULL AND $column != ''");
foreach ($results as $row) {
$formatted_plate = $this->FORMAT_LICENSE_PLATE($row[$column]);
if ($formatted_plate !== '') {
if (!isset($counted_plates[$formatted_plate])) {
$counted_plates[$formatted_plate] = 0;
}
$counted_plates[$formatted_plate]++;
}
}
}
}
$response->success(['counted_license_plates' => $counted_plates, 'total_unique_plates' => count($counted_plates)]);
});
$this->get('/economic/doesCustomerExist', function () {
global $response;
@@ -170,13 +170,11 @@ class xlvaskUsageLogsRoute
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
$id = (int)($this->fromRoute('id') ?? 0);
if ($id < 1) {
$response->error('Invalid XL Vask usage log id', 400);
return;
}
$reason = $this->isParametersSet(['reason']) ? trim((string)$this->getParameter('reason')) : null;
@@ -210,7 +208,6 @@ class xlvaskUsageLogsRoute
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
$ids = $this->isParametersSet(['ids']) ? $this->getParameter('ids') : [];
@@ -238,13 +235,11 @@ class xlvaskUsageLogsRoute
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
$id = (int)($this->fromRoute('id') ?? 0);
if ($id < 1) {
$response->error('Invalid XL Vask usage log id', 400);
return;
}
$response->success(
@@ -263,13 +258,11 @@ class xlvaskUsageLogsRoute
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
$id = (int)($this->fromRoute('id') ?? 0);
if ($id < 1) {
$response->error('Invalid XL Vask usage log id', 400);
return;
}
$suggestionId = $this->isParametersSet(['suggestion_id']) ? (int)$this->getParameter('suggestion_id') : null;
@@ -291,13 +284,11 @@ class xlvaskUsageLogsRoute
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
return;
}
$id = (int)($this->fromRoute('id') ?? 0);
if ($id < 1) {
$response->error('Invalid XL Vask usage log id', 400);
return;
}
$suggestionId = $this->isParametersSet(['suggestion_id']) ? (int)$this->getParameter('suggestion_id') : null;
@@ -56,7 +56,7 @@ it('keeps the OpenAPI manifest entries aligned with the API spec', function ():
$operationsInSpec = [];
$currentPath = null;
foreach ($lines as $line) {
if (preg_match('/^ (\/[^:]+):\s*$/', $line, $pathMatch) === 1) {
if (preg_match('/^ {2}(\/[^:]+):\s*$/', $line, $pathMatch) === 1) {
$currentPath = $pathMatch[1];
continue;
}
@@ -65,7 +65,7 @@ it('keeps the OpenAPI manifest entries aligned with the API spec', function ():
continue;
}
if (preg_match('/^ ([a-z]+):\s*$/', $line, $methodMatch) === 1) {
if (preg_match('/^ {4}([a-z]+):\s*$/', $line, $methodMatch) === 1) {
$operationsInSpec[] = strtoupper($methodMatch[1]) . ' ' . $currentPath;
}
}
@@ -40,9 +40,11 @@ function usesApiSuite(): void
// The API lifecycle is bound via Tests\Support\Api\ApiTestCase in tests/Pest.php.
}
function api_test_covers(string $operation, string $kind = 'happy'): bool
function api_test_covers(string $operation, string $kind = 'happy'): void
{
return $operation !== '' && $kind !== '';
if ($operation === '' || $kind === '') {
throw new InvalidArgumentException('API coverage markers require an operation and coverage kind.');
}
}
function assert_api_envelope(ApiResponse $response): ApiResponse
@@ -12,7 +12,7 @@ it('unregisters legacy booking completion forms', function (): void {
expect($code)->not->toContain('GENERATE_BOOKING_WASH_CERTIFICATE');
});
it('keeps legacy wash certificate downloads but disables generation and completion', function (): void {
it('keeps legacy wash certificate downloads but removes disabled generation and completion code', function (): void {
$code = (string)file_get_contents(app_path('modules/washcertificates/index.php'));
$downloadPosition = strpos($code, "isset(\$_GET['justDownload'])");
@@ -21,9 +21,8 @@ it('keeps legacy wash certificate downloads but disables generation and completi
expect($downloadPosition)->not->toBeFalse();
expect($disabledPosition)->not->toBeFalse();
expect($completionPosition)->not->toBeFalse();
expect($completionPosition)->toBeFalse();
expect($downloadPosition)->toBeLessThan($disabledPosition);
expect($disabledPosition)->toBeLessThan($completionPosition);
expect($code)->not->toContain('new WashCertificateGenerator');
expect($code)->toContain('Booking completion must be completed through POS desktop or mobile steps.');
});
@@ -163,7 +163,6 @@ it('allows failed Coolify replica targets to be removed after the service disapp
it('retries Coolify maintenance while linked replication provisioning is still incomplete', function (): void {
$method = new ReflectionMethod(coolify_manager::class, 'replicationHostStillNeedsProvisioning');
$method->setAccessible(true);
expect($method->invoke(null, [
'role' => 'replica',
@@ -189,7 +188,6 @@ it('retries Coolify maintenance while linked replication provisioning is still i
it('plans Hetzner load balancer target and service drift without mutating state', function (): void {
$manager = new coolify_manager();
$method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile');
$method->setAccessible(true);
$plan = $method->invoke($manager, [
'targets' => [
@@ -219,7 +217,6 @@ it('plans Hetzner load balancer target and service drift without mutating state'
it('plans Hetzner load balancer service health check drift updates', function (): void {
$manager = new coolify_manager();
$method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile');
$method->setAccessible(true);
$plan = $method->invoke($manager, [
'targets' => [
@@ -272,7 +269,6 @@ it('plans Hetzner load balancer service health check drift updates', function ()
it('does not plan removal of the last Hetzner load balancer target', function (): void {
$manager = new coolify_manager();
$method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile');
$method->setAccessible(true);
$plan = $method->invoke($manager, [
'targets' => [
@@ -294,7 +290,6 @@ it('does not plan removal of the last Hetzner load balancer target', function ()
it('plans removal only for disabled or deleted Hetzner load balancer targets', function (): void {
$manager = new coolify_manager();
$method = new ReflectionMethod(coolify_manager::class, 'planLoadBalancerReconcile');
$method->setAccessible(true);
$plan = $method->invoke($manager, [
'targets' => [
@@ -320,9 +315,7 @@ it('plans removal only for disabled or deleted Hetzner load balancer targets', f
it('builds gateway API auto-provision context for connected Coolify servers', function (): void {
$manager = new coolify_manager();
$contextMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteProvisionDeployContext');
$contextMethod->setAccessible(true);
$ipMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteServerPublicIp');
$ipMethod->setAccessible(true);
$server = [
'uuid' => 'server-node1',
@@ -376,7 +369,6 @@ it('builds gateway API auto-provision context for connected Coolify servers', fu
it('builds gateway frontend auto-provision context with the release Dockerfile', function (): void {
$manager = new coolify_manager();
$contextMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteProvisionDeployContext');
$contextMethod->setAccessible(true);
$server = [
'uuid' => 'server-node3',
@@ -421,9 +413,7 @@ it('builds gateway frontend auto-provision context with the release Dockerfile',
it('adds explicit Coolify application route labels for gateway API domains', function (): void {
$payloadMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteApplicationPayload');
$payloadMethod->setAccessible(true);
$publicUrlMethod = new ReflectionMethod(coolify_manager::class, 'gatewayRouteTargetPublicUrl');
$publicUrlMethod->setAccessible(true);
$payload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io', 'api-app-uuid', 8080, base64_encode(implode("\n", [
'custom.keep=true',
@@ -479,7 +469,6 @@ it('adds explicit Coolify application route labels for gateway API domains', fun
it('isolates and restores Hetzner load balancer IP targets for gateway certificate bootstrap', function (): void {
$manager = new coolify_manager();
$method = new ReflectionMethod(coolify_manager::class, 'setLoadBalancerIpTargets');
$method->setAccessible(true);
$client = new CoolifyManagerHetznerTargetSetFake([
'94.130.142.41',
'65.21.214.30',
@@ -508,7 +497,6 @@ it('isolates and restores Hetzner load balancer IP targets for gateway certifica
it('requires gateway ping probes to return the API ping contract', function (): void {
$method = new ReflectionMethod(coolify_manager::class, 'gatewayProbePingContract');
$method->setAccessible(true);
expect($method->invoke(null, json_encode([
'success' => true,
@@ -523,7 +511,6 @@ it('requires gateway ping probes to return the API ping contract', function ():
it('normalizes gateway probe paths for release gateway health checks', function (): void {
$method = new ReflectionMethod(coolify_manager::class, 'normalizeGatewayProbePath');
$method->setAccessible(true);
expect($method->invoke(null, 'internal/api/ping'))->toBe('/internal/api/ping')
->and($method->invoke(null, '//internal//api//ping//'))->toBe('/internal/api/ping')
@@ -533,7 +520,6 @@ it('normalizes gateway probe paths for release gateway health checks', function
it('returns structured errors for failed gateway certificate bootstrap and verification', function (): void {
$method = new ReflectionMethod(coolify_manager::class, 'gatewayRouteHealthErrors');
$method->setAccessible(true);
$errors = $method->invoke(null, [
'ok' => false,
@@ -10,7 +10,6 @@ function department_daily_reports_route_invoke_private(object $route, string $me
{
$reflection = new ReflectionClass(departmentDailyReportsRoute::class);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return $target->invokeArgs($route, $args);
}
@@ -49,7 +49,6 @@ if (!class_exists('EconomicMLegacyGrantFallbackProbe')) {
private function setPrivateTokenField(string $fieldName, string $value): void
{
$reflection = new ReflectionProperty(economic_m::class, $fieldName);
$reflection->setAccessible(true);
$reflection->setValue($this, $value);
}
}
@@ -11,7 +11,6 @@ function economic_customer_helper_from_payload(object $payload): economic_custom
$customer = $reflection->newInstanceWithoutConstructor();
$property = $reflection->getProperty('customer_data_object');
$property->setAccessible(true);
$property->setValue($customer, $payload);
return $customer;
@@ -8,7 +8,6 @@ function economic_payment_terms_route_extract_collection(mixed $response): array
{
$reflection = new ReflectionClass(economicPaymentTermsRoute::class);
$target = $reflection->getMethod('extractPaymentTermsCollection');
$target->setAccessible(true);
return $target->invokeArgs(null, [$response]);
}
@@ -8,7 +8,6 @@ function economic_transfer_queue_invoke_private(economic_transfer_queue $queue,
{
$reflection = new ReflectionClass($queue);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return $target->invokeArgs($queue, $args);
}
@@ -17,7 +17,6 @@ function invoice_period_flag_service_invoke(string $method, array $args = []): m
$service = invoice_period_flag_service_instance();
$reflection = new ReflectionClass(invoice_period_flag_service::class);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return $target->invokeArgs($service, $args);
}
@@ -35,7 +34,6 @@ it('canonicalizes invoice period redis keys for bare dates and normalized API ti
$reflection = new ReflectionClass(\classes\redis::class);
$redis = $reflection->newInstanceWithoutConstructor();
$key = $reflection->getMethod('invoicePeriodCacheKey');
$key->setAccessible(true);
expect($key->invoke($redis, 'invoice_period_automatic_flags', '2025-03-01', '2025-03-31'))
->toBe($key->invoke(
@@ -682,15 +680,12 @@ it('uses a preloaded e-conomic global discount in expected price breakdowns', fu
$reflection = new ReflectionClass(invoice_period_flag_service::class);
$cache = $reflection->getProperty('economicCustomerDiscountCache');
$cache->setAccessible(true);
$cache->setValue($service, [
35131752 => 18,
]);
$calculate = $reflection->getMethod('calculateExpectedPrice');
$calculate->setAccessible(true);
$breakdownMethod = $reflection->getMethod('priceBreakdown');
$breakdownMethod->setAccessible(true);
$row = [
'customer_number' => 35131752,
@@ -813,9 +808,7 @@ it('seeds order item preview cache from period rows', function (): void {
$reflection = new ReflectionClass(invoice_period_flag_service::class);
$seed = $reflection->getMethod('seedOrderItemsPreviewCacheFromRows');
$seed->setAccessible(true);
$preview = $reflection->getMethod('getOrderItemsForPreview');
$preview->setAccessible(true);
$seed->invoke($service, [
[
@@ -8,7 +8,6 @@ function invoicing_period_draft_overlay_invoke(string $method, array $args = [])
{
$reflection = new ReflectionClass(InvoicingPeriodRoute::class);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return $target->invokeArgs(null, $args);
}
@@ -47,7 +46,6 @@ function invoicing_period_draft_overlay_reset_deleted_at_column_cache(): void
{
$reflection = new ReflectionClass(InvoicingPeriodRoute::class);
$property = $reflection->getProperty('collectedOrderInvoicesHasDeletedAtColumn');
$property->setAccessible(true);
$property->setValue(null, null);
}
@@ -10,7 +10,6 @@ function invoicing_period_pagination_invoke(string $method, array $args = []): m
{
$reflection = new ReflectionClass(InvoicingPeriodRoute::class);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return $target->invokeArgs(null, $args);
}
@@ -8,7 +8,6 @@ function invoicing_period_queue_overlay_invoke(string $method, array $args = [])
{
$reflection = new ReflectionClass(InvoicingPeriodRoute::class);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return $target->invokeArgs(null, $args);
}
@@ -8,7 +8,6 @@ function invoicing_period_route_invoke_private(InvoicingPeriodRoute $route, stri
{
$reflection = new ReflectionClass($route);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return $target->invokeArgs($route, $args);
}
@@ -68,7 +68,6 @@ it('only includes invoice period flags when the list permission is granted', fun
it('maps batched period transaction rows to the legacy transaction response shape', function (): void {
$reflection = new ReflectionClass(InvoicingPeriodRoute::class);
$method = $reflection->getMethod('constructTransactionObjectFromPeriodRow');
$method->setAccessible(true);
$transaction = $method->invokeArgs(null, [[
'id' => '42',
@@ -8,7 +8,6 @@ function n8n_route_invoke_private(moduleN8nRoute $route, string $method, array $
{
$reflection = new ReflectionClass($route);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return $target->invokeArgs($route, $args);
}
@@ -7,7 +7,7 @@ it('restricts absolute webhook URLs to configured n8n webhook host', function ()
expect($content)->not->toBeFalse();
expect($content)->toContain('isAllowedWebhookAbsoluteUrl');
expect($content)->toContain('Webhook URL must use the configured n8n webhook host.');
expect($content)->toContain("$targetHost !== $baseHost");
expect($content)->toContain("$targetScheme !== $baseScheme");
expect($content)->toContain('$targetHost !== $baseHost');
expect($content)->toContain('$targetScheme !== $baseScheme');
expect($content)->toContain('return $targetPort === $basePort;');
});
@@ -25,7 +25,7 @@ if (!class_exists('OrdersAutoWashCertificateCompletionDouble')) {
{
public bool $containsWashCertificate = false;
public bool $washCertificateAttached = false;
/** @var array<int, array{seal_number:?string,operator:?string,date:mixed}> */
/** @var array<int, array{seal_number: ?string, operator: ?string, date: mixed}> */
public array $generatedCertificates = [];
public ?order_bookings_o $linkedBooking = null;
@@ -13,9 +13,9 @@ class RedisAtomicReservationTestClient extends PredisClient
{
}
public function mget(array $keys): array
public function mget(array $keyOrKeys): array
{
$this->calls[] = ['mget', $keys];
$this->calls[] = ['mget', $keyOrKeys];
return $this->returnValue;
}
@@ -34,7 +34,6 @@ function redis_test_inject_client(redis $redis, PredisClient $client): void
{
$reflection = new ReflectionClass($redis);
$property = $reflection->getProperty('redis');
$property->setAccessible(true);
$property->setValue($redis, $client);
}
@@ -8,7 +8,6 @@ function releaseStatusOverviewForTest(array $summary): array
{
$manager = new release_manager();
$method = new ReflectionMethod(release_manager::class, 'releaseStatusOverview');
$method->setAccessible(true);
return $method->invoke($manager, array_replace([
'generated_at' => '2026-05-20T10:00:00+00:00',
@@ -89,9 +89,7 @@ it('verifies CI release gate bearer tokens from dedicated release credentials',
it('normalizes app-specific release gate auto-sync metadata', function (): void {
$manager = new release_manager();
$normalizeGate = new ReflectionMethod(release_manager::class, 'normalizeReleaseGateInput');
$normalizeGate->setAccessible(true);
$appMatches = new ReflectionMethod(release_manager::class, 'releaseGateAppMatches');
$appMatches->setAccessible(true);
$gate = $normalizeGate->invoke($manager, [
'channel_slug' => 'stable',
@@ -129,7 +127,6 @@ it('normalizes app-specific release gate auto-sync metadata', function (): void
it('requires non-empty release gate checks before auto-sync can proceed', function (): void {
$manager = new release_manager();
$method = new ReflectionMethod(release_manager::class, 'releaseGateAutoSyncValidationSteps');
$method->setAccessible(true);
$failed = $method->invoke($manager, [
'channel_slug' => 'stable',
@@ -167,7 +164,6 @@ it('requires non-empty release gate checks before auto-sync can proceed', functi
it('extracts API ping commit metadata for release gate verification', function (): void {
$manager = new release_manager();
$method = new ReflectionMethod(release_manager::class, 'releaseGateApiPayloadCommitSha');
$method->setAccessible(true);
expect($method->invoke($manager, [
'success' => true,
@@ -194,7 +190,6 @@ it('normalizes GitHub repository identifiers for private repository access check
it('keeps GitHub commit timestamps in public release manager commit payloads', function (): void {
$manager = new release_manager();
$method = new ReflectionMethod(release_manager::class, 'publicGithubCommit');
$method->setAccessible(true);
$commit = $method->invoke($manager, [
'sha' => 'feedface00000000000000000000000000000000',
@@ -248,7 +243,6 @@ it('summarizes failed deployments and blocks promotion until a deployment succee
it('uses the selected Coolify project and resolves server UUID from the instance default', function (): void {
$manager = new release_manager();
$method = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');
$method->setAccessible(true);
$payload = $method->invoke($manager, [
'channel_slug' => 'internal',
@@ -276,11 +270,9 @@ it('supports isolated stack mode and names new Coolify services explicitly', fun
$manager = new release_manager();
$normalizeMode = new ReflectionMethod(release_manager::class, 'normalizeServiceSetMode');
$normalizeMode->setAccessible(true);
expect($normalizeMode->invoke($manager, ' isolated_stack '))->toBe('isolated_stack');
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');
$payloadMethod->setAccessible(true);
$payload = $payloadMethod->invoke($manager, [
'channel_slug' => 'internal',
'app' => 'frontend',
@@ -306,7 +298,6 @@ it('supports isolated stack mode and names new Coolify services explicitly', fun
it('creates frontend Coolify GitHub App application payloads with the release Dockerfile', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload');
$payloadMethod->setAccessible(true);
$payload = $payloadMethod->invoke($manager, [
'channel_slug' => 'canary',
@@ -346,7 +337,6 @@ it('creates frontend Coolify GitHub App application payloads with the release Do
it('uses the self-contained Coolify API Dockerfile for API applications', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload');
$payloadMethod->setAccessible(true);
$payload = $payloadMethod->invoke($manager, [
'channel_slug' => 'internal',
@@ -378,7 +368,6 @@ it('uses the self-contained Coolify API Dockerfile for API applications', functi
it('creates private Coolify application payloads for cron workers', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload');
$payloadMethod->setAccessible(true);
$payload = $payloadMethod->invoke($manager, [
'channel_slug' => 'internal',
@@ -411,7 +400,6 @@ it('creates private Coolify application payloads for cron workers', function ():
it('derives cron worker deployment context from the API target without public routing', function (): void {
$manager = new release_manager();
$contextMethod = new ReflectionMethod(release_manager::class, 'cronWorkerDeployContext');
$contextMethod->setAccessible(true);
$context = $contextMethod->invoke($manager, [
'id' => 17,
@@ -446,8 +434,6 @@ it('requires Coolify cron worker autoprovisioning for API deployments by default
$manager = new release_manager();
$enabledMethod = new ReflectionMethod(release_manager::class, 'cronWorkerAutoprovisionEnabled');
$requiredMethod = new ReflectionMethod(release_manager::class, 'cronWorkerAutoprovisionRequired');
$enabledMethod->setAccessible(true);
$requiredMethod->setAccessible(true);
expect($enabledMethod->invoke($manager, ['deploy_context_json' => null]))->toBeTrue();
expect($requiredMethod->invoke($manager, ['deploy_context_json' => null]))->toBeTrue();
@@ -475,7 +461,6 @@ it('requires Coolify cron worker autoprovisioning for API deployments by default
it('classifies cron worker deployment and heartbeat lifecycle states', function (): void {
$manager = new release_manager();
$healthMethod = new ReflectionMethod(release_manager::class, 'cronWorkerHealth');
$healthMethod->setAccessible(true);
expect($healthMethod->invoke($manager, null, null, [], ['running' => 0, 'stale' => 0, 'failed' => 0], null)['state'])
->toBe('needs_deploy');
@@ -503,7 +488,6 @@ it('classifies cron worker deployment and heartbeat lifecycle states', function
it('extracts Coolify cron deployment operation identifiers from provider payloads', function (): void {
$manager = new release_manager();
$operationMethod = new ReflectionMethod(release_manager::class, 'coolifyDeploymentOperationId');
$operationMethod->setAccessible(true);
expect($operationMethod->invoke($manager, ['deployment' => ['deployment_uuid' => 'deployment-123']]))
->toBe('deployment-123');
@@ -515,7 +499,6 @@ it('extracts Coolify cron deployment operation identifiers from provider payload
it('detects missing Coolify cron worker resources from provider errors', function (): void {
$method = new ReflectionMethod(release_manager::class, 'coolifyResourceMissing');
$method->setAccessible(true);
expect($method->invoke(null, new RuntimeException('Coolify API request failed: HTTP 404')))->toBeTrue();
expect($method->invoke(null, new RuntimeException('Application not found')))->toBeTrue();
@@ -525,7 +508,6 @@ it('detects missing Coolify cron worker resources from provider errors', functio
it('classifies missing Coolify cron worker resources as repairable', function (): void {
$manager = new release_manager();
$readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness');
$readiness->setAccessible(true);
$result = $readiness->invoke($manager, [
'id' => 17,
@@ -552,7 +534,6 @@ it('classifies missing Coolify cron worker resources as repairable', function ()
it('repairs from an existing cron target when the API target is absent', function (): void {
$manager = new release_manager();
$readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness');
$readiness->setAccessible(true);
$result = $readiness->invoke($manager, null, [
'id' => 71,
@@ -574,7 +555,6 @@ it('repairs from an existing cron target when the API target is absent', functio
it('blocks cron worker deployment without an API target or deployable cron context', function (): void {
$manager = new release_manager();
$readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness');
$readiness->setAccessible(true);
$result = $readiness->invoke($manager, null, [
'id' => 71,
@@ -596,7 +576,6 @@ it('blocks cron worker deployment without an API target or deployable cron conte
it('builds explicit Coolify application route labels for release API targets', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationRoutePayload');
$payloadMethod->setAccessible(true);
$payload = $payloadMethod->invoke($manager, [
'channel_slug' => 'internal',
@@ -622,7 +601,6 @@ it('builds explicit Coolify application route labels for release API targets', f
it('updates existing frontend Coolify applications away from legacy Nixpacks detection', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationUpdatePayload');
$payloadMethod->setAccessible(true);
$payload = $payloadMethod->invoke($manager, [
'channel_slug' => 'internal',
@@ -650,7 +628,6 @@ it('updates existing frontend Coolify applications away from legacy Nixpacks det
it('can use the Coolify instance default GitHub App when source targets do not store it yet', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload');
$payloadMethod->setAccessible(true);
$payload = $payloadMethod->invoke($manager, [
'channel_slug' => 'internal',
@@ -676,7 +653,6 @@ it('can use the Coolify instance default GitHub App when source targets do not s
it('does not treat an existing Coolify service as an application just because a GitHub App UUID is stored', function (): void {
$manager = new release_manager();
$resourceTypeMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyResourceType');
$resourceTypeMethod->setAccessible(true);
expect($resourceTypeMethod->invoke($manager, [
'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github',
@@ -695,7 +671,6 @@ it('does not treat an existing Coolify service as an application just because a
it('auto-prepares path-routed release targets for Coolify application creation', function (): void {
$manager = new release_manager();
$needsApplication = new ReflectionMethod(release_manager::class, 'releaseTargetNeedsApplicationAutoCreate');
$needsApplication->setAccessible(true);
$target = [
'id' => 42,
@@ -720,7 +695,6 @@ it('auto-prepares path-routed release targets for Coolify application creation',
it('offers application target preparation for missing Coolify service creation failures', function (): void {
$needsApplicationAction = new ReflectionMethod(release_manager::class, 'releaseStatusIssueNeedsApplicationTarget');
$needsApplicationAction->setAccessible(true);
expect($needsApplicationAction->invoke(null, [
'message' => 'API deployment failed before activation.',
@@ -762,7 +736,6 @@ it('builds API Coolify runtime environment from allowed process variables', func
$manager = new release_manager();
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
$runtimeEnv->setAccessible(true);
$env = $runtimeEnv->invoke($manager, [
'app' => 'api',
@@ -853,7 +826,6 @@ it('resolves backend commit sha from API runtime environment in priority order',
it('forces selected API commit into generated Coolify runtime env keys', function (): void {
$manager = new release_manager();
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
$runtimeEnv->setAccessible(true);
$selectedCommit = '1111111111111111111111111111111111111111';
$explicitCommit = '2222222222222222222222222222222222222222';
@@ -893,7 +865,6 @@ it('builds cron worker runtime environment from API runtime keys and cron contex
$manager = new release_manager();
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
$runtimeEnv->setAccessible(true);
$selectedCommit = '4444444444444444444444444444444444444444';
$env = $runtimeEnv->invoke($manager, [
@@ -932,7 +903,6 @@ it('builds cron worker runtime environment from API runtime keys and cron contex
it('uses the selected deployment commit before stale Coolify context commits', function (): void {
$manager = new release_manager();
$gitCommitSha = new ReflectionMethod(release_manager::class, 'releaseCoolifyGitCommitSha');
$gitCommitSha->setAccessible(true);
$selectedCommit = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
$staleCommit = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
@@ -948,7 +918,6 @@ it('uses the selected deployment commit before stale Coolify context commits', f
it('injects selected frontend commit into Coolify runtime env for manifest builds', function (): void {
$manager = new release_manager();
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
$runtimeEnv->setAccessible(true);
$selectedCommit = '3333333333333333333333333333333333333333';
@@ -987,7 +956,6 @@ it('keeps beta API runtime environment on production database target', function
$manager = new release_manager();
$runtimeEnv = new ReflectionMethod(release_manager::class, 'releaseCoolifyRuntimeEnv');
$runtimeEnv->setAccessible(true);
$env = $runtimeEnv->invoke($manager, [
'app' => 'api',
@@ -1015,7 +983,6 @@ it('keeps beta API runtime environment on production database target', function
it('treats attach-existing service sets without data target ids as production-shared ready', function (): void {
$manager = new release_manager();
$status = new ReflectionMethod(release_manager::class, 'serviceSetStatus');
$status->setAccessible(true);
$dataTargets = ['database' => null, 'redis' => null, 'minio' => null];
expect($status->invoke($manager, 'attach_existing', 10, 11, $dataTargets))->toBe('ready');
@@ -1027,7 +994,6 @@ it('treats attach-existing service sets without data target ids as production-sh
it('detects explicit data target ids so beta service sets can stay data-only', function (): void {
$manager = new release_manager();
$hasExplicitDataTargets = new ReflectionMethod(release_manager::class, 'serviceSetInputHasExplicitDataTargets');
$hasExplicitDataTargets->setAccessible(true);
expect($hasExplicitDataTargets->invoke($manager, [
'mode' => 'attach_existing',
@@ -1046,7 +1012,6 @@ it('detects explicit data target ids so beta service sets can stay data-only', f
it('allows beta production-service bundles only when data services stay production-shared', function (): void {
$manager = new release_manager();
$assert = new ReflectionMethod(release_manager::class, 'assertBetaProductionDataPolicy');
$assert->setAccessible(true);
$betaChannel = ['id' => 2, 'slug' => 'beta'];
expect($assert->invoke($manager, $betaChannel, ['mode' => 'attach_existing']))->toBeNull();
@@ -1060,7 +1025,6 @@ it('allows beta production-service bundles only when data services stay producti
it('keeps release branch services out of the production Coolify environment', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');
$payloadMethod->setAccessible(true);
$canaryPayload = $payloadMethod->invoke($manager, [
'channel_slug' => 'canary',
@@ -1103,7 +1067,6 @@ it('keeps release branch services out of the production Coolify environment', fu
it('does not invent GHCR images for Coolify service payloads', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');
$payloadMethod->setAccessible(true);
$payloadMethod->invoke($manager, [
'channel_slug' => 'internal',
@@ -1123,7 +1086,6 @@ it('does not invent GHCR images for Coolify service payloads', function (): void
it('creates Coolify service payloads from raw compose without a service type', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyServicePayload');
$payloadMethod->setAccessible(true);
$compose = "services:\n app:\n image: ghcr.io/copenhagentruckwash/api:test";
$payload = $payloadMethod->invoke($manager, [
@@ -1485,7 +1447,6 @@ it('uses master as the public route slug for the stable release channel', functi
it('ignores explicit runtime selection for channels outside the principal channel set', function (): void {
$manager = new release_manager();
$chooseRuntimeChannel = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel');
$chooseRuntimeChannel->setAccessible(true);
$stable = [
'id' => 1,
@@ -1508,15 +1469,10 @@ it('ignores explicit runtime selection for channels outside the principal channe
it('requires non-default release channel runtime URLs and preserves load balancer paths', function (): void {
$manager = new release_manager();
$availability = new ReflectionMethod(release_manager::class, 'channelAvailability');
$availability->setAccessible(true);
$runtimeUrls = new ReflectionMethod(release_manager::class, 'releaseRuntimeUrls');
$runtimeUrls->setAccessible(true);
$publicUrl = new ReflectionMethod(release_manager::class, 'releaseCoolifyPublicUrl');
$publicUrl->setAccessible(true);
$targetPublicBaseUrl = new ReflectionMethod(release_manager::class, 'releaseTargetPublicBaseUrl');
$targetPublicBaseUrl->setAccessible(true);
$applicationLabels = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationLabels');
$applicationLabels->setAccessible(true);
expect($availability->invoke($manager, [
'id' => 1,
@@ -1628,11 +1584,8 @@ it('requires non-default release channel runtime URLs and preserves load balance
it('resolves release deployment endpoints from manual overrides, URLs, health checks, and gateway defaults', function (): void {
$manager = new release_manager();
$endpoint = new ReflectionMethod(release_manager::class, 'releaseDeploymentEndpoint');
$endpoint->setAccessible(true);
$publicUrl = new ReflectionMethod(release_manager::class, 'releaseCoolifyPublicUrl');
$publicUrl->setAccessible(true);
$targetPublicBaseUrl = new ReflectionMethod(release_manager::class, 'releaseTargetPublicBaseUrl');
$targetPublicBaseUrl->setAccessible(true);
$manual = $endpoint->invoke($manager, [
'app' => 'api',
@@ -1746,7 +1699,6 @@ it('resolves release deployment endpoints from manual overrides, URLs, health ch
it('collects previous Coolify application UUIDs for stale route cleanup', function (): void {
$manager = new release_manager();
$previousApplications = new ReflectionMethod(release_manager::class, 'releaseCoolifyPreviousApplicationUuids');
$previousApplications->setAccessible(true);
expect($previousApplications->invoke($manager, [
'coolify_previous_artifact_app_uuid' => 'old-artifact-app',
@@ -1767,7 +1719,6 @@ it('collects previous Coolify application UUIDs for stale route cleanup', functi
it('deletes only generated API commit env rows before Coolify application env updates', function (): void {
$manager = new release_manager();
$deleteCommitEnvs = new ReflectionMethod(release_manager::class, 'deleteCoolifyGeneratedCommitEnvs');
$deleteCommitEnvs->setAccessible(true);
$client = new ReleaseManagerCoolifyEnvFake([
['uuid' => 'api-commit', 'key' => 'API_COMMIT_SHA'],
['uuid' => 'commit', 'key' => 'COMMIT_SHA'],
@@ -1797,7 +1748,6 @@ it('waits for an in-flight automatic sync instead of passing the retry immediate
it('redacts GitHub access metadata from public release versions', function (): void {
$manager = new release_manager();
$publicVersion = new ReflectionMethod(release_manager::class, 'publicVersion');
$publicVersion->setAccessible(true);
$version = $publicVersion->invoke($manager, [
'id' => 12,
@@ -1841,7 +1791,6 @@ it('redacts GitHub access metadata from public release versions', function (): v
it('only chooses requested runtime channels from channels available to the principal', function (): void {
$manager = new release_manager();
$choose = new ReflectionMethod(release_manager::class, 'chooseRuntimeChannel');
$choose->setAccessible(true);
$stable = [
'id' => 1,
@@ -1905,13 +1854,9 @@ it('restricts release gate fetches to Truckwash release hosts and relative paths
$manager = new release_manager();
$joinUrl = new ReflectionMethod(release_manager::class, 'releaseGateJoinUrl');
$joinUrl->setAccessible(true);
$hostAllowed = new ReflectionMethod(release_manager::class, 'releaseGateFetchHostAllowed');
$hostAllowed->setAccessible(true);
$publicIpAllowed = new ReflectionMethod(release_manager::class, 'releaseGatePublicIpAllowed');
$publicIpAllowed->setAccessible(true);
$stringArray = new ReflectionMethod(release_manager::class, 'releaseGateStringArray');
$stringArray->setAccessible(true);
expect($joinUrl->invoke($manager, 'https://api-v2.truckwash.io', '/master/api/ping'))
->toBe('https://api-v2.truckwash.io/master/api/ping')
@@ -436,7 +436,6 @@ it('allows the MinIO client binary to be configured explicitly', function (): vo
try {
$method = new ReflectionMethod(replication_manager::class, 'minioClientBinary');
$method->setAccessible(true);
expect($method->invoke(null))->toBe('/opt/minio/mc');
} finally {
@@ -460,15 +459,10 @@ it('supports MinIO client runtime fallback configuration', function (): void {
try {
$downloadUrl = new ReflectionMethod(replication_manager::class, 'minioClientDownloadUrl');
$downloadUrl->setAccessible(true);
$autoInstall = new ReflectionMethod(replication_manager::class, 'minioClientAutoInstallEnabled');
$autoInstall->setAccessible(true);
$commandTimeout = new ReflectionMethod(replication_manager::class, 'minioClientCommandTimeoutSeconds');
$commandTimeout->setAccessible(true);
$downloadTimeout = new ReflectionMethod(replication_manager::class, 'minioClientDownloadTimeoutSeconds');
$downloadTimeout->setAccessible(true);
$commandLabel = new ReflectionMethod(replication_manager::class, 'minioClientCommandLabel');
$commandLabel->setAccessible(true);
expect($downloadUrl->invoke(null))->toBe('https://example.test/mc');
expect($autoInstall->invoke(null))->toBeFalse();
@@ -632,11 +626,8 @@ it('creates the generated replication user on the primary during provisioning',
it('extracts host-specific MariaDB replication account denials', function (): void {
$extract = new ReflectionMethod(replication_manager::class, 'databaseDeniedAccountHostsFromText');
$extract->setAccessible(true);
$normalize = new ReflectionMethod(replication_manager::class, 'normalizeDatabaseAccountHost');
$normalize->setAccessible(true);
$candidates = new ReflectionMethod(replication_manager::class, 'databaseAccountHostGrantCandidates');
$candidates->setAccessible(true);
expect($extract->invoke(null, "Access denied for user 'replication'@'10.0.1.13' (using password: YES)"))
->toBe(['10.0.1.13']);
@@ -650,7 +641,6 @@ it('extracts host-specific MariaDB replication account denials', function (): vo
it('identifies stopped database replication threads as a restartable status', function (): void {
$onlyThreadBlockers = new ReflectionMethod(replication_manager::class, 'databaseOnlyReplicationThreadBlockers');
$onlyThreadBlockers->setAccessible(true);
expect($onlyThreadBlockers->invoke(null, [
'Database replication IO and SQL threads must both be running.',
@@ -427,7 +427,7 @@ it('uses a short exact-image result cache before calling Plate Recognizer', func
expect($source)->toContain('RESULT_CACHE_TTL_SECONDS = 10');
expect($source)->toContain('RESULT_CACHE_REDIS_KEY_PREFIX');
expect($source)->toContain('$cached_result = $this->readRecognitionResultCache($result_cache, $result_cache_key);');
expect($source)->toContain('return $cached_result;');
expect($source)->toContain('return $this->completeRecognition($started_at, $cached_result);');
expect($source)->toContain('$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);');
expect($source)->toContain('$this->last_timings[\'cache\']');
expect($source)->toContain('$this->last_timings[\'cache_hit\'] = 1;');
@@ -13,7 +13,6 @@ function system_search_entity_coverage_invoke_private(object $instance, string $
{
$reflection = new ReflectionClass($instance);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return (array)$target->invoke($instance);
}
@@ -8,7 +8,6 @@ function system_search_route_invoke_private(systemSearchRoute $route, string $me
{
$reflection = new ReflectionClass($route);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return $target->invokeArgs($route, $args);
}
@@ -141,7 +141,6 @@ if (!function_exists('system_search_service_invoke_private')) {
function system_search_service_invoke_private(object $instance, string $method, array $args = []): mixed
{
$reflection = new ReflectionMethod($instance, $method);
$reflection->setAccessible(true);
return $reflection->invokeArgs($instance, $args);
}
}
@@ -87,7 +87,7 @@ it('resolves relay bindings without requiring a primary department gateway first
expect($managerSource)->not->toBeFalse();
preg_match(
'/public function resolveRelayBinding\(int \$departmentId, string \$logicalRelayId\): array\s*\{(?P<body>.*?)\n \}\n\n \/\*\*/s',
'/public function resolveRelayBinding\(int \$departmentId, string \$logicalRelayId\): array\s*\{(?P<body>.*?)\n\x20{4}\x7d\n\n\x20{4}\/\*\*/s',
(string)$managerSource,
$matches
);
@@ -109,7 +109,7 @@ it('reactivates soft-deleted relay bindings before inserting replacements', func
expect($managerSource)->not->toBeFalse();
preg_match(
'/public function setRelayBindings\(int \$gatewayId, array \$bindings, \?int \$userId = null\): array\s*\{(?P<body>.*?)\n \}\n\n \/\*\*/s',
'/public function setRelayBindings\(int \$gatewayId, array \$bindings, \?int \$userId = null\): array\s*\{(?P<body>.*?)\n\x20{4}\x7d\n\n\x20{4}\/\*\*/s',
(string)$managerSource,
$matches
);
@@ -57,7 +57,6 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
function invoke_edge_gateway_private(object $instance, string $method, mixed ...$arguments): mixed
{
$reflection = new ReflectionMethod($instance, $method);
$reflection->setAccessible(true);
return $reflection->invokeArgs($instance, $arguments);
}
@@ -424,7 +424,6 @@ it('keeps runtime on published v2 configs and leaves draft JSON as the studio ed
it('upserts path editor answers into generated condition and task config rows', function (): void {
$service = selfserve_studio_graph_without_constructor();
$method = new ReflectionMethod(selfserve_studio_graph::class, 'applyConfigOperation');
$method->setAccessible(true);
$config = [
'schema_version' => 2,
'questions' => [
@@ -35,7 +35,6 @@ it('infers legacy-defaulted always task gates from condition_id at runtime', fun
$reflection = new ReflectionClass(selfserve_wash_flow::class);
$flow = $reflection->newInstanceWithoutConstructor();
$method = $reflection->getMethod('resolveTaskGate');
$method->setAccessible(true);
$conditionGate = $method->invoke($flow, [
'condition_id' => 22,
@@ -99,7 +99,6 @@ it('marks non-injected local and gateway overrides as local-only gateway transpo
foreach (['local', 'gateway'] as $override) {
$transport = $resolver->resolveForDepartment(17, $override);
$localOnly = new ReflectionProperty($transport, 'localOnly');
$localOnly->setAccessible(true);
expect($transport)->toBeInstanceOf(gateway_shelly_transport::class);
expect($localOnly->getValue($transport))->toBeTrue();
@@ -8,7 +8,6 @@ function weather_cache_invoke_private(moduleWeatherAPIRoute $route, string $meth
{
$reflection = new ReflectionClass($route);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return $target->invokeArgs($route, $args);
}
@@ -189,7 +189,6 @@ function department_weather_runtime_invoke_private(moduleWeatherAPIRoute $route,
{
$reflection = new ReflectionClass($route);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return $target->invokeArgs($route, $args);
}
@@ -8,7 +8,6 @@ function department_weather_fallback_invoke_private(moduleWeatherAPIRoute $route
{
$reflection = new ReflectionClass($route);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return $target->invokeArgs($route, $args);
}
@@ -8,7 +8,6 @@ function weather_status_targets_invoke_private(moduleWeatherAPIRoute $route, str
{
$reflection = new ReflectionClass($route);
$target = $reflection->getMethod($method);
$target->setAccessible(true);
return $target->invokeArgs($route, $args);
}

Some files were not shown because too many files have changed in this diff Show More