Restructure Support dashboard into 2 KPI groups (Tickets + Contracts) with section dividers, merge escalations and recommendations into "Attention needed". Redesign Sales dashboard with clickable contract timeline and dialog. Add CSS shimmer skeleton loading for all dashboard tabs and aside. Unify vertical rhythm via * + * sibling combinator on tab content containers. Remove ~265 lines of dead CSS (contract cards, escalation styles) and unused JS functions. Refactor hydrateTicketCategoryFilter into generic hydrateSelectFilter. Fix role="button" CSS bleed from shell and remove extra future year from timeline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
668 lines
25 KiB
PHP
668 lines
25 KiB
PHP
<?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';
|
|
public const KEY_SYSTEM_RECOMMENDATIONS_CONFIG = 'helpdesk.system_recommendations_config';
|
|
public const KEY_CONTROLLING_RISK_CONFIG = 'helpdesk.controlling_risk_config';
|
|
|
|
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';
|
|
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,
|
|
],
|
|
],
|
|
];
|
|
|
|
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<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);
|
|
if (!is_string($encoded)) {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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() === [];
|
|
}
|
|
|
|
// --- 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);
|
|
if (!is_string($encoded)) {
|
|
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']),
|
|
],
|
|
],
|
|
];
|
|
}
|
|
}
|