1
0

feat(helpdesk): add multi-tenant BC connection settings with risk radar refinements

Introduce per-tenant override for helpdesk BC connection config.
New table helpdesk_tenant_settings stores tenant-specific credentials
with encrypted secrets (AES-256-GCM). EffectiveHelpdeskSettingsService
resolves global vs tenant config per request. Settings UI extended with
tenant override tab, toggle, and dual editing mode.

All runtime consumers (OData, SOAP, OAuth) read through the new
resolver. Token cache flushed on any config change. Default behavior
unchanged — tenants use global config until override explicitly enabled.

Also includes risk radar UI refinements: Stripe-style card redesign
with accent borders and score pills, removal of redundant KPI tiles
and search, and filtering of zero-score entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 10:29:59 +02:00
parent 97dbc7f57b
commit ea786f5341
27 changed files with 1150 additions and 109 deletions

View File

@@ -9,8 +9,11 @@ use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
use MintyPHP\Module\Helpdesk\Service\BcSoapGateway;
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
use MintyPHP\Module\Helpdesk\Service\DebitorSearchService;
use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService;
use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService;
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
use MintyPHP\Module\Helpdesk\Service\HelpdeskTenantSettingsGateway;
use MintyPHP\Module\Helpdesk\Service\HelpdeskTenantSettingsRepository;
use MintyPHP\Module\Helpdesk\Service\HelpdeskTokenRepository;
use MintyPHP\Module\Helpdesk\Service\SystemRecommendationEngine;
use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService;
@@ -31,14 +34,27 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar
$c->get(SettingServicesFactory::class)->createSettingsCryptoGateway()
));
$container->set(BcODataGateway::class, static fn (AppContainer $c): BcODataGateway => new BcODataGateway(
$container->set(HelpdeskTenantSettingsRepository::class, static fn (): HelpdeskTenantSettingsRepository => new HelpdeskTenantSettingsRepository());
$container->set(HelpdeskTenantSettingsGateway::class, static fn (AppContainer $c): HelpdeskTenantSettingsGateway => new HelpdeskTenantSettingsGateway(
$c->get(HelpdeskTenantSettingsRepository::class),
$c->get(SettingServicesFactory::class)->createSettingsCryptoGateway()
));
$container->set(EffectiveHelpdeskSettingsService::class, static fn (AppContainer $c): EffectiveHelpdeskSettingsService => new EffectiveHelpdeskSettingsService(
$c->get(HelpdeskSettingsGateway::class),
$c->get(HelpdeskTenantSettingsGateway::class),
$c->get(SessionStoreInterface::class)
));
$container->set(BcODataGateway::class, static fn (AppContainer $c): BcODataGateway => new BcODataGateway(
$c->get(EffectiveHelpdeskSettingsService::class),
$c->get(HelpdeskOAuthTokenService::class),
$c->get(SessionStoreInterface::class)
));
$container->set(BcSoapGateway::class, static fn (AppContainer $c): BcSoapGateway => new BcSoapGateway(
$c->get(HelpdeskSettingsGateway::class),
$c->get(EffectiveHelpdeskSettingsService::class),
$c->get(HelpdeskOAuthTokenService::class),
$c->get(SessionStoreInterface::class)
));
@@ -48,18 +64,18 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar
));
$container->set(HelpdeskOAuthTokenService::class, static fn (AppContainer $c): HelpdeskOAuthTokenService => new HelpdeskOAuthTokenService(
$c->get(HelpdeskSettingsGateway::class),
$c->get(EffectiveHelpdeskSettingsService::class),
$c->get(HelpdeskTokenRepository::class)
));
$container->set(DebitorSearchService::class, static fn (AppContainer $c): DebitorSearchService => new DebitorSearchService(
$c->get(BcODataGateway::class),
$c->get(HelpdeskSettingsGateway::class)
$c->get(EffectiveHelpdeskSettingsService::class)
));
$container->set(DebitorDetailService::class, static fn (AppContainer $c): DebitorDetailService => new DebitorDetailService(
$c->get(BcODataGateway::class),
$c->get(HelpdeskSettingsGateway::class)
$c->get(EffectiveHelpdeskSettingsService::class)
));
$container->set(TicketCommunicationService::class, static fn (AppContainer $c): TicketCommunicationService => new TicketCommunicationService(

View File

@@ -26,7 +26,7 @@ class BcODataGateway
private const REQUEST_TIMEOUT = 30;
public function __construct(
private readonly HelpdeskSettingsGateway $settingsGateway,
private readonly EffectiveHelpdeskSettingsService $settingsGateway,
private readonly HelpdeskOAuthTokenService $oauthTokenService,
private readonly SessionStoreInterface $sessionStore
) {

View File

@@ -16,7 +16,7 @@ class BcSoapGateway
private const SOAP_ACTION_GET_TICKET_FILE = 'urn:microsoft-dynamics-schemas/codeunit/ICISupportWebserviceAPI:GetTicketFile';
public function __construct(
private readonly HelpdeskSettingsGateway $settingsGateway,
private readonly EffectiveHelpdeskSettingsService $settingsGateway,
private readonly HelpdeskOAuthTokenService $oauthTokenService,
private readonly SessionStoreInterface $sessionStore
) {

View File

@@ -15,7 +15,7 @@ class DebitorDetailService
public function __construct(
private readonly BcODataGateway $bcODataGateway,
private readonly HelpdeskSettingsGateway $settingsGateway
private readonly EffectiveHelpdeskSettingsService $settingsGateway
) {
}

View File

@@ -11,7 +11,7 @@ class DebitorSearchService
public function __construct(
private readonly BcODataGateway $bcODataGateway,
private readonly HelpdeskSettingsGateway $settingsGateway
private readonly EffectiveHelpdeskSettingsService $settingsGateway
) {
}

View File

@@ -0,0 +1,71 @@
<?php
namespace MintyPHP\Module\Helpdesk\Service;
/**
* Immutable value object holding the fully resolved helpdesk BC connection config.
*
* Created by EffectiveHelpdeskSettingsService — consumers read this instead
* of reaching into global or tenant settings directly.
*/
final class EffectiveHelpdeskSettings
{
/**
* @param 'global'|'tenant' $source
*/
public function __construct(
public readonly string $source,
public readonly string $authMode,
public readonly string $odataBaseUrl,
public readonly string $companyName,
public readonly ?string $basicUser,
public readonly ?string $basicPassword,
public readonly ?string $oauthTenantId,
public readonly ?string $oauthClientId,
public readonly ?string $oauthClientSecret,
public readonly ?string $oauthTokenEndpoint,
public readonly ?string $systemRecommendationsConfigRaw,
public readonly ?string $controllingRiskConfigRaw,
) {
}
public function buildEntityUrl(string $entitySet): string
{
$company = rawurlencode($this->companyName);
return $this->odataBaseUrl . "/Company('" . $company . "')/" . $entitySet;
}
/**
* @return array<string> List of missing setting descriptions (empty = valid)
*/
public function validateConfiguration(): array
{
$missing = [];
if ($this->basicUser === null && $this->authMode === HelpdeskSettingsGateway::AUTH_MODE_BASIC) {
$missing[] = 'BC Basic Auth User';
}
if ($this->basicPassword === null && $this->authMode === HelpdeskSettingsGateway::AUTH_MODE_BASIC) {
$missing[] = 'BC Basic Auth Password';
}
if ($this->authMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2) {
if ($this->oauthClientId === null) {
$missing[] = 'BC OAuth2 Client ID';
}
if ($this->oauthClientSecret === null) {
$missing[] = 'BC OAuth2 Client Secret';
}
if ($this->oauthTokenEndpoint === null) {
$missing[] = 'BC OAuth2 Token Endpoint';
}
}
return $missing;
}
public function isConfigured(): bool
{
return $this->validateConfiguration() === [];
}
}

View File

@@ -0,0 +1,199 @@
<?php
namespace MintyPHP\Module\Helpdesk\Service;
use MintyPHP\Http\SessionStoreInterface;
/**
* Resolves the effective helpdesk BC config for the current request.
*
* Resolution rule:
* - If the current tenant has an active override (override_enabled=1): tenant config.
* - Otherwise: global config from the settings table.
*
* All runtime consumers (OData, SOAP, OAuth) should read through this service
* instead of accessing HelpdeskSettingsGateway directly.
*/
class EffectiveHelpdeskSettingsService
{
public function __construct(
private readonly HelpdeskSettingsGateway $globalGateway,
private readonly HelpdeskTenantSettingsGateway $tenantGateway,
private readonly SessionStoreInterface $sessionStore
) {
}
/**
* Resolve the effective settings for the given tenant.
*/
public function resolveForTenant(int $tenantId): EffectiveHelpdeskSettings
{
if ($tenantId > 0 && $this->tenantGateway->isOverrideEnabled($tenantId)) {
return $this->buildFromTenant($tenantId);
}
return $this->buildFromGlobal();
}
/**
* Resolve for the current session tenant.
*/
public function resolveForCurrentTenant(): EffectiveHelpdeskSettings
{
$tenantId = $this->resolveCurrentTenantId();
return $this->resolveForTenant($tenantId);
}
// --- Proxy methods for gateway compatibility ---
// These allow consumers to swap HelpdeskSettingsGateway → EffectiveHelpdeskSettingsService
// with minimal code changes.
public function getAuthMode(): string
{
return $this->resolveForCurrentTenant()->authMode;
}
public function getODataBaseUrl(): string
{
return $this->resolveForCurrentTenant()->odataBaseUrl;
}
public function getCompanyName(): string
{
return $this->resolveForCurrentTenant()->companyName;
}
public function getBasicUser(): ?string
{
return $this->resolveForCurrentTenant()->basicUser;
}
public function getBasicPassword(): ?string
{
return $this->resolveForCurrentTenant()->basicPassword;
}
public function getOAuthTenantId(): ?string
{
return $this->resolveForCurrentTenant()->oauthTenantId;
}
public function getOAuthClientId(): ?string
{
return $this->resolveForCurrentTenant()->oauthClientId;
}
public function getOAuthClientSecret(): ?string
{
return $this->resolveForCurrentTenant()->oauthClientSecret;
}
public function getOAuthTokenEndpoint(): ?string
{
return $this->resolveForCurrentTenant()->oauthTokenEndpoint;
}
public function buildEntityUrl(string $entitySet): string
{
return $this->resolveForCurrentTenant()->buildEntityUrl($entitySet);
}
/**
* @return array<string>
*/
public function validateConfiguration(): array
{
return $this->resolveForCurrentTenant()->validateConfiguration();
}
public function isConfigured(): bool
{
return $this->resolveForCurrentTenant()->isConfigured();
}
/**
* @return array{
* config: array<string, mixed>,
* source: 'default'|'settings'|'fallback_invalid_json'
* }
*/
public function getSystemRecommendationsConfigEnvelope(): array
{
return $this->globalGateway->getSystemRecommendationsConfigEnvelope();
}
/**
* @return array<string, mixed>
*/
public function getSystemRecommendationsConfig(): array
{
return $this->globalGateway->getSystemRecommendationsConfig();
}
/**
* @return array{
* config: array<string, mixed>,
* source: 'default'|'settings'|'fallback_invalid_json'
* }
*/
public function getControllingRiskConfigEnvelope(): array
{
return $this->globalGateway->getControllingRiskConfigEnvelope();
}
/**
* @return array<string, mixed>
*/
public function getControllingRiskConfig(): array
{
return $this->globalGateway->getControllingRiskConfig();
}
private function buildFromGlobal(): EffectiveHelpdeskSettings
{
return new EffectiveHelpdeskSettings(
source: 'global',
authMode: $this->globalGateway->getAuthMode(),
odataBaseUrl: $this->globalGateway->getODataBaseUrl(),
companyName: $this->globalGateway->getCompanyName(),
basicUser: $this->globalGateway->getBasicUser(),
basicPassword: $this->globalGateway->getBasicPassword(),
oauthTenantId: $this->globalGateway->getOAuthTenantId(),
oauthClientId: $this->globalGateway->getOAuthClientId(),
oauthClientSecret: $this->globalGateway->getOAuthClientSecret(),
oauthTokenEndpoint: $this->globalGateway->getOAuthTokenEndpoint(),
systemRecommendationsConfigRaw: null,
controllingRiskConfigRaw: null,
);
}
private function buildFromTenant(int $tenantId): EffectiveHelpdeskSettings
{
return new EffectiveHelpdeskSettings(
source: 'tenant',
authMode: $this->tenantGateway->getAuthMode($tenantId),
odataBaseUrl: $this->tenantGateway->getODataBaseUrl($tenantId),
companyName: $this->tenantGateway->getCompanyName($tenantId),
basicUser: $this->tenantGateway->getBasicUser($tenantId),
basicPassword: $this->tenantGateway->getBasicPassword($tenantId),
oauthTenantId: $this->tenantGateway->getOAuthTenantId($tenantId),
oauthClientId: $this->tenantGateway->getOAuthClientId($tenantId),
oauthClientSecret: $this->tenantGateway->getOAuthClientSecret($tenantId),
oauthTokenEndpoint: $this->tenantGateway->getOAuthTokenEndpoint($tenantId),
systemRecommendationsConfigRaw: $this->tenantGateway->getSystemRecommendationsConfig($tenantId),
controllingRiskConfigRaw: $this->tenantGateway->getControllingRiskConfig($tenantId),
);
}
private function resolveCurrentTenantId(): int
{
$session = $this->sessionStore->all();
$tenant = $session['current_tenant'] ?? null;
if (!is_array($tenant)) {
return 0;
}
return (int) ($tenant['id'] ?? 0);
}
}

View File

@@ -15,7 +15,7 @@ class HelpdeskOAuthTokenService
private const REQUEST_TIMEOUT = 30;
public function __construct(
private readonly HelpdeskSettingsGateway $settingsGateway,
private readonly EffectiveHelpdeskSettingsService $settingsGateway,
private readonly HelpdeskTokenRepository $tokenRepository
) {
}

View File

@@ -0,0 +1,203 @@
<?php
namespace MintyPHP\Module\Helpdesk\Service;
use MintyPHP\Service\Settings\SettingsCryptoGatewayInterface;
/**
* Gateway for per-tenant helpdesk BC connection settings.
*
* Wraps the tenant settings repository and handles encryption/decryption
* of secrets (basic_password, oauth_client_secret) via SettingsCryptoGateway.
*/
class HelpdeskTenantSettingsGateway
{
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);
if ($row === null) {
return false;
}
return (int) ($row['override_enabled'] ?? 0) === 1;
}
/**
* @return array<string, mixed>|null Raw row data (secrets still encrypted)
*/
public function findByTenantId(int $tenantId): ?array
{
return $this->repository->findByTenantId($tenantId);
}
public function getAuthMode(int $tenantId): string
{
$row = $this->repository->findByTenantId($tenantId);
$value = trim((string) ($row['auth_mode'] ?? ''));
return $value === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2
? HelpdeskSettingsGateway::AUTH_MODE_OAUTH2
: HelpdeskSettingsGateway::AUTH_MODE_BASIC;
}
public function getODataBaseUrl(int $tenantId): string
{
$row = $this->repository->findByTenantId($tenantId);
$value = trim((string) ($row['odata_base_url'] ?? ''));
return $value !== '' ? rtrim($value, '/') : HelpdeskSettingsGateway::DEFAULT_ODATA_BASE_URL;
}
public function getCompanyName(int $tenantId): string
{
$row = $this->repository->findByTenantId($tenantId);
$value = trim((string) ($row['company_name'] ?? ''));
return $value !== '' ? $value : HelpdeskSettingsGateway::DEFAULT_COMPANY_NAME;
}
public function getBasicUser(int $tenantId): ?string
{
$row = $this->repository->findByTenantId($tenantId);
$value = trim((string) ($row['basic_user'] ?? ''));
return $value !== '' ? $value : null;
}
public function getBasicPassword(int $tenantId): ?string
{
$row = $this->repository->findByTenantId($tenantId);
$encrypted = trim((string) ($row['basic_password_enc'] ?? ''));
if ($encrypted === '') {
return null;
}
try {
$decrypted = $this->settingsCryptoGateway->decryptString($encrypted);
} catch (\Throwable) {
return null;
}
$decrypted = trim($decrypted);
return $decrypted !== '' ? $decrypted : null;
}
public function getOAuthTenantId(int $tenantId): ?string
{
$row = $this->repository->findByTenantId($tenantId);
$value = trim((string) ($row['oauth_tenant_id'] ?? ''));
return $value !== '' ? $value : null;
}
public function getOAuthClientId(int $tenantId): ?string
{
$row = $this->repository->findByTenantId($tenantId);
$value = trim((string) ($row['oauth_client_id'] ?? ''));
return $value !== '' ? $value : null;
}
public function getOAuthClientSecret(int $tenantId): ?string
{
$row = $this->repository->findByTenantId($tenantId);
$encrypted = trim((string) ($row['oauth_client_secret_enc'] ?? ''));
if ($encrypted === '') {
return null;
}
try {
$decrypted = $this->settingsCryptoGateway->decryptString($encrypted);
} catch (\Throwable) {
return null;
}
$decrypted = trim($decrypted);
return $decrypted !== '' ? $decrypted : null;
}
public function getOAuthTokenEndpoint(int $tenantId): ?string
{
$row = $this->repository->findByTenantId($tenantId);
$value = trim((string) ($row['oauth_token_endpoint'] ?? ''));
return $value !== '' ? $value : null;
}
public function getSystemRecommendationsConfig(int $tenantId): ?string
{
$row = $this->repository->findByTenantId($tenantId);
return ($row['system_recommendations_config'] ?? null) !== null
? (string) $row['system_recommendations_config']
: null;
}
public function getControllingRiskConfig(int $tenantId): ?string
{
$row = $this->repository->findByTenantId($tenantId);
return ($row['controlling_risk_config'] ?? null) !== null
? (string) $row['controlling_risk_config']
: null;
}
/**
* Persist tenant override settings. Secrets are encrypted before storage.
*
* @param array<string, mixed> $data Field values (plain-text passwords/secrets accepted)
*/
public function save(int $tenantId, array $data): bool
{
if ($tenantId <= 0) {
return false;
}
// Encrypt secrets before storage
if (isset($data['basic_password']) && is_string($data['basic_password'])) {
$password = trim($data['basic_password']);
unset($data['basic_password']);
if ($password !== '') {
try {
$data['basic_password_enc'] = $this->settingsCryptoGateway->encryptString($password);
} catch (\Throwable) {
return false;
}
} else {
$data['basic_password_enc'] = null;
}
}
if (isset($data['oauth_client_secret']) && is_string($data['oauth_client_secret'])) {
$secret = trim($data['oauth_client_secret']);
unset($data['oauth_client_secret']);
if ($secret !== '') {
try {
$data['oauth_client_secret_enc'] = $this->settingsCryptoGateway->encryptString($secret);
} catch (\Throwable) {
return false;
}
} else {
$data['oauth_client_secret_enc'] = null;
}
}
return $this->repository->upsert($tenantId, $data);
}
public function deleteByTenantId(int $tenantId): bool
{
return $this->repository->deleteByTenantId($tenantId);
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace MintyPHP\Module\Helpdesk\Service;
use MintyPHP\DB;
/**
* Repository for per-tenant helpdesk BC connection overrides (helpdesk_tenant_settings table).
*
* All queries are scoped by tenant_id. No cross-tenant access is possible.
*/
class HelpdeskTenantSettingsRepository
{
/**
* @return array<string, mixed>|null
*/
public function findByTenantId(int $tenantId): ?array
{
if ($tenantId <= 0) {
return null;
}
$result = DB::selectOne(
'SELECT * FROM helpdesk_tenant_settings WHERE tenant_id = ? LIMIT 1',
(string) $tenantId
);
if (!is_array($result) || $result === []) {
return null;
}
// MintyPHP wraps selectOne results in ['table_name' => [...]]
return $result['helpdesk_tenant_settings'] ?? null;
}
/**
* @param array<string, mixed> $data
*/
public function upsert(int $tenantId, array $data): bool
{
if ($tenantId <= 0) {
return false;
}
$existing = $this->findByTenantId($tenantId);
if ($existing !== null) {
return $this->update($tenantId, $data);
}
return $this->insert($tenantId, $data);
}
public function deleteByTenantId(int $tenantId): bool
{
if ($tenantId <= 0) {
return false;
}
return (bool) DB::delete(
'DELETE FROM helpdesk_tenant_settings WHERE tenant_id = ?',
(string) $tenantId
);
}
/**
* @param array<string, mixed> $data
*/
private function insert(int $tenantId, array $data): bool
{
$row = $this->buildRow($data);
return (bool) DB::insert(
'INSERT INTO helpdesk_tenant_settings (tenant_id, 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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
(string) $tenantId,
$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']
);
}
/**
* @param array<string, mixed> $data
*/
private function update(int $tenantId, array $data): bool
{
$row = $this->buildRow($data);
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
);
}
/**
* Build a complete row array from partial data, using empty strings as defaults.
*
* @param array<string, mixed> $data
* @return array<string, string>
*/
private function buildRow(array $data): array
{
$columns = [
'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',
];
$row = [];
foreach ($columns as $col) {
$row[$col] = (string) ($data[$col] ?? '');
}
return $row;
}
}

View File

@@ -48,6 +48,14 @@ class HelpdeskTokenRepository
}
}
/**
* Delete all cached tokens (used when BC config changes to prevent stale credentials).
*/
public function deleteAll(): bool
{
return (bool) DB::delete('DELETE FROM helpdesk_oauth_token_cache WHERE 1=1');
}
/**
* Store a new token in the cache (encrypted, tenant-scoped).
*/

View File

@@ -142,6 +142,9 @@ final class RiskRadarService
$dimensions = self::scoreDimensions($c);
$totalScore = self::computeTotalScore($dimensions);
if ($totalScore === 0) {
continue;
}
$level = $totalScore >= self::LEVEL_HIGH ? 'high'
: ($totalScore >= self::LEVEL_MEDIUM ? 'medium' : 'low');