2026-04-02 17:48:27 +02:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace MintyPHP\Module\Helpdesk\Service;
|
|
|
|
|
|
|
|
|
|
use MintyPHP\Service\Settings\SettingsCryptoGatewayInterface;
|
|
|
|
|
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Gateway for helpdesk BC connection settings stored in the DB settings table.
|
|
|
|
|
*
|
|
|
|
|
* All secrets (passwords, client secrets) are encrypted via SettingsCryptoGateway.
|
|
|
|
|
* Setting keys are prefixed with 'helpdesk.' to avoid collisions.
|
|
|
|
|
*/
|
|
|
|
|
class HelpdeskSettingsGateway
|
|
|
|
|
{
|
|
|
|
|
public const KEY_AUTH_MODE = 'helpdesk.bc_auth_mode';
|
|
|
|
|
public const KEY_ODATA_BASE_URL = 'helpdesk.bc_odata_base_url';
|
|
|
|
|
public const KEY_COMPANY_NAME = 'helpdesk.bc_company_name';
|
|
|
|
|
public const KEY_BASIC_USER = 'helpdesk.bc_basic_user';
|
|
|
|
|
public const KEY_BASIC_PASSWORD_ENC = 'helpdesk.bc_basic_password_enc';
|
|
|
|
|
public const KEY_OAUTH_TENANT_ID = 'helpdesk.bc_oauth_tenant_id';
|
|
|
|
|
public const KEY_OAUTH_CLIENT_ID = 'helpdesk.bc_oauth_client_id';
|
|
|
|
|
public const KEY_OAUTH_CLIENT_SECRET_ENC = 'helpdesk.bc_oauth_client_secret_enc';
|
|
|
|
|
public const KEY_OAUTH_TOKEN_ENDPOINT = 'helpdesk.bc_oauth_token_endpoint';
|
2026-04-04 18:34:03 +02:00
|
|
|
public const KEY_SYSTEM_RECOMMENDATIONS_CONFIG = 'helpdesk.system_recommendations_config';
|
|
|
|
|
public const KEY_CONTROLLING_RISK_CONFIG = 'helpdesk.controlling_risk_config';
|
2026-06-25 10:30:05 +02:00
|
|
|
/** @api Read in helpdesk/settings + analytics pages */
|
|
|
|
|
public const KEY_INACTIVITY_THRESHOLD_DAYS = 'helpdesk.inactivity_threshold_days';
|
|
|
|
|
|
|
|
|
|
/** @api Default surfaced in views + tests */
|
|
|
|
|
public const DEFAULT_INACTIVITY_THRESHOLD_DAYS = 60;
|
|
|
|
|
private const MIN_INACTIVITY_THRESHOLD_DAYS = 7;
|
|
|
|
|
private const MAX_INACTIVITY_THRESHOLD_DAYS = 365;
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
|
|
|
public const AUTH_MODE_BASIC = 'basic';
|
|
|
|
|
public const AUTH_MODE_OAUTH2 = 'oauth2';
|
|
|
|
|
|
|
|
|
|
public const DEFAULT_ODATA_BASE_URL = 'https://bc.icoreon.de:7048/BusinessCentral-NUP/ODataV4';
|
|
|
|
|
public const DEFAULT_COMPANY_NAME = 'breadcrumb mediasolutions GmbH';
|
2026-04-04 18:34:03 +02:00
|
|
|
private const DEFAULT_CONTROLLING_RISK_CONFIG = [
|
|
|
|
|
'version' => 1,
|
|
|
|
|
'rules' => [
|
|
|
|
|
'ticket_volume_increase' => [
|
|
|
|
|
'enabled' => true,
|
|
|
|
|
'threshold_percent' => 30,
|
|
|
|
|
],
|
|
|
|
|
'avg_resolution_high' => [
|
|
|
|
|
'enabled' => true,
|
|
|
|
|
'threshold_hours' => 72,
|
|
|
|
|
],
|
|
|
|
|
'open_aging' => [
|
|
|
|
|
'enabled' => true,
|
|
|
|
|
'threshold_hours' => 168,
|
|
|
|
|
],
|
|
|
|
|
'unassigned_ratio' => [
|
|
|
|
|
'enabled' => true,
|
|
|
|
|
'threshold_percent' => 20,
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
private const DEFAULT_SYSTEM_RECOMMENDATIONS_CONFIG = [
|
|
|
|
|
'version' => 1,
|
|
|
|
|
'max_items' => 5,
|
|
|
|
|
'rules' => [
|
|
|
|
|
'escalation_overdue' => [
|
|
|
|
|
'enabled' => true,
|
|
|
|
|
'priority' => 100,
|
|
|
|
|
'min_overdue_minutes' => 0,
|
|
|
|
|
],
|
|
|
|
|
'high_risk_aging' => [
|
|
|
|
|
'enabled' => true,
|
|
|
|
|
'priority' => 90,
|
|
|
|
|
'codes' => ['MAX', 'HOCH'],
|
|
|
|
|
'min_age_hours' => 8,
|
|
|
|
|
],
|
|
|
|
|
'unassigned_open' => [
|
|
|
|
|
'enabled' => true,
|
|
|
|
|
'priority' => 80,
|
|
|
|
|
'min_age_hours' => 2,
|
|
|
|
|
],
|
|
|
|
|
'stale_open' => [
|
|
|
|
|
'enabled' => true,
|
|
|
|
|
'priority' => 70,
|
|
|
|
|
'min_age_hours' => 48,
|
|
|
|
|
],
|
|
|
|
|
'customer_backlog' => [
|
|
|
|
|
'enabled' => true,
|
|
|
|
|
'priority' => 60,
|
|
|
|
|
'min_open_tickets' => 6,
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
];
|
2026-04-02 17:48:27 +02:00
|
|
|
|
|
|
|
|
public function __construct(
|
|
|
|
|
private readonly SettingsMetadataGateway $settingsMetadataGateway,
|
|
|
|
|
private readonly SettingsCryptoGatewayInterface $settingsCryptoGateway
|
|
|
|
|
) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getAuthMode(): string
|
|
|
|
|
{
|
|
|
|
|
$value = $this->settingsMetadataGateway->getValue(self::KEY_AUTH_MODE);
|
|
|
|
|
$value = trim((string) ($value ?? ''));
|
|
|
|
|
|
|
|
|
|
return $value === self::AUTH_MODE_OAUTH2 ? self::AUTH_MODE_OAUTH2 : self::AUTH_MODE_BASIC;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function setAuthMode(string $mode): bool
|
|
|
|
|
{
|
|
|
|
|
$mode = trim($mode);
|
|
|
|
|
if (!in_array($mode, [self::AUTH_MODE_BASIC, self::AUTH_MODE_OAUTH2], true)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->settingsMetadataGateway->set(self::KEY_AUTH_MODE, $mode, 'helpdesk.bc_auth_mode');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getODataBaseUrl(): string
|
|
|
|
|
{
|
|
|
|
|
$value = $this->settingsMetadataGateway->getValue(self::KEY_ODATA_BASE_URL);
|
|
|
|
|
$value = trim((string) ($value ?? ''));
|
|
|
|
|
|
|
|
|
|
return $value !== '' ? rtrim($value, '/') : self::DEFAULT_ODATA_BASE_URL;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function setODataBaseUrl(?string $url): bool
|
|
|
|
|
{
|
|
|
|
|
$url = trim((string) ($url ?? ''));
|
|
|
|
|
if ($url !== '' && !preg_match('#^https://#i', $url)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->settingsMetadataGateway->set(
|
|
|
|
|
self::KEY_ODATA_BASE_URL,
|
|
|
|
|
$url !== '' ? rtrim($url, '/') : null,
|
|
|
|
|
'helpdesk.bc_odata_base_url'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getCompanyName(): string
|
|
|
|
|
{
|
|
|
|
|
$value = $this->settingsMetadataGateway->getValue(self::KEY_COMPANY_NAME);
|
|
|
|
|
$value = trim((string) ($value ?? ''));
|
|
|
|
|
|
|
|
|
|
return $value !== '' ? $value : self::DEFAULT_COMPANY_NAME;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function setCompanyName(?string $name): bool
|
|
|
|
|
{
|
|
|
|
|
$name = trim((string) ($name ?? ''));
|
|
|
|
|
|
|
|
|
|
return $this->settingsMetadataGateway->set(
|
|
|
|
|
self::KEY_COMPANY_NAME,
|
|
|
|
|
$name !== '' ? $name : null,
|
|
|
|
|
'helpdesk.bc_company_name'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getBasicUser(): ?string
|
|
|
|
|
{
|
|
|
|
|
$value = $this->settingsMetadataGateway->getValue(self::KEY_BASIC_USER);
|
|
|
|
|
$value = trim((string) ($value ?? ''));
|
|
|
|
|
|
|
|
|
|
return $value !== '' ? $value : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function setBasicUser(?string $user): bool
|
|
|
|
|
{
|
|
|
|
|
$user = trim((string) ($user ?? ''));
|
|
|
|
|
|
|
|
|
|
return $this->settingsMetadataGateway->set(
|
|
|
|
|
self::KEY_BASIC_USER,
|
|
|
|
|
$user !== '' ? $user : null,
|
|
|
|
|
'helpdesk.bc_basic_user'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getBasicPassword(): ?string
|
|
|
|
|
{
|
|
|
|
|
$encrypted = $this->settingsMetadataGateway->getValue(self::KEY_BASIC_PASSWORD_ENC);
|
|
|
|
|
$encrypted = trim((string) ($encrypted ?? ''));
|
|
|
|
|
if ($encrypted === '') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$decrypted = $this->settingsCryptoGateway->decryptString($encrypted);
|
|
|
|
|
} catch (\Throwable) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$decrypted = trim($decrypted);
|
|
|
|
|
|
|
|
|
|
return $decrypted !== '' ? $decrypted : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function setBasicPassword(?string $password): bool
|
|
|
|
|
{
|
|
|
|
|
$password = trim((string) ($password ?? ''));
|
|
|
|
|
if ($password === '') {
|
|
|
|
|
return $this->settingsMetadataGateway->set(
|
|
|
|
|
self::KEY_BASIC_PASSWORD_ENC,
|
|
|
|
|
null,
|
|
|
|
|
'helpdesk.bc_basic_password_enc'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$encrypted = $this->settingsCryptoGateway->encryptString($password);
|
|
|
|
|
} catch (\Throwable) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->settingsMetadataGateway->set(
|
|
|
|
|
self::KEY_BASIC_PASSWORD_ENC,
|
|
|
|
|
$encrypted,
|
|
|
|
|
'helpdesk.bc_basic_password_enc'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getOAuthTenantId(): ?string
|
|
|
|
|
{
|
|
|
|
|
$value = $this->settingsMetadataGateway->getValue(self::KEY_OAUTH_TENANT_ID);
|
|
|
|
|
$value = trim((string) ($value ?? ''));
|
|
|
|
|
|
|
|
|
|
return $value !== '' ? $value : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function setOAuthTenantId(?string $tenantId): bool
|
|
|
|
|
{
|
|
|
|
|
$tenantId = trim((string) ($tenantId ?? ''));
|
|
|
|
|
|
|
|
|
|
return $this->settingsMetadataGateway->set(
|
|
|
|
|
self::KEY_OAUTH_TENANT_ID,
|
|
|
|
|
$tenantId !== '' ? $tenantId : null,
|
|
|
|
|
'helpdesk.bc_oauth_tenant_id'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getOAuthClientId(): ?string
|
|
|
|
|
{
|
|
|
|
|
$value = $this->settingsMetadataGateway->getValue(self::KEY_OAUTH_CLIENT_ID);
|
|
|
|
|
$value = trim((string) ($value ?? ''));
|
|
|
|
|
|
|
|
|
|
return $value !== '' ? $value : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function setOAuthClientId(?string $clientId): bool
|
|
|
|
|
{
|
|
|
|
|
$clientId = trim((string) ($clientId ?? ''));
|
|
|
|
|
|
|
|
|
|
return $this->settingsMetadataGateway->set(
|
|
|
|
|
self::KEY_OAUTH_CLIENT_ID,
|
|
|
|
|
$clientId !== '' ? $clientId : null,
|
|
|
|
|
'helpdesk.bc_oauth_client_id'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getOAuthClientSecret(): ?string
|
|
|
|
|
{
|
|
|
|
|
$encrypted = $this->settingsMetadataGateway->getValue(self::KEY_OAUTH_CLIENT_SECRET_ENC);
|
|
|
|
|
$encrypted = trim((string) ($encrypted ?? ''));
|
|
|
|
|
if ($encrypted === '') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$decrypted = $this->settingsCryptoGateway->decryptString($encrypted);
|
|
|
|
|
} catch (\Throwable) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$decrypted = trim($decrypted);
|
|
|
|
|
|
|
|
|
|
return $decrypted !== '' ? $decrypted : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function setOAuthClientSecret(?string $secret): bool
|
|
|
|
|
{
|
|
|
|
|
$secret = trim((string) ($secret ?? ''));
|
|
|
|
|
if ($secret === '') {
|
|
|
|
|
return $this->settingsMetadataGateway->set(
|
|
|
|
|
self::KEY_OAUTH_CLIENT_SECRET_ENC,
|
|
|
|
|
null,
|
|
|
|
|
'helpdesk.bc_oauth_client_secret_enc'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$encrypted = $this->settingsCryptoGateway->encryptString($secret);
|
|
|
|
|
} catch (\Throwable) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->settingsMetadataGateway->set(
|
|
|
|
|
self::KEY_OAUTH_CLIENT_SECRET_ENC,
|
|
|
|
|
$encrypted,
|
|
|
|
|
'helpdesk.bc_oauth_client_secret_enc'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getOAuthTokenEndpoint(): ?string
|
|
|
|
|
{
|
|
|
|
|
$value = $this->settingsMetadataGateway->getValue(self::KEY_OAUTH_TOKEN_ENDPOINT);
|
|
|
|
|
$value = trim((string) ($value ?? ''));
|
|
|
|
|
|
|
|
|
|
return $value !== '' ? $value : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function setOAuthTokenEndpoint(?string $endpoint): bool
|
|
|
|
|
{
|
|
|
|
|
$endpoint = trim((string) ($endpoint ?? ''));
|
|
|
|
|
if ($endpoint !== '' && !preg_match('#^https://#i', $endpoint)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->settingsMetadataGateway->set(
|
|
|
|
|
self::KEY_OAUTH_TOKEN_ENDPOINT,
|
|
|
|
|
$endpoint !== '' ? $endpoint : null,
|
|
|
|
|
'helpdesk.bc_oauth_token_endpoint'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 18:34:03 +02:00
|
|
|
/**
|
|
|
|
|
* @return array{
|
|
|
|
|
* config: array{
|
|
|
|
|
* version: int,
|
|
|
|
|
* max_items: int,
|
|
|
|
|
* rules: array{
|
|
|
|
|
* escalation_overdue: array{enabled: bool, priority: int, min_overdue_minutes: int},
|
|
|
|
|
* high_risk_aging: array{enabled: bool, priority: int, codes: array<int, string>, min_age_hours: int},
|
|
|
|
|
* unassigned_open: array{enabled: bool, priority: int, min_age_hours: int},
|
|
|
|
|
* stale_open: array{enabled: bool, priority: int, min_age_hours: int},
|
|
|
|
|
* customer_backlog: array{enabled: bool, priority: int, min_open_tickets: int}
|
|
|
|
|
* }
|
|
|
|
|
* },
|
|
|
|
|
* source: 'default'|'settings'|'fallback_invalid_json'
|
|
|
|
|
* }
|
|
|
|
|
*/
|
|
|
|
|
public function getSystemRecommendationsConfigEnvelope(): array
|
|
|
|
|
{
|
|
|
|
|
$rawValue = $this->settingsMetadataGateway->getValue(self::KEY_SYSTEM_RECOMMENDATIONS_CONFIG);
|
|
|
|
|
$rawValue = trim((string) ($rawValue ?? ''));
|
|
|
|
|
if ($rawValue === '') {
|
|
|
|
|
return [
|
|
|
|
|
'config' => self::DEFAULT_SYSTEM_RECOMMENDATIONS_CONFIG,
|
|
|
|
|
'source' => 'default',
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$decoded = json_decode($rawValue, true);
|
|
|
|
|
if (!is_array($decoded)) {
|
|
|
|
|
return [
|
|
|
|
|
'config' => self::DEFAULT_SYSTEM_RECOMMENDATIONS_CONFIG,
|
|
|
|
|
'source' => 'fallback_invalid_json',
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'config' => self::normalizeSystemRecommendationsConfig($decoded),
|
|
|
|
|
'source' => 'settings',
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array{
|
|
|
|
|
* version: int,
|
|
|
|
|
* max_items: int,
|
|
|
|
|
* rules: array{
|
|
|
|
|
* escalation_overdue: array{enabled: bool, priority: int, min_overdue_minutes: int},
|
|
|
|
|
* high_risk_aging: array{enabled: bool, priority: int, codes: array<int, string>, min_age_hours: int},
|
|
|
|
|
* unassigned_open: array{enabled: bool, priority: int, min_age_hours: int},
|
|
|
|
|
* stale_open: array{enabled: bool, priority: int, min_age_hours: int},
|
|
|
|
|
* customer_backlog: array{enabled: bool, priority: int, min_open_tickets: int}
|
|
|
|
|
* }
|
|
|
|
|
* }
|
|
|
|
|
*/
|
|
|
|
|
public function getSystemRecommendationsConfig(): array
|
|
|
|
|
{
|
|
|
|
|
$envelope = $this->getSystemRecommendationsConfigEnvelope();
|
|
|
|
|
|
|
|
|
|
return $envelope['config'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param array<string, mixed> $config
|
|
|
|
|
*/
|
|
|
|
|
public function setSystemRecommendationsConfig(array $config): bool
|
|
|
|
|
{
|
|
|
|
|
$normalized = self::normalizeSystemRecommendationsConfig($config);
|
|
|
|
|
$encoded = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
2026-04-05 16:32:34 +02:00
|
|
|
if (!is_string($encoded)) {
|
2026-04-04 18:34:03 +02:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->settingsMetadataGateway->set(
|
|
|
|
|
self::KEY_SYSTEM_RECOMMENDATIONS_CONFIG,
|
|
|
|
|
$encoded,
|
|
|
|
|
'helpdesk.system_recommendations_config'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param array<string, mixed> $config
|
|
|
|
|
* @return array{
|
|
|
|
|
* version: int,
|
|
|
|
|
* max_items: int,
|
|
|
|
|
* rules: array{
|
|
|
|
|
* escalation_overdue: array{enabled: bool, priority: int, min_overdue_minutes: int},
|
|
|
|
|
* high_risk_aging: array{enabled: bool, priority: int, codes: array<int, string>, min_age_hours: int},
|
|
|
|
|
* unassigned_open: array{enabled: bool, priority: int, min_age_hours: int},
|
|
|
|
|
* stale_open: array{enabled: bool, priority: int, min_age_hours: int},
|
|
|
|
|
* customer_backlog: array{enabled: bool, priority: int, min_open_tickets: int}
|
|
|
|
|
* }
|
|
|
|
|
* }
|
|
|
|
|
*/
|
|
|
|
|
private static function normalizeSystemRecommendationsConfig(array $config): array
|
|
|
|
|
{
|
|
|
|
|
$defaults = self::DEFAULT_SYSTEM_RECOMMENDATIONS_CONFIG;
|
|
|
|
|
$rawRules = is_array($config['rules'] ?? null) ? $config['rules'] : [];
|
|
|
|
|
|
|
|
|
|
$escalationOverdue = is_array($rawRules['escalation_overdue'] ?? null) ? $rawRules['escalation_overdue'] : [];
|
|
|
|
|
$highRiskAging = is_array($rawRules['high_risk_aging'] ?? null) ? $rawRules['high_risk_aging'] : [];
|
|
|
|
|
$unassignedOpen = is_array($rawRules['unassigned_open'] ?? null) ? $rawRules['unassigned_open'] : [];
|
|
|
|
|
$staleOpen = is_array($rawRules['stale_open'] ?? null) ? $rawRules['stale_open'] : [];
|
|
|
|
|
$customerBacklog = is_array($rawRules['customer_backlog'] ?? null) ? $rawRules['customer_backlog'] : [];
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'version' => 1,
|
|
|
|
|
'max_items' => self::normalizeIntRange($config['max_items'] ?? $defaults['max_items'], 1, 20, (int) $defaults['max_items']),
|
|
|
|
|
'rules' => [
|
|
|
|
|
'escalation_overdue' => [
|
|
|
|
|
'enabled' => self::normalizeBool($escalationOverdue['enabled'] ?? $defaults['rules']['escalation_overdue']['enabled']),
|
|
|
|
|
'priority' => self::normalizeIntRange($escalationOverdue['priority'] ?? $defaults['rules']['escalation_overdue']['priority'], 1, 999, (int) $defaults['rules']['escalation_overdue']['priority']),
|
|
|
|
|
'min_overdue_minutes' => self::normalizeIntRange($escalationOverdue['min_overdue_minutes'] ?? $defaults['rules']['escalation_overdue']['min_overdue_minutes'], 0, 43200, (int) $defaults['rules']['escalation_overdue']['min_overdue_minutes']),
|
|
|
|
|
],
|
|
|
|
|
'high_risk_aging' => [
|
|
|
|
|
'enabled' => self::normalizeBool($highRiskAging['enabled'] ?? $defaults['rules']['high_risk_aging']['enabled']),
|
|
|
|
|
'priority' => self::normalizeIntRange($highRiskAging['priority'] ?? $defaults['rules']['high_risk_aging']['priority'], 1, 999, (int) $defaults['rules']['high_risk_aging']['priority']),
|
|
|
|
|
'codes' => self::normalizeCodeList($highRiskAging['codes'] ?? $defaults['rules']['high_risk_aging']['codes']),
|
|
|
|
|
'min_age_hours' => self::normalizeIntRange($highRiskAging['min_age_hours'] ?? $defaults['rules']['high_risk_aging']['min_age_hours'], 0, 720, (int) $defaults['rules']['high_risk_aging']['min_age_hours']),
|
|
|
|
|
],
|
|
|
|
|
'unassigned_open' => [
|
|
|
|
|
'enabled' => self::normalizeBool($unassignedOpen['enabled'] ?? $defaults['rules']['unassigned_open']['enabled']),
|
|
|
|
|
'priority' => self::normalizeIntRange($unassignedOpen['priority'] ?? $defaults['rules']['unassigned_open']['priority'], 1, 999, (int) $defaults['rules']['unassigned_open']['priority']),
|
|
|
|
|
'min_age_hours' => self::normalizeIntRange($unassignedOpen['min_age_hours'] ?? $defaults['rules']['unassigned_open']['min_age_hours'], 0, 720, (int) $defaults['rules']['unassigned_open']['min_age_hours']),
|
|
|
|
|
],
|
|
|
|
|
'stale_open' => [
|
|
|
|
|
'enabled' => self::normalizeBool($staleOpen['enabled'] ?? $defaults['rules']['stale_open']['enabled']),
|
|
|
|
|
'priority' => self::normalizeIntRange($staleOpen['priority'] ?? $defaults['rules']['stale_open']['priority'], 1, 999, (int) $defaults['rules']['stale_open']['priority']),
|
|
|
|
|
'min_age_hours' => self::normalizeIntRange($staleOpen['min_age_hours'] ?? $defaults['rules']['stale_open']['min_age_hours'], 0, 1440, (int) $defaults['rules']['stale_open']['min_age_hours']),
|
|
|
|
|
],
|
|
|
|
|
'customer_backlog' => [
|
|
|
|
|
'enabled' => self::normalizeBool($customerBacklog['enabled'] ?? $defaults['rules']['customer_backlog']['enabled']),
|
|
|
|
|
'priority' => self::normalizeIntRange($customerBacklog['priority'] ?? $defaults['rules']['customer_backlog']['priority'], 1, 999, (int) $defaults['rules']['customer_backlog']['priority']),
|
|
|
|
|
'min_open_tickets' => self::normalizeIntRange($customerBacklog['min_open_tickets'] ?? $defaults['rules']['customer_backlog']['min_open_tickets'], 1, 500, (int) $defaults['rules']['customer_backlog']['min_open_tickets']),
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param mixed $value
|
|
|
|
|
*/
|
|
|
|
|
private static function normalizeBool(mixed $value): bool
|
|
|
|
|
{
|
|
|
|
|
if (is_bool($value)) {
|
|
|
|
|
return $value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (bool) $value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param mixed $value
|
|
|
|
|
*/
|
|
|
|
|
private static function normalizeIntRange(mixed $value, int $min, int $max, int $default): int
|
|
|
|
|
{
|
|
|
|
|
if (!is_numeric($value)) {
|
|
|
|
|
return $default;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return max($min, min($max, (int) $value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param mixed $value
|
|
|
|
|
* @return array<int, string>
|
|
|
|
|
*/
|
|
|
|
|
private static function normalizeCodeList(mixed $value): array
|
|
|
|
|
{
|
|
|
|
|
$rawCodes = [];
|
|
|
|
|
if (is_array($value)) {
|
|
|
|
|
$rawCodes = $value;
|
|
|
|
|
} elseif (is_string($value)) {
|
|
|
|
|
$rawCodes = explode(',', $value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$normalized = [];
|
|
|
|
|
foreach ($rawCodes as $code) {
|
|
|
|
|
$candidate = strtoupper(trim((string) $code));
|
|
|
|
|
if ($candidate === '') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$normalized[] = $candidate;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$normalized = array_values(array_unique($normalized));
|
|
|
|
|
if ($normalized === []) {
|
|
|
|
|
return ['MAX', 'HOCH'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $normalized;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 10:30:05 +02:00
|
|
|
/**
|
|
|
|
|
* Days of silence after which a customer is flagged as "no contact".
|
|
|
|
|
* Clamped to a sane range; falls back to the default on invalid/empty values.
|
|
|
|
|
*
|
|
|
|
|
* @api Used by helpdesk/analytics + helpdesk/settings pages
|
|
|
|
|
*/
|
|
|
|
|
public function getInactivityThresholdDays(): int
|
|
|
|
|
{
|
|
|
|
|
$value = $this->settingsMetadataGateway->getValue(self::KEY_INACTIVITY_THRESHOLD_DAYS);
|
|
|
|
|
$value = trim((string) ($value ?? ''));
|
|
|
|
|
if ($value === '' || !is_numeric($value)) {
|
|
|
|
|
return self::DEFAULT_INACTIVITY_THRESHOLD_DAYS;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return max(
|
|
|
|
|
self::MIN_INACTIVITY_THRESHOLD_DAYS,
|
|
|
|
|
min(self::MAX_INACTIVITY_THRESHOLD_DAYS, (int) $value)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @api Used by the helpdesk/settings page */
|
|
|
|
|
public function setInactivityThresholdDays(?int $days): bool
|
|
|
|
|
{
|
|
|
|
|
if ($days === null) {
|
|
|
|
|
return $this->settingsMetadataGateway->set(
|
|
|
|
|
self::KEY_INACTIVITY_THRESHOLD_DAYS,
|
|
|
|
|
null,
|
|
|
|
|
'helpdesk.inactivity_threshold_days'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$clamped = max(
|
|
|
|
|
self::MIN_INACTIVITY_THRESHOLD_DAYS,
|
|
|
|
|
min(self::MAX_INACTIVITY_THRESHOLD_DAYS, $days)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return $this->settingsMetadataGateway->set(
|
|
|
|
|
self::KEY_INACTIVITY_THRESHOLD_DAYS,
|
|
|
|
|
(string) $clamped,
|
|
|
|
|
'helpdesk.inactivity_threshold_days'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 17:48:27 +02:00
|
|
|
/**
|
|
|
|
|
* Build the full OData entity URL for a given entity set.
|
|
|
|
|
*/
|
|
|
|
|
public function buildEntityUrl(string $entitySet): string
|
|
|
|
|
{
|
|
|
|
|
$baseUrl = $this->getODataBaseUrl();
|
|
|
|
|
$company = rawurlencode($this->getCompanyName());
|
|
|
|
|
|
|
|
|
|
return $baseUrl . "/Company('" . $company . "')/" . $entitySet;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate that required settings for the current auth mode are present.
|
|
|
|
|
*
|
|
|
|
|
* @return array<string> List of missing setting descriptions (empty = valid)
|
|
|
|
|
*/
|
|
|
|
|
public function validateConfiguration(): array
|
|
|
|
|
{
|
|
|
|
|
$missing = [];
|
|
|
|
|
|
|
|
|
|
if ($this->getBasicUser() === null && $this->getAuthMode() === self::AUTH_MODE_BASIC) {
|
|
|
|
|
$missing[] = 'BC Basic Auth User';
|
|
|
|
|
}
|
|
|
|
|
if ($this->getBasicPassword() === null && $this->getAuthMode() === self::AUTH_MODE_BASIC) {
|
|
|
|
|
$missing[] = 'BC Basic Auth Password';
|
|
|
|
|
}
|
|
|
|
|
if ($this->getAuthMode() === self::AUTH_MODE_OAUTH2) {
|
|
|
|
|
if ($this->getOAuthClientId() === null) {
|
|
|
|
|
$missing[] = 'BC OAuth2 Client ID';
|
|
|
|
|
}
|
|
|
|
|
if ($this->getOAuthClientSecret() === null) {
|
|
|
|
|
$missing[] = 'BC OAuth2 Client Secret';
|
|
|
|
|
}
|
|
|
|
|
if ($this->getOAuthTokenEndpoint() === null) {
|
|
|
|
|
$missing[] = 'BC OAuth2 Token Endpoint';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $missing;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check whether the configuration is complete for the current auth mode.
|
|
|
|
|
*/
|
|
|
|
|
public function isConfigured(): bool
|
|
|
|
|
{
|
|
|
|
|
return $this->validateConfiguration() === [];
|
|
|
|
|
}
|
2026-04-04 18:34:03 +02:00
|
|
|
|
|
|
|
|
// --- Controlling risk configuration ---
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array{
|
|
|
|
|
* config: array{version: int, rules: array<string, array<string, mixed>>},
|
|
|
|
|
* source: 'default'|'settings'|'fallback_invalid_json'
|
|
|
|
|
* }
|
|
|
|
|
*/
|
|
|
|
|
public function getControllingRiskConfigEnvelope(): array
|
|
|
|
|
{
|
|
|
|
|
$rawValue = $this->settingsMetadataGateway->getValue(self::KEY_CONTROLLING_RISK_CONFIG);
|
|
|
|
|
$rawValue = trim((string) ($rawValue ?? ''));
|
|
|
|
|
if ($rawValue === '') {
|
|
|
|
|
return [
|
|
|
|
|
'config' => self::DEFAULT_CONTROLLING_RISK_CONFIG,
|
|
|
|
|
'source' => 'default',
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$decoded = json_decode($rawValue, true);
|
|
|
|
|
if (!is_array($decoded)) {
|
|
|
|
|
return [
|
|
|
|
|
'config' => self::DEFAULT_CONTROLLING_RISK_CONFIG,
|
|
|
|
|
'source' => 'fallback_invalid_json',
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'config' => self::normalizeControllingRiskConfig($decoded),
|
|
|
|
|
'source' => 'settings',
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array{version: int, rules: array<string, array<string, mixed>>}
|
|
|
|
|
*/
|
|
|
|
|
public function getControllingRiskConfig(): array
|
|
|
|
|
{
|
|
|
|
|
$envelope = $this->getControllingRiskConfigEnvelope();
|
|
|
|
|
|
|
|
|
|
return $envelope['config'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param array<string, mixed> $config
|
|
|
|
|
*/
|
|
|
|
|
public function setControllingRiskConfig(array $config): bool
|
|
|
|
|
{
|
|
|
|
|
$normalized = self::normalizeControllingRiskConfig($config);
|
|
|
|
|
$encoded = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
2026-04-05 16:32:34 +02:00
|
|
|
if (!is_string($encoded)) {
|
2026-04-04 18:34:03 +02:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->settingsMetadataGateway->set(
|
|
|
|
|
self::KEY_CONTROLLING_RISK_CONFIG,
|
|
|
|
|
$encoded,
|
|
|
|
|
'helpdesk.controlling_risk_config'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param array<string, mixed> $config
|
|
|
|
|
* @return array{version: int, rules: array<string, array<string, mixed>>}
|
|
|
|
|
*/
|
|
|
|
|
private static function normalizeControllingRiskConfig(array $config): array
|
|
|
|
|
{
|
|
|
|
|
$defaults = self::DEFAULT_CONTROLLING_RISK_CONFIG;
|
|
|
|
|
$rawRules = is_array($config['rules'] ?? null) ? $config['rules'] : [];
|
|
|
|
|
|
|
|
|
|
$ticketVolume = is_array($rawRules['ticket_volume_increase'] ?? null) ? $rawRules['ticket_volume_increase'] : [];
|
|
|
|
|
$avgResolution = is_array($rawRules['avg_resolution_high'] ?? null) ? $rawRules['avg_resolution_high'] : [];
|
|
|
|
|
$openAging = is_array($rawRules['open_aging'] ?? null) ? $rawRules['open_aging'] : [];
|
|
|
|
|
$unassigned = is_array($rawRules['unassigned_ratio'] ?? null) ? $rawRules['unassigned_ratio'] : [];
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'version' => 1,
|
|
|
|
|
'rules' => [
|
|
|
|
|
'ticket_volume_increase' => [
|
|
|
|
|
'enabled' => self::normalizeBool($ticketVolume['enabled'] ?? $defaults['rules']['ticket_volume_increase']['enabled']),
|
|
|
|
|
'threshold_percent' => self::normalizeIntRange($ticketVolume['threshold_percent'] ?? $defaults['rules']['ticket_volume_increase']['threshold_percent'], 5, 200, (int) $defaults['rules']['ticket_volume_increase']['threshold_percent']),
|
|
|
|
|
],
|
|
|
|
|
'avg_resolution_high' => [
|
|
|
|
|
'enabled' => self::normalizeBool($avgResolution['enabled'] ?? $defaults['rules']['avg_resolution_high']['enabled']),
|
|
|
|
|
'threshold_hours' => self::normalizeIntRange($avgResolution['threshold_hours'] ?? $defaults['rules']['avg_resolution_high']['threshold_hours'], 1, 720, (int) $defaults['rules']['avg_resolution_high']['threshold_hours']),
|
|
|
|
|
],
|
|
|
|
|
'open_aging' => [
|
|
|
|
|
'enabled' => self::normalizeBool($openAging['enabled'] ?? $defaults['rules']['open_aging']['enabled']),
|
|
|
|
|
'threshold_hours' => self::normalizeIntRange($openAging['threshold_hours'] ?? $defaults['rules']['open_aging']['threshold_hours'], 1, 2160, (int) $defaults['rules']['open_aging']['threshold_hours']),
|
|
|
|
|
],
|
|
|
|
|
'unassigned_ratio' => [
|
|
|
|
|
'enabled' => self::normalizeBool($unassigned['enabled'] ?? $defaults['rules']['unassigned_ratio']['enabled']),
|
|
|
|
|
'threshold_percent' => self::normalizeIntRange($unassigned['threshold_percent'] ?? $defaults['rules']['unassigned_ratio']['threshold_percent'], 1, 100, (int) $defaults['rules']['unassigned_ratio']['threshold_percent']),
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
}
|
2026-04-02 17:48:27 +02:00
|
|
|
}
|