Add customer mass import service with API route, test coverage, and e-conomic integration

This commit is contained in:
Jeppe Bundgaard
2026-04-23 21:07:21 +02:00
parent 4420d76cd2
commit 0813bfc0f0
27 changed files with 2801 additions and 238 deletions
@@ -124,7 +124,7 @@ class edge_gateway_manager
'status' => self::INSTALL_SESSION_STATUS_PENDING,
'step' => self::INSTALL_SESSION_STATUS_PENDING,
'message' => 'Installer command generated. Run it on the gateway host.',
], strtotime($expiresAt) - self::INSTALL_TOKEN_TTL_SECONDS),
], (self::parseApplicationDateTime($expiresAt) ?? time()) - self::INSTALL_TOKEN_TTL_SECONDS),
],
]);
@@ -1735,8 +1735,10 @@ BASH;
throw new Exception('Shell session is closed');
}
$expiresAt = $session->expires_at->value() === null ? null : strtotime((string)$session->expires_at->value());
if ($expiresAt !== null && $expiresAt !== false && $expiresAt <= time()) {
$expiresAt = self::parseApplicationDateTime(
$session->expires_at->value() === null ? null : (string)$session->expires_at->value()
);
if ($expiresAt !== null && $expiresAt <= time()) {
$session->status->set('EXPIRED');
$session->closed_at->set($this->now());
throw new Exception('Shell session expired');
@@ -2071,7 +2073,11 @@ BASH;
'events' => self::sanitizeInstallSessionEvents($session['events'] ?? []),
];
$status = (string)$normalized['status'];
if (!self::installSessionStatusIsTerminal($status) && $expiresAt !== null && strtotime($expiresAt) < ($now ?? time())) {
if (
!self::installSessionStatusIsTerminal($status)
&& $expiresAt !== null
&& (self::parseApplicationDateTime($expiresAt) ?? PHP_INT_MAX) < ($now ?? time())
) {
$status = self::INSTALL_SESSION_STATUS_EXPIRED;
$normalized['status'] = $status;
$normalized['message'] = $normalized['message'] ?: 'Installer token expired before the gateway claimed successfully.';
@@ -2257,7 +2263,16 @@ BASH;
$host .= ':' . $port;
}
return $scheme . '://' . $host;
$forwardedPrefix = $this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_PREFIX'] ?? null);
if ($forwardedPrefix !== null) {
$normalizedForwardedPrefix = '/' . trim($forwardedPrefix, '/');
$basePath = $normalizedForwardedPrefix === '/' ? '' : $normalizedForwardedPrefix;
} else {
$requestPath = parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
$basePath = (is_string($requestPath) && preg_match('#^/api(?:/|$)#', $requestPath) === 1) ? '/api' : '';
}
return $scheme . '://' . $host . $basePath;
}
private function detectForwardedScheme(): ?string
@@ -2383,7 +2398,7 @@ BASH;
if (!$claimToken->exists()) {
throw new Exception('Invalid install token');
}
if (strtotime((string)$claimToken->expires_at->value()) < time()) {
if ((self::parseApplicationDateTime((string)$claimToken->expires_at->value()) ?? 0) < time()) {
throw new Exception('Install token has expired');
}
@@ -2597,7 +2612,7 @@ BASH;
try {
$statement = $pdo->prepare(
'SELECT id
'SELECT id, delivery_json
FROM edge_gateway_command_jobs
WHERE gateway_id = :gateway_id
AND deleted_at IS NULL
@@ -2626,16 +2641,25 @@ BASH;
return null;
}
$delivery = isset($row['delivery_json']) && is_string($row['delivery_json'])
? json_decode($row['delivery_json'], true)
: [];
if (!is_array($delivery)) {
$delivery = [];
}
$delivery['delivery_channel'] = self::DELIVERY_CHANNEL_API;
$delivery['attempt_count'] = ((int)($delivery['attempt_count'] ?? 0)) + 1;
$delivery['last_dispatch_error'] = null;
$encodedDelivery = json_encode($delivery, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encodedDelivery)) {
$encodedDelivery = '{}';
}
$update = $pdo->prepare(
'UPDATE edge_gateway_command_jobs
SET status = :status,
response_json = :response_json,
delivery_json = JSON_SET(
COALESCE(delivery_json, JSON_OBJECT()),
\'$.delivery_channel\', :delivery_channel,
\'$.attempt_count\', COALESCE(JSON_EXTRACT(COALESCE(delivery_json, JSON_OBJECT()), \'$.attempt_count\'), 0) + 1,
\'$.last_dispatch_error\', CAST(NULL AS JSON)
),
delivery_json = :delivery_json,
error_message = NULL,
completed_at = NULL
WHERE id = :id'
@@ -2643,7 +2667,7 @@ BASH;
$update->execute([
':status' => 'DISPATCHING',
':response_json' => json_encode([], JSON_UNESCAPED_UNICODE),
':delivery_channel' => json_encode(self::DELIVERY_CHANNEL_API, JSON_UNESCAPED_UNICODE),
':delivery_json' => $encodedDelivery,
':id' => (int)$row['id'],
]);
@@ -2655,6 +2679,7 @@ BASH;
throw $throwable;
}
$this->clearObjectPropertyCache('edge_gateway_command_jobs', (int)$row['id']);
return (new edge_gateway_command_jobs_o())->select((int)$row['id']);
}
@@ -4441,8 +4466,8 @@ BASH;
? (array)$gateway['active_operation']
: null;
if ($activeOperation !== null && !empty($activeOperation['started_at'])) {
$startedAt = strtotime((string)$activeOperation['started_at']);
if ($startedAt !== false && (($now ?? time()) - $startedAt) >= edge_gateway_operation_service::OPERATION_TIMEOUT_SECONDS) {
$startedAt = self::parseApplicationDateTime((string)$activeOperation['started_at']);
if ($startedAt !== null && (($now ?? time()) - $startedAt) >= edge_gateway_operation_service::OPERATION_TIMEOUT_SECONDS) {
$diagnostics[] = [
'code' => edge_gateway_operation_service::ERROR_OPERATION_TIMEOUT,
'severity' => 'warning',
@@ -4594,8 +4619,8 @@ BASH;
continue;
}
$epoch = strtotime($timestamp);
if ($epoch === false) {
$epoch = self::parseApplicationDateTime($timestamp);
if ($epoch === null) {
continue;
}
@@ -4622,6 +4647,16 @@ BASH;
return self::RELAY_FALLBACK_PREFER_LOCAL;
}
private function clearObjectPropertyCache(string $table, int $id): void
{
if ($id <= 0 || !defined('redis')) {
return;
}
$normalizedTable = trim($table, " `\t\n\r\0\x0B");
redis->clear_keys('obj_prop:' . $normalizedTable . ':' . $id . ':*');
}
private static function resolveDeviceFreshnessState(?array $device, ?int $ageSeconds): string
{
if ($device === null) {
@@ -4692,8 +4727,8 @@ BASH;
return null;
}
$heartbeatTimestamp = strtotime($lastHeartbeatAt);
if ($heartbeatTimestamp === false) {
$heartbeatTimestamp = self::parseApplicationDateTime($lastHeartbeatAt);
if ($heartbeatTimestamp === null) {
return null;
}
@@ -4706,8 +4741,7 @@ BASH;
return 0;
}
$heartbeatTimestamp = strtotime($lastHeartbeatAt);
return $heartbeatTimestamp === false ? 0 : $heartbeatTimestamp;
return self::parseApplicationDateTime($lastHeartbeatAt) ?? 0;
}
private static function statusPriority(string $status): int
@@ -4822,14 +4856,55 @@ BASH;
return hash('sha256', $plainToken);
}
public static function parseApplicationDateTime(?string $value): ?int
{
$normalized = trim((string)$value);
if ($normalized === '') {
return null;
}
$timezone = self::applicationTimeZone();
$dateTime = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $normalized, $timezone);
if ($dateTime instanceof \DateTimeImmutable) {
return $dateTime->getTimestamp();
}
try {
return (new \DateTimeImmutable($normalized, $timezone))->getTimestamp();
} catch (\Throwable) {
return null;
}
}
public static function formatApplicationDateTime(int $timestamp): string
{
return (new \DateTimeImmutable('@' . $timestamp))
->setTimezone(self::applicationTimeZone())
->format('Y-m-d H:i:s');
}
private static function applicationTimeZone(): \DateTimeZone
{
$timezone = trim((string)($_ENV['CONFIG_TIMEZONE'] ?? getenv('CONFIG_TIMEZONE') ?: 'Europe/Copenhagen'));
if ($timezone === '') {
$timezone = 'Europe/Copenhagen';
}
try {
return new \DateTimeZone($timezone);
} catch (\Throwable) {
return new \DateTimeZone('Europe/Copenhagen');
}
}
private function now(): string
{
return date('Y-m-d H:i:s');
return self::formatApplicationDateTime(time());
}
private function formatDateTime(int $timestamp): string
{
return date('Y-m-d H:i:s', $timestamp);
return self::formatApplicationDateTime($timestamp);
}
private function remoteIp(): ?string