Files
breadcrumb-the-shire/modules/helpdesk/lib/Module/Helpdesk/Service/DomainListService.php

245 lines
9.4 KiB
PHP
Raw Normal View History

feat(core): generic CSV export primitive + helpdesk domains consumer Add a reusable CSV-export building block to the core so any Grid.js list page can gain a filter/sort-aware download with two clicks of glue. Core primitive: - core/Service/Export/CsvExportService: flavor-aware writer (plain CSV or Excel-compatible UTF-8+BOM+semicolon), with OWASP-aligned formula- injection escape (=, @, +, -, TAB, CR) applied to both rows *and* headers. Value objects for columns (ExportColumn) carry an extractor closure and an allowSignedNumeric flag for phone-number-shaped cells. - core/Support/helpers/export.php: thin HTTP layer (exportSendCsv, exportCapLimit, exportResolveFlavor, exportRequireGetRequest) reusing the requestInput() contract for GR-CORE-003 consistency. Filename is sanitized and length-clamped; callers must still enforce auth. - templates/partials/app-list-export-dropdown.phtml: zero-config <details class="dropdown"> with CSV + Excel triggers. - web/js/core/app-list-export.js: initListExport({ gridConfig, exportUrl }) mirrors the current grid filters + sort onto the export URL and navigates, preserving session cookies for download. First consumer — Helpdesk domains: - DomainListService extracts the shared filter/enrich/sort logic from domains-data so the grid endpoint and the new domains/export endpoint cannot drift. - domains/export endpoint delegates to DomainListService, declares ExportColumns (with translated level labels), and exits via exportSendCsv. - domains-data now ~20 lines, delegating to DomainListService. Tests: - CsvExportServiceTest (10 cases): both flavors, BOM/no-BOM, formula escapes incl. TAB/CR, header escape, signed-numeric allowlist, quoting, multiline, empty columns, generator-compatible iterable. - ExportHelpersTest (5 cases): exportCapLimit bounds. - DomainListServiceTest (8 cases): filter, search, "all" sentinel, sort, paging, enrichment, BC failure, tenant-scoped security filter. Gates: PHPUnit green, PHPStan clean on touched files, module:sync ok. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:57:19 +02:00
<?php
namespace MintyPHP\Module\Helpdesk\Service;
/**
* Domain list orchestrator.
*
* Owns the shared filter + enrichment flow used by both the Grid.js
* data endpoint (`domains-data()`) and the export endpoint
* (`domains/export()`). Keeps the two in lockstep same filters
* visible in the grid, same rows produced by the export.
*
* Input: parsed filter values (from gridParseFiltersFromSchemaFile).
* Output: `rows` enriched with security-level + state variant,
* `total` matching the filtered set *before* paging.
*/
final class DomainListService
{
private const STATE_VARIANT_MAP = [
'Aktiv' => 'success',
'In Vorbereitung' => 'warning',
];
public function __construct(
private readonly BcODataGateway $bc,
private readonly DomainSecurityLevelService $securityLevels,
) {
}
/**
* @api Called from domains-data and domains/export endpoints
* @param array{
* search?: string,
* customer?: string,
* contract_type?: string,
* state?: string,
* administration?: string,
* security_level?: string,
* order?: string,
* dir?: string,
* limit?: int,
* offset?: int
* } $filters
*
* @return array{rows: list<array<string,mixed>>, total: int}
*/
public function query(int $tenantId, array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$customer = trim((string) ($filters['customer'] ?? ''));
$contractType = trim((string) ($filters['contract_type'] ?? ''));
$state = $this->normalizeAll((string) ($filters['state'] ?? ''));
$administration = $this->normalizeAll((string) ($filters['administration'] ?? ''));
$securityLevel = $this->normalizeAll((string) ($filters['security_level'] ?? ''));
$order = (string) ($filters['order'] ?? 'Customer_Name');
$dir = strtolower((string) ($filters['dir'] ?? 'asc')) === 'desc' ? 'desc' : 'asc';
$limit = max(0, (int) ($filters['limit'] ?? 0));
$offset = max(0, (int) ($filters['offset'] ?? 0));
try {
$allDomains = $this->bc->listDomains();
} catch (\Throwable) {
return ['rows' => [], 'total' => 0];
}
$enriched = $this->enrichContracts($allDomains);
$filtered = $this->applyFilters($enriched, $tenantId, [
'search' => $search,
'customer' => $customer,
'contract_type' => $contractType,
'state' => $state,
'administration' => $administration,
'security_level' => $securityLevel,
]);
$total = count($filtered);
usort($filtered, static function (array $a, array $b) use ($order, $dir): int {
$va = (string) ($a[$order] ?? '');
$vb = (string) ($b[$order] ?? '');
$cmp = strnatcasecmp($va, $vb);
return $dir === 'desc' ? -$cmp : $cmp;
});
if ($limit > 0) {
$filtered = array_slice($filtered, $offset, $limit);
} elseif ($offset > 0) {
$filtered = array_slice($filtered, $offset);
}
$rows = $this->prepareRows($filtered, $tenantId);
return ['rows' => $rows, 'total' => $total];
}
private function normalizeAll(string $value): string
{
$trimmed = trim($value);
return $trimmed === 'all' ? '' : $trimmed;
}
/**
* @param list<array<string,mixed>> $domains
* @return list<array<string,mixed>>
*/
private function enrichContracts(array $domains): array
{
$lookup = [];
try {
foreach ($this->bc->listDomainContractLines() as $line) {
$dnsNo = trim((string) ($line['No'] ?? ''));
if ($dnsNo !== '' && !isset($lookup[$dnsNo])) {
$lookup[$dnsNo] = [
'contract_type' => trim((string) ($line['PI_Header_Type'] ?? '')),
'contract_no' => trim((string) ($line['Header_No'] ?? '')),
'contract_description' => trim((string) ($line['PI_Header_Description'] ?? '')),
];
}
}
} catch (\Throwable) {
// best-effort: proceed without contract enrichment
}
foreach ($domains as &$domain) {
$dnsNo = trim((string) ($domain['No'] ?? ''));
$contract = $lookup[$dnsNo] ?? null;
$domain['contract_type'] = $contract['contract_type'] ?? '';
$domain['contract_no'] = $contract['contract_no'] ?? '';
$domain['contract_description'] = $contract['contract_description'] ?? '';
}
unset($domain);
return $domains;
}
/**
* @param list<array<string,mixed>> $domains
* @param array{search: string, customer: string, contract_type: string, state: string, administration: string, security_level: string} $f
* @return list<array<string,mixed>>
*/
private function applyFilters(array $domains, int $tenantId, array $f): array
{
if ($f['search'] !== '') {
$needle = mb_strtolower($f['search']);
$domains = array_values(array_filter($domains, static function (array $d) use ($needle): bool {
$hay = mb_strtolower(
($d['Customer_No'] ?? '') . ' '
. ($d['Customer_Name'] ?? '') . ' '
. ($d['URL'] ?? '') . ' '
. ($d['No'] ?? '') . ' '
. ($d['contract_type'] ?? '')
);
return str_contains($hay, $needle);
}));
}
if ($f['customer'] !== '') {
$needle = mb_strtolower($f['customer']);
$domains = array_values(array_filter($domains, static function (array $d) use ($needle): bool {
$hay = mb_strtolower(($d['Customer_No'] ?? '') . ' ' . ($d['Customer_Name'] ?? ''));
return str_contains($hay, $needle);
}));
}
if ($f['contract_type'] !== '') {
$needle = mb_strtolower($f['contract_type']);
$domains = array_values(array_filter($domains, static fn (array $d): bool => str_contains(mb_strtolower((string) ($d['contract_type'] ?? '')), $needle)));
}
if ($f['state'] !== '') {
$value = $f['state'];
$domains = array_values(array_filter($domains, static fn (array $d): bool => trim((string) ($d['State'] ?? '')) === $value));
}
if ($f['administration'] !== '') {
$value = $f['administration'];
$domains = array_values(array_filter($domains, static fn (array $d): bool => trim((string) ($d['Administration'] ?? '')) === $value));
}
if ($f['security_level'] !== '' && $tenantId > 0) {
$domainNos = array_values(array_filter(
array_map(static fn (array $d): string => trim((string) ($d['No'] ?? '')), $domains),
static fn (string $n): bool => $n !== ''
));
$lookup = $domainNos !== [] ? $this->securityLevels->getAllLevelsForDomains($tenantId, $domainNos) : [];
$value = $f['security_level'];
$domains = array_values(array_filter($domains, static function (array $d) use ($value, $lookup): bool {
$no = trim((string) ($d['No'] ?? ''));
return ($lookup[$no] ?? 'normal') === $value;
}));
}
return $domains;
}
/**
* @param list<array<string,mixed>> $domains
* @return list<array<string,mixed>>
*/
private function prepareRows(array $domains, int $tenantId): array
{
$lookup = [];
if ($tenantId > 0) {
$domainNos = array_values(array_filter(
array_map(static fn (array $d): string => trim((string) ($d['No'] ?? '')), $domains),
static fn (string $n): bool => $n !== ''
));
if ($domainNos !== []) {
$lookup = $this->securityLevels->getAllDetailsForDomains($tenantId, $domainNos);
}
}
$debitorBaseUrl = lurl('helpdesk/debitor/');
$rows = [];
foreach ($domains as $domain) {
$customerNo = trim((string) ($domain['Customer_No'] ?? ''));
$domainState = (string) ($domain['State'] ?? '');
$domainNo = (string) ($domain['No'] ?? '');
$details = $lookup[$domainNo] ?? null;
$level = $details['level'] ?? 'normal';
$note = $details['note'] ?? '';
$rows[] = [
'No' => $domainNo,
'Customer_No' => $customerNo,
'Customer_Name' => (string) ($domain['Customer_Name'] ?? ''),
'URL' => (string) ($domain['URL'] ?? ''),
'State' => $domainState,
'state_variant' => self::STATE_VARIANT_MAP[$domainState] ?? 'neutral',
'Administration' => (string) ($domain['Administration'] ?? ''),
'contract_type' => (string) ($domain['contract_type'] ?? ''),
'contract_no' => (string) ($domain['contract_no'] ?? ''),
'debitor_url' => $customerNo !== '' ? $debitorBaseUrl . rawurlencode($customerNo) : '',
'security_level' => $level,
'security_level_variant' => DomainSecurityLevelService::getBadgeVariant($level),
'security_level_note' => $note,
];
}
return $rows;
}
}