Feature: Security Level mit Notiz für Domains

Implementiert ein Dialog-basiertes UI zum Bearbeiten des Security Levels
mit optionaler Notiz-Funktion für Helpdesk-Domains.

Backend:
- DomainSecurityLevelRepository: CRUD für security_levels mit note-Spalte
- DomainSecurityLevelService: Business-Logik mit Batch-Enrichment
- security-level-data() Endpoint: AJAX + Full-Page POST mit CSRF
- Migration 010: note TEXT-Spalte hinzugefügt
- Bugfix: findByTenantAndDomainNo gibt null bei leerem Array zurück

Frontend (Domain-Liste):
- Badge in Grid klickbar → öffnet Modal-Dialog
- Dialog mit Level-Select + Notiz-Textarea
- AJAX-Save mit Live-Badge-Update ohne Reload
- Event-Delegation für dynamische Grid-Inhalte

Frontend (Domain-Detail):
- Notiz-Feld im Security-Level-Formular
- PRG-Pattern mit Flash-Messages

Core:
- app-http.js: DEFAULT_HEADERS auf XMLHttpRequest für CSRF-Kompatibilität

i18n:
- Keys: Priority, Note, Optional note, Security level updated

Tests:
- DomainSecurityLevelServiceTest: CRUD + Batch-Enrichment
This commit is contained in:
2026-04-21 13:43:16 +02:00
parent c3de7d4238
commit 6ac685084d
23 changed files with 1017 additions and 19 deletions

View File

@@ -21,10 +21,12 @@ use MintyPHP\Module\Helpdesk\Service\SoftwareProductSyncService;
use MintyPHP\Module\Helpdesk\Service\SystemRecommendationEngine;
use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService;
use MintyPHP\Module\Helpdesk\Handler\SoftwareProductSyncJobHandler;
use MintyPHP\Module\Helpdesk\Repository\DomainSecurityLevelRepository;
use MintyPHP\Module\Helpdesk\Repository\HandoverRepository;
use MintyPHP\Module\Helpdesk\Repository\HandoverRevisionRepository;
use MintyPHP\Module\Helpdesk\Repository\SoftwareProductRepository;
use MintyPHP\Module\Helpdesk\Repository\UpdateRepository;
use MintyPHP\Module\Helpdesk\Service\DomainSecurityLevelService;
use MintyPHP\Module\Helpdesk\Service\HandoverRevisionService;
use MintyPHP\Module\Helpdesk\Service\HandoverService;
use MintyPHP\Module\Helpdesk\Service\UpdateService;
@@ -119,6 +121,12 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar
$c->get(SoftwareProductSyncService::class)
));
$container->set(DomainSecurityLevelRepository::class, static fn (): DomainSecurityLevelRepository => new DomainSecurityLevelRepository());
$container->set(DomainSecurityLevelService::class, static fn (AppContainer $c): DomainSecurityLevelService => new DomainSecurityLevelService(
$c->get(DomainSecurityLevelRepository::class)
));
$container->set(HandoverRepository::class, static fn (): HandoverRepository => new HandoverRepository());
$container->set(HandoverRevisionRepository::class, static fn (): HandoverRevisionRepository => new HandoverRevisionRepository());

View File

