Merge master into fix-system-search-associations-vulnerability
This commit is contained in:
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
require_once WD . '/interfaces/universal_module_i.php';
|
||||
require_once WD . '/modules/coolify/coolify_c.php';
|
||||
|
||||
use Exception;
|
||||
use interfaces\universal_module_i;
|
||||
use modules\coolify\coolify_c;
|
||||
|
||||
class coolify implements universal_module_i
|
||||
{
|
||||
public coolify_c $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = new coolify_c();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function requireModuleEnabled(): void
|
||||
{
|
||||
if (!$this->config->enabled->isTrue()) {
|
||||
throw new Exception('The Coolify module is not enabled');
|
||||
}
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
try {
|
||||
return $this->config->enabled->isTrue();
|
||||
} catch (Exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class coolify_api_client
|
||||
{
|
||||
private string $baseUrl;
|
||||
private string $token;
|
||||
private int $timeoutSeconds;
|
||||
|
||||
public function __construct(string $baseUrl, string $token, int $timeoutSeconds = 4)
|
||||
{
|
||||
$this->baseUrl = self::normalizeBaseUrl($baseUrl);
|
||||
$this->token = trim($token);
|
||||
$this->timeoutSeconds = max(1, $timeoutSeconds);
|
||||
if ($this->baseUrl === '' || $this->token === '') {
|
||||
throw new RuntimeException('Coolify base URL and API token are required.');
|
||||
}
|
||||
}
|
||||
|
||||
public static function normalizeBaseUrl(string $baseUrl): string
|
||||
{
|
||||
$baseUrl = rtrim(trim($baseUrl), '/');
|
||||
if ($baseUrl === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (preg_match('#/api/v[0-9]+$#i', $baseUrl) === 1) {
|
||||
return $baseUrl;
|
||||
}
|
||||
|
||||
return $baseUrl . '/api/v1';
|
||||
}
|
||||
|
||||
public function healthcheck(): array
|
||||
{
|
||||
return $this->request('GET', '/health', null, false);
|
||||
}
|
||||
|
||||
public function version(): array
|
||||
{
|
||||
return $this->request('GET', '/version');
|
||||
}
|
||||
|
||||
public function listServers(): array
|
||||
{
|
||||
return $this->request('GET', '/servers');
|
||||
}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
return $this->request('GET', '/projects');
|
||||
}
|
||||
|
||||
public function listProjectEnvironments(string $projectUuid): array
|
||||
{
|
||||
return $this->request('GET', '/projects/' . rawurlencode($projectUuid) . '/environments');
|
||||
}
|
||||
|
||||
public function listServices(): array
|
||||
{
|
||||
return $this->request('GET', '/services');
|
||||
}
|
||||
|
||||
public function listGithubApps(): array
|
||||
{
|
||||
return $this->request('GET', '/github-apps');
|
||||
}
|
||||
|
||||
public function getService(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/services/' . rawurlencode($uuid));
|
||||
}
|
||||
|
||||
public function createService(array $payload): array
|
||||
{
|
||||
return $this->request('POST', '/services', $payload);
|
||||
}
|
||||
|
||||
public function createPrivateGithubAppApplication(array $payload): array
|
||||
{
|
||||
return $this->request('POST', '/applications/private-github-app', $payload);
|
||||
}
|
||||
|
||||
public function getApplication(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid));
|
||||
}
|
||||
|
||||
public function updateApplication(string $uuid, array $payload): array
|
||||
{
|
||||
return $this->request('PATCH', '/applications/' . rawurlencode($uuid), $payload);
|
||||
}
|
||||
|
||||
public function updateService(string $uuid, array $payload): array
|
||||
{
|
||||
return $this->request('PATCH', '/services/' . rawurlencode($uuid), $payload);
|
||||
}
|
||||
|
||||
public function updateServiceEnvsBulk(string $uuid, array $env): array
|
||||
{
|
||||
if ($env === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->request('PATCH', '/services/' . rawurlencode($uuid) . '/envs/bulk', [
|
||||
'data' => self::bulkEnvData($env),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateApplicationEnvsBulk(string $uuid, array $env): array
|
||||
{
|
||||
if ($env === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->request('PATCH', '/applications/' . rawurlencode($uuid) . '/envs/bulk', [
|
||||
'data' => self::bulkEnvData($env),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function bulkEnvData(array $env): array
|
||||
{
|
||||
$data = [];
|
||||
foreach ($env as $key => $value) {
|
||||
$data[] = [
|
||||
'key' => (string)$key,
|
||||
'value' => (string)$value,
|
||||
'is_preview' => false,
|
||||
'is_literal' => true,
|
||||
'is_multiline' => str_contains((string)$value, "\n"),
|
||||
'is_shown_once' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function deployResource(string $uuid, bool $force = false): array
|
||||
{
|
||||
$path = '/deploy?uuid=' . rawurlencode($uuid) . '&force=' . ($force ? 'true' : 'false');
|
||||
return $this->request('GET', $path);
|
||||
}
|
||||
|
||||
public function startService(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/services/' . rawurlencode($uuid) . '/start');
|
||||
}
|
||||
|
||||
public function restartService(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/services/' . rawurlencode($uuid) . '/restart');
|
||||
}
|
||||
|
||||
public function restartApplication(string $uuid): array
|
||||
{
|
||||
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
|
||||
}
|
||||
|
||||
public function deleteService(string $uuid): array
|
||||
{
|
||||
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
|
||||
}
|
||||
|
||||
public function listDeployments(): array
|
||||
{
|
||||
return $this->request('GET', '/deployments');
|
||||
}
|
||||
|
||||
protected function request(string $method, string $path, ?array $payload = null, bool $versionedApi = true): array
|
||||
{
|
||||
$url = ($versionedApi ? $this->baseUrl : $this->apiRootUrl()) . '/' . ltrim($path, '/');
|
||||
$curl = curl_init($url);
|
||||
if ($curl === false) {
|
||||
throw new RuntimeException('Could not initialize Coolify API request.');
|
||||
}
|
||||
|
||||
$headers = [
|
||||
'Accept: application/json',
|
||||
'Authorization: Bearer ' . $this->token,
|
||||
];
|
||||
|
||||
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, strtoupper($method));
|
||||
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, min(2, $this->timeoutSeconds));
|
||||
curl_setopt($curl, CURLOPT_TIMEOUT, $this->timeoutSeconds);
|
||||
curl_setopt($curl, CURLOPT_NOSIGNAL, true);
|
||||
curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
|
||||
|
||||
if ($payload !== null) {
|
||||
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($body === false) {
|
||||
throw new RuntimeException('Could not encode Coolify API payload.');
|
||||
}
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
|
||||
|
||||
$raw = curl_exec($curl);
|
||||
$error = curl_error($curl);
|
||||
$status = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
curl_close($curl);
|
||||
|
||||
if ($raw === false) {
|
||||
throw new RuntimeException('Coolify API request failed: ' . $error);
|
||||
}
|
||||
|
||||
$decoded = null;
|
||||
if (trim((string)$raw) !== '') {
|
||||
$decoded = json_decode((string)$raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
$decoded = ['raw' => (string)$raw];
|
||||
}
|
||||
}
|
||||
|
||||
if ($status < 200 || $status >= 300) {
|
||||
$message = is_array($decoded)
|
||||
? (string)($decoded['message'] ?? $decoded['error'] ?? ('HTTP ' . $status))
|
||||
: ('HTTP ' . $status);
|
||||
if (is_array($decoded)) {
|
||||
$details = self::validationErrorSummary($decoded);
|
||||
if ($details !== '') {
|
||||
$message .= ': ' . $details;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException('Coolify API request failed: ' . $message);
|
||||
}
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private static function validationErrorSummary(array $decoded): string
|
||||
{
|
||||
$errors = $decoded['errors'] ?? $decoded['data']['errors'] ?? null;
|
||||
if (!is_array($errors)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
foreach ($errors as $field => $messages) {
|
||||
$fieldName = trim((string)$field);
|
||||
$fieldPrefix = $fieldName !== '' ? $fieldName . ': ' : '';
|
||||
if (is_array($messages)) {
|
||||
$messages = implode(', ', array_filter(array_map(static fn(mixed $message): string => trim((string)$message), $messages)));
|
||||
} else {
|
||||
$messages = trim((string)$messages);
|
||||
}
|
||||
if ($messages !== '') {
|
||||
$parts[] = $fieldPrefix . $messages;
|
||||
}
|
||||
}
|
||||
|
||||
return implode('; ', array_slice($parts, 0, 5));
|
||||
}
|
||||
|
||||
private function apiRootUrl(): string
|
||||
{
|
||||
return preg_replace('#/v[0-9]+$#i', '', $this->baseUrl) ?: $this->baseUrl;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Additive schema bootstrap for the Coolify infrastructure integration.
|
||||
*
|
||||
* The legacy stack has no centralized migration runner, so this class must be
|
||||
* safe to call from request handlers, cron, and tests.
|
||||
*/
|
||||
class coolify_schema_bootstrap
|
||||
{
|
||||
private static bool $initialized = false;
|
||||
private static ?bool $tablesExist = null;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
$queries = [
|
||||
"CREATE TABLE IF NOT EXISTS coolify_instances (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
label VARCHAR(128) NOT NULL,
|
||||
base_url VARCHAR(512) NOT NULL,
|
||||
api_token_secret TEXT NOT NULL,
|
||||
default_project_uuid VARCHAR(128) NULL,
|
||||
default_environment_uuid VARCHAR(128) NULL,
|
||||
default_environment_name VARCHAR(128) NULL,
|
||||
default_server_uuid VARCHAR(128) NULL,
|
||||
default_destination_uuid VARCHAR(128) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'unknown',
|
||||
last_checked_at DATETIME NULL,
|
||||
last_error TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
INDEX idx_coolify_instances_status (status),
|
||||
INDEX idx_coolify_instances_deleted_at (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS coolify_targets (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
instance_id BIGINT UNSIGNED NOT NULL,
|
||||
replication_host_id BIGINT UNSIGNED NULL,
|
||||
kind VARCHAR(16) NOT NULL,
|
||||
label VARCHAR(128) NOT NULL,
|
||||
role VARCHAR(16) NOT NULL DEFAULT 'replica',
|
||||
server_uuid VARCHAR(128) NULL,
|
||||
project_uuid VARCHAR(128) NULL,
|
||||
environment_uuid VARCHAR(128) NULL,
|
||||
environment_name VARCHAR(128) NULL,
|
||||
destination_uuid VARCHAR(128) NULL,
|
||||
resource_uuid VARCHAR(128) NULL,
|
||||
resource_type VARCHAR(32) NOT NULL DEFAULT 'service',
|
||||
resource_name VARCHAR(128) NULL,
|
||||
deployment_status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
availability_state VARCHAR(32) NOT NULL DEFAULT 'degraded',
|
||||
desired_compose_hash CHAR(64) NULL,
|
||||
last_reconcile_status VARCHAR(32) NULL,
|
||||
last_reconcile_json LONGTEXT NULL,
|
||||
last_reconciled_at DATETIME NULL,
|
||||
options_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
INDEX idx_coolify_targets_instance (instance_id),
|
||||
INDEX idx_coolify_targets_replication_host (replication_host_id),
|
||||
INDEX idx_coolify_targets_kind_status (kind, deployment_status),
|
||||
INDEX idx_coolify_targets_availability (availability_state),
|
||||
INDEX idx_coolify_targets_resource_uuid (resource_uuid),
|
||||
INDEX idx_coolify_targets_deleted_at (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS coolify_operations (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
target_id BIGINT UNSIGNED NULL,
|
||||
instance_id BIGINT UNSIGNED NULL,
|
||||
operation VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'running',
|
||||
guarded TINYINT(1) NOT NULL DEFAULT 1,
|
||||
message VARCHAR(512) NULL,
|
||||
error_message TEXT NULL,
|
||||
context_json LONGTEXT NULL,
|
||||
actor_user_id INT NULL,
|
||||
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_coolify_operations_target (target_id),
|
||||
INDEX idx_coolify_operations_instance (instance_id),
|
||||
INDEX idx_coolify_operations_status (status),
|
||||
INDEX idx_coolify_operations_operation (operation)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS coolify_audit_logs (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
target_id BIGINT UNSIGNED NULL,
|
||||
instance_id BIGINT UNSIGNED NULL,
|
||||
replication_host_id BIGINT UNSIGNED NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
actor_user_id INT NULL,
|
||||
severity VARCHAR(16) NOT NULL DEFAULT 'info',
|
||||
context_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_coolify_audit_target (target_id),
|
||||
INDEX idx_coolify_audit_instance (instance_id),
|
||||
INDEX idx_coolify_audit_replication_host (replication_host_id),
|
||||
INDEX idx_coolify_audit_action (action),
|
||||
INDEX idx_coolify_audit_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS coolify_instance_gateways (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
instance_id BIGINT UNSIGNED NULL,
|
||||
hostname VARCHAR(255) NOT NULL,
|
||||
target_ip VARCHAR(64) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
priority INT NOT NULL DEFAULT 100,
|
||||
health_state VARCHAR(32) NOT NULL DEFAULT 'unknown',
|
||||
lb_state VARCHAR(32) NOT NULL DEFAULT 'unknown',
|
||||
last_probe_json LONGTEXT NULL,
|
||||
last_probed_at DATETIME NULL,
|
||||
last_reconciled_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL,
|
||||
UNIQUE KEY uniq_coolify_instance_gateways_target_ip (target_ip),
|
||||
INDEX idx_coolify_instance_gateways_instance (instance_id),
|
||||
INDEX idx_coolify_instance_gateways_enabled (enabled),
|
||||
INDEX idx_coolify_instance_gateways_lb_state (lb_state),
|
||||
INDEX idx_coolify_instance_gateways_deleted_at (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $sql) {
|
||||
$db->query($sql);
|
||||
}
|
||||
|
||||
self::ensureColumn('coolify_instances', 'default_destination_uuid', 'VARCHAR(128) NULL');
|
||||
self::ensureColumn('coolify_targets', 'availability_state', "VARCHAR(32) NOT NULL DEFAULT 'degraded'");
|
||||
self::ensureColumn('coolify_targets', 'desired_compose_hash', 'CHAR(64) NULL');
|
||||
self::ensureColumn('coolify_targets', 'last_reconcile_json', 'LONGTEXT NULL');
|
||||
self::ensureColumn('coolify_operations', 'guarded', 'TINYINT(1) NOT NULL DEFAULT 1');
|
||||
self::ensureColumn('coolify_instance_gateways', 'last_reconciled_at', 'DATETIME NULL');
|
||||
|
||||
self::ensureModuleConfigDefault('Coolify', 'enabled', 'false', 'bool');
|
||||
self::ensureModuleConfigDefault('Coolify', 'lb_automation_enabled', 'false', 'bool');
|
||||
self::ensureModuleConfigDefault('Coolify', 'lb_automation_mode', 'report_only', 'string');
|
||||
self::ensureModuleConfigDefault('Coolify', 'hetzner_load_balancer_id', '', 'string');
|
||||
self::ensureModuleConfigDefault('Coolify', 'hetzner_cloud_api_token', '', 'string');
|
||||
self::ensureModuleConfigDefault('Coolify', 'public_gateway_host', 'api-v2.truckwash.io', 'string');
|
||||
self::ensureModuleConfigDefault('Coolify', 'public_gateway_probe_path', '', 'string');
|
||||
|
||||
self::ensureDefaultGateway('node1.truckwash.io', '94.130.142.41', 10);
|
||||
self::ensureDefaultGateway('node2.truckwash.io', '65.21.214.30', 20);
|
||||
self::ensureDefaultGateway('node3.truckwash.io', '23.88.23.183', 30);
|
||||
|
||||
self::$initialized = true;
|
||||
self::$tablesExist = true;
|
||||
}
|
||||
|
||||
public static function tablesExist(): bool
|
||||
{
|
||||
if (self::$tablesExist !== null) {
|
||||
return self::$tablesExist;
|
||||
}
|
||||
|
||||
global $db;
|
||||
|
||||
foreach (['coolify_instances', 'coolify_targets', 'coolify_operations', 'coolify_audit_logs', 'coolify_instance_gateways'] as $table) {
|
||||
$tableSql = $db->escape_string($table);
|
||||
$result = $db->query("SHOW TABLES LIKE '$tableSql'");
|
||||
if ($result === false || $result->num_rows === 0) {
|
||||
self::$tablesExist = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
self::$tablesExist = true;
|
||||
return self::$tablesExist;
|
||||
}
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
|
||||
if ($table === '' || $column === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
|
||||
}
|
||||
|
||||
private static function ensureModuleConfigDefault(string $module, string $variable, string $value, string $type): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$moduleSql = $db->escape_string($module);
|
||||
$variableSql = $db->escape_string($variable);
|
||||
$result = $db->query("SELECT value FROM module_config WHERE module = '$moduleSql' AND variable = '$variableSql' LIMIT 1");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$valueSql = $db->escape_string($value);
|
||||
$typeSql = $db->escape_string($type);
|
||||
$db->query("INSERT INTO module_config (module, variable, value, type) VALUES ('$moduleSql', '$variableSql', '$valueSql', '$typeSql')");
|
||||
}
|
||||
|
||||
private static function ensureDefaultGateway(string $hostname, string $targetIp, int $priority): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$targetIpSql = $db->escape_string($targetIp);
|
||||
$result = $db->query("SELECT id FROM coolify_instance_gateways WHERE target_ip = '$targetIpSql' LIMIT 1");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hostnameSql = $db->escape_string($hostname);
|
||||
$db->query(
|
||||
"INSERT INTO coolify_instance_gateways (hostname, target_ip, enabled, priority)
|
||||
VALUES ('$hostnameSql', '$targetIpSql', 1, " . (int)$priority . ")"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class cors_policy
|
||||
{
|
||||
public const ALLOWED_HEADERS = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
|
||||
public const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
|
||||
public const MAX_AGE_SECONDS = '86400';
|
||||
|
||||
private const REQUIRED_ALLOWED_ORIGINS = [
|
||||
'https://truckwash.io',
|
||||
'https://www.truckwash.io',
|
||||
'https://api.truckwash.io',
|
||||
'https://api.truckwash.io:4433',
|
||||
'https://api-v2.truckwash.io',
|
||||
'https://web.truckwash.dk',
|
||||
'https://api.truckwash.dk',
|
||||
'https://truckwash.dk',
|
||||
'https://www.truckwash.dk',
|
||||
'https://staging.truckwash.io',
|
||||
'http://localhost',
|
||||
'https://localhost',
|
||||
'http://localhost:4433',
|
||||
'https://localhost:4433',
|
||||
'https://twdev.jeppeb.dk',
|
||||
'http://localhost:5173',
|
||||
];
|
||||
|
||||
public static function normalizeOrigin(?string $value): string
|
||||
{
|
||||
$value = trim((string)$value);
|
||||
if ($value === '' || $value === '*') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (preg_match('#^https?://#i', $value) !== 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parts = parse_url($value);
|
||||
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$scheme = strtolower((string)$parts['scheme']);
|
||||
if (!in_array($scheme, ['http', 'https'], true)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$host = strtolower((string)$parts['host']);
|
||||
$port = isset($parts['port']) ? ':' . (int)$parts['port'] : '';
|
||||
|
||||
return $scheme . '://' . $host . $port;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public static function requiredAllowedOrigins(): array
|
||||
{
|
||||
return self::REQUIRED_ALLOWED_ORIGINS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public static function allowedOrigins(string $corsConfig): array
|
||||
{
|
||||
$origins = [];
|
||||
foreach (self::splitOrigins($corsConfig) as $configuredOrigin) {
|
||||
if ($configuredOrigin === '*') {
|
||||
return ['*'];
|
||||
}
|
||||
|
||||
$origin = self::normalizeOrigin($configuredOrigin);
|
||||
if ($origin !== '') {
|
||||
$origins[$origin] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (self::REQUIRED_ALLOWED_ORIGINS as $requiredOrigin) {
|
||||
$origin = self::normalizeOrigin($requiredOrigin);
|
||||
if ($origin !== '') {
|
||||
$origins[$origin] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($origins);
|
||||
}
|
||||
|
||||
public static function withRequiredOrigins(string $corsConfig): string
|
||||
{
|
||||
$allowedOrigins = self::allowedOrigins($corsConfig);
|
||||
if ($allowedOrigins === ['*']) {
|
||||
return '*';
|
||||
}
|
||||
|
||||
return implode(',', $allowedOrigins);
|
||||
}
|
||||
|
||||
public static function isOriginAllowed(?string $origin, string $corsConfig): bool
|
||||
{
|
||||
$origin = self::normalizeOrigin($origin);
|
||||
if ($origin === '' || $origin === '*') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowedOrigins = self::allowedOrigins($corsConfig);
|
||||
return in_array('*', $allowedOrigins, true) || in_array($origin, $allowedOrigins, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public static function responseHeaders(?string $origin, string $corsConfig): array
|
||||
{
|
||||
$origin = self::normalizeOrigin($origin);
|
||||
if ($origin === '' || !self::isOriginAllowed($origin, $corsConfig)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'Access-Control-Allow-Origin' => $origin,
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
|
||||
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
|
||||
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
|
||||
'Vary' => 'Origin',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{allowed:bool,status:int,headers:array<string,string>,body:string}
|
||||
*/
|
||||
public static function preflightResponse(?string $origin, string $corsConfig): array
|
||||
{
|
||||
$headers = self::responseHeaders($origin, $corsConfig);
|
||||
if ($headers === []) {
|
||||
return [
|
||||
'allowed' => false,
|
||||
'status' => 403,
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'body' => json_encode(['success' => false, 'message' => 'CORS origin not allowed']) ?: '',
|
||||
];
|
||||
}
|
||||
|
||||
$headers['Content-Type'] = 'application/json';
|
||||
return [
|
||||
'allowed' => true,
|
||||
'status' => 200,
|
||||
'headers' => $headers,
|
||||
'body' => '',
|
||||
];
|
||||
}
|
||||
|
||||
public static function applyResponseHeaders(string $corsConfig, ?string $origin = null): bool
|
||||
{
|
||||
$headers = self::responseHeaders($origin ?? ($_SERVER['HTTP_ORIGIN'] ?? ''), $corsConfig);
|
||||
if ($headers === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self::emitHeaders($headers);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $headers
|
||||
*/
|
||||
public static function emitHeaders(array $headers): void
|
||||
{
|
||||
foreach ($headers as $name => $value) {
|
||||
header($name . ': ' . $value, strtolower((string)$name) !== 'vary');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private static function splitOrigins(string $corsConfig): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
array_map('trim', explode(',', $corsConfig)),
|
||||
static fn(string $origin): bool => $origin !== ''
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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') ?: ''));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 . ').';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user