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, ], ], ]; 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' ); } /** * @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, 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, 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 $config */ public function setSystemRecommendationsConfig(array $config): bool { $normalized = self::normalizeSystemRecommendationsConfig($config); $encoded = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if (!is_string($encoded)) { return false; } return $this->settingsMetadataGateway->set( self::KEY_SYSTEM_RECOMMENDATIONS_CONFIG, $encoded, 'helpdesk.system_recommendations_config' ); } /** * @param array $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, 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 */ 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; } /** * 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' ); } /** * 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 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() === []; } // --- Controlling risk configuration --- /** * @return array{ * config: array{version: int, rules: array>}, * 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>} */ public function getControllingRiskConfig(): array { $envelope = $this->getControllingRiskConfigEnvelope(); return $envelope['config']; } /** * @param array $config */ public function setControllingRiskConfig(array $config): bool { $normalized = self::normalizeControllingRiskConfig($config); $encoded = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if (!is_string($encoded)) { return false; } return $this->settingsMetadataGateway->set( self::KEY_CONTROLLING_RISK_CONFIG, $encoded, 'helpdesk.controlling_risk_config' ); } /** * @param array $config * @return array{version: int, rules: array>} */ 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']), ], ], ]; } }