@@ -0,0 +1,155 @@
<?php
namespace MintyPHP\Module\Helpdesk\Repository;
use MintyPHP\DB;
class DomainSecurityLevelRepository
{
private const ALLOWED_LEVELS = ['niedrig', 'normal', 'hoch', 'kritisch'];
public function findByTenantAndDomainNo(int $tenantId, string $domainNo): ?array
{
if ($domainNo === '') {
return null;
}
$row = DB::selectOne(
'SELECT * FROM helpdesk_domain_security_levels WHERE tenant_id = ? AND domain_no = ? LIMIT 1',
(string) $tenantId,
$domainNo
);
if (!is_array($row) || $row === []) {
return null;
}
return $row;
}
/**
* @param list<string> $domainNos
* @return array<string, string> Map of domain_no => security_level
*/
public function listLevelsByDomainNos(int $tenantId, array $domainNos): array
{
if ($domainNos === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($domainNos), '?'));
$params = array_map('strval', $domainNos);
$params[] = (string) $tenantId;
$rows = DB::select(
'SELECT domain_no, security_level FROM helpdesk_domain_security_levels WHERE tenant_id = ? AND domain_no IN (' . $placeholders . ')',
...$params
);
$result = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$domainNo = (string) ($row['domain_no'] ?? '');
$level = (string) ($row['security_level'] ?? 'normal');
if ($domainNo !== '') {
$result[$domainNo] = in_array($level, self::ALLOWED_LEVELS, true) ? $level : 'normal';
}
}
}
return $result;
}
/**
* @param list<string> $domainNos
* @return array<string, array{level: string, note: string}> Map of domain_no => {level, note}
*/
public function listDetailsByDomainNos(int $tenantId, array $domainNos): array
{
if ($domainNos === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($domainNos), '?'));
$params = array_map('strval', $domainNos);
$params[] = (string) $tenantId;
$rows = DB::select(
'SELECT domain_no, security_level, note FROM helpdesk_domain_security_levels WHERE tenant_id = ? AND domain_no IN (' . $placeholders . ')',
...$params
);
$result = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$domainNo = (string) ($row['domain_no'] ?? '');
$level = (string) ($row['security_level'] ?? 'normal');
$note = (string) ($row['note'] ?? '');
if ($domainNo !== '') {
$result[$domainNo] = [
'level' => in_array($level, self::ALLOWED_LEVELS, true) ? $level : 'normal',
'note' => $note,
];
}
}
}
return $result;
}
/**
* Insert or update security level for a domain.
*
* @return bool True on success
*/
public function upsert(int $tenantId, string $domainNo, string $level, ?int $userId, ?string $note = null): bool
{
if ($domainNo === '' || !in_array($level, self::ALLOWED_LEVELS, true)) {
return false;
}
$existing = $this->findByTenantAndDomainNo($tenantId, $domainNo);
if ($existing !== null) {
$result = DB::update(
'UPDATE helpdesk_domain_security_levels SET security_level = ?, note = ?, updated_by = ? WHERE tenant_id = ? AND domain_no = ?',
$level,
$note,
$userId !== null ? (string) $userId : null,
(string) $tenantId,
$domainNo
);
return $result !== false;
}
$result = DB::insert(
'INSERT INTO helpdesk_domain_security_levels (tenant_id, domain_no, security_level, note, created_by, updated_by) VALUES (?, ?, ?, ?, ?, ?)',
(string) $tenantId,
$domainNo,
$level,
$note,
$userId !== null ? (string) $userId : null,
$userId !== null ? (string) $userId : null
);
return $result !== false;
}
/**
* @api Reserved for future cleanup jobs
*/
public function deleteByTenantAndDomainNo(int $tenantId, string $domainNo): bool
{
if ($domainNo === '') {
return false;
}
$result = DB::update(
'DELETE FROM helpdesk_domain_security_levels WHERE tenant_id = ? AND domain_no = ?',
(string) $tenantId,
$domainNo
);
return $result !== false;
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace MintyPHP\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Repository\DomainSecurityLevelRepository;
class DomainSecurityLevelService
{
private const ALLOWED_LEVELS = ['niedrig', 'normal', 'hoch', 'kritisch'];
private const BADGE_VARIANTS = [
'niedrig' => 'info',
'normal' => 'neutral',
'hoch' => 'warning',
'kritisch' => 'error',
];
public function __construct(
private readonly DomainSecurityLevelRepository $repository
) {
}
/**
* @api Called from page actions and AJAX endpoints
*/
public function getLevel(int $tenantId, string $domainNo): string
{
if ($domainNo === '') {
return 'normal';
}
$row = $this->repository->findByTenantAndDomainNo($tenantId, $domainNo);
if ($row === null) {
return 'normal';
}
$level = (string) ($row['security_level'] ?? 'normal');
return in_array($level, self::ALLOWED_LEVELS, true) ? $level : 'normal';
}
/**
* @api Called from AJAX endpoints — returns level + note
* @return array{level: string, note: string}
*/
public function getDetails(int $tenantId, string $domainNo): array
{
if ($domainNo === '') {
return ['level' => 'normal', 'note' => ''];
}
$row = $this->repository->findByTenantAndDomainNo($tenantId, $domainNo);
if ($row === null) {
return ['level' => 'normal', 'note' => ''];
}
$level = (string) ($row['security_level'] ?? 'normal');
$note = (string) ($row['note'] ?? '');
return [
'level' => in_array($level, self::ALLOWED_LEVELS, true) ? $level : 'normal',
'note' => $note,
];
}
/**
* @api Called from page actions and AJAX endpoints
*/
public function setLevel(int $tenantId, string $domainNo, string $level, ?int $userId, ?string $note = null): bool
{
if ($domainNo === '' || !in_array($level, self::ALLOWED_LEVELS, true)) {
return false;
}
return $this->repository->upsert($tenantId, $domainNo, $level, $userId, $note);
}
/**
* @api Called from domains-data endpoint for batch enrichment
*/
public function getAllLevelsForDomains(int $tenantId, array $domainNos): array
{
if ($domainNos === []) {
return [];
}
return $this->repository->listLevelsByDomainNos($tenantId, $domainNos);
}
/**
* @api Called from domains-data endpoint for batch enrichment with note
* @param list<string> $domainNos
* @return array<string, array{level: string, note: string}>
*/
public function getAllDetailsForDomains(int $tenantId, array $domainNos): array
{
if ($domainNos === []) {
return [];
}
return $this->repository->listDetailsByDomainNos($tenantId, $domainNos);
}
/**
* @api Called from views and AJAX responses for badge rendering
*/
public static function getBadgeVariant(string $level): string
{
return self::BADGE_VARIANTS[$level] ?? 'neutral';
}
/**
* @return list<string>
*/
/**
* @api Called from AJAX endpoint for validation
*/
public static function getAllowedLevels(): array
{
return self::ALLOWED_LEVELS;
}
}