feat(helpdesk): refactor multi-tenant BC settings and risk radar improvements
Restructure helpdesk tenant settings into per-connection config with improved token handling. Update risk radar data endpoint and UI. Clean up ImageUploadTrait and update architecture contract tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -615,27 +615,52 @@ class BcODataGateway
|
||||
* Get tickets across all customers for risk radar aggregation.
|
||||
*
|
||||
* Uses PBI_LV_Tickets with client-driven paging ($skip/$top).
|
||||
* Hardcap: 5000 rows (10 pages × 500 per page).
|
||||
* Tries a server-side date filter first (2× periodDays for trend window).
|
||||
* Falls back to unfiltered pagination if BC rejects the filter.
|
||||
* Follows @odata.nextLink for robust pagination.
|
||||
*
|
||||
* @return array{tickets: array<int, array<string, mixed>>, truncated: bool}
|
||||
*/
|
||||
public function getTicketsForRiskRadar(): array
|
||||
public function getTicketsForRiskRadar(int $periodDays = 90): array
|
||||
{
|
||||
$pageSize = 500;
|
||||
$maxPages = 10;
|
||||
$select = 'No,Customer_No,Cust_Name,Escalation_Code,Created_On,Last_Activity_Date,Process_Stage,Ticket_State,Support_User_Name,Category_1_Code,Process_Code';
|
||||
$baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV);
|
||||
|
||||
// Strategy 1: server-side date filter (drastically reduces data volume)
|
||||
$cutoff = (new \DateTimeImmutable('-' . ($periodDays * 2) . ' days'))->format('Y-m-d');
|
||||
$filteredUrl = $baseUrl
|
||||
. '?$top=500'
|
||||
. '&$select=' . rawurlencode($select)
|
||||
. '&$filter=' . rawurlencode("Created_On ge " . $cutoff)
|
||||
. '&$orderby=' . rawurlencode('Created_On desc');
|
||||
|
||||
$result = $this->paginateOData($filteredUrl, 20);
|
||||
if ($result['tickets'] !== []) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Strategy 2: fallback without filter (BC may not support date filters on this entity)
|
||||
$unfilteredUrl = $baseUrl
|
||||
. '?$top=500'
|
||||
. '&$select=' . rawurlencode($select)
|
||||
. '&$orderby=' . rawurlencode('Created_On desc');
|
||||
|
||||
return $this->paginateOData($unfilteredUrl, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginate an OData request following @odata.nextLink.
|
||||
*
|
||||
* @return array{tickets: array<int, array<string, mixed>>, truncated: bool}
|
||||
*/
|
||||
private function paginateOData(string $startUrl, int $maxPages): array
|
||||
{
|
||||
$allTickets = [];
|
||||
$truncated = false;
|
||||
$nextUrl = $startUrl;
|
||||
|
||||
for ($page = 0; $page < $maxPages; $page++) {
|
||||
$skip = $page * $pageSize;
|
||||
$url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV)
|
||||
. '?$top=' . $pageSize
|
||||
. '&$skip=' . $skip
|
||||
. '&$select=' . rawurlencode($select)
|
||||
. '&$orderby=' . rawurlencode('Created_On desc');
|
||||
|
||||
$response = $this->request('GET', $url);
|
||||
$response = $this->request('GET', $nextUrl);
|
||||
if ($response === null) {
|
||||
break;
|
||||
}
|
||||
@@ -649,10 +674,13 @@ class BcODataGateway
|
||||
$allTickets[] = $row;
|
||||
}
|
||||
|
||||
if (count($rows) < $pageSize) {
|
||||
$link = trim((string) ($response['@odata.nextLink'] ?? $response['odata.nextLink'] ?? ''));
|
||||
if ($link === '' || count($rows) < 500) {
|
||||
break;
|
||||
}
|
||||
|
||||
$nextUrl = $link;
|
||||
|
||||
if ($page === $maxPages - 1) {
|
||||
$truncated = true;
|
||||
}
|
||||
|
||||
@@ -9,21 +9,24 @@ use MintyPHP\Service\Settings\SettingsCryptoGatewayInterface;
|
||||
*
|
||||
* Wraps the tenant settings repository and handles encryption/decryption
|
||||
* of secrets (basic_password, oauth_client_secret) via SettingsCryptoGateway.
|
||||
*
|
||||
* Uses a per-request row cache to avoid N+1 queries when multiple getters
|
||||
* are called for the same tenant (e.g. during settings resolution).
|
||||
*/
|
||||
class HelpdeskTenantSettingsGateway
|
||||
{
|
||||
/** @var array<int, array<string, mixed>|false> Cache: tenantId → row or false (not found) */
|
||||
private array $rowCache = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly HelpdeskTenantSettingsRepository $repository,
|
||||
private readonly SettingsCryptoGatewayInterface $settingsCryptoGateway
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an active override exists for the given tenant.
|
||||
*/
|
||||
public function isOverrideEnabled(int $tenantId): bool
|
||||
{
|
||||
$row = $this->repository->findByTenantId($tenantId);
|
||||
$row = $this->resolveRow($tenantId);
|
||||
if ($row === null) {
|
||||
return false;
|
||||
}
|
||||
@@ -36,12 +39,12 @@ class HelpdeskTenantSettingsGateway
|
||||
*/
|
||||
public function findByTenantId(int $tenantId): ?array
|
||||
{
|
||||
return $this->repository->findByTenantId($tenantId);
|
||||
return $this->resolveRow($tenantId);
|
||||
}
|
||||
|
||||
public function getAuthMode(int $tenantId): string
|
||||
{
|
||||
$row = $this->repository->findByTenantId($tenantId);
|
||||
$row = $this->resolveRow($tenantId);
|
||||
$value = trim((string) ($row['auth_mode'] ?? ''));
|
||||
|
||||
return $value === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2
|
||||
@@ -51,7 +54,7 @@ class HelpdeskTenantSettingsGateway
|
||||
|
||||
public function getODataBaseUrl(int $tenantId): string
|
||||
{
|
||||
$row = $this->repository->findByTenantId($tenantId);
|
||||
$row = $this->resolveRow($tenantId);
|
||||
$value = trim((string) ($row['odata_base_url'] ?? ''));
|
||||
|
||||
return $value !== '' ? rtrim($value, '/') : HelpdeskSettingsGateway::DEFAULT_ODATA_BASE_URL;
|
||||
@@ -59,7 +62,7 @@ class HelpdeskTenantSettingsGateway
|
||||
|
||||
public function getCompanyName(int $tenantId): string
|
||||
{
|
||||
$row = $this->repository->findByTenantId($tenantId);
|
||||
$row = $this->resolveRow($tenantId);
|
||||
$value = trim((string) ($row['company_name'] ?? ''));
|
||||
|
||||
return $value !== '' ? $value : HelpdeskSettingsGateway::DEFAULT_COMPANY_NAME;
|
||||
@@ -67,7 +70,7 @@ class HelpdeskTenantSettingsGateway
|
||||
|
||||
public function getBasicUser(int $tenantId): ?string
|
||||
{
|
||||
$row = $this->repository->findByTenantId($tenantId);
|
||||
$row = $this->resolveRow($tenantId);
|
||||
$value = trim((string) ($row['basic_user'] ?? ''));
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
@@ -75,7 +78,7 @@ class HelpdeskTenantSettingsGateway
|
||||
|
||||
public function getBasicPassword(int $tenantId): ?string
|
||||
{
|
||||
$row = $this->repository->findByTenantId($tenantId);
|
||||
$row = $this->resolveRow($tenantId);
|
||||
$encrypted = trim((string) ($row['basic_password_enc'] ?? ''));
|
||||
if ($encrypted === '') {
|
||||
return null;
|
||||
@@ -94,7 +97,7 @@ class HelpdeskTenantSettingsGateway
|
||||
|
||||
public function getOAuthTenantId(int $tenantId): ?string
|
||||
{
|
||||
$row = $this->repository->findByTenantId($tenantId);
|
||||
$row = $this->resolveRow($tenantId);
|
||||
$value = trim((string) ($row['oauth_tenant_id'] ?? ''));
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
@@ -102,7 +105,7 @@ class HelpdeskTenantSettingsGateway
|
||||
|
||||
public function getOAuthClientId(int $tenantId): ?string
|
||||
{
|
||||
$row = $this->repository->findByTenantId($tenantId);
|
||||
$row = $this->resolveRow($tenantId);
|
||||
$value = trim((string) ($row['oauth_client_id'] ?? ''));
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
@@ -110,7 +113,7 @@ class HelpdeskTenantSettingsGateway
|
||||
|
||||
public function getOAuthClientSecret(int $tenantId): ?string
|
||||
{
|
||||
$row = $this->repository->findByTenantId($tenantId);
|
||||
$row = $this->resolveRow($tenantId);
|
||||
$encrypted = trim((string) ($row['oauth_client_secret_enc'] ?? ''));
|
||||
if ($encrypted === '') {
|
||||
return null;
|
||||
@@ -129,7 +132,7 @@ class HelpdeskTenantSettingsGateway
|
||||
|
||||
public function getOAuthTokenEndpoint(int $tenantId): ?string
|
||||
{
|
||||
$row = $this->repository->findByTenantId($tenantId);
|
||||
$row = $this->resolveRow($tenantId);
|
||||
$value = trim((string) ($row['oauth_token_endpoint'] ?? ''));
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
@@ -137,20 +140,18 @@ class HelpdeskTenantSettingsGateway
|
||||
|
||||
public function getSystemRecommendationsConfig(int $tenantId): ?string
|
||||
{
|
||||
$row = $this->repository->findByTenantId($tenantId);
|
||||
$row = $this->resolveRow($tenantId);
|
||||
$value = trim((string) ($row['system_recommendations_config'] ?? ''));
|
||||
|
||||
return ($row['system_recommendations_config'] ?? null) !== null
|
||||
? (string) $row['system_recommendations_config']
|
||||
: null;
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
public function getControllingRiskConfig(int $tenantId): ?string
|
||||
{
|
||||
$row = $this->repository->findByTenantId($tenantId);
|
||||
$row = $this->resolveRow($tenantId);
|
||||
$value = trim((string) ($row['controlling_risk_config'] ?? ''));
|
||||
|
||||
return ($row['controlling_risk_config'] ?? null) !== null
|
||||
? (string) $row['controlling_risk_config']
|
||||
: null;
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,8 +175,6 @@ class HelpdeskTenantSettingsGateway
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
$data['basic_password_enc'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,16 +187,42 @@ class HelpdeskTenantSettingsGateway
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
$data['oauth_client_secret_enc'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate cache after write
|
||||
unset($this->rowCache[$tenantId]);
|
||||
|
||||
return $this->repository->upsert($tenantId, $data);
|
||||
}
|
||||
|
||||
public function deleteByTenantId(int $tenantId): bool
|
||||
{
|
||||
unset($this->rowCache[$tenantId]);
|
||||
|
||||
return $this->repository->deleteByTenantId($tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load row once per tenant per request. Returns null if no row exists.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function resolveRow(int $tenantId): ?array
|
||||
{
|
||||
if ($tenantId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (array_key_exists($tenantId, $this->rowCache)) {
|
||||
$cached = $this->rowCache[$tenantId];
|
||||
|
||||
return $cached === false ? null : $cached;
|
||||
}
|
||||
|
||||
$row = $this->repository->findByTenantId($tenantId);
|
||||
$this->rowCache[$tenantId] = $row ?? false;
|
||||
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,39 +89,60 @@ class HelpdeskTenantSettingsRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Update only the columns present in $data — leaves other columns untouched.
|
||||
* This prevents overwriting secrets (basic_password_enc, oauth_client_secret_enc)
|
||||
* when they are not explicitly included in the save payload.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function update(int $tenantId, array $data): bool
|
||||
{
|
||||
$row = $this->buildRow($data);
|
||||
$allowed = self::allowedColumns();
|
||||
$setClauses = [];
|
||||
$params = [];
|
||||
|
||||
foreach ($allowed as $col) {
|
||||
if (!array_key_exists($col, $data)) {
|
||||
continue;
|
||||
}
|
||||
$setClauses[] = "{$col} = ?";
|
||||
$params[] = (string) ($data[$col] ?? '');
|
||||
}
|
||||
|
||||
if ($setClauses === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$params[] = (string) $tenantId;
|
||||
|
||||
return (bool) DB::update(
|
||||
'UPDATE helpdesk_tenant_settings SET override_enabled = ?, auth_mode = ?, odata_base_url = ?, company_name = ?, basic_user = ?, basic_password_enc = ?, oauth_tenant_id = ?, oauth_client_id = ?, oauth_client_secret_enc = ?, oauth_token_endpoint = ?, system_recommendations_config = ?, controlling_risk_config = ? WHERE tenant_id = ?',
|
||||
$row['override_enabled'],
|
||||
$row['auth_mode'],
|
||||
$row['odata_base_url'],
|
||||
$row['company_name'],
|
||||
$row['basic_user'],
|
||||
$row['basic_password_enc'],
|
||||
$row['oauth_tenant_id'],
|
||||
$row['oauth_client_id'],
|
||||
$row['oauth_client_secret_enc'],
|
||||
$row['oauth_token_endpoint'],
|
||||
$row['system_recommendations_config'],
|
||||
$row['controlling_risk_config'],
|
||||
(string) $tenantId
|
||||
'UPDATE helpdesk_tenant_settings SET ' . implode(', ', $setClauses) . ' WHERE tenant_id = ?',
|
||||
...$params
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a complete row array from partial data, using empty strings as defaults.
|
||||
* Build a complete row for INSERT, using empty strings for missing columns.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildRow(array $data): array
|
||||
{
|
||||
$columns = [
|
||||
$row = [];
|
||||
foreach (self::allowedColumns() as $col) {
|
||||
$row[$col] = (string) ($data[$col] ?? '');
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function allowedColumns(): array
|
||||
{
|
||||
return [
|
||||
'override_enabled',
|
||||
'auth_mode',
|
||||
'odata_base_url',
|
||||
@@ -135,12 +156,5 @@ class HelpdeskTenantSettingsRepository
|
||||
'system_recommendations_config',
|
||||
'controlling_risk_config',
|
||||
];
|
||||
|
||||
$row = [];
|
||||
foreach ($columns as $col) {
|
||||
$row[$col] = (string) ($data[$col] ?? '');
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,11 +32,13 @@ class HelpdeskTokenRepository
|
||||
(string) $tenantId
|
||||
);
|
||||
|
||||
if (!is_array($result) || !isset($result['access_token_encrypted'])) {
|
||||
// MintyPHP wraps selectOne results in ['table_name' => [...]]
|
||||
$row = is_array($result) ? ($result['helpdesk_oauth_token_cache'] ?? $result) : null;
|
||||
if (!is_array($row) || !isset($row['access_token_encrypted'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$encrypted = trim((string) $result['access_token_encrypted']);
|
||||
$encrypted = trim((string) $row['access_token_encrypted']);
|
||||
if ($encrypted === '') {
|
||||
return null;
|
||||
}
|
||||
@@ -49,13 +51,28 @@ class HelpdeskTokenRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all cached tokens (used when BC config changes to prevent stale credentials).
|
||||
* Delete all cached tokens (used when global BC config changes).
|
||||
*/
|
||||
public function deleteAll(): bool
|
||||
{
|
||||
return (bool) DB::delete('DELETE FROM helpdesk_oauth_token_cache WHERE 1=1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cached tokens for a specific tenant (used when tenant config changes).
|
||||
*/
|
||||
public function deleteByTenantId(int $tenantId): bool
|
||||
{
|
||||
if ($tenantId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) DB::delete(
|
||||
'DELETE FROM helpdesk_oauth_token_cache WHERE tenant_id = ?',
|
||||
(string) $tenantId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new token in the cache (encrypted, tenant-scoped).
|
||||
*/
|
||||
|
||||
@@ -337,24 +337,6 @@ final class RiskRadarService
|
||||
return ($days * 86400) + ($hours * 3600) + ($minutes * 60) + $seconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $values
|
||||
*/
|
||||
private static function median(array $values): ?float
|
||||
{
|
||||
if ($values === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
sort($values);
|
||||
$count = count($values);
|
||||
$mid = intdiv($count, 2);
|
||||
|
||||
return $count % 2 === 0
|
||||
? round(($values[$mid - 1] + $values[$mid]) / 2, 1)
|
||||
: (float) $values[$mid];
|
||||
}
|
||||
|
||||
private static function parseTimestamp(string $value): ?int
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
Reference in New Issue
Block a user