Merge master into fix-system-search-associations-vulnerability

This commit is contained in:
copilot-swe-agent[bot]
2026-06-01 21:25:51 +00:00
committed by GitHub
1964 changed files with 300817 additions and 8111 deletions
@@ -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';
}
}
+59 -12
View File
@@ -27,18 +27,40 @@ class attachments implements attachments_i
*/
public function list(string $type, int $object_id, array $options = []): array
{
if (count($options) === 0) {
$options = ['id', 'content', 'created_at', 'object_id', 'object_type', 'created_at', 'updated_at']; // Default fields to return
$rows = $this->fetchAttachmentRows($type, [$object_id], $options);
return array_map(fn(array $row): attachment => $this->toAttachment($row), $rows);
}
/**
* List attachments for multiple objects in one query.
*
* @param string $type
* @param int[] $object_ids
* @param array $options
* @return array<int, attachment[]>
*/
public function listMany(string $type, array $object_ids, array $options = []): array
{
$object_ids = array_values(array_unique(array_filter(array_map('intval', $object_ids), static fn(int $id): bool => $id > 0)));
if (empty($object_ids)) {
return [];
}
return array_map(function ($item) {
$item = (object)$item;
$item->content = json_decode($item->content, true); // Decode JSON content
return (new attachment())->populate((object)$item);
}, (new object_attachments_o())->getFieldsWhere([
'object_type' => $type,
'object_id' => $object_id,
'deleted_at' => null
], $options));
$rows = $this->fetchAttachmentRows($type, $object_ids, $options);
$grouped = [];
foreach ($object_ids as $object_id) {
$grouped[$object_id] = [];
}
foreach ($rows as $row) {
$object_id = (int)($row['object_id'] ?? 0);
if (!isset($grouped[$object_id])) {
$grouped[$object_id] = [];
}
$grouped[$object_id][] = $this->toAttachment($row);
}
return $grouped;
}
/**
@@ -107,4 +129,29 @@ class attachments implements attachments_i
}
return false;
}
}
protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array
{
$options = $this->normalizeAttachmentOptions($options);
return (new object_attachments_o())->getFieldsWhereIn([
'object_type' => $type,
'object_id' => $object_ids,
'deleted_at' => null
], $options);
}
protected function toAttachment(array $item): attachment
{
$payload = (object)$item;
$payload->content = json_decode((string)$payload->content, true);
return (new attachment())->populate($payload);
}
protected function normalizeAttachmentOptions(array $options): array
{
if (count($options) === 0) {
return ['id', 'content', 'created_at', 'object_id', 'object_type', 'created_at', 'updated_at'];
}
return $options;
}
}
+51 -16
View File
@@ -6,12 +6,38 @@ use classes\totp;
use Exception;
use interfaces\authentication_i;
use objects\plate_scanners_o;
use objects\subuser_grants_o;
use objects\tokens_o;
use objects\users_o;
use objects\subusers_o;
class authentication implements authentication_i
{
private function touchResolvedUserSession(users_o $user, string $token): void
{
if (trim($token) === '') {
return;
}
try {
(new system_session_activity_tracker())->touchUser($user, $token);
} catch (\Throwable) {
// Session tracking must never block authentication resolution.
}
}
private function touchResolvedSubuserSession(subusers_o $subuser, string $token, int|null $customerNumberContext = null): void
{
if (trim($token) === '') {
return;
}
try {
(new system_session_activity_tracker())->touchSubuser($subuser, $token, $customerNumberContext);
} catch (\Throwable) {
// Session tracking must never block authentication resolution.
}
}
/**
* @throws Exception
@@ -100,9 +126,13 @@ class authentication implements authentication_i
public function validate_token(string $token): bool
{
// First: try validating as a classic user auth token
$dbToken = (new tokens_o())->getToken($token);
if ($dbToken && $dbToken->id) {
return true;
try {
$dbToken = (new tokens_o())->getToken($token);
if ($dbToken && $dbToken->id && $dbToken->type->value() === 'AUTH_TOKEN') {
return true;
}
} catch (Exception) {
// Ignore and continue to subuser session validation
}
// Fallback: try validating as a subuser session token
$subuser = (new subusers_o())->getSubuserBySessionToken($token);
@@ -125,26 +155,26 @@ class authentication implements authentication_i
if (!isset($headers['Authorization'])) {
return false;
}
$token = $headers['Authorization'];
$rawToken = $headers['Authorization'];
// Strip the Bearer prefix
$token = str_replace('Bearer ', '', $token);
$rawToken = str_replace('Bearer ', '', $rawToken);
// Get the token from the database
$token = (new tokens_o())->getToken($token);
try {
$token = (new tokens_o())->getToken($rawToken);
} catch (Exception) {
return false;
}
// Check if the token exists
if (!$token->id) {
return false;
}
if ($token->type->value() === "AUTH_TOKEN_SUBUSER") {
// Get the customer number from the headers
if (!isset($headers['X-Customer-Number'])) {
return false;
}
$customer_number = (int)$headers['X-Customer-Number'];
// Get the user by the customer number
return (new users_o())->getUserByCustomerNumber($customer_number);
if ($token->type->value() !== 'AUTH_TOKEN') {
return false;
}
// Get the user from the database
return (new users_o())->getUserById($token->user_id->value());
$user = (new users_o())->getUserById($token->user_id->value());
$this->touchResolvedUserSession($user, $rawToken);
return $user;
}
public function get_plate_scanner(): plate_scanners_o|false
@@ -197,6 +227,11 @@ class authentication implements authentication_i
if ($subuser === null) {
return false;
}
$customerNumberContext = null;
if (isset($headers['X-Customer-Number'])) {
$customerNumberContext = (int)$headers['X-Customer-Number'];
}
$this->touchResolvedSubuserSession($subuser, $token, $customerNumberContext);
return $subuser;
}
@@ -232,4 +267,4 @@ class authentication implements authentication_i
}
return (int)$headers['X-Customer-Number'];
}
}
}
+452 -25
View File
@@ -2,28 +2,87 @@
namespace classes;
require_once WD . '/interfaces/bird_i.php';
require_once WD . '/modules/bird/bird_c.php';
require_once WD . '/modules/bird/classes/bird_api_client.php';
require_once WD . '/modules/bird/classes/bird_voice_calls_client.php';
require_once WD . '/modules/bird/classes/bird_voice_recordings_client.php';
require_once WD . '/modules/bird/classes/bird_voice_insights_client.php';
require_once WD . '/modules/bird/classes/bird_flash_calls_client.php';
use bird\bird_c;
use bird\classes\bird_flash_calls_client;
use bird\classes\bird_voice_calls_client;
use bird\classes\bird_voice_insights_client;
use bird\classes\bird_voice_recordings_client;
use Exception;
use interfaces\bird_i;
use objects\logs_o;
class bird
class bird implements bird_i
{
public const TEST_OUTBOUND_NUMBER_RAW = '+45 42 33 11 28';
public const TEST_OUTBOUND_NUMBER_E164 = '+4542331128';
private const ALLOWED_HANGUP_CAUSES = ['rejected', 'busy', 'completed'];
public const OUTGOING_NUMBER_E164 = '+4532330288';
public const OUTGOING_NUMBER_RAW = '+45 32 33 02 88';
public const OUTGOING_NUMBER = '+4532330288';
private const ALLOWED_HANGUP_CAUSES = ['rejected', 'busy'];
private const ACCEPTED_CALL_STATUSES = ['accepted', 'ongoing'];
private const TERMINAL_GATE_FAILURE_STATUSES = ['rejected', 'busy', 'failed', 'cancelled', 'no-answer', 'completed'];
private const FLASH_GATE_SUCCESS_STATUSES = ['accepted', 'ongoing', 'completed'];
private const FLASH_GATE_FAILURE_STATUSES = ['rejected', 'busy', 'failed', 'cancelled', 'no-answer'];
/**
* Configuration of the Bird module
* @var bird_c|object
* @var bird_c
*/
public $config;
public bird_c $config;
private ?bird_voice_calls_client $voice_calls_client = null;
private ?bird_voice_recordings_client $voice_recordings_client = null;
private ?bird_voice_insights_client $voice_insights_client = null;
private ?bird_flash_calls_client $flash_calls_client = null;
public function __construct()
{
$this->config = new bird_c();
}
private function voiceCallsClient(): bird_voice_calls_client
{
if ($this->voice_calls_client === null) {
$this->voice_calls_client = new bird_voice_calls_client($this);
}
return $this->voice_calls_client;
}
private function voiceRecordingsClient(): bird_voice_recordings_client
{
if ($this->voice_recordings_client === null) {
$this->voice_recordings_client = new bird_voice_recordings_client($this);
}
return $this->voice_recordings_client;
}
private function voiceInsightsClient(): bird_voice_insights_client
{
if ($this->voice_insights_client === null) {
$this->voice_insights_client = new bird_voice_insights_client($this);
}
return $this->voice_insights_client;
}
private function flashCallsClient(): bird_flash_calls_client
{
if ($this->flash_calls_client === null) {
$this->flash_calls_client = new bird_flash_calls_client($this);
}
return $this->flash_calls_client;
}
/**
* Ensure module is enabled
* @throws Exception
@@ -153,6 +212,19 @@ class bird
throw new Exception('cURL error: ' . $err);
}
curl_close($ch);
// Debug slack
$data = json_decode($body, true) ?? [];
$resp = $resp === false ? 'cURL error with no response' : $resp;
$slack_debug_message = "*Bird API Request Debug:*"
. "\nEndpoint: $url"
. "\nMethod: $method"
. "\nStatus: $code"
. "\nPayload Keys: " . implode(',', array_keys($data))
. "\nResponse: $resp";
// Send slack notification for every request for easier debugging of issues in production (can be removed later if too noisy)
$slack = new \classes\slack();
$slack->send_message($slack_debug_message);
return [
'status_code' => (int)$code,
'body' => $resp,
@@ -270,30 +342,50 @@ class bird
public function createVoiceCall(string $workspaceId, string $channelId, array $payload): array|object|null
{
$base = $this->voiceBase($workspaceId, $channelId);
$this->logBirdAction('BIRD_VOICE_CALL_CREATE', 'workspace=' . $workspaceId . ' channel=' . $channelId);
return $this->sendPostRequest($base, $payload);
return $this->voiceCallsClient()->createVoiceCall($workspaceId, $channelId, $payload);
}
public function listVoiceCalls(string $workspaceId, string $channelId, array $query = []): array|object|null
{
$base = $this->voiceBase($workspaceId, $channelId);
$this->logBirdAction('BIRD_VOICE_CALL_LIST', 'workspace=' . $workspaceId . ' channel=' . $channelId);
return $this->sendGetRequest($base, $query);
return $this->voiceCallsClient()->listVoiceCalls($workspaceId, $channelId, $query);
}
public function getVoiceCall(string $workspaceId, string $channelId, string $callId): array|object|null
{
$base = $this->voiceBase($workspaceId, $channelId);
$this->logBirdAction('BIRD_VOICE_CALL_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
return $this->sendGetRequest($base . '/' . rawurlencode($callId));
return $this->voiceCallsClient()->getVoiceCall($workspaceId, $channelId, $callId);
}
public function updateVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
{
$this->logBirdAction('BIRD_VOICE_CALL_UPDATE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
return $this->voiceCallsClient()->updateVoiceCall($workspaceId, $channelId, $callId, $payload);
}
public function answerVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
{
$this->logBirdAction('BIRD_VOICE_CALL_ANSWER', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
return $this->voiceCallsClient()->answerVoiceCall($workspaceId, $channelId, $callId, $payload);
}
public function ringVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
{
$this->logBirdAction('BIRD_VOICE_CALL_RINGING', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
return $this->voiceCallsClient()->ringVoiceCall($workspaceId, $channelId, $callId, $payload);
}
public function hangupVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
{
$base = $this->voiceBase($workspaceId, $channelId);
$this->logBirdAction('BIRD_VOICE_CALL_HANGUP', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
return $this->sendPostRequest($base . '/' . rawurlencode($callId) . '/hangup', $payload);
return $this->voiceCallsClient()->hangupVoiceCall($workspaceId, $channelId, $callId, $payload);
}
public function playbackVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
{
$this->logBirdAction('BIRD_VOICE_CALL_PLAYBACK', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
return $this->voiceCallsClient()->playbackVoiceCall($workspaceId, $channelId, $callId, $payload);
}
public function sayMessage(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
@@ -321,7 +413,7 @@ class bird
'timeout' => 1,
...$payload,
];
return $this->sendPostRequest($this->sayBase($workspaceId, $channelId, $callId), $tmp);
return $this->voiceCallsClient()->sayVoiceCall($workspaceId, $channelId, $callId, $tmp);
}
public function gatherMessage(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
@@ -374,7 +466,55 @@ class bird
'input' => 'dtmf',
...$payload,
];
return $this->sendPostRequest($this->gatherBase($workspaceId, $channelId, $callId), $tmp);
return $this->voiceCallsClient()->gatherVoiceCall($workspaceId, $channelId, $callId, $tmp);
}
public function bridgeVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
{
$this->logBirdAction('BIRD_VOICE_CALL_BRIDGE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
return $this->voiceCallsClient()->bridgeVoiceCall($workspaceId, $channelId, $callId, $payload);
}
public function recordVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
{
$this->logBirdAction('BIRD_VOICE_CALL_RECORD', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
return $this->voiceCallsClient()->recordVoiceCall($workspaceId, $channelId, $callId, $payload);
}
public function createVoiceCallRecordingSession(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
{
$this->logBirdAction('BIRD_VOICE_CALL_RECORDING_CREATE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
return $this->voiceRecordingsClient()->createVoiceCallRecordingSession($workspaceId, $channelId, $callId, $payload);
}
public function listVoiceCallRecordings(string $workspaceId, string $channelId, string $callId, array $query = []): array|object|null
{
$this->logBirdAction('BIRD_VOICE_CALL_RECORDING_LIST', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
return $this->voiceRecordingsClient()->listVoiceCallRecordings($workspaceId, $channelId, $callId, $query);
}
public function getVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId): array|object|null
{
$this->logBirdAction('BIRD_VOICE_CALL_RECORDING_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId . ' recording=' . $recordingId);
return $this->voiceRecordingsClient()->getVoiceCallRecording($workspaceId, $channelId, $callId, $recordingId);
}
public function updateVoiceCallRecording(string $workspaceId, string $channelId, string $callId, string $recordingId, array $payload): array|object|null
{
$this->logBirdAction('BIRD_VOICE_CALL_RECORDING_UPDATE', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId . ' recording=' . $recordingId);
return $this->voiceRecordingsClient()->updateVoiceCallRecording($workspaceId, $channelId, $callId, $recordingId, $payload);
}
public function getVoiceCallInsights(string $workspaceId, string $channelId, string $callId): array|object|null
{
$this->logBirdAction('BIRD_VOICE_CALL_INSIGHTS_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
return $this->voiceInsightsClient()->getVoiceCallInsights($workspaceId, $channelId, $callId);
}
public function getVoiceCallsLog(string $workspaceId, array $query = []): array|object|null
{
$this->logBirdAction('BIRD_VOICE_CALL_LOG_LIST', 'workspace=' . $workspaceId);
return $this->voiceInsightsClient()->getVoiceCallsLog($workspaceId, $query);
}
public function listNumbers(string $workspaceId, array $query = []): array|object|null
@@ -395,6 +535,36 @@ class bird
return $this->sendDeleteRequest('/workspaces/' . rawurlencode($workspaceId) . '/numbers/' . rawurlencode($numberId));
}
public function createFlashCall(string $workspaceId, string $channelId, array $payload): array|object|null
{
$this->logBirdAction('BIRD_FLASH_CALL_CREATE', 'workspace=' . $workspaceId . ' channel=' . $channelId);
return $this->flashCallsClient()->createFlashCall($workspaceId, $channelId, $payload);
}
public function listFlashCalls(string $workspaceId, string $channelId, array $query = []): array|object|null
{
$this->logBirdAction('BIRD_FLASH_CALL_LIST', 'workspace=' . $workspaceId . ' channel=' . $channelId);
return $this->flashCallsClient()->listFlashCalls($workspaceId, $channelId, $query);
}
public function getFlashCall(string $workspaceId, string $channelId, string $callId): array|object|null
{
$this->logBirdAction('BIRD_FLASH_CALL_GET', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
return $this->flashCallsClient()->getFlashCall($workspaceId, $channelId, $callId);
}
public function endFlashCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
{
$this->logBirdAction('BIRD_FLASH_CALL_END', 'workspace=' . $workspaceId . ' channel=' . $channelId . ' call=' . $callId);
return $this->flashCallsClient()->endFlashCall($workspaceId, $channelId, $callId, $payload);
}
public function hangupFlashCall(string $workspaceId, string $channelId, array $payload = []): array|object|null
{
$this->logBirdAction('BIRD_FLASH_CALL_HANGUP', 'workspace=' . $workspaceId . ' channel=' . $channelId);
return $this->flashCallsClient()->hangupFlashCall($workspaceId, $channelId, $payload);
}
public function createOutboundTestCallAndHangupWhenAccepted(string $workspaceId, string $channelId, array $options = []): array
{
return $this->executeCallAndHangupWhenAccepted($workspaceId, $channelId, $options, 'BIRD_TEST_OUTBOUND_CALL');
@@ -418,11 +588,7 @@ class bird
$targetNumber = self::TEST_OUTBOUND_NUMBER_E164;
}
$payload['to'] = $targetNumber;
// Ensure Bird terminates an unanswered call after we've stopped polling for it.
if (!isset($payload['timeout'])) {
$payload['timeout'] = $maxPollSeconds;
}
$payload = $this->normalizeCreateVoiceCallPayload($payload, $maxPollSeconds);
$this->logBirdAction(
$logPrefix . '_START',
@@ -443,18 +609,18 @@ class bird
}
$lastCall = null;
$acceptedStates = ['accepted', 'ongoing'];
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$current = $this->getVoiceCall($workspaceId, $channelId, $callId);
$lastCall = $current;
$status = $this->extractStatus($current);
$normalizedStatus = $status === null ? null : strtolower($status);
$this->logBirdAction(
$logPrefix . '_POLL',
'call=' . $callId . ' attempt=' . $attempt . '/' . $maxAttempts . ' status=' . ($status ?? 'unknown')
);
if ($status !== null && in_array(strtolower($status), $acceptedStates, true)) {
if ($normalizedStatus !== null && in_array($normalizedStatus, self::ACCEPTED_CALL_STATUSES, true)) {
$hangupPayload = [];
if (isset($options['hangupCause']) && is_string($options['hangupCause']) && $options['hangupCause'] !== '') {
$normalizedCause = strtolower(trim($options['hangupCause']));
@@ -476,8 +642,26 @@ class bird
];
}
if ($normalizedStatus !== null && in_array($normalizedStatus, self::TERMINAL_GATE_FAILURE_STATUSES, true)) {
$this->logBirdAction(
$logPrefix . '_TERMINAL',
'call=' . $callId . ' status=' . $status
);
return [
'to' => $targetNumber,
'to_e164' => $targetNumber,
'call_id' => $callId,
'final_status' => $status,
'hangup_sent' => false,
'terminal_failure' => true,
'created_call' => $createResponse,
'last_call_snapshot' => $lastCall,
'message' => 'Call reached terminal status before acceptance',
];
}
if ($attempt < $maxAttempts) {
sleep($pollIntervalSeconds);
$this->waitForCallPollInterval($pollIntervalSeconds);
}
}
@@ -499,11 +683,24 @@ class bird
];
}
/**
* Hook point for tests to avoid real waiting during call polling.
*/
protected function waitForCallPollInterval(int $pollIntervalSeconds): void
{
sleep($pollIntervalSeconds);
}
private function voiceBase(string $workspaceId, string $channelId): string
{
return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/calls';
}
private function flashBase(string $workspaceId, string $channelId): string
{
return '/workspaces/' . rawurlencode($workspaceId) . '/channels/' . rawurlencode($channelId) . '/flashcalls';
}
private function sayBase(string $workspaceId, string $channelId, string $callId): string
{
// https://api.bird.com/workspaces/{workspaceId}/channels/{channelId}/calls/{callId}/say
@@ -538,6 +735,23 @@ class bird
return null;
}
protected function extractFrom(array|object|string|null $response): ?string
{
if (is_object($response)) {
if (isset($response->from) && is_string($response->from)) {
return $this->normalizePhoneIdentifier($response->from);
}
return null;
}
if (is_array($response)) {
if (array_key_exists('from', $response) && is_string($response['from'])) {
return $this->normalizePhoneIdentifier($response['from']);
}
return null;
}
return null;
}
private function buildHttpErrorMessage(int $status, string|false|null $response): string
{
$base = 'Bird API request failed with status ' . $status;
@@ -626,12 +840,24 @@ class bird
public function callGateAndHangupWhenAccepted(int $countryCode, int $phone, int $timeout)
{
$ws = $this->config->workplaceId->getVariableValue();
$ch = $this->config->channelId->getVariableValue();
$ws = $this->getConfiguredWorkspaceId();
$ch = $this->getConfiguredChannelId();
if ($ws === '') {
throw new Exception('Bird workspaceId is not configured for gate calls');
}
if ($ch === '') {
throw new Exception('Bird channelId is not configured for gate calls');
}
$normalizedRingTimeout = $this->normalizeRingTimeoutValue($timeout);
if ($normalizedRingTimeout === null) {
$normalizedRingTimeout = 30;
}
$options = [
'from' => self::OUTGOING_NUMBER_E164,
'to' => '+' . $countryCode . $phone,
'maxPollSeconds' => (int)$timeout,
'maxPollSeconds' => max(5, (int)$timeout),
'ringTimeout' => $normalizedRingTimeout,
];
$result = $this->executeCallAndHangupWhenAccepted($ws, $ch, $options, 'BIRD_GATE_CALL');
@@ -640,10 +866,211 @@ class bird
$msg = $result['message'] ?? 'Failed to call gate and hangup when accepted';
if ($result['timed_out_waiting_for_accepted'] ?? false) {
$msg = 'Timed out waiting for gate to accept call';
} elseif ($result['terminal_failure'] ?? false) {
$status = isset($result['final_status']) ? (string)$result['final_status'] : 'unknown';
$msg = 'Gate call reached terminal status: ' . $status;
}
throw new Exception("Failed to call gate (+{$countryCode}{$phone}): " . $msg);
}
}
public function callGateViaFlashCall(int $countryCode, int $phone, int $ringTimeout): void
{
$ws = $this->getConfiguredWorkspaceId();
$ch = $this->getConfiguredChannelId();
if ($ws === '') {
throw new Exception('Bird workspaceId is not configured for gate flash calls');
}
if ($ch === '') {
throw new Exception('Bird channelId is not configured for gate flash calls');
}
$normalizedRingTimeout = $this->normalizeRingTimeoutValue($ringTimeout);
if ($normalizedRingTimeout === null) {
$normalizedRingTimeout = 30;
}
$createResponse = $this->createFlashCall($ws, $ch, [
'from' => self::OUTGOING_NUMBER_E164,
'to' => '+' . $countryCode . $phone,
'ringTimeout' => $normalizedRingTimeout,
]);
$expectedFrom = $this->normalizePhoneIdentifier(self::OUTGOING_NUMBER_E164);
$flashFrom = $this->extractFrom($createResponse);
if ($flashFrom === null) {
throw new Exception('Gate flash call did not confirm caller id');
}
if ($expectedFrom !== null && $flashFrom !== $expectedFrom) {
throw new Exception('Gate flash call used unexpected caller id: ' . $flashFrom);
}
$initialStatus = $this->extractStatus($createResponse);
$normalizedInitialStatus = $initialStatus === null ? null : strtolower($initialStatus);
if ($normalizedInitialStatus !== null && in_array($normalizedInitialStatus, self::FLASH_GATE_FAILURE_STATUSES, true)) {
throw new Exception('Gate flash call failed with status: ' . $initialStatus);
}
if ($normalizedInitialStatus !== null && in_array($normalizedInitialStatus, self::FLASH_GATE_SUCCESS_STATUSES, true)) {
return;
}
$callId = $this->extractId($createResponse);
if ($callId === null) {
throw new Exception('Failed to create gate flash call: no call id returned');
}
$pollIntervalSeconds = 5;
$maxPollSeconds = max(5, $normalizedRingTimeout + 5);
$maxAttempts = (int)max(1, floor($maxPollSeconds / $pollIntervalSeconds));
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$current = $this->getFlashCall($ws, $ch, $callId);
$currentFrom = $this->extractFrom($current);
if ($expectedFrom !== null && $currentFrom !== null && $currentFrom !== $expectedFrom) {
throw new Exception('Gate flash call switched to unexpected caller id: ' . $currentFrom);
}
$status = $this->extractStatus($current);
$normalizedStatus = $status === null ? null : strtolower($status);
if ($normalizedStatus !== null && in_array($normalizedStatus, self::FLASH_GATE_SUCCESS_STATUSES, true)) {
return;
}
if ($normalizedStatus !== null && in_array($normalizedStatus, self::FLASH_GATE_FAILURE_STATUSES, true)) {
throw new Exception('Gate flash call failed with status: ' . $status);
}
if ($attempt < $maxAttempts) {
$this->waitForCallPollInterval($pollIntervalSeconds);
}
}
throw new Exception('Timed out waiting for gate flash call completion');
}
public function callGatePreferringFlashCall(int $countryCode, int $phone, int $timeout): void
{
try {
$this->callGateViaFlashCall($countryCode, $phone, $timeout);
return;
} catch (\Throwable $flashError) {
$this->logBirdAction(
'BIRD_GATE_FLASH_FALLBACK',
'Flash gate call failed, falling back to regular call. reason=' . $flashError->getMessage(),
0
);
}
$this->callGateAndHangupWhenAccepted($countryCode, $phone, $timeout);
}
protected function getConfiguredWorkspaceId(): string
{
if (!is_object($this->config)) {
return '';
}
$workspaceConfig = null;
if (property_exists($this->config, 'workspaceId')) {
$workspaceConfig = $this->config->workspaceId;
} elseif (property_exists($this->config, 'workplaceId')) {
// Backward compatibility with existing Bird module variable naming.
$workspaceConfig = $this->config->workplaceId;
}
if (!is_object($workspaceConfig) || !method_exists($workspaceConfig, 'getVariableValue')) {
return '';
}
return $this->normalizeOptionalString($workspaceConfig->getVariableValue());
}
protected function getConfiguredChannelId(): string
{
if (!is_object($this->config)) {
return '';
}
$channelConfig = null;
if (property_exists($this->config, 'channelId')) {
$channelConfig = $this->config->channelId;
}
if (!is_object($channelConfig) || !method_exists($channelConfig, 'getVariableValue')) {
return '';
}
return $this->normalizeOptionalString($channelConfig->getVariableValue());
}
protected function normalizeOptionalString(mixed $value): string
{
if (!is_scalar($value)) {
return '';
}
$normalized = trim((string)$value);
if ($normalized === '') {
return '';
}
$lower = strtolower($normalized);
if ($lower === 'undefined' || $lower === 'null') {
return '';
}
return $normalized;
}
/**
* Align create-call payload with Bird voice call schema.
* - Map legacy `timeout` to documented `ringTimeout`.
* - Clamp `ringTimeout` to documented [3,120] range.
*/
protected function normalizeCreateVoiceCallPayload(array $payload, int $fallbackRingTimeout): array
{
if (array_key_exists('timeout', $payload) && !array_key_exists('ringTimeout', $payload)) {
$payload['ringTimeout'] = $payload['timeout'];
}
unset($payload['timeout']);
$normalizedRingTimeout = null;
if (array_key_exists('ringTimeout', $payload)) {
$normalizedRingTimeout = $this->normalizeRingTimeoutValue($payload['ringTimeout']);
}
if ($normalizedRingTimeout === null) {
$normalizedRingTimeout = $this->normalizeRingTimeoutValue($fallbackRingTimeout);
}
if ($normalizedRingTimeout !== null) {
$payload['ringTimeout'] = $normalizedRingTimeout;
} else {
unset($payload['ringTimeout']);
}
return $payload;
}
protected function normalizeRingTimeoutValue(mixed $value): ?int
{
if (!is_numeric($value)) {
return null;
}
$timeout = (int)$value;
if ($timeout < 3) {
$timeout = 3;
}
if ($timeout > 120) {
$timeout = 120;
}
return $timeout;
}
protected function normalizePhoneIdentifier(mixed $value): ?string
{
if (!is_string($value)) {
return null;
}
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
$digits = preg_replace('/\D+/', '', $trimmed);
if (!is_string($digits) || $digits === '') {
return null;
}
return '+' . $digits;
}
}
@@ -0,0 +1,80 @@
<?php
namespace classes;
require_once WD . '/interfaces/shelly_transport_i.php';
require_once WD . '/classes/shelly.php';
use interfaces\shelly_transport_i;
class cloud_shelly_transport implements shelly_transport_i
{
public function __construct(
private readonly ?shelly $client = null,
private readonly bool $logRelaySignals = true,
private readonly ?edge_gateway_manager $manager = null
)
{
}
private function client(): shelly
{
return $this->client ?? new shelly();
}
public function requireModuleEnabled(): void
{
$this->client()->requireModuleEnabled();
}
public function requireValidSecretKey(): void
{
$this->client()->requireValidSecretKey();
}
public function sendPostRequest(string $endpoint, array $data, ?int $department_id = null): array|object|null
{
try {
$response = $this->client()->sendPostRequest($endpoint, $data);
$this->logRelaySignal($endpoint, $data, $department_id, $response, null);
return $response;
} catch (\Throwable $exception) {
$this->logRelaySignal($endpoint, $data, $department_id, null, $exception);
throw $exception;
}
}
private function logRelaySignal(
string $endpoint,
array $data,
?int $department_id,
array|object|null $response,
?\Throwable $exception
): void {
if (!$this->logRelaySignals || $department_id === null || $department_id <= 0 || !$this->isRelayEndpoint($endpoint)) {
return;
}
try {
$this->manager()->appendRelayTransportLog(
$department_id,
$endpoint,
$data,
$response,
'cloud',
$exception?->getMessage()
);
} catch (\Throwable) {
}
}
private function isRelayEndpoint(string $endpoint): bool
{
return in_array($endpoint, ['/v2/devices/api/get', '/v2/devices/api/set/switch'], true);
}
private function manager(): edge_gateway_manager
{
return $this->manager ?? new edge_gateway_manager();
}
}
+39
View File
@@ -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 . ")"
);
}
}
+188
View File
@@ -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 !== ''
));
}
}
@@ -0,0 +1,497 @@
<?php
namespace classes;
use objects\logs_o;
use objects\users_o;
class customer_mass_import_service
{
/**
* Import or create a company customer from one normalized spreadsheet row.
*
* @throws \RuntimeException
*/
public function import(array $payload): array
{
$normalized = $this->normalizePayload($payload);
$this->assertValidNormalizedPayload($normalized);
$customerNumber = (int)$normalized['customer_number'];
$cvr = (string)$normalized['cvr'];
$warnings = [];
$economicCustomers = $this->searchEconomicCustomersByCvr($cvr);
$localUserExistsBefore = $this->localCustomerNumberExists($customerNumber);
$localUser = $localUserExistsBefore ? $this->loadLocalCustomerByNumber($customerNumber) : null;
$matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economicCustomers, $customerNumber);
if ($matchingEconomicCustomer !== null) {
$customer = $this->resolveLocalCustomer($customerNumber, $localUserExistsBefore, $localUser);
$this->syncLocalCustomer($customer, $normalized, $warnings);
[$action, $message] = $this->resolveExistingCustomerOutcome($localUserExistsBefore, $this->hasLocalAccount($customer));
return $this->buildSuccessResult(
$normalized,
$customer,
$action,
$message,
$localUserExistsBefore,
true,
false,
$warnings
);
}
if (count($economicCustomers) > 0) {
$existingEconomicCustomerNumber = $this->extractEconomicCustomerNumber($economicCustomers[0]);
$this->logIssue('CUSTOMER_MASS_IMPORT_CONFLICT', [
'phase' => 'search',
'cvr' => $cvr,
'requestedCustomerNumber' => $customerNumber,
'existingCustomerNumber' => $existingEconomicCustomerNumber,
]);
throw new \RuntimeException(
'CVR already registered under customer number '
. $existingEconomicCustomerNumber
. '. The submitted phone number must match the customer id. Manual cleanup or reassignment is required before retrying.',
409
);
}
$normalized['name'] = $this->resolveCreateName($normalized);
$normalized['email'] = $this->resolveCreateEmail($normalized, $warnings);
$createResponse = $this->createEconomicCustomer($normalized);
$createdCustomerNumber = $this->extractEconomicCustomerNumber($createResponse);
if ($createdCustomerNumber !== $customerNumber) {
$this->logIssue('CUSTOMER_MASS_IMPORT_CONFLICT', [
'phase' => 'create',
'cvr' => $cvr,
'requestedCustomerNumber' => $customerNumber,
'createdCustomerNumber' => $createdCustomerNumber,
'response' => $createResponse,
]);
throw new \RuntimeException(
'E-conomic created the customer under customer number '
. $createdCustomerNumber
. ' instead of the submitted phone number '
. $customerNumber
. '. Manual cleanup or reassignment is required before retrying.',
409
);
}
$customer = $this->resolveLocalCustomer($customerNumber, $localUserExistsBefore, $localUser);
$this->syncLocalCustomer($customer, $normalized, $warnings);
[$action, $message] = $this->resolveCreatedCustomerOutcome($localUserExistsBefore, $this->hasLocalAccount($customer));
return $this->buildSuccessResult(
$normalized,
$customer,
$action,
$message,
$localUserExistsBefore,
false,
true,
$warnings
);
}
protected function normalizePayload(array $payload): array
{
return [
'customer_number' => $this->normalizePositiveInt($payload['customer_number'] ?? $payload['phone'] ?? null),
'phone' => $this->normalizePositiveInt($payload['phone'] ?? $payload['customer_number'] ?? null),
'cvr' => $this->normalizeDigitString($payload['cvr'] ?? null),
'name' => $this->normalizeText($payload['name'] ?? $payload['company_name'] ?? null),
'email' => $this->normalizeEmail($payload['email'] ?? null),
'ean' => $this->normalizeDigitString($payload['ean'] ?? null),
];
}
protected function assertValidNormalizedPayload(array $normalized): void
{
$customerNumber = $normalized['customer_number'];
$cvr = $normalized['cvr'];
if ($customerNumber === null) {
throw new \RuntimeException('Phone number is required.', 400);
}
$customerNumberLength = strlen((string)$customerNumber);
if ($customerNumberLength < 8 || $customerNumberLength > 10) {
throw new \RuntimeException('Phone number must be between 8 and 10 digits.', 400);
}
if ($cvr === null) {
throw new \RuntimeException('CVR is required.', 400);
}
$cvrLength = strlen($cvr);
if ($cvrLength < 8 || $cvrLength > 20) {
throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400);
}
}
protected function normalizePositiveInt(mixed $value): ?int
{
$digits = $this->normalizeDigitString($value);
if ($digits === null) {
return null;
}
$normalized = (int)$digits;
return $normalized > 0 ? $normalized : null;
}
protected function normalizeDigitString(mixed $value): ?string
{
if ($value === null) {
return null;
}
$digits = preg_replace('/\D+/', '', (string)$value);
if (!is_string($digits)) {
return null;
}
$digits = trim($digits);
return $digits !== '' ? $digits : null;
}
protected function normalizeText(mixed $value): ?string
{
if ($value === null) {
return null;
}
$normalized = trim((string)$value);
return $normalized !== '' ? $normalized : null;
}
protected function normalizeEmail(mixed $value): ?string
{
$email = $this->normalizeText($value);
if ($email === null) {
return null;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \RuntimeException('Invalid email address.', 400);
}
return $email;
}
protected function resolveCreateName(array $normalized): string
{
if ($normalized['name'] !== null) {
return $normalized['name'];
}
$name = trim($this->fetchCompanyNameByCvr((string)$normalized['cvr']));
if ($name === '') {
throw new \RuntimeException('Customer name is required to create a new company.', 400);
}
return $name;
}
protected function resolveCreateEmail(array $normalized, array &$warnings): string
{
if ($normalized['email'] !== null) {
return $normalized['email'];
}
$warnings[] = 'No email was provided, defaulted to jb@truckwash.dk for the new e-conomic customer.';
return 'jb@truckwash.dk';
}
protected function searchEconomicCustomersByCvr(string $cvr): array
{
$response = (new economic())->customers->customers->search([
'corporateIdentificationNumber' => $cvr,
], [
'skipPages' => 0,
'pageSize' => 1000,
])->collection ?? [];
return is_array($response) ? $response : [];
}
protected function createEconomicCustomer(array $normalized): object
{
$payload = [
'customerNumber' => (int)$normalized['customer_number'],
'corporateIdentificationNumber' => (string)$normalized['cvr'],
'customerGroup' => [
'customerGroupNumber' => 1,
],
'paymentTerms' => [
'paymentTermsNumber' => 12,
],
'name' => (string)$normalized['name'],
'email' => (string)$normalized['email'],
'phone' => (int)$normalized['phone'],
'telephoneAndFaxNumber' => (string)$normalized['phone'],
'mobilePhone' => (string)$normalized['phone'],
'currency' => 'DKK',
'vatZone' => [
'vatZoneNumber' => 1,
],
];
if ($normalized['ean'] !== null) {
$payload['ean'] = (string)$normalized['ean'];
}
return (new economic())->customers->customers->create($payload);
}
protected function localCustomerNumberExists(int $customerNumber): bool
{
$rows = (new users_o())->getFieldsWhere([
'customer_number' => (string)$customerNumber,
], ['id']);
return count($rows) > 0;
}
protected function loadLocalCustomerByNumber(int $customerNumber): ?object
{
$customer = (new users_o())->getUserByCustomerNumber($customerNumber);
return $this->localUserExists($customer) ? $customer : null;
}
protected function resolveLocalCustomer(int $customerNumber, bool $localUserExistsBefore, ?object $localUser): object
{
if ($localUserExistsBefore && $this->localUserExists($localUser)) {
return $localUser;
}
return $this->bootstrapLocalCustomerOrFail($customerNumber);
}
protected function bootstrapLocalCustomerOrFail(int $customerNumber): object
{
$customer = (new users_o())->getUserByCustomerNumber($customerNumber);
if ($this->localUserExists($customer)) {
return $customer;
}
$this->logIssue('CUSTOMER_MASS_IMPORT_LOCAL_BOOTSTRAP_FAILED', [
'customerNumber' => $customerNumber,
]);
throw new \RuntimeException('Customer was created in e-conomic but could not be imported locally.', 500);
}
protected function fetchCompanyNameByCvr(string $cvr): string
{
return (string)((new virkdata())->getCompanyInformation($cvr, '', [])->name ?? '');
}
protected function logIssue(string $action, array $context): void
{
$message = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($message === false) {
$message = 'Unable to encode customer mass import context';
}
(new logs_o())->add('customers', 'global', 0, 0, $action, $message);
}
protected function findEconomicCustomerByNumber(array $customers, int $customerNumber): ?object
{
foreach ($customers as $customer) {
if (!is_object($customer)) {
continue;
}
if ($this->extractEconomicCustomerNumber($customer) === $customerNumber) {
return $customer;
}
}
return null;
}
protected function extractEconomicCustomerNumber(object $customer): int
{
if (!isset($customer->customerNumber) || !is_numeric($customer->customerNumber)) {
return 0;
}
return (int)$customer->customerNumber;
}
protected function resolveExistingCustomerOutcome(bool $localUserExistsBefore, bool $hasAccount): array
{
if ($localUserExistsBefore && $hasAccount) {
return [
'account_already_exists',
'Customer already exists locally and already has a login account.',
];
}
if ($localUserExistsBefore) {
return [
'customer_already_exists',
'Customer already exists locally but does not have a login password yet.',
];
}
return [
'imported_existing_customer',
'Imported an existing e-conomic customer into the local customer database.',
];
}
protected function resolveCreatedCustomerOutcome(bool $localUserExistsBefore, bool $hasAccount): array
{
if ($localUserExistsBefore && $hasAccount) {
return [
'economic_customer_created_for_existing_account',
'Created the e-conomic customer for an existing local login account.',
];
}
if ($localUserExistsBefore) {
return [
'economic_customer_created_for_existing_customer',
'Created the e-conomic customer for an existing local customer record.',
];
}
return [
'created_customer',
'Created the customer in e-conomic and imported it locally.',
];
}
protected function buildSuccessResult(
array $normalized,
object $customer,
string $action,
string $message,
bool $existingLocalCustomer,
bool $existingEconomicCustomer,
bool $createdEconomicCustomer,
array $warnings
): array {
$customerName = $this->extractLocalUserDisplayName($customer) ?? $normalized['name'];
return [
'customer_number' => (int)$normalized['customer_number'],
'cvr' => (string)$normalized['cvr'],
'name' => $customerName,
'email' => $normalized['email'],
'ean' => $normalized['ean'],
'action' => $action,
'message' => $message,
'user_id' => $this->extractLocalUserId($customer),
'has_account' => $this->hasLocalAccount($customer),
'existing_local_customer' => $existingLocalCustomer,
'existing_economic_customer' => $existingEconomicCustomer,
'created_economic_customer' => $createdEconomicCustomer,
'warnings' => array_values(array_filter($warnings, static fn(mixed $warning): bool => is_string($warning) && trim($warning) !== '')),
];
}
protected function syncLocalCustomer(object $customer, array $normalized, array &$warnings): void
{
if (!$customer instanceof users_o || !$customer->exists()) {
return;
}
$name = $normalized['name'] ?? null;
$email = $normalized['email'] ?? null;
$phone = $normalized['phone'] ?? null;
$displayName = trim((string)($customer->display_name->value() ?? ''));
if ($name !== null && ($displayName === '' || strtolower($displayName) === 'unnamed')) {
$customer->display_name->set($name);
}
if ($email !== null && trim((string)($customer->email->value() ?? '')) === '') {
try {
$customer->setEmail($email);
} catch (\Throwable $throwable) {
$warnings[] = 'Unable to update local email: ' . $throwable->getMessage();
}
}
if ($phone !== null && empty($customer->phone->value())) {
try {
$customer->setPhoneNumber((int)$phone);
} catch (\Throwable $throwable) {
$warnings[] = 'Unable to update local phone number: ' . $throwable->getMessage();
}
}
}
protected function localUserExists(?object $user): bool
{
if (!is_object($user)) {
return false;
}
if (method_exists($user, 'exists')) {
try {
return (bool)$user->exists();
} catch (\Throwable) {
return false;
}
}
return isset($user->id) && is_numeric($user->id) && (int)$user->id > 0;
}
protected function hasLocalAccount(?object $user): bool
{
if (!$this->localUserExists($user)) {
return false;
}
if (method_exists($user, 'hasPassword')) {
try {
return (bool)$user->hasPassword();
} catch (\Throwable) {
return false;
}
}
return (bool)($user->has_password ?? false);
}
protected function extractLocalUserId(?object $user): ?int
{
if (!is_object($user) || !isset($user->id) || !is_numeric($user->id)) {
return null;
}
$userId = (int)$user->id;
return $userId > 0 ? $userId : null;
}
protected function extractLocalUserDisplayName(?object $user): ?string
{
if (!is_object($user)) {
return null;
}
if ($user instanceof users_o) {
$name = trim((string)($user->display_name->value() ?? ''));
return $name !== '' ? $name : null;
}
$name = trim((string)($user->display_name ?? $user->name ?? ''));
return $name !== '' ? $name : null;
}
}
@@ -0,0 +1,94 @@
<?php
namespace classes;
class customer_name_cache_payload_builder
{
/**
* @return array{name:string}|null
*/
public static function build(mixed $cached_name, ?string $fallback_name): ?array
{
$cached_name = self::normalizePayload($cached_name);
$name = self::extractName($cached_name);
if ($name !== null) {
return ['name' => $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;
}
}
+43 -7
View File
@@ -13,6 +13,7 @@ class db
private string $user;
private string $password;
private string $database;
private int $port = 3306;
private string $ssl_mode = 'DISABLED'; // mysqldump SSL mode (e.g., DISABLED, PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY)
public function __construct(array $config)
@@ -21,6 +22,9 @@ class db
$this->user = $config['user'];
$this->password = $config['password'];
$this->database = $config['database'];
if (isset($config['port']) && is_numeric($config['port'])) {
$this->port = (int)$config['port'];
}
if (isset($config['ssl_mode']) && is_string($config['ssl_mode']) && $config['ssl_mode'] !== '') {
$this->ssl_mode = $config['ssl_mode'];
}
@@ -28,22 +32,46 @@ class db
public static function getPDO(): \PDO
{
global $config;
$dsn = "mysql:host={$config['db']['host']};dbname={$config['db']['database']};charset=utf8mb4";
return new \PDO($dsn, $config['db']['user'], $config['db']['password'], [
global $CONFIG_DB;
$port = $CONFIG_DB['port'] ?? 3306;
$dsn = "mysql:host={$CONFIG_DB['host']};port={$port};dbname={$CONFIG_DB['database']};charset=utf8mb4";
return new \PDO($dsn, $CONFIG_DB['user'], $CONFIG_DB['password'], [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
\PDO::ATTR_EMULATE_PREPARES => false,
]);
}
public function testConnection(): bool
{
try {
$conn = new mysqli($this->host, $this->user, $this->password, $this->database, $this->port);
if ($conn->connect_error) {
return false;
}
$this->conn = $conn;
return true;
} catch (Exception) {
return false;
}
}
public function connect(): void
{
global $response;
try {
$this->conn = new mysqli($this->host, $this->user, $this->password, $this->database);
// Enable error reporting for mysqli to catch connection issues via exceptions
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$this->conn = new mysqli($this->host, $this->user, $this->password, $this->database, $this->port);
if ($this->conn->connect_error) {
throw new Exception($this->conn->connect_error);
}
} catch (Exception $e) {
$response->internal_server_error($e->getMessage());
if ($response) {
$response->internal_server_error("Database connection failed: " . $e->getMessage());
} else {
throw $e;
}
}
}
@@ -54,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)
@@ -170,9 +205,10 @@ class db
$user = escapeshellarg($this->user);
$pass = escapeshellarg($this->password);
$db = escapeshellarg($this->database);
$port = (int)$this->port;
$outfile = escapeshellarg($path);
$sslPart = $sslFlag !== '' ? ($sslFlag . ' ') : '';
$command = "mysqldump {$sslPart}-h $host -u $user --password=$pass $db > $outfile 2>&1";
$command = "mysqldump {$sslPart}-h $host -P $port -u $user --password=$pass $db > $outfile 2>&1";
exec($command, $output, $return);
// Check if the command was successful
return $return === 0;
@@ -0,0 +1,152 @@
<?php
namespace classes;
/**
* Ensures additive schema for department daily report customer complaints.
*/
class department_daily_report_complaints_schema_bootstrap
{
private static bool $initialized = false;
private const TABLE = 'department_daily_report_complaints';
private const WASH_DATE_INDEX = 'idx_department_daily_report_complaints_department_wash_date';
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
self::ensureSupportingTables();
$db->query(
"CREATE TABLE IF NOT EXISTS department_daily_report_complaints (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
department_id INT NOT NULL,
customer_number INT NULL,
wash_date DATE NULL,
category VARCHAR(64) NULL,
description TEXT NOT NULL,
created_by INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_department_daily_report_complaints_department_created (department_id, created_at),
INDEX " . self::WASH_DATE_INDEX . " (department_id, wash_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
if (!self::columnExists(self::TABLE, 'wash_date')) {
$db->query(
"ALTER TABLE " . self::TABLE . "
ADD COLUMN wash_date DATE NULL
AFTER customer_number"
);
}
if (!self::columnExists(self::TABLE, 'category')) {
$db->query(
"ALTER TABLE " . self::TABLE . "
ADD COLUMN category VARCHAR(64) NULL
AFTER wash_date"
);
}
if (!self::indexExists(self::TABLE, self::WASH_DATE_INDEX)) {
$db->query(
"ALTER TABLE " . self::TABLE . "
ADD INDEX " . self::WASH_DATE_INDEX . " (department_id, wash_date)"
);
}
self::$initialized = true;
}
private static function ensureSupportingTables(): void
{
global $db;
$db->query(
"CREATE TABLE IF NOT EXISTS departments (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
economic_department_id INT NOT NULL DEFAULT 0,
slack_webhook TEXT NULL,
dimension INT NOT NULL DEFAULT 0,
branding INT NOT NULL DEFAULT 0,
visible TINYINT(1) NOT NULL DEFAULT 1,
archived TINYINT(1) NOT NULL DEFAULT 0,
longitude DECIMAL(10,7) NOT NULL DEFAULT 0,
latitude DECIMAL(10,7) NOT NULL DEFAULT 0,
order_priority INT NOT NULL DEFAULT 0,
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_departments_archived (archived)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS department_variables (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
department_id INT NOT NULL,
variable VARCHAR(191) NOT NULL,
value TEXT NULL,
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_department_variables_department_id (department_id),
KEY idx_department_variables_variable (variable)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
customer_number INT NOT NULL,
display_name VARCHAR(255) NULL,
email VARCHAR(255) NULL,
phone_country_code INT NULL,
phone BIGINT NULL,
password VARCHAR(255) NULL,
group_id INT NOT NULL DEFAULT 0,
xlvask_customer_id VARCHAR(255) NULL,
sms_notifications_enabled TINYINT(1) NOT NULL DEFAULT 0,
email_notifications_enabled TINYINT(1) NOT NULL DEFAULT 0,
wash_certificate_email VARCHAR(255) NULL,
two_factor_enabled TINYINT(1) NOT NULL DEFAULT 0,
two_factor_secret VARCHAR(255) NULL,
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
KEY idx_users_customer_number (customer_number),
KEY idx_users_group_id (group_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
private static function indexExists(string $table, string $index): bool
{
global $db;
$table_sql = $db->escape_string($table);
$index_sql = $db->escape_string($index);
$result = $db->query(
"SHOW INDEX FROM `$table_sql` WHERE Key_name = '$index_sql'"
);
return $result !== false && $result->num_rows > 0;
}
private static function columnExists(string $table, string $column): bool
{
global $db;
$table_sql = $db->escape_string($table);
$column_sql = $db->escape_string($column);
$result = $db->query(
"SHOW COLUMNS FROM `$table_sql` LIKE '$column_sql'"
);
return $result !== false && $result->num_rows > 0;
}
}
@@ -9,15 +9,19 @@ class department_gate_config
public string $type;
public ?string $phone_number = null;
public ?int $call_duration_threshold = null;
public ?string $relay_id = null;
public ?int $pulse_seconds = null;
/**
* @param array $config
*/
public function __construct(array $config = [])
{
$this->type = (string)($config['type'] ?? '');
$this->type = strtoupper(trim((string)($config['type'] ?? '')));
$this->phone_number = isset($config['phone_number']) ? (string)$config['phone_number'] : null;
$this->call_duration_threshold = isset($config['call_duration_threshold']) ? (int)$config['call_duration_threshold'] : null;
$this->relay_id = isset($config['relay_id']) ? trim((string)$config['relay_id']) : null;
$this->pulse_seconds = isset($config['pulse_seconds']) ? (int)$config['pulse_seconds'] : null;
}
/**
@@ -37,6 +41,14 @@ class department_gate_config
$array['call_duration_threshold'] = $this->call_duration_threshold;
}
if ($this->relay_id !== null) {
$array['relay_id'] = $this->relay_id;
}
if ($this->pulse_seconds !== null) {
$array['pulse_seconds'] = $this->pulse_seconds;
}
return $array;
}
@@ -58,6 +70,19 @@ class department_gate_config
if ($this->call_duration_threshold === null) {
throw new Exception('Call duration threshold is required for PHONE_CALL gate type');
}
return;
}
if ($this->type === 'RELAY') {
if ($this->relay_id === null || $this->relay_id === '') {
throw new Exception('relay_id is required for RELAY gate type');
}
if ($this->pulse_seconds !== null && $this->pulse_seconds < 0) {
throw new Exception('pulse_seconds must be a positive integer for RELAY gate type');
}
return;
}
throw new Exception('Unsupported gate config type: ' . $this->type);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,89 @@
<?php
namespace classes;
/**
* Ensures additive schema for department lifecycle metadata.
*/
class departments_schema_bootstrap
{
private static bool $initialized = false;
private const ARCHIVED_INDEX = 'idx_departments_archived';
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
if (!self::tableExists($db, 'departments')) {
return;
}
if (!self::columnExists($db, 'departments', 'archived')) {
$db->query(
"ALTER TABLE departments
ADD COLUMN archived TINYINT(1) NOT NULL DEFAULT 0
AFTER visible"
);
}
if (!self::indexExists($db, 'departments', self::ARCHIVED_INDEX)) {
$db->query(
"ALTER TABLE departments
ADD INDEX " . self::ARCHIVED_INDEX . " (archived)"
);
}
self::$initialized = true;
}
private static function tableExists(object $db, string $table): bool
{
$table = self::escapeIdentifier($table);
$result = $db->query("SHOW TABLES LIKE '{$table}'");
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
return false;
}
return (int)$result->num_rows > 0;
}
private static function columnExists(object $db, string $table, string $column): bool
{
$table = self::escapeIdentifier($table);
$column = self::escapeIdentifier($column);
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
return false;
}
return (int)$result->num_rows > 0;
}
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);
}
}
+105 -5
View File
@@ -29,6 +29,9 @@ 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
* @var economic_c
@@ -120,9 +123,59 @@ class economic implements economic_i
return new $this->helpers->economic_tasks();
}
public function createCustomer(int $customer_number, string $name, int $cvr_number, string $email, int $phone): economic_customer
public function getTransactionDraftCustomerNumber(): ?int
{
$result = $this->customers->customers->create([
$value = $this->config->transaction_draft_customer_number->getVariableValue();
if ($value === null || $value === '') {
return null;
}
$customer_number = (int)$value;
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();
if ($configured_customer_number === null || $customer_number === null) {
return false;
}
return $configured_customer_number === (int)$customer_number;
}
/**
* @throws \Exception
*/
public function assertCustomerNumberIsNotDraft(?int $customer_number): void
{
if ($this->isDraftCustomerNumber($customer_number)) {
throw new \Exception(self::DRAFT_CUSTOMER_EXPORT_BLOCKED_MESSAGE);
}
}
/**
* 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,
?int $mobile_phone = null,
object|array|null $company_information = null
): object
{
$payload = [
'customerNumber' => $customer_number,
'corporateIdentificationNumber' => (string)$cvr_number,
'customerGroup' => [
@@ -134,13 +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,
]
]);
];
return new $this->helpers->economic_customer($customer_number);
$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;
}
/**
@@ -153,4 +253,4 @@ class economic implements economic_i
// Return the booked invoice helper
return (new economic_invoice_booked($raw));
}
}
}
@@ -0,0 +1,442 @@
<?php
namespace classes;
use economic_invoice_draft_mo;
use Exception;
use objects\collected_order_invoices_o;
use objects\customer_fixed_pricing_o;
use objects\departments_o;
use objects\economic_module_orders;
use objects\logs_o;
use objects\orders_o;
use objects\users_o;
/**
* Executes e-conomic transfer flows that are now run by queue workers.
*/
class economic_transfer_executor
{
/**
* Export order draft invoice to e-conomic.
* @throws Exception
*/
public function exportOrderDraftInvoice(int $order_id, int $user_id = 0): array
{
$economic = new economic();
$order = (new orders_o())->getOrderById($order_id);
if (!$order->exists()) {
throw new Exception('Order not found');
}
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
$invoice_id = (int)$economic_module_orders->economic_invoice_id->value();
if ($invoice_id > 0) {
throw new Exception('An invoice has already been created, invoice ID: ' . $invoice_id);
}
$order_items = (new orders_o())->getOrderItems($order_id);
$order_items = (new orders_o())->applyDepartmentPrices($order_items, $order->department_id->value());
if (count($order_items) === 0) {
throw new Exception('No order items found');
}
$customer = (new orders_o())->getCustomerByOrderId($order_id);
if (!$customer->exists()) {
throw new Exception('Customer not found');
}
$economic->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
$customer_economic = $customer->getCustomerEcocomicData()->economic_customer;
$economic_invoice_draft = new economic_invoice_draft_mo();
$economic_invoice_draft->setCustomerNumber((int)$customer_economic->customer_number);
$economic_invoice_draft->setRecipient(
$customer_economic->name ?? 'Ukendt',
$customer_economic->address ?? 'Ukendt',
$customer_economic->zip ?? 'Ukendt',
$customer_economic->city ?? 'Ukendt'
);
$department = (new departments_o())->getDepartmentById($order->department_id->value());
$this->addTheDepartmentDateReference($economic_invoice_draft, $department['name'], $order);
$billable_order_items = 0;
foreach ($order_items as $order_item) {
$added = $this->addOrderItemToInvoice(
$customer,
$order,
$order_item,
$economic_invoice_draft,
(int)($order_item['quantity'] ?? 1)
);
if ($added) {
$billable_order_items++;
}
}
if ($billable_order_items < 1) {
throw new Exception('No billable order items found');
}
$result = null;
if ($customer->hasOpenInvoiceDraft() && !$customer->invoicePerOrder()) {
$open_invoice_draft = (int)$customer->getOpenInvoiceDraft();
$result = $this->addOrderToInvoiceDraft($open_invoice_draft, $order, $customer, $order_items);
}
if ($result === null) {
$result = $economic_invoice_draft->createInvoiceDraftExample();
}
if (!isset($result->draftInvoiceNumber) && !isset($result->lines[0])) {
(new logs_o())->add(
'economic_invoice_draft',
'global',
3,
$user_id,
'ECONOMIC_INVOICE_DRAFT_EXPORT',
'Failed to create economic invoice draft'
);
$message = $result->message ?? 'Failed to create economic invoice draft';
throw new Exception((string)$message);
}
if ($economic_module_orders->economic_invoice_draft_id->value() > 0) {
$economic_invoice_draft->deleteInvoiceDraft($economic_module_orders->economic_invoice_draft_id->value());
}
$new_draft_id = (int)($result->draftInvoiceNumber ?? $customer->getOpenInvoiceDraft());
$economic_module_orders->economic_invoice_draft_id->set($new_draft_id);
if (!$customer->invoicePerOrder()) {
$customer->setOpenInvoiceDraft($new_draft_id);
} else {
$customer->unsetOpenInvoiceDraft();
}
(new logs_o())->add(
'economic_invoice_draft',
'global',
1,
$user_id,
'ECONOMIC_INVOICE_DRAFT_EXPORT',
'Successfully exported an economic invoice draft'
);
return $economic_module_orders->getArray();
}
/**
* Export booked invoice from existing draft.
* @throws Exception
*/
public function exportOrderInvoice(int $order_id, int $user_id = 0): array
{
$economic = new economic();
$order = (new orders_o())->getOrderById($order_id);
if (!$order->exists()) {
throw new Exception('Order not found');
}
$customer = (new orders_o())->getCustomerByOrderId($order_id);
if (!$customer->exists()) {
throw new Exception('Customer not found');
}
$economic->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
throw new Exception('No economic invoice draft found');
}
$invoice_id = (int)$economic_module_orders->economic_invoice_id->value();
if ($invoice_id > 0) {
throw new Exception('An invoice has already been created, invoice ID: ' . $invoice_id);
}
$invoice_draft_id = (int)$economic_module_orders->economic_invoice_draft_id->value();
$economic_invoice_draft = new economic_invoice_draft_mo();
$result = $economic_invoice_draft->publishInvoiceDraft($invoice_draft_id);
if (!isset($result->bookedInvoiceNumber)) {
(new logs_o())->add(
'economic_invoice',
'global',
3,
$user_id,
'ECONOMIC_INVOICE_EXPORT',
'Failed to create economic invoice from draft: ' . $invoice_draft_id
);
$message = $result->message ?? 'Failed to create economic invoice';
throw new Exception((string)$message);
}
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
$economic_module_orders->economic_invoice_id->set((int)$result->bookedInvoiceNumber);
$customer->unsetOpenInvoiceDraft();
(new logs_o())->add(
'economic_invoice',
'global',
1,
$user_id,
'ECONOMIC_INVOICE_EXPORT',
'Successfully exported an economic invoice'
);
return $economic_module_orders->getArray();
}
/**
* Export collected invoice to e-conomic.
* @throws Exception
*/
public function exportCollectedInvoice(int $collected_invoice_id, bool $send_as_is = false, int $user_id = 0): array
{
$collected_order_invoices = (new collected_order_invoices_o())->select($collected_invoice_id);
$collected_order_invoices->requireSelected();
(new economic())->assertCustomerNumberIsNotDraft((int)$collected_order_invoices->customer_number->value());
if ($collected_order_invoices->external_id->value() === null) {
if (!$send_as_is) {
$customer_fixed_pricing_o = new customer_fixed_pricing_o();
if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) {
$customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value());
$customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value();
$collected_order_invoices->overridePricesFixed($customer_fixed_pricing_price);
} else {
$collected_order_invoices->addVehicleSubscriptionsTransaction();
}
} else {
$collected_order_invoices->removeSpecialArrangements();
$collected_order_invoices->setAllItemsToBeIncludedInInvoice();
}
$collected_order_invoices->addToEconomic();
} else {
if ($collected_order_invoices->booked_invoice_id->value() !== null) {
throw new Exception('Invoice has already been booked');
}
if ($collected_order_invoices->isDraftExisting()) {
throw new Exception('Invoice draft already exists in E-Conomic');
}
$customer_fixed_pricing_o = new customer_fixed_pricing_o();
if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) {
$customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value());
$customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value();
$collected_order_invoices->overridePricesFixed($customer_fixed_pricing_price);
} else {
$collected_order_invoices->addVehicleSubscriptionsTransaction();
}
$collected_order_invoices->addToEconomic(true);
}
(new logs_o())->add(
'orderInvoices',
'global',
1,
$user_id,
'ADD_COLLECTED_INVOICE_ECONOMIC',
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
);
return $collected_order_invoices->asArray();
}
/**
* @throws Exception
*/
public function addTheDepartmentDateReference(economic_invoice_draft_mo $economic_invoice_draft, mixed $department_name, orders_o $order): void
{
$parsed_date = date('d/m/Y H:i', strtotime($order->created_at->value()));
$economic_invoice_draft->addLineTEXT("[ " . $parsed_date . ' ' . $department_name . ' #' . $order->id . " ]");
if ($order->reference->value() !== '') {
$economic_invoice_draft->addLineTEXT('Reference:');
if (str_contains($order->reference->value(), "\n")) {
foreach (explode("\n", $order->reference->value()) as $line) {
$economic_invoice_draft->addLineTEXT('# ' . $line);
}
} else {
$economic_invoice_draft->addLineTEXT('# ' . $order->reference->value());
}
}
$line_reg = '';
if ($order->reg_1->value() !== '') {
$line_reg .= 'Reg 1: ' . strtoupper($order->reg_1->value());
}
if ($order->reg_2->value() !== '') {
$line_reg .= ', Reg 2: ' . strtoupper($order->reg_2->value());
}
if ($order->reg_3->value() !== '') {
$line_reg .= ', Reg 3: ' . strtoupper($order->reg_3->value());
}
$economic_invoice_draft->addLineTEXT($line_reg);
if ($order->notes->value() !== '') {
$economic_invoice_draft->addLineTEXT('Notat:');
if (str_contains($order->notes->value(), "\n")) {
foreach (explode("\n", $order->notes->value()) as $line) {
$economic_invoice_draft->addLineTEXT('# ' . $line);
}
} else {
$economic_invoice_draft->addLineTEXT('# ' . $order->notes->value());
}
}
}
/**
* Returns true when a product line was added, false when skipped.
* @throws Exception
*/
public function addOrderItemToInvoice(
users_o $customer,
orders_o $order,
mixed $order_item,
economic_invoice_draft_mo $economic_invoice_draft,
int $quantity = 1
): bool {
if (self::shouldSkipOrderItemForInvoice($order_item, $quantity)) {
return false;
}
if (!is_array($order_item)) {
throw new Exception('Order item payload must be an array');
}
$product_number = trim((string)($order_item['product']['economic_product_id'] ?? ''));
if ($product_number === '') {
throw new Exception('Order item is missing economic product id');
}
$product_name = trim((string)($order_item['product']['name'] ?? ''));
if ($product_name === '') {
$product_name = 'Ukendt produkt';
}
$reference = isset($order_item['reference']) ? (string)$order_item['reference'] : '';
$notes = isset($order_item['notes']) ? (string)$order_item['notes'] : '';
$department = $order->getDepartmentByOrderId($order->id);
$economic_department_id = $department['economic_department_id'];
$economic_dimension_id = $department['economic_dimension_id'] ?? 0;
$order_item_price = (float)($order_item['price'] ?? 0);
$product_price = (float)($order_item['product']['price'] ?? 0);
$economic_invoice_draft->addLine(
$product_number,
$product_name,
$quantity,
$order_item_price,
0,
(int)$economic_department_id ?? 0,
(int)$economic_dimension_id ?? 0
);
$show_discount = abs($order_item_price - $product_price) > 0.00001;
if ($show_discount && abs($product_price) > 0.00001) {
$discount_percentage = round((($product_price - $order_item_price) / $product_price) * 100, 0);
$economic_invoice_draft->addLineTEXT('Rabat: ' . ($order_item_price - $product_price) . ' DKK (' . $discount_percentage . '%)');
}
if ($reference !== '') {
$economic_invoice_draft->addLineTEXT('Reference:');
if (str_contains($reference, "\n")) {
foreach (explode("\n", $reference) as $line) {
$economic_invoice_draft->addLineTEXT('# ' . $line);
}
} else {
$economic_invoice_draft->addLineTEXT('# ' . $reference);
}
}
if ($customer->doesUserHaveAttribute('requiresRegistrationNumbersInvoice')) {
$line_reg = '';
if ($order->reg_1->value() !== '') {
$line_reg .= 'Reg 1: ' . strtoupper($order->reg_1->value());
}
if ($order->reg_2->value() !== '') {
$line_reg .= ', Reg 2: ' . strtoupper($order->reg_2->value());
}
if ($order->reg_3->value() !== '') {
$line_reg .= ', Reg 3: ' . strtoupper($order->reg_3->value());
}
$economic_invoice_draft->addLineTEXT($line_reg);
}
if ($notes !== '') {
$economic_invoice_draft->addLineTEXT('Notat:');
if (str_contains($notes, "\n")) {
foreach (explode("\n", $notes) as $line) {
$economic_invoice_draft->addLineTEXT('# ' . $line);
}
} else {
$economic_invoice_draft->addLineTEXT('# ' . $notes);
}
}
return true;
}
/**
* @throws Exception
*/
public function addOrderToInvoiceDraft(int $economic_invoice_draft_id, orders_o $order, users_o $customer, array $order_items): object
{
$has_billable_items = false;
foreach ($order_items as $order_item) {
if (!self::shouldSkipOrderItemForInvoice($order_item, (int)($order_item['quantity'] ?? 1))) {
$has_billable_items = true;
break;
}
}
if (!$has_billable_items) {
throw new Exception('No billable order items found');
}
$economic_invoice_draft = new economic_invoice_draft_mo();
$economic_invoice_draft->addLineTEXT('');
$economic_invoice_draft->addLineTEXT('');
$this->addTheDepartmentDateReference($economic_invoice_draft, $order->getDepartmentByOrderId($order->id)['name'], $order);
foreach ($order_items as $order_item) {
$this->addOrderItemToInvoice($customer, $order, $order_item, $economic_invoice_draft, (int)($order_item['quantity'] ?? 1));
}
return $economic_invoice_draft->addLinesToInvoiceDraft($economic_invoice_draft_id);
}
/**
* Skip item lines that are not billable in e-conomic export (0 quantity or 0 unit cost).
*/
public static function shouldSkipOrderItemForInvoice(mixed $order_item, int $quantity = 1): bool
{
if (!is_array($order_item)) {
return true;
}
$line_quantity = $quantity;
if (isset($order_item['quantity']) && is_numeric($order_item['quantity'])) {
$line_quantity = (int)$order_item['quantity'];
}
if ($line_quantity <= 0) {
return true;
}
$line_price = null;
if (isset($order_item['price']) && is_numeric($order_item['price'])) {
$line_price = (float)$order_item['price'];
}
if ($line_price === null) {
return true;
}
if (abs($line_price) < 0.00001) {
return true;
}
return false;
}
}
@@ -0,0 +1,867 @@
<?php
namespace classes;
use Exception;
use mysqli_result;
use objects\logs_o;
/**
* Queue service for asynchronous e-conomic transfer jobs.
*/
class economic_transfer_queue
{
public const STATUS_QUEUED = 'QUEUED';
public const STATUS_PROCESSING = 'PROCESSING';
public const STATUS_COMPLETED = 'COMPLETED';
public const STATUS_FAILED = 'FAILED';
public const TYPE_ORDER_DRAFT_EXPORT = 'ORDER_DRAFT_EXPORT';
public const TYPE_ORDER_INVOICE_EXPORT = 'ORDER_INVOICE_EXPORT';
public const TYPE_COLLECTED_INVOICE_EXPORT = 'COLLECTED_INVOICE_EXPORT';
private const STALE_PROCESSING_LOCK_SECONDS = 900;
private economic_transfer_executor $executor;
public function __construct(?economic_transfer_executor $executor = null)
{
$this->executor = $executor ?? new economic_transfer_executor();
economic_transfer_queue_schema_bootstrap::ensureTables();
}
/**
* @throws Exception
*/
public function enqueue(string $transfer_type, array $payload, int $created_by = 0, int $max_attempts = 3): array
{
global $db;
$created_by = max(0, $created_by);
$max_attempts = max(1, min(10, $max_attempts));
$transfer_type = $this->validateTransferType($transfer_type);
$payload = $this->normalizePayloadForTransferType($transfer_type, $payload, $created_by);
$active_job = $this->findActiveJobByTarget($transfer_type, $payload);
if ($active_job !== null) {
$target_label = $this->buildTargetLabel($transfer_type, $payload);
$this->logQueueEvent(
1,
$created_by,
'ECONOMIC_TRANSFER_JOB_DEDUPED',
'Transfer job deduped for active target (' . $target_label . '), returning existing job #' . (int)($active_job['id'] ?? 0)
);
return $active_job;
}
$payload_json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($payload_json === false) {
throw new Exception('Failed to serialize queue payload');
}
$stmt = $db->prepare(
"INSERT INTO economic_transfer_queue_jobs
(transfer_type, payload_json, status, progress_percent, progress_message, attempts, max_attempts, created_by)
VALUES (?, ?, ?, 0, 'Queued', 0, ?, ?)"
);
if (!$stmt) {
throw new Exception('Failed to prepare queue insert statement');
}
$status = self::STATUS_QUEUED;
$stmt->bind_param('sssii', $transfer_type, $payload_json, $status, $max_attempts, $created_by);
if (!$stmt->execute()) {
throw new Exception('Failed to enqueue transfer job');
}
$job_id = (int)$db->insert_id();
$stmt->close();
$this->logQueueEvent(
1,
$created_by,
'ECONOMIC_TRANSFER_JOB_ENQUEUED',
'Transfer job #' . $job_id . ' queued: ' . $transfer_type
);
$job = $this->getJobById($job_id);
if ($job === null) {
throw new Exception('Failed to load queued transfer job');
}
return $job;
}
public function getJobById(int $job_id): ?array
{
global $db;
$stmt = $db->prepare("SELECT * FROM economic_transfer_queue_jobs WHERE id = ? LIMIT 1");
if (!$stmt) {
return null;
}
$stmt->bind_param('i', $job_id);
if (!$stmt->execute()) {
$stmt->close();
return null;
}
$result = $stmt->get_result();
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
$stmt->close();
if (!$row) {
return null;
}
return $this->normalizeJobRow($row);
}
public function listJobs(array $statuses = [], int $limit = 50, int $offset = 0, ?string $transfer_type = null): array
{
global $db;
$limit = max(1, min(500, $limit));
$offset = max(0, $offset);
$where = $this->buildListJobsWhereClause($statuses, $transfer_type);
$sql = "SELECT * FROM economic_transfer_queue_jobs $where ORDER BY id DESC LIMIT $limit OFFSET $offset";
$result = $db->query($sql);
if (!$result instanceof mysqli_result) {
return [];
}
$jobs = [];
while ($row = $result->fetch_assoc()) {
$jobs[] = $this->normalizeJobRow($row);
}
return $jobs;
}
public function countJobs(array $statuses = [], ?string $transfer_type = null): int
{
global $db;
$where = $this->buildListJobsWhereClause($statuses, $transfer_type);
$sql = "SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs $where";
$result = $db->query($sql);
if (!$result instanceof mysqli_result) {
return 0;
}
$row = $result->fetch_assoc();
if (!is_array($row) || !isset($row['total'])) {
return 0;
}
return max(0, (int)$row['total']);
}
public function listMonitorJobsForUser(int $user_id, int $limit = 50, ?string $transfer_type = null): array
{
global $db;
$user_id = max(0, $user_id);
$limit = max(1, min(100, $limit));
try {
$normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== ''
? $this->validateTransferType($transfer_type)
: null;
} catch (Exception) {
return [];
}
$transfer_condition = '';
if ($normalized_transfer_type !== null) {
$transfer_condition = "AND q.transfer_type = '" . $db->escape_string($normalized_transfer_type) . "'";
}
$sql = "SELECT q.*
FROM economic_transfer_queue_jobs q
LEFT JOIN economic_transfer_queue_job_dismissals d
ON d.queue_job_id = q.id
AND d.user_id = $user_id
AND d.dismissed_status = q.status
WHERE 1 = 1
$transfer_condition
AND (
q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "')
OR d.queue_job_id IS NULL
)
ORDER BY
CASE WHEN q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "') THEN 0 ELSE 1 END,
q.id DESC
LIMIT $limit";
$result = $db->query($sql);
if (!$result instanceof mysqli_result) {
return [];
}
$jobs = [];
while ($row = $result->fetch_assoc()) {
$jobs[] = $this->normalizeJobRow($row);
}
return $jobs;
}
/**
* @throws Exception
*/
public function dismissTerminalJobForUser(int $job_id, int $user_id): array
{
global $db;
$job_id = max(0, $job_id);
$user_id = max(0, $user_id);
if ($job_id < 1 || $user_id < 1) {
throw new Exception('Queue job and user are required');
}
$job = $this->getJobById($job_id);
if ($job === null) {
throw new Exception('Queue job not found');
}
$status = strtoupper((string)($job['status'] ?? ''));
if (!in_array($status, [self::STATUS_COMPLETED, self::STATUS_FAILED], true)) {
throw new Exception('Only completed or failed queue jobs can be dismissed');
}
$stmt = $db->prepare(
"INSERT INTO economic_transfer_queue_job_dismissals (queue_job_id, user_id, dismissed_status, dismissed_at)
VALUES (?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()"
);
if (!$stmt) {
throw new Exception('Failed to prepare queue dismissal statement');
}
$stmt->bind_param('iis', $job_id, $user_id, $status);
if (!$stmt->execute()) {
$stmt->close();
throw new Exception('Failed to dismiss queue job');
}
$stmt->close();
return $job;
}
public function dismissTerminalJobsForUser(int $user_id, ?string $transfer_type = null): int
{
global $db;
$user_id = max(0, $user_id);
if ($user_id < 1) {
return 0;
}
try {
$normalized_transfer_type = $transfer_type !== null && trim($transfer_type) !== ''
? $this->validateTransferType($transfer_type)
: null;
} catch (Exception) {
return 0;
}
$transfer_condition = '';
if ($normalized_transfer_type !== null) {
$transfer_condition = "AND q.transfer_type = '" . $db->escape_string($normalized_transfer_type) . "'";
}
$sql = "INSERT INTO economic_transfer_queue_job_dismissals (queue_job_id, user_id, dismissed_status, dismissed_at)
SELECT q.id, $user_id, q.status, NOW()
FROM economic_transfer_queue_jobs q
LEFT JOIN economic_transfer_queue_job_dismissals d
ON d.queue_job_id = q.id
AND d.user_id = $user_id
AND d.dismissed_status = q.status
WHERE q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "')
$transfer_condition
AND d.queue_job_id IS NULL
ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()";
$db->query($sql);
return max(0, (int)($db->affected_rows ?? 0));
}
/**
* @throws Exception
*/
public function retryJob(int $job_id): array
{
global $db;
$existing_job = $this->getJobById($job_id);
if ($existing_job === null) {
throw new Exception('Queue job not found');
}
if ((string)($existing_job['status'] ?? '') !== self::STATUS_FAILED) {
throw new Exception('Only failed jobs can be retried');
}
if ((int)($existing_job['attempts'] ?? 0) >= (int)($existing_job['max_attempts'] ?? 1)) {
throw new Exception('Queue job reached max retry attempts');
}
$stmt = $db->prepare(
"UPDATE economic_transfer_queue_jobs
SET status = ?, progress_percent = 0, progress_message = 'Queued for retry',
error_message = NULL, result_json = NULL, started_at = NULL, completed_at = NULL, locked_at = NULL
WHERE id = ? AND status = ?"
);
if (!$stmt) {
throw new Exception('Failed to prepare retry statement');
}
$queued = self::STATUS_QUEUED;
$failed = self::STATUS_FAILED;
$stmt->bind_param('sis', $queued, $job_id, $failed);
$stmt->execute();
$affected = $stmt->affected_rows;
$stmt->close();
if ($affected < 1) {
throw new Exception('Failed to retry queue job');
}
$this->clearDismissalsForJob($job_id);
$job = $this->getJobById($job_id);
if ($job === null) {
throw new Exception('Retry updated job could not be loaded');
}
return $job;
}
public function processPending(int $limit = 5): array
{
return $this->processPendingInternal($limit);
}
/**
* @throws Exception
*/
public function processPendingByTransferType(string $transfer_type, int $limit = 10): array
{
return $this->processPendingInternal($limit, $this->validateTransferType($transfer_type));
}
private function processPendingInternal(int $limit = 5, ?string $transfer_type = null): array
{
$limit = max(1, min(100, $limit));
$this->releaseStaleProcessingLocks();
$processed = 0;
$completed = 0;
$failed = 0;
$jobs = [];
$empty_claims = 0;
for ($i = 0; $i < $limit; $i++) {
$job = $this->claimNextJob($transfer_type);
if ($job === null) {
$empty_claims++;
if ($empty_claims >= 3) {
break;
}
continue;
}
$empty_claims = 0;
$processed++;
$jobs[] = $job['id'];
$this->updateProgress((int)$job['id'], 15, 'Running transfer');
try {
$result = $this->executeJob($job);
$this->markCompleted((int)$job['id'], $result);
$completed++;
} catch (Exception $e) {
$this->markFailed((int)$job['id'], $e->getMessage());
$failed++;
} catch (\Throwable $e) {
$this->markFailed((int)$job['id'], $e->getMessage());
$failed++;
}
}
return [
'processed' => $processed,
'completed' => $completed,
'failed' => $failed,
'jobs' => $jobs,
];
}
private function claimNextJob(?string $transfer_type = null): ?array
{
global $db;
$sql = "SELECT id
FROM economic_transfer_queue_jobs
WHERE status = ?
AND attempts < max_attempts
AND (next_retry_at IS NULL OR next_retry_at <= NOW())";
if ($transfer_type !== null) {
$sql .= " AND transfer_type = ?";
}
$sql .= " ORDER BY id ASC LIMIT 1";
$stmt = $db->prepare($sql);
if (!$stmt) {
return null;
}
$queued = self::STATUS_QUEUED;
if ($transfer_type !== null) {
$stmt->bind_param('ss', $queued, $transfer_type);
} else {
$stmt->bind_param('s', $queued);
}
if (!$stmt->execute()) {
$stmt->close();
return null;
}
$result = $stmt->get_result();
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
$stmt->close();
if (!$row || !isset($row['id'])) {
return null;
}
$job_id = (int)$row['id'];
$stmt = $db->prepare(
"UPDATE economic_transfer_queue_jobs
SET status = ?, progress_percent = 5, progress_message = 'Processing', started_at = NOW(), locked_at = NOW()
WHERE id = ? AND status = ?"
);
if (!$stmt) {
return null;
}
$processing = self::STATUS_PROCESSING;
$stmt->bind_param('sis', $processing, $job_id, $queued);
$stmt->execute();
$affected = $stmt->affected_rows;
$stmt->close();
if ($affected < 1) {
return null;
}
return $this->getJobById($job_id);
}
/**
* @throws Exception
*/
private function executeJob(array $job): array
{
$payload = (array)($job['payload'] ?? []);
$transfer_type = (string)($job['transfer_type'] ?? '');
$requested_by = (int)($payload['requested_by'] ?? ($job['created_by'] ?? 0));
$this->updateProgress((int)$job['id'], 40, 'Validating job payload');
return match ($transfer_type) {
self::TYPE_ORDER_DRAFT_EXPORT => $this->executeOrderDraftExport($job, $payload, $requested_by),
self::TYPE_ORDER_INVOICE_EXPORT => $this->executeOrderInvoiceExport($job, $payload, $requested_by),
self::TYPE_COLLECTED_INVOICE_EXPORT => $this->executeCollectedInvoiceExport($job, $payload, $requested_by),
default => throw new Exception('Unsupported transfer type: ' . $transfer_type),
};
}
/**
* @throws Exception
*/
private function executeOrderDraftExport(array $job, array $payload, int $requested_by): array
{
$order_id = (int)($payload['order_id'] ?? 0);
if ($order_id < 1) {
throw new Exception('order_id is required');
}
$this->updateProgress((int)$job['id'], 65, 'Exporting order draft invoice');
return $this->executor->exportOrderDraftInvoice($order_id, $requested_by);
}
/**
* @throws Exception
*/
private function executeOrderInvoiceExport(array $job, array $payload, int $requested_by): array
{
$order_id = (int)($payload['order_id'] ?? 0);
if ($order_id < 1) {
throw new Exception('order_id is required');
}
$this->updateProgress((int)$job['id'], 65, 'Exporting booked invoice');
return $this->executor->exportOrderInvoice($order_id, $requested_by);
}
/**
* @throws Exception
*/
private function executeCollectedInvoiceExport(array $job, array $payload, int $requested_by): array
{
$collected_invoice_id = (int)($payload['collected_invoice_id'] ?? 0);
if ($collected_invoice_id < 1) {
throw new Exception('collected_invoice_id is required');
}
$send_as_is = (bool)($payload['send_as_is'] ?? false);
$this->updateProgress((int)$job['id'], 65, 'Exporting collected invoice');
return $this->executor->exportCollectedInvoice($collected_invoice_id, $send_as_is, $requested_by);
}
private function updateProgress(int $job_id, int $percent, string $message): void
{
global $db;
$percent = max(0, min(100, $percent));
$escaped_message = $db->escape_string($message);
$sql = "UPDATE economic_transfer_queue_jobs
SET progress_percent = $percent, progress_message = '$escaped_message'
WHERE id = $job_id";
$db->query($sql);
}
private function markCompleted(int $job_id, array $result): void
{
global $db;
$result_json = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($result_json === false) {
$result_json = json_encode(['result' => 'serialization_error']);
}
$escaped_result = $db->escape_string((string)$result_json);
$sql = "UPDATE economic_transfer_queue_jobs
SET status = '" . self::STATUS_COMPLETED . "',
progress_percent = 100,
progress_message = 'Completed',
result_json = '$escaped_result',
error_message = NULL,
completed_at = NOW(),
locked_at = NULL
WHERE id = $job_id";
$db->query($sql);
$this->logQueueEvent(
1,
0,
'ECONOMIC_TRANSFER_JOB_COMPLETED',
'Transfer job #' . $job_id . ' completed'
);
}
private function markFailed(int $job_id, string $error_message): void
{
global $db;
$error_message = trim($error_message);
if ($error_message === '') {
$error_message = 'Unknown transfer queue error';
}
$escaped_error = $db->escape_string($error_message);
$sql = "UPDATE economic_transfer_queue_jobs
SET status = '" . self::STATUS_FAILED . "',
progress_message = 'Failed',
error_message = '$escaped_error',
attempts = attempts + 1,
completed_at = NOW(),
locked_at = NULL
WHERE id = $job_id";
$db->query($sql);
$this->logQueueEvent(
3,
0,
'ECONOMIC_TRANSFER_JOB_FAILED',
'Transfer job #' . $job_id . ' failed: ' . $error_message
);
}
private function decodeJsonValue(mixed $json): mixed
{
if (!is_string($json) || trim($json) === '') {
return null;
}
$decoded = json_decode($json, true);
return json_last_error() === JSON_ERROR_NONE ? $decoded : null;
}
private function normalizeJobRow(array $row): array
{
return [
'id' => (int)$row['id'],
'transfer_type' => (string)($row['transfer_type'] ?? ''),
'status' => (string)($row['status'] ?? self::STATUS_QUEUED),
'progress_percent' => (int)($row['progress_percent'] ?? 0),
'progress_message' => $row['progress_message'] ?? null,
'attempts' => (int)($row['attempts'] ?? 0),
'max_attempts' => (int)($row['max_attempts'] ?? 0),
'error_message' => $row['error_message'] ?? null,
'payload' => $this->decodeJsonValue($row['payload_json'] ?? null),
'result' => $this->decodeJsonValue($row['result_json'] ?? null),
'created_by' => isset($row['created_by']) ? (int)$row['created_by'] : null,
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
'started_at' => $row['started_at'] ?? null,
'completed_at' => $row['completed_at'] ?? null,
'next_retry_at' => $row['next_retry_at'] ?? null,
];
}
private function clearDismissalsForJob(int $job_id): void
{
global $db;
$job_id = max(0, $job_id);
if ($job_id < 1) {
return;
}
$db->query("DELETE FROM economic_transfer_queue_job_dismissals WHERE queue_job_id = $job_id");
}
/**
* Release jobs stuck in PROCESSING due to crashes or killed workers.
*/
private function releaseStaleProcessingLocks(): void
{
global $db;
$timeout = (int)self::STALE_PROCESSING_LOCK_SECONDS;
$sql = "UPDATE economic_transfer_queue_jobs
SET status = '" . self::STATUS_QUEUED . "',
progress_message = 'Re-queued after stale processing lock',
locked_at = NULL,
started_at = NULL
WHERE status = '" . self::STATUS_PROCESSING . "'
AND locked_at IS NOT NULL
AND locked_at < (NOW() - INTERVAL $timeout SECOND)";
$db->query($sql);
}
/**
* @throws Exception
*/
private function normalizePayloadForTransferType(string $transfer_type, array $payload, int $created_by): array
{
$normalized_payload = $payload;
if (isset($normalized_payload['requested_by'])) {
$normalized_payload['requested_by'] = max(0, (int)$normalized_payload['requested_by']);
}
return match ($transfer_type) {
self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => $this->normalizeOrderPayload($normalized_payload, $created_by),
self::TYPE_COLLECTED_INVOICE_EXPORT => $this->normalizeCollectedInvoicePayload($normalized_payload, $created_by),
default => $this->rejectPayload($created_by, 'Unsupported transfer type payload: ' . $transfer_type),
};
}
/**
* @throws Exception
*/
private function normalizeOrderPayload(array $payload, int $created_by): array
{
$order_id = $payload['order_id'] ?? null;
if ($order_id === null || !is_numeric($order_id) || (int)$order_id < 1) {
return $this->rejectPayload($created_by, 'order_id is required and must be a positive number');
}
$payload['order_id'] = (int)$order_id;
return $payload;
}
/**
* @throws Exception
*/
private function normalizeCollectedInvoicePayload(array $payload, int $created_by): array
{
$collected_invoice_id = $payload['collected_invoice_id'] ?? null;
if ($collected_invoice_id === null || !is_numeric($collected_invoice_id) || (int)$collected_invoice_id < 1) {
return $this->rejectPayload($created_by, 'collected_invoice_id is required and must be a positive number');
}
$payload['collected_invoice_id'] = (int)$collected_invoice_id;
$payload['send_as_is'] = $this->normalizeBooleanPayloadValue($payload['send_as_is'] ?? false, 'send_as_is', $created_by);
return $payload;
}
/**
* @throws Exception
*/
private function normalizeBooleanPayloadValue(mixed $value, string $field_name, int $created_by): bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value) || (is_string($value) && is_numeric($value))) {
$numeric = (int)$value;
if ($numeric === 0 || $numeric === 1) {
return $numeric === 1;
}
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
}
if (is_string($value)) {
$normalized = strtolower(trim($value));
if (in_array($normalized, ['true', 'false', '1', '0'], true)) {
return in_array($normalized, ['true', '1'], true);
}
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
}
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
}
private function findActiveJobByTarget(string $transfer_type, array $payload): ?array
{
return match ($transfer_type) {
self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget(
$transfer_type,
'$.order_id',
(int)($payload['order_id'] ?? 0)
),
self::TYPE_COLLECTED_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget(
$transfer_type,
'$.collected_invoice_id',
(int)($payload['collected_invoice_id'] ?? 0)
),
default => null,
};
}
private function findActiveJobByJsonNumericTarget(string $transfer_type, string $json_path, int $target_value): ?array
{
global $db;
if ($target_value < 1) {
return null;
}
$stmt = $db->prepare(
"SELECT id
FROM economic_transfer_queue_jobs
WHERE transfer_type = ?
AND status IN (?, ?)
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$json_path')) AS UNSIGNED) = ?
ORDER BY id DESC
LIMIT 1"
);
if (!$stmt) {
return null;
}
$queued = self::STATUS_QUEUED;
$processing = self::STATUS_PROCESSING;
$stmt->bind_param('sssi', $transfer_type, $queued, $processing, $target_value);
if (!$stmt->execute()) {
$stmt->close();
return null;
}
$result = $stmt->get_result();
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
$stmt->close();
if (!$row || !isset($row['id'])) {
return null;
}
return $this->getJobById((int)$row['id']);
}
private function buildTargetLabel(string $transfer_type, array $payload): string
{
return match ($transfer_type) {
self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => 'order_id=' . (int)($payload['order_id'] ?? 0),
self::TYPE_COLLECTED_INVOICE_EXPORT => 'collected_invoice_id=' . (int)($payload['collected_invoice_id'] ?? 0),
default => 'unknown',
};
}
/**
* @throws Exception
*/
private function rejectPayload(int $created_by, string $message): never
{
$this->logQueueEvent(
3,
max(0, $created_by),
'ECONOMIC_TRANSFER_JOB_VALIDATION_REJECTED',
'Transfer job enqueue rejected: ' . $message
);
throw new Exception($message);
}
private function logQueueEvent(int $status_code, int $user_id, string $event, string $message): void
{
try {
(new logs_o())->add(
'economic_transfer_queue',
'global',
$status_code,
$user_id,
$event,
$message
);
} catch (\Throwable) {
// Logging is best effort for queue operations.
}
}
/**
* @throws Exception
*/
private function validateTransferType(string $transfer_type): string
{
$transfer_type = strtoupper(trim($transfer_type));
if (!in_array($transfer_type, [
self::TYPE_ORDER_DRAFT_EXPORT,
self::TYPE_ORDER_INVOICE_EXPORT,
self::TYPE_COLLECTED_INVOICE_EXPORT,
], true)) {
throw new Exception('Unsupported transfer type: ' . $transfer_type);
}
return $transfer_type;
}
private function sanitizeStatuses(array $statuses): array
{
return array_values(array_unique(array_filter(array_map(static function ($status): string {
return strtoupper(trim((string)$status));
}, $statuses), static function ($status): bool {
return in_array($status, [
self::STATUS_QUEUED,
self::STATUS_PROCESSING,
self::STATUS_COMPLETED,
self::STATUS_FAILED,
], true);
})));
}
private function buildListJobsWhereClause(array $statuses = [], ?string $transfer_type = null): string
{
global $db;
$conditions = [];
$clean_statuses = $this->sanitizeStatuses($statuses);
if (!empty($clean_statuses)) {
$escaped_statuses = array_map(static function ($status) use ($db): string {
return "'" . $db->escape_string($status) . "'";
}, $clean_statuses);
$conditions[] = 'status IN (' . implode(',', $escaped_statuses) . ')';
}
if ($transfer_type !== null && trim($transfer_type) !== '') {
try {
$normalized_transfer_type = $this->validateTransferType($transfer_type);
} catch (Exception) {
return 'WHERE 1 = 0';
}
$conditions[] = "transfer_type = '" . $db->escape_string($normalized_transfer_type) . "'";
}
if (empty($conditions)) {
return '';
}
return 'WHERE ' . implode(' AND ', $conditions);
}
}
@@ -0,0 +1,220 @@
<?php
namespace classes;
/**
* Builds stable, UI-friendly summaries for collected-invoice transfer queue jobs.
*/
class economic_transfer_queue_details_summary
{
public static function buildCollectedInvoiceSummary(array $job): array
{
$payload = self::toArray($job['payload'] ?? null);
$result = self::toArray($job['result'] ?? null);
$collected_invoice_id = self::toPositiveInt(
$payload['collected_invoice_id'] ?? $job['collected_invoice_id'] ?? $job['invoice_collection_id'] ?? null
);
$summary = [
'message' => self::resolveMessage($job, $result),
'target' => [
'collected_invoice_id' => $collected_invoice_id,
'send_as_is' => self::toNullableBool($payload['send_as_is'] ?? null),
'requested_by' => self::toNonNegativeInt($payload['requested_by'] ?? $job['created_by'] ?? null),
],
'customer' => [
'customer_number' => self::toPositiveInt(
$result['customer_number']
?? $result['user']['customer_number']
?? $payload['customer_number']
?? $payload['customer']['customer_number']
?? null
),
'name' => self::toNonEmptyString(
$result['customer_name']
?? $result['user']['customer_name']
?? $result['user']['display_name']
?? $result['user']['name']
?? $result['user']['company_name']
?? $payload['customer_name']
?? $payload['customer']['customer_name']
?? $payload['customer']['display_name']
?? $payload['customer']['name']
?? null
),
],
'outcome' => [
'economic_invoice_draft_id' => self::toPositiveInt(
$result['economic_invoice_draft_id']
?? $result['draft_invoice_id']
?? null
),
'economic_invoice_booked_id' => self::toPositiveInt(
$result['economic_invoice_booked_id']
?? $result['booked_invoice_id']
?? null
),
'external_id' => self::toNonEmptyString($result['external_id'] ?? null),
'total_net_amount' => self::toNullableFloat($result['total_net_amount'] ?? null),
'order_count' => self::toOrderCount($result['orders'] ?? null),
],
'raw_available' => [
'payload' => self::hasRawValue($job['payload'] ?? null),
'result' => self::hasRawValue($job['result'] ?? null),
],
];
return $summary;
}
private static function resolveMessage(array $job, array $result): ?string
{
$error_message = self::toNonEmptyString($job['error_message'] ?? null);
if ($error_message !== null) {
return $error_message;
}
$result_message = self::toNonEmptyString($result['message'] ?? null);
if ($result_message !== null) {
return $result_message;
}
$progress_message = self::toNonEmptyString($job['progress_message'] ?? null);
if ($progress_message !== null) {
return $progress_message;
}
$status = strtoupper(trim((string)($job['status'] ?? '')));
return match ($status) {
economic_transfer_queue::STATUS_COMPLETED => 'Completed',
economic_transfer_queue::STATUS_FAILED => 'Failed',
economic_transfer_queue::STATUS_PROCESSING => 'Processing',
economic_transfer_queue::STATUS_QUEUED => 'Queued',
default => null,
};
}
private static function toArray(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (is_object($value)) {
$decoded = json_decode(json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
private static function toPositiveInt(mixed $value): ?int
{
if ($value === null || $value === '') {
return null;
}
if (!is_numeric($value)) {
return null;
}
$parsed = (int)$value;
return $parsed > 0 ? $parsed : null;
}
private static function toNonNegativeInt(mixed $value): ?int
{
if ($value === null || $value === '') {
return null;
}
if (!is_numeric($value)) {
return null;
}
$parsed = (int)$value;
return $parsed >= 0 ? $parsed : null;
}
private static function toNullableBool(mixed $value): ?bool
{
if (is_bool($value)) {
return $value;
}
if ($value === null) {
return null;
}
if (is_int($value) || is_float($value) || (is_string($value) && is_numeric($value))) {
$numeric = (int)$value;
if ($numeric === 0 || $numeric === 1) {
return $numeric === 1;
}
return null;
}
if (is_string($value)) {
$normalized = strtolower(trim($value));
if (in_array($normalized, ['true', '1'], true)) {
return true;
}
if (in_array($normalized, ['false', '0'], true)) {
return false;
}
}
return null;
}
private static function toNonEmptyString(mixed $value): ?string
{
if (!is_string($value)) {
return null;
}
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
private static function toNullableFloat(mixed $value): ?float
{
if ($value === null || $value === '') {
return null;
}
if (!is_numeric($value)) {
return null;
}
return (float)$value;
}
private static function toOrderCount(mixed $orders): ?int
{
if (!is_array($orders)) {
return null;
}
return count($orders);
}
private static function hasRawValue(mixed $value): bool
{
if ($value === null) {
return false;
}
if (is_string($value)) {
return trim($value) !== '';
}
if (is_array($value) || is_object($value)) {
return true;
}
return true;
}
}
@@ -0,0 +1,59 @@
<?php
namespace classes;
/**
* Ensures additive schema for e-conomic transfer queue jobs.
*/
class economic_transfer_queue_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
$db->query(
"CREATE TABLE IF NOT EXISTS economic_transfer_queue_jobs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
transfer_type VARCHAR(64) NOT NULL,
payload_json JSON NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'QUEUED',
progress_percent TINYINT UNSIGNED NOT NULL DEFAULT 0,
progress_message VARCHAR(255) NULL,
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 3,
error_message TEXT NULL,
result_json JSON NULL,
created_by INT NULL,
started_at DATETIME NULL,
completed_at DATETIME NULL,
next_retry_at DATETIME NULL,
locked_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_economic_transfer_queue_jobs_status_created (status, created_at),
INDEX idx_economic_transfer_queue_jobs_next_retry (next_retry_at),
INDEX idx_economic_transfer_queue_jobs_transfer_type (transfer_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS economic_transfer_queue_job_dismissals (
queue_job_id BIGINT UNSIGNED NOT NULL,
user_id INT NOT NULL,
dismissed_status VARCHAR(32) NOT NULL,
dismissed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (queue_job_id, user_id),
INDEX idx_economic_transfer_queue_job_dismissals_user_status (user_id, dismissed_status),
INDEX idx_economic_transfer_queue_job_dismissals_job (queue_job_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::$initialized = true;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,150 @@
<?php
namespace classes;
use Exception;
class edge_broker_transport_exception extends Exception
{
public function __construct(string $message, private readonly int $curlErrno = 0, int $code = 0, ?Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
public function curlErrno(): int
{
return $this->curlErrno;
}
}
class edge_broker_http_exception extends Exception
{
public function __construct(string $message, private readonly int $statusCode, int $code = 0, ?Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
public function statusCode(): int
{
return $this->statusCode;
}
}
class edge_broker_client
{
private const DEFAULT_BROKER_URL = 'http://edge-broker:4300';
public function __construct(
private readonly ?string $baseUrl = null,
private readonly ?string $sharedSecret = null,
private readonly int $timeoutSeconds = 10
) {
}
public function isConfigured(): bool
{
return trim((string)$this->resolveBaseUrl()) !== '';
}
public function dispatchCommand(int $gatewayId, string $commandType, array $payload): array
{
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/gateways/' . $gatewayId . '/commands';
$response = $this->request('POST', $url, [
'commandType' => $commandType,
'payload' => $payload,
]);
return is_array($response) ? $response : ['ok' => false, 'response' => $response];
}
public function validateAgent(int $gatewayId, string $agentToken): array
{
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/agent/auth';
$response = $this->request('POST', $url, [
'gatewayId' => $gatewayId,
'agentToken' => $agentToken,
]);
return is_array($response) ? $response : [];
}
public function validateShellSession(string $sessionToken): array
{
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/shell/auth';
$response = $this->request('POST', $url, [
'sessionToken' => $sessionToken,
]);
return is_array($response) ? $response : [];
}
public function closeShellSession(int $sessionId, string $sessionToken, string $transcript, string $closedReason): array
{
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/shell-sessions/' . $sessionId . '/close';
$response = $this->request('POST', $url, [
'sessionToken' => $sessionToken,
'transcript' => $transcript,
'closedReason' => $closedReason,
]);
return is_array($response) ? $response : [];
}
private function resolveBaseUrl(): string
{
return trim((string)($this->baseUrl ?? getenv('EDGE_BROKER_URL') ?: self::DEFAULT_BROKER_URL));
}
private function resolveSharedSecret(): string
{
return trim((string)($this->sharedSecret
?? getenv('EDGE_BROKER_SHARED_SECRET')
?: getenv('EDGE_INTERNAL_SECRET')
?: ''));
}
/**
* @throws Exception
*/
private function request(string $method, string $url, array $payload): array|object|null
{
if (trim($url) === '') {
throw new Exception('Edge broker URL is not configured');
}
$sharedSecret = $this->resolveSharedSecret();
if ($sharedSecret === '') {
throw new Exception('Edge broker shared secret is not configured');
}
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeoutSeconds);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'X-Edge-Broker-Secret: ' . $sharedSecret,
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
$rawResponse = curl_exec($ch);
$statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErrno = curl_errno($ch);
$curlError = curl_error($ch);
curl_close($ch);
if ($rawResponse === false) {
throw new edge_broker_transport_exception('Edge broker request failed: ' . $curlError, $curlErrno);
}
$decoded = json_decode((string)$rawResponse, true);
if ($statusCode >= 400) {
$message = is_array($decoded)
? (string)($decoded['error'] ?? $decoded['message'] ?? 'Edge broker request failed')
: 'Edge broker request failed';
throw new edge_broker_http_exception($message, $statusCode);
}
return $decoded;
}
}
@@ -0,0 +1,89 @@
<?php
namespace classes;
require_once WD . '/interfaces/universal_module_i.php';
require_once WD . '/modules/edgegateway/edgegateway_c.php';
use Exception;
use interfaces\universal_module_i;
use modules\edgegateway\edgegateway_c;
class edgegateway implements universal_module_i
{
public edgegateway_c $config;
public function __construct()
{
$this->config = new edgegateway_c();
}
/**
* @throws Exception
*/
public function requireModuleEnabled(): void
{
if (!$this->config->enabled->isTrue()) {
throw new Exception('The edge gateway module is not enabled');
}
}
public function isEnabled(): bool
{
try {
return $this->config->enabled->isTrue();
} catch (Exception $exception) {
return false;
}
}
public function defaultReleaseChannel(): string
{
$configured = trim((string)$this->config->default_release_channel->getVariableValue());
return $configured !== '' ? $configured : 'stable';
}
public function defaultUpdateWindow(): string
{
$configured = trim((string)$this->config->default_update_window->getVariableValue());
return $configured !== '' ? $configured : '02:00-04:00';
}
public function brokerUrl(): string
{
$configured = trim((string)$this->config->broker_url->getVariableValue());
if ($configured !== '') {
return rtrim($configured, '/');
}
$fallback = trim((string)(getenv('EDGE_BROKER_URL') ?: ''));
return $fallback !== '' ? rtrim($fallback, '/') : '';
}
public function publicBrokerUrl(): string
{
$configured = trim((string)$this->config->public_broker_url->getVariableValue());
if ($configured !== '') {
return rtrim($configured, '/');
}
$fallback = trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: ''));
return $fallback !== '' ? rtrim($fallback, '/') : '';
}
public function brokerAuthMode(): string
{
$configured = trim((string)$this->config->broker_auth_mode->getVariableValue());
return $configured !== '' ? $configured : 'manager';
}
public function brokerSharedSecret(): string
{
$configured = trim((string)$this->config->broker_shared_secret->getVariableValue());
if ($configured !== '') {
return $configured;
}
return trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
}
}
+24 -1
View File
@@ -39,6 +39,8 @@ use Psr\Http\Client\ClientExceptionInterface;
#[AllowDynamicProperties] class email implements email_i
{
public static array $fake_deliveries = [];
/**
* Configuration for the email service
* @var email_c
@@ -125,6 +127,17 @@ use Psr\Http\Client\ClientExceptionInterface;
*/
private function sendEmailMailerSend(string $to, string $recipient_name, string $subject, string $message, string $html = null, string $references = null, array $attachments = []): void
{
if (self::isFakeDeliveryEnabled()) {
self::$fake_deliveries[] = [
'to' => $to,
'recipient_name' => $recipient_name,
'subject' => $subject,
'message' => $message,
'html' => $html,
];
return;
}
// Check if the email is blacklisted
$blacklisted_emails = [
'invoice.dk@freja.com', // TODO: Make this dynamic.
@@ -209,6 +222,16 @@ use Psr\Http\Client\ClientExceptionInterface;
$this->sendEmailMailerSend($to, $recipient_name, 'Betalingslink for bestilling #' . $order_id, '', $html);
}
public static function resetFakeDeliveries(): void
{
self::$fake_deliveries = [];
}
private static function isFakeDeliveryEnabled(): bool
{
return getenv('EMAIL_FAKE_MODE') === '1';
}
/**
* Send a booking confirmation email
* @throws MailerSendException
@@ -488,4 +511,4 @@ use Psr\Http\Client\ClientExceptionInterface;
$this->attachments
);
}
}
}
@@ -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);
}
}
+17
View File
@@ -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();
}
}
+1 -16
View File
@@ -12,8 +12,6 @@ use Exception;
use forms\form_helper_c;
use forms\objects\book_interior_wash_f;
use forms\objects\book_wash_f;
use forms\objects\complete_booking_f;
use forms\objects\generate_booking_wash_certificate_f;
use objects\form_submissions_o;
use traits\form_t;
@@ -30,25 +28,12 @@ class form
* @var book_wash_f $book_wash The BOOK_WASH form
*/
public book_wash_f $book_wash;
/**
* The GENERATE_BOOKING_CERTIFICATE form
* @var generate_booking_wash_certificate_f $generate_booking_wash_certificate The GENERATE_BOOKING_CERTIFICATE form
*/
public generate_booking_wash_certificate_f $generate_booking_wash_certificate;
/**
* The COMPLETE_BOOKING_WITHOUT_WASH_CERTIFICATE form
* @var complete_booking_f $complete_booking_without_wash_certificate The COMPLETE_BOOKING_WITHOUT_WASH_CERTIFICATE form
*/
public complete_booking_f $complete_booking_without_wash_certificate;
public $last_submitted_form;
public form_submissions_o $form_submission;
public function __construct()
{
$this->book_wash = new book_wash_f();
$this->generate_booking_wash_certificate = new generate_booking_wash_certificate_f();
$this->complete_booking_without_wash_certificate = new complete_booking_f();
}
/**
@@ -109,4 +94,4 @@ class form
}
throw new Exception('The form was not found');
}
}
}
@@ -0,0 +1,137 @@
<?php
namespace classes;
require_once WD . '/interfaces/shelly_transport_i.php';
use Exception;
use interfaces\shelly_transport_i;
class gateway_shelly_transport implements shelly_transport_i
{
public function __construct(
private readonly ?edge_gateway_manager $manager = null,
private readonly bool $localOnly = false
)
{
}
public function requireModuleEnabled(): void
{
// Gateway mode is department-scoped and does not depend on the Shelly cloud module flag.
}
public function requireValidSecretKey(): void
{
// Gateway mode does not use the Shelly cloud auth key.
}
/**
* @throws Exception
*/
public function sendPostRequest(string $endpoint, array $data, ?int $department_id = null): array|object|null
{
if ($department_id === null || $department_id <= 0) {
throw new Exception('A department_id is required for gateway Shelly transport');
}
return match ($endpoint) {
'/v2/devices/api/get' => $this->handleGetStates($department_id, $data),
'/v2/devices/api/set/switch' => $this->handleSetSwitch($department_id, $data),
default => throw new Exception('Unsupported gateway Shelly transport endpoint: ' . $endpoint),
};
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
*/
private function handleGetStates(int $departmentId, array $data): array
{
$ids = array_values(array_filter(
array_map(static fn(mixed $value): string => trim((string)$value), (array)($data['ids'] ?? [])),
static fn(string $value): bool => $value !== ''
));
$result = [];
foreach ($ids as $logicalRelayId) {
$status = $this->localOnly
? $this->manager()->dispatchRelayStatusLocalOnly($departmentId, $logicalRelayId)
: $this->manager()->dispatchRelayStatus($departmentId, $logicalRelayId);
$result[] = $this->normalizeRelayPayload($logicalRelayId, $status);
}
return $result;
}
/**
* @throws Exception
*/
private function handleSetSwitch(int $departmentId, array $data): array
{
$logicalRelayId = trim((string)($data['id'] ?? ''));
if ($logicalRelayId === '') {
throw new Exception('Shelly gateway switch requests require an id');
}
$toggleAfter = $this->normalizeToggleAfter(
$data['toggle_after'] ?? $data['toggleAfter'] ?? $data['timer'] ?? null
);
$status = $this->localOnly
? $this->manager()->dispatchRelaySwitchLocalOnlyWithTimer(
$departmentId,
$logicalRelayId,
(bool)($data['on'] ?? false),
$toggleAfter
)
: $this->manager()->dispatchRelaySwitchWithTimer(
$departmentId,
$logicalRelayId,
(bool)($data['on'] ?? false),
$toggleAfter
);
return [$this->normalizeRelayPayload($logicalRelayId, $status)];
}
/**
* @param array<string,mixed> $status
* @return array<string,mixed>
*/
private function normalizeRelayPayload(string $logicalRelayId, array $status): array
{
$on = (bool)($status['on'] ?? $status['output'] ?? false);
$online = (bool)($status['online'] ?? true);
return [
'id' => $logicalRelayId,
'relay_id' => $logicalRelayId,
'online' => $online,
'on' => $on,
'status' => [
'switch:0' => [
'output' => $on,
],
],
'binding' => (array)($status['binding'] ?? []),
'execution' => (array)($status['execution'] ?? []),
'raw' => (array)($status['raw'] ?? []),
];
}
private function manager(): edge_gateway_manager
{
return $this->manager ?? new edge_gateway_manager();
}
private function normalizeToggleAfter(mixed $toggleAfter): ?int
{
if ($toggleAfter === null || $toggleAfter === '') {
return null;
}
$seconds = (int)$toggleAfter;
return $seconds > 0 ? $seconds : null;
}
}
@@ -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;
}
}
@@ -0,0 +1,64 @@
<?php
namespace classes;
/**
* Ensures additive schema for superuser invoice-period flags.
*/
class invoice_period_flag_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"CREATE TABLE IF NOT EXISTS invoice_period_flags (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
source VARCHAR(32) NOT NULL,
severity VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'active',
target_type VARCHAR(64) NOT NULL,
target_id BIGINT NOT NULL,
field VARCHAR(64) NULL,
customer_number INT NULL,
order_id BIGINT NULL,
order_item_id BIGINT NULL,
invoice_collection_id BIGINT NULL,
xlvask_usage_log_id BIGINT NULL,
definition_key VARCHAR(128) NULL,
fingerprint VARCHAR(191) NULL,
reason TEXT NULL,
status_reason TEXT NULL,
context_json JSON NULL,
created_by INT NULL,
status_changed_by INT NULL,
status_changed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_invoice_period_flags_auto_fingerprint (source, fingerprint),
KEY idx_invoice_period_flags_target (target_type, target_id, status),
KEY idx_invoice_period_flags_customer_status (customer_number, status),
KEY idx_invoice_period_flags_source_status (source, status),
KEY idx_invoice_period_flags_order (order_id),
KEY idx_invoice_period_flags_order_item (order_item_id),
KEY idx_invoice_period_flags_invoice_collection (invoice_collection_id),
KEY idx_invoice_period_flags_xlvask (xlvask_usage_log_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
products_schema_bootstrap::ensureTables();
xlvask_usage_logs_schema_bootstrap::ensureTables();
self::$initialized = true;
}
}
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -155,7 +155,7 @@ class motorapi implements motorapi_i
// Get the cached result from the log/local database/cache
$motorapi_lookups = new motorapi_lookups_o();
// Add the cached value to the meta
$response->add_meta('cached', true);
self::addCachedMetaIfPossible($response);
$cleaned_result = self::cleanJSON($motorapi_lookups->getCachedResult($licensePlate)->result->value());
$object = json_decode($cleaned_result);
if ($object === null) {
@@ -165,6 +165,13 @@ class motorapi implements motorapi_i
return json_decode($cleaned_result);
}
public static function addCachedMetaIfPossible(mixed $response): void
{
if (is_object($response) && method_exists($response, 'add_meta')) {
$response->add_meta('cached', true);
}
}
/**
* @inheritDoc
* @throws Exception If the module is not enabled, the license plate is invalid, the daily limit is exceeded, or the secret key is invalid
@@ -385,4 +392,4 @@ class motorapi implements motorapi_i
$motorapi_lookups = new motorapi_lookups_o();
$motorapi_lookups->add($licensePlate, json_encode($response), $endpoint);
}
}
}
+587
View File
@@ -0,0 +1,587 @@
<?php
namespace classes;
require_once WD . '/modules/n8n/n8n_c.php';
use Exception;
use interfaces\n8n_i;
use n8n\n8n_c;
use stdClass;
class n8n implements n8n_i
{
private const WORKFLOW_READ_ONLY_FIELDS = [
'id',
'active',
'createdAt',
'updatedAt',
'tags',
'shared',
'activeVersion',
];
public n8n_c $config;
public function __construct()
{
$this->config = new n8n_c();
}
/**
* @throws Exception
*/
public function requireModuleEnabled(): void
{
if (!$this->config->enabled->isTrue()) {
throw new Exception('The n8n module is not enabled.');
}
}
/**
* @throws Exception
*/
public function listWorkflows(array $filters = []): object
{
return $this->sendApiRequest('GET', '/workflows', $this->filterAllowed($filters, [
'active',
'tags',
'name',
'projectId',
'excludePinnedData',
'limit',
'cursor',
]));
}
/**
* @throws Exception
*/
public function getWorkflow(string $id, bool $excludePinnedData = false): object
{
$this->requireValidIdentifier($id, 'workflow id');
$query = [];
if ($excludePinnedData) {
$query['excludePinnedData'] = true;
}
return $this->sendApiRequest('GET', '/workflows/' . rawurlencode($id), $query);
}
/**
* @throws Exception
*/
public function createWorkflow(object $workflow): object
{
return $this->sendApiRequest('POST', '/workflows', [], $this->sanitizeWorkflowPayload($workflow));
}
/**
* @throws Exception
*/
public function updateWorkflow(string $id, object $changes): object
{
$this->requireValidIdentifier($id, 'workflow id');
$existing = $this->getWorkflow($id);
$merged = $this->mergeWorkflowPayload($existing, $changes);
return $this->sendApiRequest('PUT', '/workflows/' . rawurlencode($id), [], $merged);
}
/**
* @throws Exception
*/
public function publishWorkflow(string $id, ?object $options = null): object
{
$this->requireValidIdentifier($id, 'workflow id');
return $this->sendApiRequest(
'POST',
'/workflows/' . rawurlencode($id) . '/activate',
[],
$options !== null ? $this->filterPublishOptions($options) : null
);
}
/**
* @throws Exception
*/
public function deactivateWorkflow(string $id): object
{
$this->requireValidIdentifier($id, 'workflow id');
return $this->sendApiRequest('POST', '/workflows/' . rawurlencode($id) . '/deactivate');
}
/**
* @throws Exception
*/
public function runWebhook(string $webhookTarget, mixed $payload = null, string $method = 'POST', array $query = []): object
{
$this->requireModuleEnabled();
$url = $this->resolveWebhookUrl($webhookTarget);
$normalizedMethod = $this->normalizeMethod($method);
return $this->sendWebhookRequest($normalizedMethod, $url, $query, $payload);
}
/**
* @throws Exception
*/
public function listExecutions(array $filters = []): object
{
return $this->sendApiRequest('GET', '/executions', $this->filterAllowed($filters, [
'includeData',
'status',
'workflowId',
'projectId',
'limit',
'cursor',
]));
}
/**
* @throws Exception
*/
public function getExecution(int $id, bool $includeData = false): object
{
$this->requirePositiveInteger($id, 'execution id');
$query = [];
if ($includeData) {
$query['includeData'] = true;
}
return $this->sendApiRequest('GET', '/executions/' . $id, $query);
}
/**
* @throws Exception
*/
public function retryExecution(int $id, bool $loadWorkflow = false): object
{
$this->requirePositiveInteger($id, 'execution id');
$payload = null;
if ($loadWorkflow) {
$payload = (object)['loadWorkflow' => true];
}
return $this->sendApiRequest('POST', '/executions/' . $id . '/retry', [], $payload);
}
/**
* @throws Exception
*/
public function stopExecution(int $id): object
{
$this->requirePositiveInteger($id, 'execution id');
return $this->sendApiRequest('POST', '/executions/' . $id . '/stop');
}
/**
* @throws Exception
*/
private function sendApiRequest(string $method, string $path, array $query = [], ?object $body = null): object
{
$this->requireModuleEnabled();
$this->requireConfiguredApiUrl();
$this->requireConfiguredApiKey();
$url = $this->buildUrl($this->config->api_url->getVariableValue(), $path, $query);
$headers = [
'Accept: application/json',
'X-N8N-API-KEY: ' . trim((string)$this->config->api_key->getVariableValue()),
];
return $this->executeJsonRequest($method, $url, $headers, $body);
}
/**
* @throws Exception
*/
private function sendWebhookRequest(string $method, string $url, array $query = [], mixed $body = null): object
{
$headers = ['Accept: application/json'];
$payload = null;
if ($body !== null) {
$headers[] = 'Content-Type: application/json';
$payload = json_encode($body, JSON_UNESCAPED_UNICODE);
if ($payload === false) {
throw new Exception('Unable to encode n8n webhook payload as JSON.');
}
}
$response = $this->executeRequest($method, $this->buildUrl($url, '', $query), $headers, $payload);
if ($response['status'] >= 400) {
throw new Exception($this->extractErrorMessage($response['body'], $response['status'], 'Webhook request failed'));
}
$decoded = json_decode($response['body']);
if (json_last_error() === JSON_ERROR_NONE) {
if (is_object($decoded)) {
$decoded->status_code = $response['status'];
return $decoded;
}
return (object)[
'status_code' => $response['status'],
'data' => $decoded,
];
}
return (object)[
'status_code' => $response['status'],
'body' => $response['body'],
];
}
/**
* @throws Exception
*/
private function executeJsonRequest(string $method, string $url, array $headers, ?object $body = null): object
{
$payload = null;
if ($body !== null) {
$headers[] = 'Content-Type: application/json';
$payload = json_encode($body, JSON_UNESCAPED_UNICODE);
if ($payload === false) {
throw new Exception('Unable to encode n8n request body as JSON.');
}
}
$response = $this->executeRequest($method, $url, $headers, $payload);
$decoded = json_decode($response['body']);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception('Invalid JSON response from n8n (HTTP ' . $response['status'] . ').');
}
if ($response['status'] >= 400) {
throw new Exception($this->extractErrorMessage($decoded, $response['status'], 'n8n API request failed'));
}
if (is_object($decoded)) {
return $decoded;
}
return (object)[
'data' => $decoded,
];
}
/**
* @throws Exception
*/
private function executeRequest(string $method, string $url, array $headers, ?string $body = null): array
{
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
]);
if ($body !== null) {
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($curl);
$statusCode = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
$error = curl_error($curl);
curl_close($curl);
if ($error !== '') {
throw new Exception('cURL request to n8n failed: ' . $error);
}
if ($responseBody === false) {
throw new Exception('n8n request returned an empty response.');
}
return [
'status' => $statusCode,
'body' => (string)$responseBody,
];
}
private function buildUrl(string $baseUrl, string $path = '', array $query = []): string
{
$url = rtrim(trim($baseUrl), '/');
if ($path !== '') {
$url .= '/' . ltrim($path, '/');
}
$query = array_filter($query, static function (mixed $value): bool {
return $value !== null && $value !== '';
});
if ($query !== []) {
$url .= '?' . http_build_query($query);
}
return $url;
}
/**
* @throws Exception
*/
private function resolveWebhookUrl(string $target): string
{
$target = trim($target);
if ($target === '') {
throw new Exception('Webhook target must not be empty.');
}
if (filter_var($target, FILTER_VALIDATE_URL) !== false) {
return $target;
}
$baseUrl = trim((string)$this->config->webhook_base_url->getVariableValue());
if ($baseUrl === '') {
throw new Exception('n8n webhook base URL is not configured.');
}
return rtrim($baseUrl, '/') . '/' . ltrim($target, '/');
}
/**
* @throws Exception
*/
private function sanitizeWorkflowPayload(object $workflow): object
{
$payload = $this->cloneObject($workflow);
foreach (self::WORKFLOW_READ_ONLY_FIELDS as $field) {
if (property_exists($payload, $field)) {
unset($payload->{$field});
}
}
if (!property_exists($payload, 'name') || !is_string($payload->name) || trim($payload->name) === '') {
throw new Exception('Workflow name is required.');
}
if (!property_exists($payload, 'nodes') || !is_array($payload->nodes)) {
throw new Exception('Workflow nodes are required and must be an array.');
}
if (!property_exists($payload, 'connections')) {
throw new Exception('Workflow connections are required.');
}
if (!property_exists($payload, 'settings') || $payload->settings === null) {
$payload->settings = new stdClass();
}
$payload->connections = $this->normalizeObjectValue($payload->connections, 'connections');
$payload->settings = $this->normalizeObjectValue($payload->settings, 'settings');
if (property_exists($payload, 'staticData') && is_array($payload->staticData) && !array_is_list($payload->staticData)) {
$payload->staticData = $this->arrayToObject($payload->staticData);
}
return $payload;
}
private function mergeWorkflowPayload(object $existing, object $changes): object
{
$merged = $this->cloneObject($existing);
foreach (get_object_vars($changes) as $key => $value) {
if (in_array($key, self::WORKFLOW_READ_ONLY_FIELDS, true)) {
continue;
}
if (property_exists($merged, $key) && is_object($merged->{$key}) && is_object($value)) {
$merged->{$key} = $this->mergeObjects($merged->{$key}, $value);
continue;
}
$merged->{$key} = $value;
}
return $this->sanitizeWorkflowPayload($merged);
}
private function mergeObjects(object $base, object $changes): object
{
foreach (get_object_vars($changes) as $key => $value) {
if (property_exists($base, $key) && is_object($base->{$key}) && is_object($value)) {
$base->{$key} = $this->mergeObjects($base->{$key}, $value);
continue;
}
$base->{$key} = $value;
}
return $base;
}
/**
* @throws Exception
*/
private function normalizeObjectValue(mixed $value, string $field): object
{
if ($value instanceof stdClass || is_object($value)) {
return $value;
}
if (is_array($value) && !array_is_list($value)) {
return $this->arrayToObject($value);
}
if ($value === [] && $field === 'settings') {
return new stdClass();
}
throw new Exception('Workflow ' . $field . ' must be an object.');
}
private function arrayToObject(array $value): object
{
$object = new stdClass();
foreach ($value as $key => $item) {
$object->{$key} = $this->normalizeMixedValue($item);
}
return $object;
}
private function normalizeMixedValue(mixed $value): mixed
{
if (!is_array($value)) {
return $value;
}
if (array_is_list($value)) {
return array_map(fn (mixed $item): mixed => $this->normalizeMixedValue($item), $value);
}
return $this->arrayToObject($value);
}
private function cloneObject(object $value): object
{
$encoded = json_encode($value, JSON_UNESCAPED_UNICODE);
if ($encoded === false) {
return clone $value;
}
$decoded = json_decode($encoded);
return is_object($decoded) ? $decoded : clone $value;
}
private function filterPublishOptions(object $options): object
{
$filtered = new stdClass();
foreach (['versionId', 'name', 'description'] as $field) {
if (property_exists($options, $field) && $options->{$field} !== null && $options->{$field} !== '') {
$filtered->{$field} = $options->{$field};
}
}
return $filtered;
}
private function filterAllowed(array $filters, array $allowedKeys): array
{
$allowed = array_flip($allowedKeys);
$filtered = [];
foreach ($filters as $key => $value) {
if (isset($allowed[$key])) {
$filtered[$key] = $value;
}
}
return $filtered;
}
/**
* @throws Exception
*/
private function requireConfiguredApiUrl(): void
{
$url = trim((string)$this->config->api_url->getVariableValue());
if ($url === '' || filter_var($this->normalizeUrlForValidation($url), FILTER_VALIDATE_URL) === false) {
throw new Exception('Invalid n8n API URL configured.');
}
}
/**
* @throws Exception
*/
private function requireConfiguredApiKey(): void
{
if (trim((string)$this->config->api_key->getVariableValue()) === '') {
throw new Exception('Invalid n8n API key configured.');
}
}
private function normalizeUrlForValidation(string $url): string
{
if (preg_match('#^https?://#i', $url)) {
return $url;
}
return 'http://' . ltrim($url, '/');
}
/**
* @throws Exception
*/
private function requireValidIdentifier(string $value, string $label): void
{
if (trim($value) === '') {
throw new Exception('Invalid ' . $label . '.');
}
}
/**
* @throws Exception
*/
private function requirePositiveInteger(int $value, string $label): void
{
if ($value <= 0) {
throw new Exception('Invalid ' . $label . '.');
}
}
private function normalizeMethod(string $method): string
{
$normalized = strtoupper(trim($method));
if (!in_array($normalized, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], true)) {
return 'POST';
}
return $normalized;
}
private function extractErrorMessage(mixed $decoded, int $statusCode, string $fallback): string
{
if (is_object($decoded)) {
if (isset($decoded->message) && is_string($decoded->message)) {
return $decoded->message;
}
if (isset($decoded->error) && is_string($decoded->error)) {
return $decoded->error;
}
}
if (is_string($decoded) && trim($decoded) !== '') {
return $decoded;
}
return $fallback . ' (HTTP ' . $statusCode . ').';
}
}
+49 -2
View File
@@ -34,11 +34,58 @@ class openai implements openai_i
*/
public function requireModuleEnabled(): void
{
if (!(bool)$this->config->enabled->getVariableValue()) {
if (!$this->config->enabled->isTrue()) {
throw new Exception('OpenAI module is not enabled.');
}
}
/**
* Send a structured JSON text task to the OpenAI Responses API.
*
* @throws Exception
*/
public function jsonTask(string $schemaName, string $prompt, array $payload, array $schema, float $temperature = 0.1): array
{
$this->requireModuleEnabled();
$data = [
'model' => $this->model,
'input' => [
[
'role' => 'user',
'content' => [
[
'type' => 'input_text',
'text' => $prompt . "\n\nData:\n" . json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
],
],
],
],
'text' => [
'format' => [
'type' => 'json_schema',
'name' => $schemaName,
'schema' => $schema,
'strict' => true,
],
],
'temperature' => $temperature,
];
$response = $this->sendRequest($data);
$output = $response['output'][0]['content'][0]['text'] ?? null;
if (!is_string($output) || $output === '') {
throw new Exception('Invalid response format from OpenAI API. (Missing text field)');
}
$decoded = json_decode($output, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
throw new Exception('Error parsing JSON response: ' . json_last_error_msg());
}
return $decoded;
}
protected function getLPRSchema(): array
{
return [
@@ -248,4 +295,4 @@ class openai implements openai_i
//print_r($responseData);
return $responseData;
}
}
}
@@ -0,0 +1,201 @@
<?php
namespace classes;
use Throwable;
class order_bookings_counts_cache
{
public const PREFIX = 'order_bookings:counts:v1:';
/**
* Optional runtime adapter for tests.
*/
private static ?object $adapter = null;
public static function setAdapterForTests(?object $adapter): void
{
self::$adapter = $adapter;
}
public static function getTtl(): int
{
$raw = getenv('ORDER_BOOKINGS_COUNTS_CACHE_TTL');
if ($raw === false || trim((string)$raw) === '') {
return 30;
}
return max(0, (int)$raw);
}
public static function buildKey(array $context): string
{
$normalized = self::normalizeValue($context);
$encoded = json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded)) {
$encoded = serialize($normalized);
}
return self::PREFIX . md5($encoded);
}
public static function getCounts(string $key): ?array
{
$raw = self::redisGet($key);
if ($raw === null || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return null;
}
return self::normalizeCounts($decoded);
}
public static function storeCounts(string $key, array $counts, ?int $ttl = null): void
{
$cacheTtl = $ttl ?? self::getTtl();
if ($cacheTtl <= 0) {
return;
}
$normalized = self::normalizeCounts($counts);
if ($normalized === null) {
return;
}
$encoded = json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded)) {
return;
}
self::redisSetEx($key, $encoded, $cacheTtl);
}
public static function clearAll(): void
{
self::clearPattern(self::PREFIX . '*');
}
private static function normalizeCounts(array $counts): ?array
{
if (!array_key_exists('past', $counts) || !array_key_exists('current', $counts) || !array_key_exists('future', $counts)) {
return null;
}
return [
'past' => max(0, (int)$counts['past']),
'current' => max(0, (int)$counts['current']),
'future' => max(0, (int)$counts['future']),
];
}
private static function normalizeValue(mixed $value): mixed
{
if (!is_array($value)) {
return self::normalizeScalar($value);
}
$normalized = array_map([self::class, 'normalizeValue'], $value);
if (array_is_list($normalized)) {
if (self::isScalarList($normalized)) {
sort($normalized);
}
return $normalized;
}
ksort($normalized);
return $normalized;
}
private static function normalizeScalar(mixed $value): mixed
{
if (!is_string($value)) {
return $value;
}
if (preg_match('/^-?\d+$/', $value) === 1) {
return (int)$value;
}
if (is_numeric($value)) {
return (float)$value;
}
return $value;
}
private static function isScalarList(array $value): bool
{
foreach ($value as $item) {
if (is_array($item) || is_object($item)) {
return false;
}
}
return true;
}
private static function clearPattern(string $pattern): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->clear_keys($pattern);
} catch (Throwable) {
// Cache invalidation must never break request flow.
}
}
private static function redisSetEx(string $key, string $value, int $ttl): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->setEx($key, $value, $ttl);
} catch (Throwable) {
// Best-effort cache write.
}
}
private static function redisGet(string $key): ?string
{
try {
$client = self::redisClient();
if ($client === null) {
return null;
}
$value = $client->get($key);
return is_string($value) ? $value : null;
} catch (Throwable) {
return null;
}
}
private static function redisClient(): ?object
{
if (self::$adapter !== null) {
return self::$adapter;
}
try {
if (defined('redis')) {
$instance = constant('redis');
if (is_object($instance)) {
return $instance;
}
}
return (new redis())->connect();
} catch (Throwable) {
return null;
}
}
}
@@ -0,0 +1,190 @@
<?php
namespace classes;
use Throwable;
class order_bookings_list_cache
{
public const PREFIX = 'order_bookings:list:v1:';
/**
* Optional runtime adapter for tests.
*/
private static ?object $adapter = null;
public static function setAdapterForTests(?object $adapter): void
{
self::$adapter = $adapter;
}
public static function getTtl(): int
{
$raw = getenv('ORDER_BOOKINGS_LIST_CACHE_TTL');
if ($raw === false || trim((string)$raw) === '') {
return 30;
}
return max(0, (int)$raw);
}
public static function buildKey(array $context): string
{
$normalized = self::normalizeValue($context);
$encoded = json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded)) {
$encoded = serialize($normalized);
}
return self::PREFIX . md5($encoded);
}
public static function getPayload(string $key): ?array
{
$raw = self::redisGet($key);
if ($raw === null || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return null;
}
if (!array_key_exists('success', $decoded) || !array_key_exists('data', $decoded)) {
return null;
}
$decoded['meta'] = isset($decoded['meta']) && is_array($decoded['meta']) ? $decoded['meta'] : [];
$decoded['includes'] = isset($decoded['includes']) && is_array($decoded['includes']) ? $decoded['includes'] : [];
return $decoded;
}
public static function storePayload(string $key, array $payload, ?int $ttl = null): void
{
$cacheTtl = $ttl ?? self::getTtl();
if ($cacheTtl <= 0) {
return;
}
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded)) {
return;
}
self::redisSetEx($key, $encoded, $cacheTtl);
}
public static function clearAll(): void
{
self::clearPattern(self::PREFIX . '*');
}
private static function normalizeValue(mixed $value): mixed
{
if (!is_array($value)) {
return self::normalizeScalar($value);
}
$normalized = array_map([self::class, 'normalizeValue'], $value);
if (array_is_list($normalized)) {
if (self::isScalarList($normalized)) {
sort($normalized);
}
return $normalized;
}
ksort($normalized);
return $normalized;
}
private static function normalizeScalar(mixed $value): mixed
{
if (!is_string($value)) {
return $value;
}
if (preg_match('/^-?\d+$/', $value) === 1) {
return (int)$value;
}
if (is_numeric($value)) {
return (float)$value;
}
return $value;
}
private static function isScalarList(array $value): bool
{
foreach ($value as $item) {
if (is_array($item) || is_object($item)) {
return false;
}
}
return true;
}
private static function clearPattern(string $pattern): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->clear_keys($pattern);
} catch (Throwable) {
// Cache invalidation must never break request flow.
}
}
private static function redisSetEx(string $key, string $value, int $ttl): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->setEx($key, $value, $ttl);
} catch (Throwable) {
// Best-effort cache write.
}
}
private static function redisGet(string $key): ?string
{
try {
$client = self::redisClient();
if ($client === null) {
return null;
}
$value = $client->get($key);
return is_string($value) ? $value : null;
} catch (Throwable) {
return null;
}
}
private static function redisClient(): ?object
{
if (self::$adapter !== null) {
return self::$adapter;
}
try {
if (defined('redis')) {
$instance = constant('redis');
if (is_object($instance)) {
return $instance;
}
}
return (new redis())->connect();
} catch (Throwable) {
return null;
}
}
}
@@ -0,0 +1,517 @@
<?php
namespace classes;
use PDO;
class order_reference_suggestions_service
{
private const DEFAULT_LIMIT = 10;
private const MAX_LIMIT = 25;
private const MAX_SOURCE_ROWS = 500;
/**
* @var array<string, bool>
*/
private array $columnExistsCache = [];
/**
* @param array{
* search?: mixed,
* department_id?: mixed,
* customer_id?: mixed,
* reg_1?: mixed,
* reg_2?: mixed,
* reg_3?: mixed,
* limit?: mixed
* } $criteria
* @return array<int, array<string, mixed>>
*/
public function suggest(array $criteria): array
{
$departmentId = $this->toPositiveInt($criteria['department_id'] ?? null);
if ($departmentId === null) {
return [];
}
$search = $this->normalizeText($criteria['search'] ?? '');
$customerId = $this->toPositiveInt($criteria['customer_id'] ?? null);
$plates = $this->normalizePlates([
$criteria['reg_1'] ?? '',
$criteria['reg_2'] ?? '',
$criteria['reg_3'] ?? '',
]);
$limit = $this->clampLimit($criteria['limit'] ?? self::DEFAULT_LIMIT);
$rows = [
...$this->fetchBookingRows($departmentId, $search),
...$this->fetchOrderRows($departmentId, $search),
...$this->fetchVehicleRows($customerId, $plates, $search),
];
$suggestions = $this->aggregateRows($rows, $search, $customerId, $plates);
usort($suggestions, [$this, 'sortSuggestions']);
return array_slice($suggestions, 0, $limit);
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchBookingRows(int $departmentId, string $search): array
{
$where = [
'department = :department_id',
'reference IS NOT NULL',
"TRIM(reference) <> ''",
];
if ($this->tableHasColumn('order_bookings', 'deleted_at')) {
array_unshift($where, 'deleted_at IS NULL');
}
$params = ['department_id' => $departmentId];
if ($search !== '') {
$where[] = 'LOWER(reference) LIKE :search';
$params['search'] = '%' . $this->lower($search) . '%';
}
$sql = "SELECT
'booking' AS source,
id AS origin_id,
TRIM(reference) AS reference,
datetime AS source_created_at,
datetime AS used_at,
customer_number AS customer_id,
department AS department_id,
reg_1,
reg_2,
reg_3
FROM order_bookings
WHERE " . implode(' AND ', $where) . "
ORDER BY datetime DESC, id DESC
LIMIT :source_limit";
return $this->fetchRows($sql, $params);
}
/**
* @return array<int, array<string, mixed>>
*/
private function fetchOrderRows(int $departmentId, string $search): array
{
$where = [
'department_id = :department_id',
'reference IS NOT NULL',
"TRIM(reference) <> ''",
];
if ($this->tableHasColumn('orders', 'deleted_at')) {
array_unshift($where, 'deleted_at IS NULL');
}
$params = ['department_id' => $departmentId];
if ($search !== '') {
$where[] = 'LOWER(reference) LIKE :search';
$params['search'] = '%' . $this->lower($search) . '%';
}
$sql = "SELECT
'order' AS source,
id AS origin_id,
TRIM(reference) AS reference,
created_at AS source_created_at,
created_at AS used_at,
customer_id,
department_id,
reg_1,
reg_2,
reg_3
FROM orders
WHERE " . implode(' AND ', $where) . "
ORDER BY created_at DESC, id DESC
LIMIT :source_limit";
return $this->fetchRows($sql, $params);
}
/**
* @param array<int, string> $plates
* @return array<int, array<string, mixed>>
*/
private function fetchVehicleRows(?int $customerId, array $plates, string $search): array
{
$contextWhere = [];
$params = [];
if ($customerId !== null) {
$contextWhere[] = 'customer_id = :customer_id';
$params['customer_id'] = $customerId;
}
foreach ($plates as $index => $plate) {
$key = 'plate_' . $index;
$contextWhere[] = "UPPER(REPLACE(reg, ' ', '')) = :$key";
$params[$key] = $plate;
}
if ($contextWhere === []) {
return [];
}
$where = [
'reference IS NOT NULL',
"TRIM(reference) <> ''",
'(' . implode(' OR ', $contextWhere) . ')',
];
if ($this->tableHasColumn('customer_vehicles', 'deleted_at')) {
array_unshift($where, 'deleted_at IS NULL');
}
if ($search !== '') {
$where[] = 'LOWER(reference) LIKE :search';
$params['search'] = '%' . $this->lower($search) . '%';
}
$sql = "SELECT
'vehicle' AS source,
id AS origin_id,
TRIM(reference) AS reference,
created_at AS source_created_at,
created_at AS used_at,
customer_id,
NULL AS department_id,
reg AS reg_1,
'' AS reg_2,
'' AS reg_3
FROM customer_vehicles
WHERE " . implode(' AND ', $where) . "
ORDER BY created_at DESC, id DESC
LIMIT :source_limit";
return $this->fetchRows($sql, $params);
}
/**
* @param array<string, mixed> $params
* @return array<int, array<string, mixed>>
*/
private function fetchRows(string $sql, array $params): array
{
$pdo = db::getPDO();
$statement = $pdo->prepare($sql);
foreach ($params as $key => $value) {
$statement->bindValue(':' . $key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR);
}
$statement->bindValue(':source_limit', self::MAX_SOURCE_ROWS, PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
}
private function tableHasColumn(string $table, string $column): bool
{
$cacheKey = $table . '.' . $column;
if (array_key_exists($cacheKey, $this->columnExistsCache)) {
return $this->columnExistsCache[$cacheKey];
}
$pdo = db::getPDO();
$statement = $pdo->prepare(
'SELECT COUNT(*) AS total
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table_name
AND COLUMN_NAME = :column_name'
);
$statement->bindValue(':table_name', $table, PDO::PARAM_STR);
$statement->bindValue(':column_name', $column, PDO::PARAM_STR);
$statement->execute();
$this->columnExistsCache[$cacheKey] = ((int)$statement->fetchColumn()) > 0;
return $this->columnExistsCache[$cacheKey];
}
/**
* @param array<int, array<string, mixed>> $rows
* @param array<int, string> $plates
* @return array<int, array<string, mixed>>
*/
private function aggregateRows(array $rows, string $search, ?int $customerId, array $plates): array
{
$groups = [];
foreach ($rows as $row) {
$reference = $this->normalizeText($row['reference'] ?? '');
if ($reference === '') {
continue;
}
$key = $this->lower($reference);
if (!isset($groups[$key])) {
$groups[$key] = [
'reference' => $reference,
'rows' => [],
'usage_count' => 0,
'last_used_at' => null,
'context_boost' => 0,
'section' => 'other',
];
}
$section = $this->contextSection($row, $customerId, $plates);
$groups[$key]['usage_count']++;
$groups[$key]['rows'][] = $row;
$groups[$key]['last_used_at'] = $this->maxDate(
$groups[$key]['last_used_at'],
$this->normalizeDate($row['used_at'] ?? null)
);
$groups[$key]['context_boost'] = max(
$groups[$key]['context_boost'],
$this->contextBoost($row, $customerId, $plates)
);
$groups[$key]['section'] = $this->bestSection(
(string)$groups[$key]['section'],
$section
);
}
$suggestions = [];
foreach ($groups as $group) {
$bestRow = $this->bestOriginRow($group['rows']);
if ($bestRow === null) {
continue;
}
$source = (string)($bestRow['source'] ?? 'order');
$usageCount = (int)$group['usage_count'];
$score = $this->matchScore((string)$group['reference'], $search)
+ (int)$group['context_boost']
+ $this->sectionScore((string)$group['section'])
+ $this->sourceScore($source)
+ min($usageCount, 20) * 5;
$suggestions[] = [
'source' => $source,
'section' => (string)$group['section'],
'reference' => (string)$group['reference'],
'source_created_at' => $this->normalizeDate($bestRow['source_created_at'] ?? null),
'last_used_at' => $group['last_used_at'],
'usage_count' => $usageCount,
'origin_id' => (int)($bestRow['origin_id'] ?? 0),
'score' => $score,
];
}
return $suggestions;
}
/**
* @param array<int, array<string, mixed>> $rows
*/
private function bestOriginRow(array $rows): ?array
{
usort($rows, function (array $left, array $right): int {
$sourceCompare = $this->sourceScore((string)($right['source'] ?? ''))
<=> $this->sourceScore((string)($left['source'] ?? ''));
if ($sourceCompare !== 0) {
return $sourceCompare;
}
$dateCompare = strcmp(
(string)$this->normalizeDate($right['source_created_at'] ?? null),
(string)$this->normalizeDate($left['source_created_at'] ?? null)
);
if ($dateCompare !== 0) {
return $dateCompare;
}
return ((int)($right['origin_id'] ?? 0)) <=> ((int)($left['origin_id'] ?? 0));
});
return $rows[0] ?? null;
}
private function sortSuggestions(array $left, array $right): int
{
$scoreCompare = ((int)($right['score'] ?? 0)) <=> ((int)($left['score'] ?? 0));
if ($scoreCompare !== 0) {
return $scoreCompare;
}
$usageCompare = ((int)($right['usage_count'] ?? 0)) <=> ((int)($left['usage_count'] ?? 0));
if ($usageCompare !== 0) {
return $usageCompare;
}
$sectionCompare = $this->sectionScore((string)($right['section'] ?? ''))
<=> $this->sectionScore((string)($left['section'] ?? ''));
if ($sectionCompare !== 0) {
return $sectionCompare;
}
$dateCompare = strcmp((string)($right['last_used_at'] ?? ''), (string)($left['last_used_at'] ?? ''));
if ($dateCompare !== 0) {
return $dateCompare;
}
$referenceCompare = strcmp((string)($left['reference'] ?? ''), (string)($right['reference'] ?? ''));
if ($referenceCompare !== 0) {
return $referenceCompare;
}
return $this->sourceScore((string)($right['source'] ?? '')) <=> $this->sourceScore((string)($left['source'] ?? ''));
}
/**
* @param array<int, string> $plates
*/
private function contextBoost(array $row, ?int $customerId, array $plates): int
{
$score = 0;
if ($customerId !== null && (int)($row['customer_id'] ?? 0) === $customerId) {
$score += 80;
}
if ($this->rowMatchesAnyPlate($row, $plates)) {
$score += 90;
}
return $score;
}
/**
* @param array<int, string> $plates
*/
private function contextSection(array $row, ?int $customerId, array $plates): string
{
if ($this->rowMatchesAnyPlate($row, $plates)) {
return 'this_vehicle';
}
if ($customerId !== null && (int)($row['customer_id'] ?? 0) === $customerId) {
return 'other_customer_vehicle';
}
return 'other';
}
/**
* @param array<int, string> $plates
*/
private function rowMatchesAnyPlate(array $row, array $plates): bool
{
$rowPlates = $this->normalizePlates([
$row['reg_1'] ?? '',
$row['reg_2'] ?? '',
$row['reg_3'] ?? '',
]);
return $plates !== [] && array_intersect($plates, $rowPlates) !== [];
}
private function matchScore(string $reference, string $search): int
{
if ($search === '') {
return 0;
}
$referenceKey = $this->lower($reference);
$searchKey = $this->lower($search);
if ($referenceKey === $searchKey) {
return 1000;
}
if (str_starts_with($referenceKey, $searchKey)) {
return 600;
}
if (str_contains($referenceKey, $searchKey)) {
return 300;
}
return 0;
}
private function sourceScore(string $source): int
{
return match ($source) {
'booking' => 30,
'order' => 20,
'vehicle' => 10,
default => 0,
};
}
private function sectionScore(string $section): int
{
return match ($section) {
'this_vehicle' => 40,
'other_customer_vehicle' => 20,
default => 0,
};
}
private function bestSection(string $left, string $right): string
{
return $this->sectionScore($right) > $this->sectionScore($left) ? $right : $left;
}
private function clampLimit(mixed $value): int
{
$limit = $this->toPositiveInt($value) ?? self::DEFAULT_LIMIT;
return max(1, min($limit, self::MAX_LIMIT));
}
private function toPositiveInt(mixed $value): ?int
{
$parsed = filter_var($value, FILTER_VALIDATE_INT);
return is_int($parsed) && $parsed > 0 ? $parsed : null;
}
private function normalizeText(mixed $value): string
{
return trim((string)($value ?? ''));
}
private function lower(string $value): string
{
return function_exists('mb_strtolower') ? mb_strtolower($value) : strtolower($value);
}
/**
* @param array<int, mixed> $values
* @return array<int, string>
*/
private function normalizePlates(array $values): array
{
$plates = [];
foreach ($values as $value) {
$plate = strtoupper(preg_replace('/\s+/', '', (string)($value ?? '')));
if ($plate !== '') {
$plates[] = $plate;
}
}
return array_values(array_unique($plates));
}
private function normalizeDate(mixed $value): ?string
{
$date = trim((string)($value ?? ''));
return $date === '' || $date === '0000-00-00 00:00:00' ? null : $date;
}
private function maxDate(?string $left, ?string $right): ?string
{
if ($left === null) {
return $right;
}
if ($right === null) {
return $left;
}
return strcmp($right, $left) > 0 ? $right : $left;
}
}
@@ -0,0 +1,86 @@
<?php
namespace classes;
use DateTimeImmutable;
use InvalidArgumentException;
class orders_input_normalizer
{
public static function normalizeRegistrationNumber(mixed $value): string
{
if ($value === null) {
return '';
}
if (!is_scalar($value)) {
throw new InvalidArgumentException('registration number must be a string');
}
$normalized = strtoupper(trim((string)$value));
$normalized = preg_replace('/[^A-Z0-9]/', '', $normalized);
if (!is_string($normalized)) {
throw new InvalidArgumentException('registration number must be a string');
}
return $normalized;
}
public static function normalizeCreatedAt(mixed $value): string
{
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d H:i:s');
}
if (!is_string($value)) {
throw new InvalidArgumentException('created_at must be a string');
}
$trimmed = trim($value);
if ($trimmed === '') {
throw new InvalidArgumentException('created_at cannot be empty');
}
foreach (['Y-m-d H:i:s', 'Y-m-d\TH:i:s', 'Y-m-d\TH:i'] as $format) {
$parsed = DateTimeImmutable::createFromFormat($format, $trimmed);
if ($parsed instanceof DateTimeImmutable && $parsed->format($format) === $trimmed) {
return $parsed->format('Y-m-d H:i:s');
}
}
throw new InvalidArgumentException('created_at must be a valid datetime');
}
public static function normalizeIncludeInInvoice(mixed $value): ?bool
{
if ($value === null) {
return null;
}
if (is_bool($value)) {
return $value;
}
if (is_int($value)) {
if ($value === 1) {
return true;
}
if ($value === 0) {
return false;
}
}
if (is_string($value)) {
$normalized = strtolower(trim($value));
return match ($normalized) {
'', 'null', 'use_department' => null,
'1', 'true', 'include', 'included', 'yes' => true,
'0', 'false', 'exclude', 'excluded', 'no' => false,
default => throw new InvalidArgumentException('include_in_invoice must be use_department, include, or exclude'),
};
}
throw new InvalidArgumentException('include_in_invoice must be use_department, include, or exclude');
}
}
@@ -0,0 +1,146 @@
<?php
namespace classes;
/**
* Ensures additive schema for orders metadata.
*/
class orders_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
if (!self::tableExists($db, 'orders')) {
return;
}
if (!self::columnExists($db, 'orders', 'include_in_invoice')) {
$db->query(
"ALTER TABLE orders
ADD COLUMN include_in_invoice TINYINT(1) NULL DEFAULT NULL
AFTER created_at"
);
}
if (!self::columnExists($db, 'orders', 'safety_seal')) {
$db->query(
"ALTER TABLE orders
ADD COLUMN safety_seal VARCHAR(255) NULL DEFAULT NULL
AFTER po"
);
}
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);
$result = $db->query("SHOW TABLES LIKE '{$table}'");
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
return false;
}
return (int)$result->num_rows > 0;
}
private static function columnExists(object $db, string $table, string $column): bool
{
$table = self::escapeIdentifier($table);
$column = self::escapeIdentifier($column);
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
return false;
}
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);
}
}
@@ -0,0 +1,68 @@
<?php
namespace classes;
/**
* Ensures additive schema for product metadata used outside the core product form.
*/
class products_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
if (!self::tableExists($db, 'products')) {
return;
}
if (!self::columnExists($db, 'products', 'max_quantity_per_order')) {
$db->query(
"ALTER TABLE products
ADD COLUMN max_quantity_per_order INT NULL DEFAULT NULL
AFTER order_priority"
);
}
self::$initialized = true;
}
private static function tableExists(object $db, string $table): bool
{
$table = self::escapeIdentifier($table);
$result = $db->query("SHOW TABLES LIKE '{$table}'");
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
return false;
}
return (int)$result->num_rows > 0;
}
private static function columnExists(object $db, string $table, string $column): bool
{
$table = self::escapeIdentifier($table);
$column = self::escapeIdentifier($column);
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
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);
}
}
+210 -1
View File
@@ -17,8 +17,12 @@ class redis implements redis_i
}
// Apply the Redis configuration
$this->redis_host = $REDIS_CONFIG['host'];
$this->redis_user = $REDIS_CONFIG['user'] ?? '';
$this->redis_database = $REDIS_CONFIG['database'];
$this->redis_password = $REDIS_CONFIG['password'];
if (isset($REDIS_CONFIG['port']) && is_numeric($REDIS_CONFIG['port'])) {
$this->redis_port = (int)$REDIS_CONFIG['port'];
}
}
@@ -360,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
*/
@@ -450,8 +626,27 @@ class redis implements redis_i
return 'temporary_cache_' . uniqid();
}
/**
* Atomically set a key with TTL only when it does not already exist.
*/
public function set_if_absent_with_expiration(string $key, string $value, int $seconds): bool
{
if (!self::is_connected()) {
self::connect();
}
$seconds = max(1, $seconds);
$result = $this->redis->set($key, $value, 'EX', $seconds, 'NX');
return $result === true || strtoupper((string)$result) === 'OK';
}
public function mget(array $array_map): array
{
if (empty($array_map)) {
return [];
}
// Get multiple keys from Redis
return $this->redis->mget($array_map);
}
@@ -485,4 +680,18 @@ class redis implements redis_i
$this->delete('perm:' . $cache_key);
return $this;
}
}
/**
* @throws \Exception
*/
public function ping(): bool
{
if (!self::is_connected()) {
self::connect();
}
if (!self::is_connected()) {
return false;
}
return true;
}
}
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);
}
}
+79 -56
View File
@@ -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,
@@ -56,6 +62,24 @@ class response implements response_i
exit;
}
#[NoReturn] public function rawJson(mixed $data, int $status = 200): void
{
header('Content-Type: application/json; charset=utf-8');
http_response_code($status);
if (!is_array($data) && !is_object($data)) {
if (is_string($data) && json_decode($data) !== null) {
echo $data;
exit;
}
$data = ['message' => $data];
}
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
public function add_include(string $string, array $dataArray): void
{
$this->add_included($string, $dataArray);
@@ -71,6 +95,16 @@ class response implements response_i
return $this->data;
}
public function get_meta(): array
{
return $this->meta;
}
public function get_includes(): array
{
return $this->includes;
}
#[NoReturn] public function not_found(): void
{
$this->error('Not found', 404);
@@ -101,6 +135,14 @@ class response implements response_i
$this->error('Internal server error' . ($error ? ': ' . $error : ''), 500);
}
#[NoReturn] public function forbidden(array $permissions): void
{
$this->error([
'message' => 'Missing permission(s)',
'permissions' => $permissions
], 403);
}
public function paginate(int $page, int $per_page, int $total, string $search = null, array $filters = null, array $order = null): void
{
// If the total is 0, return 1 page, 0 total
@@ -139,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;
}
/**
@@ -176,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();
}
/**
@@ -201,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
@@ -278,4 +301,4 @@ class response implements response_i
// Return the user object
return $this->users_o;
}
}
}
+2 -2
View File
@@ -44,7 +44,7 @@ class router
// Try to run the routes, if there is an error, catch it and send an internal server error response
try {
$this->run();
} catch (\Exception $e) {
} catch (\Throwable $e) {
$response->internal_server_error($e->getMessage());
}
}
@@ -125,4 +125,4 @@ class router
{
return $this->routes;
}
}
}
@@ -0,0 +1,242 @@
<?php
namespace classes;
/**
* Ensures additive self-serve tables and columns exist.
*
* This project has no centralized migration runner, so the bootstrap must be
* idempotent and safe to call from runtime flows.
*/
class selfserve_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
$queries = [
"CREATE TABLE IF NOT EXISTS selfserve_config_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT',
version_number INT NOT NULL,
config_json JSON NOT NULL,
validation_result_json JSON NULL,
source_version_id INT NULL,
created_by INT NULL,
published_at DATETIME 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_config_versions_department_status (department_id, status),
INDEX idx_selfserve_config_versions_department_version (department_id, version_number)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS selfserve_machine_types (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description 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,
UNIQUE KEY uniq_selfserve_machine_types_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS selfserve_wash_sessions (
id INT AUTO_INCREMENT PRIMARY KEY,
lane_id INT NOT NULL,
department_id INT NOT NULL,
machine_type_id INT NULL,
customer_number INT NULL,
vehicle_id INT NULL,
vehicle_type_id INT NULL,
reg VARCHAR(255) NOT NULL,
status VARCHAR(64) NOT NULL DEFAULT 'PENDING_QUESTIONS',
allowed TINYINT(1) NOT NULL DEFAULT 0,
machine_relay_enabled TINYINT(1) NOT NULL DEFAULT 0,
machine_relay_enabled_at DATETIME NULL,
machine_start_triggered TINYINT(1) NOT NULL DEFAULT 0,
machine_start_triggered_at DATETIME NULL,
wash_started_at DATETIME NULL,
order_id INT NULL,
completed_at DATETIME NULL,
metadata_json JSON 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_wash_sessions_lane_reg (lane_id, reg),
INDEX idx_selfserve_wash_sessions_status (status),
INDEX idx_selfserve_wash_sessions_customer (customer_number),
INDEX idx_selfserve_wash_sessions_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS selfserve_wash_session_answers (
id INT AUTO_INCREMENT PRIMARY KEY,
session_id INT NOT NULL,
question_id INT NOT NULL,
question_text VARCHAR(255) NOT NULL,
answer_value TINYINT(1) NOT NULL,
answered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
UNIQUE KEY uniq_selfserve_wash_session_answer (session_id, question_id),
INDEX idx_selfserve_wash_session_answers_session (session_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS selfserve_wash_session_tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
session_id INT NOT NULL,
task_id INT NULL,
task_text VARCHAR(255) NOT NULL,
description VARCHAR(255) NULL,
services JSON NULL,
buttons JSON NULL,
dynamic_image_id INT 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_wash_session_tasks_session (session_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS selfserve_wash_session_events (
id INT AUTO_INCREMENT PRIMARY KEY,
session_id INT NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_selfserve_wash_session_events_session (session_id),
INDEX idx_selfserve_wash_session_events_type (event_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS department_selfserve_studio_layouts (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NOT NULL,
user_id INT NULL,
layout_json JSON NOT 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_department_selfserve_studio_layouts_department_user (department_id, user_id),
INDEX idx_department_selfserve_studio_layouts_department_updated (department_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS department_selfserve_studio_virtual_hardware (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NOT NULL,
config_json JSON NOT NULL,
created_by INT NULL,
updated_by INT 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,
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) {
$db->query($sql);
}
self::ensureColumn(
'department_lanes',
'machine_type_id',
'ALTER TABLE department_lanes ADD COLUMN machine_type_id INT NULL AFTER dynamic_image_id'
);
self::ensureColumn(
'department_lanes',
'selfserve_enabled',
'ALTER TABLE department_lanes ADD COLUMN selfserve_enabled TINYINT(1) NOT NULL DEFAULT 1 AFTER machine_type_id'
);
self::ensureColumn(
'department_selfserve_conditions',
'machine_type_id',
'ALTER TABLE department_selfserve_conditions ADD COLUMN machine_type_id INT NULL AFTER product'
);
self::ensureColumn(
'department_selfserve_tasks',
'machine_type_id',
'ALTER TABLE department_selfserve_tasks ADD COLUMN machine_type_id INT NULL AFTER product'
);
self::ensureColumn(
'department_selfserve_tasks',
'gate_type',
"ALTER TABLE department_selfserve_tasks ADD COLUMN gate_type VARCHAR(16) NULL DEFAULT 'ALWAYS' AFTER condition_id"
);
self::ensureColumn(
'department_selfserve_tasks',
'gate_ref_id',
'ALTER TABLE department_selfserve_tasks ADD COLUMN gate_ref_id INT NULL AFTER gate_type'
);
self::ensureColumn(
'selfserve_wash_session_tasks',
'dynamic_images_vehicle_type',
'ALTER TABLE selfserve_wash_session_tasks ADD COLUMN dynamic_images_vehicle_type INT NULL AFTER buttons'
);
self::ensureColumn(
'selfserve_wash_sessions',
'wash_started_at',
'ALTER TABLE selfserve_wash_sessions ADD COLUMN wash_started_at DATETIME NULL AFTER machine_start_triggered_at'
);
self::$initialized = true;
}
public static function tableHasColumn(string $table, string $column): bool
{
global $db;
$table = $db->escape_string($table);
$column = $db->escape_string($column);
$database = $db->escape_string($db->getDatabase());
$sql = "SELECT COUNT(*) AS c
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = '$database'
AND TABLE_NAME = '$table'
AND COLUMN_NAME = '$column'";
$result = $db->query($sql);
if (!$result) {
return false;
}
$row = $result->fetch_assoc();
return ((int)($row['c'] ?? 0)) > 0;
}
public static function ensureColumn(string $table, string $column, string $alterSql): void
{
global $db;
if (self::tableHasColumn($table, $column)) {
return;
}
$db->query($alterSql);
}
}
+191 -4
View File
@@ -13,6 +13,14 @@ use shelly\shelly_c;
class shelly implements shelly_i
{
private const SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS = 20;
private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;
private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate';
/**
* @var array<int,array<string,mixed>>
*/
private static array $blocked_request_log = [];
/**
* Configuration of the shelly module
* @var shelly_c
@@ -34,6 +42,19 @@ class shelly implements shelly_i
$this->shelly_search = new shelly_search_a();
}
public static function resetBlockedRequestLog(): void
{
self::$blocked_request_log = [];
}
/**
* @return array<int,array<string,mixed>>
*/
public static function blockedRequestLog(): array
{
return self::$blocked_request_log;
}
/**
* @inheritDoc
* @throws Exception If the module is not enabled, the license plate is invalid, the daily limit is exceeded, or the secret key is invalid
@@ -50,7 +71,7 @@ class shelly implements shelly_i
self::requireValidSecretKey();
// Send the request
$response = match ($method) {
//'GET' => self::sendGetRequest($endpoint, $data),
'GET' => self::sendGetRequest($endpoint, $data),
'POST' => self::sendPostRequest($endpoint, $data),
//'PUT' => self::sendPutRequest($endpoint, $data),
//'DELETE' => self::sendDeleteRequest($endpoint, $data),
@@ -142,10 +163,12 @@ class shelly implements shelly_i
* -H 'Content-Type: application/json' \
* -d '<BODY>'
*/
$this->guardRealShellyRequest('POST', $endpoint, $data);
// Require the module to be enabled
self::requireModuleEnabled();
self::requireValidSecretKey();
self::requireValidServerURL();
$this->waitForShellyRateLimitWindow();
// Send the request
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, self::appendAuthKeyToQuery($this->config->server_url->getVariableValue() . $endpoint));
@@ -181,8 +204,172 @@ class shelly implements shelly_i
return json_decode($response);
}
function appendAuthKeyToQuery(string $url): string
/**
* @inheritDoc
* @throws Exception
*/
function sendGetRequest(string $endpoint, array $data): array|object|null
{
return $url . '?auth_key=' . $this->config->secret_key->getVariableValue();
$this->guardRealShellyRequest('GET', $endpoint, $data);
self::requireModuleEnabled();
self::requireValidSecretKey();
self::requireValidServerURL();
$this->waitForShellyRateLimitWindow();
$ch = curl_init();
curl_setopt(
$ch,
CURLOPT_URL,
self::appendAuthKeyToQuery($this->config->server_url->getVariableValue() . $endpoint, $data)
);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPGET, true);
$response = curl_exec($ch);
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
self::exception(
[
'method' => 'GET',
'endpoint' => $endpoint,
'data' => $data,
'response' => $response,
],
$status_code
);
}
curl_close($ch);
if ($response === false) {
return null;
}
return json_decode($response);
}
}
function appendAuthKeyToQuery(string $url, array $query = []): string
{
$query['auth_key'] = $this->config->secret_key->getVariableValue();
$separator = str_contains($url, '?') ? '&' : '?';
return $url . $separator . http_build_query($query);
}
/**
* @param array<string,mixed> $data
* @throws Exception
*/
private function guardRealShellyRequest(string $method, string $endpoint, array $data): void
{
if (!$this->shouldBlockRealShellyRequest()) {
return;
}
$record = [
'method' => strtoupper($method),
'endpoint' => $endpoint,
'data' => $data,
'at' => date('c'),
];
self::$blocked_request_log[] = $record;
$log_path = trim((string)(getenv('TRUCKWASH_TEST_SHELLY_GUARD_LOG') ?: ''));
if ($log_path !== '') {
$encoded = json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (is_string($encoded)) {
@file_put_contents($log_path, $encoded . PHP_EOL, FILE_APPEND | LOCK_EX);
}
}
throw new Exception('Real Shelly requests are blocked in test mode: ' . strtoupper($method) . ' ' . $endpoint);
}
private function shouldBlockRealShellyRequest(): bool
{
return trim((string)(getenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY') ?: '')) === '1';
}
/**
* @throws Exception
*/
private function waitForShellyRateLimitWindow(): void
{
$deadline = $this->nowTimestamp() + self::SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS;
do {
if ($this->tryAcquireShellyRateLimitSlot()) {
return;
}
if ($this->nowTimestamp() >= $deadline) {
throw new Exception('Shelly rate limit gate wait timed out');
}
$remaining_ms = $this->getShellyRateLimitSlotRemainingMs();
if ($remaining_ms <= 0) {
$remaining_ms = 50;
}
$this->sleepMicroseconds(min($remaining_ms, 250) * 1000);
} while (true);
}
private function tryAcquireShellyRateLimitSlot(): bool
{
$redis = $this->redisFacade();
if ($redis === null) {
return true;
}
try {
$result = $redis->get_client()->set(
self::SHELLY_RATE_LIMIT_GATE_KEY,
(string)$this->nowTimestamp(),
'PX',
self::SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS,
'NX'
);
return $result === true || strtoupper((string)$result) === 'OK';
} catch (\Throwable) {
// If Redis gate can't be evaluated, fail open to avoid blocking API traffic completely.
return true;
}
}
private function getShellyRateLimitSlotRemainingMs(): int
{
$redis = $this->redisFacade();
if ($redis === null) {
return 0;
}
try {
$ttl = $redis->get_client()->pttl(self::SHELLY_RATE_LIMIT_GATE_KEY);
if (!is_numeric($ttl)) {
return 0;
}
$ttl = (int)$ttl;
return $ttl > 0 ? $ttl : 0;
} catch (\Throwable) {
return 0;
}
}
protected function redisFacade(): mixed
{
return defined('redis') ? redis : null;
}
protected function nowTimestamp(): float
{
return microtime(true);
}
protected function sleepMicroseconds(int $microseconds): void
{
if ($microseconds > 0) {
usleep($microseconds);
}
}
}
@@ -0,0 +1,619 @@
<?php
namespace classes;
use Exception;
class shelly_relay_inventory
{
private ?shelly $client;
/** @var callable|null */
private $inventory_fetcher = null;
/** @var callable|null */
private $device_list_fetcher = null;
public function __construct(?shelly $client = null)
{
$this->client = $client;
}
public function setInventoryFetcher(callable $fetcher): self
{
$this->inventory_fetcher = $fetcher;
return $this;
}
public function setDeviceListFetcher(callable $fetcher): self
{
$this->device_list_fetcher = $fetcher;
return $this;
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
*/
public function listRelayOptions(): array
{
$devices_status = $this->fetchOwnedDevicesStatus();
$device_catalog = $this->fetchOwnedDeviceCatalog();
$options_by_id = [];
foreach ($devices_status as $device) {
$normalized_device = $this->normalizeToArray($device);
$device_id = $this->extractFirstString([
$normalized_device['_dev_info']['id'] ?? null,
$normalized_device['id'] ?? null,
]);
$catalog_entry = $device_id !== '' ? ($device_catalog[$device_id] ?? null) : null;
$option = $this->buildRelayOption(
$normalized_device,
is_array($catalog_entry) ? $catalog_entry : null
);
if ($option === null) {
continue;
}
$options_by_id[$option['id']] = $option;
}
$options = array_values($options_by_id);
usort($options, static function (array $left, array $right): int {
$status_compare = self::statusSortWeight((string)($left['status_color'] ?? ''))
<=> self::statusSortWeight((string)($right['status_color'] ?? ''));
if ($status_compare !== 0) {
return $status_compare;
}
$name_compare = strcasecmp((string)($left['name'] ?? ''), (string)($right['name'] ?? ''));
if ($name_compare !== 0) {
return $name_compare;
}
return strcmp((string)($left['id'] ?? ''), (string)($right['id'] ?? ''));
});
return $options;
}
/**
* @return array<string,array<string,mixed>>
*/
private function fetchOwnedDeviceCatalog(): array
{
try {
$payload = is_callable($this->device_list_fetcher)
? ($this->device_list_fetcher)()
: $this->getClient()->sendGetRequest('/interface/device/list', [
'no_shared' => 'true',
]);
} catch (\Throwable) {
return [];
}
$normalized = $this->normalizeToArray($payload);
if (($normalized['isok'] ?? true) === false) {
return [];
}
$devices = $normalized['data']['devices'] ?? null;
if (!is_array($devices)) {
return [];
}
$catalog_by_id = [];
foreach ($devices as $device_key => $device) {
$normalized_device = $this->normalizeToArray($device);
$device_id = $this->extractFirstString([
$normalized_device['id'] ?? null,
is_string($device_key) ? $device_key : null,
]);
if ($device_id === '') {
continue;
}
$catalog_by_id[$device_id] = $normalized_device;
}
return $catalog_by_id;
}
/**
* @return array<string,mixed>
* @throws Exception
*/
private function fetchOwnedDevicesStatus(): array
{
$payload = is_callable($this->inventory_fetcher)
? ($this->inventory_fetcher)()
: $this->getClient()->sendGetRequest('/device/all_status', [
'show_info' => 'true',
'no_shared' => 'true',
]);
$normalized = $this->normalizeToArray($payload);
$is_ok = $normalized['isok'] ?? null;
if ($is_ok === false) {
throw new Exception('Shelly relay inventory request failed');
}
$devices_status = $normalized['data']['devices_status'] ?? null;
if (!is_array($devices_status)) {
throw new Exception('Shelly relay inventory response was missing devices_status');
}
return $devices_status;
}
private function getClient(): shelly
{
if ($this->client instanceof shelly) {
return $this->client;
}
$this->client = new shelly();
return $this->client;
}
/**
* @param array<string,mixed> $device
* @return array<string,mixed>|null
*/
private function buildRelayOption(array $device, ?array $catalog_entry = null): ?array
{
if ($device === [] || !$this->isRelayCapableDevice($device)) {
return null;
}
$device_id = $this->extractFirstString([
$device['_dev_info']['id'] ?? null,
$device['id'] ?? null,
]);
if ($device_id === '') {
return null;
}
$cloud_name = $this->extractFirstString([
$catalog_entry['name'] ?? null,
]);
$local_device_name = $this->extractFirstString([
$device['name'] ?? null,
$device['_dev_info']['name'] ?? null,
$device['settings']['name'] ?? null,
$device['settings']['device']['name'] ?? null,
$device['status']['name'] ?? null,
$device['status']['sys']['device']['name'] ?? null,
]);
$device_name = $cloud_name !== '' ? $cloud_name : $local_device_name;
$device_code = $this->extractFirstString([
$device['_dev_info']['code'] ?? null,
$device['code'] ?? null,
]);
$device_model = $this->extractDeviceModel($device, $device_code, $catalog_entry);
if ($device_code === '' && $device_model !== null) {
$device_code = $device_model;
}
$device_type = $this->extractDeviceType($device, $device_code, $catalog_entry);
$device_generation = $this->extractDeviceGeneration($device, $device_code, $device_type, $catalog_entry);
$control_type = $this->extractControlType($device);
$control_name = $this->extractControlName($device);
$online = $this->extractOnlineState($device);
if ($online === null) {
$online = $this->normalizeBoolean($catalog_entry['cloud_online'] ?? null);
}
$status_color = $this->extractStatusColor($online);
$local_ip = $this->extractLocalIp($device, $catalog_entry);
return [
'id' => $device_id,
'name' => $this->buildRelayLabel(
$device_type,
$cloud_name,
$local_device_name,
$control_name,
$device_id
),
'device_id' => $device_id,
'device_name' => $device_name !== '' ? $device_name : null,
'cloud_name' => $cloud_name !== '' ? $cloud_name : null,
'device_type' => $device_type,
'code' => $device_code !== '' ? $device_code : null,
'device_model' => $device_model,
'device_generation' => $device_generation,
'control_type' => $control_type,
'control_name' => $control_name !== '' ? $control_name : null,
'local_ip' => $local_ip,
'status_color' => $status_color,
'online' => $online,
];
}
/**
* @param array<string,mixed> $device
*/
private function isRelayCapableDevice(array $device): bool
{
if ($this->payloadContainsRelayState($device)) {
return true;
}
if ($this->payloadContainsRelayState($device['status'] ?? null)) {
return true;
}
return $this->payloadContainsRelayState($device['settings'] ?? null);
}
private function payloadContainsRelayState(array|object|null $payload): bool
{
$normalized = $this->normalizeToArray($payload);
if ($normalized === []) {
return false;
}
foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) {
if (array_key_exists($switch_key, $normalized)) {
return true;
}
}
foreach (['relays', 'switches'] as $collection_key) {
if (isset($normalized[$collection_key]) && is_array($normalized[$collection_key]) && $normalized[$collection_key] !== []) {
return true;
}
}
return false;
}
/**
* @param array<string,mixed> $device
*/
private function extractOnlineState(array $device): ?bool
{
$online = $device['_dev_info']['online'] ?? $device['online'] ?? null;
if (is_bool($online)) {
return $online;
}
if (is_numeric($online)) {
return (int)$online === 1;
}
return null;
}
private function extractStatusColor(?bool $online): string
{
if ($online === true) {
return 'Green';
}
if ($online === false) {
return 'Red';
}
return 'Yellow';
}
/**
* @param array<string,mixed> $device
*/
private function extractDeviceType(array $device, string $device_code, ?array $catalog_entry = null): ?string
{
$candidates = [
$device['_dev_info']['type'] ?? null,
$device['type'] ?? null,
$device['settings']['device']['type'] ?? null,
$device['_dev_info']['model'] ?? null,
$device['model'] ?? null,
$device['_dev_info']['app'] ?? null,
$device['app'] ?? null,
$catalog_entry['type'] ?? null,
$device_code,
];
foreach ($candidates as $candidate) {
$normalized = trim((string)($candidate ?? ''));
if ($normalized !== '' && !$this->looksLikeShellyModelCode($normalized)) {
return $normalized;
}
}
$device_type = $this->extractFirstString($candidates);
return $device_type !== '' ? $device_type : null;
}
/**
* @param array<string,mixed> $device
*/
private function extractDeviceModel(array $device, string $device_code, ?array $catalog_entry = null): ?string
{
$candidates = [
$device_code,
$device['_dev_info']['model'] ?? null,
$device['model'] ?? null,
$catalog_entry['type'] ?? null,
$catalog_entry['model'] ?? null,
$device['_dev_info']['type'] ?? null,
$device['type'] ?? null,
];
foreach ($candidates as $candidate) {
$normalized = trim((string)($candidate ?? ''));
if ($normalized !== '' && $this->looksLikeShellyModelCode($normalized)) {
return $normalized;
}
}
return null;
}
/**
* @param array<string,mixed> $device
*/
private function extractDeviceGeneration(
array $device,
string $device_code,
?string $device_type,
?array $catalog_entry = null
): ?int {
foreach ([
$device['_dev_info']['gen'] ?? null,
$device['_dev_info']['generation'] ?? null,
$device['gen'] ?? null,
$device['generation'] ?? null,
$device['settings']['device']['gen'] ?? null,
$device['settings']['device']['generation'] ?? null,
$device['status']['sys']['gen'] ?? null,
$device['status']['sys']['generation'] ?? null,
$catalog_entry['gen'] ?? null,
$catalog_entry['generation'] ?? null,
] as $candidate) {
$generation = $this->normalizeDeviceGeneration($candidate);
if ($generation !== null) {
return $generation;
}
}
foreach ([
$device_code,
$device_type,
$device['_dev_info']['model'] ?? null,
$device['_dev_info']['type'] ?? null,
$device['_dev_info']['app'] ?? null,
$device['model'] ?? null,
$device['type'] ?? null,
$device['app'] ?? null,
$catalog_entry['type'] ?? null,
$catalog_entry['model'] ?? null,
] as $candidate) {
$generation = $this->inferDeviceGenerationFromString((string)($candidate ?? ''));
if ($generation !== null) {
return $generation;
}
}
return null;
}
private function normalizeDeviceGeneration(mixed $value): ?int
{
if (is_int($value)) {
return $value > 0 ? $value : null;
}
if (is_numeric($value)) {
$generation = (int)$value;
return $generation > 0 ? $generation : null;
}
return $this->inferDeviceGenerationFromString((string)($value ?? ''));
}
private function inferDeviceGenerationFromString(string $value): ?int
{
$normalized = trim($value);
if ($normalized === '') {
return null;
}
if (preg_match('/\bgen(?:eration)?\s*([1-9]\d*)\b/i', $normalized, $matches) === 1) {
return (int)$matches[1];
}
$upper = strtoupper($normalized);
if (preg_match('/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/', $upper, $matches) === 1) {
return (int)$matches[1];
}
if (preg_match('/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i', $normalized) === 1
|| preg_match('/\bSP[A-Z0-9]+-[A-Z0-9-]+\b/', $upper) === 1) {
return 2;
}
if (preg_match('/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/', $upper) === 1) {
return 1;
}
return null;
}
private function looksLikeShellyModelCode(string $value): bool
{
$normalized = strtoupper(trim($value));
if ($normalized === '') {
return false;
}
return preg_match('/^(?:S[1-9]|SP|SN|SH)[A-Z0-9]+-[A-Z0-9-]+$/', $normalized) === 1;
}
/**
* @param array<string,mixed> $device
*/
private function extractControlType(array $device): string
{
$status = $this->normalizeToArray($device['status'] ?? null);
$settings = $this->normalizeToArray($device['settings'] ?? null);
foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) {
if (array_key_exists($switch_key, $device) || array_key_exists($switch_key, $status)) {
return 'Switch';
}
}
if ((isset($device['switches']) && is_array($device['switches']) && $device['switches'] !== [])
|| (isset($settings['switches']) && is_array($settings['switches']) && $settings['switches'] !== [])) {
return 'Switch';
}
if ((isset($device['relays']) && is_array($device['relays']) && $device['relays'] !== [])
|| (isset($settings['relays']) && is_array($settings['relays']) && $settings['relays'] !== [])) {
return 'Relay';
}
return 'Device';
}
/**
* @param array<string,mixed> $device
*/
private function extractControlName(array $device): string
{
$status = $this->normalizeToArray($device['status'] ?? null);
$settings = $this->normalizeToArray($device['settings'] ?? null);
return $this->extractFirstString([
$status['switch:0']['name'] ?? null,
$status['switch_0']['name'] ?? null,
$status['switch0']['name'] ?? null,
$device['switches'][0]['name'] ?? null,
$settings['switches'][0]['name'] ?? null,
$device['relays'][0]['name'] ?? null,
$settings['relays'][0]['name'] ?? null,
]);
}
/**
* @param array<string,mixed> $device
* @param array<string,mixed>|null $catalog_entry
*/
private function extractLocalIp(array $device, ?array $catalog_entry = null): ?string
{
$candidates = [
$device['local_ip'] ?? null,
$device['localIp'] ?? null,
$device['ip'] ?? null,
$device['_dev_info']['local_ip'] ?? null,
$device['_dev_info']['localIp'] ?? null,
$device['_dev_info']['ip'] ?? null,
$device['wifi_sta']['ip'] ?? null,
$device['wifi']['ip'] ?? null,
$device['eth']['ip'] ?? null,
$device['status']['wifi_sta']['ip'] ?? null,
$device['status']['wifi']['ip'] ?? null,
$device['status']['eth']['ip'] ?? null,
$device['status']['sta_ip'] ?? null,
$device['settings']['wifi_sta']['ip'] ?? null,
$device['settings']['wifi']['ip'] ?? null,
$device['settings']['eth']['ip'] ?? null,
$catalog_entry['local_ip'] ?? null,
$catalog_entry['localIp'] ?? null,
$catalog_entry['ip'] ?? null,
];
foreach ($candidates as $candidate) {
$normalized = trim((string)($candidate ?? ''));
if ($normalized !== '' && filter_var($normalized, FILTER_VALIDATE_IP) !== false) {
return $normalized;
}
}
return null;
}
private function buildRelayLabel(
?string $device_type,
string $cloud_name,
string $local_device_name,
string $control_name,
string $device_id
): string {
if ($cloud_name !== '') {
$display_name = $cloud_name;
} elseif ($local_device_name !== '' && $control_name !== '' && strcasecmp($local_device_name, $control_name) !== 0) {
$display_name = $local_device_name . ' / ' . $control_name;
} else {
$display_name = $control_name !== '' ? $control_name : ($local_device_name !== '' ? $local_device_name : $device_id);
}
if ($device_type !== null && $device_type !== '') {
return $display_name . ' (' . $device_type . ')';
}
return $display_name;
}
/**
* @param array<int,mixed> $values
*/
private function extractFirstString(array $values): string
{
foreach ($values as $value) {
$normalized = trim((string)($value ?? ''));
if ($normalized !== '') {
return $normalized;
}
}
return '';
}
private function normalizeBoolean(mixed $value): ?bool
{
if (is_bool($value)) {
return $value;
}
if (is_numeric($value)) {
return (int)$value === 1;
}
return null;
}
private static function statusSortWeight(string $status_color): int
{
return match (strtolower($status_color)) {
'green' => 0,
'yellow' => 1,
'red' => 2,
default => 3,
};
}
/**
* @return array<string,mixed>
*/
private function normalizeToArray(array|object|null $payload): array
{
if (is_array($payload)) {
return $payload;
}
if (is_object($payload)) {
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE);
if (!is_string($encoded)) {
return [];
}
$decoded = json_decode($encoded, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
}
@@ -0,0 +1,48 @@
<?php
namespace classes;
require_once WD . '/interfaces/shelly_transport_i.php';
require_once WD . '/classes/cloud_shelly_transport.php';
require_once WD . '/classes/gateway_shelly_transport.php';
use interfaces\shelly_transport_i;
class shelly_transport_resolver
{
public function __construct(
private readonly ?edge_gateway_manager $edgeGatewayManager = null,
private readonly ?shelly_transport_i $cloudTransport = null,
private readonly ?shelly_transport_i $gatewayTransport = null
) {
}
public function resolveForDepartment(int $departmentId, ?string $transportOverride = null): shelly_transport_i
{
$override = strtolower(trim((string)$transportOverride));
$explicitLocalOverride = in_array($override, ['local', edge_gateway_manager::TRANSPORT_MODE_GATEWAY], true);
if ($override === 'local') {
$override = edge_gateway_manager::TRANSPORT_MODE_GATEWAY;
}
if ($override === edge_gateway_manager::TRANSPORT_MODE_GATEWAY) {
return $this->gatewayTransport ?? new gateway_shelly_transport($this->manager(), $explicitLocalOverride);
}
if ($override === edge_gateway_manager::TRANSPORT_MODE_CLOUD) {
return $this->cloudTransport ?? new cloud_shelly_transport();
}
$mode = $this->manager()->getDepartmentTransportMode($departmentId);
if ($mode === edge_gateway_manager::TRANSPORT_MODE_GATEWAY) {
return $this->gatewayTransport ?? new gateway_shelly_transport($this->manager());
}
return $this->cloudTransport ?? new cloud_shelly_transport();
}
private function manager(): edge_gateway_manager
{
return $this->edgeGatewayManager ?? new edge_gateway_manager();
}
}
+15 -1
View File
@@ -10,6 +10,7 @@ require_once WD . '/modules/stripe/endpoints/stripe_endpoint_prices.php';
require_once WD . '/modules/stripe/endpoints/stripe_endpoint_invoice.php';
require_once WD . '/modules/stripe/endpoints/stripe_endpoint_readers.php';
require_once WD . '/modules/stripe/endpoints/stripe_endpoint_payment_intents.php';
require_once WD . '/classes/stripe_fake_http_client.php';
// Require all helper classes
@@ -29,6 +30,7 @@ use stripe\endpoints\stripe_endpoint_prices;
use stripe\endpoints\stripe_endpoint_product;
use stripe\endpoints\stripe_endpoint_readers;
use stripe\stripe_c;
use Stripe\ApiRequestor;
use Stripe\StripeClient;
/**
@@ -104,6 +106,13 @@ class stripe implements stripe_i
{
// Get the stripe client
if (!isset($this->client)) {
if (self::isFakeModeEnabled()) {
ApiRequestor::setHttpClient(new stripe_fake_http_client());
$this->client = new StripeClient([
'api_key' => 'sk_test_fake'
]);
return $this->client;
}
// Require the module to be enabled
self::requireModuleEnabled();
// Require the secret key to be set
@@ -118,6 +127,11 @@ class stripe implements stripe_i
return $this->client;
}
private static function isFakeModeEnabled(): bool
{
return getenv('STRIPE_FAKE_MODE') === '1';
}
/**
* @inheritDoc
* @throws Exception
@@ -153,4 +167,4 @@ class stripe implements stripe_i
throw new Exception('Invalid publishable key');
}
}
}
}
@@ -0,0 +1,268 @@
<?php
namespace classes;
use Stripe\HttpClient\ClientInterface;
class stripe_fake_http_client implements ClientInterface
{
private const DEFAULT_STATE = [
'next_customer' => 1,
'next_price' => 1,
'next_invoice' => 1,
'next_invoice_item' => 1,
'customers' => [],
'prices' => [],
'products' => [],
'invoice_items' => [],
'invoices' => [],
];
public static function resetStore(): void
{
self::writeStore(self::DEFAULT_STATE);
}
public static function setInvoiceState(string $invoiceId, array $attributes): void
{
$store = self::readStore();
$invoice = $store['invoices'][$invoiceId] ?? null;
if (!$invoice) {
return;
}
$store['invoices'][$invoiceId] = [
...$invoice,
...$attributes,
];
self::writeStore($store);
}
public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode = 'v1')
{
$path = (string)parse_url((string)$absUrl, PHP_URL_PATH);
$store = self::readStore();
if ($method === 'post' && $path === '/v1/customers') {
$customerId = sprintf('cus_fake_%06d', (int)$store['next_customer']);
$store['next_customer']++;
$customer = [
'id' => $customerId,
'object' => 'customer',
'email' => (string)($params['email'] ?? ''),
];
$store['customers'][$customerId] = $customer;
self::writeStore($store);
return $this->jsonResponse($customer);
}
if ($method === 'get' && preg_match('#^/v1/customers/(?P<id>[^/]+)$#', $path, $matches)) {
$customer = $store['customers'][$matches['id']] ?? null;
if (!$customer) {
return $this->errorResponse(404, 'resource_missing', 'No such customer.');
}
return $this->jsonResponse($customer);
}
if ($method === 'get' && preg_match('#^/v1/products/(?P<id>[^/]+)$#', $path, $matches)) {
$product = $store['products'][$matches['id']] ?? null;
if (!$product) {
return $this->errorResponse(404, 'resource_missing', 'No such product.');
}
return $this->jsonResponse($product);
}
if ($method === 'post' && $path === '/v1/products') {
$productId = (string)($params['id'] ?? sprintf('prod_fake_%06d', count($store['products']) + 1));
$product = [
'id' => $productId,
'object' => 'product',
'name' => (string)($params['name'] ?? 'Fake Product'),
];
$store['products'][$productId] = $product;
self::writeStore($store);
return $this->jsonResponse($product);
}
if ($method === 'post' && $path === '/v1/prices') {
$priceId = sprintf('price_fake_%06d', (int)$store['next_price']);
$store['next_price']++;
$price = [
'id' => $priceId,
'object' => 'price',
'product' => (string)($params['product'] ?? ''),
'unit_amount' => (int)($params['unit_amount'] ?? 0),
'currency' => strtolower((string)($params['currency'] ?? 'dkk')),
];
$store['prices'][$priceId] = $price;
self::writeStore($store);
return $this->jsonResponse($price);
}
if ($method === 'post' && $path === '/v1/invoices') {
$invoiceId = sprintf('in_fake_%06d', (int)$store['next_invoice']);
$store['next_invoice']++;
$invoice = [
'id' => $invoiceId,
'object' => 'invoice',
'customer' => (string)($params['customer'] ?? ''),
'status' => 'draft',
'paid' => false,
'amount_due' => 0,
'amount_paid' => 0,
'collection_method' => (string)($params['collection_method'] ?? 'send_invoice'),
'hosted_invoice_url' => sprintf('https://stripe.test/invoices/%s', $invoiceId),
'metadata' => is_array($params['metadata'] ?? null) ? $params['metadata'] : [],
'lines' => [],
];
$store['invoices'][$invoiceId] = $invoice;
self::writeStore($store);
return $this->jsonResponse($invoice);
}
if ($method === 'post' && $path === '/v1/invoiceitems') {
$invoiceId = (string)($params['invoice'] ?? '');
$invoice = $store['invoices'][$invoiceId] ?? null;
if (!$invoice) {
return $this->errorResponse(404, 'resource_missing', 'No such invoice.');
}
$invoiceItemId = sprintf('ii_fake_%06d', (int)$store['next_invoice_item']);
$store['next_invoice_item']++;
$priceId = (string)($params['price'] ?? '');
$price = $store['prices'][$priceId] ?? ['unit_amount' => 0];
$invoiceItem = [
'id' => $invoiceItemId,
'object' => 'invoiceitem',
'invoice' => $invoiceId,
'customer' => (string)($params['customer'] ?? ''),
'price' => $priceId,
'amount' => (int)($price['unit_amount'] ?? 0),
];
$store['invoice_items'][$invoiceItemId] = $invoiceItem;
$store['invoices'][$invoiceId]['lines'][] = $invoiceItemId;
$store['invoices'][$invoiceId]['amount_due'] += (int)($price['unit_amount'] ?? 0);
self::writeStore($store);
return $this->jsonResponse($invoiceItem);
}
if ($method === 'post' && preg_match('#^/v1/invoices/(?P<id>[^/]+)/finalize$#', $path, $matches)) {
$invoiceId = $matches['id'];
$invoice = $store['invoices'][$invoiceId] ?? null;
if (!$invoice) {
return $this->errorResponse(404, 'resource_missing', 'No such invoice.');
}
$invoice['status'] = 'open';
$store['invoices'][$invoiceId] = $invoice;
self::writeStore($store);
return $this->jsonResponse($invoice);
}
if ($method === 'post' && preg_match('#^/v1/invoices/(?P<id>[^/]+)/void$#', $path, $matches)) {
$invoiceId = $matches['id'];
$invoice = $store['invoices'][$invoiceId] ?? null;
if (!$invoice) {
return $this->errorResponse(404, 'resource_missing', 'No such invoice.');
}
$invoice['status'] = 'void';
$invoice['paid'] = false;
$store['invoices'][$invoiceId] = $invoice;
self::writeStore($store);
return $this->jsonResponse($invoice);
}
if ($method === 'get' && preg_match('#^/v1/invoices/(?P<id>[^/]+)$#', $path, $matches)) {
$invoice = $store['invoices'][$matches['id']] ?? null;
if (!$invoice) {
return $this->errorResponse(404, 'resource_missing', 'No such invoice.');
}
return $this->jsonResponse($invoice);
}
return $this->errorResponse(404, 'resource_missing', 'Unsupported fake Stripe request: ' . $method . ' ' . $path);
}
private function jsonResponse(array $payload, int $status = 200): array
{
return [
json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$status,
[
'Content-Type' => 'application/json',
'Request-Id' => 'req_fake_stripe',
],
];
}
private function errorResponse(int $status, string $code, string $message): array
{
return [
json_encode([
'error' => [
'type' => 'invalid_request_error',
'code' => $code,
'message' => $message,
],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$status,
[
'Content-Type' => 'application/json',
'Request-Id' => 'req_fake_stripe_error',
],
];
}
private static function readStore(): array
{
$path = self::storePath();
if (!is_file($path)) {
return self::DEFAULT_STATE;
}
$json = file_get_contents($path);
if ($json === false || trim($json) === '') {
return self::DEFAULT_STATE;
}
$decoded = json_decode($json, true);
if (!is_array($decoded)) {
return self::DEFAULT_STATE;
}
return [
...self::DEFAULT_STATE,
...$decoded,
];
}
private static function writeStore(array $store): void
{
file_put_contents(
self::storePath(),
json_encode($store, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)
);
}
private static function storePath(): string
{
$path = trim((string)getenv('STRIPE_FAKE_STORE_PATH'));
if ($path !== '') {
return $path;
}
return sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'truckwash-stripe-fake-store.json';
}
}
File diff suppressed because it is too large Load Diff
@@ -11,6 +11,7 @@ class system_search_cache
public const INTENT_PREFIX = self::PREFIX . 'intent:';
public const DIRTY_TABLES_KEY = self::PREFIX . 'dirty_tables';
public const REBUILD_REQUEST_KEY = self::PREFIX . 'rebuild_request';
public const TABLE_VERSION_PREFIX = self::PREFIX . 'table_version:';
/**
* Optional runtime adapter for tests.
*/
@@ -78,6 +79,7 @@ class system_search_cache
if ($table === '') {
return;
}
self::bumpTableVersion($table);
$tables = self::redisGetArray(self::DIRTY_TABLES_KEY);
if (!in_array($table, $tables, true)) {
$tables[] = $table;
@@ -85,8 +87,6 @@ class system_search_cache
// Keep dirty markers briefly in case cron is delayed.
self::redisExpire(self::DIRTY_TABLES_KEY, 3600);
}
// Query cache depends on mutable data and must be invalidated immediately.
self::clearQueryCaches();
}
public static function consumeDirtyTables(): array
@@ -96,6 +96,56 @@ class system_search_cache
return $tables;
}
public static function peekDirtyTables(): array
{
return self::redisGetArray(self::DIRTY_TABLES_KEY);
}
public static function bumpTableVersion(string $table): int
{
$table = trim($table, " `\t\n\r\0\x0B");
if ($table === '') {
return 0;
}
$key = self::TABLE_VERSION_PREFIX . $table;
try {
$client = self::redisClient();
if ($client === null) {
return 0;
}
if (method_exists($client, 'incr')) {
return (int)$client->incr($key);
}
$current = self::redisGet($key);
$next = max(1, (int)$current + 1);
self::redisSet($key, (string)$next);
return $next;
} catch (Throwable) {
return 0;
}
}
/**
* @param array<int, string> $tables
*/
public static function tableVersionFingerprint(array $tables): string
{
$versions = [];
foreach ($tables as $table) {
if (!is_string($table)) {
continue;
}
$normalized = trim($table, " `\t\n\r\0\x0B");
if ($normalized === '') {
continue;
}
$versions[$normalized] = (int)(self::redisGet(self::TABLE_VERSION_PREFIX . $normalized) ?? 0);
}
ksort($versions);
return md5(json_encode($versions, JSON_UNESCAPED_UNICODE));
}
public static function enqueueRebuild(string $scope = 'all', array $types = []): array
{
$payload = [
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,382 @@
<?php
namespace classes;
use objects\users_o;
use Throwable;
class system_search_economic_customer_index
{
public const TABLE = 'system_search_economic_customer_index';
private static bool $initialized = false;
public static function ensureTable(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!is_object($db) || !method_exists($db, 'query')) {
return;
}
$sql = "CREATE TABLE IF NOT EXISTS `" . self::TABLE . "` (
`customer_number` INT NOT NULL,
`user_id` INT NULL,
`local_display_name` VARCHAR(255) NULL,
`local_email` VARCHAR(255) NULL,
`local_phone` VARCHAR(64) NULL,
`economic_name` VARCHAR(255) NULL,
`economic_address` VARCHAR(255) NULL,
`economic_city` VARCHAR(255) NULL,
`economic_zip` VARCHAR(64) NULL,
`economic_email` VARCHAR(255) NULL,
`economic_cvr` VARCHAR(64) NULL,
`economic_mobile_phone` VARCHAR(64) NULL,
`economic_barred` TINYINT(1) NULL,
`search_text` TEXT NULL,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`customer_number`),
INDEX `idx_system_search_econ_customer_user` (`user_id`),
INDEX `idx_system_search_econ_customer_name` (`economic_name`),
INDEX `idx_system_search_econ_customer_email` (`economic_email`),
INDEX `idx_system_search_econ_customer_cvr` (`economic_cvr`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
$db->query($sql);
self::ensureColumn(
'economic_barred',
"ALTER TABLE `" . self::TABLE . "` ADD COLUMN `economic_barred` TINYINT(1) NULL AFTER `economic_mobile_phone`"
);
self::$initialized = true;
}
/**
* @param array<int, int> $customerNumbers
* @return array<int, array<string, mixed>>
*/
public static function fetchContexts(array $customerNumbers): array
{
self::ensureTable();
$normalized = array_values(array_unique(array_filter(
array_map('intval', $customerNumbers),
static fn(int $value): bool => $value > 0
)));
if (empty($normalized)) {
return [];
}
global $db;
if (!is_object($db) || !method_exists($db, 'query')) {
return [];
}
$result = $db->query(
"SELECT `customer_number`, `user_id`, `local_display_name`, `local_email`, `local_phone`,"
. " `economic_name`, `economic_address`, `economic_city`, `economic_zip`, `economic_email`,"
. " `economic_cvr`, `economic_mobile_phone`, `economic_barred`"
. " FROM `" . self::TABLE . "`"
. " WHERE `customer_number` IN (" . implode(',', $normalized) . ")"
);
if (!($result instanceof \mysqli_result)) {
return [];
}
$contexts = [];
while ($row = $result->fetch_assoc()) {
$customerNumber = (int)($row['customer_number'] ?? 0);
if ($customerNumber <= 0) {
continue;
}
$barred = self::toNullableBool($row['economic_barred'] ?? null);
$contexts[$customerNumber] = [
'customer_number' => $customerNumber,
'user_id' => self::toNullableInt($row['user_id'] ?? null),
'name' => self::toNullableString($row['economic_name'] ?? null)
?? self::toNullableString($row['local_display_name'] ?? null),
'barred' => $barred,
'status' => self::barredStatus($barred),
'email' => self::toNullableString($row['economic_email'] ?? null)
?? self::toNullableString($row['local_email'] ?? null),
'phone' => self::toNullableString($row['economic_mobile_phone'] ?? null)
?? self::toNullableString($row['local_phone'] ?? null),
'cvr' => self::toNullableString($row['economic_cvr'] ?? null),
'address' => self::toNullableString($row['economic_address'] ?? null),
'city' => self::toNullableString($row['economic_city'] ?? null),
'zip' => self::toNullableString($row['economic_zip'] ?? null),
];
}
return $contexts;
}
/**
* Rebuild local e-conomic customer index from local users + cached/live e-conomic snapshots.
*
* @return array<string, int>
*/
public static function refreshIndex(bool $refreshEconomicData = false): array
{
self::ensureTable();
global $db;
if (!is_object($db) || !method_exists($db, 'query') || !property_exists($db, 'conn')) {
return [
'processed' => 0,
'upserted' => 0,
'deleted' => 0,
'errors' => 0,
];
}
$stats = [
'processed' => 0,
'upserted' => 0,
'deleted' => 0,
'errors' => 0,
];
$result = $db->query("SELECT `id`, `customer_number`, `display_name`, `email`, `phone`
FROM `users`
WHERE `customer_number` IS NOT NULL
AND `customer_number` <> 0");
if (!($result instanceof \mysqli_result)) {
return $stats;
}
$rows = $db->fetch_all($result);
$seenCustomerNumbers = [];
foreach ($rows as $row) {
$stats['processed']++;
$customerNumber = (int)($row['customer_number'] ?? 0);
if ($customerNumber <= 0) {
continue;
}
$seenCustomerNumbers[$customerNumber] = true;
$economic = [];
try {
$tmpUser = new users_o();
$tmpUser->getUserByCustomerNumber($customerNumber);
if ($refreshEconomicData) {
$tmpUser->getCustomerEcocomicData($customerNumber);
}
$cached = $tmpUser->getCached('economic_customer');
if (!$cached && !$refreshEconomicData) {
$tmpUser->getCustomerEcocomicData($customerNumber);
$cached = $tmpUser->getCached('economic_customer');
}
$economic = self::normalizeEconomicSnapshot($cached);
} catch (Throwable) {
$stats['errors']++;
}
$localDisplayName = self::toNullableString($row['display_name'] ?? null);
$localEmail = self::toNullableString($row['email'] ?? null);
$localPhone = self::toNullableString($row['phone'] ?? null);
$economicName = self::toNullableString($economic['name'] ?? null);
$economicAddress = self::toNullableString($economic['address'] ?? null);
$economicCity = self::toNullableString($economic['city'] ?? null);
$economicZip = self::toNullableString($economic['zip'] ?? null);
$economicEmail = self::toNullableString($economic['email'] ?? null);
$economicCvr = self::toNullableString($economic['corporateIdentificationNumber'] ?? null);
$economicMobilePhone = self::toNullableString($economic['mobilePhone'] ?? null);
$economicBarred = self::toNullableBool($economic['barred'] ?? null);
$searchText = trim(implode(' ', array_values(array_filter([
$customerNumber > 0 ? (string)$customerNumber : null,
$localDisplayName,
$localEmail,
$localPhone,
$economicName,
$economicAddress,
$economicCity,
$economicZip,
$economicEmail,
$economicCvr,
$economicMobilePhone,
], static fn($v) => is_string($v) && trim($v) !== ''))));
if ($searchText === '') {
$searchText = null;
}
$sql = "INSERT INTO `" . self::TABLE . "` (
`customer_number`,
`user_id`,
`local_display_name`,
`local_email`,
`local_phone`,
`economic_name`,
`economic_address`,
`economic_city`,
`economic_zip`,
`economic_email`,
`economic_cvr`,
`economic_mobile_phone`,
`economic_barred`,
`search_text`
) VALUES (
" . (int)$customerNumber . ",
" . (int)($row['id'] ?? 0) . ",
" . self::sqlNullableString($localDisplayName) . ",
" . self::sqlNullableString($localEmail) . ",
" . self::sqlNullableString($localPhone) . ",
" . self::sqlNullableString($economicName) . ",
" . self::sqlNullableString($economicAddress) . ",
" . self::sqlNullableString($economicCity) . ",
" . self::sqlNullableString($economicZip) . ",
" . self::sqlNullableString($economicEmail) . ",
" . self::sqlNullableString($economicCvr) . ",
" . self::sqlNullableString($economicMobilePhone) . ",
" . self::sqlNullableBool($economicBarred) . ",
" . self::sqlNullableString($searchText) . "
) ON DUPLICATE KEY UPDATE
`user_id` = VALUES(`user_id`),
`local_display_name` = VALUES(`local_display_name`),
`local_email` = VALUES(`local_email`),
`local_phone` = VALUES(`local_phone`),
`economic_name` = VALUES(`economic_name`),
`economic_address` = VALUES(`economic_address`),
`economic_city` = VALUES(`economic_city`),
`economic_zip` = VALUES(`economic_zip`),
`economic_email` = VALUES(`economic_email`),
`economic_cvr` = VALUES(`economic_cvr`),
`economic_mobile_phone` = VALUES(`economic_mobile_phone`),
`economic_barred` = VALUES(`economic_barred`),
`search_text` = VALUES(`search_text`),
`updated_at` = CURRENT_TIMESTAMP";
$db->query($sql);
$stats['upserted']++;
}
$seen = array_keys($seenCustomerNumbers);
if (empty($seen)) {
$db->query("DELETE FROM `" . self::TABLE . "`");
$stats['deleted'] = self::safeAffectedRows();
return $stats;
}
$in = implode(',', array_map('intval', $seen));
$db->query("DELETE FROM `" . self::TABLE . "` WHERE `customer_number` NOT IN (" . $in . ")");
$stats['deleted'] = self::safeAffectedRows();
return $stats;
}
/**
* @return array<string, mixed>
*/
private static function normalizeEconomicSnapshot(mixed $snapshot): array
{
if (is_object($snapshot)) {
return get_object_vars($snapshot);
}
if (is_array($snapshot)) {
return $snapshot;
}
return [];
}
private static function toNullableString(mixed $value): ?string
{
if ($value === null) {
return null;
}
$string = trim((string)$value);
return $string === '' ? null : $string;
}
private static function toNullableInt(mixed $value): ?int
{
if (is_int($value)) {
return $value;
}
if (is_numeric($value) && (string)(int)$value === trim((string)$value)) {
return (int)$value;
}
return null;
}
private static function toNullableBool(mixed $value): ?bool
{
if ($value === null || $value === '') {
return null;
}
if (is_bool($value)) {
return $value;
}
if (is_int($value)) {
return $value !== 0;
}
if (is_string($value)) {
$normalized = trim(mb_strtolower($value));
if ($normalized === '') {
return null;
}
if (in_array($normalized, ['1', 'true', 'yes'], true)) {
return true;
}
if (in_array($normalized, ['0', 'false', 'no'], true)) {
return false;
}
}
return null;
}
private static function sqlNullableString(?string $value): string
{
global $db;
if ($value === null) {
return 'NULL';
}
return "'" . $db->escape_string($value) . "'";
}
private static function sqlNullableBool(?bool $value): string
{
if ($value === null) {
return 'NULL';
}
return $value ? '1' : '0';
}
private static function ensureColumn(string $column, string $alterSql): void
{
global $db;
if (!is_object($db) || !method_exists($db, 'query')) {
return;
}
$result = $db->query(
"SHOW COLUMNS FROM `" . self::TABLE . "` LIKE '" . $db->escape_string($column) . "'"
);
if ($result instanceof \mysqli_result && $result->num_rows === 0) {
$db->query($alterSql);
}
}
private static function barredStatus(?bool $barred): string
{
return match ($barred) {
true => 'barred',
false => 'active',
default => 'unknown',
};
}
private static function safeAffectedRows(): int
{
global $db;
if (!is_object($db) || !property_exists($db, 'conn') || !is_object($db->conn)) {
return 0;
}
return max(0, (int)($db->conn->affected_rows ?? 0));
}
}
@@ -78,7 +78,11 @@ class system_search_openai_intent_parser implements system_search_intent_parser_
{
$query = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[email]', $query) ?? $query;
$query = preg_replace('/\b\d{8}\b/', '[cvr]', $query) ?? $query;
$query = preg_replace('/\b[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}\b/i', '[uuid]', $query) ?? $query;
$query = preg_replace('/\+?\d[\d\s\-]{6,}\d/', '[phone]', $query) ?? $query;
$query = preg_replace('/\b(order|invoice|booking|customer|kunde|faktura)\s*[#:\-]?\s*\d{4,}\b/iu', '$1 [id]', $query) ?? $query;
$query = preg_replace('/\b(reg(?:istration)?|plate|license plate|nummerplade)\s*[#:\-]?\s*[a-z0-9\-]{4,10}\b/iu', '$1 [plate]', $query) ?? $query;
$query = preg_replace('/\b[a-z]{2}\s?\d{5}\b/iu', '[plate]', $query) ?? $query;
return $query;
}
@@ -109,7 +113,10 @@ class system_search_openai_intent_parser implements system_search_intent_parser_
. "Rules:\n"
. "- Keep output concise and valid JSON only.\n"
. "- Do not invent entity types not listed in allowed_entity_types.\n"
. "- aliases should contain user-friendly alternative terms.\n"
. "- Infer what the user is trying to find, not just literal words.\n"
. "- aliases should contain user-friendly and backend-friendly equivalent terms.\n"
. "- Include cross-language/domain synonyms when likely (example: Danish 'rabat' -> 'discount').\n"
. "- If user references a customer/company by name, include hints that help find related invoices/orders/discounts.\n"
. "- confidence must be between 0 and 1.\n"
. "- association_hint should be true if related records likely needed.\n\n"
. "Context:\n"
@@ -0,0 +1,277 @@
<?php
namespace classes;
class system_search_registry
{
/**
* @return array<string, array<string, mixed>>
*/
public static function genericEntityConfigs(): array
{
return [
'bookings' => [
'table' => 'bookings',
'customer_field' => 'customer_number',
'department_field' => 'department',
'search_fields' => ['id', 'customer_number', 'department', 'status', 'reference', 'notes', 'reg', 'reg_1', 'plate'],
],
'bookings_new' => [
'table' => 'bookings_new',
'customer_field' => 'customer_number',
'department_field' => 'department',
'search_fields' => ['id', 'customer_number', 'department', 'status', 'reference', 'notes', 'reg', 'reg_1', 'plate'],
],
'branding' => ['table' => 'branding'],
'categories' => ['table' => 'categories'],
'currency_conversion_rates' => ['table' => 'currency_conversion_rates'],
'customer_codes' => ['table' => 'customer_codes'],
'customer_default_department' => [
'table' => 'customer_default_department',
'customer_field' => 'customer_number',
'department_field' => 'department',
'search_fields' => ['id', 'customer_number', 'department', 'name', 'reference', 'description'],
],
'customer_notes' => [
'table' => 'customer_notes',
'customer_field' => 'customer_id',
'search_fields' => ['id', 'customer_id', 'title', 'note', 'notes', 'description'],
],
'customer_vehicles_addons' => [
'table' => 'customer_vehicles_addons',
'search_fields' => ['id', 'customer_id', 'vehicle_id', 'name', 'reference', 'description', 'type'],
],
'department_categories' => ['table' => 'department_categories', 'department_field' => 'department'],
'department_daily_reports' => [
'table' => 'department_daily_reports',
'department_field' => 'department_id',
'search_fields' => ['id', 'department_id', 'title', 'description', 'notes', 'status'],
],
'department_gates' => ['table' => 'department_gates', 'department_field' => 'department'],
'department_goals' => ['table' => 'goals'],
'department_lanes' => ['table' => 'department_lanes', 'department_field' => 'department'],
'department_notification_sms' => ['table' => 'department_notification_sms', 'department_field' => 'department_id'],
'department_relays' => ['table' => 'department_relays', 'department_field' => 'department'],
'department_selfserve_condition_rules' => ['table' => 'department_selfserve_condition_rules'],
'department_selfserve_conditions' => ['table' => 'department_selfserve_conditions', 'department_field' => 'department'],
'department_selfserve_questions' => ['table' => 'department_selfserve_questions', 'department_field' => 'department'],
'department_selfserve_tasks' => [
'table' => 'department_selfserve_tasks',
'department_field' => 'department',
'search_fields' => ['id', 'department', 'lane', 'product', 'task', 'description'],
'title_fields' => ['task', 'description', 'id'],
'description_fields' => ['description', 'department', 'lane', 'product'],
],
'department_selfserve_vehicle_conditions' => ['table' => 'department_selfserve_vehicle_conditions', 'customer_field' => 'customer_id', 'department_field' => 'department'],
'department_time_bookings_entries' => ['table' => 'department_time_bookings_entries', 'department_field' => 'department'],
'department_time_bookings_opening_hours' => ['table' => 'department_time_bookings_opening_hours', 'department_field' => 'department'],
'department_time_bookings_types' => ['table' => 'department_time_bookings_types', 'department_field' => 'department'],
'department_variables' => ['table' => 'department_variables'],
'fxratesapi_conversion_rates' => ['table' => 'fxratesapi_conversion_rates'],
'module_action_logs' => [
'table' => 'module_usage_logs',
'search_fields' => ['id', 'module', 'action', 'message', 'customer_number', 'customer_id'],
'title_fields' => ['action', 'module', 'id'],
'description_fields' => ['message', 'module'],
],
'motorapi_lookups' => [
'table' => 'motorapi_lookups',
'search_fields' => ['id', 'reg', 'plate', 'reference', 'message', 'status'],
],
'notifications' => [
'table' => 'notifications',
'customer_field' => 'customer_number',
'search_fields' => ['id', 'customer_number', 'customer_id', 'title', 'message', 'type', 'status'],
'title_fields' => ['title', 'type', 'id'],
'description_fields' => ['message', 'status'],
],
'order_bookings' => [
'table' => 'order_bookings',
'customer_field' => 'customer_number',
'department_field' => 'department',
'search_fields' => ['id', 'order_id', 'customer_number', 'department', 'status', 'reference', 'notes'],
],
'plate_scanners' => ['table' => 'plate_scanners', 'department_field' => 'department_id'],
'plate_scans' => [
'table' => 'plate_scans',
'search_fields' => ['id', 'plate', 'number_plate', 'reg', 'status', 'message'],
],
'product_options' => ['table' => 'products_options', 'search_fields' => ['id', 'product_id', 'name', 'description', 'type', 'reference']],
'products' => [
'table' => 'products',
'search_fields' => ['id', 'name', 'description', 'product_number', 'reference'],
'title_fields' => ['name', 'reference', 'id'],
'description_fields' => ['description', 'product_number'],
],
'users' => [
'table' => 'users',
'customer_field' => 'customer_number',
'search_fields' => ['id', 'customer_number', 'display_name', 'email', 'phone', 'username', 'role'],
'title_fields' => ['display_name', 'email', 'customer_number', 'id'],
'description_fields' => ['email', 'phone', 'role'],
],
'stripe_module_customers' => [
'table' => 'stripe_module_customers',
'customer_field' => 'customer_id',
'search_fields' => ['id', 'customer_id', 'name', 'email', 'reference', 'status'],
],
'stripe_module_orders' => [
'table' => 'stripe_module_orders',
'customer_field' => 'customer_id',
'exclude_columns' => ['url'],
'search_fields' => ['id', 'customer_id', 'reference', 'status', 'payment_intent_id'],
],
'stripe_payment_intents' => [
'table' => 'stripe_payment_intents',
'exclude_columns' => ['client_secret', 'data'],
'search_fields' => ['id', 'customer_id', 'status', 'reference', 'payment_method'],
],
'subuser_grants' => [
'table' => 'subuser_grants',
'customer_field' => 'billing_customer_number',
'search_fields' => ['id', 'subuser', 'billing_customer_number', 'name', 'description', 'reference'],
],
'xlvask_customers' => [
'table' => 'xlvask_customers',
'customer_field' => 'externId',
'customer_field_mode' => 'digits_only',
],
'xlvask_potential_order_matches' => ['table' => 'xlvask_potential_order_matches', 'customer_field' => 'customer_number', 'department_field' => 'department'],
'xlvask_usage_log_wash_items' => ['table' => 'xlvask_usage_log_wash_items'],
'xlvask_usage_logs' => ['table' => 'xlvask_usage_logs'],
'xlvask_vehicle_types' => ['table' => 'xlvask_vehicle_types'],
'xlvask_vehicles' => ['table' => 'xlvask_vehicles'],
];
}
/**
* @return array<int, string>
*/
public static function allEntityTypes(): array
{
return array_values(array_unique([
'objects',
'module_config',
'orders',
'order_items',
'customers',
'employees',
'subusers',
'customer_discounts',
'customer_fixed_prices',
'departments',
'permissions',
'roles',
'invoices',
'vehicles',
...array_keys(self::genericEntityConfigs()),
]));
}
/**
* @return array<int, string>
*/
public static function indexedEntityTypes(): array
{
return array_values(array_diff(self::allEntityTypes(), ['permissions', 'subusers']));
}
/**
* @return array<int, string>
*/
public static function sourceTablesForEntityType(string $entityType): array
{
$entityType = trim(mb_strtolower($entityType));
$manual = [
'objects' => ['object_attachments', 'orders', 'department_selfserve_tasks'],
'module_config' => ['module_config'],
'orders' => ['orders'],
'order_items' => ['order_items', 'orders'],
'customers' => ['users', system_search_economic_customer_index::TABLE],
'employees' => ['users', 'groups_permissions'],
'subusers' => ['subusers', 'subuser_grants'],
'customer_discounts' => ['price_overrides', 'users', system_search_economic_customer_index::TABLE],
'customer_fixed_prices' => ['customer_fixed_pricing', system_search_economic_customer_index::TABLE],
'departments' => ['departments'],
'permissions' => [],
'roles' => ['groups'],
'invoices' => ['collected_order_invoices'],
'vehicles' => ['customer_vehicles'],
];
if (isset($manual[$entityType])) {
return $manual[$entityType];
}
$config = self::genericEntityConfigs()[$entityType] ?? null;
if (!is_array($config)) {
return [];
}
$table = trim((string)($config['table'] ?? ''));
return $table === '' ? [] : [$table];
}
/**
* @param array<int, string> $tables
* @return array<int, string>
*/
public static function entityTypesForDirtyTables(array $tables): array
{
$normalizedTables = array_values(array_unique(array_filter(array_map(
static fn($table) => is_string($table) ? trim($table, " `\t\n\r\0\x0B") : '',
$tables
))));
if (empty($normalizedTables)) {
return [];
}
$types = [];
foreach (self::indexedEntityTypes() as $entityType) {
$sourceTables = self::sourceTablesForEntityType($entityType);
if (!empty(array_intersect($normalizedTables, $sourceTables))) {
$types[] = $entityType;
}
}
return array_values(array_unique($types));
}
/**
* @return array<string, array<int, string>>
*/
public static function taxonomyAliases(): array
{
return [
'customers' => ['customer', 'account', 'company', 'kunde'],
'orders' => ['order', 'work order'],
'order_items' => ['order item', 'line item'],
'invoices' => ['invoice', 'billing'],
'vehicles' => ['vehicle', 'truck', 'plate'],
'employees' => ['employee', 'staff'],
'subusers' => ['subuser', 'driver'],
'customer_discounts' => ['discount', 'price override', 'rabat'],
'customer_fixed_prices' => ['fixed price', 'monthly agreement'],
'departments' => ['department', 'location'],
'permissions' => ['permission', 'acl'],
'roles' => ['role', 'group'],
'module_config' => ['module config', 'setting', 'configuration'],
'objects' => ['attachment', 'object'],
'bookings' => ['booking', 'wash booking'],
'bookings_new' => ['new booking', 'booking queue'],
'customer_notes' => ['customer note', 'note'],
'order_bookings' => ['order booking', 'scheduled order'],
'products' => ['product', 'service'],
'product_options' => ['product option', 'addon', 'add on'],
'plate_scans' => ['plate scan', 'license plate scan'],
'plate_scanners' => ['plate scanner', 'license plate scanner'],
'notifications' => ['notification', 'alert'],
'users' => ['user', 'account user'],
'module_action_logs' => ['module log', 'action log'],
'motorapi_lookups' => ['motorapi lookup', 'plate lookup'],
'xlvask_customers' => ['xlvask customer'],
'xlvask_vehicles' => ['xlvask vehicle'],
'xlvask_usage_logs' => ['xlvask usage log'],
'department_daily_reports' => ['department daily report', 'daily report'],
];
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,124 @@
<?php
namespace classes;
/**
* Ensures additive system session activity storage exists.
*
* This project uses lazy runtime schema bootstraps instead of a central
* migration runner, so every change must be idempotent.
*/
class system_session_activity_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
$queries = [
"CREATE TABLE IF NOT EXISTS system_session_activity (
id INT AUTO_INCREMENT PRIMARY KEY,
session_hash CHAR(64) NOT NULL,
session_kind VARCHAR(16) NOT NULL,
principal_id INT NOT NULL,
customer_number_context INT NULL,
device_type VARCHAR(16) NOT NULL DEFAULT 'unknown',
user_agent VARCHAR(1024) NULL,
last_route VARCHAR(255) NULL,
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_system_session_activity_session_hash (session_hash),
INDEX idx_system_session_activity_last_seen_at (last_seen_at),
INDEX idx_system_session_activity_principal (session_kind, principal_id),
INDEX idx_system_session_activity_customer_context (customer_number_context)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($queries as $sql) {
$db->query($sql);
}
self::ensureColumn(
'system_session_activity',
'customer_number_context',
'ALTER TABLE system_session_activity ADD COLUMN customer_number_context INT NULL AFTER principal_id'
);
self::ensureColumn(
'system_session_activity',
'device_type',
"ALTER TABLE system_session_activity ADD COLUMN device_type VARCHAR(16) NOT NULL DEFAULT 'unknown' AFTER customer_number_context"
);
self::ensureColumn(
'system_session_activity',
'user_agent',
'ALTER TABLE system_session_activity ADD COLUMN user_agent VARCHAR(1024) NULL AFTER device_type'
);
self::ensureColumn(
'system_session_activity',
'last_route',
'ALTER TABLE system_session_activity ADD COLUMN last_route VARCHAR(255) NULL AFTER user_agent'
);
self::ensureColumn(
'system_session_activity',
'first_seen_at',
'ALTER TABLE system_session_activity ADD COLUMN first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER last_route'
);
self::ensureColumn(
'system_session_activity',
'last_seen_at',
'ALTER TABLE system_session_activity ADD COLUMN last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER first_seen_at'
);
self::ensureColumn(
'system_session_activity',
'created_at',
'ALTER TABLE system_session_activity ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER last_seen_at'
);
self::ensureColumn(
'system_session_activity',
'updated_at',
'ALTER TABLE system_session_activity ADD COLUMN updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at'
);
self::$initialized = true;
}
public static function tableHasColumn(string $table, string $column): bool
{
global $db;
$table = $db->escape_string($table);
$column = $db->escape_string($column);
$database = $db->escape_string($db->getDatabase());
$sql = "SELECT COUNT(*) AS c
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = '$database'
AND TABLE_NAME = '$table'
AND COLUMN_NAME = '$column'";
$result = $db->query($sql);
if (!$result) {
return false;
}
$row = $result->fetch_assoc();
return ((int)($row['c'] ?? 0)) > 0;
}
public static function ensureColumn(string $table, string $column, string $alterSql): void
{
global $db;
if (self::tableHasColumn($table, $column)) {
return;
}
$db->query($alterSql);
}
}
@@ -0,0 +1,306 @@
<?php
namespace classes;
use DateTimeImmutable;
use DateTimeZone;
use Exception;
use objects\subusers_o;
use objects\users_o;
class system_session_activity_tracker
{
public const ACTIVE_WINDOW_MINUTES = 15;
public const PRUNE_AFTER_DAYS = 30;
private const DATABASE_TIMEZONE = 'UTC';
/**
* Deduplicate touches within a single request lifecycle.
*
* @var array<string, bool>
*/
private static array $touchedSessions = [];
public function __construct()
{
system_session_activity_schema_bootstrap::ensureTables();
}
public function touchUser(users_o $user, string $token): void
{
$customerNumber = null;
if (isset($user->customer_number)) {
try {
$customerNumber = (int)$user->customer_number->value();
} catch (Exception) {
$customerNumber = null;
}
}
$this->touch('user', (int)$user->id, $token, $customerNumber);
}
public function touchSubuser(subusers_o $subuser, string $token, ?int $customerNumberContext = null): void
{
$this->touch('subuser', (int)$subuser->id, $token, $customerNumberContext);
}
public function touch(string $sessionKind, int $principalId, string $token, ?int $customerNumberContext = null): void
{
global $db;
$token = trim($token);
if ($token === '' || $principalId <= 0) {
return;
}
$sessionHash = hash('sha256', $token);
if (isset(self::$touchedSessions[$sessionHash])) {
return;
}
self::$touchedSessions[$sessionHash] = true;
$headers = function_exists('getallheaders') ? (getallheaders() ?: []) : [];
$userAgent = trim((string)($headers['User-Agent'] ?? $headers['user-agent'] ?? ''));
$lastRoute = trim((string)($_SERVER['REQUEST_URI'] ?? ''));
if ($lastRoute !== '') {
$lastRoute = explode('?', $lastRoute)[0] ?? $lastRoute;
}
$sessionHashEscaped = $db->escape_string($sessionHash);
$sessionKindEscaped = $db->escape_string($sessionKind);
$deviceTypeEscaped = $db->escape_string(self::detectDeviceType($userAgent));
$userAgentEscaped = $db->escape_string(substr($userAgent, 0, 1024));
$lastRouteEscaped = $db->escape_string(substr($lastRoute, 0, 255));
$customerNumberSql = $customerNumberContext === null ? 'NULL' : (string)(int)$customerNumberContext;
$currentUtcDateTime = self::utcSqlDateTime();
$sql = "INSERT INTO system_session_activity (
session_hash,
session_kind,
principal_id,
customer_number_context,
device_type,
user_agent,
last_route,
first_seen_at,
last_seen_at
) VALUES (
'$sessionHashEscaped',
'$sessionKindEscaped',
" . (int)$principalId . ",
$customerNumberSql,
'$deviceTypeEscaped',
'$userAgentEscaped',
'$lastRouteEscaped',
'$currentUtcDateTime',
'$currentUtcDateTime'
)
ON DUPLICATE KEY UPDATE
customer_number_context = VALUES(customer_number_context),
device_type = VALUES(device_type),
user_agent = VALUES(user_agent),
last_route = VALUES(last_route),
last_seen_at = '$currentUtcDateTime'";
$db->query($sql);
}
public function getSnapshot(int $limit = 50): array
{
global $db;
system_session_activity_schema_bootstrap::ensureTables();
$limit = max(1, min(200, $limit));
$cutoff = self::utcSqlDateTime(time() - (self::ACTIVE_WINDOW_MINUTES * 60));
$cutoffEscaped = $db->escape_string($cutoff);
$activeUsersResult = $db->query(
"SELECT COUNT(DISTINCT CONCAT(session_kind, ':', principal_id)) AS c
FROM system_session_activity
WHERE last_seen_at >= '$cutoffEscaped'"
);
$activeSessionsResult = $db->query(
"SELECT COUNT(*) AS c
FROM system_session_activity
WHERE last_seen_at >= '$cutoffEscaped'"
);
$recentRows = $db->query(
"SELECT
s.session_kind,
s.principal_id,
s.customer_number_context,
s.device_type,
s.user_agent,
s.last_route,
s.first_seen_at,
s.last_seen_at,
u.display_name AS user_display_name,
u.customer_number AS user_customer_number,
su.name AS subuser_name,
su.username AS subuser_username
FROM system_session_activity s
LEFT JOIN users u
ON s.session_kind = 'user'
AND u.id = s.principal_id
LEFT JOIN subusers su
ON s.session_kind = 'subuser'
AND su.id = s.principal_id
ORDER BY s.last_seen_at DESC
LIMIT $limit"
);
$recentSessions = [];
while ($row = $recentRows->fetch_assoc()) {
$isUser = ($row['session_kind'] ?? '') === 'user';
$displayName = $isUser
? trim((string)($row['user_display_name'] ?? ''))
: trim((string)($row['subuser_name'] ?? ''));
$displayNameKey = null;
$displayNameParams = [];
if ($displayName === '') {
if ($isUser && !empty($row['user_customer_number'])) {
$displayName = 'Customer ' . $row['user_customer_number'];
$displayNameKey = 'customer_number';
$displayNameParams = ['number' => (int)$row['user_customer_number']];
} elseif (!$isUser && !empty($row['subuser_username'])) {
$displayName = $row['subuser_username'];
} elseif ($isUser) {
$displayName = 'User #' . (int)($row['principal_id'] ?? 0);
$displayNameKey = 'user_with_id';
$displayNameParams = ['id' => (int)($row['principal_id'] ?? 0)];
} elseif (($row['session_kind'] ?? '') === 'subuser') {
$displayName = 'Subuser #' . (int)($row['principal_id'] ?? 0);
$displayNameKey = 'subuser_with_id';
$displayNameParams = ['id' => (int)($row['principal_id'] ?? 0)];
} else {
$displayName = 'Session #' . (int)($row['principal_id'] ?? 0);
$displayNameKey = 'session_with_id';
$displayNameParams = ['id' => (int)($row['principal_id'] ?? 0)];
}
}
$contextLabel = null;
$contextLabelKey = null;
$contextLabelParams = [];
if ($isUser && !empty($row['user_customer_number'])) {
$contextLabel = 'Customer ' . $row['user_customer_number'];
$contextLabelKey = 'customer_number';
$contextLabelParams = ['number' => (int)$row['user_customer_number']];
} elseif (!empty($row['customer_number_context'])) {
$contextLabel = 'Customer ' . $row['customer_number_context'];
$contextLabelKey = 'customer_number';
$contextLabelParams = ['number' => (int)$row['customer_number_context']];
}
$firstSeenAt = self::databaseDateTimeToIso8601($row['first_seen_at'] ?? null);
$lastSeenAt = self::databaseDateTimeToIso8601($row['last_seen_at'] ?? null);
$recentSessions[] = [
'session_kind' => (string)($row['session_kind'] ?? 'unknown'),
'principal_id' => (int)($row['principal_id'] ?? 0),
'display_name' => $displayName,
'display_name_key' => $displayNameKey,
'display_name_params' => $displayNameParams,
'context_label' => $contextLabel,
'context_label_key' => $contextLabelKey,
'context_label_params' => $contextLabelParams,
'customer_number_context' => isset($row['customer_number_context']) ? (int)$row['customer_number_context'] : null,
'device_type' => (string)($row['device_type'] ?? 'unknown'),
'user_agent' => (string)($row['user_agent'] ?? ''),
'last_route' => (string)($row['last_route'] ?? ''),
'first_seen_at' => $firstSeenAt,
'last_seen_at' => $lastSeenAt,
'active' => self::isActive($lastSeenAt),
];
}
$activeUsers = (int)(($activeUsersResult?->fetch_assoc()['c']) ?? 0);
$activeSessions = (int)(($activeSessionsResult?->fetch_assoc()['c']) ?? 0);
return [
'active_window_minutes' => self::ACTIVE_WINDOW_MINUTES,
'active_users' => $activeUsers,
'active_sessions' => $activeSessions,
'recent_sessions' => $recentSessions,
];
}
public function pruneOlderThanDays(int $days = self::PRUNE_AFTER_DAYS): int
{
global $db;
system_session_activity_schema_bootstrap::ensureTables();
$days = max(1, $days);
$cutoff = self::utcSqlDateTime(time() - ($days * 86400));
$cutoffEscaped = $db->escape_string($cutoff);
$db->query("DELETE FROM system_session_activity WHERE last_seen_at < '$cutoffEscaped'");
return (int)$db->conn()->affected_rows;
}
public static function detectDeviceType(string $userAgent): string
{
$userAgent = strtolower(trim($userAgent));
if ($userAgent === '') {
return 'unknown';
}
if (preg_match('/bot|crawler|spider|slurp|curl|wget|postman|insomnia/', $userAgent) === 1) {
return 'bot';
}
if (preg_match('/ipad|tablet|kindle|playbook|silk/', $userAgent) === 1) {
return 'tablet';
}
if (preg_match('/iphone|ipod|android.+mobile|windows phone|mobile/', $userAgent) === 1) {
return 'mobile';
}
if (preg_match('/macintosh|windows nt|linux|x11|cros/', $userAgent) === 1) {
return 'desktop';
}
return 'unknown';
}
public static function isActive(?string $lastSeenAt, int $windowMinutes = self::ACTIVE_WINDOW_MINUTES, ?int $referenceTimestamp = null): bool
{
if ($lastSeenAt === null || trim($lastSeenAt) === '') {
return false;
}
$lastSeenTimestamp = strtotime($lastSeenAt);
if ($lastSeenTimestamp === false) {
return false;
}
$referenceTimestamp = $referenceTimestamp ?? time();
return $lastSeenTimestamp >= ($referenceTimestamp - (max(1, $windowMinutes) * 60));
}
public static function databaseDateTimeToIso8601(?string $value): ?string
{
if ($value === null || trim($value) === '') {
return null;
}
$dateTime = DateTimeImmutable::createFromFormat(
'Y-m-d H:i:s',
trim($value),
new DateTimeZone(self::DATABASE_TIMEZONE)
);
if (!$dateTime instanceof DateTimeImmutable) {
return null;
}
return $dateTime->format(DATE_ATOM);
}
private static function utcSqlDateTime(?int $timestamp = null): string
{
return gmdate('Y-m-d H:i:s', $timestamp ?? time());
}
}
+328
View File
@@ -0,0 +1,328 @@
<?php
namespace classes;
require_once WD . '/modules/workfeed/workfeed_c.php';
use Exception;
use interfaces\workfeed_i;
use workfeed\workfeed_c;
class workfeed implements workfeed_i
{
public workfeed_c $config;
public function __construct()
{
$this->config = new workfeed_c();
}
/**
* @throws Exception
*/
public function requireModuleEnabled(): void
{
if (!$this->config->enabled->isTrue()) {
throw new Exception('The workfeed module is not enabled.');
}
}
/**
* @throws Exception
*/
public function listEmployees(array $filters = []): array|object
{
return $this->sendApiRequest('GET', '/employees');
}
/**
* @throws Exception
*/
public function getEmployee(string $id): object
{
$this->requireValidIdentifier($id, 'employee id');
return $this->sendApiRequest('GET', '/employees/' . rawurlencode($id));
}
/**
* @throws Exception
*/
public function listShifts(array $filters = []): array|object
{
$normalizedFilters = $this->normalizeShiftFilters($filters);
return $this->sendApiRequest('GET', '/shifts', $normalizedFilters);
}
/**
* @throws Exception
*/
public function getShift(string $id): object
{
$this->requireValidIdentifier($id, 'shift id');
return $this->sendApiRequest('GET', '/shifts/' . rawurlencode($id));
}
/**
* @throws Exception
*/
public function listDepartments(array $filters = []): array|object
{
return $this->sendApiRequest('GET', '/departments');
}
/**
* @throws Exception
*/
private function sendApiRequest(string $method, string $path, array $query = []): array|object
{
$this->requireModuleEnabled();
$this->requireConfiguredApiUrl();
$this->requireConfiguredApiKey();
$companyId = $this->requireConfiguredCompanyId();
$url = $this->buildUrl(
$this->config->api_url->getVariableValue(),
'/companies/' . rawurlencode($companyId) . '/' . ltrim($path, '/'),
$query
);
$headers = [
'Accept: application/json',
'Authorization: ' . trim((string)$this->config->api_key->getVariableValue()),
];
return $this->executeJsonRequest($method, $url, $headers);
}
/**
* @throws Exception
*/
private function executeJsonRequest(string $method, string $url, array $headers): array|object
{
$response = $this->executeRequest($method, $url, $headers);
$decoded = json_decode($response['body']);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception('Invalid JSON response from Workfeed (HTTP ' . $response['status'] . ').');
}
if ($response['status'] >= 400) {
throw new Exception($this->extractErrorMessage($decoded, $response['status'], 'Workfeed API request failed'));
}
if (is_object($decoded) || is_array($decoded)) {
return $decoded;
}
return (object)[
'value' => $decoded,
];
}
/**
* @throws Exception
*/
private function executeRequest(string $method, string $url, array $headers, ?string $body = null): array
{
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
]);
if ($body !== null) {
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($curl);
$statusCode = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
$error = curl_error($curl);
curl_close($curl);
if ($error !== '') {
throw new Exception('cURL request to Workfeed failed: ' . $error);
}
if ($responseBody === false) {
throw new Exception('Workfeed request returned an empty response.');
}
return [
'status' => $statusCode,
'body' => (string)$responseBody,
];
}
private function buildUrl(string $baseUrl, string $path = '', array $query = []): string
{
$url = rtrim(trim($baseUrl), '/');
if ($path !== '') {
$url .= '/' . ltrim($path, '/');
}
$query = array_filter($query, static function (mixed $value): bool {
return $value !== null && $value !== '';
});
if ($query !== []) {
$url .= '?' . http_build_query($query);
}
return $url;
}
/**
* @throws Exception
*/
private function requireConfiguredApiUrl(): void
{
$url = trim((string)$this->config->api_url->getVariableValue());
if ($url === '' || filter_var($this->normalizeUrlForValidation($url), FILTER_VALIDATE_URL) === false) {
throw new Exception('Invalid Workfeed API URL configured.');
}
}
/**
* @throws Exception
*/
private function requireConfiguredApiKey(): void
{
if (trim((string)$this->config->api_key->getVariableValue()) === '') {
throw new Exception('Invalid Workfeed API key configured.');
}
}
/**
* @throws Exception
*/
private function requireConfiguredCompanyId(): string
{
$companyId = trim((string)$this->config->company_id->getVariableValue());
if ($companyId === '') {
throw new Exception('Invalid Workfeed CompanyID configured.');
}
return $companyId;
}
private function normalizeUrlForValidation(string $url): string
{
if (preg_match('#^https?://#i', $url)) {
return $url;
}
return 'https://' . ltrim($url, '/');
}
/**
* @throws Exception
*/
private function requireValidIdentifier(string $value, string $label): void
{
if (trim($value) === '') {
throw new Exception('Invalid ' . $label . '.');
}
}
private function filterAllowed(array $filters, array $allowedKeys): array
{
$allowed = array_flip($allowedKeys);
$filtered = [];
foreach ($filters as $key => $value) {
if (isset($allowed[$key])) {
$filtered[$key] = $value;
}
}
return $filtered;
}
/**
* @throws Exception
*/
private function normalizeShiftFilters(array $filters): array
{
$filtered = $this->filterAllowed($filters, [
'startFrom',
'startTo',
'from',
'to',
'employeeID',
'employeeId',
'released',
]);
if (!isset($filtered['startFrom']) && isset($filtered['from'])) {
$filtered['startFrom'] = $filtered['from'];
}
if (!isset($filtered['startTo']) && isset($filtered['to'])) {
$filtered['startTo'] = $filtered['to'];
}
if (!isset($filtered['employeeID']) && isset($filtered['employeeId'])) {
$filtered['employeeID'] = $filtered['employeeId'];
}
unset($filtered['from'], $filtered['to'], $filtered['employeeId']);
if (!isset($filtered['startFrom']) || trim((string)$filtered['startFrom']) === '') {
throw new Exception('Workfeed shift query requires startFrom.');
}
if (!isset($filtered['startTo']) || trim((string)$filtered['startTo']) === '') {
throw new Exception('Workfeed shift query requires startTo.');
}
if (isset($filtered['released'])) {
$filtered['released'] = $this->normalizeBooleanQueryValue($filtered['released']);
}
return $filtered;
}
private function normalizeBooleanQueryValue(mixed $value): string
{
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_string($value)) {
$normalized = strtolower(trim($value));
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
return 'true';
}
if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) {
return 'false';
}
}
if (is_int($value)) {
return $value === 1 ? 'true' : 'false';
}
return (string)$value;
}
private function extractErrorMessage(mixed $decoded, int $statusCode, string $fallback): string
{
if (is_object($decoded)) {
if (isset($decoded->message) && is_string($decoded->message)) {
return $decoded->message;
}
if (isset($decoded->error) && is_string($decoded->error)) {
return $decoded->error;
}
}
if (is_string($decoded) && trim($decoded) !== '') {
return $decoded;
}
return $fallback . ' (HTTP ' . $statusCode . ').';
}
}
@@ -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;
}
}
@@ -0,0 +1,285 @@
<?php
namespace classes;
use DateTime;
use DateTimeZone;
use Exception;
final class workfeed_shift_time_resolver
{
private const MAX_UNAPPROVED_EXTENSION_SECONDS = 6 * 3600;
private const MAX_UNAPPROVED_SHIFT_SPAN_SECONDS = 24 * 3600;
/**
* @return array{actualStart:DateTime,scheduledEnd:DateTime,actualEnd:DateTime,hasApproval:bool}|null
*/
public static function resolveShiftTiming(mixed $record_value): ?array
{
$record = self::normalizeRecord($record_value);
if ($record === []) {
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',
'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',
'endTime',
'to',
]);
$actual_only_end = self::firstDateTimeFromPaths($record, [
'checkOut.time',
'checkOut',
'actualEnd',
'actualEndTime',
'clockOut',
'clockOutTime',
]);
$check_in_punch = self::firstDateTimeFromPaths($record, [
'checkIn.time',
'checkIn',
]);
$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;
}
$actual_end = self::resolveActualEnd($record, $actual_start, $scheduled_end, $saved_actual_end, $actual_only_end);
if ($actual_end->getTimestamp() <= $actual_start->getTimestamp()) {
return null;
}
return [
'actualStart' => $actual_start,
'scheduledEnd' => $scheduled_end,
'actualEnd' => $actual_end,
'hasApproval' => $has_approval,
];
}
public static function calculateOvertimeHoursInRange(mixed $record_value, DateTime $range_start, DateTime $range_end_exclusive): float
{
$timing = self::resolveShiftTiming($record_value);
if ($timing === null) {
return 0.0;
}
$scheduled_end_ts = $timing['scheduledEnd']->getTimestamp();
$actual_end_ts = $timing['actualEnd']->getTimestamp();
if ($actual_end_ts <= $scheduled_end_ts) {
return 0.0;
}
$overtime_start_ts = max($scheduled_end_ts, $range_start->getTimestamp());
$overtime_end_ts = min($actual_end_ts, $range_end_exclusive->getTimestamp());
if ($overtime_end_ts <= $overtime_start_ts) {
return 0.0;
}
return round(($overtime_end_ts - $overtime_start_ts) / 3600, 2);
}
/**
* @return array<string,mixed>
*/
private static function normalizeRecord(mixed $record): array
{
if (is_array($record)) {
return $record;
}
if (is_object($record)) {
return get_object_vars($record);
}
return [];
}
private static function getNestedRecordValue(array $record, string $path): mixed
{
$segments = explode('.', $path);
$current = $record;
foreach ($segments as $segment) {
if (is_array($current)) {
if (!array_key_exists($segment, $current)) {
return null;
}
$current = $current[$segment];
continue;
}
if (is_object($current)) {
if (!property_exists($current, $segment)) {
return null;
}
$current = $current->$segment;
continue;
}
return null;
}
return $current;
}
/**
* @param array<int,string> $paths
*/
private static function firstDateTimeFromPaths(array $record, array $paths): ?DateTime
{
foreach ($paths as $path) {
$parsed = self::parseDateTimeValue(self::getNestedRecordValue($record, $path));
if ($parsed !== null) {
return $parsed;
}
}
return null;
}
private static function parseDateTimeValue(mixed $value): ?DateTime
{
if (is_string($value)) {
$normalized = trim($value);
if ($normalized === '') {
return null;
}
try {
return new DateTime($normalized);
} catch (Exception) {
if (!is_numeric($normalized)) {
return null;
}
$value = (float)$normalized;
}
}
if (is_int($value) || is_float($value)) {
if (!is_finite((float)$value)) {
return null;
}
$timestamp = (float)$value;
if ($timestamp > 9999999999) {
$timestamp /= 1000;
}
try {
$date = new DateTime('@' . (string)(int)round($timestamp));
$date->setTimezone(new DateTimeZone('UTC'));
return $date;
} catch (Exception) {
return null;
}
}
$record = self::normalizeRecord($value);
foreach (['seconds', '_seconds', 'epochSeconds', 'timestamp'] as $key) {
if (!array_key_exists($key, $record)) {
continue;
}
$parsed = self::parseDateTimeValue($record[$key]);
if ($parsed !== null) {
return $parsed;
}
}
return null;
}
private static function hasShiftApproval(array $record): bool
{
if (!array_key_exists('approval', $record)) {
return false;
}
$approval = $record['approval'];
if ($approval === null) {
return false;
}
if (is_array($approval)) {
return $approval !== [];
}
if (is_object($approval)) {
return get_object_vars($approval) !== [];
}
return true;
}
private static function resolveActualEnd(
array $record,
DateTime $actual_start,
DateTime $scheduled_end,
DateTime $saved_actual_end,
?DateTime $actual_only_end
): DateTime {
if (self::hasShiftApproval($record) || $actual_only_end !== null) {
return $saved_actual_end;
}
$update_time = self::parseDateTimeValue($record['updateTime'] ?? null);
if ($update_time === null) {
return $saved_actual_end;
}
$shift_start_ts = $actual_start->getTimestamp();
$scheduled_end_ts = $scheduled_end->getTimestamp();
$saved_actual_end_ts = $saved_actual_end->getTimestamp();
$update_ts = $update_time->getTimestamp();
$fallback_base_ts = max($saved_actual_end_ts, $scheduled_end_ts);
if ($update_ts <= $fallback_base_ts) {
return $saved_actual_end;
}
// Only use updateTime as an overtime hint when no explicit actual end was saved.
if (($update_ts - $scheduled_end_ts) > self::MAX_UNAPPROVED_EXTENSION_SECONDS) {
return $saved_actual_end;
}
if (($update_ts - $shift_start_ts) > self::MAX_UNAPPROVED_SHIFT_SPAN_SECONDS) {
return $saved_actual_end;
}
return $update_time;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,145 @@
<?php
namespace classes;
/**
* Ensures additive schema for XL Vask usage-log review state.
*/
class xlvask_usage_logs_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
if (!self::tableExists($db, 'xlvask_usage_logs')) {
return;
}
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;
}
private static function ensureAutomationTables(object $db): void
{
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_suggestions` (
`id` INT NOT NULL AUTO_INCREMENT,
`usage_log_id` INT NOT NULL,
`wash_id` VARCHAR(128) NOT NULL,
`signature_hash` CHAR(64) NOT NULL,
`signature_json` LONGTEXT NULL,
`action` VARCHAR(32) NOT NULL,
`status` VARCHAR(32) NOT NULL DEFAULT 'suggested',
`confidence` DECIMAL(5,4) NOT NULL DEFAULT 0.0000,
`source` VARCHAR(32) NOT NULL DEFAULT 'deterministic',
`matched_order_id` INT NULL,
`created_order_id` INT NULL,
`proposed_order_json` LONGTEXT NULL,
`candidate_order_json` LONGTEXT NULL,
`reason` TEXT NULL,
`created_by` INT NULL,
`decided_by` INT NULL,
`decided_at` DATETIME NULL,
`executed_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_xlvask_automation_usage` (`usage_log_id`),
KEY `idx_xlvask_automation_wash` (`wash_id`),
KEY `idx_xlvask_automation_signature` (`signature_hash`),
KEY `idx_xlvask_automation_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_feedback` (
`id` INT NOT NULL AUTO_INCREMENT,
`usage_log_id` INT NULL,
`wash_id` VARCHAR(128) NULL,
`signature_hash` CHAR(64) NOT NULL,
`signature_json` LONGTEXT NULL,
`action` VARCHAR(32) NOT NULL,
`decision` VARCHAR(32) NOT NULL,
`order_id` INT NULL,
`reason` TEXT NULL,
`created_by` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_xlvask_feedback_signature_action` (`signature_hash`, `action`),
KEY `idx_xlvask_feedback_usage` (`usage_log_id`),
KEY `idx_xlvask_feedback_decision` (`decision`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_openai_cache` (
`id` INT NOT NULL AUTO_INCREMENT,
`cache_key` CHAR(64) NOT NULL,
`schema_name` VARCHAR(96) NOT NULL,
`input_json` LONGTEXT NOT NULL,
`result_json` LONGTEXT NOT NULL,
`hits` INT NOT NULL DEFAULT 0,
`last_hit_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`),
KEY `idx_xlvask_openai_cache_schema` (`schema_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
}
private static function addColumnIfMissing(object $db, string $table, string $column, string $definition): void
{
if (!self::columnExists($db, $table, $column)) {
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
}
}
private static function tableExists(object $db, string $table): bool
{
$table = self::escapeIdentifier($table);
$result = $db->query("SHOW TABLES LIKE '{$table}'");
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
return false;
}
return (int)$result->num_rows > 0;
}
private static function columnExists(object $db, string $table, string $column): bool
{
$table = self::escapeIdentifier($table);
$column = self::escapeIdentifier($column);
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
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);
}
}