Merge origin/master and resolve XLVask route contract conflict
This commit is contained in:
+8903
-2839
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
worker_processes auto;
|
||||
pid /run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
access_log /dev/stdout;
|
||||
error_log /dev/stderr warn;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
client_max_body_size 64m;
|
||||
|
||||
gzip on;
|
||||
gzip_types application/json application/javascript application/xml text/css text/plain;
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
root /var/www/html;
|
||||
index index.php;
|
||||
|
||||
location / {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_read_timeout 60s;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
|
||||
fastcgi_param SCRIPT_NAME /index.php;
|
||||
fastcgi_param X_REQUEST_ID $http_x_request_id;
|
||||
}
|
||||
|
||||
location ~ /\.(?!well-known) {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
mkdir -p /run/nginx
|
||||
php-fpm -D
|
||||
exec nginx -g "daemon off;"
|
||||
@@ -45,6 +45,7 @@ test("base docker compose routes edge broker traffic through traefik", () => {
|
||||
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`\)/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-io\.rule=Host\(`api\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-v2\.rule=Host\(`api-v2\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
|
||||
assert.match(serviceBlock, /traefik\.http\.middlewares\.edge-broker-strip\.stripPrefix\.prefixes=\/edge-broker/);
|
||||
assert.match(serviceBlock, /traefik\.http\.middlewares\.edge-broker-strip-local\.stripPrefix\.prefixes=\/api\/edge-broker/);
|
||||
@@ -70,6 +71,7 @@ test("standalone production compose routes edge broker traffic through traefik",
|
||||
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`\)/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-io\.rule=Host\(`api\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
|
||||
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-v2\.rule=Host\(`api-v2\.truckwash\.io`\) && 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/);
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -12120,3 +12120,87 @@
|
||||
[Mon Apr 27 16:09:11 2026] 127.0.0.1:40070 Closing
|
||||
[Mon Apr 27 16:09:12 2026] 127.0.0.1:41964 Accepted
|
||||
[Mon Apr 27 16:09:54 2026] 127.0.0.1:41964 Closing
|
||||
[Tue May 26 09:18:49 2026] PHP 8.2.15 Development Server (http://127.0.0.1:35029) started
|
||||
[Tue May 26 09:18:49 2026] 127.0.0.1:35080 Accepted
|
||||
[Tue May 26 09:18:53 2026] 127.0.0.1:35080 Closing
|
||||
[Tue May 26 09:18:53 2026] 127.0.0.1:35088 Accepted
|
||||
[Tue May 26 09:18:56 2026] 127.0.0.1:35088 Closing
|
||||
[Tue May 26 09:18:58 2026] 127.0.0.1:39708 Accepted
|
||||
[Tue May 26 09:19:01 2026] 127.0.0.1:39708 Closing
|
||||
[Tue May 26 09:19:02 2026] 127.0.0.1:39714 Accepted
|
||||
[Tue May 26 09:19:06 2026] 127.0.0.1:39714 Closing
|
||||
[Tue May 26 09:19:05 2026] 127.0.0.1:38256 Accepted
|
||||
[Tue May 26 09:19:08 2026] 127.0.0.1:38256 Closing
|
||||
[Tue May 26 09:19:09 2026] 127.0.0.1:38268 Accepted
|
||||
[Tue May 26 09:19:12 2026] 127.0.0.1:38268 Closing
|
||||
[Tue May 26 09:19:13 2026] 127.0.0.1:37926 Accepted
|
||||
[Tue May 26 09:19:17 2026] 127.0.0.1:37926 Closing
|
||||
[Tue May 26 09:21:24 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33343) started
|
||||
[Tue May 26 09:21:25 2026] 127.0.0.1:53732 Accepted
|
||||
[Tue May 26 09:21:27 2026] 127.0.0.1:53732 Closing
|
||||
[Tue May 26 09:21:27 2026] 127.0.0.1:55472 Accepted
|
||||
[Tue May 26 09:21:30 2026] 127.0.0.1:55472 Closing
|
||||
[Tue May 26 09:21:32 2026] 127.0.0.1:55484 Accepted
|
||||
[Tue May 26 09:21:35 2026] 127.0.0.1:55484 Closing
|
||||
[Tue May 26 09:21:36 2026] 127.0.0.1:44478 Accepted
|
||||
[Tue May 26 09:21:40 2026] 127.0.0.1:44478 Closing
|
||||
[Tue May 26 09:21:41 2026] 127.0.0.1:44486 Accepted
|
||||
[Tue May 26 09:21:46 2026] 127.0.0.1:44486 Closing
|
||||
[Tue May 26 09:21:47 2026] 127.0.0.1:50508 Accepted
|
||||
[Tue May 26 09:21:53 2026] 127.0.0.1:50508 Closing
|
||||
[Tue May 26 09:21:55 2026] 127.0.0.1:42034 Accepted
|
||||
[Tue May 26 09:22:00 2026] 127.0.0.1:42034 Closing
|
||||
[Tue May 26 09:23:59 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41083) started
|
||||
[Tue May 26 09:23:59 2026] 127.0.0.1:45896 Accepted
|
||||
[Tue May 26 09:24:04 2026] 127.0.0.1:45896 Closing
|
||||
[Tue May 26 09:24:04 2026] 127.0.0.1:45908 Accepted
|
||||
[Tue May 26 09:24:07 2026] 127.0.0.1:45908 Closing
|
||||
[Tue May 26 09:25:43 2026] PHP 8.2.15 Development Server (http://127.0.0.1:38485) started
|
||||
[Tue May 26 09:25:43 2026] 127.0.0.1:34288 Accepted
|
||||
[Tue May 26 09:25:47 2026] 127.0.0.1:34288 Closing
|
||||
[Tue May 26 09:25:47 2026] 127.0.0.1:34290 Accepted
|
||||
[Tue May 26 09:25:51 2026] 127.0.0.1:34290 Closing
|
||||
[Tue May 26 09:25:51 2026] 127.0.0.1:41802 Accepted
|
||||
[Tue May 26 09:25:55 2026] 127.0.0.1:41802 Closing
|
||||
[Tue May 26 09:27:23 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37463) started
|
||||
[Tue May 26 09:27:24 2026] 127.0.0.1:43206 Accepted
|
||||
[Tue May 26 09:27:26 2026] 127.0.0.1:43206 Closing
|
||||
[Tue May 26 09:27:26 2026] 127.0.0.1:43212 Accepted
|
||||
[Tue May 26 09:27:33 2026] 127.0.0.1:43212 Closing
|
||||
[Tue May 26 09:27:34 2026] 127.0.0.1:51102 Accepted
|
||||
[Tue May 26 09:27:39 2026] 127.0.0.1:51102 Closing
|
||||
[Tue May 26 09:28:26 2026] PHP 8.2.15 Development Server (http://127.0.0.1:46559) started
|
||||
[Tue May 26 09:28:26 2026] 127.0.0.1:59372 Accepted
|
||||
[Tue May 26 09:28:28 2026] 127.0.0.1:59372 Closing
|
||||
[Tue May 26 09:28:28 2026] 127.0.0.1:59378 Accepted
|
||||
[Tue May 26 09:28:30 2026] 127.0.0.1:59378 Closing
|
||||
[Tue May 26 09:28:31 2026] 127.0.0.1:54846 Accepted
|
||||
[Tue May 26 09:28:31 2026] 127.0.0.1:54846 Closing
|
||||
[Tue May 26 09:28:33 2026] 127.0.0.1:54850 Accepted
|
||||
[Tue May 26 09:28:34 2026] 127.0.0.1:54850 Closing
|
||||
[Tue May 26 09:28:35 2026] 127.0.0.1:54854 Accepted
|
||||
[Tue May 26 09:28:36 2026] 127.0.0.1:54854 Closing
|
||||
[Tue May 26 09:28:36 2026] 127.0.0.1:54864 Accepted
|
||||
[Tue May 26 09:28:41 2026] 127.0.0.1:54864 Closing
|
||||
[Tue May 26 09:28:42 2026] 127.0.0.1:52868 Accepted
|
||||
[Tue May 26 09:28:46 2026] 127.0.0.1:52868 Closing
|
||||
[Tue May 26 09:30:14 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41205) started
|
||||
[Tue May 26 09:30:14 2026] 127.0.0.1:56928 Accepted
|
||||
[Tue May 26 09:30:15 2026] 127.0.0.1:56928 Closing
|
||||
[Tue May 26 09:30:15 2026] 127.0.0.1:56938 Accepted
|
||||
[Tue May 26 09:30:18 2026] 127.0.0.1:56938 Closing
|
||||
[Tue May 26 09:30:49 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44523) started
|
||||
[Tue May 26 09:30:49 2026] 127.0.0.1:54004 Accepted
|
||||
[Tue May 26 09:30:52 2026] 127.0.0.1:54004 Closing
|
||||
[Tue May 26 09:30:52 2026] 127.0.0.1:38720 Accepted
|
||||
[Tue May 26 09:30:51 2026] 127.0.0.1:38720 Closing
|
||||
[Tue May 26 09:30:53 2026] 127.0.0.1:38734 Accepted
|
||||
[Tue May 26 09:30:56 2026] 127.0.0.1:38734 Closing
|
||||
[Tue May 26 09:30:57 2026] 127.0.0.1:38738 Accepted
|
||||
[Tue May 26 09:31:00 2026] 127.0.0.1:38738 Closing
|
||||
[Tue May 26 09:31:01 2026] 127.0.0.1:38278 Accepted
|
||||
[Tue May 26 09:31:02 2026] 127.0.0.1:38278 Closing
|
||||
[Tue May 26 09:31:03 2026] 127.0.0.1:38284 Accepted
|
||||
[Tue May 26 09:31:07 2026] 127.0.0.1:38284 Closing
|
||||
[Tue May 26 09:31:08 2026] 127.0.0.1:38290 Accepted
|
||||
[Tue May 26 09:31:12 2026] 127.0.0.1:38290 Closing
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class application_write_freeze
|
||||
{
|
||||
public static function freeze(string $reason, string $owner, int $ttlSeconds = 300): void
|
||||
{
|
||||
$payload = [
|
||||
'reason' => $reason,
|
||||
'owner' => $owner,
|
||||
'created_at' => date('c'),
|
||||
'expires_at' => date('c', time() + max(30, $ttlSeconds)),
|
||||
];
|
||||
|
||||
self::writeState($payload);
|
||||
}
|
||||
|
||||
public static function unfreeze(?string $owner = null): void
|
||||
{
|
||||
$state = self::state();
|
||||
if ($owner !== null && isset($state['owner']) && $state['owner'] !== $owner) {
|
||||
return;
|
||||
}
|
||||
|
||||
$path = self::statePath();
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public static function state(): array
|
||||
{
|
||||
$path = self::statePath();
|
||||
if (!is_file($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$state = json_decode((string)file_get_contents($path), true);
|
||||
if (!is_array($state)) {
|
||||
@unlink($path);
|
||||
return [];
|
||||
}
|
||||
|
||||
$expiresAt = strtotime((string)($state['expires_at'] ?? ''));
|
||||
if ($expiresAt !== false && $expiresAt < time()) {
|
||||
@unlink($path);
|
||||
return [];
|
||||
}
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
public static function isFrozen(): bool
|
||||
{
|
||||
return self::state() !== [];
|
||||
}
|
||||
|
||||
public static function shouldBlock(string $method, string $uri, bool $isCronOrCli): bool
|
||||
{
|
||||
if (!self::isFrozen()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($isCronOrCli) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$method = strtoupper($method);
|
||||
if (in_array($method, ['GET', 'HEAD', 'OPTIONS'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '';
|
||||
return !str_starts_with($path, '/superuser/replication');
|
||||
}
|
||||
|
||||
private static function writeState(array $state): void
|
||||
{
|
||||
$path = self::statePath();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0770, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException('Could not create write-freeze directory.');
|
||||
}
|
||||
|
||||
$tempPath = tempnam($dir, 'write-freeze-');
|
||||
if ($tempPath === false) {
|
||||
throw new RuntimeException('Could not create write-freeze temp file.');
|
||||
}
|
||||
|
||||
try {
|
||||
file_put_contents($tempPath, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL, LOCK_EX);
|
||||
if (!rename($tempPath, $path)) {
|
||||
throw new RuntimeException('Could not atomically replace write-freeze state.');
|
||||
}
|
||||
} finally {
|
||||
if (is_file($tempPath)) {
|
||||
@unlink($tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function statePath(): string
|
||||
{
|
||||
$root = defined('WD') ? WD : dirname(__DIR__);
|
||||
return $root . DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR . 'application-write-freeze.json';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
require_once WD . '/interfaces/universal_module_i.php';
|
||||
require_once WD . '/modules/coolify/coolify_c.php';
|
||||
|
||||
use Exception;
|
||||
use interfaces\universal_module_i;
|
||||
use modules\coolify\coolify_c;
|
||||
|
||||
class coolify implements universal_module_i
|
||||
{
|
||||
public coolify_c $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = new coolify_c();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function requireModuleEnabled(): void
|
||||
{
|
||||
if (!$this->config->enabled->isTrue()) {
|
||||
throw new Exception('The Coolify module is not enabled');
|
||||
}
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
try {
|
||||
return $this->config->enabled->isTrue();
|
||||
} catch (Exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class coolify_api_client
|
||||
{
|
||||
private string $baseUrl;
|
||||
private string $token;
|
||||
private int $timeoutSeconds;
|
||||
|
||||
public function __construct(string $baseUrl, string $token, int $timeoutSeconds = 4)
|
||||
{
|
||||
$this->baseUrl = self::normalizeBaseUrl($baseUrl);
|
||||
$this->token = trim($token);
|
||||
$this->timeoutSeconds = max(1, $timeoutSeconds);
|
||||
if ($this->baseUrl === '' || $this->token === '') {
|
||||
throw new RuntimeException('Coolify base URL and API token are required.');
|
||||
}
|
||||
}
|
||||
|
||||
public static function normalizeBaseUrl(string $baseUrl): string
|
||||
{
|
||||
$baseUrl = rtrim(trim($baseUrl), '/');
|
||||
if ($baseUrl === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (preg_match('#/api/v[0-9]+$#i', $baseUrl) === 1) {
|
||||
return $baseUrl;
|
||||
}
|
||||
|
||||
return $baseUrl . '/api/v1';
|
||||
}
|
||||
|
||||
public function healthcheck(): array
|
||||
{
|
||||
return $this->request('GET', '/health', null, false);
|
||||
}
|
||||
|
||||
public function version(): array
|
||||
{
|
||||
return $this->request('GET', '/version');
|
||||
}
|
||||
|
||||
public function listServers(): array
|
||||
{
|
||||
return $this->request('GET', '/servers');
|
||||
}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
return $this->request('GET', '/projects');
|
||||
}
|
||||
|
||||
public function listProjectEnvironments(string $projectUuid): array
|
||||
{
|
||||
return $this->request('GET', '/projects/' . rawurlencode($projectUuid) . '/environments');
|
||||
}
|
||||
|
||||
public function listServices(): array
|
||||
{
|
||||
return $this->request('GET', '/services');
|
||||
}
|
||||
|
||||
public function listGithubApps(): array
|
||||
{
|
||||
return $this->request('GET', '/github-apps');
|
||||
}
|
||||
|
||||
public function getService(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/services/' . rawurlencode($uuid));
|
||||
}
|
||||
|
||||
public function createService(array $payload): array
|
||||
{
|
||||
return $this->request('POST', '/services', $payload);
|
||||
}
|
||||
|
||||
public function createPrivateGithubAppApplication(array $payload): array
|
||||
{
|
||||
return $this->request('POST', '/applications/private-github-app', $payload);
|
||||
}
|
||||
|
||||
public function getApplication(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid));
|
||||
}
|
||||
|
||||
public function updateApplication(string $uuid, array $payload): array
|
||||
{
|
||||
return $this->request('PATCH', '/applications/' . rawurlencode($uuid), $payload);
|
||||
}
|
||||
|
||||
public function updateService(string $uuid, array $payload): array
|
||||
{
|
||||
return $this->request('PATCH', '/services/' . rawurlencode($uuid), $payload);
|
||||
}
|
||||
|
||||
public function updateServiceEnvsBulk(string $uuid, array $env): array
|
||||
{
|
||||
if ($env === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->request('PATCH', '/services/' . rawurlencode($uuid) . '/envs/bulk', [
|
||||
'data' => self::bulkEnvData($env),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateApplicationEnvsBulk(string $uuid, array $env): array
|
||||
{
|
||||
if ($env === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->request('PATCH', '/applications/' . rawurlencode($uuid) . '/envs/bulk', [
|
||||
'data' => self::bulkEnvData($env),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function bulkEnvData(array $env): array
|
||||
{
|
||||
$data = [];
|
||||
foreach ($env as $key => $value) {
|
||||
$data[] = [
|
||||
'key' => (string)$key,
|
||||
'value' => (string)$value,
|
||||
'is_preview' => false,
|
||||
'is_literal' => true,
|
||||
'is_multiline' => str_contains((string)$value, "\n"),
|
||||
'is_shown_once' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function deployResource(string $uuid, bool $force = false): array
|
||||
{
|
||||
$path = '/deploy?uuid=' . rawurlencode($uuid) . '&force=' . ($force ? 'true' : 'false');
|
||||
return $this->request('GET', $path);
|
||||
}
|
||||
|
||||
public function startService(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/services/' . rawurlencode($uuid) . '/start');
|
||||
}
|
||||
|
||||
public function restartService(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/services/' . rawurlencode($uuid) . '/restart');
|
||||
}
|
||||
|
||||
public function restartApplication(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
|
||||
}
|
||||
|
||||
public function deleteService(string $uuid): array
|
||||
{
|
||||
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
|
||||
}
|
||||
|
||||
public function listDeployments(): array
|
||||
{
|
||||
return $this->request('GET', '/deployments');
|
||||
}
|
||||
|
||||
protected function request(string $method, string $path, ?array $payload = null, bool $versionedApi = true): array
|
||||
{
|
||||
$url = ($versionedApi ? $this->baseUrl : $this->apiRootUrl()) . '/' . ltrim($path, '/');
|
||||
$curl = curl_init($url);
|
||||
if ($curl === false) {
|
||||
throw new RuntimeException('Could not initialize Coolify API request.');
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Accept: application/json',
|
||||
'Authorization: Bearer ' . $this->token,
|
||||
];
|
||||
|
||||
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, strtoupper($method));
|
||||
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, min(2, $this->timeoutSeconds));
|
||||
curl_setopt($curl, CURLOPT_TIMEOUT, $this->timeoutSeconds);
|
||||
curl_setopt($curl, CURLOPT_NOSIGNAL, true);
|
||||
curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
|
||||
|
||||
if ($payload !== null) {
|
||||
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($body === false) {
|
||||
throw new RuntimeException('Could not encode Coolify API payload.');
|
||||
}
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
|
||||
|
||||
$raw = curl_exec($curl);
|
||||
$error = curl_error($curl);
|
||||
$status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
curl_close($curl);
|
||||
|
||||
if ($raw === false) {
|
||||
throw new RuntimeException('Coolify API request failed: ' . $error);
|
||||
}
|
||||
|
||||
$decoded = null;
|
||||
if (trim((string)$raw) !== '') {
|
||||
$decoded = json_decode((string)$raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
$decoded = ['raw' => (string)$raw];
|
||||
}
|
||||
}
|
||||
|
||||
if ($status < 200 || $status >= 300) {
|
||||
$message = is_array($decoded)
|
||||
? (string)($decoded['message'] ?? $decoded['error'] ?? ('HTTP ' . $status))
|
||||
: ('HTTP ' . $status);
|
||||
if (is_array($decoded)) {
|
||||
$details = self::validationErrorSummary($decoded);
|
||||
if ($details !== '') {
|
||||
$message .= ': ' . $details;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException('Coolify API request failed: ' . $message);
|
||||
}
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private static function validationErrorSummary(array $decoded): string
|
||||
{
|
||||
$errors = $decoded['errors'] ?? $decoded['data']['errors'] ?? null;
|
||||
if (!is_array($errors)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
foreach ($errors as $field => $messages) {
|
||||
$fieldName = trim((string)$field);
|
||||
$fieldPrefix = $fieldName !== '' ? $fieldName . ': ' : '';
|
||||
if (is_array($messages)) {
|
||||
$messages = implode(', ', array_filter(array_map(static fn(mixed $message): string => trim((string)$message), $messages)));
|
||||
} else {
|
||||
$messages = trim((string)$messages);
|
||||
}
|
||||
if ($messages !== '') {
|
||||
$parts[] = $fieldPrefix . $messages;
|
||||
}
|
||||
}
|
||||
|
||||
return implode('; ', array_slice($parts, 0, 5));
|
||||
}
|
||||
|
||||
private function apiRootUrl(): string
|
||||
{
|
||||
return preg_replace('#/v[0-9]+$#i', '', $this->baseUrl) ?: $this->baseUrl;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Additive schema bootstrap for the Coolify infrastructure integration.
|
||||
*
|
||||
* The legacy stack has no centralized migration runner, so this class must be
|
||||
* safe to call from request handlers, cron, and tests.
|
||||
*/
|
||||
class coolify_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
private static ?bool $tablesExist = null;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
$queries = [
|
||||
"CREATE TABLE IF NOT EXISTS coolify_instances (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
label VARCHAR(128) NOT NULL,
|
||||
base_url VARCHAR(512) NOT NULL,
|
||||
api_token_secret TEXT NOT NULL,
|
||||
default_project_uuid VARCHAR(128) NULL,
|
||||
default_environment_uuid VARCHAR(128) NULL,
|
||||
default_environment_name VARCHAR(128) NULL,
|
||||
default_server_uuid VARCHAR(128) NULL,
|
||||
default_destination_uuid VARCHAR(128) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'unknown',
|
||||
last_checked_at DATETIME NULL,
|
||||
last_error TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
INDEX idx_coolify_instances_status (status),
|
||||
INDEX idx_coolify_instances_deleted_at (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS coolify_targets (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
instance_id BIGINT UNSIGNED NOT NULL,
|
||||
replication_host_id BIGINT UNSIGNED NULL,
|
||||
kind VARCHAR(16) NOT NULL,
|
||||
label VARCHAR(128) NOT NULL,
|
||||
role VARCHAR(16) NOT NULL DEFAULT 'replica',
|
||||
server_uuid VARCHAR(128) NULL,
|
||||
project_uuid VARCHAR(128) NULL,
|
||||
environment_uuid VARCHAR(128) NULL,
|
||||
environment_name VARCHAR(128) NULL,
|
||||
destination_uuid VARCHAR(128) NULL,
|
||||
resource_uuid VARCHAR(128) NULL,
|
||||
resource_type VARCHAR(32) NOT NULL DEFAULT 'service',
|
||||
resource_name VARCHAR(128) NULL,
|
||||
deployment_status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
availability_state VARCHAR(32) NOT NULL DEFAULT 'degraded',
|
||||
desired_compose_hash CHAR(64) NULL,
|
||||
last_reconcile_status VARCHAR(32) NULL,
|
||||
last_reconcile_json LONGTEXT NULL,
|
||||
last_reconciled_at DATETIME NULL,
|
||||
options_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
INDEX idx_coolify_targets_instance (instance_id),
|
||||
INDEX idx_coolify_targets_replication_host (replication_host_id),
|
||||
INDEX idx_coolify_targets_kind_status (kind, deployment_status),
|
||||
INDEX idx_coolify_targets_availability (availability_state),
|
||||
INDEX idx_coolify_targets_resource_uuid (resource_uuid),
|
||||
INDEX idx_coolify_targets_deleted_at (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS coolify_operations (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
target_id BIGINT UNSIGNED NULL,
|
||||
instance_id BIGINT UNSIGNED NULL,
|
||||
operation VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'running',
|
||||
guarded TINYINT(1) NOT NULL DEFAULT 1,
|
||||
message VARCHAR(512) NULL,
|
||||
error_message TEXT NULL,
|
||||
context_json LONGTEXT NULL,
|
||||
actor_user_id INT NULL,
|
||||
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_coolify_operations_target (target_id),
|
||||
INDEX idx_coolify_operations_instance (instance_id),
|
||||
INDEX idx_coolify_operations_status (status),
|
||||
INDEX idx_coolify_operations_operation (operation)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS coolify_audit_logs (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
target_id BIGINT UNSIGNED NULL,
|
||||
instance_id BIGINT UNSIGNED NULL,
|
||||
replication_host_id BIGINT UNSIGNED NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
actor_user_id INT NULL,
|
||||
severity VARCHAR(16) NOT NULL DEFAULT 'info',
|
||||
context_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_coolify_audit_target (target_id),
|
||||
INDEX idx_coolify_audit_instance (instance_id),
|
||||
INDEX idx_coolify_audit_replication_host (replication_host_id),
|
||||
INDEX idx_coolify_audit_action (action),
|
||||
INDEX idx_coolify_audit_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS coolify_instance_gateways (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
instance_id BIGINT UNSIGNED NULL,
|
||||
hostname VARCHAR(255) NOT NULL,
|
||||
target_ip VARCHAR(64) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
priority INT NOT NULL DEFAULT 100,
|
||||
health_state VARCHAR(32) NOT NULL DEFAULT 'unknown',
|
||||
lb_state VARCHAR(32) NOT NULL DEFAULT 'unknown',
|
||||
last_probe_json LONGTEXT NULL,
|
||||
last_probed_at DATETIME NULL,
|
||||
last_reconciled_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
UNIQUE KEY uniq_coolify_instance_gateways_target_ip (target_ip),
|
||||
INDEX idx_coolify_instance_gateways_instance (instance_id),
|
||||
INDEX idx_coolify_instance_gateways_enabled (enabled),
|
||||
INDEX idx_coolify_instance_gateways_lb_state (lb_state),
|
||||
INDEX idx_coolify_instance_gateways_deleted_at (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $sql) {
|
||||
$db->query($sql);
|
||||
}
|
||||
|
||||
self::ensureColumn('coolify_instances', 'default_destination_uuid', 'VARCHAR(128) NULL');
|
||||
self::ensureColumn('coolify_targets', 'availability_state', "VARCHAR(32) NOT NULL DEFAULT 'degraded'");
|
||||
self::ensureColumn('coolify_targets', 'desired_compose_hash', 'CHAR(64) NULL');
|
||||
self::ensureColumn('coolify_targets', 'last_reconcile_json', 'LONGTEXT NULL');
|
||||
self::ensureColumn('coolify_operations', 'guarded', 'TINYINT(1) NOT NULL DEFAULT 1');
|
||||
self::ensureColumn('coolify_instance_gateways', 'last_reconciled_at', 'DATETIME NULL');
|
||||
|
||||
self::ensureModuleConfigDefault('Coolify', 'enabled', 'false', 'bool');
|
||||
self::ensureModuleConfigDefault('Coolify', 'lb_automation_enabled', 'false', 'bool');
|
||||
self::ensureModuleConfigDefault('Coolify', 'lb_automation_mode', 'report_only', 'string');
|
||||
self::ensureModuleConfigDefault('Coolify', 'hetzner_load_balancer_id', '', 'string');
|
||||
self::ensureModuleConfigDefault('Coolify', 'hetzner_cloud_api_token', '', 'string');
|
||||
self::ensureModuleConfigDefault('Coolify', 'public_gateway_host', 'api-v2.truckwash.io', 'string');
|
||||
self::ensureModuleConfigDefault('Coolify', 'public_gateway_probe_path', '', 'string');
|
||||
|
||||
self::ensureDefaultGateway('node1.truckwash.io', '94.130.142.41', 10);
|
||||
self::ensureDefaultGateway('node2.truckwash.io', '65.21.214.30', 20);
|
||||
self::ensureDefaultGateway('node3.truckwash.io', '23.88.23.183', 30);
|
||||
|
||||
self::$initialized = true;
|
||||
self::$tablesExist = true;
|
||||
}
|
||||
|
||||
public static function tablesExist(): bool
|
||||
{
|
||||
if (self::$tablesExist !== null) {
|
||||
return self::$tablesExist;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
foreach (['coolify_instances', 'coolify_targets', 'coolify_operations', 'coolify_audit_logs', 'coolify_instance_gateways'] as $table) {
|
||||
$tableSql = $db->escape_string($table);
|
||||
$result = $db->query("SHOW TABLES LIKE '$tableSql'");
|
||||
if ($result === false || $result->num_rows === 0) {
|
||||
self::$tablesExist = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
self::$tablesExist = true;
|
||||
return self::$tablesExist;
|
||||
}
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||
if ($table === '' || $column === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
|
||||
}
|
||||
|
||||
private static function ensureModuleConfigDefault(string $module, string $variable, string $value, string $type): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$moduleSql = $db->escape_string($module);
|
||||
$variableSql = $db->escape_string($variable);
|
||||
$result = $db->query("SELECT value FROM module_config WHERE module = '$moduleSql' AND variable = '$variableSql' LIMIT 1");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$valueSql = $db->escape_string($value);
|
||||
$typeSql = $db->escape_string($type);
|
||||
$db->query("INSERT INTO module_config (module, variable, value, type) VALUES ('$moduleSql', '$variableSql', '$valueSql', '$typeSql')");
|
||||
}
|
||||
|
||||
private static function ensureDefaultGateway(string $hostname, string $targetIp, int $priority): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$targetIpSql = $db->escape_string($targetIp);
|
||||
$result = $db->query("SELECT id FROM coolify_instance_gateways WHERE target_ip = '$targetIpSql' LIMIT 1");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hostnameSql = $db->escape_string($hostname);
|
||||
$db->query(
|
||||
"INSERT INTO coolify_instance_gateways (hostname, target_ip, enabled, priority)
|
||||
VALUES ('$hostnameSql', '$targetIpSql', 1, " . (int)$priority . ")"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class cors_policy
|
||||
{
|
||||
public const ALLOWED_HEADERS = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
|
||||
public const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
|
||||
public const MAX_AGE_SECONDS = '86400';
|
||||
|
||||
private const REQUIRED_ALLOWED_ORIGINS = [
|
||||
'https://truckwash.io',
|
||||
'https://www.truckwash.io',
|
||||
'https://api.truckwash.io',
|
||||
'https://api.truckwash.io:4433',
|
||||
'https://api-v2.truckwash.io',
|
||||
'https://web.truckwash.dk',
|
||||
'https://api.truckwash.dk',
|
||||
'https://truckwash.dk',
|
||||
'https://www.truckwash.dk',
|
||||
'https://staging.truckwash.io',
|
||||
'http://localhost',
|
||||
'https://localhost',
|
||||
'http://localhost:4433',
|
||||
'https://localhost:4433',
|
||||
'https://twdev.jeppeb.dk',
|
||||
'http://localhost:5173',
|
||||
];
|
||||
|
||||
public static function normalizeOrigin(?string $value): string
|
||||
{
|
||||
$value = trim((string)$value);
|
||||
if ($value === '' || $value === '*') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (preg_match('#^https?://#i', $value) !== 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parts = parse_url($value);
|
||||
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$scheme = strtolower((string)$parts['scheme']);
|
||||
if (!in_array($scheme, ['http', 'https'], true)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$host = strtolower((string)$parts['host']);
|
||||
$port = isset($parts['port']) ? ':' . (int)$parts['port'] : '';
|
||||
|
||||
return $scheme . '://' . $host . $port;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public static function requiredAllowedOrigins(): array
|
||||
{
|
||||
return self::REQUIRED_ALLOWED_ORIGINS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public static function allowedOrigins(string $corsConfig): array
|
||||
{
|
||||
$origins = [];
|
||||
foreach (self::splitOrigins($corsConfig) as $configuredOrigin) {
|
||||
if ($configuredOrigin === '*') {
|
||||
return ['*'];
|
||||
}
|
||||
|
||||
$origin = self::normalizeOrigin($configuredOrigin);
|
||||
if ($origin !== '') {
|
||||
$origins[$origin] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (self::REQUIRED_ALLOWED_ORIGINS as $requiredOrigin) {
|
||||
$origin = self::normalizeOrigin($requiredOrigin);
|
||||
if ($origin !== '') {
|
||||
$origins[$origin] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($origins);
|
||||
}
|
||||
|
||||
public static function withRequiredOrigins(string $corsConfig): string
|
||||
{
|
||||
$allowedOrigins = self::allowedOrigins($corsConfig);
|
||||
if ($allowedOrigins === ['*']) {
|
||||
return '*';
|
||||
}
|
||||
|
||||
return implode(',', $allowedOrigins);
|
||||
}
|
||||
|
||||
public static function isOriginAllowed(?string $origin, string $corsConfig): bool
|
||||
{
|
||||
$origin = self::normalizeOrigin($origin);
|
||||
if ($origin === '' || $origin === '*') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowedOrigins = self::allowedOrigins($corsConfig);
|
||||
return in_array('*', $allowedOrigins, true) || in_array($origin, $allowedOrigins, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public static function responseHeaders(?string $origin, string $corsConfig): array
|
||||
{
|
||||
$origin = self::normalizeOrigin($origin);
|
||||
if ($origin === '' || !self::isOriginAllowed($origin, $corsConfig)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'Access-Control-Allow-Origin' => $origin,
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
|
||||
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
|
||||
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
|
||||
'Vary' => 'Origin',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{allowed:bool,status:int,headers:array<string,string>,body:string}
|
||||
*/
|
||||
public static function preflightResponse(?string $origin, string $corsConfig): array
|
||||
{
|
||||
$headers = self::responseHeaders($origin, $corsConfig);
|
||||
if ($headers === []) {
|
||||
return [
|
||||
'allowed' => false,
|
||||
'status' => 403,
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'body' => json_encode(['success' => false, 'message' => 'CORS origin not allowed']) ?: '',
|
||||
];
|
||||
}
|
||||
|
||||
$headers['Content-Type'] = 'application/json';
|
||||
return [
|
||||
'allowed' => true,
|
||||
'status' => 200,
|
||||
'headers' => $headers,
|
||||
'body' => '',
|
||||
];
|
||||
}
|
||||
|
||||
public static function applyResponseHeaders(string $corsConfig, ?string $origin = null): bool
|
||||
{
|
||||
$headers = self::responseHeaders($origin ?? ($_SERVER['HTTP_ORIGIN'] ?? ''), $corsConfig);
|
||||
if ($headers === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self::emitHeaders($headers);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $headers
|
||||
*/
|
||||
public static function emitHeaders(array $headers): void
|
||||
{
|
||||
foreach ($headers as $name => $value) {
|
||||
header($name . ': ' . $value, strtolower((string)$name) !== 'vary');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private static function splitOrigins(string $corsConfig): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
array_map('trim', explode(',', $corsConfig)),
|
||||
static fn(string $origin): bool => $origin !== ''
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -9,19 +9,86 @@ class customer_name_cache_payload_builder
|
||||
*/
|
||||
public static function build(mixed $cached_name, ?string $fallback_name): ?array
|
||||
{
|
||||
if (
|
||||
is_object($cached_name)
|
||||
&& isset($cached_name->name)
|
||||
&& is_string($cached_name->name)
|
||||
&& trim($cached_name->name) !== ''
|
||||
) {
|
||||
return ['name' => $cached_name->name];
|
||||
$cached_name = self::normalizePayload($cached_name);
|
||||
$name = self::extractName($cached_name);
|
||||
if ($name !== null) {
|
||||
return ['name' => $name];
|
||||
}
|
||||
|
||||
if ($fallback_name !== null && trim($fallback_name) !== '') {
|
||||
$fallback_name = self::normalizeName($fallback_name);
|
||||
if ($fallback_name !== null) {
|
||||
return ['name' => $fallback_name];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function normalizePayload(mixed $payload): mixed
|
||||
{
|
||||
if (!is_string($payload)) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$trimmed = trim($payload);
|
||||
if ($trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($trimmed);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
private static function extractName(mixed $payload): ?string
|
||||
{
|
||||
if (is_string($payload)) {
|
||||
return self::normalizeName($payload);
|
||||
}
|
||||
|
||||
if (!is_object($payload) && !is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (['name', 'customerName', 'customer_name', 'displayName', 'display_name'] as $key) {
|
||||
$name = self::normalizeName(self::payloadValue($payload, $key));
|
||||
if ($name !== null) {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['customer', 'data', 'economic_customer'] as $key) {
|
||||
$name = self::extractName(self::payloadValue($payload, $key));
|
||||
if ($name !== null) {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function payloadValue(mixed $payload, string $key): mixed
|
||||
{
|
||||
if (is_object($payload) && property_exists($payload, $key)) {
|
||||
return $payload->{$key};
|
||||
}
|
||||
|
||||
if (is_array($payload) && array_key_exists($key, $payload)) {
|
||||
return $payload[$key];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function normalizeName(mixed $name): ?string
|
||||
{
|
||||
if (!is_string($name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = trim($name);
|
||||
return $name === '' ? null : $name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,14 @@ class db
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
$this->conn->close();
|
||||
if (!isset($this->conn)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->conn->close();
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
public function get(string $table, int $id)
|
||||
|
||||
@@ -30,6 +30,7 @@ use interfaces\economic_i;
|
||||
class economic implements economic_i
|
||||
{
|
||||
public const DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE = 'Transactions for the configured draft customer cannot be exported to e-conomic.';
|
||||
public const DEFAULT_DISTRIBUTION_DEPARTMENT_ID = 1;
|
||||
|
||||
/**
|
||||
* Configuration of the economic module
|
||||
@@ -133,6 +134,14 @@ class economic implements economic_i
|
||||
return $customer_number > 0 ? $customer_number : null;
|
||||
}
|
||||
|
||||
public function getDefaultDistributionDepartmentId(): int
|
||||
{
|
||||
$value = $this->config->default_department_id->getVariableValue();
|
||||
$department_id = (int)$value;
|
||||
|
||||
return $department_id > 0 ? $department_id : self::DEFAULT_DISTRIBUTION_DEPARTMENT_ID;
|
||||
}
|
||||
|
||||
public function isDraftCustomerNumber(?int $customer_number): bool
|
||||
{
|
||||
$configured_customer_number = $this->getTransactionDraftCustomerNumber();
|
||||
@@ -156,9 +165,17 @@ class economic implements economic_i
|
||||
/**
|
||||
* Create a customer in e-conomic and return the raw upstream payload.
|
||||
*/
|
||||
public function createCustomer(int $customer_number, string $name, int $cvr_number, string $email, int $phone): object
|
||||
public function createCustomer(
|
||||
int $customer_number,
|
||||
string $name,
|
||||
int $cvr_number,
|
||||
string $email,
|
||||
int $phone,
|
||||
?int $mobile_phone = null,
|
||||
object|array|null $company_information = null
|
||||
): object
|
||||
{
|
||||
return $this->customers->customers->create([
|
||||
$payload = [
|
||||
'customerNumber' => $customer_number,
|
||||
'corporateIdentificationNumber' => (string)$cvr_number,
|
||||
'customerGroup' => [
|
||||
@@ -170,11 +187,60 @@ class economic implements economic_i
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'phone' => $phone,
|
||||
'telephoneAndFaxNumber' => (string)$phone,
|
||||
'mobilePhone' => (string)($mobile_phone ?? $phone),
|
||||
'currency' => 'DKK',
|
||||
'vatZone' => [
|
||||
'vatZoneNumber' => 1,
|
||||
]
|
||||
]);
|
||||
];
|
||||
|
||||
$payload = array_replace($payload, $this->buildCustomerPayloadFromCompanyInformation($company_information));
|
||||
|
||||
return $this->customers->customers->create($payload);
|
||||
}
|
||||
|
||||
private function buildCustomerPayloadFromCompanyInformation(object|array|null $company_information): array
|
||||
{
|
||||
if ($company_information === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$payload = [];
|
||||
$field_map = [
|
||||
'address' => 'address',
|
||||
'zipcode' => 'zip',
|
||||
'city' => 'city',
|
||||
'website' => 'website',
|
||||
];
|
||||
|
||||
foreach ($field_map as $source_field => $economic_field) {
|
||||
$value = $this->companyInformationValue($company_information, $source_field);
|
||||
if ($value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload[$economic_field] = $value;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function companyInformationValue(object|array $company_information, string $field): ?string
|
||||
{
|
||||
if (is_array($company_information)) {
|
||||
$value = $company_information[$field] ?? null;
|
||||
} else {
|
||||
$value = $company_information->{$field} ?? null;
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = trim((string)$value);
|
||||
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -892,6 +892,7 @@ class economic_v2_distribution_service
|
||||
|
||||
$weight_total = array_sum($eligible_weights);
|
||||
if (abs($weight_total) <= self::EPSILON) {
|
||||
$fallback_department_id = $this->getFallbackDistributionDepartmentId();
|
||||
$warnings[] = 'Booked department 75 '
|
||||
. $source_category
|
||||
. ' amount for customer '
|
||||
@@ -900,11 +901,15 @@ class economic_v2_distribution_service
|
||||
. $month_key
|
||||
. ' on invoice(s) '
|
||||
. $invoice_ids
|
||||
. ' has no redistribution basis and remains undistributed.';
|
||||
. ' has no redistribution basis and was assigned to fallback department '
|
||||
. $fallback_department_id
|
||||
. '.';
|
||||
|
||||
return [
|
||||
'department_distribution' => [],
|
||||
'undistributed_net_amount' => $booked_amount,
|
||||
'department_distribution' => [
|
||||
$fallback_department_id => $booked_amount,
|
||||
],
|
||||
'undistributed_net_amount' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1061,6 +1066,9 @@ class economic_v2_distribution_service
|
||||
} elseif (!$this->isOrderEligible($order)) {
|
||||
continue;
|
||||
}
|
||||
$distribution_department_id = $system_order_fallback
|
||||
? $this->getFallbackDistributionDepartmentId()
|
||||
: $department_id;
|
||||
|
||||
$fixed_version = $this->versioning->resolveFixedPricingVersionAt($customer_number, $created_at);
|
||||
if ($fixed_version === null) {
|
||||
@@ -1090,14 +1098,14 @@ class economic_v2_distribution_service
|
||||
$order_original_price = $this->calculateOrderOriginalPrice(
|
||||
$order_items_by_order_id[$order_id] ?? [],
|
||||
$customer_number,
|
||||
$department_id,
|
||||
$distribution_department_id,
|
||||
$created_at
|
||||
);
|
||||
$groups[$group_key]['original_price'] += $order_original_price;
|
||||
if (!isset($groups[$group_key]['department_totals'][$department_id])) {
|
||||
$groups[$group_key]['department_totals'][$department_id] = 0.0;
|
||||
if (!isset($groups[$group_key]['department_totals'][$distribution_department_id])) {
|
||||
$groups[$group_key]['department_totals'][$distribution_department_id] = 0.0;
|
||||
}
|
||||
$groups[$group_key]['department_totals'][$department_id] += $order_original_price;
|
||||
$groups[$group_key]['department_totals'][$distribution_department_id] += $order_original_price;
|
||||
$groups[$group_key]['order_ids'][] = $order_id;
|
||||
|
||||
if (!isset($customer_transactions[$customer_number][$order_id])) {
|
||||
@@ -1141,12 +1149,15 @@ class economic_v2_distribution_service
|
||||
} elseif (!$this->isOrderEligible($order)) {
|
||||
continue;
|
||||
}
|
||||
$distribution_department_id = $system_order_fallback
|
||||
? $this->getFallbackDistributionDepartmentId()
|
||||
: $department_id;
|
||||
|
||||
$month_key = substr($created_at, 0, 7);
|
||||
if (!isset($customer_department_month_map[$customer_number][$month_key][$department_id])) {
|
||||
$customer_department_month_map[$customer_number][$month_key][$department_id] = 0;
|
||||
if (!isset($customer_department_month_map[$customer_number][$month_key][$distribution_department_id])) {
|
||||
$customer_department_month_map[$customer_number][$month_key][$distribution_department_id] = 0;
|
||||
}
|
||||
$customer_department_month_map[$customer_number][$month_key][$department_id]++;
|
||||
$customer_department_month_map[$customer_number][$month_key][$distribution_department_id]++;
|
||||
|
||||
$candidates = $this->buildWashSubscriptionCandidates(
|
||||
$order,
|
||||
@@ -1193,10 +1204,10 @@ class economic_v2_distribution_service
|
||||
];
|
||||
}
|
||||
|
||||
if (!isset($groups[$group_key]['distribution'][$department_id])) {
|
||||
$groups[$group_key]['distribution'][$department_id] = 0;
|
||||
if (!isset($groups[$group_key]['distribution'][$distribution_department_id])) {
|
||||
$groups[$group_key]['distribution'][$distribution_department_id] = 0;
|
||||
}
|
||||
$groups[$group_key]['distribution'][$department_id]++;
|
||||
$groups[$group_key]['distribution'][$distribution_department_id]++;
|
||||
$groups[$group_key]['order_ids'][] = $order_id;
|
||||
$matched_order = true;
|
||||
}
|
||||
@@ -1394,6 +1405,9 @@ class economic_v2_distribution_service
|
||||
): array {
|
||||
$distribution = [];
|
||||
$department_counts = $customer_department_month_map[$customer_number][$month_key] ?? [];
|
||||
if (!empty($department_counts)) {
|
||||
$department_counts = $this->normalizeFallbackDepartmentCounts($department_counts);
|
||||
}
|
||||
if (!empty($department_counts)) {
|
||||
$total = (float)array_sum($department_counts);
|
||||
foreach ($department_counts as $department_id => $count) {
|
||||
@@ -1402,20 +1416,61 @@ class economic_v2_distribution_service
|
||||
return $distribution;
|
||||
}
|
||||
|
||||
$default_department = 1;
|
||||
try {
|
||||
$default = (new users_o())->getUserByCustomerNumber($customer_number)->getDefaultDepartment();
|
||||
if (!empty($default)) {
|
||||
$default_department = (int)$default;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// fall back to department 1
|
||||
}
|
||||
$customer_default_department_id = $this->getCustomerDefaultDepartmentId($customer_number);
|
||||
$default_department = $customer_default_department_id !== null && $customer_default_department_id > 0
|
||||
? $customer_default_department_id
|
||||
: $this->getFallbackDistributionDepartmentId();
|
||||
|
||||
$distribution[$default_department] = $monthly_price;
|
||||
return $distribution;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string,int|float> $department_counts
|
||||
* @return array<int,int|float>
|
||||
*/
|
||||
private function normalizeFallbackDepartmentCounts(array $department_counts): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($department_counts as $department_id => $count) {
|
||||
$department_id = (int)$department_id;
|
||||
if ($department_id === self::SYSTEM_ORDER_DEPARTMENT_ID || $department_id <= 0) {
|
||||
$department_id = $this->getFallbackDistributionDepartmentId();
|
||||
}
|
||||
if (!isset($normalized[$department_id])) {
|
||||
$normalized[$department_id] = 0;
|
||||
}
|
||||
$normalized[$department_id] += $count;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
protected function getCustomerDefaultDepartmentId(int $customer_number): ?int
|
||||
{
|
||||
try {
|
||||
$default = (new users_o())->getUserByCustomerNumber($customer_number)->getDefaultDepartment();
|
||||
return !empty($default) ? (int)$default : null;
|
||||
} catch (Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getFallbackDistributionDepartmentId(): int
|
||||
{
|
||||
try {
|
||||
$department_id = (new economic())->getDefaultDistributionDepartmentId();
|
||||
} catch (\Throwable $e) {
|
||||
$department_id = economic::DEFAULT_DISTRIBUTION_DEPARTMENT_ID;
|
||||
}
|
||||
|
||||
if ($department_id <= 0 || $department_id === self::SYSTEM_ORDER_DEPARTMENT_ID) {
|
||||
return economic::DEFAULT_DISTRIBUTION_DEPARTMENT_ID;
|
||||
}
|
||||
|
||||
return $department_id;
|
||||
}
|
||||
|
||||
private function normalizeSubscriptionGroupAllocation(array $distribution, float $monthly_price): array
|
||||
{
|
||||
if (empty($distribution)) {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class error_report_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
private static ?bool $tablesExist = null;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
$db->query("CREATE TABLE IF NOT EXISTS error_reports (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'open',
|
||||
reporter_type VARCHAR(16) NOT NULL,
|
||||
reporter_user_id INT NULL,
|
||||
reporter_subuser_id INT NULL,
|
||||
reporter_customer_number INT NULL,
|
||||
reporter_customer_number_context INT NULL,
|
||||
reporter_name VARCHAR(255) NULL,
|
||||
reporter_email VARCHAR(255) NULL,
|
||||
route_path VARCHAR(512) NULL,
|
||||
page_url VARCHAR(1024) NULL,
|
||||
release_trace_id VARCHAR(64) NULL,
|
||||
frontend_version VARCHAR(128) NULL,
|
||||
api_version VARCHAR(128) NULL,
|
||||
screenshot_object_key VARCHAR(512) NOT NULL,
|
||||
screenshot_mime_type VARCHAR(64) NOT NULL,
|
||||
screenshot_size_bytes INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
before_error TEXT NOT NULL,
|
||||
expected TEXT NOT NULL,
|
||||
actual TEXT NOT NULL,
|
||||
request_error_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
vue_error_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
request_errors_json LONGTEXT NULL,
|
||||
vue_errors_json LONGTEXT NULL,
|
||||
runtime_context_json LONGTEXT NULL,
|
||||
data_collection_accepted TINYINT(1) NOT NULL DEFAULT 0,
|
||||
data_collection_accepted_at DATETIME NOT NULL,
|
||||
data_collection_policy_version VARCHAR(64) NOT NULL DEFAULT 'error-report-v1',
|
||||
resolved_at DATETIME NULL,
|
||||
resolved_by_user_id INT NULL,
|
||||
resolution_note TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_error_reports_status_created (status, created_at),
|
||||
INDEX idx_error_reports_reporter_user (reporter_user_id, created_at),
|
||||
INDEX idx_error_reports_reporter_subuser (reporter_subuser_id, created_at),
|
||||
INDEX idx_error_reports_customer (reporter_customer_number, reporter_customer_number_context),
|
||||
INDEX idx_error_reports_trace (release_trace_id),
|
||||
INDEX idx_error_reports_route (route_path)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||||
|
||||
self::$initialized = true;
|
||||
self::$tablesExist = true;
|
||||
}
|
||||
|
||||
public static function tablesExist(): bool
|
||||
{
|
||||
if (self::$tablesExist !== null) {
|
||||
return self::$tablesExist;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
$result = $db->query("SHOW TABLES LIKE 'error_reports'");
|
||||
self::$tablesExist = $result !== false && $result->num_rows > 0;
|
||||
return self::$tablesExist;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class error_report_service
|
||||
{
|
||||
private const ANSWER_MAX_LENGTH = 4000;
|
||||
private const NOTE_MAX_LENGTH = 2000;
|
||||
private const SCREENSHOT_MAX_BYTES = 6_000_000;
|
||||
private const JSON_MAX_LENGTH = 200_000;
|
||||
private const STATUS_OPEN = 'open';
|
||||
private const STATUS_RESOLVED = 'resolved';
|
||||
|
||||
private bool $schemaEnsured = false;
|
||||
private error_report_store $store;
|
||||
|
||||
public function __construct(?error_report_store $store = null)
|
||||
{
|
||||
$this->store = $store ?? new error_report_store();
|
||||
}
|
||||
|
||||
public static function redactPayload(mixed $value, int $depth = 0): mixed
|
||||
{
|
||||
if ($depth > 8) {
|
||||
return '[depth-limit]';
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
$redacted = [];
|
||||
$index = 0;
|
||||
foreach ($value as $key => $item) {
|
||||
$index++;
|
||||
if ($index > 80) {
|
||||
$redacted['[truncated]'] = 'More than 80 keys omitted.';
|
||||
break;
|
||||
}
|
||||
|
||||
$keyString = (string)$key;
|
||||
if (preg_match('/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn/i', $keyString) === 1) {
|
||||
$redacted[$key] = '[redacted]';
|
||||
continue;
|
||||
}
|
||||
|
||||
$redacted[$key] = self::redactPayload($item, $depth + 1);
|
||||
}
|
||||
return $redacted;
|
||||
}
|
||||
|
||||
if (is_object($value)) {
|
||||
return self::redactPayload((array)$value, $depth + 1);
|
||||
}
|
||||
|
||||
if (is_string($value) && strlen($value) > 4000) {
|
||||
return substr($value, 0, 4000) . "\n... [truncated]";
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function decodeScreenshotDataUri(string $dataUri): array
|
||||
{
|
||||
if (!preg_match('/^data:(image\/(?:png|jpeg|webp));base64,([a-zA-Z0-9+\/=\r\n]+)$/', trim($dataUri), $matches)) {
|
||||
throw new RuntimeException('Screenshot must be a PNG, JPEG, or WebP data URI.');
|
||||
}
|
||||
|
||||
$contents = base64_decode(preg_replace('/\s+/', '', $matches[2]) ?? '', true);
|
||||
if ($contents === false || $contents === '') {
|
||||
throw new RuntimeException('Screenshot could not be decoded.');
|
||||
}
|
||||
|
||||
if (strlen($contents) > self::SCREENSHOT_MAX_BYTES) {
|
||||
throw new RuntimeException('Screenshot is too large.');
|
||||
}
|
||||
|
||||
return [
|
||||
'mime_type' => $matches[1],
|
||||
'contents' => $contents,
|
||||
'size_bytes' => strlen($contents),
|
||||
];
|
||||
}
|
||||
|
||||
public function createFromCurrentPrincipal(array $payload): array
|
||||
{
|
||||
$this->ensureSchema();
|
||||
$principal = $this->resolvePrincipal();
|
||||
$answers = $this->validatedAnswers($payload);
|
||||
|
||||
if (!$this->acceptedDataCollection($payload['data_collection_accepted'] ?? null)) {
|
||||
throw new RuntimeException('Data collection acceptance is required.');
|
||||
}
|
||||
|
||||
$screenshot = self::decodeScreenshotDataUri((string)($payload['screenshot'] ?? ''));
|
||||
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
|
||||
$context = is_array($payload['context'] ?? null) ? $payload['context'] : [];
|
||||
$requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25);
|
||||
$vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25);
|
||||
$runtimeContext = $this->runtimeContext($payload, $context);
|
||||
|
||||
$this->execute(
|
||||
"INSERT INTO error_reports (
|
||||
status,
|
||||
reporter_type,
|
||||
reporter_user_id,
|
||||
reporter_subuser_id,
|
||||
reporter_customer_number,
|
||||
reporter_customer_number_context,
|
||||
reporter_name,
|
||||
reporter_email,
|
||||
route_path,
|
||||
page_url,
|
||||
release_trace_id,
|
||||
frontend_version,
|
||||
api_version,
|
||||
screenshot_object_key,
|
||||
screenshot_mime_type,
|
||||
screenshot_size_bytes,
|
||||
before_error,
|
||||
expected,
|
||||
actual,
|
||||
request_error_count,
|
||||
vue_error_count,
|
||||
request_errors_json,
|
||||
vue_errors_json,
|
||||
runtime_context_json,
|
||||
data_collection_accepted,
|
||||
data_collection_accepted_at,
|
||||
data_collection_policy_version
|
||||
) VALUES (
|
||||
'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, NOW(), ?
|
||||
)",
|
||||
'siiiisssssssssisssiissss',
|
||||
[
|
||||
$principal['type'],
|
||||
$principal['user_id'],
|
||||
$principal['subuser_id'],
|
||||
$principal['customer_number'],
|
||||
$principal['customer_number_context'],
|
||||
$principal['name'],
|
||||
$principal['email'],
|
||||
$runtimeContext['route_path'],
|
||||
$runtimeContext['page_url'],
|
||||
$runtimeContext['release_trace_id'],
|
||||
$runtimeContext['frontend_version'],
|
||||
$runtimeContext['api_version'],
|
||||
$storedScreenshot['key'],
|
||||
$storedScreenshot['mime_type'],
|
||||
(int)$storedScreenshot['size_bytes'],
|
||||
$answers['before_error'],
|
||||
$answers['expected'],
|
||||
$answers['actual'],
|
||||
count($requestErrors),
|
||||
count($vueErrors),
|
||||
$this->jsonEncodeLimited(self::redactPayload($requestErrors)),
|
||||
$this->jsonEncodeLimited(self::redactPayload($vueErrors)),
|
||||
$this->jsonEncodeLimited(self::redactPayload($runtimeContext)),
|
||||
$runtimeContext['data_collection_policy_version'],
|
||||
]
|
||||
);
|
||||
|
||||
return $this->get($this->insertId());
|
||||
}
|
||||
|
||||
public function list(array $filters = []): array
|
||||
{
|
||||
$this->ensureSchema();
|
||||
|
||||
$where = ['1 = 1'];
|
||||
$types = '';
|
||||
$params = [];
|
||||
$status = $this->statusFilter($filters['status'] ?? self::STATUS_OPEN);
|
||||
if ($status !== 'all') {
|
||||
$where[] = 'status = ?';
|
||||
$types .= 's';
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
$search = trim((string)($filters['q'] ?? $filters['search'] ?? ''));
|
||||
if ($search !== '') {
|
||||
$where[] = '(route_path LIKE ? OR page_url LIKE ? OR before_error LIKE ? OR actual LIKE ? OR reporter_name LIKE ? OR reporter_email LIKE ?)';
|
||||
$types .= 'ssssss';
|
||||
$like = '%' . $search . '%';
|
||||
array_push($params, $like, $like, $like, $like, $like, $like);
|
||||
}
|
||||
|
||||
$limit = min(200, max(1, (int)($filters['limit'] ?? 50)));
|
||||
$offset = max(0, (int)($filters['offset'] ?? 0));
|
||||
$types .= 'ii';
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
|
||||
$items = $this->selectRows(
|
||||
"SELECT id, status, reporter_type, reporter_user_id, reporter_subuser_id,
|
||||
reporter_customer_number, reporter_customer_number_context, reporter_name, reporter_email,
|
||||
route_path, page_url, release_trace_id, frontend_version, api_version,
|
||||
screenshot_mime_type, screenshot_size_bytes, before_error, expected, actual,
|
||||
request_error_count, vue_error_count, resolved_at, resolved_by_user_id, created_at, updated_at
|
||||
FROM error_reports
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?",
|
||||
$types,
|
||||
$params
|
||||
);
|
||||
|
||||
return [
|
||||
'items' => array_map(fn(array $row): array => $this->publicReport($row, false), $items),
|
||||
'counts' => $this->counts(),
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
];
|
||||
}
|
||||
|
||||
public function get(int $id): array
|
||||
{
|
||||
$this->ensureSchema();
|
||||
$row = $this->selectOne('SELECT * FROM error_reports WHERE id = ? LIMIT 1', 'i', [$id]);
|
||||
if ($row === null) {
|
||||
throw new RuntimeException('Error report not found.');
|
||||
}
|
||||
|
||||
return $this->publicReport($row, true);
|
||||
}
|
||||
|
||||
public function updateStatus(int $id, string $status, ?string $resolutionNote, ?int $actorUserId): array
|
||||
{
|
||||
$this->ensureSchema();
|
||||
$status = self::normalizeStatus($status);
|
||||
$note = $resolutionNote !== null ? $this->trimmedString($resolutionNote, self::NOTE_MAX_LENGTH, false) : null;
|
||||
|
||||
if ($status === self::STATUS_RESOLVED) {
|
||||
$this->execute(
|
||||
'UPDATE error_reports SET status = ?, resolved_at = NOW(), resolved_by_user_id = ?, resolution_note = ? WHERE id = ?',
|
||||
'sisi',
|
||||
[$status, $actorUserId, $note, $id]
|
||||
);
|
||||
} else {
|
||||
$this->execute(
|
||||
'UPDATE error_reports SET status = ?, resolved_at = NULL, resolved_by_user_id = NULL, resolution_note = ? WHERE id = ?',
|
||||
'ssi',
|
||||
[$status, $note, $id]
|
||||
);
|
||||
}
|
||||
|
||||
return $this->get($id);
|
||||
}
|
||||
|
||||
public static function normalizeStatus(string $status): string
|
||||
{
|
||||
$status = strtolower(trim($status));
|
||||
if (!in_array($status, [self::STATUS_OPEN, self::STATUS_RESOLVED], true)) {
|
||||
throw new RuntimeException('Invalid error report status.');
|
||||
}
|
||||
return $status;
|
||||
}
|
||||
|
||||
private function validatedAnswers(array $payload): array
|
||||
{
|
||||
return [
|
||||
'before_error' => $this->requiredAnswer($payload, ['before_error', 'what_were_you_doing_before_error_occurred']),
|
||||
'expected' => $this->requiredAnswer($payload, ['expected', 'what_did_you_expect_would_happen']),
|
||||
'actual' => $this->requiredAnswer($payload, ['actual', 'what_actually_happened']),
|
||||
];
|
||||
}
|
||||
|
||||
private function requiredAnswer(array $payload, array $keys): string
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
if (array_key_exists($key, $payload)) {
|
||||
return $this->trimmedString((string)$payload[$key], self::ANSWER_MAX_LENGTH, true);
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('Missing required answer.');
|
||||
}
|
||||
|
||||
private function trimmedString(string $value, int $maxLength, bool $required): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($required && $value === '') {
|
||||
throw new RuntimeException('Required text fields must not be empty.');
|
||||
}
|
||||
|
||||
if (strlen($value) > $maxLength) {
|
||||
return substr($value, 0, $maxLength);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function acceptedDataCollection(mixed $value): bool
|
||||
{
|
||||
return $value === true || $value === 1 || $value === '1' || $value === 'true';
|
||||
}
|
||||
|
||||
private function runtimeContext(array $payload, array $context): array
|
||||
{
|
||||
return [
|
||||
'route_path' => $this->nullableString($payload['route_path'] ?? $context['route_path'] ?? $context['route'] ?? null, 512),
|
||||
'page_url' => $this->nullableString($payload['page_url'] ?? $context['page_url'] ?? $context['url'] ?? null, 1024),
|
||||
'release_trace_id' => $this->nullableString($payload['release_trace_id'] ?? $context['release_trace_id'] ?? $context['trace_id'] ?? $this->releaseRequestContext('trace_id'), 64),
|
||||
'frontend_version' => $this->nullableString($payload['frontend_version'] ?? $context['frontend_version'] ?? $this->releaseRequestContext('frontend_version'), 128),
|
||||
'api_version' => $this->nullableString($payload['api_version'] ?? $context['api_version'] ?? $this->releaseRequestContext('backend_version'), 128),
|
||||
'viewport' => is_array($context['viewport'] ?? null) ? $context['viewport'] : null,
|
||||
'user_agent' => $this->nullableString($context['user_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? null), 1024),
|
||||
'captured_at' => $this->nullableString($context['captured_at'] ?? null, 64),
|
||||
'data_collection_policy_version' => $this->nullableString($payload['data_collection_policy_version'] ?? $context['data_collection_policy_version'] ?? 'error-report-v1', 64) ?? 'error-report-v1',
|
||||
];
|
||||
}
|
||||
|
||||
private function releaseRequestContext(string $key): ?string
|
||||
{
|
||||
$context = is_array($GLOBALS['RELEASE_REQUEST_CONTEXT'] ?? null) ? $GLOBALS['RELEASE_REQUEST_CONTEXT'] : [];
|
||||
return isset($context[$key]) ? (string)$context[$key] : null;
|
||||
}
|
||||
|
||||
private function nullableString(mixed $value, int $maxLength): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$value = trim((string)$value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
return substr($value, 0, $maxLength);
|
||||
}
|
||||
|
||||
private function boundedArray(mixed $value, int $limit): array
|
||||
{
|
||||
return is_array($value) ? array_slice(array_values($value), 0, $limit) : [];
|
||||
}
|
||||
|
||||
private function statusFilter(mixed $status): string
|
||||
{
|
||||
$status = strtolower(trim((string)$status));
|
||||
if ($status === '' || $status === self::STATUS_OPEN) {
|
||||
return self::STATUS_OPEN;
|
||||
}
|
||||
if ($status === self::STATUS_RESOLVED || $status === 'all') {
|
||||
return $status;
|
||||
}
|
||||
return self::STATUS_OPEN;
|
||||
}
|
||||
|
||||
private function counts(): array
|
||||
{
|
||||
$rows = $this->selectRows('SELECT status, COUNT(*) AS count FROM error_reports GROUP BY status');
|
||||
$counts = [
|
||||
self::STATUS_OPEN => 0,
|
||||
self::STATUS_RESOLVED => 0,
|
||||
'all' => 0,
|
||||
];
|
||||
foreach ($rows as $row) {
|
||||
$status = (string)($row['status'] ?? '');
|
||||
$count = (int)($row['count'] ?? 0);
|
||||
if (isset($counts[$status])) {
|
||||
$counts[$status] = $count;
|
||||
}
|
||||
$counts['all'] += $count;
|
||||
}
|
||||
return $counts;
|
||||
}
|
||||
|
||||
private function resolvePrincipal(): array
|
||||
{
|
||||
$auth = new authentication();
|
||||
|
||||
try {
|
||||
$subuser = $auth->get_subuser();
|
||||
if ($subuser !== false) {
|
||||
return [
|
||||
'type' => 'subuser',
|
||||
'user_id' => null,
|
||||
'subuser_id' => (int)$subuser->id,
|
||||
'customer_number' => null,
|
||||
'customer_number_context' => $this->headerInt('X-Customer-Number'),
|
||||
'name' => $this->safeObjectValue($subuser, 'name') ?: $this->safeObjectValue($subuser, 'username'),
|
||||
'email' => $this->safeObjectValue($subuser, 'email'),
|
||||
];
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
$user = $auth->get_user();
|
||||
if ($user !== false) {
|
||||
return [
|
||||
'type' => 'user',
|
||||
'user_id' => (int)$user->id,
|
||||
'subuser_id' => null,
|
||||
'customer_number' => isset($user->customer_number) ? (int)$user->customer_number->value() : null,
|
||||
'customer_number_context' => null,
|
||||
'name' => $this->safeObjectValue($user, 'display_name'),
|
||||
'email' => $this->safeObjectValue($user, 'email'),
|
||||
];
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
throw new RuntimeException('Authentication failed. Invalid or missing token.');
|
||||
}
|
||||
|
||||
private function headerInt(string $name): ?int
|
||||
{
|
||||
$headers = function_exists('getallheaders') ? getallheaders() : [];
|
||||
foreach ($headers as $key => $value) {
|
||||
if (strcasecmp((string)$key, $name) === 0) {
|
||||
$int = (int)$value;
|
||||
return $int > 0 ? $int : null;
|
||||
}
|
||||
}
|
||||
$serverKey = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
|
||||
$int = (int)($_SERVER[$serverKey] ?? 0);
|
||||
return $int > 0 ? $int : null;
|
||||
}
|
||||
|
||||
private function safeObjectValue(object $object, string $property): ?string
|
||||
{
|
||||
try {
|
||||
if (!isset($object->{$property}) || !method_exists($object->{$property}, 'value')) {
|
||||
return null;
|
||||
}
|
||||
$value = $object->{$property}->value();
|
||||
return $value === null ? null : substr((string)$value, 0, 255);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function publicReport(array $row, bool $includeDetail): array
|
||||
{
|
||||
$report = [
|
||||
'id' => (int)$row['id'],
|
||||
'status' => (string)$row['status'],
|
||||
'reporter' => [
|
||||
'type' => $row['reporter_type'] ?? null,
|
||||
'user_id' => isset($row['reporter_user_id']) ? (int)$row['reporter_user_id'] : null,
|
||||
'subuser_id' => isset($row['reporter_subuser_id']) ? (int)$row['reporter_subuser_id'] : null,
|
||||
'customer_number' => isset($row['reporter_customer_number']) ? (int)$row['reporter_customer_number'] : null,
|
||||
'customer_number_context' => isset($row['reporter_customer_number_context']) ? (int)$row['reporter_customer_number_context'] : null,
|
||||
'name' => $row['reporter_name'] ?? null,
|
||||
'email' => $row['reporter_email'] ?? null,
|
||||
],
|
||||
'route_path' => $row['route_path'] ?? null,
|
||||
'page_url' => $row['page_url'] ?? null,
|
||||
'release_trace_id' => $row['release_trace_id'] ?? null,
|
||||
'frontend_version' => $row['frontend_version'] ?? null,
|
||||
'api_version' => $row['api_version'] ?? null,
|
||||
'screenshot' => [
|
||||
'mime_type' => $row['screenshot_mime_type'] ?? null,
|
||||
'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0,
|
||||
],
|
||||
'answers' => [
|
||||
'before_error' => $row['before_error'] ?? '',
|
||||
'expected' => $row['expected'] ?? '',
|
||||
'actual' => $row['actual'] ?? '',
|
||||
],
|
||||
'request_error_count' => isset($row['request_error_count']) ? (int)$row['request_error_count'] : 0,
|
||||
'vue_error_count' => isset($row['vue_error_count']) ? (int)$row['vue_error_count'] : 0,
|
||||
'resolved_at' => $row['resolved_at'] ?? null,
|
||||
'resolved_by_user_id' => isset($row['resolved_by_user_id']) ? (int)$row['resolved_by_user_id'] : null,
|
||||
'created_at' => $row['created_at'] ?? null,
|
||||
'updated_at' => $row['updated_at'] ?? null,
|
||||
];
|
||||
|
||||
if ($includeDetail) {
|
||||
$report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? ''));
|
||||
$report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null;
|
||||
$report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
|
||||
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
|
||||
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
|
||||
$report['data_collection'] = [
|
||||
'accepted' => (bool)($row['data_collection_accepted'] ?? false),
|
||||
'accepted_at' => $row['data_collection_accepted_at'] ?? null,
|
||||
'policy_version' => $row['data_collection_policy_version'] ?? null,
|
||||
];
|
||||
$report['resolution_note'] = $row['resolution_note'] ?? null;
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
private function ensureSchema(): void
|
||||
{
|
||||
if ($this->schemaEnsured) {
|
||||
return;
|
||||
}
|
||||
error_report_schema_bootstrap::ensureTables();
|
||||
$this->schemaEnsured = true;
|
||||
}
|
||||
|
||||
private function selectOne(string $sql, string $types = '', array $params = []): ?array
|
||||
{
|
||||
$rows = $this->selectRows($sql, $types, $params);
|
||||
return $rows[0] ?? null;
|
||||
}
|
||||
|
||||
private function selectRows(string $sql, string $types = '', array $params = []): array
|
||||
{
|
||||
global $db;
|
||||
if ($types === '') {
|
||||
$result = $db->query($sql);
|
||||
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
}
|
||||
|
||||
$stmt = $db->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new RuntimeException('Could not prepare error report query.');
|
||||
}
|
||||
$stmt->bind_param($types, ...$params);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
}
|
||||
|
||||
private function execute(string $sql, string $types = '', array $params = []): void
|
||||
{
|
||||
global $db;
|
||||
if ($types === '') {
|
||||
$db->query($sql);
|
||||
return;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new RuntimeException('Could not prepare error report statement.');
|
||||
}
|
||||
$stmt->bind_param($types, ...$params);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
private function insertId(): int
|
||||
{
|
||||
global $db;
|
||||
return (int)$db->insert_id();
|
||||
}
|
||||
|
||||
private function jsonEncodeLimited(mixed $value): string
|
||||
{
|
||||
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException('Could not encode error report JSON payload.');
|
||||
}
|
||||
if (strlen($json) <= self::JSON_MAX_LENGTH) {
|
||||
return $json;
|
||||
}
|
||||
|
||||
$truncated = [
|
||||
'[truncated]' => 'Payload exceeded ' . self::JSON_MAX_LENGTH . ' bytes.',
|
||||
'preview' => substr($json, 0, self::JSON_MAX_LENGTH),
|
||||
];
|
||||
$encoded = json_encode($truncated, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return $encoded === false ? '{}' : $encoded;
|
||||
}
|
||||
|
||||
private function jsonDecode(mixed $value): array
|
||||
{
|
||||
if (!is_string($value) || trim($value) === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use traits\minio_t;
|
||||
|
||||
class error_report_store
|
||||
{
|
||||
use minio_t;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
self::setBucket('uploads');
|
||||
}
|
||||
|
||||
public function storeScreenshot(string $mimeType, string $contents): array
|
||||
{
|
||||
$extension = match ($mimeType) {
|
||||
'image/webp' => 'webp',
|
||||
'image/jpeg' => 'jpg',
|
||||
default => 'png',
|
||||
};
|
||||
|
||||
$datePath = date('Y/m');
|
||||
$key = sprintf('error-reports/%s/%s.%s', $datePath, bin2hex(random_bytes(16)), $extension);
|
||||
|
||||
if (!self::createObject($key, $contents)) {
|
||||
throw new \RuntimeException('Could not store error report screenshot.');
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'mime_type' => $mimeType,
|
||||
'size_bytes' => strlen($contents),
|
||||
];
|
||||
}
|
||||
|
||||
public function screenshotUrl(string $key): ?string
|
||||
{
|
||||
$key = trim($key);
|
||||
if ($key === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::getPresignedUrl($key, 1200, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
require_once WD . '/modules/failover/failover_c.php';
|
||||
|
||||
use failover\failover_c;
|
||||
|
||||
class failover
|
||||
{
|
||||
public failover_c $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = new failover_c();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class hetzner_cloud_api_exception extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
private readonly int $statusCode = 0,
|
||||
private readonly string $apiCode = ''
|
||||
) {
|
||||
parent::__construct($message, $statusCode);
|
||||
}
|
||||
|
||||
public function statusCode(): int
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
|
||||
public function apiCode(): string
|
||||
{
|
||||
return $this->apiCode;
|
||||
}
|
||||
}
|
||||
|
||||
class hetzner_cloud_client
|
||||
{
|
||||
private const BASE_URL = 'https://api.hetzner.cloud/v1';
|
||||
|
||||
public function __construct(private readonly string $token, private readonly int $timeoutSeconds = 8)
|
||||
{
|
||||
if (trim($token) === '') {
|
||||
throw new RuntimeException('Hetzner Cloud API token is required.');
|
||||
}
|
||||
}
|
||||
|
||||
public function getLoadBalancer(int|string $id): array
|
||||
{
|
||||
return $this->request('GET', '/load_balancers/' . rawurlencode((string)$id))['load_balancer'] ?? [];
|
||||
}
|
||||
|
||||
public function addIpTarget(int|string $loadBalancerId, string $ip): array
|
||||
{
|
||||
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/add_target', [
|
||||
'type' => 'ip',
|
||||
'ip' => ['ip' => $ip],
|
||||
]);
|
||||
}
|
||||
|
||||
public function removeIpTarget(int|string $loadBalancerId, string $ip): array
|
||||
{
|
||||
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/remove_target', [
|
||||
'type' => 'ip',
|
||||
'ip' => ['ip' => $ip],
|
||||
]);
|
||||
}
|
||||
|
||||
public function addService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array
|
||||
{
|
||||
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/add_service', self::servicePayload(
|
||||
$protocol,
|
||||
$listenPort,
|
||||
$destinationPort,
|
||||
$options
|
||||
));
|
||||
}
|
||||
|
||||
public function updateService(int|string $loadBalancerId, string $protocol, int $listenPort, int $destinationPort, array $options = []): array
|
||||
{
|
||||
return $this->request('POST', '/load_balancers/' . rawurlencode((string)$loadBalancerId) . '/actions/update_service', self::servicePayload(
|
||||
$protocol,
|
||||
$listenPort,
|
||||
$destinationPort,
|
||||
$options
|
||||
));
|
||||
}
|
||||
|
||||
private static function servicePayload(string $protocol, int $listenPort, int $destinationPort, array $options): array
|
||||
{
|
||||
$payload = [
|
||||
'protocol' => strtolower($protocol),
|
||||
'listen_port' => $listenPort,
|
||||
'destination_port' => $destinationPort,
|
||||
'proxyprotocol' => false,
|
||||
];
|
||||
|
||||
foreach (['health_check', 'http'] as $key) {
|
||||
if (isset($options[$key]) && is_array($options[$key])) {
|
||||
$payload[$key] = $options[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function request(string $method, string $path, ?array $payload = null): array
|
||||
{
|
||||
$curl = curl_init(self::BASE_URL . $path);
|
||||
if ($curl === false) {
|
||||
throw new RuntimeException('Could not initialize Hetzner Cloud API request.');
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Accept: application/json',
|
||||
'Authorization: Bearer ' . trim($this->token),
|
||||
];
|
||||
|
||||
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, strtoupper($method));
|
||||
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, min(3, $this->timeoutSeconds));
|
||||
curl_setopt($curl, CURLOPT_TIMEOUT, max(1, $this->timeoutSeconds));
|
||||
curl_setopt($curl, CURLOPT_NOSIGNAL, true);
|
||||
curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
|
||||
|
||||
if ($payload !== null) {
|
||||
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($body === false) {
|
||||
throw new RuntimeException('Could not encode Hetzner Cloud API payload.');
|
||||
}
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
|
||||
|
||||
$raw = curl_exec($curl);
|
||||
$error = curl_error($curl);
|
||||
$status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
curl_close($curl);
|
||||
|
||||
if ($raw === false) {
|
||||
throw new RuntimeException('Hetzner Cloud API request failed: ' . $error);
|
||||
}
|
||||
|
||||
$decoded = trim((string)$raw) === '' ? [] : json_decode((string)$raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
$decoded = ['raw' => (string)$raw];
|
||||
}
|
||||
|
||||
if ($status < 200 || $status >= 300) {
|
||||
$errorPayload = is_array($decoded['error'] ?? null) ? $decoded['error'] : [];
|
||||
$apiCode = (string)($errorPayload['code'] ?? $decoded['code'] ?? '');
|
||||
$message = (string)($errorPayload['message'] ?? $decoded['message'] ?? ('HTTP ' . $status));
|
||||
throw new hetzner_cloud_api_exception('Hetzner Cloud API request failed: ' . $message, $status, $apiCode);
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ class invoice_period_flag_service
|
||||
private const WASH_CERTIFICATE_PRODUCT_ID = 41;
|
||||
private array $economicCustomerDiscountCache = [];
|
||||
private array $userDisplayNameCache = [];
|
||||
private array $orderItemsPreviewCache = [];
|
||||
private ?array $manualFlagsInstanceCache = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -185,6 +187,7 @@ class invoice_period_flag_service
|
||||
*/
|
||||
public function applyFlagsToPeriodTypes(array $types, string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null): array
|
||||
{
|
||||
global $response;
|
||||
$context = $this->buildPeriodContext($types, $dateFrom, $dateTo, $onlyCustomerNumbers);
|
||||
$manualFlags = $this->getManualFlagsForPeriod($context, $dateFrom, $dateTo, $onlyCustomerNumbers);
|
||||
$automaticFlags = $this->getAutomaticFlagsForPeriod($dateFrom, $dateTo, $onlyCustomerNumbers);
|
||||
@@ -204,7 +207,11 @@ class invoice_period_flag_service
|
||||
foreach ($types as $typeName => $customers) {
|
||||
foreach ($customers as $index => $customer) {
|
||||
$customerNumber = (int)($customer['customer_number'] ?? 0);
|
||||
$flags = $flagsByCustomerNumber[$customerNumber] ?? [];
|
||||
$flags = $this->flagsForCustomerCard(
|
||||
$customer,
|
||||
$flagsByCustomerNumber[$customerNumber] ?? [],
|
||||
(string)$typeName
|
||||
);
|
||||
usort($flags, [$this, 'sortFlags']);
|
||||
$types[$typeName][$index]['flags'] = array_values($flags);
|
||||
$types[$typeName][$index]['flag_counts'] = $this->countFlags($flags);
|
||||
@@ -218,6 +225,69 @@ class invoice_period_flag_service
|
||||
return $types;
|
||||
}
|
||||
|
||||
private function flagsForCustomerCard(array $customer, array $flags, string $typeName = ''): array
|
||||
{
|
||||
$transactionIds = [];
|
||||
$invoiceCollectionIds = [];
|
||||
foreach (($customer['transactions'] ?? []) as $transaction) {
|
||||
$orderId = (int)($transaction['id'] ?? 0);
|
||||
if ($orderId > 0) {
|
||||
$transactionIds[$orderId] = true;
|
||||
}
|
||||
|
||||
$invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0);
|
||||
if ($invoiceCollectionId > 0) {
|
||||
$invoiceCollectionIds[$invoiceCollectionId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_filter($flags, function (array $flag) use ($customer, $transactionIds, $invoiceCollectionIds, $typeName): bool {
|
||||
return $this->flagBelongsToCustomerCard($customer, $flag, $transactionIds, $invoiceCollectionIds, $typeName);
|
||||
}));
|
||||
}
|
||||
|
||||
private function flagBelongsToCustomerCard(
|
||||
array $customer,
|
||||
array $flag,
|
||||
array $transactionIds,
|
||||
array $invoiceCollectionIds,
|
||||
string $typeName = ''
|
||||
): bool {
|
||||
if ((string)($flag['status'] ?? self::STATUS_ACTIVE) !== self::STATUS_ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$customerNumber = (int)($customer['customer_number'] ?? 0);
|
||||
$targetType = (string)($flag['target_type'] ?? '');
|
||||
if ($targetType === 'customer') {
|
||||
return (int)($flag['customer_number'] ?? $flag['target_id'] ?? 0) === $customerNumber;
|
||||
}
|
||||
|
||||
if ($targetType === 'xlvask_usage_log') {
|
||||
return $typeName === 'all' && (int)($flag['customer_number'] ?? 0) === $customerNumber;
|
||||
}
|
||||
|
||||
if (in_array($targetType, ['order', 'order_field', 'order_item', 'order_item_field'], true)) {
|
||||
$orderId = (int)($flag['order_id'] ?? $flag['context']['order_id'] ?? 0);
|
||||
if ($orderId < 1 && in_array($targetType, ['order', 'order_field'], true)) {
|
||||
$orderId = (int)($flag['target_id'] ?? 0);
|
||||
}
|
||||
return $orderId > 0 && isset($transactionIds[$orderId]);
|
||||
}
|
||||
|
||||
if ($targetType === 'collected_order_invoice') {
|
||||
$invoiceCollectionId = (int)(
|
||||
$flag['invoice_collection_id']
|
||||
?? $flag['context']['invoice_collection_id']
|
||||
?? $flag['target_id']
|
||||
?? 0
|
||||
);
|
||||
return $invoiceCollectionId > 0 && isset($invoiceCollectionIds[$invoiceCollectionId]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function getStoredFlag(int $id): array
|
||||
{
|
||||
global $db;
|
||||
@@ -249,6 +319,30 @@ class invoice_period_flag_service
|
||||
return $this->formatStoredFlag($row);
|
||||
}
|
||||
|
||||
public function warmManualFlagsCache(): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$result = $db->query(
|
||||
"SELECT * FROM invoice_period_flags
|
||||
WHERE source = '" . self::SOURCE_MANUAL . "'
|
||||
AND status = '" . self::STATUS_ACTIVE . "'
|
||||
ORDER BY created_at ASC, id ASC"
|
||||
);
|
||||
|
||||
$flags = [];
|
||||
if ($result) {
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$flags[] = $this->formatStoredFlag($row);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
(new redis())->cache_invoice_period_manual_flags($flags);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private function formatStoredFlag(array $row): array
|
||||
{
|
||||
$context = [];
|
||||
@@ -285,6 +379,32 @@ class invoice_period_flag_service
|
||||
];
|
||||
}
|
||||
|
||||
private function getCachedManualFlags(): array
|
||||
{
|
||||
try {
|
||||
$flags = (new redis())->get_invoice_period_manual_flags();
|
||||
} catch (Throwable) {
|
||||
$flags = null;
|
||||
}
|
||||
|
||||
if (!is_array($flags)) {
|
||||
// Cache miss — warm on demand and re-fetch
|
||||
$this->warmManualFlagsCache();
|
||||
try {
|
||||
$flags = (new redis())->get_invoice_period_manual_flags();
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
if (!is_array($flags)) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_filter($flags, static function ($flag): bool {
|
||||
return is_array($flag);
|
||||
}));
|
||||
}
|
||||
|
||||
private function buildPeriodContext(array $types, string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array
|
||||
{
|
||||
$customerNumbers = [];
|
||||
@@ -330,16 +450,11 @@ class invoice_period_flag_service
|
||||
|
||||
private function getManualFlagsForPeriod(array $context, string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$result = $db->query(
|
||||
"SELECT * FROM invoice_period_flags
|
||||
WHERE source = '" . self::SOURCE_MANUAL . "'
|
||||
AND status = '" . self::STATUS_ACTIVE . "'
|
||||
ORDER BY created_at ASC, id ASC"
|
||||
);
|
||||
|
||||
if (!$result || $result->num_rows === 0) {
|
||||
if ($this->manualFlagsInstanceCache === null) {
|
||||
$this->manualFlagsInstanceCache = $this->getCachedManualFlags();
|
||||
}
|
||||
$cachedFlags = $this->manualFlagsInstanceCache;
|
||||
if (empty($cachedFlags)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -356,7 +471,7 @@ class invoice_period_flag_service
|
||||
}
|
||||
|
||||
$flags = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
foreach ($cachedFlags as $row) {
|
||||
$targetType = (string)$row['target_type'];
|
||||
$targetId = (int)$row['target_id'];
|
||||
$customerNumber = null;
|
||||
@@ -396,7 +511,7 @@ class invoice_period_flag_service
|
||||
continue;
|
||||
}
|
||||
|
||||
$flag = $this->formatStoredFlag($row);
|
||||
$flag = $row;
|
||||
$flag['customer_number'] = $customerNumber;
|
||||
$flag['message'] = (string)$flag['reason'];
|
||||
$flags[] = $flag;
|
||||
@@ -407,16 +522,48 @@ class invoice_period_flag_service
|
||||
|
||||
private function getAutomaticFlagsForPeriod(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array
|
||||
{
|
||||
$rows = $this->getPeriodOrderItemRows($dateFrom, $dateTo, $onlyCustomerNumbers);
|
||||
$attributes = $this->getCustomerAttributes($onlyCustomerNumbers);
|
||||
try {
|
||||
$flags = (new redis())->get_invoice_period_automatic_flags($dateFrom, $dateTo);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_merge(
|
||||
if (!is_array($flags)) {
|
||||
// Cache miss — enqueue for warming on the next cron run
|
||||
try {
|
||||
(new redis())->enqueue_invoice_period_warming($dateFrom, $dateTo);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($onlyCustomerNumbers === null) {
|
||||
return $flags;
|
||||
}
|
||||
|
||||
$allowed = array_fill_keys(array_map('intval', $onlyCustomerNumbers), true);
|
||||
return array_values(array_filter($flags, static function (array $flag) use ($allowed): bool {
|
||||
return isset($allowed[(int)($flag['customer_number'] ?? 0)]);
|
||||
}));
|
||||
}
|
||||
|
||||
public function warmAutomaticFlagsForPeriod(string $dateFrom, string $dateTo): void
|
||||
{
|
||||
$rows = $this->getPeriodOrderItemRows($dateFrom, $dateTo, null);
|
||||
$attributes = $this->getCustomerAttributes(null);
|
||||
|
||||
$flags = array_merge(
|
||||
$this->detectCustomerRuleViolations($rows, $attributes),
|
||||
$this->detectPriceMismatches($rows),
|
||||
$this->detectAbnormalQuantities($rows, $dateFrom, $dateTo),
|
||||
$this->detectVehicleTypeMismatches($rows, $dateFrom),
|
||||
$this->detectMissingXlVaskLinks($dateFrom, $dateTo, $onlyCustomerNumbers)
|
||||
$this->detectMissingXlVaskLinks($dateFrom, $dateTo, null)
|
||||
);
|
||||
|
||||
try {
|
||||
(new redis())->cache_invoice_period_automatic_flags($dateFrom, $dateTo, $flags);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private function filterSuppressedAutomaticFlags(array $flags): array
|
||||
@@ -455,12 +602,44 @@ class invoice_period_flag_service
|
||||
}
|
||||
|
||||
private function getPeriodOrderItemRows(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array
|
||||
{
|
||||
try {
|
||||
$rows = (new redis())->get_invoice_period_order_item_rows($dateFrom, $dateTo);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->seedOrderItemsPreviewCacheFromRows($rows);
|
||||
|
||||
if ($onlyCustomerNumbers === null) {
|
||||
return $rows;
|
||||
}
|
||||
|
||||
$allowed = array_fill_keys(array_map('intval', $onlyCustomerNumbers), true);
|
||||
return array_values(array_filter($rows, static function (array $row) use ($allowed): bool {
|
||||
return isset($allowed[(int)($row['customer_number'] ?? 0)]);
|
||||
}));
|
||||
}
|
||||
|
||||
public function warmOrderItemRowsForPeriod(string $dateFrom, string $dateTo): void
|
||||
{
|
||||
$rows = $this->fetchOrderItemRowsFromDb($dateFrom, $dateTo);
|
||||
try {
|
||||
(new redis())->cache_invoice_period_order_item_rows($dateFrom, $dateTo, $rows);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private function fetchOrderItemRowsFromDb(string $dateFrom, string $dateTo): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$customerFilter = $this->customerFilterSql('o.customer_id', $onlyCustomerNumbers);
|
||||
$dateFrom = $db->escape_string($dateFrom);
|
||||
$dateTo = $db->escape_string($dateTo);
|
||||
$escapedDateFrom = $db->escape_string($dateFrom);
|
||||
$escapedDateTo = $db->escape_string($dateTo);
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
@@ -525,9 +704,8 @@ class invoice_period_flag_service
|
||||
) category_discount
|
||||
ON category_discount.customer_number = o.customer_id
|
||||
AND category_discount.product_or_category_id = p.category
|
||||
WHERE o.created_at BETWEEN '{$dateFrom}' AND '{$dateTo}'
|
||||
WHERE o.created_at BETWEEN '{$escapedDateFrom}' AND '{$escapedDateTo}'
|
||||
AND o.deleted_at IS NULL
|
||||
{$customerFilter}
|
||||
ORDER BY o.customer_id, o.id, oi.id";
|
||||
|
||||
$result = $db->query($sql);
|
||||
@@ -539,6 +717,7 @@ class invoice_period_flag_service
|
||||
$row['has_wash_certificate_attachment'] = isset($certificateAttachmentOrderIds[$orderId]) ? 1 : 0;
|
||||
}
|
||||
unset($row);
|
||||
$this->seedOrderItemsPreviewCacheFromRows($rows);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
@@ -759,6 +938,8 @@ class invoice_period_flag_service
|
||||
|
||||
private function detectPriceMismatches(array $rows): array
|
||||
{
|
||||
$this->preloadEconomicCustomerDiscounts($rows);
|
||||
|
||||
$flags = [];
|
||||
foreach ($rows as $row) {
|
||||
$orderItemId = (int)($row['order_item_id'] ?? 0);
|
||||
@@ -986,7 +1167,7 @@ class invoice_period_flag_service
|
||||
}
|
||||
}
|
||||
|
||||
$history = $this->getPrimaryProductHistory($dateFrom);
|
||||
$history = $this->getPrimaryProductHistory($dateFrom, array_column($primaryRows, 'reg_1'));
|
||||
foreach ($primaryRows as $row) {
|
||||
$reg = strtoupper(trim((string)($row['reg_1'] ?? '')));
|
||||
if ($reg === '' || !isset($history[$reg])) {
|
||||
@@ -1372,10 +1553,11 @@ class invoice_period_flag_service
|
||||
private function getCustomerName(int $customerNumber): string
|
||||
{
|
||||
global $db;
|
||||
$result = $db->query("SELECT name FROM users WHERE customer_number = {$customerNumber} LIMIT 1");
|
||||
$result = $db->query("SELECT display_name FROM users WHERE customer_number = {$customerNumber} LIMIT 1");
|
||||
if ($result && $result->num_rows > 0) {
|
||||
$row = $result->fetch_assoc();
|
||||
return (string)($row['name'] ?? ('#' . $customerNumber));
|
||||
$displayName = trim((string)($row['display_name'] ?? ''));
|
||||
return $displayName !== '' ? $displayName : '#' . $customerNumber;
|
||||
}
|
||||
return '#' . $customerNumber;
|
||||
}
|
||||
@@ -1462,11 +1644,27 @@ class invoice_period_flag_service
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function getPrimaryProductHistory(string $dateFrom): array
|
||||
private function getPrimaryProductHistory(string $dateFrom, array $registrationNumbers): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$registrations = [];
|
||||
foreach ($registrationNumbers as $registrationNumber) {
|
||||
$registrationNumber = preg_replace('/[^A-Z0-9]/', '', strtoupper(trim((string)$registrationNumber)));
|
||||
$registrationNumber = is_string($registrationNumber) ? $registrationNumber : '';
|
||||
if ($registrationNumber !== '') {
|
||||
$registrations[$registrationNumber] = true;
|
||||
}
|
||||
}
|
||||
if (empty($registrations)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$dateFrom = $db->escape_string($dateFrom);
|
||||
$historyStart = $db->escape_string(date('Y-m-d H:i:s', strtotime($dateFrom . ' -18 months')));
|
||||
$registrationFilter = implode(',', array_map(static function (string $registrationNumber) use ($db): string {
|
||||
return "'" . $db->escape_string($registrationNumber) . "'";
|
||||
}, array_keys($registrations)));
|
||||
$result = $db->query(
|
||||
"SELECT UPPER(TRIM(o.reg_1)) AS reg, oi.product_id, p.name AS product_name, COUNT(*) AS usage_count
|
||||
FROM orders o
|
||||
@@ -1479,32 +1677,37 @@ class invoice_period_flag_service
|
||||
AND p.is_wash = 1
|
||||
AND COALESCE(oi.related_item_id, 0) = 0
|
||||
AND COALESCE(o.reg_1, '') <> ''
|
||||
AND o.reg_1 IN ({$registrationFilter})
|
||||
GROUP BY UPPER(TRIM(o.reg_1)), oi.product_id, p.name
|
||||
ORDER BY reg, usage_count DESC"
|
||||
ORDER BY reg, usage_count DESC, oi.product_id ASC"
|
||||
);
|
||||
|
||||
$byReg = [];
|
||||
$history = [];
|
||||
$seenCounts = [];
|
||||
if ($result) {
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$byReg[(string)$row['reg']][] = [
|
||||
$reg = (string)$row['reg'];
|
||||
$item = [
|
||||
'product_id' => (int)$row['product_id'],
|
||||
'product_name' => (string)($row['product_name'] ?? ''),
|
||||
'count' => (int)$row['usage_count'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$history = [];
|
||||
foreach ($byReg as $reg => $items) {
|
||||
$top = $items[0] ?? null;
|
||||
$second = $items[1] ?? null;
|
||||
if (!$top || (int)$top['count'] < 3) {
|
||||
continue;
|
||||
if (!isset($seenCounts[$reg])) {
|
||||
$seenCounts[$reg] = 1;
|
||||
if ($item['count'] >= 3) {
|
||||
$history[$reg] = $item;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($seenCounts[$reg] === 1) {
|
||||
$seenCounts[$reg] = 2;
|
||||
if (isset($history[$reg]) && $item['count'] >= (int)$history[$reg]['count']) {
|
||||
unset($history[$reg]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($second && (int)$second['count'] >= (int)$top['count']) {
|
||||
continue;
|
||||
}
|
||||
$history[$reg] = $top;
|
||||
}
|
||||
return $history;
|
||||
}
|
||||
@@ -1521,9 +1724,21 @@ class invoice_period_flag_service
|
||||
|
||||
$currentVehicleType = $this->normalizePrimaryVehicleProductName($currentProductName);
|
||||
$expectedVehicleType = $this->normalizePrimaryVehicleProductName($expectedProductName);
|
||||
return $currentVehicleType !== ''
|
||||
&& $expectedVehicleType !== ''
|
||||
&& $currentVehicleType === $expectedVehicleType;
|
||||
if ($currentVehicleType === '' || $expectedVehicleType === '') {
|
||||
return false;
|
||||
}
|
||||
if ($currentVehicleType === $expectedVehicleType) {
|
||||
return true;
|
||||
}
|
||||
// Allow a match if one normalized name's tokens are a subset of the other.
|
||||
// E.g. "Indvendig vask Kassevogn" → "kassevogn" is a subset of
|
||||
// "Kassevogn/varevogn" → "kassevogn varevogn", meaning the same vehicle type.
|
||||
$currentTokens = explode(' ', $currentVehicleType);
|
||||
$expectedTokens = explode(' ', $expectedVehicleType);
|
||||
if (count($currentTokens) <= count($expectedTokens)) {
|
||||
return array_diff($currentTokens, $expectedTokens) === [];
|
||||
}
|
||||
return array_diff($expectedTokens, $currentTokens) === [];
|
||||
}
|
||||
|
||||
private function normalizePrimaryVehicleProductName(string $productName): string
|
||||
@@ -1559,16 +1774,123 @@ class invoice_period_flag_service
|
||||
if ($orderId < 1) {
|
||||
return [];
|
||||
}
|
||||
if (array_key_exists($orderId, $this->orderItemsPreviewCache)) {
|
||||
return $this->orderItemsPreviewCache[$orderId];
|
||||
}
|
||||
|
||||
$result = $db->query(
|
||||
"SELECT oi.id, oi.order_id, oi.product_id, oi.reference, oi.notes, oi.price, oi.quantity,
|
||||
oi.related_item_id, p.name AS product_name, p.price AS product_base_price
|
||||
"SELECT oi.id, oi.product_id, oi.price, oi.quantity, p.name AS product_name
|
||||
FROM order_items oi
|
||||
LEFT JOIN products p ON p.id = oi.product_id
|
||||
WHERE oi.order_id = {$orderId}
|
||||
AND (oi.deleted_at IS NULL OR oi.deleted_at = '')
|
||||
ORDER BY oi.related_item_id IS NOT NULL, oi.id"
|
||||
);
|
||||
return $result ? $db->fetch_all($result) : [];
|
||||
$rows = $result ? $db->fetch_all($result) : [];
|
||||
$this->orderItemsPreviewCache[$orderId] = array_map(static function (array $row): array {
|
||||
return [
|
||||
'id' => (int)($row['id'] ?? 0),
|
||||
'product_id' => (int)($row['product_id'] ?? 0),
|
||||
'product_name' => (string)($row['product_name'] ?? ''),
|
||||
'quantity' => (int)($row['quantity'] ?? 0),
|
||||
'price' => (int)($row['price'] ?? 0),
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return $this->orderItemsPreviewCache[$orderId];
|
||||
}
|
||||
|
||||
private function seedOrderItemsPreviewCacheFromRows(array $rows): void
|
||||
{
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$orderId = (int)($row['order_id'] ?? 0);
|
||||
if ($orderId < 1) {
|
||||
continue;
|
||||
}
|
||||
$grouped[$orderId] ??= [];
|
||||
|
||||
$orderItemId = (int)($row['order_item_id'] ?? 0);
|
||||
if ($orderItemId < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$grouped[$orderId][] = [
|
||||
'id' => $orderItemId,
|
||||
'product_id' => (int)($row['product_id'] ?? 0),
|
||||
'product_name' => (string)($row['product_name'] ?? ''),
|
||||
'quantity' => (int)($row['item_quantity'] ?? 0),
|
||||
'price' => (int)($row['item_price'] ?? 0),
|
||||
'_related_sort' => (int)($row['related_item_id'] ?? 0) > 0 ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($grouped as $orderId => $items) {
|
||||
usort($items, static function (array $a, array $b): int {
|
||||
return ((int)$a['_related_sort'] <=> (int)$b['_related_sort'])
|
||||
?: ((int)$a['id'] <=> (int)$b['id']);
|
||||
});
|
||||
$this->orderItemsPreviewCache[(int)$orderId] = array_map(static function (array $item): array {
|
||||
unset($item['_related_sort']);
|
||||
return $item;
|
||||
}, $items);
|
||||
}
|
||||
}
|
||||
|
||||
private function preloadEconomicCustomerDiscounts(array $rows): void
|
||||
{
|
||||
$customerUserIds = [];
|
||||
foreach ($rows as $row) {
|
||||
if ((int)($row['apply_category_discount'] ?? 0) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$customerNumber = (int)($row['customer_number'] ?? 0);
|
||||
$userId = (int)($row['user_id'] ?? 0);
|
||||
if ($customerNumber < 1 || $userId < 1 || array_key_exists($customerNumber, $this->economicCustomerDiscountCache)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$customerUserIds[$customerNumber] = $userId;
|
||||
}
|
||||
|
||||
foreach ($customerUserIds as $customerNumber => $userId) {
|
||||
$discount = $this->getCachedEconomicCustomerDiscount($userId);
|
||||
if ($discount === null) {
|
||||
$discount = $this->loadEconomicCustomerDiscount((int)$customerNumber, $userId);
|
||||
}
|
||||
|
||||
$this->economicCustomerDiscountCache[(int)$customerNumber] = $discount;
|
||||
}
|
||||
}
|
||||
|
||||
private function getCachedEconomicCustomerDiscount(int $userId): ?int
|
||||
{
|
||||
if ($userId < 1 || !defined('redis')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$cachedDiscount = constant('redis')->get_economic_customer_discount_percentage($userId);
|
||||
return $cachedDiscount === null ? null : (int)$cachedDiscount;
|
||||
} catch (Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function loadEconomicCustomerDiscount(int $customerNumber, int $userId): int
|
||||
{
|
||||
if ($customerNumber < 1 || $userId < 1 || !defined('redis')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$discount = (int)(new \customers\economicCustomers())->getCustomerDiscountPercentage($customerNumber);
|
||||
constant('redis')->cache_economic_customer_discount_percentage($userId, $discount);
|
||||
return $discount;
|
||||
} catch (Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private function calculateExpectedPrice(array $row): int
|
||||
@@ -1621,11 +1943,10 @@ class invoice_period_flag_service
|
||||
return $this->economicCustomerDiscountCache[$customerNumber];
|
||||
}
|
||||
|
||||
try {
|
||||
$user = (new \objects\users_o())->getUserByCustomerNumber($customerNumber);
|
||||
$discount = (int)$user->getEconomicCustomerDiscountPercentage();
|
||||
} catch (Throwable $e) {
|
||||
$discount = 0;
|
||||
$discount = 0;
|
||||
$userId = (int)($row['user_id'] ?? 0);
|
||||
if ($userId > 0) {
|
||||
$discount = $this->getCachedEconomicCustomerDiscount($userId) ?? 0;
|
||||
}
|
||||
|
||||
$this->economicCustomerDiscountCache[$customerNumber] = $discount;
|
||||
|
||||
@@ -41,9 +41,39 @@ class orders_schema_bootstrap
|
||||
);
|
||||
}
|
||||
|
||||
self::backfillBookingPoDefaults($db);
|
||||
|
||||
self::ensureIndex($db, 'orders', 'idx_orders_period_customer_created_deleted', 'customer_id, created_at, deleted_at');
|
||||
self::ensureIndex($db, 'orders', 'idx_orders_period_created_deleted_customer', 'created_at, deleted_at, customer_id');
|
||||
self::ensureIndex($db, 'order_items', 'idx_order_items_order_deleted', 'order_id, deleted_at');
|
||||
self::ensureIndex($db, 'customer_attributes', 'idx_customer_attributes_attribute_user', 'attribute, user_id');
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function backfillBookingPoDefaults(object $db): void
|
||||
{
|
||||
if (
|
||||
!self::tableExists($db, 'order_bookings')
|
||||
|| !self::columnExists($db, 'orders', 'booking_id')
|
||||
|| !self::columnExists($db, 'orders', 'po')
|
||||
|| !self::columnExists($db, 'order_bookings', 'po')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query(
|
||||
"UPDATE orders o
|
||||
INNER JOIN order_bookings b ON b.id = o.booking_id
|
||||
SET o.po = b.po
|
||||
WHERE o.booking_id IS NOT NULL
|
||||
AND o.booking_id > 0
|
||||
AND (o.po IS NULL OR TRIM(o.po) = '')
|
||||
AND b.po IS NOT NULL
|
||||
AND TRIM(b.po) <> ''"
|
||||
);
|
||||
}
|
||||
|
||||
private static function tableExists(object $db, string $table): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
@@ -69,6 +99,46 @@ class orders_schema_bootstrap
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function ensureIndex(object $db, string $table, string $index, string $columns): void
|
||||
{
|
||||
if (
|
||||
!self::tableExists($db, $table)
|
||||
|| self::indexExists($db, $table, $index)
|
||||
|| !self::columnsExist($db, $table, $columns)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$table = self::escapeIdentifier($table);
|
||||
$index = self::escapeIdentifier($index);
|
||||
$db->query("ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})");
|
||||
}
|
||||
|
||||
private static function columnsExist(object $db, string $table, string $columns): bool
|
||||
{
|
||||
foreach (explode(',', $columns) as $column) {
|
||||
$column = trim($column, " \t\n\r\0\x0B`");
|
||||
if ($column === '' || !self::columnExists($db, $table, $column)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function indexExists(object $db, string $table, string $index): bool
|
||||
{
|
||||
$table = self::escapeIdentifier($table);
|
||||
$index = self::escapeIdentifier($index);
|
||||
$result = $db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'");
|
||||
|
||||
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int)$result->num_rows > 0;
|
||||
}
|
||||
|
||||
private static function escapeIdentifier(string $value): string
|
||||
{
|
||||
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
|
||||
|
||||
@@ -364,6 +364,178 @@ class redis implements redis_i
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function cache_invoice_period_manual_flags(array $flags): self
|
||||
{
|
||||
$this->set_array('invoice_period_manual_flags', $flags);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function get_invoice_period_manual_flags(): array|null
|
||||
{
|
||||
return $this->get_array('invoice_period_manual_flags');
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function clear_invoice_period_manual_flags(): self
|
||||
{
|
||||
$this->delete('invoice_period_manual_flags');
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function invoicePeriodCacheKey(string $prefix, string $dateFrom, string $dateTo): string
|
||||
{
|
||||
return $prefix . ':' . $dateFrom . ':' . $dateTo;
|
||||
}
|
||||
|
||||
private function workfeedEmployeeNameCacheKey(string $employeeId): string
|
||||
{
|
||||
return 'workfeed_employee_name:' . rawurlencode($employeeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function cache_invoice_period_automatic_flags(string $dateFrom, string $dateTo, array $flags): self
|
||||
{
|
||||
$this->set_array($this->invoicePeriodCacheKey('invoice_period_automatic_flags', $dateFrom, $dateTo), $flags);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function get_invoice_period_automatic_flags(string $dateFrom, string $dateTo): array|null
|
||||
{
|
||||
return $this->get_array($this->invoicePeriodCacheKey('invoice_period_automatic_flags', $dateFrom, $dateTo));
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function clear_invoice_period_automatic_flags(string $dateFrom, string $dateTo): self
|
||||
{
|
||||
$this->delete($this->invoicePeriodCacheKey('invoice_period_automatic_flags', $dateFrom, $dateTo));
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function cache_invoice_period_order_item_rows(string $dateFrom, string $dateTo, array $rows): self
|
||||
{
|
||||
$this->set_array($this->invoicePeriodCacheKey('invoice_period_order_item_rows', $dateFrom, $dateTo), $rows);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function get_invoice_period_order_item_rows(string $dateFrom, string $dateTo): array|null
|
||||
{
|
||||
return $this->get_array($this->invoicePeriodCacheKey('invoice_period_order_item_rows', $dateFrom, $dateTo));
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function clear_invoice_period_order_item_rows(string $dateFrom, string $dateTo): self
|
||||
{
|
||||
$this->delete($this->invoicePeriodCacheKey('invoice_period_order_item_rows', $dateFrom, $dateTo));
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function cache_workfeed_employee_name(string $employeeId, string $employeeName, int $ttl = 86400): self
|
||||
{
|
||||
$normalizedEmployeeId = trim($employeeId);
|
||||
if ($normalizedEmployeeId === '') {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$normalizedEmployeeName = trim($employeeName);
|
||||
if ($normalizedEmployeeName === '') {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$key = $this->workfeedEmployeeNameCacheKey($normalizedEmployeeId);
|
||||
$this->set($key, $normalizedEmployeeName);
|
||||
$this->expire($key, $ttl);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function get_workfeed_employee_name(string $employeeId): string|null
|
||||
{
|
||||
$normalizedEmployeeId = trim($employeeId);
|
||||
if ($normalizedEmployeeId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $this->get($this->workfeedEmployeeNameCacheKey($normalizedEmployeeId));
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = trim((string)$value);
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function clear_workfeed_employee_name(string $employeeId): self
|
||||
{
|
||||
$normalizedEmployeeId = trim($employeeId);
|
||||
if ($normalizedEmployeeId === '') {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->delete($this->workfeedEmployeeNameCacheKey($normalizedEmployeeId));
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function enqueue_invoice_period_warming(string $dateFrom, string $dateTo): self
|
||||
{
|
||||
$this->get_client()->sadd('invoice_period_warming_queue', [$dateFrom . '|' . $dateTo]);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function consume_invoice_period_warming_queue(): array
|
||||
{
|
||||
$client = $this->get_client();
|
||||
$members = $client->smembers('invoice_period_warming_queue');
|
||||
if (!empty($members)) {
|
||||
$client->del('invoice_period_warming_queue');
|
||||
}
|
||||
$periods = [];
|
||||
foreach ($members as $member) {
|
||||
$parts = explode('|', (string)$member, 2);
|
||||
if (count($parts) === 2 && $parts[0] !== '' && $parts[1] !== '') {
|
||||
$periods[] = ['dateFrom' => $parts[0], 'dateTo' => $parts[1]];
|
||||
}
|
||||
}
|
||||
return $periods;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,557 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Additive schema bootstrap for application release management.
|
||||
*
|
||||
* The legacy API does not have a centralized migration runner, so these
|
||||
* migrations must be safe to call from request handlers, tests, and cron.
|
||||
*/
|
||||
class release_manager_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
private static ?bool $tablesExist = null;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
$queries = [
|
||||
"CREATE TABLE IF NOT EXISTS release_channels (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
slug VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
description TEXT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
default_channel TINYINT(1) NOT NULL DEFAULT 0,
|
||||
rollout_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
|
||||
frontend_base_url VARCHAR(512) NULL,
|
||||
api_base_url VARCHAR(512) NULL,
|
||||
replay_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
capture_level VARCHAR(32) NOT NULL DEFAULT 'metadata',
|
||||
retention_days INT NOT NULL DEFAULT 14,
|
||||
metadata_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
UNIQUE KEY uq_release_channels_slug (slug),
|
||||
INDEX idx_release_channels_enabled (enabled, deleted_at),
|
||||
INDEX idx_release_channels_default (default_channel, deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_versions (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
app VARCHAR(16) NOT NULL,
|
||||
repository VARCHAR(255) NULL,
|
||||
branch VARCHAR(128) NULL,
|
||||
commit_sha VARCHAR(64) NULL,
|
||||
tag VARCHAR(128) NULL,
|
||||
version_label VARCHAR(128) NULL,
|
||||
build_url VARCHAR(512) NULL,
|
||||
artifact_url VARCHAR(512) NULL,
|
||||
deployed_url VARCHAR(512) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'discovered',
|
||||
metadata_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deployed_at DATETIME NULL,
|
||||
INDEX idx_release_versions_app_status (app, status),
|
||||
INDEX idx_release_versions_commit (commit_sha),
|
||||
INDEX idx_release_versions_repo_branch (repository, branch)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_channel_versions (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
channel_id BIGINT UNSIGNED NOT NULL,
|
||||
frontend_version_id BIGINT UNSIGNED NULL,
|
||||
api_version_id BIGINT UNSIGNED NULL,
|
||||
deployment_id BIGINT UNSIGNED NULL,
|
||||
service_set_id BIGINT UNSIGNED NULL,
|
||||
bundle_id BIGINT UNSIGNED NULL,
|
||||
actor_user_id INT NULL,
|
||||
active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
activated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_release_channel_versions_channel_active (channel_id, active),
|
||||
INDEX idx_release_channel_versions_frontend (frontend_version_id),
|
||||
INDEX idx_release_channel_versions_api (api_version_id),
|
||||
INDEX idx_release_channel_versions_deployment (deployment_id),
|
||||
INDEX idx_release_channel_versions_service_set (service_set_id),
|
||||
INDEX idx_release_channel_versions_bundle (bundle_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_assignments (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
subject_type VARCHAR(16) NOT NULL,
|
||||
subject_id VARCHAR(64) NOT NULL,
|
||||
channel_id BIGINT UNSIGNED NOT NULL,
|
||||
reason VARCHAR(255) NULL,
|
||||
expires_at DATETIME NULL,
|
||||
actor_user_id INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
INDEX idx_release_assignments_subject (subject_type, subject_id, deleted_at, expires_at),
|
||||
INDEX idx_release_assignments_channel (channel_id, deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_deployment_targets (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
channel_id BIGINT UNSIGNED NOT NULL,
|
||||
app VARCHAR(16) NOT NULL,
|
||||
coolify_instance_id BIGINT UNSIGNED NULL,
|
||||
coolify_service_uuid VARCHAR(128) NULL,
|
||||
repository VARCHAR(255) NOT NULL,
|
||||
branch VARCHAR(128) NOT NULL DEFAULT 'main',
|
||||
auto_deploy TINYINT(1) NOT NULL DEFAULT 1,
|
||||
health_url VARCHAR(512) NULL,
|
||||
deploy_context_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
INDEX idx_release_targets_channel_app (channel_id, app, deleted_at),
|
||||
INDEX idx_release_targets_repo_branch (repository, branch, auto_deploy, deleted_at),
|
||||
INDEX idx_release_targets_coolify (coolify_instance_id, coolify_service_uuid)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_auto_sync_events (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
channel_id BIGINT UNSIGNED NOT NULL,
|
||||
app VARCHAR(16) NOT NULL,
|
||||
repository VARCHAR(255) NOT NULL,
|
||||
branch VARCHAR(128) NOT NULL,
|
||||
commit_sha VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
source VARCHAR(64) NULL,
|
||||
workflow_url VARCHAR(512) NULL,
|
||||
gate_operation_id BIGINT UNSIGNED NULL,
|
||||
sync_operation_id BIGINT UNSIGNED NULL,
|
||||
deployment_id BIGINT UNSIGNED NULL,
|
||||
error_message TEXT NULL,
|
||||
metadata_json LONGTEXT NULL,
|
||||
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
gate_passed_at DATETIME NULL,
|
||||
synced_at DATETIME NULL,
|
||||
promoted_at DATETIME NULL,
|
||||
failed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_release_auto_sync_event (channel_id, app, repository, branch, commit_sha),
|
||||
INDEX idx_release_auto_sync_channel_status (channel_id, status, updated_at),
|
||||
INDEX idx_release_auto_sync_gate (gate_operation_id),
|
||||
INDEX idx_release_auto_sync_sync (sync_operation_id),
|
||||
INDEX idx_release_auto_sync_deployment (deployment_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_service_sets (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
channel_id BIGINT UNSIGNED NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
slug VARCHAR(64) NOT NULL,
|
||||
mode VARCHAR(32) NOT NULL DEFAULT 'attach_existing',
|
||||
source_service_set_id BIGINT UNSIGNED NULL,
|
||||
frontend_target_id BIGINT UNSIGNED NULL,
|
||||
api_target_id BIGINT UNSIGNED NULL,
|
||||
database_coolify_target_id BIGINT UNSIGNED NULL,
|
||||
redis_coolify_target_id BIGINT UNSIGNED NULL,
|
||||
minio_coolify_target_id BIGINT UNSIGNED NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'needs_configuration',
|
||||
health_json LONGTEXT NULL,
|
||||
metadata_json LONGTEXT NULL,
|
||||
actor_user_id INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
UNIQUE KEY uq_release_service_sets_slug (slug),
|
||||
INDEX idx_release_service_sets_channel (channel_id, deleted_at),
|
||||
INDEX idx_release_service_sets_source (source_service_set_id),
|
||||
INDEX idx_release_service_sets_status (status, deleted_at),
|
||||
INDEX idx_release_service_sets_frontend_target (frontend_target_id),
|
||||
INDEX idx_release_service_sets_api_target (api_target_id),
|
||||
INDEX idx_release_service_sets_database_target (database_coolify_target_id),
|
||||
INDEX idx_release_service_sets_redis_target (redis_coolify_target_id),
|
||||
INDEX idx_release_service_sets_minio_target (minio_coolify_target_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_deployments (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
channel_id BIGINT UNSIGNED NOT NULL,
|
||||
target_id BIGINT UNSIGNED NULL,
|
||||
version_id BIGINT UNSIGNED NULL,
|
||||
service_set_id BIGINT UNSIGNED NULL,
|
||||
bundle_id BIGINT UNSIGNED NULL,
|
||||
deployment_kind VARCHAR(32) NOT NULL DEFAULT 'single_app',
|
||||
app VARCHAR(16) NOT NULL,
|
||||
active_channel_app_key VARCHAR(96) NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'coolify',
|
||||
repository VARCHAR(255) NULL,
|
||||
branch VARCHAR(128) NULL,
|
||||
commit_sha VARCHAR(64) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
provider_operation_id VARCHAR(128) NULL,
|
||||
deployment_url VARCHAR(512) NULL,
|
||||
actor_user_id INT NULL,
|
||||
requested_payload_json LONGTEXT NULL,
|
||||
result_json LONGTEXT NULL,
|
||||
error_message TEXT NULL,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_release_deployments_channel_status (channel_id, status),
|
||||
INDEX idx_release_deployments_target (target_id),
|
||||
INDEX idx_release_deployments_version (version_id),
|
||||
INDEX idx_release_deployments_service_set (service_set_id),
|
||||
INDEX idx_release_deployments_bundle (bundle_id),
|
||||
INDEX idx_release_deployments_kind (deployment_kind),
|
||||
INDEX idx_release_deployments_commit (commit_sha)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_bundles (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
channel_id BIGINT UNSIGNED NOT NULL,
|
||||
service_set_id BIGINT UNSIGNED NOT NULL,
|
||||
version_label VARCHAR(128) NULL,
|
||||
frontend_version_id BIGINT UNSIGNED NULL,
|
||||
api_version_id BIGINT UNSIGNED NULL,
|
||||
frontend_deployment_id BIGINT UNSIGNED NULL,
|
||||
api_deployment_id BIGINT UNSIGNED NULL,
|
||||
frontend_repository VARCHAR(255) NULL,
|
||||
frontend_branch VARCHAR(128) NULL,
|
||||
frontend_commit_sha VARCHAR(64) NULL,
|
||||
api_repository VARCHAR(255) NULL,
|
||||
api_branch VARCHAR(128) NULL,
|
||||
api_commit_sha VARCHAR(64) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'draft',
|
||||
deployment_result_json LONGTEXT NULL,
|
||||
metadata_json LONGTEXT NULL,
|
||||
actor_user_id INT NULL,
|
||||
deployed_at DATETIME NULL,
|
||||
promoted_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
INDEX idx_release_bundles_channel_status (channel_id, status, deleted_at),
|
||||
INDEX idx_release_bundles_service_set (service_set_id, status, deleted_at),
|
||||
INDEX idx_release_bundles_frontend_version (frontend_version_id),
|
||||
INDEX idx_release_bundles_api_version (api_version_id),
|
||||
INDEX idx_release_bundles_frontend_deployment (frontend_deployment_id),
|
||||
INDEX idx_release_bundles_api_deployment (api_deployment_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_replay_targets (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
target_type VARCHAR(16) NOT NULL,
|
||||
target_id VARCHAR(64) NULL,
|
||||
channel_id BIGINT UNSIGNED NULL,
|
||||
capture_level VARCHAR(32) NOT NULL DEFAULT 'full_redacted',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
expires_at DATETIME NULL,
|
||||
actor_user_id INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
INDEX idx_release_replay_target (target_type, target_id, enabled, deleted_at, expires_at),
|
||||
INDEX idx_release_replay_channel (channel_id, enabled, deleted_at, expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_timeline_sessions (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
trace_id VARCHAR(64) NOT NULL,
|
||||
session_hash VARCHAR(64) NULL,
|
||||
principal_type VARCHAR(16) NULL,
|
||||
principal_id VARCHAR(64) NULL,
|
||||
customer_number INT NULL,
|
||||
channel_id BIGINT UNSIGNED NULL,
|
||||
channel_slug VARCHAR(64) NULL,
|
||||
frontend_version_id BIGINT UNSIGNED NULL,
|
||||
api_version_id BIGINT UNSIGNED NULL,
|
||||
device_type VARCHAR(16) NULL,
|
||||
browser_name VARCHAR(64) NULL,
|
||||
browser_version VARCHAR(64) NULL,
|
||||
os_name VARCHAR(64) NULL,
|
||||
os_version VARCHAR(64) NULL,
|
||||
viewport_width INT NULL,
|
||||
viewport_height INT NULL,
|
||||
device_pixel_ratio DECIMAL(6,3) NULL,
|
||||
frontend_version_label VARCHAR(128) NULL,
|
||||
frontend_commit_sha VARCHAR(128) NULL,
|
||||
api_version_label VARCHAR(128) NULL,
|
||||
api_commit_sha VARCHAR(128) NULL,
|
||||
last_route_path VARCHAR(255) NULL,
|
||||
user_agent VARCHAR(512) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_release_timeline_trace (trace_id),
|
||||
INDEX idx_release_timeline_principal (principal_type, principal_id),
|
||||
INDEX idx_release_timeline_channel (channel_id, channel_slug),
|
||||
INDEX idx_release_timeline_device (device_type),
|
||||
INDEX idx_release_timeline_release (frontend_version_label, api_version_label),
|
||||
INDEX idx_release_timeline_customer (customer_number),
|
||||
INDEX idx_release_timeline_last_seen (last_seen_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_timeline_events (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
timeline_session_id BIGINT UNSIGNED NULL,
|
||||
trace_id VARCHAR(64) NOT NULL,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
severity VARCHAR(16) NOT NULL DEFAULT 'info',
|
||||
module_key VARCHAR(64) NULL,
|
||||
route_path VARCHAR(255) NULL,
|
||||
component VARCHAR(255) NULL,
|
||||
request_id VARCHAR(128) NULL,
|
||||
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
payload_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_release_timeline_events_session (timeline_session_id, occurred_at),
|
||||
INDEX idx_release_timeline_events_trace (trace_id, occurred_at),
|
||||
INDEX idx_release_timeline_events_type (event_type, severity),
|
||||
INDEX idx_release_timeline_events_module (module_key, occurred_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_module_health_snapshots (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
channel_id BIGINT UNSIGNED NULL,
|
||||
module_key VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
reason VARCHAR(512) NULL,
|
||||
payload_json LONGTEXT NULL,
|
||||
checked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_release_module_health_module (module_key, checked_at),
|
||||
INDEX idx_release_module_health_channel (channel_id, module_key, checked_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_operation_runs (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
operation_type VARCHAR(64) NOT NULL,
|
||||
subject_type VARCHAR(64) NULL,
|
||||
subject_id VARCHAR(128) NULL,
|
||||
channel_id BIGINT UNSIGNED NULL,
|
||||
app VARCHAR(16) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
title VARCHAR(255) NULL,
|
||||
summary TEXT NULL,
|
||||
solution_hint TEXT NULL,
|
||||
actor_user_id INT NULL,
|
||||
context_json LONGTEXT NULL,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_release_operation_runs_channel (channel_id, created_at),
|
||||
INDEX idx_release_operation_runs_subject (subject_type, subject_id, created_at),
|
||||
INDEX idx_release_operation_runs_type_status (operation_type, status),
|
||||
INDEX idx_release_operation_runs_created (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_operation_steps (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
operation_run_id BIGINT UNSIGNED NOT NULL,
|
||||
step_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
message TEXT NULL,
|
||||
diagnostic TEXT NULL,
|
||||
solution_hint TEXT NULL,
|
||||
context_json LONGTEXT NULL,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_release_operation_steps_run (operation_run_id, id),
|
||||
INDEX idx_release_operation_steps_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_audit_logs (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
channel_id BIGINT UNSIGNED NULL,
|
||||
deployment_id BIGINT UNSIGNED NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
actor_user_id INT NULL,
|
||||
severity VARCHAR(16) NOT NULL DEFAULT 'info',
|
||||
context_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_release_audit_channel (channel_id, created_at),
|
||||
INDEX idx_release_audit_deployment (deployment_id),
|
||||
INDEX idx_release_audit_action (action)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $sql) {
|
||||
$db->query($sql);
|
||||
}
|
||||
|
||||
self::ensureColumn('release_channel_versions', 'service_set_id', 'BIGINT UNSIGNED NULL AFTER deployment_id');
|
||||
self::ensureColumn('release_channel_versions', 'bundle_id', 'BIGINT UNSIGNED NULL AFTER service_set_id');
|
||||
self::ensureColumn('release_deployments', 'service_set_id', 'BIGINT UNSIGNED NULL AFTER version_id');
|
||||
self::ensureColumn('release_deployments', 'bundle_id', 'BIGINT UNSIGNED NULL AFTER service_set_id');
|
||||
self::ensureColumn('release_deployments', 'deployment_kind', "VARCHAR(32) NOT NULL DEFAULT 'single_app' AFTER bundle_id");
|
||||
self::ensureColumn('release_deployments', 'active_channel_app_key', 'VARCHAR(96) NULL AFTER app');
|
||||
self::ensureColumn('release_timeline_sessions', 'device_type', 'VARCHAR(16) NULL AFTER api_version_id');
|
||||
self::ensureColumn('release_timeline_sessions', 'browser_name', 'VARCHAR(64) NULL AFTER device_type');
|
||||
self::ensureColumn('release_timeline_sessions', 'browser_version', 'VARCHAR(64) NULL AFTER browser_name');
|
||||
self::ensureColumn('release_timeline_sessions', 'os_name', 'VARCHAR(64) NULL AFTER browser_version');
|
||||
self::ensureColumn('release_timeline_sessions', 'os_version', 'VARCHAR(64) NULL AFTER os_name');
|
||||
self::ensureColumn('release_timeline_sessions', 'viewport_width', 'INT NULL AFTER os_version');
|
||||
self::ensureColumn('release_timeline_sessions', 'viewport_height', 'INT NULL AFTER viewport_width');
|
||||
self::ensureColumn('release_timeline_sessions', 'device_pixel_ratio', 'DECIMAL(6,3) NULL AFTER viewport_height');
|
||||
self::ensureColumn('release_timeline_sessions', 'frontend_version_label', 'VARCHAR(128) NULL AFTER device_pixel_ratio');
|
||||
self::ensureColumn('release_timeline_sessions', 'frontend_commit_sha', 'VARCHAR(128) NULL AFTER frontend_version_label');
|
||||
self::ensureColumn('release_timeline_sessions', 'api_version_label', 'VARCHAR(128) NULL AFTER frontend_commit_sha');
|
||||
self::ensureColumn('release_timeline_sessions', 'api_commit_sha', 'VARCHAR(128) NULL AFTER api_version_label');
|
||||
self::ensureColumn('release_timeline_sessions', 'last_route_path', 'VARCHAR(255) NULL AFTER api_commit_sha');
|
||||
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_device', 'device_type');
|
||||
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_release', 'frontend_version_label, api_version_label');
|
||||
self::ensureUniqueIndex('release_deployments', 'uniq_release_deployments_active_channel_app', 'active_channel_app_key');
|
||||
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'enabled', 'true', 'bool');
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'github_webhook_secret', '', 'string');
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'release_gate_token', '', 'string');
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'release_gate_required_for_promotion', 'true', 'bool');
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'github_token', '', 'string');
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'github_api_url', 'https://api.github.com', 'string');
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'default_retention_days', '14', 'int');
|
||||
|
||||
self::ensureDefaultChannels();
|
||||
|
||||
self::$initialized = true;
|
||||
self::$tablesExist = true;
|
||||
}
|
||||
|
||||
public static function tablesExist(): bool
|
||||
{
|
||||
if (self::$tablesExist !== null) {
|
||||
return self::$tablesExist;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
foreach ([
|
||||
'release_channels',
|
||||
'release_versions',
|
||||
'release_channel_versions',
|
||||
'release_assignments',
|
||||
'release_auto_sync_events',
|
||||
'release_service_sets',
|
||||
'release_deployments',
|
||||
'release_bundles',
|
||||
'release_operation_runs',
|
||||
'release_operation_steps',
|
||||
'release_timeline_sessions',
|
||||
'release_timeline_events',
|
||||
] as $table) {
|
||||
$tableSql = $db->escape_string($table);
|
||||
$result = $db->query("SHOW TABLES LIKE '$tableSql'");
|
||||
if ($result === false || $result->num_rows === 0) {
|
||||
self::$tablesExist = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
self::$tablesExist = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function ensureDefaultChannels(): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$channels = [
|
||||
['stable', 'Stable', 'Default production channel.', 1, 1, 'metadata'],
|
||||
['canary', 'Canary', 'Earliest production validation channel.', 1, 0, 'metadata'],
|
||||
['beta', 'Beta', 'Broader pre-stable rollout channel.', 1, 0, 'metadata'],
|
||||
['internal', 'Internal', 'Internal staff and superuser validation channel.', 1, 0, 'metadata'],
|
||||
];
|
||||
|
||||
foreach ($channels as [$slug, $name, $description, $enabled, $default, $captureLevel]) {
|
||||
$db->query(sprintf(
|
||||
"INSERT IGNORE INTO release_channels (slug, name, description, enabled, default_channel, capture_level)
|
||||
VALUES ('%s', '%s', '%s', %d, %d, '%s')",
|
||||
$db->escape_string($slug),
|
||||
$db->escape_string($name),
|
||||
$db->escape_string($description),
|
||||
(int)$enabled,
|
||||
(int)$default,
|
||||
$db->escape_string($captureLevel)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private static function ensureModuleConfigDefault(string $module, string $variable, string $value, string $type): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$moduleSql = $db->escape_string($module);
|
||||
$variableSql = $db->escape_string($variable);
|
||||
$result = $db->query("SELECT value FROM module_config WHERE module = '$moduleSql' AND variable = '$variableSql' LIMIT 1");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$valueSql = $db->escape_string($value);
|
||||
$typeSql = $db->escape_string($type);
|
||||
$db->query("INSERT INTO module_config (module, variable, value, type) VALUES ('$moduleSql', '$variableSql', '$valueSql', '$typeSql')");
|
||||
}
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||
if ($table === '' || $column === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
|
||||
}
|
||||
|
||||
private static function ensureIndex(string $table, string $index, string $columns): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$index = preg_replace('/[^a-zA-Z0-9_]/', '', $index);
|
||||
if ($table === '' || $index === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$indexSql = $db->escape_string($index);
|
||||
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
|
||||
}
|
||||
|
||||
private static function ensureUniqueIndex(string $table, string $index, string $columns): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$index = preg_replace('/[^a-zA-Z0-9_]/', '', $index);
|
||||
if ($table === '' || $index === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$indexSql = $db->escape_string($index);
|
||||
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD UNIQUE KEY `$index` ($columns)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class releasemanager
|
||||
{
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
try {
|
||||
release_manager_schema_bootstrap::ensureTables();
|
||||
global $db;
|
||||
$result = $db->query("SELECT value FROM module_config WHERE module = 'ReleaseManager' AND variable = 'enabled' LIMIT 1");
|
||||
$row = $result ? $result->fetch_assoc() : null;
|
||||
return in_array(strtolower(trim((string)($row['value'] ?? 'true'))), ['1', 'true', 'yes', 'on'], true);
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Aws\S3\S3Client;
|
||||
use mysqli;
|
||||
use Predis\Client as PredisClient;
|
||||
use Throwable;
|
||||
|
||||
class replica_failover_manager
|
||||
{
|
||||
public const KIND_DATABASE = 'database';
|
||||
public const KIND_REDIS = 'redis';
|
||||
public const KIND_MINIO = 'minio';
|
||||
public const DEFAULT_MAX_STATUS_AGE_SECONDS = 90;
|
||||
|
||||
public static function configDefaults(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => false,
|
||||
'database_enabled' => false,
|
||||
'redis_enabled' => false,
|
||||
'minio_enabled' => false,
|
||||
'max_status_age_seconds' => self::DEFAULT_MAX_STATUS_AGE_SECONDS,
|
||||
];
|
||||
}
|
||||
|
||||
public static function normalizeConfig(array $config): array
|
||||
{
|
||||
$normalized = self::configDefaults();
|
||||
foreach (['enabled', 'database_enabled', 'redis_enabled', 'minio_enabled'] as $key) {
|
||||
if (array_key_exists($key, $config)) {
|
||||
$normalized[$key] = self::boolValue($config[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('max_status_age_seconds', $config)) {
|
||||
$normalized['max_status_age_seconds'] = max(1, (int)$config['max_status_age_seconds']);
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
public static function kindEnabled(array $config, string $kind): bool
|
||||
{
|
||||
$config = self::normalizeConfig($config);
|
||||
return $config['enabled'] && !empty($config[$kind . '_enabled']);
|
||||
}
|
||||
|
||||
public static function snapshotHostIsStrictlyFresh(array $host, int $maxAgeSeconds, ?int $now = null): bool
|
||||
{
|
||||
if (($host['role'] ?? '') !== 'replica') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!empty($host['deleted_at'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = self::hostStatus($host);
|
||||
if (($status['status'] ?? '') !== 'ok') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (round((float)($status['replication_percent'] ?? 0), 2) < 100.0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$blockers = $status['blockers'] ?? [];
|
||||
if (is_array($blockers) && $blockers !== []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$checkedAt = self::hostCheckedAt($host, $status);
|
||||
if ($checkedAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (($now ?? time()) - $checkedAt) <= max(1, $maxAgeSeconds);
|
||||
}
|
||||
|
||||
public static function snapshotFailoverCandidate(array $hosts, string $kind, int $maxAgeSeconds, ?int $now = null): ?array
|
||||
{
|
||||
$eligible = array_values(array_filter(
|
||||
$hosts,
|
||||
static fn(array $host): bool => ($host['kind'] ?? '') === $kind
|
||||
&& self::snapshotHostIsStrictlyFresh($host, $maxAgeSeconds, $now)
|
||||
));
|
||||
|
||||
if ($eligible === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($eligible, static function (array $a, array $b) use ($now): int {
|
||||
$aChecked = self::hostCheckedAt($a, self::hostStatus($a)) ?? 0;
|
||||
$bChecked = self::hostCheckedAt($b, self::hostStatus($b)) ?? 0;
|
||||
if ($aChecked === $bChecked) {
|
||||
return (int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0);
|
||||
}
|
||||
return $bChecked <=> $aChecked;
|
||||
});
|
||||
|
||||
return $eligible[0];
|
||||
}
|
||||
|
||||
public static function activeConfigFromHost(string $kind, array $host): ?array
|
||||
{
|
||||
if ($kind === self::KIND_DATABASE) {
|
||||
$database = trim((string)($host['database_name'] ?? $host['database'] ?? ''));
|
||||
$user = trim((string)($host['username'] ?? $host['user'] ?? ''));
|
||||
if ($database === '' || $user === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => isset($host['id']) ? (int)$host['id'] : null,
|
||||
'host' => (string)($host['host'] ?? ''),
|
||||
'port' => (int)($host['port'] ?? 3306) ?: 3306,
|
||||
'database' => $database,
|
||||
'user' => $user,
|
||||
'password_secret' => (string)($host['password_secret'] ?? ''),
|
||||
'ssl_mode' => (string)($host['ssl_mode'] ?? 'DISABLED'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($kind === self::KIND_REDIS) {
|
||||
return [
|
||||
'id' => isset($host['id']) ? (int)$host['id'] : null,
|
||||
'host' => (string)($host['host'] ?? ''),
|
||||
'port' => (int)($host['port'] ?? 6379) ?: 6379,
|
||||
'database' => (int)($host['database_index'] ?? $host['database'] ?? 0),
|
||||
'user' => (string)($host['username'] ?? $host['user'] ?? ''),
|
||||
'password_secret' => (string)($host['password_secret'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
if ($kind === self::KIND_MINIO) {
|
||||
$options = self::jsonDecode($host['options_json'] ?? null);
|
||||
return [
|
||||
'id' => isset($host['id']) ? (int)$host['id'] : null,
|
||||
'endpoint' => self::minioEndpoint($host, $options),
|
||||
'access_key' => (string)($host['username'] ?? $host['access_key'] ?? ''),
|
||||
'secret_key_secret' => (string)($host['password_secret'] ?? ''),
|
||||
'buckets' => is_array($options['buckets'] ?? null) ? array_values($options['buckets']) : [],
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function applyStartupFailoverFromSnapshot(?string $path = null, array $probes = []): array
|
||||
{
|
||||
$snapshot = replication_bootstrap_config::loadSnapshot($path);
|
||||
$failover = is_array($snapshot['failover'] ?? null) ? $snapshot['failover'] : [];
|
||||
$config = self::normalizeConfig(is_array($failover['config'] ?? null) ? $failover['config'] : $failover);
|
||||
$active = is_array($snapshot['active'] ?? null) ? $snapshot['active'] : [];
|
||||
$hostGroups = is_array($failover['hosts'] ?? null) ? $failover['hosts'] : [];
|
||||
$maxAgeSeconds = (int)$config['max_status_age_seconds'];
|
||||
$summary = [];
|
||||
$changed = false;
|
||||
|
||||
$primaryDown = $probes['primary_down'] ?? [self::class, 'activePrimaryIsDown'];
|
||||
$candidateReachable = $probes['candidate_reachable'] ?? [self::class, 'candidateReachable'];
|
||||
$promoteCandidate = $probes['promote_candidate'] ?? [self::class, 'promoteCandidate'];
|
||||
|
||||
foreach ([self::KIND_DATABASE, self::KIND_REDIS, self::KIND_MINIO] as $kind) {
|
||||
if (!self::kindEnabled($config, $kind)) {
|
||||
$summary[$kind] = ['status' => 'skipped', 'reason' => 'disabled'];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!is_array($active[$kind] ?? null)) {
|
||||
$summary[$kind] = ['status' => 'skipped', 'reason' => 'missing_active_primary'];
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!call_user_func($primaryDown, $kind, $active[$kind], $snapshot)) {
|
||||
$summary[$kind] = ['status' => 'skipped', 'reason' => 'primary_healthy'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$hosts = is_array($hostGroups[$kind] ?? null) ? $hostGroups[$kind] : [];
|
||||
$candidate = self::snapshotFailoverCandidate($hosts, $kind, $maxAgeSeconds);
|
||||
if ($candidate === null) {
|
||||
$summary[$kind] = ['status' => 'skipped', 'reason' => 'no_fresh_caught_up_replica'];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!call_user_func($candidateReachable, $kind, $candidate)) {
|
||||
$summary[$kind] = [
|
||||
'status' => 'skipped',
|
||||
'reason' => 'candidate_unreachable',
|
||||
'candidate_id' => (int)($candidate['id'] ?? 0),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
call_user_func($promoteCandidate, $kind, $candidate);
|
||||
$candidateActive = self::activeConfigFromHost($kind, $candidate);
|
||||
if ($candidateActive === null) {
|
||||
$summary[$kind] = [
|
||||
'status' => 'skipped',
|
||||
'reason' => 'candidate_missing_active_config',
|
||||
'candidate_id' => (int)($candidate['id'] ?? 0),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot['active'][$kind] = $candidateActive;
|
||||
$pending = is_array($snapshot['pending_failovers'] ?? null) ? $snapshot['pending_failovers'] : [];
|
||||
$pending[] = [
|
||||
'kind' => $kind,
|
||||
'host_id' => (int)($candidate['id'] ?? 0),
|
||||
'label' => (string)($candidate['label'] ?? ''),
|
||||
'source' => 'startup_snapshot',
|
||||
'promoted_at' => date('c'),
|
||||
];
|
||||
$snapshot['pending_failovers'] = $pending;
|
||||
$summary[$kind] = [
|
||||
'status' => 'promoted',
|
||||
'candidate_id' => (int)($candidate['id'] ?? 0),
|
||||
];
|
||||
$changed = true;
|
||||
} catch (Throwable $throwable) {
|
||||
$summary[$kind] = [
|
||||
'status' => 'failed',
|
||||
'reason' => $throwable->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$snapshot['generated_at'] = date('c');
|
||||
replication_bootstrap_config::writeSnapshot($snapshot, $path);
|
||||
if ($path === null) {
|
||||
replication_bootstrap_config::applyToGlobals($snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'changed' => $changed,
|
||||
'results' => $summary,
|
||||
];
|
||||
}
|
||||
|
||||
public static function activePrimaryIsDown(string $kind, array $activeConfig, array $snapshot = []): bool
|
||||
{
|
||||
try {
|
||||
match ($kind) {
|
||||
self::KIND_DATABASE => self::probeActiveDatabase($activeConfig),
|
||||
self::KIND_REDIS => self::probeActiveRedis($activeConfig),
|
||||
self::KIND_MINIO => self::probeActiveMinio($activeConfig),
|
||||
default => null,
|
||||
};
|
||||
return false;
|
||||
} catch (Throwable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static function candidateReachable(string $kind, array $host): bool
|
||||
{
|
||||
try {
|
||||
match ($kind) {
|
||||
self::KIND_DATABASE => self::probeHostDatabase($host),
|
||||
self::KIND_REDIS => self::probeHostRedis($host),
|
||||
self::KIND_MINIO => self::probeHostMinio($host),
|
||||
default => null,
|
||||
};
|
||||
return true;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function promoteCandidate(string $kind, array $host): void
|
||||
{
|
||||
match ($kind) {
|
||||
self::KIND_DATABASE => self::promoteDatabaseCandidate($host),
|
||||
self::KIND_REDIS => self::promoteRedisCandidate($host),
|
||||
self::KIND_MINIO => self::probeHostMinio($host),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static function probeActiveDatabase(array $config): void
|
||||
{
|
||||
$host = (string)($config['host'] ?? '');
|
||||
$user = (string)($config['user'] ?? '');
|
||||
$database = (string)($config['database'] ?? '');
|
||||
$password = self::activePassword($config, 'password_secret', 'password');
|
||||
self::connectMysqli($host, $user, $password, $database, (int)($config['port'] ?? 3306))->close();
|
||||
}
|
||||
|
||||
private static function probeHostDatabase(array $host): void
|
||||
{
|
||||
$credentials = self::hostCredentials($host);
|
||||
$connection = self::connectMysqli(
|
||||
(string)($host['host'] ?? ''),
|
||||
$credentials['username'],
|
||||
$credentials['password'],
|
||||
(string)($host['database_name'] ?? ''),
|
||||
(int)($host['port'] ?? 3306)
|
||||
);
|
||||
$connection->close();
|
||||
}
|
||||
|
||||
private static function promoteDatabaseCandidate(array $host): void
|
||||
{
|
||||
$credentials = self::hostCredentials($host);
|
||||
$user = $credentials['admin_username'] !== '' ? $credentials['admin_username'] : $credentials['username'];
|
||||
$password = $credentials['admin_password'] !== '' ? $credentials['admin_password'] : $credentials['password'];
|
||||
$connection = self::connectMysqli(
|
||||
(string)($host['host'] ?? ''),
|
||||
$user,
|
||||
$password,
|
||||
(string)($host['database_name'] ?? ''),
|
||||
(int)($host['port'] ?? 3306)
|
||||
);
|
||||
|
||||
try {
|
||||
foreach (['STOP REPLICA', 'STOP SLAVE'] as $statement) {
|
||||
try {
|
||||
$connection->query($statement);
|
||||
break;
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
foreach (['SET GLOBAL super_read_only = OFF', 'SET GLOBAL read_only = OFF'] as $statement) {
|
||||
try {
|
||||
$connection->query($statement);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$connection->close();
|
||||
}
|
||||
}
|
||||
|
||||
private static function probeActiveRedis(array $config): void
|
||||
{
|
||||
self::redisClientFromConfig([
|
||||
'host' => (string)($config['host'] ?? ''),
|
||||
'port' => (int)($config['port'] ?? 6379),
|
||||
'database' => (int)($config['database'] ?? 0),
|
||||
'user' => (string)($config['user'] ?? ''),
|
||||
'password' => self::activePassword($config, 'password_secret', 'password'),
|
||||
])->ping();
|
||||
}
|
||||
|
||||
private static function probeHostRedis(array $host): void
|
||||
{
|
||||
self::redisClientFromHost($host)->ping();
|
||||
}
|
||||
|
||||
private static function promoteRedisCandidate(array $host): void
|
||||
{
|
||||
$client = self::redisClientFromHost($host);
|
||||
$client->executeRaw(['REPLICAOF', 'NO', 'ONE']);
|
||||
try {
|
||||
$client->executeRaw(['CONFIG', 'REWRITE']);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private static function probeActiveMinio(array $config): void
|
||||
{
|
||||
self::minioClientFromConfig([
|
||||
'endpoint' => (string)($config['endpoint'] ?? ''),
|
||||
'access_key' => (string)($config['access_key'] ?? $config['user'] ?? ''),
|
||||
'secret_key' => self::activePassword($config, 'secret_key_secret', 'secret_key'),
|
||||
])->listBuckets();
|
||||
}
|
||||
|
||||
private static function probeHostMinio(array $host): void
|
||||
{
|
||||
self::minioClientFromHost($host)->listBuckets();
|
||||
}
|
||||
|
||||
private static function connectMysqli(string $host, string $user, string $password, string $database, int $port): mysqli
|
||||
{
|
||||
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
|
||||
$connection = mysqli_init();
|
||||
$connection->options(MYSQLI_OPT_CONNECT_TIMEOUT, 2);
|
||||
$connection->real_connect($host, $user, $password, $database, $port ?: 3306);
|
||||
$connection->set_charset('utf8mb4');
|
||||
return $connection;
|
||||
}
|
||||
|
||||
private static function redisClientFromHost(array $host): PredisClient
|
||||
{
|
||||
$credentials = self::hostCredentials($host);
|
||||
return self::redisClientFromConfig([
|
||||
'host' => (string)($host['host'] ?? ''),
|
||||
'port' => (int)($host['port'] ?? 6379),
|
||||
'database' => (int)($host['database_index'] ?? 0),
|
||||
'user' => $credentials['username'],
|
||||
'password' => $credentials['password'],
|
||||
]);
|
||||
}
|
||||
|
||||
private static function redisClientFromConfig(array $config): PredisClient
|
||||
{
|
||||
$params = [
|
||||
'scheme' => 'tcp',
|
||||
'host' => (string)$config['host'],
|
||||
'port' => (int)$config['port'],
|
||||
'database' => (int)$config['database'],
|
||||
'password' => (string)$config['password'],
|
||||
'timeout' => 2.0,
|
||||
'read_write_timeout' => 2.0,
|
||||
];
|
||||
if (($config['user'] ?? '') !== '' && $config['user'] !== 'default') {
|
||||
$params['username'] = (string)$config['user'];
|
||||
}
|
||||
return new PredisClient($params);
|
||||
}
|
||||
|
||||
private static function minioClientFromHost(array $host): S3Client
|
||||
{
|
||||
$credentials = self::hostCredentials($host);
|
||||
$options = self::jsonDecode($host['options_json'] ?? null);
|
||||
return self::minioClientFromConfig([
|
||||
'endpoint' => self::minioEndpoint($host, $options),
|
||||
'access_key' => $credentials['username'],
|
||||
'secret_key' => $credentials['password'],
|
||||
]);
|
||||
}
|
||||
|
||||
private static function minioClientFromConfig(array $config): S3Client
|
||||
{
|
||||
return new S3Client([
|
||||
'version' => 'latest',
|
||||
'region' => 'us-east-1',
|
||||
'endpoint' => (string)$config['endpoint'],
|
||||
'use_path_style_endpoint' => true,
|
||||
'credentials' => [
|
||||
'key' => (string)$config['access_key'],
|
||||
'secret' => (string)$config['secret_key'],
|
||||
],
|
||||
'http' => [
|
||||
'connect_timeout' => 2,
|
||||
'timeout' => 2,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private static function minioEndpoint(array $host, array $options): string
|
||||
{
|
||||
$endpoint = trim((string)($options['endpoint'] ?? ''));
|
||||
if ($endpoint !== '') {
|
||||
return $endpoint;
|
||||
}
|
||||
|
||||
$scheme = strtolower(trim((string)($options['scheme'] ?? 'http')));
|
||||
if ($scheme !== 'https') {
|
||||
$scheme = 'http';
|
||||
}
|
||||
|
||||
return $scheme . '://' . (string)($host['host'] ?? '') . ':' . ((int)($host['port'] ?? 9000) ?: 9000);
|
||||
}
|
||||
|
||||
private static function hostCredentials(array $host): array
|
||||
{
|
||||
return [
|
||||
'username' => (string)($host['username'] ?? ''),
|
||||
'password' => replication_secret_box::decrypt($host['password_secret'] ?? ''),
|
||||
'admin_username' => (string)($host['admin_username'] ?? ''),
|
||||
'admin_password' => replication_secret_box::decrypt($host['admin_password_secret'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private static function activePassword(array $config, string $secretKey, string $plainKey): string
|
||||
{
|
||||
if (!empty($config[$secretKey])) {
|
||||
return replication_secret_box::decrypt((string)$config[$secretKey]);
|
||||
}
|
||||
|
||||
return (string)($config[$plainKey] ?? '');
|
||||
}
|
||||
|
||||
private static function hostStatus(array $host): array
|
||||
{
|
||||
if (isset($host['last_status']) && is_array($host['last_status'])) {
|
||||
return $host['last_status'];
|
||||
}
|
||||
|
||||
return self::jsonDecode($host['last_status_json'] ?? null);
|
||||
}
|
||||
|
||||
private static function hostCheckedAt(array $host, array $status): ?int
|
||||
{
|
||||
$raw = $host['last_checked_at'] ?? $status['checked_at'] ?? null;
|
||||
if (!is_string($raw) || trim($raw) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$timestamp = strtotime($raw);
|
||||
return $timestamp === false ? null : $timestamp;
|
||||
}
|
||||
|
||||
private static function boolValue(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private static function jsonDecode(mixed $value): array
|
||||
{
|
||||
if (!is_string($value) || trim($value) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class replication_bootstrap_config
|
||||
{
|
||||
public static function snapshotPath(): string
|
||||
{
|
||||
return self::storageDir() . DIRECTORY_SEPARATOR . 'replication-bootstrap.json';
|
||||
}
|
||||
|
||||
public static function loadSnapshot(?string $path = null): array
|
||||
{
|
||||
$path = $path ?: self::snapshotPath();
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)file_get_contents($path), true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
public static function writeSnapshot(array $snapshot, ?string $path = null): void
|
||||
{
|
||||
$path = $path ?: self::snapshotPath();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0770, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException('Could not create replication bootstrap snapshot directory.');
|
||||
}
|
||||
|
||||
$snapshot = array_replace([
|
||||
'version' => 1,
|
||||
'generated_at' => date('c'),
|
||||
], $snapshot);
|
||||
|
||||
$json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException('Could not encode replication bootstrap snapshot.');
|
||||
}
|
||||
|
||||
$tempPath = tempnam($dir, 'replication-bootstrap-');
|
||||
if ($tempPath === false) {
|
||||
throw new RuntimeException('Could not create replication bootstrap snapshot temp file.');
|
||||
}
|
||||
|
||||
try {
|
||||
if (file_put_contents($tempPath, $json . PHP_EOL, LOCK_EX) === false) {
|
||||
throw new RuntimeException('Could not write replication bootstrap snapshot.');
|
||||
}
|
||||
|
||||
if (!rename($tempPath, $path)) {
|
||||
throw new RuntimeException('Could not atomically replace replication bootstrap snapshot.');
|
||||
}
|
||||
} finally {
|
||||
if (is_file($tempPath)) {
|
||||
@unlink($tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function applyToGlobals(array $snapshot): void
|
||||
{
|
||||
try {
|
||||
$active = is_array($snapshot['active'] ?? null) ? $snapshot['active'] : [];
|
||||
$database = self::activeDatabaseConfigFromSnapshot($active['database'] ?? null);
|
||||
$redis = self::activeRedisConfigFromSnapshot($active['redis'] ?? null);
|
||||
$minio = self::activeMinioConfigFromSnapshot($active['minio'] ?? null);
|
||||
|
||||
if ($database !== null) {
|
||||
$GLOBALS['CONFIG_DB'] = array_merge($GLOBALS['CONFIG_DB'] ?? [], $database);
|
||||
}
|
||||
|
||||
if ($redis !== null) {
|
||||
$GLOBALS['REDIS_CONFIG'] = array_merge($GLOBALS['REDIS_CONFIG'] ?? [], $redis);
|
||||
}
|
||||
|
||||
if ($minio !== null) {
|
||||
$GLOBALS['MINIO'] = array_merge($GLOBALS['MINIO'] ?? [], $minio);
|
||||
}
|
||||
} catch (Throwable $throwable) {
|
||||
error_log('[replication-bootstrap] Falling back to environment configuration: ' . $throwable->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static function activeDatabaseConfigFromSnapshot(mixed $config): ?array
|
||||
{
|
||||
if (!is_array($config)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$host = trim((string)($config['host'] ?? ''));
|
||||
$database = trim((string)($config['database'] ?? ''));
|
||||
$user = trim((string)($config['user'] ?? ''));
|
||||
if ($host === '' || $database === '' || $user === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'host' => $host,
|
||||
'user' => $user,
|
||||
'password' => replication_secret_box::decrypt($config['password_secret'] ?? ''),
|
||||
'database' => $database,
|
||||
'port' => (int)($config['port'] ?? 3306) ?: 3306,
|
||||
'ssl_mode' => (string)($config['ssl_mode'] ?? 'DISABLED'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function activeRedisConfigFromSnapshot(mixed $config): ?array
|
||||
{
|
||||
if (!is_array($config)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$host = trim((string)($config['host'] ?? ''));
|
||||
if ($host === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'host' => $host,
|
||||
'user' => (string)($config['user'] ?? ''),
|
||||
'password' => replication_secret_box::decrypt($config['password_secret'] ?? ''),
|
||||
'database' => (int)($config['database'] ?? 0),
|
||||
'port' => (int)($config['port'] ?? 6379) ?: 6379,
|
||||
];
|
||||
}
|
||||
|
||||
public static function activeMinioConfigFromSnapshot(mixed $config): ?array
|
||||
{
|
||||
if (!is_array($config)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$endpoint = trim((string)($config['endpoint'] ?? ''));
|
||||
$accessKey = trim((string)($config['access_key'] ?? $config['user'] ?? ''));
|
||||
if ($endpoint === '' || $accessKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'endpoint' => $endpoint,
|
||||
'access_key' => $accessKey,
|
||||
'secret_key' => replication_secret_box::decrypt($config['secret_key_secret'] ?? $config['password_secret'] ?? ''),
|
||||
'buckets' => is_array($config['buckets'] ?? null) ? array_values($config['buckets']) : ($config['buckets'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
private static function storageDir(): string
|
||||
{
|
||||
$root = defined('WD') ? WD : dirname(__DIR__);
|
||||
return $root . DIRECTORY_SEPARATOR . 'storage';
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class replication_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
$queries = [
|
||||
"CREATE TABLE IF NOT EXISTS replication_hosts (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
kind VARCHAR(16) NOT NULL,
|
||||
label VARCHAR(128) NOT NULL,
|
||||
host VARCHAR(255) NOT NULL,
|
||||
port INT UNSIGNED NOT NULL,
|
||||
database_name VARCHAR(128) NULL,
|
||||
database_index INT NULL,
|
||||
username VARCHAR(128) NULL,
|
||||
password_secret TEXT NULL,
|
||||
admin_username VARCHAR(128) NULL,
|
||||
admin_password_secret TEXT NULL,
|
||||
replication_username VARCHAR(128) NULL,
|
||||
replication_password_secret TEXT NULL,
|
||||
role VARCHAR(16) NOT NULL DEFAULT 'replica',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
||||
replication_source_id BIGINT UNSIGNED NULL,
|
||||
ssl_mode VARCHAR(32) NULL,
|
||||
options_json LONGTEXT NULL,
|
||||
last_status_json LONGTEXT NULL,
|
||||
last_checked_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
INDEX idx_replication_hosts_kind_role (kind, role),
|
||||
INDEX idx_replication_hosts_kind_status (kind, status),
|
||||
INDEX idx_replication_hosts_source (replication_source_id),
|
||||
INDEX idx_replication_hosts_deleted_at (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS replication_operations (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
kind VARCHAR(16) NOT NULL,
|
||||
host_id BIGINT UNSIGNED NOT NULL,
|
||||
operation VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL,
|
||||
progress_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
|
||||
message VARCHAR(512) NULL,
|
||||
error_message TEXT NULL,
|
||||
context_json LONGTEXT NULL,
|
||||
actor_user_id INT NULL,
|
||||
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_replication_operations_host (host_id),
|
||||
INDEX idx_replication_operations_kind_status (kind, status),
|
||||
INDEX idx_replication_operations_operation (operation)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS replication_status_snapshots (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
host_id BIGINT UNSIGNED NOT NULL,
|
||||
kind VARCHAR(16) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL,
|
||||
replication_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
|
||||
lag_seconds INT NULL,
|
||||
blockers_json LONGTEXT NULL,
|
||||
raw_status_json LONGTEXT NULL,
|
||||
checked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_replication_status_host_checked (host_id, checked_at),
|
||||
INDEX idx_replication_status_kind_checked (kind, checked_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
"CREATE TABLE IF NOT EXISTS replication_audit_logs (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
kind VARCHAR(16) NOT NULL,
|
||||
host_id BIGINT UNSIGNED NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
actor_user_id INT NULL,
|
||||
severity VARCHAR(16) NOT NULL DEFAULT 'info',
|
||||
context_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_replication_audit_kind_host (kind, host_id),
|
||||
INDEX idx_replication_audit_action (action),
|
||||
INDEX idx_replication_audit_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $sql) {
|
||||
$db->query($sql);
|
||||
}
|
||||
|
||||
self::ensureColumn('replication_operations', 'progress_percent', "DECIMAL(5,2) NOT NULL DEFAULT 0.00");
|
||||
self::ensureColumn('replication_operations', 'message', 'VARCHAR(512) NULL');
|
||||
self::ensureColumn('replication_operations', 'context_json', 'LONGTEXT NULL');
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||
if ($table === '' || $column === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class replication_secret_box
|
||||
{
|
||||
private const PREFIX = 'twsec:v1:';
|
||||
private const CIPHER = 'aes-256-gcm';
|
||||
|
||||
public static function encrypt(string $plaintext): string
|
||||
{
|
||||
$nonce = random_bytes(12);
|
||||
$tag = '';
|
||||
$ciphertext = openssl_encrypt(
|
||||
$plaintext,
|
||||
self::CIPHER,
|
||||
self::key(),
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag,
|
||||
'',
|
||||
16
|
||||
);
|
||||
|
||||
if ($ciphertext === false || $tag === '') {
|
||||
throw new RuntimeException('Secret encryption failed.');
|
||||
}
|
||||
|
||||
return self::PREFIX . base64_encode(json_encode([
|
||||
'nonce' => base64_encode($nonce),
|
||||
'tag' => base64_encode($tag),
|
||||
'ciphertext' => base64_encode($ciphertext),
|
||||
], JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
public static function decrypt(?string $secret): string
|
||||
{
|
||||
$secret = (string)$secret;
|
||||
if ($secret === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!str_starts_with($secret, self::PREFIX)) {
|
||||
return $secret;
|
||||
}
|
||||
|
||||
$payload = json_decode(base64_decode(substr($secret, strlen(self::PREFIX)), true) ?: '', true);
|
||||
if (!is_array($payload)) {
|
||||
throw new RuntimeException('Encrypted secret payload is invalid.');
|
||||
}
|
||||
|
||||
$nonce = base64_decode((string)($payload['nonce'] ?? ''), true);
|
||||
$tag = base64_decode((string)($payload['tag'] ?? ''), true);
|
||||
$ciphertext = base64_decode((string)($payload['ciphertext'] ?? ''), true);
|
||||
|
||||
if ($nonce === false || $tag === false || $ciphertext === false) {
|
||||
throw new RuntimeException('Encrypted secret payload is incomplete.');
|
||||
}
|
||||
|
||||
$plaintext = openssl_decrypt(
|
||||
$ciphertext,
|
||||
self::CIPHER,
|
||||
self::key(),
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag
|
||||
);
|
||||
|
||||
if ($plaintext === false) {
|
||||
throw new RuntimeException('Secret decryption failed.');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
|
||||
public static function mask(?string $value): string
|
||||
{
|
||||
$value = (string)$value;
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$length = strlen($value);
|
||||
if ($length <= 4) {
|
||||
return str_repeat('*', $length);
|
||||
}
|
||||
|
||||
return substr($value, 0, 2) . str_repeat('*', max(4, $length - 4)) . substr($value, -2);
|
||||
}
|
||||
|
||||
private static function key(): string
|
||||
{
|
||||
$keyMaterial = (string)($GLOBALS['ENCRYPTION_KEY'] ?? getenv('ENCRYPTION_KEY') ?: '');
|
||||
if (trim($keyMaterial) === '') {
|
||||
throw new RuntimeException('ENCRYPTION_KEY is required for replication secret encryption.');
|
||||
}
|
||||
|
||||
return hash('sha256', $keyMaterial, true);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ class response implements response_i
|
||||
private array $meta = [];
|
||||
private array $includes = [];
|
||||
private users_o $users_o;
|
||||
private ?array $jsonRequestBody = null;
|
||||
|
||||
#[NoReturn] public function success(mixed $data, int $status = null): void
|
||||
{
|
||||
@@ -47,6 +48,11 @@ class response implements response_i
|
||||
'data' => $this->get_data()
|
||||
]);
|
||||
}
|
||||
try {
|
||||
release_manager::recordBackendFailure($success, $data, $status ?? ($success ? 200 : 400));
|
||||
} catch (\Throwable) {
|
||||
// Release failure telemetry is best-effort and must not block responses.
|
||||
}
|
||||
echo json_encode([
|
||||
'success' => $success,
|
||||
'data' => $data,
|
||||
@@ -175,35 +181,7 @@ class response implements response_i
|
||||
|
||||
public function getRequestParameter(string $key): mixed
|
||||
{
|
||||
$data = [];
|
||||
// Get the request data if the method is POST, PUT or PATCH
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
}
|
||||
// Get the request data if the method is GET, DELETE or OPTIONS
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || $_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
$data = $_GET;
|
||||
}
|
||||
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
|
||||
// If the data key is not set, try to get it from the opposite method
|
||||
if (!array_key_exists($key, $data)) {
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
|
||||
$data = $_GET;
|
||||
} else {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
|
||||
// Return the data
|
||||
return $data[$key] ?? null;
|
||||
return $this->requestParametersForMethod()[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,21 +190,7 @@ class response implements response_i
|
||||
*/
|
||||
public function getAllRequestParameters(): array
|
||||
{
|
||||
$data = [];
|
||||
// Get the request data if the method is POST, PUT or PATCH
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
}
|
||||
// Get the request data if the method is GET, DELETE or OPTIONS
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || $_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
$data = $_GET;
|
||||
}
|
||||
|
||||
if (!is_array($data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $data;
|
||||
return $this->requestParametersForMethod();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -237,21 +201,44 @@ class response implements response_i
|
||||
*/
|
||||
public function isRequestParameterSet(string $key): bool
|
||||
{
|
||||
$data = [];
|
||||
// Get the request data if the method is POST, PUT or PATCH
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'PATCH') {
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
}
|
||||
// Get the request data if the method is GET, DELETE or OPTIONS
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' || $_SERVER['REQUEST_METHOD'] === 'DELETE' || $_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
$data = $_GET;
|
||||
return array_key_exists($key, $this->requestParametersForMethod());
|
||||
}
|
||||
|
||||
private function requestParametersForMethod(): array
|
||||
{
|
||||
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
|
||||
if (in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
|
||||
return array_replace($_GET, $this->jsonRequestBody());
|
||||
}
|
||||
|
||||
if (!is_array($data)) {
|
||||
return false;
|
||||
if ($method === 'DELETE') {
|
||||
return array_replace($_GET, $this->jsonRequestBody());
|
||||
}
|
||||
|
||||
return array_key_exists($key, $data);
|
||||
if ($method === 'GET' || $method === 'OPTIONS') {
|
||||
return $_GET;
|
||||
}
|
||||
|
||||
return $this->jsonRequestBody();
|
||||
}
|
||||
|
||||
private function jsonRequestBody(): array
|
||||
{
|
||||
if ($this->jsonRequestBody !== null) {
|
||||
return $this->jsonRequestBody;
|
||||
}
|
||||
|
||||
$decoded = json_decode($this->rawRequestBody(), true);
|
||||
$this->jsonRequestBody = is_array($decoded) ? $decoded : [];
|
||||
|
||||
return $this->jsonRequestBody;
|
||||
}
|
||||
|
||||
protected function rawRequestBody(): string
|
||||
{
|
||||
$body = file_get_contents('php://input');
|
||||
return is_string($body) ? $body : '';
|
||||
}
|
||||
|
||||
public function parseFilters(?string $filters): array|null
|
||||
|
||||
@@ -139,6 +139,28 @@ class selfserve_schema_bootstrap
|
||||
UNIQUE KEY uniq_selfserve_vhw_department (department_id),
|
||||
INDEX idx_selfserve_vhw_dept_updated (department_id, updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS department_selfserve_path_confirmations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
department_id INT NOT NULL,
|
||||
lane_id INT NULL,
|
||||
vehicle_type_id INT NULL,
|
||||
config_version_id INT NULL,
|
||||
config_source VARCHAR(32) NOT NULL DEFAULT 'draft',
|
||||
path_signature VARCHAR(128) NOT NULL,
|
||||
result_signature VARCHAR(128) NOT NULL,
|
||||
answers_json JSON NOT NULL,
|
||||
result_json JSON NOT NULL,
|
||||
scope_json JSON NULL,
|
||||
confirmed_by INT NULL,
|
||||
confirmed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
stale_reason VARCHAR(255) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
INDEX idx_selfserve_path_conf_department_scope (department_id, lane_id, vehicle_type_id, config_version_id),
|
||||
INDEX idx_selfserve_path_conf_signature (department_id, config_version_id, path_signature)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $sql) {
|
||||
|
||||
@@ -27,6 +27,9 @@ class superuser_system_status_service
|
||||
$dependencies['database']['status'] ?? 'down',
|
||||
$dependencies['redis']['status'] ?? 'down',
|
||||
$dependencies['minio']['status'] ?? 'down',
|
||||
$dependencies['database']['replication']['status'] ?? 'not_configured',
|
||||
$dependencies['redis']['replication']['status'] ?? 'not_configured',
|
||||
$dependencies['minio']['replication']['status'] ?? 'not_configured',
|
||||
];
|
||||
foreach ($modules as $module) {
|
||||
if (($module['enabled'] ?? false) === true) {
|
||||
@@ -295,6 +298,16 @@ class superuser_system_status_service
|
||||
$database = $this->probeDatabase();
|
||||
$redis = $this->probeRedis();
|
||||
$minio = $this->probeMinio();
|
||||
try {
|
||||
$replicationManager = new replication_manager();
|
||||
$database['replication'] = $replicationManager->dependencyReplication('database');
|
||||
$redis['replication'] = $replicationManager->dependencyReplication('redis');
|
||||
$minio['replication'] = $replicationManager->dependencyReplication('minio');
|
||||
} catch (Throwable $throwable) {
|
||||
$database['replication'] = $this->replicationStatusFallback('database', $throwable);
|
||||
$redis['replication'] = $this->replicationStatusFallback('redis', $throwable);
|
||||
$minio['replication'] = $this->replicationStatusFallback('minio', $throwable);
|
||||
}
|
||||
|
||||
if (($redis['status'] ?? '') === 'down') {
|
||||
$this->pushWarning(
|
||||
@@ -320,6 +333,19 @@ class superuser_system_status_service
|
||||
];
|
||||
}
|
||||
|
||||
private function replicationStatusFallback(string $kind, Throwable $throwable): array
|
||||
{
|
||||
return [
|
||||
'status' => 'degraded',
|
||||
'min_percent' => 0.0,
|
||||
'average_percent' => 0.0,
|
||||
'replicas' => [],
|
||||
'blockers' => [
|
||||
'Replication status for ' . $kind . ' could not be loaded: ' . $throwable->getMessage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function probeCpu(array &$warnings): array
|
||||
{
|
||||
$checkedAt = date('c');
|
||||
@@ -503,7 +529,7 @@ class superuser_system_status_service
|
||||
|
||||
protected function minioBuckets(): array
|
||||
{
|
||||
return ['attachments', 'backups', 'invoices', 'pdfs', 'uploads', 'truckwashdev'];
|
||||
return replication_manager::normalizeMinioBuckets($GLOBALS['MINIO']['buckets'] ?? ['attachments', 'backups', 'invoices', 'pdfs', 'uploads', 'truckwashdev']);
|
||||
}
|
||||
|
||||
protected function collectModules(bool $force, array &$warnings): array
|
||||
@@ -816,11 +842,89 @@ class superuser_system_status_service
|
||||
['key' => 'licenseplaterecognizer', 'module' => 'licenseplaterecognizer', 'enabled_variable' => 'enabled', 'required' => ['api_key'], 'probe' => fn(array $config): array => $this->probeLicensePlateRecognizerModule($config)],
|
||||
['key' => 'virkdata', 'module' => 'virkdata', 'enabled_variable' => 'enabled', 'required' => ['secret_key']],
|
||||
['key' => 'shelly', 'module' => 'shelly', 'enabled_variable' => 'enabled', 'required' => ['server_url', 'secret_key'], 'probe' => fn(array $config): array => $this->probeShellyModule($config)],
|
||||
['key' => 'coolify', 'module' => 'Coolify', 'enabled_variable' => 'enabled', 'required' => [], 'probe' => fn(array $config): array => $this->probeCoolifyModule($config)],
|
||||
['key' => 'releasemanager', 'module' => 'ReleaseManager', 'enabled_variable' => 'enabled', 'required' => [], 'always_enabled' => true, 'probe' => fn(array $config): array => $this->probeReleaseManagerModule($config)],
|
||||
['key' => 'selfserve', 'module' => 'selfserve', 'enabled_variable' => 'enabled', 'required' => ['machine_wash_minutes_included', 'minute_product'], 'probe' => fn(array $config): array => $this->probeSelfserveModule($config)],
|
||||
['key' => 'bird', 'module' => 'bird', 'enabled_variable' => 'enabled', 'required' => ['server_url', 'api_key', 'channelId', 'workplaceId'], 'probe' => fn(array $config): array => $this->probeBirdModule($config)],
|
||||
];
|
||||
}
|
||||
|
||||
protected function probeReleaseManagerModule(array $config): array
|
||||
{
|
||||
return (new release_manager())->healthProbe();
|
||||
}
|
||||
|
||||
protected function probeCoolifyModule(array $config): array
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
|
||||
try {
|
||||
$summary = (new coolify_manager())->summary();
|
||||
$instances = is_array($summary['instances'] ?? null) ? $summary['instances'] : [];
|
||||
$targets = is_array($summary['targets'] ?? null) ? $summary['targets'] : [];
|
||||
|
||||
if ($instances === []) {
|
||||
return [
|
||||
'status' => 'not_configured',
|
||||
'status_reason' => 'Coolify is enabled, but no Coolify API instance is configured.',
|
||||
'status_reason_key' => 'coolify_instances_missing',
|
||||
'status_reason_params' => [],
|
||||
'checked_at' => date('c'),
|
||||
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||
];
|
||||
}
|
||||
|
||||
$downInstances = array_values(array_filter($instances, static fn(array $instance): bool => ($instance['status'] ?? 'unknown') === 'down'));
|
||||
$blockedTargets = array_values(array_filter($targets, static function (array $target): bool {
|
||||
$state = (string)($target['availability_state'] ?? 'degraded');
|
||||
return $state === 'destructive_action_required' || str_contains($state, 'blocked');
|
||||
}));
|
||||
$failedTargets = array_values(array_filter($targets, static function (array $target): bool {
|
||||
return in_array((string)($target['deployment_status'] ?? ''), ['reconcile_failed', 'restart_failed', 'provision_blocked'], true);
|
||||
}));
|
||||
|
||||
$status = 'ok';
|
||||
$reason = 'Coolify deployment state is available.';
|
||||
$reasonKey = 'coolify_available';
|
||||
if ($downInstances !== []) {
|
||||
$status = 'down';
|
||||
$reason = 'One or more Coolify API instances are unreachable.';
|
||||
$reasonKey = 'coolify_instances_down';
|
||||
} elseif ($blockedTargets !== [] || $failedTargets !== []) {
|
||||
$status = 'degraded';
|
||||
$reason = 'One or more Coolify targets need operator attention before availability can be protected.';
|
||||
$reasonKey = 'coolify_targets_need_attention';
|
||||
} elseif ($targets === []) {
|
||||
$status = 'degraded';
|
||||
$reason = 'Coolify is connected, but no replicated infrastructure targets are managed yet.';
|
||||
$reasonKey = 'coolify_targets_missing';
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'status_reason' => $reason,
|
||||
'status_reason_key' => $reasonKey,
|
||||
'status_reason_params' => [
|
||||
'instances' => count($instances),
|
||||
'targets' => count($targets),
|
||||
'blocked_targets' => count($blockedTargets),
|
||||
'failed_targets' => count($failedTargets),
|
||||
],
|
||||
'checked_at' => date('c'),
|
||||
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||
];
|
||||
} catch (Throwable $throwable) {
|
||||
return [
|
||||
'status' => 'down',
|
||||
'status_reason' => 'Coolify module probe failed: ' . $throwable->getMessage(),
|
||||
'status_reason_key' => 'coolify_probe_failed',
|
||||
'status_reason_params' => ['error' => $throwable->getMessage()],
|
||||
'checked_at' => date('c'),
|
||||
'latency_ms' => round((microtime(true) - $startedAt) * 1000, 2),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
protected function probeEconomicModule(array $config): array
|
||||
{
|
||||
$appSecretToken = trim((string)($GLOBALS['ECONOMIC_API']['app_secret_token'] ?? ''));
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
final class workfeed_employee_name_formatter
|
||||
{
|
||||
/**
|
||||
* @param array<int,string> $firstNamePaths
|
||||
* @param array<int,string> $lastNamePaths
|
||||
* @param array<int,string> $fallbackNamePaths
|
||||
*/
|
||||
public static function fromRecord(
|
||||
mixed $record,
|
||||
array $firstNamePaths,
|
||||
array $lastNamePaths,
|
||||
array $fallbackNamePaths = [],
|
||||
?string $employeeId = null
|
||||
): ?string {
|
||||
$firstName = self::firstTextValueByPath($record, $firstNamePaths);
|
||||
$lastName = self::firstTextValueByPath($record, $lastNamePaths);
|
||||
|
||||
$schemaName = self::joinNameParts($firstName, $lastName);
|
||||
if ($schemaName !== null) {
|
||||
return $schemaName;
|
||||
}
|
||||
|
||||
foreach ($fallbackNamePaths as $path) {
|
||||
$name = self::normalizeTextValue(self::valueByPath($record, $path));
|
||||
if ($name !== null && !self::isMissingDisplayName($name, $employeeId)) {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function isMissingDisplayName(?string $employeeName, ?string $employeeId = null): bool
|
||||
{
|
||||
if ($employeeName === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($employeeId !== null && strcasecmp($employeeName, 'Employee ' . $employeeId) === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return strcasecmp($employeeName, 'Unknown employee') === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $paths
|
||||
*/
|
||||
private static function firstTextValueByPath(mixed $record, array $paths): ?string
|
||||
{
|
||||
foreach ($paths as $path) {
|
||||
$value = self::normalizeTextValue(self::valueByPath($record, $path));
|
||||
if ($value !== null) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function joinNameParts(?string $firstName, ?string $lastName): ?string
|
||||
{
|
||||
$name = trim((string)($firstName ?? '') . ' ' . (string)($lastName ?? ''));
|
||||
|
||||
return $name !== '' ? $name : null;
|
||||
}
|
||||
|
||||
private static function valueByPath(mixed $record, string $path): mixed
|
||||
{
|
||||
$segments = explode('.', $path);
|
||||
$value = $record;
|
||||
foreach ($segments as $segment) {
|
||||
if (is_array($value) && array_key_exists($segment, $value)) {
|
||||
$value = $value[$segment];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_object($value) && isset($value->{$segment})) {
|
||||
$value = $value->{$segment};
|
||||
continue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function normalizeTextValue(mixed $value): ?string
|
||||
{
|
||||
if (!is_scalar($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = trim((string)$value);
|
||||
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
}
|
||||
@@ -21,16 +21,27 @@ final class workfeed_shift_time_resolver
|
||||
return null;
|
||||
}
|
||||
|
||||
$has_approval = self::hasShiftApproval($record);
|
||||
$update_time = self::parseDateTimeValue($record['updateTime'] ?? null);
|
||||
$can_use_saved_bounds = $has_approval || $update_time !== null;
|
||||
|
||||
$actual_start = self::firstDateTimeFromPaths($record, [
|
||||
'checkIn.time',
|
||||
'checkIn',
|
||||
'actualStart',
|
||||
'actualStartTime',
|
||||
'clockIn',
|
||||
'clockInTime',
|
||||
'start',
|
||||
'startTime',
|
||||
'from',
|
||||
'approval.originalStart',
|
||||
]);
|
||||
if ($actual_start === null && $can_use_saved_bounds) {
|
||||
$actual_start = self::firstDateTimeFromPaths($record, [
|
||||
'start',
|
||||
'startTime',
|
||||
'from',
|
||||
]);
|
||||
}
|
||||
|
||||
$scheduled_end = self::firstDateTimeFromPaths($record, [
|
||||
'approval.originalEnd',
|
||||
'end',
|
||||
@@ -38,17 +49,33 @@ final class workfeed_shift_time_resolver
|
||||
'to',
|
||||
]);
|
||||
$actual_only_end = self::firstDateTimeFromPaths($record, [
|
||||
'checkOut.time',
|
||||
'checkOut',
|
||||
'actualEnd',
|
||||
'actualEndTime',
|
||||
'clockOut',
|
||||
'clockOutTime',
|
||||
]);
|
||||
$current_end = self::firstDateTimeFromPaths($record, [
|
||||
'end',
|
||||
'endTime',
|
||||
'to',
|
||||
$check_in_punch = self::firstDateTimeFromPaths($record, [
|
||||
'checkIn.time',
|
||||
'checkIn',
|
||||
]);
|
||||
$saved_actual_end = $actual_only_end ?? $current_end;
|
||||
$check_out_punch = self::firstDateTimeFromPaths($record, [
|
||||
'checkOut.time',
|
||||
'checkOut',
|
||||
]);
|
||||
$saved_actual_end = $actual_only_end;
|
||||
if ($saved_actual_end === null && $can_use_saved_bounds) {
|
||||
$saved_actual_end = self::firstDateTimeFromPaths($record, [
|
||||
'end',
|
||||
'endTime',
|
||||
'to',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($saved_actual_end === null && $check_in_punch !== null && $check_out_punch === null) {
|
||||
$saved_actual_end = new DateTime();
|
||||
}
|
||||
|
||||
if ($actual_start === null || $scheduled_end === null || $saved_actual_end === null) {
|
||||
return null;
|
||||
@@ -63,7 +90,7 @@ final class workfeed_shift_time_resolver
|
||||
'actualStart' => $actual_start,
|
||||
'scheduledEnd' => $scheduled_end,
|
||||
'actualEnd' => $actual_end,
|
||||
'hasApproval' => self::hasShiftApproval($record),
|
||||
'hasApproval' => $has_approval,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ class xlvask_usage_logs_schema_bootstrap
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_at', 'DATETIME NULL AFTER WashItems');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_by', 'INT NULL AFTER ignored_at');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_reason', 'TEXT NULL AFTER ignored_by');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_total_net_amount', 'DECIMAL(12,2) NULL AFTER ignored_reason');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_primary_product_name', 'VARCHAR(255) NULL AFTER cached_total_net_amount');
|
||||
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_amount_at', 'DATETIME NULL AFTER cached_primary_product_name');
|
||||
self::ensureAutomationTables($db);
|
||||
|
||||
self::$initialized = true;
|
||||
|
||||
@@ -15,6 +15,26 @@
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php -r \"putenv('RUN_INTEGRATION_TESTS=1'); putenv('CONFIG_DB_TARGET=debug'); putenv('EDGE_BROKER_URL'); passthru('vendor/bin/pest tests/Integration/EdgeGateway --colors=always', $exitCode); exit($exitCode);\""
|
||||
],
|
||||
"test:ci:unit": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php tests/Support/run_ci_suite.php unit"
|
||||
],
|
||||
"test:ci:integration": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php tests/Support/run_ci_suite.php integration"
|
||||
],
|
||||
"test:ci:api": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php tests/Support/run_ci_suite.php api"
|
||||
],
|
||||
"test:ci:legacy": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php tests/Support/run_ci_suite.php legacy"
|
||||
],
|
||||
"test:ci:all": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php tests/Support/run_ci_suite.php all"
|
||||
],
|
||||
"test:coverage": [
|
||||
"@php -r \"is_dir('build/logs') || mkdir('build/logs', 0777, true);\"",
|
||||
"@php -r \"if (extension_loaded('pcov')) { passthru('php -d pcov.enabled=1 vendor/bin/pest --testsuite=Unit --coverage --coverage-clover build/logs/clover.xml --colors=always', $exitCode); exit($exitCode); } if (extension_loaded('xdebug')) { passthru('php -d xdebug.mode=coverage vendor/bin/pest --testsuite=Unit --coverage --coverage-clover build/logs/clover.xml --colors=always', $exitCode); exit($exitCode); } fwrite(STDERR, 'No coverage driver available. Enable pcov or xdebug for test:coverage.' . PHP_EOL); exit(1);\""
|
||||
@@ -49,6 +69,10 @@
|
||||
"modules/",
|
||||
"routes/",
|
||||
"statistics/"
|
||||
],
|
||||
"exclude-from-classmap": [
|
||||
"modules/*/vendor/",
|
||||
"modules/*/vendor/**"
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
|
||||
Generated
+3
-3
@@ -7530,7 +7530,7 @@
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"stability-flags": {},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
@@ -7539,6 +7539,6 @@
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*"
|
||||
},
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.6.0"
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
|
||||
@@ -163,3 +163,14 @@ if (strtolower(trim((string)($_ENV['USE_ENV'] ?? getenv('USE_ENV') ?? ''))) ===
|
||||
// Throw an error if the environment variables are not set
|
||||
throw new Exception('Environment variables are not set');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/classes/replication_secret_box.php';
|
||||
require_once __DIR__ . '/classes/replication_bootstrap_config.php';
|
||||
require_once __DIR__ . '/classes/replica_failover_manager.php';
|
||||
$replicationBootstrapSnapshot = \classes\replication_bootstrap_config::loadSnapshot();
|
||||
\classes\replication_bootstrap_config::applyToGlobals($replicationBootstrapSnapshot);
|
||||
try {
|
||||
\classes\replica_failover_manager::applyStartupFailoverFromSnapshot();
|
||||
} catch (Throwable $throwable) {
|
||||
error_log('[replication-bootstrap] Startup failover skipped: ' . $throwable->getMessage());
|
||||
}
|
||||
|
||||
@@ -4,10 +4,16 @@
|
||||
use classes\backup_store;
|
||||
use classes\economic;
|
||||
use classes\economic_transfer_queue;
|
||||
use classes\invoice_period_flag_service;
|
||||
use classes\coolify_manager;
|
||||
use classes\replication_manager;
|
||||
use classes\redis;
|
||||
use classes\system_search_cache;
|
||||
use classes\system_search_document_index;
|
||||
use classes\system_search_economic_customer_index;
|
||||
use classes\system_search_registry;
|
||||
use classes\workfeed;
|
||||
use classes\workfeed_employee_name_formatter;
|
||||
use classes\xlvask;
|
||||
use classes\slack as Slack;
|
||||
use classes\email as Email;
|
||||
@@ -29,6 +35,7 @@ use routes\moduleWeatherAPIRoute;
|
||||
require_once __DIR__ . '/../classes/economic_transfer_executor.php';
|
||||
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
|
||||
require_once __DIR__ . '/../classes/economic_transfer_queue.php';
|
||||
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
|
||||
|
||||
if (!defined('WD')) {
|
||||
exit;
|
||||
@@ -63,6 +70,24 @@ $cron_tasks = [
|
||||
'next_run' => 0,
|
||||
'function' => 'syncLogsToDatabase',
|
||||
],
|
||||
'ReplicaFailoverMonitorCron' => [
|
||||
'interval' => 60, // 1 minute
|
||||
'last_run' => 0,
|
||||
'next_run' => 0,
|
||||
'function' => 'ReplicaFailoverMonitorCron',
|
||||
],
|
||||
'CoolifyAvailabilityMonitorCron' => [
|
||||
'interval' => 60, // 1 minute
|
||||
'last_run' => 0,
|
||||
'next_run' => 0,
|
||||
'function' => 'CoolifyAvailabilityMonitorCron',
|
||||
],
|
||||
'CoolifyLoadBalancerReconcileCron' => [
|
||||
'interval' => 60, // 1 minute
|
||||
'last_run' => 0,
|
||||
'next_run' => 0,
|
||||
'function' => 'CoolifyLoadBalancerReconcileCron',
|
||||
],
|
||||
'SyncUserEconomicCustomerDiscounts' => [
|
||||
'interval' => 180, // 3 minutes
|
||||
'last_run' => 0,
|
||||
@@ -123,6 +148,12 @@ $cron_tasks = [
|
||||
'next_run' => 0,
|
||||
'function' => 'PreloadDepartmentWeatherResponsesCron',
|
||||
],
|
||||
'WarmWorkfeedEmployeeNamesCron' => [
|
||||
'interval' => 21600, // 6 hours
|
||||
'last_run' => 0,
|
||||
'next_run' => 0,
|
||||
'function' => 'WarmWorkfeedEmployeeNamesCron',
|
||||
],
|
||||
'GoalsProgressAlertsCron' => [
|
||||
'interval' => 60, // check every minute
|
||||
'last_run' => 0,
|
||||
@@ -135,8 +166,343 @@ $cron_tasks = [
|
||||
'next_run' => 0,
|
||||
'function' => 'PruneSystemSessionActivityCron',
|
||||
],
|
||||
'WarmInvoicePeriodManualFlagsCron' => [
|
||||
'interval' => 300, // 5 minutes
|
||||
'last_run' => 0,
|
||||
'next_run' => 0,
|
||||
'function' => 'WarmInvoicePeriodManualFlagsCron',
|
||||
],
|
||||
'WarmInvoicePeriodAutomaticFlagsCron' => [
|
||||
'interval' => 300, // 5 minutes
|
||||
'last_run' => 0,
|
||||
'next_run' => 0,
|
||||
'function' => 'WarmInvoicePeriodAutomaticFlagsCron',
|
||||
],
|
||||
];
|
||||
|
||||
function ReplicaFailoverMonitorCron(): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (!($db instanceof \classes\db)) {
|
||||
warn('ReplicaFailoverMonitorCron skipped: database connection is unavailable.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = (new replication_manager())->runAutomaticFailoverMonitor();
|
||||
$promoted = array_filter(
|
||||
$result['results'] ?? [],
|
||||
static fn(array $entry): bool => ($entry['status'] ?? '') === 'promoted'
|
||||
);
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] ReplicaFailoverMonitorCron: "
|
||||
. count($promoted) . " promotions.\n";
|
||||
} catch (Throwable $throwable) {
|
||||
warn('ReplicaFailoverMonitorCron failed: ' . $throwable->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function CoolifyAvailabilityMonitorCron(): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (!($db instanceof \classes\db)) {
|
||||
warn('CoolifyAvailabilityMonitorCron skipped: database connection is unavailable.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = (new coolify_manager())->runAvailabilityMaintenance();
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyAvailabilityMonitorCron: "
|
||||
. count($result['targets'] ?? []) . " targets checked.\n";
|
||||
} catch (Throwable $throwable) {
|
||||
warn('CoolifyAvailabilityMonitorCron failed: ' . $throwable->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function CoolifyLoadBalancerReconcileCron(): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (!($db instanceof \classes\db)) {
|
||||
warn('CoolifyLoadBalancerReconcileCron skipped: database connection is unavailable.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$manager = new coolify_manager();
|
||||
if (!$manager->loadBalancerAutomationEnabled()) {
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyLoadBalancerReconcileCron: skipped.\n";
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $manager->reconcileLoadBalancer(false);
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] CoolifyLoadBalancerReconcileCron: "
|
||||
. count($result['applied'] ?? []) . " applied, "
|
||||
. count($result['skipped'] ?? []) . " skipped.\n";
|
||||
} catch (Throwable $throwable) {
|
||||
warn('CoolifyLoadBalancerReconcileCron failed: ' . $throwable->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function WarmInvoicePeriodManualFlagsCron(): void
|
||||
{
|
||||
(new invoice_period_flag_service())->warmManualFlagsCache();
|
||||
}
|
||||
|
||||
function WarmInvoicePeriodAutomaticFlagsCron(): void
|
||||
{
|
||||
$service = new invoice_period_flag_service();
|
||||
$now = new DateTime();
|
||||
$previousMonth = new DateTime('first day of previous month');
|
||||
|
||||
$toWarm = [];
|
||||
foreach ([$now, $previousMonth] as $date) {
|
||||
$dateFrom = $date->format('Y-m-01');
|
||||
$dateTo = $date->format('Y-m-t');
|
||||
$toWarm[$dateFrom . '|' . $dateTo] = ['dateFrom' => $dateFrom, 'dateTo' => $dateTo];
|
||||
}
|
||||
|
||||
try {
|
||||
$queued = (new redis())->consume_invoice_period_warming_queue();
|
||||
foreach ($queued as $period) {
|
||||
$key = $period['dateFrom'] . '|' . $period['dateTo'];
|
||||
$toWarm[$key] = $period;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
foreach ($toWarm as $period) {
|
||||
$service->warmOrderItemRowsForPeriod($period['dateFrom'], $period['dateTo']);
|
||||
$service->warmAutomaticFlagsForPeriod($period['dateFrom'], $period['dateTo']);
|
||||
}
|
||||
}
|
||||
|
||||
function WarmWorkfeedEmployeeNamesCron(): void
|
||||
{
|
||||
if (!defined('redis')) {
|
||||
warn('WarmWorkfeedEmployeeNamesCron skipped: Redis is unavailable.');
|
||||
return;
|
||||
}
|
||||
|
||||
$start = microtime(true);
|
||||
|
||||
$ttlRaw = getenv('WORKFEED_EMPLOYEE_NAME_CACHE_TTL');
|
||||
$ttl = max(
|
||||
60,
|
||||
(int)(
|
||||
$ttlRaw !== false && trim((string)$ttlRaw) !== ''
|
||||
? $ttlRaw
|
||||
: 86400
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
$employeesResponse = (new workfeed())->listEmployees();
|
||||
} catch (Throwable $e) {
|
||||
warn('WarmWorkfeedEmployeeNamesCron failed to list employees: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
$employees = normalizeWorkfeedEmployeeWarmupCollection($employeesResponse);
|
||||
if ($employees === []) {
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] WarmWorkfeedEmployeeNamesCron: no employees returned.\n";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$cache = new redis();
|
||||
} catch (Throwable $e) {
|
||||
warn('WarmWorkfeedEmployeeNamesCron failed to initialize cache: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
$cachedCount = 0;
|
||||
$skippedCount = 0;
|
||||
foreach ($employees as $employee) {
|
||||
$identity = extractWorkfeedEmployeeWarmupIdentity($employee);
|
||||
$employeeIds = extractWorkfeedEmployeeWarmupIds($employee);
|
||||
$employeeName = $identity['name'] ?? null;
|
||||
if ($employeeIds === [] || $employeeName === null) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($employeeIds as $employeeId) {
|
||||
$cache->cache_workfeed_employee_name($employeeId, $employeeName, $ttl);
|
||||
$cachedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$durationMs = (int)round((microtime(true) - $start) * 1000);
|
||||
echo "[" . date('Y-m-d H:i:s') . "][CRON] WarmWorkfeedEmployeeNamesCron: cached " . $cachedCount
|
||||
. " employees, skipped " . $skippedCount . " in " . $durationMs . "ms.\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,mixed>
|
||||
*/
|
||||
function normalizeWorkfeedEmployeeWarmupCollection(mixed $raw): array
|
||||
{
|
||||
if (is_array($raw)) {
|
||||
return array_values($raw);
|
||||
}
|
||||
|
||||
if ($raw instanceof Traversable) {
|
||||
return array_values(iterator_to_array($raw, false));
|
||||
}
|
||||
|
||||
if (!is_object($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$record = get_object_vars($raw);
|
||||
foreach (['data', 'items', 'employees', 'results'] as $key) {
|
||||
$nested = $record[$key] ?? null;
|
||||
$normalized = normalizeWorkfeedEmployeeWarmupCollection($nested);
|
||||
if ($normalized !== []) {
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return [$raw];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id:?string,name:?string}
|
||||
*/
|
||||
function extractWorkfeedEmployeeWarmupIdentity(mixed $employee): array
|
||||
{
|
||||
$employeeIds = extractWorkfeedEmployeeWarmupIds($employee);
|
||||
|
||||
$record = is_object($employee)
|
||||
? get_object_vars($employee)
|
||||
: (is_array($employee) ? $employee : []);
|
||||
|
||||
if ($record === []) {
|
||||
return [
|
||||
'id' => null,
|
||||
'name' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$employeeId = $employeeIds[0] ?? null;
|
||||
|
||||
$employeeName = workfeed_employee_name_formatter::fromRecord($record, [
|
||||
'firstname',
|
||||
'firstName',
|
||||
'first_name',
|
||||
'employee.firstname',
|
||||
'employee.firstName',
|
||||
'employee.first_name',
|
||||
'user.firstname',
|
||||
'user.firstName',
|
||||
'user.first_name',
|
||||
], [
|
||||
'lastname',
|
||||
'lastName',
|
||||
'last_name',
|
||||
'employee.lastname',
|
||||
'employee.lastName',
|
||||
'employee.last_name',
|
||||
'user.lastname',
|
||||
'user.lastName',
|
||||
'user.last_name',
|
||||
], [
|
||||
'employeeName',
|
||||
'employee.name',
|
||||
'employee.fullName',
|
||||
'employee.full_name',
|
||||
'employee.displayName',
|
||||
'employee.display_name',
|
||||
'name',
|
||||
'fullName',
|
||||
'full_name',
|
||||
'displayName',
|
||||
'display_name',
|
||||
'user.name',
|
||||
'user.fullName',
|
||||
'user.full_name',
|
||||
'user.displayName',
|
||||
'user.display_name',
|
||||
], $employeeId);
|
||||
|
||||
return [
|
||||
'id' => $employeeId,
|
||||
'name' => $employeeName,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
function extractWorkfeedEmployeeWarmupIds(mixed $employee): array
|
||||
{
|
||||
$record = is_object($employee)
|
||||
? get_object_vars($employee)
|
||||
: (is_array($employee) ? $employee : []);
|
||||
|
||||
if ($record === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$employeeIds = [];
|
||||
foreach ([
|
||||
'employeeID',
|
||||
'employeeId',
|
||||
'id',
|
||||
'uuid',
|
||||
'employee.id',
|
||||
'employee.employeeID',
|
||||
'employee.employeeId',
|
||||
'employee.uuid',
|
||||
'employeeUUID',
|
||||
'employee_uuid',
|
||||
'user.id',
|
||||
'userId',
|
||||
] as $path) {
|
||||
$employeeId = normalizeWarmupTextValue(getWarmupRecordValueByPath($record, $path));
|
||||
if ($employeeId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$employeeIds[$employeeId] = true;
|
||||
}
|
||||
|
||||
return array_keys($employeeIds);
|
||||
}
|
||||
|
||||
function getWarmupRecordValueByPath(array $record, string $path): mixed
|
||||
{
|
||||
$segments = explode('.', $path);
|
||||
$value = $record;
|
||||
foreach ($segments as $segment) {
|
||||
if (is_array($value) && array_key_exists($segment, $value)) {
|
||||
$value = $value[$segment];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_object($value) && isset($value->{$segment})) {
|
||||
$value = $value->{$segment};
|
||||
continue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
function normalizeWarmupTextValue(mixed $value): ?string
|
||||
{
|
||||
if (is_string($value) || is_numeric($value)) {
|
||||
$normalized = trim((string)$value);
|
||||
return $normalized !== '' ? $normalized : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function checkUnfulfilledBookings(): void
|
||||
{
|
||||
// This is deactivated for now, as it is not wanted.
|
||||
|
||||
@@ -8,30 +8,20 @@ ini_set('zlib.output_compression', false);
|
||||
*/
|
||||
const WD = __DIR__;
|
||||
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
require_once 'config.php';
|
||||
require_once __DIR__ . '/classes/cors_policy.php';
|
||||
|
||||
/** CORS */
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$allowed_origins = array_map('trim', explode(',', (string)($CORS ?? '*')));
|
||||
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
|
||||
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
|
||||
header("Access-Control-Allow-Credentials: true");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, *");
|
||||
header("Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
||||
}
|
||||
|
||||
// OPTIONS requests are preflight requests for CORS, we can just return a 200 OK response
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
|
||||
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
|
||||
header("Access-Control-Allow-Credentials: true");
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: *');
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
$preflight = \classes\cors_policy::preflightResponse($_SERVER['HTTP_ORIGIN'] ?? '', (string)($CORS ?? ''));
|
||||
\classes\cors_policy::emitHeaders($preflight['headers']);
|
||||
http_response_code($preflight['status']);
|
||||
echo $preflight['body'];
|
||||
exit;
|
||||
}
|
||||
\classes\cors_policy::applyResponseHeaders((string)($CORS ?? ''));
|
||||
/** Debug */
|
||||
if ($DEBUG) {
|
||||
ini_set('display_errors', 1);
|
||||
@@ -167,14 +157,16 @@ spl_autoload_register(function (string $class): void {
|
||||
}
|
||||
});
|
||||
|
||||
use classes\application_write_freeze;
|
||||
use classes\db;
|
||||
use classes\replication_manager;
|
||||
use classes\release_manager;
|
||||
use classes\redis;
|
||||
use classes\request;
|
||||
use classes\response;
|
||||
use classes\router;
|
||||
|
||||
// Start the session
|
||||
$router = new router();
|
||||
$response = new response();
|
||||
$request = new request();
|
||||
$db = new db($CONFIG_DB);
|
||||
@@ -195,7 +187,49 @@ try {
|
||||
$response->error($e->getMessage(), 500);
|
||||
}
|
||||
|
||||
try {
|
||||
release_manager::initializeRequestContext();
|
||||
$releaseIngressPath = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: '');
|
||||
release_manager::normalizeReleaseApiIngressPath(
|
||||
preg_match('#^/[A-Za-z0-9_-]{1,64}/api(?:/|$)#', $releaseIngressPath) === 1
|
||||
? (new release_manager())->enabledReleaseChannelSlugs()
|
||||
: []
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[release-manager] Could not initialize request context or normalize ingress path: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$router = new router();
|
||||
|
||||
try {
|
||||
$replicationBootstrapSnapshotForRequest = replication_bootstrap_config::loadSnapshot();
|
||||
$pendingStartupFailovers = is_array($replicationBootstrapSnapshotForRequest['pending_failovers'] ?? null)
|
||||
? $replicationBootstrapSnapshotForRequest['pending_failovers']
|
||||
: [];
|
||||
if ($pendingStartupFailovers !== []) {
|
||||
(new replication_manager())->syncStartupFailoversFromSnapshot();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log('[replication-bootstrap] Could not sync startup failover metadata: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
if (application_write_freeze::shouldBlock(
|
||||
$_SERVER['REQUEST_METHOD'] ?? 'GET',
|
||||
$_SERVER['REQUEST_URI'] ?? '/',
|
||||
php_sapi_name() === 'cli' || isset($_GET['internalCronCall'])
|
||||
)) {
|
||||
$freezeState = application_write_freeze::state();
|
||||
if (php_sapi_name() === 'cli') {
|
||||
fwrite(STDERR, 'Application writes are frozen: ' . (string)($freezeState['reason'] ?? 'replication promotion') . PHP_EOL);
|
||||
exit(75);
|
||||
}
|
||||
|
||||
$response->error([
|
||||
'message' => 'Application writes are temporarily frozen.',
|
||||
'reason' => $freezeState['reason'] ?? null,
|
||||
'expires_at' => $freezeState['expires_at'] ?? null,
|
||||
], 503);
|
||||
}
|
||||
|
||||
// If the program was called from the command line, run the cli script
|
||||
if (php_sapi_name() === 'cli' || isset($_GET['internalCronCall'])) {
|
||||
|
||||
@@ -290,6 +290,98 @@ interface redis_i
|
||||
*/
|
||||
public function clear_auth_session(string $token): self;
|
||||
|
||||
/**
|
||||
* Cache invoice period manual flags payload
|
||||
* @param array $flags
|
||||
* @return self
|
||||
*/
|
||||
public function cache_invoice_period_manual_flags(array $flags): self;
|
||||
|
||||
/**
|
||||
* Get cached invoice period manual flags payload
|
||||
* @return array|null
|
||||
*/
|
||||
public function get_invoice_period_manual_flags(): array|null;
|
||||
|
||||
/**
|
||||
* Clear cached invoice period manual flags payload
|
||||
* @return self
|
||||
*/
|
||||
public function clear_invoice_period_manual_flags(): self;
|
||||
|
||||
/**
|
||||
* Cache invoice period automatic flags payload for a date range
|
||||
* @param string $dateFrom
|
||||
* @param string $dateTo
|
||||
* @param array $flags
|
||||
* @return self
|
||||
*/
|
||||
public function cache_invoice_period_automatic_flags(string $dateFrom, string $dateTo, array $flags): self;
|
||||
|
||||
/**
|
||||
* Get cached invoice period automatic flags payload for a date range
|
||||
* @param string $dateFrom
|
||||
* @param string $dateTo
|
||||
* @return array|null
|
||||
*/
|
||||
public function get_invoice_period_automatic_flags(string $dateFrom, string $dateTo): array|null;
|
||||
|
||||
/**
|
||||
* Clear cached invoice period automatic flags payload for a date range
|
||||
* @param string $dateFrom
|
||||
* @param string $dateTo
|
||||
* @return self
|
||||
*/
|
||||
public function clear_invoice_period_automatic_flags(string $dateFrom, string $dateTo): self;
|
||||
|
||||
/**
|
||||
* Cache invoice period order item rows for a date range
|
||||
* @param string $dateFrom
|
||||
* @param string $dateTo
|
||||
* @param array $rows
|
||||
* @return self
|
||||
*/
|
||||
public function cache_invoice_period_order_item_rows(string $dateFrom, string $dateTo, array $rows): self;
|
||||
|
||||
/**
|
||||
* Get cached invoice period order item rows for a date range
|
||||
* @param string $dateFrom
|
||||
* @param string $dateTo
|
||||
* @return array|null
|
||||
*/
|
||||
public function get_invoice_period_order_item_rows(string $dateFrom, string $dateTo): array|null;
|
||||
|
||||
/**
|
||||
* Clear cached invoice period order item rows for a date range
|
||||
* @param string $dateFrom
|
||||
* @param string $dateTo
|
||||
* @return self
|
||||
*/
|
||||
public function clear_invoice_period_order_item_rows(string $dateFrom, string $dateTo): self;
|
||||
|
||||
/**
|
||||
* Cache Workfeed employee display name by employee id
|
||||
* @param string $employeeId
|
||||
* @param string $employeeName
|
||||
* @param int $ttl
|
||||
* @return self
|
||||
*/
|
||||
public function cache_workfeed_employee_name(string $employeeId, string $employeeName, int $ttl = 86400): self;
|
||||
|
||||
/**
|
||||
* Get cached Workfeed employee display name by employee id
|
||||
* @param string $employeeId
|
||||
* @return string|null
|
||||
*/
|
||||
public function get_workfeed_employee_name(string $employeeId): string|null;
|
||||
|
||||
/**
|
||||
* Clear cached Workfeed employee display name by employee id
|
||||
* @param string $employeeId
|
||||
* @return self
|
||||
*/
|
||||
public function clear_workfeed_employee_name(string $employeeId): self;
|
||||
|
||||
/**
|
||||
* Cache a permission evaluation
|
||||
* @param string $cache_key
|
||||
@@ -312,4 +404,18 @@ interface redis_i
|
||||
* @return self
|
||||
*/
|
||||
public function clear_permission(string $cache_key): self;
|
||||
|
||||
/**
|
||||
* Enqueue a period for automatic flags warming.
|
||||
* @param string $dateFrom
|
||||
* @param string $dateTo
|
||||
* @return self
|
||||
*/
|
||||
public function enqueue_invoice_period_warming(string $dateFrom, string $dateTo): self;
|
||||
|
||||
/**
|
||||
* Consume all queued warming periods and clear the queue.
|
||||
* @return array<int,array{dateFrom:string,dateTo:string}>
|
||||
*/
|
||||
public function consume_invoice_period_warming_queue(): array;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace attachments\helpers;
|
||||
class attachment_content
|
||||
{
|
||||
const OTHER_TYPE_WASH_CERTIFICATE = 'WASH_CERTIFICATE';
|
||||
const OTHER_TYPE_SELF_SERVE_WASH = 'SELF_SERVE_WASH';
|
||||
public ?string $image; // Used to store the attachment object name, in the attachment store.
|
||||
public ?string $document; // Used to store the attachment object name, in the attachment store.
|
||||
public ?attachment_relation $relation; // Used to store the attachment relation object.
|
||||
@@ -49,4 +50,4 @@ class attachment_content
|
||||
$this->relation = $relation;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace modules\coolify\config;
|
||||
|
||||
use traits\module_config_variable;
|
||||
|
||||
class coolify_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfigVariable(
|
||||
'Coolify',
|
||||
'enabled',
|
||||
'bool',
|
||||
false,
|
||||
null,
|
||||
'Enable Coolify-managed replicated infrastructure targets.',
|
||||
'true',
|
||||
false,
|
||||
'false'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace modules\coolify\config;
|
||||
|
||||
use classes\replication_secret_box;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class coolify_hetzner_cloud_api_token_c
|
||||
{
|
||||
use module_config_variable {
|
||||
setVariableValue as private traitSetVariableValue;
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfigVariable(
|
||||
'Coolify',
|
||||
'hetzner_cloud_api_token',
|
||||
'string',
|
||||
false,
|
||||
null,
|
||||
'Hetzner Cloud API token with Load Balancer read/write permissions.',
|
||||
'pat_...',
|
||||
true,
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
public function setVariableValue(mixed $value): void
|
||||
{
|
||||
$value = trim((string)($value ?? ''));
|
||||
if ($value !== '' && !str_starts_with($value, 'twsec:v1:')) {
|
||||
$value = replication_secret_box::encrypt($value);
|
||||
}
|
||||
|
||||
$this->traitSetVariableValue($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace modules\coolify\config;
|
||||
|
||||
use traits\module_config_variable;
|
||||
|
||||
class coolify_hetzner_load_balancer_id_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfigVariable(
|
||||
'Coolify',
|
||||
'hetzner_load_balancer_id',
|
||||
'string',
|
||||
false,
|
||||
null,
|
||||
'Hetzner Cloud Load Balancer ID used as the public Coolify gateway.',
|
||||
'1234567',
|
||||
false,
|
||||
''
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace modules\coolify\config;
|
||||
|
||||
use traits\module_config_variable;
|
||||
|
||||
class coolify_lb_automation_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfigVariable(
|
||||
'Coolify',
|
||||
'lb_automation_enabled',
|
||||
'bool',
|
||||
false,
|
||||
null,
|
||||
'Enable automated Hetzner Load Balancer reconciliation for the Coolify public gateway.',
|
||||
'false',
|
||||
false,
|
||||
'false'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace modules\coolify\config;
|
||||
|
||||
use traits\module_config_variable;
|
||||
|
||||
class coolify_lb_automation_mode_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfigVariable(
|
||||
'Coolify',
|
||||
'lb_automation_mode',
|
||||
'string',
|
||||
false,
|
||||
['report_only', 'enforce'],
|
||||
'Controls whether Hetzner Load Balancer reconciliation reports drift only or applies changes.',
|
||||
'report_only',
|
||||
false,
|
||||
'report_only'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace modules\coolify\config;
|
||||
|
||||
use traits\module_config_variable;
|
||||
|
||||
class coolify_public_gateway_host_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfigVariable(
|
||||
'Coolify',
|
||||
'public_gateway_host',
|
||||
'string',
|
||||
false,
|
||||
null,
|
||||
'Public DNS hostname served by the replicated Coolify gateway Load Balancer.',
|
||||
'api-v2.truckwash.io',
|
||||
false,
|
||||
'api-v2.truckwash.io'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace modules\coolify;
|
||||
|
||||
require_once WD . '/modules/coolify/config/coolify_enabled_c.php';
|
||||
require_once WD . '/modules/coolify/config/coolify_hetzner_cloud_api_token_c.php';
|
||||
require_once WD . '/modules/coolify/config/coolify_hetzner_load_balancer_id_c.php';
|
||||
require_once WD . '/modules/coolify/config/coolify_lb_automation_enabled_c.php';
|
||||
require_once WD . '/modules/coolify/config/coolify_lb_automation_mode_c.php';
|
||||
require_once WD . '/modules/coolify/config/coolify_public_gateway_host_c.php';
|
||||
|
||||
use modules\coolify\config\coolify_hetzner_cloud_api_token_c;
|
||||
use modules\coolify\config\coolify_hetzner_load_balancer_id_c;
|
||||
use modules\coolify\config\coolify_enabled_c;
|
||||
use modules\coolify\config\coolify_lb_automation_enabled_c;
|
||||
use modules\coolify\config\coolify_lb_automation_mode_c;
|
||||
use modules\coolify\config\coolify_public_gateway_host_c;
|
||||
use traits\module_config_t;
|
||||
|
||||
class coolify_c
|
||||
{
|
||||
use module_config_t {
|
||||
getConfigRequest as private traitGetConfigRequest;
|
||||
}
|
||||
|
||||
public coolify_enabled_c $enabled;
|
||||
public coolify_lb_automation_enabled_c $lb_automation_enabled;
|
||||
public coolify_lb_automation_mode_c $lb_automation_mode;
|
||||
public coolify_hetzner_load_balancer_id_c $hetzner_load_balancer_id;
|
||||
public coolify_hetzner_cloud_api_token_c $hetzner_cloud_api_token;
|
||||
public coolify_public_gateway_host_c $public_gateway_host;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfig('Coolify');
|
||||
$this->allowUpdate([
|
||||
coolify_enabled_c::class,
|
||||
coolify_lb_automation_enabled_c::class,
|
||||
coolify_lb_automation_mode_c::class,
|
||||
coolify_hetzner_load_balancer_id_c::class,
|
||||
coolify_hetzner_cloud_api_token_c::class,
|
||||
coolify_public_gateway_host_c::class,
|
||||
]);
|
||||
$this->enabled = new coolify_enabled_c();
|
||||
$this->lb_automation_enabled = new coolify_lb_automation_enabled_c();
|
||||
$this->lb_automation_mode = new coolify_lb_automation_mode_c();
|
||||
$this->hetzner_load_balancer_id = new coolify_hetzner_load_balancer_id_c();
|
||||
$this->hetzner_cloud_api_token = new coolify_hetzner_cloud_api_token_c();
|
||||
$this->public_gateway_host = new coolify_public_gateway_host_c();
|
||||
}
|
||||
|
||||
public function getConfigRequest(): array
|
||||
{
|
||||
return array_map(static function (array $row): array {
|
||||
if (($row['variable'] ?? '') === 'hetzner_cloud_api_token') {
|
||||
$secretSet = trim((string)($row['value'] ?? '')) !== '';
|
||||
$row['value'] = $secretSet ? '[redacted]' : '';
|
||||
$row['secret_set'] = $secretSet;
|
||||
}
|
||||
return $row;
|
||||
}, $this->traitGetConfigRequest());
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ class machine_1 extends dynamicimages_image
|
||||
const IMAGE_BUTTON_HIGHLIGHTED_GREEN = 'machine_1_button_highlighted_green.png';
|
||||
const BUTTON_RESET = 'reset';
|
||||
const BUTTON_START = 'start';
|
||||
const BUTTON_PROGRAM_PICKER = 'program_picker';
|
||||
// Thumb
|
||||
public int $thumb_position = 1; // 0-11 (default: 0 = up = 270 degrees)
|
||||
public int $thumb_size = 1550; // height and width of the thumb
|
||||
@@ -73,7 +74,6 @@ class machine_1 extends dynamicimages_image
|
||||
$this->drawAsset($this->getAsset(self::IMAGE_PANEL_BACKGROUND), 0, 0);
|
||||
$this->drawProgramWheel();
|
||||
$this->drawThumb();
|
||||
$this->drawStepThumb();
|
||||
$deferredStartButtons = $this->drawHighlightedButtonSequence();
|
||||
$this->drawAsset($this->getAsset(self::IMAGE_POWER_BUTTON), 0, 0);
|
||||
$this->drawDeferredHighlightedButtons($deferredStartButtons);
|
||||
@@ -198,7 +198,7 @@ class machine_1 extends dynamicimages_image
|
||||
if (is_string($button)) {
|
||||
$trimmed = trim($button);
|
||||
$specialButton = strtolower($trimmed);
|
||||
if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START) {
|
||||
if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START || $specialButton === self::BUTTON_PROGRAM_PICKER) {
|
||||
return $specialButton;
|
||||
}
|
||||
|
||||
@@ -255,6 +255,10 @@ class machine_1 extends dynamicimages_image
|
||||
|
||||
private function getHighlightedButtonCoordinates(int|string $button): ?array
|
||||
{
|
||||
if ($button === self::BUTTON_PROGRAM_PICKER) {
|
||||
return $this->getProgramPickerStepCoordinates();
|
||||
}
|
||||
|
||||
if ($button === self::BUTTON_RESET) {
|
||||
return [
|
||||
'x' => 1736,
|
||||
@@ -274,6 +278,25 @@ class machine_1 extends dynamicimages_image
|
||||
return is_int($button) ? $this->getRegularButtonCoordinates($button) : null;
|
||||
}
|
||||
|
||||
private function getProgramPickerStepCoordinates(): array
|
||||
{
|
||||
$thumbX = 650;
|
||||
$thumbY = 1290;
|
||||
$stepSize = 350;
|
||||
$baseThumb = $this->getAsset(self::IMAGE_WASH_PROGRAMS_THUMB)->resize($this->calculateThumbWidth($this->thumb_size), $this->thumb_size);
|
||||
$centerX = $thumbX + (int)floor($baseThumb->getWidth() / 2);
|
||||
$centerY = $thumbY + (int)floor($baseThumb->getHeight() / 2);
|
||||
$angle = $this->getRotationThumbPosition();
|
||||
$radius = ($this->thumb_size / 2) - ($stepSize / 2) - 150;
|
||||
$rad = deg2rad($angle);
|
||||
|
||||
return [
|
||||
'x' => $centerX + (int)round($radius * cos($rad)) - (int)floor($stepSize / 2),
|
||||
'y' => $centerY + (int)round($radius * sin($rad)) - (int)floor($stepSize / 2),
|
||||
'size' => $stepSize,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace config;
|
||||
|
||||
use traits\module_config_variable;
|
||||
|
||||
class economic_default_department_id_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'economic',
|
||||
'defaultDepartmentId',
|
||||
'int',
|
||||
false,
|
||||
null,
|
||||
'Fallback department id used when customers have no standard department for invoicing distribution',
|
||||
'1',
|
||||
false,
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,14 @@ require_once WD . '/modules/economic/config/economic_admin_fee_monthly_c.php';
|
||||
require_once WD . '/modules/economic/config/economic_admin_fee_order_c.php';
|
||||
require_once WD . '/modules/economic/config/economic_fee_product_id_c.php';
|
||||
require_once WD . '/modules/economic/config/economic_transaction_draft_customer_number_c.php';
|
||||
require_once WD . '/modules/economic/config/economic_default_department_id_c.php';
|
||||
|
||||
use config\economic_invoice_layout_c;
|
||||
use config\economic_payment_terms_c;
|
||||
use config\economic_admin_fee_monthly_c;
|
||||
use config\economic_admin_fee_order_c;
|
||||
use config\economic_fee_product_id_c;
|
||||
use config\economic_default_department_id_c;
|
||||
use config\economic_transaction_draft_customer_number_c;
|
||||
use traits\module_config_t;
|
||||
|
||||
@@ -23,6 +25,7 @@ class economic_c
|
||||
public economic_admin_fee_monthly_c $admin_fee_monthly;
|
||||
public economic_admin_fee_order_c $admin_fee_order;
|
||||
public economic_fee_product_id_c $fee_product_id;
|
||||
public economic_default_department_id_c $default_department_id;
|
||||
public economic_transaction_draft_customer_number_c $transaction_draft_customer_number;
|
||||
|
||||
public function __construct()
|
||||
@@ -34,6 +37,7 @@ class economic_c
|
||||
economic_admin_fee_monthly_c::class,
|
||||
economic_admin_fee_order_c::class,
|
||||
economic_fee_product_id_c::class,
|
||||
economic_default_department_id_c::class,
|
||||
economic_transaction_draft_customer_number_c::class,
|
||||
]);
|
||||
$this->invoice_layout = new economic_invoice_layout_c();
|
||||
@@ -41,6 +45,7 @@ class economic_c
|
||||
$this->admin_fee_monthly = new economic_admin_fee_monthly_c();
|
||||
$this->admin_fee_order = new economic_admin_fee_order_c();
|
||||
$this->fee_product_id = new economic_fee_product_id_c();
|
||||
$this->default_department_id = new economic_default_department_id_c();
|
||||
$this->transaction_draft_customer_number = new economic_transaction_draft_customer_number_c();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,8 @@ class economic_m
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_ENCODING => '',
|
||||
CURLOPT_MAXREDIRS => 10,
|
||||
CURLOPT_TIMEOUT => 0,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
|
||||
@@ -364,6 +364,7 @@ class edge_gateway_manager
|
||||
}
|
||||
|
||||
$gatewayPayload = $this->getGateway($gatewayId);
|
||||
$gatewayPayload['broker_url'] = $this->buildBrokerPublicUrl();
|
||||
edge_gateway_view_cache::syncGateway($gatewayPayload);
|
||||
|
||||
return $gatewayPayload;
|
||||
|
||||
@@ -21,9 +21,9 @@ class edgegateway_public_broker_url_c
|
||||
false,
|
||||
null,
|
||||
'The browser-routable edge broker URL, including the proxy path prefix.',
|
||||
'https://api.truckwash.io:4433/edge-broker',
|
||||
'https://api-v2.truckwash.io/edge-broker',
|
||||
false,
|
||||
trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: ''))
|
||||
trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: '')) ?: 'https://api-v2.truckwash.io/edge-broker'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace failover\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class failover_database_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Failover',
|
||||
'database_enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether automatic database replica failover is enabled.',
|
||||
'false',
|
||||
false,
|
||||
'false'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace failover\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class failover_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Failover',
|
||||
'enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether automatic failover is enabled.',
|
||||
'false',
|
||||
false,
|
||||
'false'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace failover\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class failover_max_status_age_seconds_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Failover',
|
||||
'max_status_age_seconds',
|
||||
'int',
|
||||
true,
|
||||
null,
|
||||
'Maximum age in seconds for a stored replica status to be eligible for automatic failover.',
|
||||
'90',
|
||||
false,
|
||||
'90'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace failover\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class failover_minio_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Failover',
|
||||
'minio_enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether automatic MinIO replica failover is enabled.',
|
||||
'false',
|
||||
false,
|
||||
'false'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace failover\config;
|
||||
|
||||
use Exception;
|
||||
use traits\module_config_variable;
|
||||
|
||||
class failover_redis_enabled_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'Failover',
|
||||
'redis_enabled',
|
||||
'bool',
|
||||
true,
|
||||
null,
|
||||
'Whether automatic Redis replica failover is enabled.',
|
||||
'false',
|
||||
false,
|
||||
'false'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace failover;
|
||||
|
||||
require_once WD . '/modules/failover/config/failover_enabled_c.php';
|
||||
require_once WD . '/modules/failover/config/failover_database_enabled_c.php';
|
||||
require_once WD . '/modules/failover/config/failover_redis_enabled_c.php';
|
||||
require_once WD . '/modules/failover/config/failover_minio_enabled_c.php';
|
||||
require_once WD . '/modules/failover/config/failover_max_status_age_seconds_c.php';
|
||||
|
||||
use failover\config\failover_database_enabled_c;
|
||||
use failover\config\failover_enabled_c;
|
||||
use failover\config\failover_max_status_age_seconds_c;
|
||||
use failover\config\failover_minio_enabled_c;
|
||||
use failover\config\failover_redis_enabled_c;
|
||||
use traits\module_config_t;
|
||||
|
||||
class failover_c
|
||||
{
|
||||
use module_config_t;
|
||||
|
||||
public failover_enabled_c $enabled;
|
||||
public failover_database_enabled_c $database_enabled;
|
||||
public failover_redis_enabled_c $redis_enabled;
|
||||
public failover_minio_enabled_c $minio_enabled;
|
||||
public failover_max_status_age_seconds_c $max_status_age_seconds;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->setupConfig('Failover');
|
||||
$this->allowUpdate([
|
||||
failover_enabled_c::class,
|
||||
failover_database_enabled_c::class,
|
||||
failover_redis_enabled_c::class,
|
||||
failover_minio_enabled_c::class,
|
||||
failover_max_status_age_seconds_c::class,
|
||||
]);
|
||||
|
||||
$this->enabled = new failover_enabled_c();
|
||||
$this->database_enabled = new failover_database_enabled_c();
|
||||
$this->redis_enabled = new failover_redis_enabled_c();
|
||||
$this->minio_enabled = new failover_minio_enabled_c();
|
||||
$this->max_status_age_seconds = new failover_max_status_age_seconds_c();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ class selfserve_lane_command_arguments
|
||||
{
|
||||
public ?string $license_plate = null;
|
||||
public ?int $customer_number = null;
|
||||
public ?int $subuser_id = null;
|
||||
public bool $defer_relay_side_effects = false;
|
||||
|
||||
/**
|
||||
@@ -25,6 +26,12 @@ class selfserve_lane_command_arguments
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setSubuserId(?int $subuser_id): self
|
||||
{
|
||||
$this->subuser_id = $subuser_id !== null && $subuser_id > 0 ? $subuser_id : null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self
|
||||
{
|
||||
$this->defer_relay_side_effects = $defer_relay_side_effects;
|
||||
@@ -40,6 +47,9 @@ class selfserve_lane_command_arguments
|
||||
if (array_key_exists('customer_number', $params)) {
|
||||
$this->setCustomerNumber($params['customer_number']);
|
||||
}
|
||||
if (array_key_exists('subuser_id', $params)) {
|
||||
$this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']);
|
||||
}
|
||||
if (array_key_exists('defer_relay_side_effects', $params)) {
|
||||
$this->setDeferRelaySideEffects(filter_var(
|
||||
$params['defer_relay_side_effects'],
|
||||
|
||||
@@ -83,7 +83,7 @@ class selfserve_studio_action_runner
|
||||
continue;
|
||||
}
|
||||
$conditionId = $action['condition_id'];
|
||||
if ($conditionId !== null && $conditionResults !== null && (($conditionResults[$conditionId] ?? false) !== true)) {
|
||||
if ($conditionId !== null && (($conditionResults[$conditionId] ?? false) !== true)) {
|
||||
continue;
|
||||
}
|
||||
$actions[] = $action;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,9 @@ require_once WD . '/modules/selfserve/helpers/selfserve_wash_session_status.php'
|
||||
require_once WD . '/modules/selfserve/interfaces/selfserve_condition_evaluator_i.php';
|
||||
require_once WD . '/modules/selfserve/interfaces/selfserve_wash_flow_i.php';
|
||||
require_once WD . '/objects/customer_vehicles_o.php';
|
||||
require_once WD . '/objects/department_lanes_o.php';
|
||||
if (!class_exists(\objects\department_lanes_o::class, false)) {
|
||||
require_once WD . '/objects/department_lanes_o.php';
|
||||
}
|
||||
require_once WD . '/objects/department_selfserve_condition_rules_o.php';
|
||||
require_once WD . '/objects/department_selfserve_conditions_o.php';
|
||||
require_once WD . '/objects/department_selfserve_questions_o.php';
|
||||
@@ -271,28 +273,19 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return;
|
||||
}
|
||||
|
||||
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE);
|
||||
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE_CLEANER);
|
||||
$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE);
|
||||
$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE_CLEANER);
|
||||
} catch (\Throwable) {
|
||||
// Best effort only; session completion flow must continue.
|
||||
}
|
||||
}
|
||||
|
||||
protected function turnOffRelayIfConfiguredAndOn(selfserve_lane $lane, selfserve_lane_relay $relay): void
|
||||
protected function turnOffRelayIfConfigured(selfserve_lane $lane, selfserve_lane_relay $relay): void
|
||||
{
|
||||
if (!$this->isRelayConfiguredForLane($lane, $relay)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$status = $lane->getRelayStatus($relay);
|
||||
if ((bool)($status['on'] ?? false) !== true) {
|
||||
return;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// If relay status can't be read, still attempt turn-off as best effort.
|
||||
}
|
||||
|
||||
try {
|
||||
$lane->setRelayStatusHard($relay, false);
|
||||
} catch (\Throwable) {
|
||||
@@ -721,14 +714,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
|
||||
protected function enableMachineRelayIfAllowed(array $snapshot, selfserve_wash_sessions_o $session): void
|
||||
{
|
||||
if ((bool)$session->machine_relay_enabled->value() === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
$laneId = (int)$snapshot['lane']['id'];
|
||||
$lane = (new selfserve())->lane($laneId);
|
||||
$this->enableCleanerRelayForStartedWash($lane);
|
||||
|
||||
if ((bool)$session->machine_relay_enabled->value() === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
$session->markRelayEnabled();
|
||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_RELAY_ENABLED, [
|
||||
'lane_id' => $laneId,
|
||||
@@ -839,6 +832,16 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$tasks = $this->buildDebugTasks($snapshot, (array)($candidates['tasks'] ?? []), $lookups, (array)($options['gateway_workspace'] ?? []));
|
||||
$actions = $this->buildDebugActions($snapshot, (array)($candidates['actions'] ?? []), $lookups);
|
||||
$hardware = $this->buildDebugHardware($snapshot, $tasks, (array)($options['gateway_workspace'] ?? []), $lookups, $actions);
|
||||
$dynamicImageButtons = $this->buildDebugDynamicImageButtons($tasks);
|
||||
$decisions = $this->buildDebugDecisions(
|
||||
$questions,
|
||||
$conditions,
|
||||
$rules,
|
||||
$tasks,
|
||||
$actions,
|
||||
(array)($hardware['signal_timeline'] ?? []),
|
||||
$dynamicImageButtons
|
||||
);
|
||||
$recommendations = $this->buildDebugRecommendations($snapshot, $questions, $tasks, $hardware, $conditions, $rules);
|
||||
$summary = $this->buildDebugSummary($snapshot, $recommendations, $hardware);
|
||||
$stages = $this->buildDebugStages($snapshot, $questions, $conditions, $rules, $tasks, $hardware, $summary, $lookups);
|
||||
@@ -869,6 +872,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'rules' => $rules,
|
||||
'tasks' => $tasks,
|
||||
'actions' => $actions,
|
||||
'dynamic_image_buttons' => $dynamicImageButtons,
|
||||
'decisions' => $decisions,
|
||||
'hardware' => $hardware,
|
||||
'signal_timeline' => (array)($hardware['signal_timeline'] ?? []),
|
||||
'graph_annotations' => $annotations,
|
||||
@@ -979,6 +984,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$conditionResults = is_array($snapshot['evaluation_trace']['visibility_condition_results'] ?? null)
|
||||
? (array)$snapshot['evaluation_trace']['visibility_condition_results']
|
||||
: [];
|
||||
$expressionTraces = is_array($snapshot['evaluation_trace']['visibility_expression_traces'] ?? null)
|
||||
? (array)$snapshot['evaluation_trace']['visibility_expression_traces']
|
||||
: [];
|
||||
$items = [];
|
||||
|
||||
foreach ($questions as $question) {
|
||||
@@ -990,20 +998,43 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$visible = isset($visibleIds[$questionId]);
|
||||
$answer = array_key_exists($questionId, $answers) ? $answers[$questionId] : null;
|
||||
$state = !$visible ? 'hidden' : ($answer === null ? 'missing' : 'answered');
|
||||
$label = (string)($question['question'] ?? $this->debugLabel($lookups, 'questions', $questionId, 'Question ' . $questionId));
|
||||
$conditionLabel = $gateId === null ? 'Always visible' : $this->debugLabel($lookups, 'conditions', $gateId, 'Condition ' . $gateId);
|
||||
$gateSatisfied = $gateId === null || (($conditionResults[$gateId] ?? false) === true);
|
||||
$causes = [];
|
||||
if ($gateId !== null) {
|
||||
$causes[] = $this->debugCause(
|
||||
'condition',
|
||||
$gateId,
|
||||
$conditionLabel,
|
||||
true,
|
||||
($conditionResults[$gateId] ?? null),
|
||||
$this->debugExpressionTraceReason($expressionTraces[$gateId] ?? null)
|
||||
);
|
||||
}
|
||||
if ($visible && $answer === null) {
|
||||
$causes[] = $this->debugCause('question', $questionId, $label, 'answered', 'missing', 'Visible question has no simulated answer.');
|
||||
}
|
||||
$reason = $visible
|
||||
? ($answer === null
|
||||
? 'Question ' . $label . ' is visible but has no answer.'
|
||||
: 'Question ' . $label . ' is visible and answered ' . $this->debugValueLabel($answer) . '.')
|
||||
: 'Question ' . $label . ' hidden because visibility condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$gateId] ?? null) . '.';
|
||||
$items[] = [
|
||||
'kind' => 'question',
|
||||
'id' => $questionId,
|
||||
'node_id' => 'question:' . $questionId,
|
||||
'label' => (string)($question['question'] ?? $this->debugLabel($lookups, 'questions', $questionId, 'Question ' . $questionId)),
|
||||
'node_ids' => ['question:' . $questionId],
|
||||
'label' => $label,
|
||||
'visible' => $visible,
|
||||
'state' => $state,
|
||||
'answer' => $answer,
|
||||
'answer_source' => $sources[$questionId] ?? 'missing',
|
||||
'condition_id' => $gateId,
|
||||
'condition' => $gateId === null ? 'Always visible' : $this->debugLabel($lookups, 'conditions', $gateId, 'Condition ' . $gateId),
|
||||
'gate_satisfied' => $gateId === null || (($conditionResults[$gateId] ?? false) === true),
|
||||
'reason' => $visible
|
||||
? ($answer === null ? 'Visible question is missing an answer.' : 'Visible question has an answer.')
|
||||
: 'Visibility condition did not pass.',
|
||||
'condition' => $conditionLabel,
|
||||
'gate_satisfied' => $gateSatisfied,
|
||||
'reason' => $reason,
|
||||
'causes' => $causes,
|
||||
'order_priority' => (int)($question['order_priority'] ?? 0),
|
||||
];
|
||||
}
|
||||
@@ -1034,19 +1065,33 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$actual = $objectType === 'condition' ? ($conditionResults[$objectId] ?? null) : ($answers[$objectId] ?? null);
|
||||
$satisfied = $this->debugRuleSatisfied((string)($rule['type'] ?? ''), $actual);
|
||||
$lookupType = $objectType === 'condition' ? 'conditions' : 'questions';
|
||||
$label = (string)($rule['name'] ?? $this->debugLabel($lookups, 'rules', $ruleId, 'Rule ' . $ruleId));
|
||||
$objectLabel = $this->debugLabel($lookups, $lookupType, $objectId, ucfirst($objectType) . ' ' . $objectId);
|
||||
$expected = $this->debugRuleExpectedValue((string)($rule['type'] ?? ''));
|
||||
$invalidReference = $objectId <= 0 || ($objectType !== 'question' && $objectType !== 'condition');
|
||||
$reason = $invalidReference
|
||||
? 'Rule ' . $label . ' skipped because its referenced object is invalid.'
|
||||
: ($satisfied
|
||||
? 'Rule ' . $label . ' passed because ' . $objectLabel . ' matched ' . $this->debugExpectedLabel($expected) . '.'
|
||||
: 'Rule ' . $label . ' failed because ' . $objectLabel . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.');
|
||||
$items[] = [
|
||||
'kind' => 'rule',
|
||||
'id' => $ruleId,
|
||||
'node_id' => 'rule:' . $ruleId,
|
||||
'node_ids' => ['rule:' . $ruleId],
|
||||
'condition_id' => (int)($rule['condition_id'] ?? 0),
|
||||
'label' => (string)($rule['name'] ?? $this->debugLabel($lookups, 'rules', $ruleId, 'Rule ' . $ruleId)),
|
||||
'label' => $label,
|
||||
'type' => (string)($rule['type'] ?? ''),
|
||||
'object_type' => $objectType,
|
||||
'object_id' => $objectId,
|
||||
'object_label' => $this->debugLabel($lookups, $lookupType, $objectId, ucfirst($objectType) . ' ' . $objectId),
|
||||
'object_label' => $objectLabel,
|
||||
'actual_value' => $actual,
|
||||
'satisfied' => $satisfied,
|
||||
'invalid_reference' => $objectId <= 0 || ($objectType !== 'question' && $objectType !== 'condition'),
|
||||
'reason' => $satisfied ? 'Rule passed for the simulated value.' : 'Rule did not pass for the simulated value.',
|
||||
'invalid_reference' => $invalidReference,
|
||||
'reason' => $reason,
|
||||
'causes' => [
|
||||
$this->debugCause($objectType ?: 'object', $objectId, $objectLabel, $expected, $actual, $invalidReference ? 'Invalid rule reference.' : null),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1080,10 +1125,29 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : [];
|
||||
$expressionTrace = is_array($expressionTraces[$conditionId] ?? null) ? (array)$expressionTraces[$conditionId] : null;
|
||||
$nextFix = $expressionTrace === null ? null : $this->nextFixForExpressionTrace($expressionTrace, $lookups);
|
||||
$label = (string)($condition['name'] ?? $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId));
|
||||
$causes = [];
|
||||
$failedExpressionCause = $expressionTrace === null ? null : $this->debugFailedExpressionCause($expressionTrace, $lookups);
|
||||
if ($failedExpressionCause !== null) {
|
||||
$causes[] = $failedExpressionCause;
|
||||
} elseif (!$result) {
|
||||
foreach ((array)($rulesByCondition[$conditionId] ?? []) as $rule) {
|
||||
if (($rule['satisfied'] ?? false) !== true) {
|
||||
foreach ((array)($rule['causes'] ?? []) as $cause) {
|
||||
if (is_array($cause)) {
|
||||
$causes[] = $cause;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$items[] = [
|
||||
'kind' => 'condition',
|
||||
'id' => $conditionId,
|
||||
'node_id' => 'condition:' . $conditionId,
|
||||
'label' => (string)($condition['name'] ?? $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId)),
|
||||
'node_ids' => ['condition:' . $conditionId],
|
||||
'label' => $label,
|
||||
'result' => $result,
|
||||
'state' => $result ? 'passed' : 'failed',
|
||||
'parent_condition_id' => $this->nullableInt($condition['condition_id'] ?? null),
|
||||
@@ -1096,6 +1160,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'reason' => in_array($conditionId, $cycleIds, true)
|
||||
? 'Condition dependency cycle detected.'
|
||||
: ($expressionTrace['expression']['reason'] ?? ($result ? 'Condition passed.' : 'Condition failed or has no passing rules.')),
|
||||
'causes' => $causes,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1128,6 +1193,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$gateTrace[(int)($trace['task_id'] ?? 0)] = $trace;
|
||||
}
|
||||
}
|
||||
$conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null)
|
||||
? (array)$snapshot['evaluation_trace']['condition_results']
|
||||
: [];
|
||||
$visibleAnswers = is_array($snapshot['debug_candidates']['visible_answers'] ?? null)
|
||||
? (array)$snapshot['debug_candidates']['visible_answers']
|
||||
: (is_array($snapshot['answers'] ?? null) ? (array)$snapshot['answers'] : []);
|
||||
$bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace);
|
||||
$items = [];
|
||||
|
||||
@@ -1147,25 +1218,32 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$active = isset($activeIds[$taskId]);
|
||||
$gateType = (string)($trace['gate_type'] ?? $task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value);
|
||||
$gateRefId = $this->nullableInt($trace['gate_ref_id'] ?? $task['gate_ref_id'] ?? $task['condition_id'] ?? null);
|
||||
$gateRefLabel = $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId);
|
||||
$label = (string)($task['task'] ?? $this->debugLabel($lookups, 'tasks', $taskId, 'Task ' . $taskId));
|
||||
$gateSatisfied = ($trace['satisfied'] ?? false) === true;
|
||||
$gateDecision = $this->debugTaskGateDecision($label, $gateType, $gateRefId, $gateRefLabel, $active, $gateSatisfied, $conditionResults, $visibleAnswers);
|
||||
$taskAttachments = is_array($task['attachments'] ?? null) ? array_values($task['attachments']) : ($activeAttachments[$taskId] ?? []);
|
||||
$items[] = [
|
||||
'kind' => 'task',
|
||||
'id' => $taskId,
|
||||
'node_id' => 'task:' . $taskId,
|
||||
'label' => (string)($task['task'] ?? $this->debugLabel($lookups, 'tasks', $taskId, 'Task ' . $taskId)),
|
||||
'node_ids' => ['task:' . $taskId],
|
||||
'label' => $label,
|
||||
'description' => (string)($task['description'] ?? ''),
|
||||
'active' => $active,
|
||||
'state' => $active ? 'active' : 'blocked',
|
||||
'gate_type' => $gateType,
|
||||
'gate_ref_id' => $gateRefId,
|
||||
'gate_ref_label' => $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId),
|
||||
'gate_satisfied' => ($trace['satisfied'] ?? false) === true,
|
||||
'gate_ref_label' => $gateRefLabel,
|
||||
'gate_satisfied' => $gateSatisfied,
|
||||
'services' => $services,
|
||||
'buttons' => $this->normalizeButtonList($task['buttons'] ?? null),
|
||||
'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'],
|
||||
'attachments' => $taskAttachments,
|
||||
'relay_bindings' => $bindings,
|
||||
'order_priority' => (int)($task['order_priority'] ?? 0),
|
||||
'reason' => $active ? 'Task gate passed.' : 'Task gate did not pass.',
|
||||
'reason' => $gateDecision['reason'],
|
||||
'causes' => $gateDecision['causes'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1210,42 +1288,59 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$modeMatches = in_array((string)$action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $expectedMode], true);
|
||||
$scopeMatches = true;
|
||||
$scopeReason = null;
|
||||
$scopeCause = null;
|
||||
if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) {
|
||||
$scopeMatches = false;
|
||||
$scopeReason = 'Action department scope does not match the simulated lane.';
|
||||
$scopeCause = $this->debugCause('department', (int)$action['department'], 'Action department scope', $departmentId, (int)$action['department'], $scopeReason);
|
||||
} elseif ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) {
|
||||
$scopeMatches = false;
|
||||
$scopeReason = 'Action lane scope does not match the simulated lane.';
|
||||
$scopeCause = $this->debugCause('lane', (int)$action['lane'], 'Action lane scope', $laneId, (int)$action['lane'], $scopeReason);
|
||||
} elseif ((int)$action['product'] !== 0 && ($vehicleTypeId === null || (int)$action['product'] !== $vehicleTypeId)) {
|
||||
$scopeMatches = false;
|
||||
$scopeReason = 'Action vehicle type scope does not match the simulated vehicle.';
|
||||
$scopeCause = $this->debugCause('vehicle_type', (int)$action['product'], 'Action vehicle type scope', $vehicleTypeId, (int)$action['product'], $scopeReason);
|
||||
} elseif ($action['machine_type_id'] !== null && (int)$action['machine_type_id'] !== ($machineTypeId ?? 0)) {
|
||||
$scopeMatches = false;
|
||||
$scopeReason = 'Action machine type scope does not match the simulated lane.';
|
||||
$scopeCause = $this->debugCause('machine_type', (int)$action['machine_type_id'], 'Action machine type scope', $machineTypeId, (int)$action['machine_type_id'], $scopeReason);
|
||||
}
|
||||
|
||||
$conditionId = $this->nullableInt($action['condition_id'] ?? null);
|
||||
$conditionSatisfied = $conditionId === null || (($conditionResults[$conditionId] ?? false) === true);
|
||||
$enabled = (bool)($action['enabled'] ?? true);
|
||||
$active = $enabled && $modeMatches && $scopeMatches && $conditionSatisfied;
|
||||
$label = (string)$action['name'];
|
||||
$conditionLabel = $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId);
|
||||
$causes = [];
|
||||
$reason = 'Action would run for this simulator event.';
|
||||
if (!$enabled) {
|
||||
$reason = 'Action is disabled.';
|
||||
$causes[] = $this->debugCause('action', $actionId, $label, true, false, $reason);
|
||||
} elseif (!$modeMatches) {
|
||||
$reason = 'Action wash mode ' . (string)$action['wash_mode'] . ' does not match simulated ' . $expectedMode . ' mode.';
|
||||
$causes[] = $this->debugCause('wash_mode', $actionId, 'Action wash mode', selfserve_studio_actions::MODE_BOTH . ' or ' . $expectedMode, (string)$action['wash_mode'], $reason);
|
||||
} elseif (!$scopeMatches) {
|
||||
$reason = $scopeReason ?? 'Action scope does not match the simulated lane.';
|
||||
if ($scopeCause !== null) {
|
||||
$causes[] = $scopeCause;
|
||||
}
|
||||
} elseif (!$conditionSatisfied) {
|
||||
$reason = 'Action condition gate did not pass.';
|
||||
$reason = 'Action ' . $label . ' skipped because condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$conditionId] ?? null) . '.';
|
||||
$causes[] = $this->debugCause('condition', $conditionId, $conditionLabel, true, ($conditionResults[$conditionId] ?? null), $reason);
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'kind' => 'action',
|
||||
'id' => $actionId,
|
||||
'node_id' => 'action:' . $actionId,
|
||||
'label' => (string)$action['name'],
|
||||
'node_ids' => ['action:' . $actionId],
|
||||
'label' => $label,
|
||||
'active' => $active,
|
||||
'state' => $active ? 'active' : 'skipped',
|
||||
'reason' => $reason,
|
||||
'causes' => $causes,
|
||||
'event' => (string)$action['event'],
|
||||
'event_label' => selfserve_studio_actions::eventLabel((string)$action['event']),
|
||||
'wash_mode' => (string)$action['wash_mode'],
|
||||
@@ -1255,7 +1350,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$action['operation']),
|
||||
'relay_state' => $action['relay_state'],
|
||||
'condition_id' => $conditionId,
|
||||
'condition' => $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId),
|
||||
'condition' => $conditionLabel,
|
||||
'condition_satisfied' => $conditionSatisfied,
|
||||
'scope' => [
|
||||
'department' => (int)$action['department'],
|
||||
@@ -1756,7 +1851,30 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
string $predictedStatus,
|
||||
?string $reason
|
||||
): array {
|
||||
$id = 'signal:' . $sequence;
|
||||
$label = trim($runtimeStage . ' ' . $relayRole . ' ' . $signalType);
|
||||
$targetBinding = $binding['node_id'] ?? null;
|
||||
$nodeIds = [$id];
|
||||
if (isset($payload['action_id'])) {
|
||||
$nodeIds[] = 'action:' . (int)$payload['action_id'];
|
||||
}
|
||||
if (is_string($targetBinding) && $targetBinding !== '') {
|
||||
$nodeIds[] = $targetBinding;
|
||||
}
|
||||
if ($relayId !== null && trim($relayId) !== '') {
|
||||
$nodeIds[] = 'relay:' . $relayId;
|
||||
}
|
||||
$causes = $reason === null ? [] : [
|
||||
$this->debugCause('signal', $id, $label, 'sent', $predictedStatus, $reason),
|
||||
];
|
||||
|
||||
return [
|
||||
'kind' => 'signal',
|
||||
'id' => $id,
|
||||
'node_id' => $id,
|
||||
'node_ids' => array_values(array_unique($nodeIds)),
|
||||
'label' => $label,
|
||||
'state' => $predictedStatus,
|
||||
'sequence' => $sequence,
|
||||
'runtime_stage' => $runtimeStage,
|
||||
'signal_type' => $signalType,
|
||||
@@ -1777,6 +1895,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'predicted_status' => $predictedStatus,
|
||||
'skip_block_reason' => $reason,
|
||||
'reason' => $reason,
|
||||
'causes' => $causes,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2083,6 +2202,333 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
};
|
||||
}
|
||||
|
||||
protected function debugRuleExpectedValue(string $type): mixed
|
||||
{
|
||||
return match (strtoupper(trim($type))) {
|
||||
'IS_TRUE', 'IS_TRUE_OR_ANY_TRUE' => true,
|
||||
'IS_FALSE' => false,
|
||||
'IS_SET' => 'set',
|
||||
'IS_TRUE_OR_NOT_SET' => 'true or missing',
|
||||
'IS_FALSE_OR_NOT_SET' => 'false or missing',
|
||||
default => strtolower(str_replace('_', ' ', trim($type))),
|
||||
};
|
||||
}
|
||||
|
||||
protected function debugExpectedLabel(mixed $expected): string
|
||||
{
|
||||
return $this->debugValueLabel($expected);
|
||||
}
|
||||
|
||||
protected function debugValueLabel(mixed $value): string
|
||||
{
|
||||
if ($value === true) {
|
||||
return 'true';
|
||||
}
|
||||
if ($value === false) {
|
||||
return 'false';
|
||||
}
|
||||
if ($value === null) {
|
||||
return 'missing';
|
||||
}
|
||||
if (is_array($value)) {
|
||||
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
return $encoded === false ? 'array' : $encoded;
|
||||
}
|
||||
if ($value instanceof \Stringable) {
|
||||
return (string)$value;
|
||||
}
|
||||
return trim((string)$value) === '' ? 'empty' : (string)$value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
protected function debugCause(string $kind, mixed $id, string $label, mixed $expected, mixed $actual, ?string $reason = null): array
|
||||
{
|
||||
return [
|
||||
'kind' => $kind,
|
||||
'id' => $id,
|
||||
'label' => $label,
|
||||
'expected' => $expected,
|
||||
'actual' => $actual,
|
||||
'expected_label' => $this->debugExpectedLabel($expected),
|
||||
'actual_label' => $this->debugValueLabel($actual),
|
||||
'reason' => $reason,
|
||||
];
|
||||
}
|
||||
|
||||
protected function debugExpressionTraceReason(mixed $trace): ?string
|
||||
{
|
||||
if (!is_array($trace)) {
|
||||
return null;
|
||||
}
|
||||
$expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : (array)$trace;
|
||||
$failed = $this->firstFailedPredicateTrace($expression);
|
||||
if ($failed !== null) {
|
||||
return (string)($failed['reason'] ?? 'Predicate did not pass.');
|
||||
}
|
||||
return isset($trace['reason']) ? (string)$trace['reason'] : (isset($expression['reason']) ? (string)$expression['reason'] : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $trace
|
||||
* @param array<string,mixed> $lookups
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
protected function debugFailedExpressionCause(array $trace, array $lookups): ?array
|
||||
{
|
||||
$expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : $trace;
|
||||
$failed = $this->firstFailedPredicateTrace($expression);
|
||||
if ($failed === null) {
|
||||
if (($expression['result'] ?? true) === false) {
|
||||
return $this->debugCause(
|
||||
'expression',
|
||||
$trace['condition_id'] ?? null,
|
||||
'Condition expression',
|
||||
true,
|
||||
false,
|
||||
(string)($expression['reason'] ?? $trace['reason'] ?? 'Expression did not pass.')
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$subjectType = strtolower((string)($failed['subject_type'] ?? 'question'));
|
||||
$subjectId = (int)($failed['subject_id'] ?? 0);
|
||||
$lookupType = $subjectType === 'condition' ? 'conditions' : 'questions';
|
||||
$label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId);
|
||||
$expected = $this->debugRuleExpectedValue((string)($failed['operator'] ?? 'IS_TRUE'));
|
||||
$actual = $failed['actual_value'] ?? null;
|
||||
$reason = ucfirst($subjectType) . ' ' . $label . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.';
|
||||
|
||||
return $this->debugCause($subjectType ?: 'predicate', $subjectId, $label, $expected, $actual, $reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,bool|null> $conditionResults
|
||||
* @param array<int,bool|null> $visibleAnswers
|
||||
* @return array{reason:string,causes:array<int,array<string,mixed>>}
|
||||
*/
|
||||
protected function debugTaskGateDecision(string $taskLabel, string $gateType, ?int $gateRefId, string $gateRefLabel, bool $active, bool $gateSatisfied, array $conditionResults, array $visibleAnswers): array
|
||||
{
|
||||
$gateType = strtoupper(trim($gateType));
|
||||
if ($gateType === '' || $gateType === selfserve_task_gate_type::ALWAYS->value) {
|
||||
if (!$active && $gateSatisfied) {
|
||||
return [
|
||||
'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ALWAYS passed.',
|
||||
'causes' => [
|
||||
$this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'),
|
||||
],
|
||||
];
|
||||
}
|
||||
return [
|
||||
'reason' => $active
|
||||
? 'Task ' . $taskLabel . ' active because gate ALWAYS is open.'
|
||||
: 'Task ' . $taskLabel . ' blocked because gate ALWAYS expected true, actual false.',
|
||||
'causes' => $active ? [] : [
|
||||
$this->debugCause('gate', null, 'ALWAYS', true, false, 'ALWAYS gate unexpectedly did not pass.'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$actual = $gateType === selfserve_task_gate_type::CONDITION->value
|
||||
? ($gateRefId === null ? null : ($conditionResults[$gateRefId] ?? null))
|
||||
: ($gateRefId === null ? null : ($visibleAnswers[$gateRefId] ?? null));
|
||||
$sourceKind = $gateType === selfserve_task_gate_type::CONDITION->value ? 'condition' : 'question';
|
||||
if (!$active && $gateSatisfied) {
|
||||
return [
|
||||
'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ' . $gateType . ' ' . $gateRefLabel . ' passed.',
|
||||
'causes' => [
|
||||
$this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, 'Gate passed.'),
|
||||
$this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'),
|
||||
],
|
||||
];
|
||||
}
|
||||
$reason = 'Task ' . $taskLabel . ($active ? ' active' : ' blocked') . ' because gate ' . $gateType . ' ' . $gateRefLabel . ' expected true, actual ' . $this->debugValueLabel($actual) . '.';
|
||||
|
||||
return [
|
||||
'reason' => $reason,
|
||||
'causes' => $active ? [] : [
|
||||
$this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, $reason),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $tasks
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
protected function buildDebugDynamicImageButtons(array $tasks): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($tasks as $task) {
|
||||
$taskId = (int)($task['id'] ?? 0);
|
||||
if ($taskId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$taskLabel = (string)($task['label'] ?? $task['task'] ?? ('Task ' . $taskId));
|
||||
$active = (bool)($task['active'] ?? false);
|
||||
foreach ($this->dynamicImageButtonSequenceForTask($task) as $index => $button) {
|
||||
$buttonLabel = $this->debugDynamicImageButtonLabel($button);
|
||||
$id = $taskId . ':' . (int)$index;
|
||||
$nodeId = 'dynamic_image_button:' . $id;
|
||||
$reason = $active
|
||||
? 'Dynamic-image button ' . $buttonLabel . ' is available because task ' . $taskLabel . ' is active.'
|
||||
: 'Dynamic-image button ' . $buttonLabel . ' hidden because task ' . $taskLabel . ' is blocked.';
|
||||
$items[] = [
|
||||
'kind' => 'dynamic_image_button',
|
||||
'id' => $id,
|
||||
'node_id' => $nodeId,
|
||||
'node_ids' => ['task:' . $taskId, $nodeId],
|
||||
'label' => $buttonLabel,
|
||||
'task_id' => $taskId,
|
||||
'task_label' => $taskLabel,
|
||||
'button_index' => (int)$index,
|
||||
'button' => $button,
|
||||
'state' => $active ? 'active' : 'hidden',
|
||||
'reason' => $reason,
|
||||
'causes' => $active ? [] : [
|
||||
$this->debugCause('task', $taskId, $taskLabel, 'active', (string)($task['state'] ?? 'blocked'), (string)($task['reason'] ?? $reason)),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
protected function taskUsesProgramPicker(array $task): bool
|
||||
{
|
||||
if (in_array(
|
||||
'PROGRAM_PICKER',
|
||||
$this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)),
|
||||
true
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array('program_picker', $this->normalizeButtonList($task['buttons'] ?? null), true);
|
||||
}
|
||||
|
||||
protected function isProgramNumberButton(mixed $button): bool
|
||||
{
|
||||
return is_int($button) && $button >= 0 && $button <= 11;
|
||||
}
|
||||
|
||||
protected function dynamicImageButtonSequenceForTask(array $task): array
|
||||
{
|
||||
$buttons = $this->normalizeButtonList($task['buttons'] ?? null);
|
||||
if (!$this->taskUsesProgramPicker($task)) {
|
||||
return $buttons;
|
||||
}
|
||||
|
||||
$sequence = ['program_picker'];
|
||||
foreach ($buttons as $button) {
|
||||
if ($button === 'program_picker' || $this->isProgramNumberButton($button)) {
|
||||
continue;
|
||||
}
|
||||
$sequence[] = $button;
|
||||
}
|
||||
|
||||
return $this->normalizeButtonList($sequence);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $questions
|
||||
* @param array<int,array<string,mixed>> $conditions
|
||||
* @param array<int,array<string,mixed>> $rules
|
||||
* @param array<int,array<string,mixed>> $tasks
|
||||
* @param array<int,array<string,mixed>> $actions
|
||||
* @param array<int,array<string,mixed>> $signals
|
||||
* @param array<int,array<string,mixed>> $dynamicImageButtons
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
protected function buildDebugDecisions(array $questions, array $conditions, array $rules, array $tasks, array $actions, array $signals, array $dynamicImageButtons): array
|
||||
{
|
||||
$decisions = [];
|
||||
foreach ([
|
||||
'question' => $questions,
|
||||
'condition' => $conditions,
|
||||
'rule' => $rules,
|
||||
'task' => $tasks,
|
||||
'action' => $actions,
|
||||
'signal' => $signals,
|
||||
'dynamic_image_button' => $dynamicImageButtons,
|
||||
] as $kind => $items) {
|
||||
foreach ($items as $item) {
|
||||
if (is_array($item)) {
|
||||
$decisions[] = $this->debugDecisionFromItem($kind, $item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $decisions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $item
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
protected function debugDecisionFromItem(string $kind, array $item): array
|
||||
{
|
||||
$nodeIds = [];
|
||||
foreach ((array)($item['node_ids'] ?? []) as $nodeId) {
|
||||
if (is_string($nodeId) && $nodeId !== '') {
|
||||
$nodeIds[] = $nodeId;
|
||||
}
|
||||
}
|
||||
if ($nodeIds === [] && isset($item['node_id']) && is_string($item['node_id']) && $item['node_id'] !== '') {
|
||||
$nodeIds[] = $item['node_id'];
|
||||
}
|
||||
|
||||
$state = (string)($item['state'] ?? '');
|
||||
if ($state === '') {
|
||||
$state = match ($kind) {
|
||||
'condition' => (($item['result'] ?? false) === true ? 'passed' : 'failed'),
|
||||
'rule' => (($item['satisfied'] ?? false) === true ? 'passed' : 'failed'),
|
||||
'task', 'action' => (($item['active'] ?? false) === true ? 'active' : 'skipped'),
|
||||
'signal' => (string)($item['predicted_status'] ?? 'unknown'),
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
$causes = [];
|
||||
foreach ((array)($item['causes'] ?? []) as $cause) {
|
||||
if (is_array($cause)) {
|
||||
$causes[] = $cause;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'kind' => (string)($item['kind'] ?? $kind),
|
||||
'id' => $item['id'] ?? ($item['node_id'] ?? null),
|
||||
'label' => (string)($item['label'] ?? $item['title'] ?? $item['node_id'] ?? $kind),
|
||||
'state' => $state,
|
||||
'reason' => (string)($item['reason'] ?? ''),
|
||||
'node_ids' => array_values(array_unique($nodeIds)),
|
||||
'causes' => $causes,
|
||||
];
|
||||
}
|
||||
|
||||
protected function debugDynamicImageButtonLabel(mixed $button): string
|
||||
{
|
||||
if (is_array($button)) {
|
||||
foreach (['label', 'name', 'title', 'button', 'id', 'value'] as $key) {
|
||||
if (isset($button[$key]) && trim((string)$button[$key]) !== '') {
|
||||
return (string)$button[$key];
|
||||
}
|
||||
}
|
||||
$encoded = json_encode($button, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
return $encoded === false ? 'Button' : $encoded;
|
||||
}
|
||||
$label = trim((string)$button);
|
||||
if (strtolower($label) === 'program_picker') {
|
||||
return 'Program picker';
|
||||
}
|
||||
|
||||
return $label === '' ? 'Button' : $label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $conditions
|
||||
* @param array<int,array<string,mixed>> $rules
|
||||
@@ -2211,6 +2657,26 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return $failed;
|
||||
}
|
||||
}
|
||||
foreach (['when', 'then', 'default'] as $field) {
|
||||
if (!is_array($trace[$field] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
$failed = $this->firstFailedPredicateTrace((array)$trace[$field]);
|
||||
if ($failed !== null) {
|
||||
return $failed;
|
||||
}
|
||||
}
|
||||
foreach (['branches', 'cases'] as $field) {
|
||||
foreach ((array)($trace[$field] ?? []) as $child) {
|
||||
if (!is_array($child)) {
|
||||
continue;
|
||||
}
|
||||
$failed = $this->firstFailedPredicateTrace((array)$child);
|
||||
if ($failed !== null) {
|
||||
return $failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,5 +5,6 @@ namespace modules\selfserve\helpers;
|
||||
enum selfserve_lane_services
|
||||
{
|
||||
case MACHINE; // Relay that controls the machine power
|
||||
case PROGRAM_PICKER; // Relay that controls the machine program picker
|
||||
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ Purpose: manage the task list shown after eligibility evaluation.
|
||||
Notes:
|
||||
|
||||
- Route parameter name is `condition_id`. That value is used as the task gate id.
|
||||
- `services` accepts an array, JSON array string, or comma-separated string. The only current enum case is `MACHINE`.
|
||||
- `services` accepts an array, JSON array string, or comma-separated string. Current enum cases are `MACHINE` and `PROGRAM_PICKER`.
|
||||
- If an active task does not expose `MACHINE`, the relay will not be enabled.
|
||||
|
||||
Typical failures:
|
||||
@@ -356,15 +356,15 @@ Purpose: operational lane control and relay management.
|
||||
| Method | Required params | Permissions | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /modules/self-serve/lane/status` | optional `lane_id`, default `1` | `modules_selfserve_lane_status_view` | Returns lane status, mode, state, wash timer, reg, and customer number. |
|
||||
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`. |
|
||||
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed` | Writes allowed service names to the lane cache. |
|
||||
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission, or customer `list_own_department_selfserve_vehicle_conditions` for scoped `START`, scoped `STOP`, and property gate commands | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`, `OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`. Customer `START` requires an enabled self-serve lane. Customer `STOP` and property gate commands require the customer's active wash in the lane department. |
|
||||
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed`, or customer `list_own_department_selfserve_vehicle_conditions` on an enabled self-serve lane | Writes allowed service names to the lane cache. This is still read-from-visible-tasks only; it does not activate relays. |
|
||||
| `GET /modules/self-serve/lane/relay/machine_program_picker/status` | `lane_id` | `modules_selfserve_lane_relay_machine_program_picker_status_view` | Reads the Shelly MACHINE_PROGRAM_PICKER relay state (`on`/`off`) for the lane. |
|
||||
| `POST /modules/self-serve/lane/relay/machine_program_picker/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_program_picker_status_set` | Sets Shelly MACHINE_PROGRAM_PICKER relay state directly (`on=true/false`) and returns updated status. |
|
||||
| `GET /modules/self-serve/lane/relay/machine_cleaner/status` | `lane_id` | `modules_selfserve_lane_relay_machine_cleaner_status_view` | Reads the Shelly MACHINE_CLEANER relay state (`on`/`off`) for the lane. |
|
||||
| `POST /modules/self-serve/lane/relay/machine_cleaner/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_cleaner_status_set` | Sets Shelly MACHINE_CLEANER relay state directly (`on=true/false`) and returns updated status. |
|
||||
| `GET /modules/self-serve/lane/relay/machine/status` | `lane_id` | `modules_selfserve_lane_relay_machine_status_view` | Reads the Shelly MACHINE relay state (`on`/`off`) for the lane. |
|
||||
| `POST /modules/self-serve/lane/relay/machine/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_status_set` | Sets Shelly MACHINE relay state directly (`on=true/false`) and returns updated status. |
|
||||
| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine` | Manual enable, still gated by allowed services. |
|
||||
| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine`, or customer `list_own_department_selfserve_vehicle_conditions` with an active wash in the lane department | Manual enable, still gated by allowed services. Customer flow calls this only after `START` and only when `MACHINE` is allowed. |
|
||||
| `POST /modules/self-serve/lane/force/machine/enable` | `lane_id`, optional `duration`, optional `license_plate` | `modules_selfserve_lane_force_machine_enable` | Bypasses service gating and marks the lane as in wash. |
|
||||
| `POST /modules/self-serve/lane/force/machine/disable` | `lane_id`, optional `license_plate` | `modules_selfserve_lane_force_machine_disable` | Keeps the lane in wash but turns the machine relay off. |
|
||||
|
||||
@@ -382,6 +382,12 @@ STOP flow details:
|
||||
- Unless bypass is enabled, the lane customer number must match the current authenticated user's customer number.
|
||||
- STOP calls `invoice()`, opens the exit port, turns off the machine relay if self-serve is enabled for the department, completes the latest open self-serve session, and then resets the lane.
|
||||
|
||||
Customer start-wash release checklist:
|
||||
|
||||
- Canary hardware validation must use the department and lane configured for the live Playwright/release credentials. Record the exact `department_id` and `lane_id` in the release notes before the live run.
|
||||
- Verify the self-serve module is enabled, department self-serve is enabled, the target lane has `selfserve_enabled=1`, relays and property gates are bound, minute billing product is configured, and the machine task exposes the `MACHINE` service before promoting canary.
|
||||
- Validate one supervised real-lane manual wash and, when configured, one machine wash before stable promotion. Confirm no relay changes before customer confirmation, active wash restore works across reloads, property gates open only during the active wash, `STOP` completes the session, and billing/order linkage is present.
|
||||
|
||||
Typical failures:
|
||||
|
||||
- `400` invalid parameters
|
||||
@@ -480,7 +486,7 @@ Important enums:
|
||||
- `selfserve_lane_command`: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`
|
||||
- `selfserve_lane_status`: `AVAILABLE`, `OCCUPIED`, `RESERVED`, `FAULT`, `MAINTENANCE`, `CLOSED`
|
||||
- `selfserve_lane_state`: `IDLE`, `IN_WASH`, relay states, gate states, and fault states
|
||||
- `selfserve_lane_services`: currently only `MACHINE`
|
||||
- `selfserve_lane_services`: currently `MACHINE` and `PROGRAM_PICKER`
|
||||
|
||||
### Machine Type And Wash Session Objects
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ trait selfserve_lane_cache_t
|
||||
{
|
||||
const CACHE_SELFSERVE_PREFIX = 'selfserve_lane_';
|
||||
const CACHE_SELFSERVE_LANE_KEY_STATUS = self::CACHE_SELFSERVE_PREFIX . 'status';
|
||||
const CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT = self::CACHE_SELFSERVE_PREFIX . 'status_audit';
|
||||
const CACHE_SELFSERVE_LANE_KEY_STATE = self::CACHE_SELFSERVE_PREFIX . 'state';
|
||||
const CACHE_SELFSERVE_LANE_KEY_MODE = self::CACHE_SELFSERVE_PREFIX . 'mode';
|
||||
const CACHE_SELFSERVE_LANE_KEY_WASH_START_TIME = self::CACHE_SELFSERVE_PREFIX . 'wash_start_time';
|
||||
@@ -77,4 +78,4 @@ trait selfserve_lane_cache_t
|
||||
redis->delete($this->getLaneCacheKey($laneId, $property));
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,15 @@ require_once WD . '/modules/selfserve/helpers/selfserve_lane_port.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_state.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_studio_action_runner.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
|
||||
if (!class_exists(\modules\selfserve\classes\selfserve_wash_flow::class, false)) {
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php';
|
||||
}
|
||||
if (!class_exists(\modules\selfserve\classes\selfserve_studio_action_runner::class, false)) {
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_studio_action_runner.php';
|
||||
}
|
||||
if (!class_exists(\modules\selfserve\classes\selfserve_studio_actions::class, false)) {
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
|
||||
}
|
||||
|
||||
use Exception;
|
||||
use modules\selfserve\classes\selfserve_lane;
|
||||
@@ -458,6 +464,7 @@ trait selfserve_lane_command_t
|
||||
* @param selfserve_lane_command_arguments $arguments The arguments for the command
|
||||
* @return selfserve_lane|selfserve_lane_command_t
|
||||
* @throws Exception If the command cannot be executed
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function execute(selfserve_lane_command $command, selfserve_lane_command_arguments $arguments): self
|
||||
{
|
||||
@@ -511,6 +518,7 @@ trait selfserve_lane_command_t
|
||||
// Open the entrance port before marking the lane occupied. Gateway timeouts are
|
||||
// ambiguous because the relay may already have received the pulse.
|
||||
$this->openEntrancePortForWashStart();
|
||||
$this->turnOnCleanerRelayForWashStart();
|
||||
} catch (\Throwable $e) {
|
||||
$this->setCustomerNumber($previous_customer_number);
|
||||
$this->setLicensePlate($previous_license_plate);
|
||||
@@ -561,7 +569,7 @@ trait selfserve_lane_command_t
|
||||
// Log the lane stop event
|
||||
$this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
|
||||
// Invoice the customer
|
||||
$this->invoice();
|
||||
$this->invoice($arguments);
|
||||
// Only bill the machine wash product when the physical machine start signal was recorded.
|
||||
$this->addVehicleTypeProductToInvoiceIfNeeded($machine_start_triggered);
|
||||
// Finalize any active self-serve wash session for this lane
|
||||
|
||||
@@ -4,15 +4,24 @@ namespace modules\selfserve\traits;
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_status.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_mode.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_lane.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
|
||||
require_once WD . '/modules/attachments/helpers/attachment_content.php';
|
||||
require_once WD . '/objects/selfserve_wash_sessions_o.php';
|
||||
require_once WD . '/objects/subusers_o.php';
|
||||
|
||||
use classes\selfserve;
|
||||
use classes\economic;
|
||||
use Exception;
|
||||
use attachments\helpers\attachment_content;
|
||||
use modules\selfserve\classes\selfserve_lane_command_arguments;
|
||||
use modules\selfserve\classes\selfserve_lane;
|
||||
use modules\selfserve\helpers\selfserve_lane_mode;
|
||||
use modules\selfserve\helpers\selfserve_lane_status;
|
||||
use objects\customer_vehicles_o;
|
||||
use objects\order_items_o;
|
||||
use objects\orders_o;
|
||||
use objects\selfserve_wash_sessions_o;
|
||||
use objects\subusers_o;
|
||||
|
||||
trait selfserve_lane_invoice_t
|
||||
{
|
||||
@@ -102,7 +111,7 @@ trait selfserve_lane_invoice_t
|
||||
* @return bool True on success, false on failure
|
||||
* @throws Exception if lane ID is not set, lane is not occupied, customer number or license plate is not set, or product ID is not set
|
||||
*/
|
||||
public function invoice(): bool
|
||||
public function invoice(?selfserve_lane_command_arguments $arguments = null): bool
|
||||
{
|
||||
$this->last_invoice_order_id = null;
|
||||
|
||||
@@ -115,12 +124,10 @@ trait selfserve_lane_invoice_t
|
||||
$included_minutes = $this->resolveIncludedMinutesForBilling();
|
||||
$billable_minutes = $this->calculateBillableMinutes($elapsed_minutes, $included_minutes);
|
||||
|
||||
if ($billable_minutes <= 0) {
|
||||
return true;
|
||||
if ($billable_minutes > 0) {
|
||||
$order = $this->createInvoiceOrderContext($arguments);
|
||||
$this->billable_minutes_order_item = $this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes);
|
||||
}
|
||||
|
||||
$order = $this->createInvoiceOrderContext();
|
||||
$this->billable_minutes_order_item = $this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -212,10 +219,12 @@ trait selfserve_lane_invoice_t
|
||||
return max(0, $elapsed_minutes - $included_minutes);
|
||||
}
|
||||
|
||||
protected function createInvoiceOrderContext(): orders_o
|
||||
protected function createInvoiceOrderContext(?selfserve_lane_command_arguments $arguments = null): orders_o
|
||||
{
|
||||
$billing_customer_number = $this->getCustomerNumber();
|
||||
$draft_customer_number = (new economic())->getTransactionDraftCustomerNumber();
|
||||
$order = (new orders_o())->add(
|
||||
$this->getCustomerNumber(),
|
||||
$billing_customer_number,
|
||||
self::INVOICE_SYSTEM_USER_ID,
|
||||
'',
|
||||
'',
|
||||
@@ -224,10 +233,101 @@ trait selfserve_lane_invoice_t
|
||||
);
|
||||
$order->lane->set($this->id);
|
||||
$this->last_invoice_order_id = (int)$order->id;
|
||||
$this->attachSelfServeMetadataToOrder($order, $billing_customer_number, $draft_customer_number, $arguments);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
protected function attachSelfServeMetadataToOrder(
|
||||
orders_o $order,
|
||||
int $billing_customer_number,
|
||||
?int $draft_customer_number,
|
||||
?selfserve_lane_command_arguments $arguments = null
|
||||
): void {
|
||||
try {
|
||||
$order->addAttachment(
|
||||
(new attachment_content())->setOther(
|
||||
$this->buildSelfServeOrderAttachmentPayload($billing_customer_number, $draft_customer_number, $arguments)
|
||||
)
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Metadata attachments must not block billing; the order itself is the source of record.
|
||||
}
|
||||
}
|
||||
|
||||
protected function buildSelfServeOrderAttachmentPayload(
|
||||
int $billing_customer_number,
|
||||
?int $draft_customer_number,
|
||||
?selfserve_lane_command_arguments $arguments = null
|
||||
): array {
|
||||
$session = $this->findOpenSelfServeSessionForAttachment($billing_customer_number);
|
||||
$subuser_id = $arguments?->subuser_id;
|
||||
|
||||
return [
|
||||
'type' => attachment_content::OTHER_TYPE_SELF_SERVE_WASH,
|
||||
'source' => 'selfserve',
|
||||
'customer_number' => $billing_customer_number,
|
||||
'draft_customer_number' => $draft_customer_number,
|
||||
'subuser_id' => $subuser_id,
|
||||
'subuser' => $this->formatSelfServeAttachmentSubuser($subuser_id),
|
||||
'session_id' => $session?->id,
|
||||
'lane_id' => (int)$this->id,
|
||||
'department_id' => (int)$this->department_lane->department->value(),
|
||||
'license_plate' => (string)$this->getLicensePlate(),
|
||||
'lane_status' => $this->getLaneStatus()->name,
|
||||
'lane_mode' => $this->getLaneMode()->name,
|
||||
'wash_start_time' => (int)$this->getWashStartTime(),
|
||||
'elapsed_wash_time_seconds' => (int)$this->getElapsedWashTime(),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function findOpenSelfServeSessionForAttachment(int $billing_customer_number): ?selfserve_wash_sessions_o
|
||||
{
|
||||
$license_plate = trim((string)$this->getLicensePlate());
|
||||
if ($license_plate === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLaneAndReg(
|
||||
(int)$this->id,
|
||||
selfserve::standardize_registration($license_plate),
|
||||
$billing_customer_number > 0 ? $billing_customer_number : null
|
||||
);
|
||||
return $session->exists() ? $session : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function formatSelfServeAttachmentSubuser(?int $subuser_id): ?array
|
||||
{
|
||||
if ($subuser_id === null || $subuser_id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$subuser = (new subusers_o())->select($subuser_id);
|
||||
if (!$subuser->exists()) {
|
||||
return [
|
||||
'id' => $subuser_id,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)$subuser->id,
|
||||
'name' => $subuser->name->value(),
|
||||
'username' => $subuser->username->value(),
|
||||
'email' => $subuser->email->value(),
|
||||
];
|
||||
} catch (\Throwable) {
|
||||
return [
|
||||
'id' => $subuser_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
protected function addMinuteBillingLine(int $order_id, int $product_id, int $quantity): order_items_o
|
||||
{
|
||||
return (new order_items_o())->addItemToOrder(
|
||||
|
||||
@@ -124,7 +124,7 @@ trait selfserve_lane_relay_controller_t
|
||||
*/
|
||||
public function setMachineRelayStatusHard(bool $on): bool
|
||||
{
|
||||
return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, $on);
|
||||
return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -87,4 +87,44 @@ trait selfserve_lane_status_t
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
public function setLaneStatusAudit(?array $audit): self
|
||||
{
|
||||
if ($audit === null) {
|
||||
$this->clearLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT);
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->setLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT, [
|
||||
'modified_at' => isset($audit['modified_at']) ? (string)$audit['modified_at'] : date(DATE_ATOM),
|
||||
'modified_by_user_id' => isset($audit['modified_by_user_id']) ? (int)$audit['modified_by_user_id'] : null,
|
||||
'modified_by_name' => isset($audit['modified_by_name']) ? (string)$audit['modified_by_name'] : null,
|
||||
]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLaneStatusAudit(): ?array
|
||||
{
|
||||
$audit = $this->getLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT);
|
||||
if (!is_array($audit)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$modified_at = isset($audit['modified_at']) ? trim((string)$audit['modified_at']) : '';
|
||||
$modified_by_name = isset($audit['modified_by_name']) ? trim((string)$audit['modified_by_name']) : '';
|
||||
$modified_by_user_id = isset($audit['modified_by_user_id']) && is_numeric($audit['modified_by_user_id'])
|
||||
? (int)$audit['modified_by_user_id']
|
||||
: null;
|
||||
|
||||
if ($modified_at === '' && $modified_by_name === '' && $modified_by_user_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'modified_at' => $modified_at !== '' ? $modified_at : null,
|
||||
'modified_by_user_id' => $modified_by_user_id,
|
||||
'modified_by_name' => $modified_by_name !== '' ? $modified_by_name : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,5 +19,8 @@
|
||||
"setasign/fpdi": "^2.6",
|
||||
"setasign/fpdf": "^1.8",
|
||||
"ext-mysqli": "*"
|
||||
},
|
||||
"config": {
|
||||
"secure-http": false
|
||||
}
|
||||
}
|
||||
|
||||
+7
-5
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "5c2ab021deb58020ce7d1f7f06e14f19",
|
||||
"content-hash": "d6a6015f0fa919d0d8fd2b111e901572",
|
||||
"packages": [
|
||||
{
|
||||
"name": "dompdf/dompdf",
|
||||
@@ -1366,10 +1366,12 @@
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"stability-flags": {},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": [],
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.3.0"
|
||||
"platform": {
|
||||
"ext-mysqli": "*"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ require_once 'twc_spreadsheet_class.php';
|
||||
// Set CORS headers
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Methods: GET, POST");
|
||||
header("Access-Control-Allow-Headers: Content-Type, X-Customer-Number");
|
||||
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma");
|
||||
|
||||
// Set the timezone
|
||||
date_default_timezone_set('Europe/Copenhagen');
|
||||
|
||||
@@ -130,6 +130,10 @@ class department_lanes_o extends db
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$status = (string)$this->getLaneStatus()->name;
|
||||
$machine_status_audit = $this->getMachineStatusAudit();
|
||||
$selfserve_configuration_warnings = $this->getSelfServeConfigurationWarnings();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'department' => (int)$this->department->value(),
|
||||
@@ -143,13 +147,76 @@ class department_lanes_o extends db
|
||||
'machine_type_id' => (function($v){ return $v === null ? null : (int)$v; })($this->machine_type_id->value()),
|
||||
'selfserve_enabled' => $this->isSelfServeEnabled(),
|
||||
// Status of the lane
|
||||
'status' => (string)$this->getLaneStatus()->name,
|
||||
'status' => $status,
|
||||
'machine_status_enabled' => self::isOperationalStatusName($status),
|
||||
'machine_status_audit' => $machine_status_audit,
|
||||
'machine_status_modified_at' => $machine_status_audit['modified_at'] ?? null,
|
||||
'machine_status_modified_by' => $machine_status_audit['modified_by_name'] ?? null,
|
||||
'machine_status_modified_by_user_id' => $machine_status_audit['modified_by_user_id'] ?? null,
|
||||
'selfserve_configured' => $selfserve_configuration_warnings === [],
|
||||
'dognvask_configured' => $selfserve_configuration_warnings === [],
|
||||
'dognvask_configuration_warnings' => $selfserve_configuration_warnings,
|
||||
// Timestamps
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => (string)$this->updated_at->value(),
|
||||
];
|
||||
}
|
||||
|
||||
private function getMachineStatusAudit(): ?array
|
||||
{
|
||||
try {
|
||||
$lane = (new selfserve())->lane((int)$this->id);
|
||||
if (!method_exists($lane, 'getLaneStatusAudit')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$audit = $lane->getLaneStatusAudit();
|
||||
return is_array($audit) ? $audit : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getSelfServeConfigurationWarnings(): array
|
||||
{
|
||||
self::requireSelected();
|
||||
|
||||
$required_fields = [
|
||||
'relay_in_id' => 'Indgangsrelæ',
|
||||
'relay_out_id' => 'Udgangsrelæ',
|
||||
'relay_machine_id' => 'Maskinrelæ',
|
||||
'relay_machine_program_picker_id' => 'Programvælgerrelæ',
|
||||
'relay_machine_cleaner_id' => 'Vaskerelæ',
|
||||
'dynamic_image_id' => 'Maskinstatusbillede',
|
||||
'machine_type_id' => 'Maskintype',
|
||||
];
|
||||
|
||||
$warnings = [];
|
||||
foreach ($required_fields as $field => $label) {
|
||||
if ($this->hasConfiguredFieldValue($field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$warnings[] = [
|
||||
'field' => $field,
|
||||
'label' => $label,
|
||||
'message' => $label . ' mangler',
|
||||
];
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
public function isSelfServeConfigured(): bool
|
||||
{
|
||||
return $this->getSelfServeConfigurationWarnings() === [];
|
||||
}
|
||||
|
||||
public static function isOperationalStatusName(string $status): bool
|
||||
{
|
||||
return in_array(strtoupper(trim($status)), ['AVAILABLE', 'OCCUPIED', 'RESERVED'], true);
|
||||
}
|
||||
|
||||
public function isSelfServeEnabled(): bool
|
||||
{
|
||||
self::requireSelected();
|
||||
@@ -184,6 +251,29 @@ class department_lanes_o extends db
|
||||
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private function hasConfiguredFieldValue(string $field): bool
|
||||
{
|
||||
if (!isset($this->{$field}) || !is_object($this->{$field}) || !method_exists($this->{$field}, 'value')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$value = $this->{$field}->value();
|
||||
if ($value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$value = trim($value);
|
||||
return $value !== '' && $value !== '0' && strtolower($value) !== 'null';
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int)$value > 0;
|
||||
}
|
||||
|
||||
return (bool)$value;
|
||||
}
|
||||
|
||||
public static function disableSelfServeRelaysBestEffort(int $lane_id): void
|
||||
{
|
||||
if ($lane_id <= 0) {
|
||||
|
||||
@@ -386,7 +386,7 @@ class department_selfserve_tasks_o extends db
|
||||
if (is_string($btn)) {
|
||||
$trimmed = trim($btn);
|
||||
$specialButton = strtolower($trimmed);
|
||||
if ($specialButton === 'reset' || $specialButton === 'start') {
|
||||
if ($specialButton === 'reset' || $specialButton === 'start' || $specialButton === 'program_picker') {
|
||||
$ids[] = $specialButton;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -364,11 +364,11 @@ class order_bookings_o extends db
|
||||
$this->createOrderItemsBy($user_id);
|
||||
}
|
||||
|
||||
if (!$this->containsWashCertificateItem()) {
|
||||
$order = $this->getOrder();
|
||||
if (!$this->containsWashCertificateItem() && !$order->containsWashCertificateItem()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order = $this->getOrder();
|
||||
$normalizedSafetySeal = orders_o::normalizeSafetySealValue($safety_seal);
|
||||
if ($normalizedSafetySeal !== null) {
|
||||
$order->setSafetySealValue($normalizedSafetySeal);
|
||||
@@ -414,11 +414,16 @@ class order_bookings_o extends db
|
||||
continue;
|
||||
}
|
||||
$orderItems = new order_items_o();
|
||||
$itemNotes = isset($item['notes']) && trim((string)$item['notes']) !== ''
|
||||
? (string)$item['notes']
|
||||
: ((string)($this->note->value() ?? '') ?: null);
|
||||
$orderItems->addItemToOrder(
|
||||
(int)$order->id,
|
||||
(int)$item['id'],
|
||||
(int)$user_id,
|
||||
(int)$item['quantity'],
|
||||
null,
|
||||
$itemNotes,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1194,6 +1194,141 @@ class orders_o extends db
|
||||
return $transactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get period transactions as plain rows grouped by customer number.
|
||||
*
|
||||
* This avoids hydrating one orders_o object per order for the invoicing period response.
|
||||
*
|
||||
* @param int[]|null $customers Null means all local customers with orders in the period.
|
||||
* @return array<int, array<int, array<string,mixed>>>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getPeriodTransactionsForCustomersInDateRange(?array $customers, string $dateFrom, string $dateTo): array
|
||||
{
|
||||
global $db;
|
||||
if (strtotime($dateFrom) === false || strtotime($dateTo) === false) {
|
||||
throw new Exception('Invalid date range provided');
|
||||
}
|
||||
if (strtotime($dateFrom) > strtotime($dateTo)) {
|
||||
throw new Exception('The start date cannot be after the end date');
|
||||
}
|
||||
|
||||
$customerFilter = '';
|
||||
if ($customers !== null) {
|
||||
$customers = array_values(array_unique(array_filter(
|
||||
array_map('intval', $customers),
|
||||
static fn(int $customerNumber): bool => $customerNumber > 0
|
||||
)));
|
||||
if (empty($customers)) {
|
||||
return [];
|
||||
}
|
||||
$customerFilter = ' AND o.customer_id IN (' . implode(',', $customers) . ')';
|
||||
}
|
||||
|
||||
$dateFrom = $db->escape_string($dateFrom);
|
||||
$dateTo = $db->escape_string($dateTo);
|
||||
$sql = "
|
||||
SELECT
|
||||
o.id,
|
||||
o.customer_id AS customer_number,
|
||||
customer_user.user_id,
|
||||
customer_user.customer_name,
|
||||
o.created_at,
|
||||
COALESCE(SUM(CASE WHEN oi.include_in_invoice = 1 THEN oi.price * oi.quantity ELSE 0 END), 0) AS net_amount,
|
||||
CASE
|
||||
WHEN COALESCE(o.invoice_collection_id, 0) > 0
|
||||
THEN CASE WHEN COALESCE(coi.booked_invoice_id, 0) <> 0 THEN 1 ELSE 0 END
|
||||
ELSE CASE WHEN COALESCE(emo.invoice_id, 0) <> 0 THEN 1 ELSE 0 END
|
||||
END AS booked,
|
||||
o.department_id,
|
||||
o.reference,
|
||||
o.po,
|
||||
o.notes,
|
||||
o.reg_1,
|
||||
o.reg_2,
|
||||
o.reg_3,
|
||||
o.invoice_collection_id,
|
||||
CASE
|
||||
WHEN o.include_in_invoice IS NOT NULL THEN o.include_in_invoice
|
||||
WHEN COALESCE(department_flags.exclude_from_invoicing, 0) = 1 THEN 0
|
||||
ELSE 1
|
||||
END AS include_in_invoice_effective
|
||||
FROM {$this->table} o
|
||||
INNER JOIN (
|
||||
SELECT customer_number, MIN(id) AS user_id, MAX(display_name) AS customer_name
|
||||
FROM users
|
||||
WHERE customer_number IS NOT NULL AND customer_number <> 0
|
||||
GROUP BY customer_number
|
||||
) customer_user ON customer_user.customer_number = o.customer_id
|
||||
LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '')
|
||||
LEFT JOIN collected_order_invoices coi ON coi.id = o.invoice_collection_id
|
||||
LEFT JOIN economic_module_orders emo ON emo.id = o.id
|
||||
LEFT JOIN (
|
||||
SELECT department_id, MAX(value = 'true') AS exclude_from_invoicing
|
||||
FROM department_variables
|
||||
WHERE variable = 'exclude_from_invoicing'
|
||||
GROUP BY department_id
|
||||
) department_flags ON department_flags.department_id = o.department_id
|
||||
WHERE o.created_at BETWEEN '{$dateFrom}' AND '{$dateTo}'
|
||||
AND o.deleted_at IS NULL
|
||||
{$customerFilter}
|
||||
GROUP BY
|
||||
o.id,
|
||||
o.customer_id,
|
||||
customer_user.user_id,
|
||||
customer_user.customer_name,
|
||||
o.created_at,
|
||||
coi.booked_invoice_id,
|
||||
emo.invoice_id,
|
||||
o.department_id,
|
||||
o.reference,
|
||||
o.po,
|
||||
o.notes,
|
||||
o.reg_1,
|
||||
o.reg_2,
|
||||
o.reg_3,
|
||||
o.invoice_collection_id,
|
||||
o.include_in_invoice,
|
||||
department_flags.exclude_from_invoicing
|
||||
ORDER BY o.customer_id, o.created_at, o.id";
|
||||
$result = $db->query($sql);
|
||||
if (!$result || $result->num_rows === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$transactions = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$customerNumber = (int)$row['customer_number'];
|
||||
if ($customerNumber < 1) {
|
||||
continue;
|
||||
}
|
||||
$invoiceCollectionId = (int)($row['invoice_collection_id'] ?? 0);
|
||||
$transactions[$customerNumber][] = [
|
||||
'id' => (int)$row['id'],
|
||||
'date' => (string)($row['created_at'] ?? ''),
|
||||
'created_at' => (string)($row['created_at'] ?? ''),
|
||||
'amount' => (float)($row['net_amount'] ?? 0),
|
||||
'booked' => (int)($row['booked'] ?? 0) === 1,
|
||||
'department_id' => (int)($row['department_id'] ?? 0),
|
||||
'customer_number' => $customerNumber,
|
||||
'reference' => (string)($row['reference'] ?? ''),
|
||||
'po' => (string)($row['po'] ?? ''),
|
||||
'notes' => (string)($row['notes'] ?? ''),
|
||||
'reg_1' => (string)($row['reg_1'] ?? ''),
|
||||
'reg_2' => (string)($row['reg_2'] ?? ''),
|
||||
'reg_3' => (string)($row['reg_3'] ?? ''),
|
||||
'excluded' => (int)($row['include_in_invoice_effective'] ?? 1) !== 1,
|
||||
'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null,
|
||||
'queue_status' => null,
|
||||
'queue_job_id' => null,
|
||||
'user_id' => isset($row['user_id']) ? (int)$row['user_id'] : null,
|
||||
'customer_name' => (string)($row['customer_name'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $transactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orders with possible duplicates in a date range
|
||||
* @param string $dateFrom The start date of the date range (inclusive) "Y-m-d H:i:s" format
|
||||
|
||||
@@ -11,6 +11,9 @@ class products_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
||||
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi';
|
||||
|
||||
/**
|
||||
* The name of the product
|
||||
* @var object_property
|
||||
@@ -214,7 +217,7 @@ class products_o extends db
|
||||
'piktogram' => $this->piktogram->value(),
|
||||
'economic_product_id' => $this->economic_product_id->value(),
|
||||
'apply_category_discount' => (bool)$this->apply_category_discount->value(),
|
||||
'requires_note' => (bool)$this->requires_note->value(),
|
||||
'requires_note' => $this->requiresOrderItemNote(),
|
||||
'is_wash' => (bool)$this->is_wash->value(),
|
||||
'display_in_booking_form' => (bool)$this->display_in_booking_form->value(),
|
||||
'order_priority' => (int)$this->order_priority->value(),
|
||||
@@ -224,6 +227,28 @@ class products_o extends db
|
||||
];
|
||||
}
|
||||
|
||||
public static function productDataRequiresOrderItemNote(array $product): bool
|
||||
{
|
||||
if ((bool)($product['requires_note'] ?? false)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((int)($product['id'] ?? 0) === self::EXTRAORDINARY_CHEMISTRY_PRODUCT_ID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return trim((string)($product['name'] ?? '')) === self::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME;
|
||||
}
|
||||
|
||||
public function requiresOrderItemNote(): bool
|
||||
{
|
||||
return self::productDataRequiresOrderItemNote([
|
||||
'id' => $this->id,
|
||||
'name' => (string)$this->name->value(),
|
||||
'requires_note' => (bool)$this->requires_note->value(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply department pricing to a list of products
|
||||
* @param array $products
|
||||
|
||||
@@ -22,28 +22,57 @@ class subuser_grants_o extends db
|
||||
public object_property $updated_at;
|
||||
public object_property $deleted_at;
|
||||
const defaultPermissions = [
|
||||
subusers_permission_node_key::VEHICLES_LIST,
|
||||
subusers_permission_node_key::SELFSERVE_ADD,
|
||||
subusers_permission_node_key::BOOKINGS_LIST,
|
||||
subusers_permission_node_key::BOOKINGS_ADD,
|
||||
subusers_permission_node_key::BOOKINGS_EDIT,
|
||||
subusers_permission_node_key::BOOKINGS_DELETE,
|
||||
'VEHICLES_LIST',
|
||||
'SELFSERVE_ADD',
|
||||
'BOOKINGS_LIST',
|
||||
'BOOKINGS_ADD',
|
||||
'BOOKINGS_EDIT',
|
||||
'BOOKINGS_DELETE',
|
||||
];
|
||||
|
||||
private static function normalizePermissionsValue(mixed $raw): array
|
||||
public static function normalizePermissionsValue(mixed $raw): array
|
||||
{
|
||||
if ($raw === null || $raw === '') {
|
||||
if ($raw === null || $raw === '' || $raw === false || $raw === 0 || $raw === '0') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($raw instanceof subusers_permission_node_key) {
|
||||
return [$raw->name];
|
||||
}
|
||||
|
||||
if (is_array($raw)) {
|
||||
return array_values(array_filter($raw, static fn ($permission) => is_string($permission) && trim($permission) !== ''));
|
||||
$permissions = [];
|
||||
$permissionCandidates = array_is_list($raw)
|
||||
? $raw
|
||||
: array_keys(array_filter($raw, static fn ($enabled): bool => (bool)$enabled));
|
||||
|
||||
foreach ($permissionCandidates as $permission) {
|
||||
if ($permission instanceof subusers_permission_node_key) {
|
||||
$permission = $permission->name;
|
||||
}
|
||||
|
||||
if (!is_string($permission)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$permission = strtoupper(trim($permission));
|
||||
if ($permission !== '' && subusers_permission_node_key::tryFrom($permission) !== null) {
|
||||
$permissions[] = $permission;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($permissions));
|
||||
}
|
||||
|
||||
if (is_string($raw)) {
|
||||
$decoded = json_decode($raw, true);
|
||||
if (is_array($decoded)) {
|
||||
return array_values(array_filter($decoded, static fn ($permission) => is_string($permission) && trim($permission) !== ''));
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
return self::normalizePermissionsValue($decoded);
|
||||
}
|
||||
|
||||
$permission = strtoupper(trim($raw));
|
||||
if (subusers_permission_node_key::tryFrom($permission) !== null) {
|
||||
return [$permission];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,12 +132,13 @@ class subuser_grants_o extends db
|
||||
public function add(int $billing_customer_number, int $subuser, bool $enabled, ?string $note, ?array $permissions = self::defaultPermissions): subuser_grants_o
|
||||
{
|
||||
global $db;
|
||||
$permissions = self::normalizePermissionsValue($permissions);
|
||||
$tmp = $this->add_object([
|
||||
'billing_customer_number' => (int)$billing_customer_number,
|
||||
'subuser' => (int)$subuser,
|
||||
'enabled' => (bool)$enabled,
|
||||
'note' => !empty($note) ? $db->escape_string($note) : null,
|
||||
'permissions' => !empty($permissions) ? json_encode($permissions) : json_encode([]),
|
||||
'permissions' => json_encode($permissions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
$this->id = (int)$tmp;
|
||||
$this->getObjectProperties();
|
||||
|
||||
@@ -15,6 +15,11 @@ class subusers_o extends db
|
||||
{
|
||||
use db_object_t;
|
||||
|
||||
public const PASSWORD_MIN_LENGTH = 8;
|
||||
public const PASSWORD_MAX_LENGTH = 255;
|
||||
public const PASSWORD_PATTERN = '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/';
|
||||
public const PASSWORD_COMPLEXITY_MESSAGE = 'Password must contain at least one uppercase letter, one lowercase letter, and one number';
|
||||
|
||||
public object_property $username;
|
||||
public object_property $password;
|
||||
public object_property $name;
|
||||
@@ -62,6 +67,23 @@ class subusers_o extends db
|
||||
return (bool)$this->two_factor_enabled->value();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function assertValidPassword(string $password): void
|
||||
{
|
||||
if (
|
||||
strlen($password) < self::PASSWORD_MIN_LENGTH
|
||||
|| strlen($password) > self::PASSWORD_MAX_LENGTH
|
||||
|| !preg_match(self::PASSWORD_PATTERN, $password)
|
||||
) {
|
||||
throw new Exception(
|
||||
'Password must be between ' . self::PASSWORD_MIN_LENGTH . ' and ' . self::PASSWORD_MAX_LENGTH
|
||||
. ' characters long and contain at least one uppercase letter, one lowercase letter, and one number.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -103,11 +125,9 @@ class subusers_o extends db
|
||||
{
|
||||
global $db, $response;
|
||||
try {
|
||||
if (!empty($password)) {
|
||||
// Validate the password (at least 8 characters, at least one uppercase letter, at least one lowercase letter, at least one number)
|
||||
if (!preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/', $password)) {
|
||||
throw new Exception('Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number.');
|
||||
}
|
||||
$passwordWasProvided = !empty($password);
|
||||
if ($passwordWasProvided) {
|
||||
self::assertValidPassword($password);
|
||||
// Hash the password
|
||||
$password = password_hash($password, PASSWORD_DEFAULT);
|
||||
}
|
||||
@@ -163,6 +183,7 @@ class subusers_o extends db
|
||||
public function setPassword(string $password): self
|
||||
{
|
||||
self::requireSelected();
|
||||
self::assertValidPassword($password);
|
||||
$this->password->set((string)password_hash($password, PASSWORD_DEFAULT));
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use classes\customer_name_cache_payload_builder;
|
||||
use classes\object_property;
|
||||
use classes\redis;
|
||||
use classes\response;
|
||||
use classes\system_search_economic_customer_index;
|
||||
use classes\xlvask;
|
||||
use customers\economic_customer_mo;
|
||||
use customers\economicCustomers;
|
||||
@@ -499,8 +500,9 @@ class users_o extends db
|
||||
}
|
||||
$cached = $tmp_user->getCached('economic_customer');
|
||||
}
|
||||
if ($cached && isset($cached->name) && is_string($cached->name) && trim($cached->name) !== '') {
|
||||
return $cached->name;
|
||||
$cachePayload = self::buildCustomerNameCachePayload($cached, $fallbackName);
|
||||
if ($cachePayload !== null) {
|
||||
return $cachePayload['name'];
|
||||
}
|
||||
return $fallbackName;
|
||||
}
|
||||
@@ -1534,7 +1536,7 @@ class users_o extends db
|
||||
* @param int[] $customer_numbers
|
||||
* @return array<string, int> Map of customer number to customer name
|
||||
*/
|
||||
public function getCustomerNames(array $customer_numbers): array
|
||||
public function getCustomerNames(array $customer_numbers, bool $allowExternalFetch = true): array
|
||||
{
|
||||
global $db;
|
||||
$customer_numbers = array_map('intval', $customer_numbers);
|
||||
@@ -1547,7 +1549,8 @@ class users_o extends db
|
||||
$customer_names_cached = self::getCachedForMultipleObjects('economic_customer_name', $customer_numbers);
|
||||
// Loop through the customer numbers and check if they are cached
|
||||
$customer_names = array_map(function ($cached_name) {
|
||||
return $cached_name ? json_decode($cached_name)->name : null;
|
||||
$cache_payload = self::buildCustomerNameCachePayload($cached_name, null);
|
||||
return $cache_payload['name'] ?? null;
|
||||
}, array_values($customer_names_cached));
|
||||
// Set the names for the cached customer numbers [ "customer_number" => "customer_name" ]
|
||||
$customer_names = array_combine(
|
||||
@@ -1560,11 +1563,24 @@ class users_o extends db
|
||||
$customer_numbers_to_fetch[] = (int)$customer_number;
|
||||
}
|
||||
}
|
||||
$fallback_names = $this->getLocalDisplayNamesByCustomerNumber($customer_numbers_to_fetch);
|
||||
$local_cached_names = $this->getCachedEconomicCustomerNamesByCustomerNumber($customer_numbers_to_fetch);
|
||||
if (!$allowExternalFetch) {
|
||||
foreach ($customer_numbers_to_fetch as $customer_number) {
|
||||
$customer_names[(string)$customer_number] = $local_cached_names[$customer_number] ?? $fallback_names[$customer_number] ?? 'Unknown Customer';
|
||||
}
|
||||
return $customer_names;
|
||||
}
|
||||
|
||||
// Fetch the remaining customer names from E-conomic
|
||||
if (count($customer_numbers_to_fetch) > 0) {
|
||||
foreach ( $customer_numbers_to_fetch as $customer_number ) {
|
||||
if (isset($local_cached_names[$customer_number])) {
|
||||
$customer_names[(string)$customer_number] = $local_cached_names[$customer_number];
|
||||
continue;
|
||||
}
|
||||
// Get the customer name from the external source
|
||||
$fallback_name = null;
|
||||
$fallback_name = $fallback_names[$customer_number] ?? null;
|
||||
try {
|
||||
// Try to get the economic customer data cached in the user
|
||||
$tmp_user = new users_o();
|
||||
@@ -1597,6 +1613,142 @@ class users_o extends db
|
||||
return $customer_names;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $customer_numbers
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private function getLocalDisplayNamesByCustomerNumber(array $customer_numbers): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$customer_numbers = array_values(array_unique(array_filter(array_map('intval', $customer_numbers), static fn(int $customer_number): bool => $customer_number > 0)));
|
||||
if (empty($customer_numbers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sql = "SELECT customer_number, display_name FROM $this->table WHERE customer_number IN (" . implode(',', $customer_numbers) . ")";
|
||||
$result = $db->query($sql);
|
||||
if (!$result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$customer_number = (int)($row['customer_number'] ?? 0);
|
||||
$display_name = trim((string)($row['display_name'] ?? ''));
|
||||
if ($customer_number > 0 && $display_name !== '') {
|
||||
$names[$customer_number] = $display_name;
|
||||
}
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve names from local e-conomic snapshots only. This keeps period/listing
|
||||
* requests fast while still avoiding "Unnamed" fallbacks when a richer cached
|
||||
* e-conomic customer payload already exists.
|
||||
*
|
||||
* @param int[] $customer_numbers
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private function getCachedEconomicCustomerNamesByCustomerNumber(array $customer_numbers): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$customer_numbers = array_values(array_unique(array_filter(array_map('intval', $customer_numbers), static fn(int $customer_number): bool => $customer_number > 0)));
|
||||
if (empty($customer_numbers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sql = "SELECT id, customer_number FROM $this->table WHERE customer_number IN (" . implode(',', $customer_numbers) . ")";
|
||||
$result = $db->query($sql);
|
||||
if (!$result) {
|
||||
return $this->getIndexedEconomicCustomerNamesByCustomerNumber($customer_numbers);
|
||||
}
|
||||
|
||||
$user_ids_by_customer_number = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$customer_number = (int)($row['customer_number'] ?? 0);
|
||||
$user_id = (int)($row['id'] ?? 0);
|
||||
if ($customer_number <= 0 || $user_id <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$user_ids_by_customer_number[$customer_number] = $user_id;
|
||||
}
|
||||
|
||||
$names = [];
|
||||
$customer_numbers_by_index = array_keys($user_ids_by_customer_number);
|
||||
$cached_names = $this->getCachedForMultipleObjects('economic_customer', array_values($user_ids_by_customer_number));
|
||||
foreach ($customer_numbers_by_index as $index => $customer_number) {
|
||||
$cached_name = $cached_names[$index] ?? null;
|
||||
$cache_payload = self::buildCustomerNameCachePayload($cached_name, null);
|
||||
if ($cache_payload === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$names[$customer_number] = $cache_payload['name'];
|
||||
$this->cache('economic_customer_name', $cache_payload, $customer_number);
|
||||
$this->setCachedExpiration('economic_customer_name', self::$economicCustomerNameCacheExpiration, $customer_number);
|
||||
}
|
||||
|
||||
$missing_customer_numbers = array_values(array_diff($customer_numbers, array_keys($names)));
|
||||
if (!empty($missing_customer_numbers)) {
|
||||
foreach ($this->getIndexedEconomicCustomerNamesByCustomerNumber($missing_customer_numbers) as $customer_number => $name) {
|
||||
$cache_payload = self::buildCustomerNameCachePayload((object)['name' => $name], null);
|
||||
if ($cache_payload === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$names[$customer_number] = $cache_payload['name'];
|
||||
$this->cache('economic_customer_name', $cache_payload, $customer_number);
|
||||
$this->setCachedExpiration('economic_customer_name', self::$economicCustomerNameCacheExpiration, $customer_number);
|
||||
}
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $customer_numbers
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private function getIndexedEconomicCustomerNamesByCustomerNumber(array $customer_numbers): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
$customer_numbers = array_values(array_unique(array_filter(array_map('intval', $customer_numbers), static fn(int $customer_number): bool => $customer_number > 0)));
|
||||
if (empty($customer_numbers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
system_search_economic_customer_index::ensureTable();
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = $db->query(
|
||||
"SELECT customer_number, economic_name FROM `" . system_search_economic_customer_index::TABLE . "`"
|
||||
. " WHERE customer_number IN (" . implode(',', $customer_numbers) . ")"
|
||||
);
|
||||
if (!$result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$customer_number = (int)($row['customer_number'] ?? 0);
|
||||
$cache_payload = self::buildCustomerNameCachePayload((object)['name' => $row['economic_name'] ?? null], null);
|
||||
if ($customer_number > 0 && $cache_payload !== null) {
|
||||
$names[$customer_number] = $cache_payload['name'];
|
||||
}
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $cashier_ids
|
||||
* @return array<int, string> Map of cashier id => display name
|
||||
|
||||
@@ -92,6 +92,107 @@ class xlvask_usage_logs_o extends db
|
||||
//TODO: Add cache invalidation
|
||||
}
|
||||
|
||||
public function getCachedAmountSummaryFromRow(array $row): array
|
||||
{
|
||||
$cached_amount = self::normalizeMoneyValue($row['cached_total_net_amount'] ?? null);
|
||||
$cached_at = trim((string)($row['cached_amount_at'] ?? ''));
|
||||
|
||||
if ($cached_amount !== null && $cached_at !== '') {
|
||||
return [
|
||||
'total_net_amount' => $cached_amount,
|
||||
'primary_product_name' => (string)($row['cached_primary_product_name'] ?? ''),
|
||||
'cached' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$summary = self::calculateAmountSummaryFromWashItems($row['WashItems'] ?? []);
|
||||
$id = (int)($row['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
self::cacheAmountSummary($id, $summary);
|
||||
}
|
||||
|
||||
return [
|
||||
...$summary,
|
||||
'cached' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public static function calculateAmountSummaryFromWashItems(array|string|null $washItems): array
|
||||
{
|
||||
if (is_string($washItems)) {
|
||||
$decoded = json_decode($washItems, true);
|
||||
$washItems = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
$total = 0.0;
|
||||
$primaryProductName = '';
|
||||
foreach (is_array($washItems) ? $washItems : [] as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($primaryProductName === '' && isset($item['OriginalProductName'])) {
|
||||
$primaryProductName = trim((string)$item['OriginalProductName']);
|
||||
}
|
||||
|
||||
$priceIncVat = self::normalizeMoneyValue($item['PriceIncVat'] ?? null);
|
||||
$vat = self::normalizeMoneyValue($item['Vat'] ?? 0.0) ?? 0.0;
|
||||
if ($priceIncVat === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$total += $priceIncVat - $vat;
|
||||
}
|
||||
|
||||
return [
|
||||
'total_net_amount' => round($total, 2),
|
||||
'primary_product_name' => $primaryProductName,
|
||||
];
|
||||
}
|
||||
|
||||
private static function cacheAmountSummary(int $id, array $summary): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$amount = number_format((float)($summary['total_net_amount'] ?? 0.0), 2, '.', '');
|
||||
$primaryProductName = $db->escape_string((string)($summary['primary_product_name'] ?? ''));
|
||||
$db->query(
|
||||
"UPDATE xlvask_usage_logs
|
||||
SET cached_total_net_amount = {$amount},
|
||||
cached_primary_product_name = " . ($primaryProductName === '' ? 'NULL' : "'{$primaryProductName}'") . ",
|
||||
cached_amount_at = NOW()
|
||||
WHERE id = {$id}"
|
||||
);
|
||||
}
|
||||
|
||||
private static function normalizeMoneyValue(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return (float)$value;
|
||||
}
|
||||
|
||||
$normalized = preg_replace('/[^\d,.\-]/', '', (string)$value);
|
||||
if ($normalized === null || $normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_contains($normalized, ',') && !str_contains($normalized, '.')) {
|
||||
$normalized = str_replace(',', '.', $normalized);
|
||||
} else {
|
||||
$normalized = str_replace(',', '', $normalized);
|
||||
}
|
||||
|
||||
return is_numeric($normalized) ? (float)$normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the usage logs from XL Vask
|
||||
* @param string $dateTimeModifier A date time modifier to use for the import, defaults to '-7 days'
|
||||
|
||||
+2422
-10
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,9 @@
|
||||
<testsuite name="Api">
|
||||
<directory suffix="Test.php">tests/Api</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Legacy">
|
||||
<directory suffix="Test.php">tests/Legacy</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<source>
|
||||
|
||||
@@ -3,9 +3,10 @@ FROM ${BASE_IMAGE}
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'
|
||||
apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose libcurl4-openssl-dev libsqlite3-dev; \
|
||||
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
|
||||
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY auto-updater.php /usr/local/bin/auto-updater.php
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ ARG BASE_IMAGE=php:8.2-cli-bookworm
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN set -eux; \
|
||||
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \
|
||||
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
|
||||
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/truckwash-edge-agent
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ ARG BASE_IMAGE=php:8.2-cli-bookworm
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN set -eux; \
|
||||
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \
|
||||
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
|
||||
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/truckwash-edge-agent
|
||||
|
||||
|
||||
@@ -1018,6 +1018,7 @@ final class TruckwashEdgeAgent
|
||||
private int $lastHeartbeatAt = 0;
|
||||
private int $lastMachineSignalPollAt = 0;
|
||||
private int $lastMachineSignalMonitorRefreshAt = 0;
|
||||
private ?array $lastControlPlaneResponse = null;
|
||||
private string $agentInstanceId;
|
||||
|
||||
public function __construct(string $configPath)
|
||||
@@ -1283,6 +1284,7 @@ final class TruckwashEdgeAgent
|
||||
}
|
||||
|
||||
$heartbeatSucceededAt = date('c');
|
||||
$this->applyBrokerUrlFromControlPlaneResponse($this->lastControlPlaneResponse);
|
||||
$this->lastHeartbeatAt = time();
|
||||
$this->recordSuccessfulSync($heartbeatSucceededAt, [
|
||||
'last_heartbeat_success_at' => $heartbeatSucceededAt,
|
||||
@@ -2430,13 +2432,15 @@ final class TruckwashEdgeAgent
|
||||
|
||||
private function sendControlPlaneEvent(string $endpoint, array $payload, string $type): bool
|
||||
{
|
||||
$this->lastControlPlaneResponse = null;
|
||||
$brokerDispatch = $this->dispatchBrokerControlPlaneEvent($endpoint, $payload);
|
||||
if ($brokerDispatch === true && !$this->shouldPersistControlPlaneEventOverHttp($endpoint)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->http->post($endpoint, $payload, 20);
|
||||
$response = $this->http->post($endpoint, $payload, 20);
|
||||
$this->lastControlPlaneResponse = is_array($response) ? $response : null;
|
||||
$this->recordSuccessfulSync();
|
||||
return true;
|
||||
} catch (Throwable $throwable) {
|
||||
@@ -2457,6 +2461,29 @@ final class TruckwashEdgeAgent
|
||||
}
|
||||
}
|
||||
|
||||
private function applyBrokerUrlFromControlPlaneResponse(?array $response): void
|
||||
{
|
||||
if (!is_array($response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = is_array($response['data'] ?? null) ? (array)$response['data'] : $response;
|
||||
$brokerUrl = trim((string)($payload['broker_url'] ?? $payload['gateway']['broker_url'] ?? ''));
|
||||
if ($brokerUrl === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$current = trim((string)$this->config->get('brokerUrl'));
|
||||
if (rtrim($current, '/') === rtrim($brokerUrl, '/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->config->set('brokerUrl', rtrim($brokerUrl, '/'));
|
||||
$this->config->save();
|
||||
$this->configureBrokerClient();
|
||||
$this->logger->info('Updated broker URL from control plane heartbeat response.');
|
||||
}
|
||||
|
||||
private function shouldPersistControlPlaneEventOverHttp(string $endpoint): bool
|
||||
{
|
||||
return preg_match('#/edge-agent/gateways/\d+/heartbeat$#', $endpoint) === 1;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ namespace routes;
|
||||
use classes\authentication;
|
||||
use classes\economic;
|
||||
use classes\email;
|
||||
use classes\release_manager;
|
||||
use classes\recaptcha;
|
||||
use classes\totp;
|
||||
use classes\virkdata;
|
||||
@@ -450,14 +451,17 @@ class authRoute
|
||||
$response->error('Company phone number already registered', 400);
|
||||
}
|
||||
|
||||
// Get the customer name
|
||||
$name = (new virkdata())->getCompanyInformation($cvr, '', [])->name;
|
||||
// Get the CVR company information used for the e-conomic customer payload.
|
||||
$companyInformation = (new virkdata())->getCompanyInformation($cvr, '', []);
|
||||
$name = (string)($companyInformation->name ?? '');
|
||||
$result = $economic->createCustomer(
|
||||
(int)$companyPhone,
|
||||
$name,
|
||||
(int)$cvr,
|
||||
(string)$invoiceEmail,
|
||||
(int)$companyPhone,
|
||||
(int)$contactPhone,
|
||||
$companyInformation,
|
||||
);
|
||||
|
||||
if (!isset($result->customerNumber) || !is_numeric($result->customerNumber)) {
|
||||
@@ -825,7 +829,9 @@ class authRoute
|
||||
[
|
||||
'economic' => [
|
||||
'transaction_draft_customer_number' => (new economic())->getTransactionDraftCustomerNumber(),
|
||||
'default_distribution_department_id' => (new economic())->getDefaultDistributionDepartmentId(),
|
||||
],
|
||||
'release' => (new release_manager())->runtimeForPayload($payload, $this->getParametersAsArray()),
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -18,6 +18,35 @@ class departmentLanesRoute
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/department/lanes/status-toggles', function () {
|
||||
global $response;
|
||||
|
||||
$this->requirePermission('list_department_lanes');
|
||||
self::requireParameters(['department_id']);
|
||||
$department_id = (int)self::getParameter('department_id');
|
||||
self::requireType($department_id, self::type_int());
|
||||
self::requireMinValue($department_id, 1);
|
||||
self::requireDepartmentAccess($department_id);
|
||||
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
(new logs_o())->add('department_lanes', 'global', 1, 0, 'LIST_DEPARTMENT_LANE_STATUS_TOGGLES', 'User tried to list department lane status toggles without being logged in');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
|
||||
(new logs_o())->add('department_lanes', 'global', 1, $user->id, 'LIST_DEPARTMENT_LANE_STATUS_TOGGLES', 'User listed department lane status toggles for department ' . $department_id);
|
||||
$response->success(
|
||||
array_map(
|
||||
static fn (department_lanes_o $department_lane): array => $department_lane->asArray(),
|
||||
(new department_lanes_o())->getDepartmentLanes($department_id)
|
||||
)
|
||||
);
|
||||
},
|
||||
[
|
||||
'list_department_lanes' => 'List department lane status toggles'
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/department/lanes', function () {
|
||||
|
||||
// Require the user to be logged in
|
||||
@@ -135,7 +164,7 @@ class departmentLanesRoute
|
||||
* Query parameters:
|
||||
* - department (int, required)
|
||||
* - lane (int, required)
|
||||
* - buttons (array|json|csv, optional) → ordered highlighted button IDs (0-indexed), "reset", or "start"
|
||||
* - buttons (array|json|csv, optional) → ordered highlighted button IDs (0-indexed), "reset", "start", or "program_picker"
|
||||
* - current_step (int >= 0, optional) → current click/step indicator
|
||||
* - only_current_step (bool/int, optional) → if true, only draw current step highlight
|
||||
* - vehicle_type (int|null, optional) → normalized but currently not used by machine_1
|
||||
|
||||
@@ -195,6 +195,29 @@ class departmentSelfserveStudioRoute
|
||||
'list_department_selfserve_vehicle_conditions' => 'Project grouped self-serve studio question path outcomes',
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/studio/path-confirmations', function (): void {
|
||||
global $response;
|
||||
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
|
||||
self::requireParameters(['department', 'path_signature']);
|
||||
$departmentId = (int)self::getParameter('department');
|
||||
$this->assertDepartmentAccess($user, $departmentId);
|
||||
|
||||
try {
|
||||
$payload = self::getParametersAsArray();
|
||||
$action = strtolower(trim((string)($payload['action'] ?? 'confirm')));
|
||||
$service = new selfserve_studio_graph();
|
||||
$result = in_array($action, ['delete', 'reset', 'clear'], true)
|
||||
? $service->resetPathConfirmation($departmentId, $payload)
|
||||
: $service->confirmPathOutcome($departmentId, $payload, (int)$user->id);
|
||||
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'CONFIRM_STUDIO_PATH', 'Updated self-serve studio path confirmation');
|
||||
$response->success($result);
|
||||
} catch (\RuntimeException $exception) {
|
||||
$response->error($exception->getMessage(), 422);
|
||||
}
|
||||
}, [
|
||||
'edit_department_selfserve_config_versions' => 'Confirm or reset projected self-serve studio answer paths',
|
||||
]);
|
||||
|
||||
$this->post('/department/selfserve/studio/publish', function (): void {
|
||||
global $response;
|
||||
$user = $this->requireStudioUser('publish_department_selfserve_config_versions');
|
||||
|
||||
@@ -115,6 +115,7 @@ class departmentsRoute
|
||||
'economic_department_id' => (int)$department['economic_department_id'],
|
||||
'created_at' => (string)$department['created_at'],
|
||||
'updated_at' => (string)$department['updated_at'],
|
||||
'visible' => (int)$department['visible'],
|
||||
'dimension' => (int)$department['dimension'],
|
||||
'branding' => (int)$department['branding'],
|
||||
'archived' => (bool)(int)($department['archived'] ?? 0),
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\error_report_service;
|
||||
use Throwable;
|
||||
use traits\route_t;
|
||||
|
||||
class errorReportRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->post('/error-reports', function () {
|
||||
global $response;
|
||||
try {
|
||||
$response->success((new error_report_service())->createFromCurrentPrincipal($this->requestPayload()), 201);
|
||||
} catch (Throwable $throwable) {
|
||||
$status = str_contains(strtolower($throwable->getMessage()), 'authentication failed') ? 401 : 400;
|
||||
$response->error(['message' => $throwable->getMessage()], $status);
|
||||
}
|
||||
});
|
||||
|
||||
$this->get('/superuser/error-reports', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_error_reports_view');
|
||||
$response->success((new error_report_service())->list($this->getParametersAsArray()));
|
||||
}, [
|
||||
'superuser_error_reports_view' => 'View authenticated user error reports',
|
||||
]);
|
||||
|
||||
$this->get('/superuser/error-reports/{id}', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_error_reports_view');
|
||||
try {
|
||||
$response->success((new error_report_service())->get($this->routeId()));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 404);
|
||||
}
|
||||
}, [
|
||||
'superuser_error_reports_view' => 'View authenticated user error report details',
|
||||
]);
|
||||
|
||||
$this->patch('/superuser/error-reports/{id}/status', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_error_reports_resolve');
|
||||
try {
|
||||
$payload = $this->requestPayload();
|
||||
$response->success((new error_report_service())->updateStatus(
|
||||
$this->routeId(),
|
||||
(string)($payload['status'] ?? ''),
|
||||
isset($payload['resolution_note']) ? (string)$payload['resolution_note'] : null,
|
||||
$this->actorUserId()
|
||||
));
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error(['message' => $throwable->getMessage()], 400);
|
||||
}
|
||||
}, [
|
||||
'superuser_error_reports_resolve' => 'Resolve and reopen authenticated user error reports',
|
||||
]);
|
||||
}
|
||||
|
||||
private function routeId(): int
|
||||
{
|
||||
$id = (int)$this->fromRoute('id');
|
||||
$this->requireParameterIntPositive($id, 'id');
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function actorUserId(): ?int
|
||||
{
|
||||
try {
|
||||
$user = (new authentication())->get_user();
|
||||
return $user !== false && isset($user->id) ? (int)$user->id : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function requestPayload(): array
|
||||
{
|
||||
$payload = json_decode(file_get_contents('php://input'), true);
|
||||
if (!is_array($payload)) {
|
||||
$payload = [];
|
||||
}
|
||||
|
||||
if ($_GET !== []) {
|
||||
$payload = array_replace($payload, $_GET);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
@@ -218,6 +218,82 @@ class moduleConfigRoute
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/failover/config', function () {
|
||||
global $response;
|
||||
$this->requirePermission('modules_failover_config');
|
||||
$user = (new authentication())->get_user();
|
||||
if ($user) {
|
||||
(new logs_o())->add('failover_config', 'global', 1, $user->id, 'FAILOVER_CONFIG', 'Successfully fetched failover config');
|
||||
$response->success(
|
||||
(new \classes\failover())->config->getConfigRequest()
|
||||
);
|
||||
} else {
|
||||
(new logs_o())->add('failover_config', 'global', 1, 0, 'FAILOVER_CONFIG', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
},
|
||||
[
|
||||
'modules_failover_config' => 'Get failover config'
|
||||
]
|
||||
);
|
||||
|
||||
$this->post('/failover/config', function () {
|
||||
global $response;
|
||||
$this->requirePermission('modules_failover_config');
|
||||
$user = (new authentication())->get_user();
|
||||
if ($user) {
|
||||
(new logs_o())->add('failover_config', 'global', 1, $user->id, 'FAILOVER_CONFIG', 'Successfully updated failover config');
|
||||
$response->success(
|
||||
(new \classes\failover())->config->postConfigRequest()
|
||||
);
|
||||
} else {
|
||||
(new logs_o())->add('failover_config', 'global', 1, 0, 'FAILOVER_CONFIG', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
},
|
||||
[
|
||||
'modules_failover_config' => 'Update failover config'
|
||||
]
|
||||
);
|
||||
|
||||
$this->get('/coolify/config', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_coolify_manage');
|
||||
$user = (new authentication())->get_user();
|
||||
if ($user) {
|
||||
(new logs_o())->add('coolify_config', 'global', 1, $user->id, 'COOLIFY_CONFIG', 'Successfully fetched Coolify config');
|
||||
$response->success(
|
||||
(new \classes\coolify())->config->getConfigRequest()
|
||||
);
|
||||
} else {
|
||||
(new logs_o())->add('coolify_config', 'global', 1, 0, 'COOLIFY_CONFIG', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
},
|
||||
[
|
||||
'superuser_coolify_manage' => 'Get Coolify config'
|
||||
]
|
||||
);
|
||||
|
||||
$this->post('/coolify/config', function () {
|
||||
global $response;
|
||||
$this->requirePermission('superuser_coolify_manage');
|
||||
$user = (new authentication())->get_user();
|
||||
if ($user) {
|
||||
(new logs_o())->add('coolify_config', 'global', 1, $user->id, 'COOLIFY_CONFIG', 'Successfully updated Coolify config');
|
||||
$response->success(
|
||||
(new \classes\coolify())->config->postConfigRequest()
|
||||
);
|
||||
} else {
|
||||
(new logs_o())->add('coolify_config', 'global', 1, 0, 'COOLIFY_CONFIG', 'No user found, or invalid session');
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
},
|
||||
[
|
||||
'superuser_coolify_manage' => 'Update Coolify config'
|
||||
]
|
||||
);
|
||||
|
||||
/** Bird config > GET */
|
||||
$this->get('/bird/config', function () {
|
||||
global $response;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user