diff --git a/core/Service/Export/CsvExportService.php b/core/Service/Export/CsvExportService.php new file mode 100644 index 0000000..856b424 --- /dev/null +++ b/core/Service/Export/CsvExportService.php @@ -0,0 +1,95 @@ +> $rows + * @param list $columns + */ + public function writeTo($handle, iterable $rows, array $columns, string $flavor = self::FLAVOR_CSV): void + { + if (!is_resource($handle)) { + throw new InvalidArgumentException('writeTo() expects an open stream resource'); + } + if ($columns === []) { + throw new InvalidArgumentException('At least one column is required'); + } + + $delimiter = $flavor === self::FLAVOR_EXCEL ? ';' : ','; + + if ($flavor === self::FLAVOR_EXCEL) { + fwrite($handle, self::UTF8_BOM); + } + + $headers = array_map( + static fn (ExportColumn $c): string => self::escapeValue($c->header), + $columns + ); + fputcsv($handle, $headers, $delimiter, '"', '\\'); + + foreach ($rows as $row) { + $line = []; + foreach ($columns as $column) { + $value = ($column->extract)($row); + $line[] = self::escapeValue($value, $column->allowSignedNumeric); + } + fputcsv($handle, $line, $delimiter, '"', '\\'); + } + } + + /** + * Prefixes a single apostrophe to values that would be interpreted + * as a formula by spreadsheet apps. Defuses the leading-character + * set recommended by OWASP CSV Injection: `=`, `@`, `+`, `-`, TAB, CR. + * + * When $allowSignedNumeric is true, purely numeric/phone-shaped values + * starting with + or - are left intact (useful for `+49 …` columns). + * + * @api Called from page actions that build rows manually + */ + public static function escapeValue(mixed $value, bool $allowSignedNumeric = false): string + { + if ($value === null) { + return ''; + } + if (is_bool($value)) { + return $value ? '1' : '0'; + } + $string = (string) $value; + if ($string === '') { + return ''; + } + $first = $string[0]; + if ($first === '=' || $first === '@' || $first === "\t" || $first === "\r") { + return "'" . $string; + } + if ($first === '+' || $first === '-') { + if ($allowSignedNumeric && preg_match('/^[+\-][0-9\s().\-]+$/', $string) === 1) { + return $string; + } + return "'" . $string; + } + return $string; + } +} diff --git a/core/Service/Export/ExportColumn.php b/core/Service/Export/ExportColumn.php new file mode 100644 index 0000000..8f2313e --- /dev/null +++ b/core/Service/Export/ExportColumn.php @@ -0,0 +1,22 @@ +isMethod('GET')) { + return; + } + header('Allow: GET'); + http_response_code(405); + exit; + } +} + +if (!function_exists('exportResolveFlavor')) { + /** + * Reads `format=csv|excel` from the request. Unknown values fall + * back to 'csv'. Accepts legacy alias 'excel-csv'. + */ + function exportResolveFlavor(RequestInput $request, string $default = CsvExportService::FLAVOR_CSV): string + { + $raw = strtolower(trim((string) $request->query('format', $default))); + if ($raw === 'excel' || $raw === 'excel-csv' || $raw === 'xls') { + return CsvExportService::FLAVOR_EXCEL; + } + return CsvExportService::FLAVOR_CSV; + } +} + +if (!function_exists('exportCapLimit')) { + /** + * Bounds a user-supplied limit to [1, $max]. Non-numeric / <=0 + * values collapse to $max. + */ + function exportCapLimit(mixed $requested, int $max = 5000): int + { + $value = is_numeric($requested) ? (int) $requested : 0; + if ($value < 1 || $value > $max) { + return $max; + } + return $value; + } +} + +if (!function_exists('exportSendCsv')) { + /** + * Streams rows as a CSV download and ends the response. + * + * IMPORTANT: this helper does NOT enforce authentication or authorization. + * Callers MUST run `Guard::requireLogin()` and the appropriate + * `Guard::requireAbilityOrForbidden(...)` check BEFORE invoking it — + * see `modules/helpdesk/pages/helpdesk/domains/export().php` for the + * reference pattern. + * + * @param iterable> $rows + * @param list $columns + */ + function exportSendCsv( + string $filename, + iterable $rows, + array $columns, + string $flavor = CsvExportService::FLAVOR_CSV + ): void { + $safeName = preg_replace('/[^A-Za-z0-9._-]+/', '-', $filename) ?: 'export.csv'; + // Clamp total filename to 100 chars while preserving the .csv suffix. + if (strlen($safeName) > 100) { + $safeName = substr($safeName, 0, 96); + } + if (!str_ends_with(strtolower($safeName), '.csv')) { + $safeName .= '.csv'; + } + + while (ob_get_level() > 0) { + ob_end_clean(); + } + + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename="' . $safeName . '"'); + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Pragma: no-cache'); + header('X-Content-Type-Options: nosniff'); + + $handle = fopen('php://output', 'w'); + if ($handle === false) { + exit; + } + + $service = new CsvExportService(); + $service->writeTo($handle, $rows, $columns, $flavor); + + fclose($handle); + exit; + } +} diff --git a/i18n/default_de.json b/i18n/default_de.json index e30a491..3f6b2e3 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -319,6 +319,9 @@ "Primary color is invalid": "Primärfarbe ist ungültig", "The primary color affects buttons and accent elements across the app.": "Die Primärfarbe beeinflusst Buttons und Akzent-Elemente in der gesamten App.", "Export CSV": "CSV exportieren", + "Export": "Export", + "Export as CSV": "Als CSV exportieren", + "Export for Excel": "Für Excel exportieren", "State": "Status", "Edit": "Bearbeiten", "Status": "Status", diff --git a/i18n/default_en.json b/i18n/default_en.json index d979c35..a477bfa 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -319,6 +319,9 @@ "Primary color is invalid": "Primary color is invalid", "The primary color affects buttons and accent elements across the app.": "The primary color affects buttons and accent elements across the app.", "Export CSV": "Export CSV", + "Export": "Export", + "Export as CSV": "Export as CSV", + "Export for Excel": "Export for Excel", "State": "State", "Edit": "Edit", "Status": "Status", diff --git a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php index f3b9d38..b49ba1f 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php +++ b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php @@ -10,6 +10,7 @@ use MintyPHP\Module\Helpdesk\Service\BcSoapGateway; use MintyPHP\Module\Helpdesk\Service\DebitorDetailService; use MintyPHP\Module\Helpdesk\Service\DebitorSearchService; use MintyPHP\Module\Helpdesk\Service\DomainDetailService; +use MintyPHP\Module\Helpdesk\Service\DomainListService; use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService; use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService; use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway; @@ -127,6 +128,11 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar $c->get(DomainSecurityLevelRepository::class) )); + $container->set(DomainListService::class, static fn (AppContainer $c): DomainListService => new DomainListService( + $c->get(BcODataGateway::class), + $c->get(DomainSecurityLevelService::class) + )); + $container->set(HandoverRepository::class, static fn (): HandoverRepository => new HandoverRepository()); $container->set(HandoverRevisionRepository::class, static fn (): HandoverRevisionRepository => new HandoverRevisionRepository()); diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DomainListService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DomainListService.php new file mode 100644 index 0000000..171dc06 --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DomainListService.php @@ -0,0 +1,244 @@ + '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>, 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> $domains + * @return list> + */ + 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> $domains + * @param array{search: string, customer: string, contract_type: string, state: string, administration: string, security_level: string} $f + * @return list> + */ + 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> $domains + * @return list> + */ + 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; + } +} diff --git a/modules/helpdesk/module.php b/modules/helpdesk/module.php index 4b197d0..1f2f579 100644 --- a/modules/helpdesk/module.php +++ b/modules/helpdesk/module.php @@ -18,6 +18,7 @@ return [ ['path' => 'helpdesk/domains', 'target' => 'helpdesk/domains'], ['path' => 'helpdesk/domains-data', 'target' => 'helpdesk/domains-data'], ['path' => 'helpdesk/domains/security-level-data', 'target' => 'helpdesk/domains/security-level-data'], + ['path' => 'helpdesk/domains/export', 'target' => 'helpdesk/domains/export'], ['path' => 'helpdesk/domain/{id}', 'target' => 'helpdesk/domain'], ['path' => 'helpdesk/domain-detail-data', 'target' => 'helpdesk/domain-detail-data'], ['path' => 'helpdesk/debitor/{id}', 'target' => 'helpdesk/debitor'], diff --git a/modules/helpdesk/pages/helpdesk/domains-data().php b/modules/helpdesk/pages/helpdesk/domains-data().php index e4b2840..6481915 100644 --- a/modules/helpdesk/pages/helpdesk/domains-data().php +++ b/modules/helpdesk/pages/helpdesk/domains-data().php @@ -2,8 +2,7 @@ use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy; -use MintyPHP\Module\Helpdesk\Service\BcODataGateway; -use MintyPHP\Module\Helpdesk\Service\DomainSecurityLevelService; +use MintyPHP\Module\Helpdesk\Service\DomainListService; use MintyPHP\Support\Guard; Guard::requireLogin(); @@ -14,183 +13,9 @@ $session = app(SessionStoreInterface::class)->all(); $tenantId = (int) ($session['current_tenant']['id'] ?? 0); $filters = gridParseFiltersFromSchemaFile(__DIR__ . '/domains/filter-schema.php'); +$filters['limit'] = (int) ($filters['limit'] ?? 10); +$filters['offset'] = (int) ($filters['offset'] ?? 0); -$search = trim((string) ($filters['search'] ?? '')); -$customer = trim((string) ($filters['customer'] ?? '')); -$contractType = trim((string) ($filters['contract_type'] ?? '')); -$state = trim((string) ($filters['state'] ?? '')); -$state = $state === 'all' ? '' : $state; -$administration = trim((string) ($filters['administration'] ?? '')); -$administration = $administration === 'all' ? '' : $administration; -$securityLevel = trim((string) ($filters['security_level'] ?? '')); -$securityLevel = $securityLevel === 'all' ? '' : $securityLevel; -$order = (string) ($filters['order'] ?? 'Customer_Name'); -$dir = (string) ($filters['dir'] ?? 'asc'); -$limit = (int) ($filters['limit'] ?? 10); -$offset = (int) ($filters['offset'] ?? 0); +$result = app(DomainListService::class)->query($tenantId, $filters); -$gateway = app(BcODataGateway::class); - -try { - $allDomains = $gateway->listDomains(); -} catch (\Throwable) { - gridJsonDataResult([], 0); - - return; -} - -// Build contract type lookup: DNS number → {contract_type, contract_no, contract_description} -$contractLookup = []; -try { - $contractLines = $gateway->listDomainContractLines(); - foreach ($contractLines as $line) { - $dnsNo = trim((string) ($line['No'] ?? '')); - if ($dnsNo !== '' && !isset($contractLookup[$dnsNo])) { - $contractLookup[$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) { - // Contract enrichment is best-effort — continue without it -} - -// Enrich domains with contract type before filtering (so search/sort can use it) -foreach ($allDomains as &$domain) { - $domainNo = trim((string) ($domain['No'] ?? '')); - $contract = $contractLookup[$domainNo] ?? null; - $domain['contract_type'] = $contract['contract_type'] ?? ''; - $domain['contract_no'] = $contract['contract_no'] ?? ''; - $domain['contract_description'] = $contract['contract_description'] ?? ''; -} -unset($domain); - -$filtered = $allDomains; - -// Text search across Customer_No, Customer_Name, URL, No, contract_type -if ($search !== '') { - $searchLower = mb_strtolower($search); - $filtered = array_values(array_filter($filtered, static function (array $domain) use ($searchLower): bool { - $haystack = mb_strtolower( - ($domain['Customer_No'] ?? '') . ' ' - . ($domain['Customer_Name'] ?? '') . ' ' - . ($domain['URL'] ?? '') . ' ' - . ($domain['No'] ?? '') . ' ' - . ($domain['contract_type'] ?? '') - ); - - return str_contains($haystack, $searchLower); - })); -} - -// Customer filter (partial match on Customer_No + Customer_Name) -if ($customer !== '') { - $customerLower = mb_strtolower($customer); - $filtered = array_values(array_filter($filtered, static function (array $domain) use ($customerLower): bool { - $haystack = mb_strtolower( - ($domain['Customer_No'] ?? '') . ' ' - . ($domain['Customer_Name'] ?? '') - ); - - return str_contains($haystack, $customerLower); - })); -} - -// Contract type filter (partial match) -if ($contractType !== '') { - $contractTypeLower = mb_strtolower($contractType); - $filtered = array_values(array_filter($filtered, static function (array $domain) use ($contractTypeLower): bool { - return str_contains(mb_strtolower((string) ($domain['contract_type'] ?? '')), $contractTypeLower); - })); -} - -// State filter (exact match) -if ($state !== '') { - $filtered = array_values(array_filter($filtered, static function (array $domain) use ($state): bool { - return trim((string) ($domain['State'] ?? '')) === $state; - })); -} - -// Administration filter (exact match) -if ($administration !== '') { - $filtered = array_values(array_filter($filtered, static function (array $domain) use ($administration): bool { - return trim((string) ($domain['Administration'] ?? '')) === $administration; - })); -} - -// Security level filter -if ($securityLevel !== '' && $tenantId > 0) { - $securityLevelService = app(DomainSecurityLevelService::class); - $allDomainNos = array_map(static fn (array $d): string => trim((string) ($d['No'] ?? '')), $filtered); - $allDomainNos = array_values(array_filter($allDomainNos, static fn (string $n): bool => $n !== '')); - $filterLookup = $allDomainNos !== [] ? $securityLevelService->getAllLevelsForDomains($tenantId, $allDomainNos) : []; - $filtered = array_values(array_filter($filtered, static function (array $domain) use ($securityLevel, $filterLookup): bool { - $domainNo = trim((string) ($domain['No'] ?? '')); - $currentLevel = $filterLookup[$domainNo] ?? 'normal'; - - return $currentLevel === $securityLevel; - })); -} - -// Sorting -$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; -}); - -// Pagination -$rows = array_slice($filtered, $offset, $limit); - -// Security level enrichment for displayed rows -$securityLevelLookup = []; -if ($tenantId > 0) { - $securityLevelService = app(DomainSecurityLevelService::class); - $pagedDomainNos = array_map(static fn (array $d): string => trim((string) ($d['No'] ?? '')), $rows); - $pagedDomainNos = array_values(array_filter($pagedDomainNos, static fn (string $n): bool => $n !== '')); - if ($pagedDomainNos !== []) { - $securityLevelLookup = $securityLevelService->getAllDetailsForDomains($tenantId, $pagedDomainNos); - } -} - -// Row preparation -$stateVariantMap = [ - 'Aktiv' => 'success', - 'In Vorbereitung' => 'warning', -]; - -$debitorBaseUrl = lurl('helpdesk/debitor/'); - -$preparedRows = []; -foreach ($rows as $domain) { - $customerNo = trim((string) ($domain['Customer_No'] ?? '')); - $domainState = (string) ($domain['State'] ?? ''); - $domainNo = (string) ($domain['No'] ?? ''); - $details = $securityLevelLookup[$domainNo] ?? null; - $level = $details['level'] ?? 'normal'; - $note = $details['note'] ?? ''; - - $preparedRows[] = [ - 'No' => $domainNo, - 'Customer_No' => $customerNo, - 'Customer_Name' => (string) ($domain['Customer_Name'] ?? ''), - 'URL' => (string) ($domain['URL'] ?? ''), - 'State' => $domainState, - 'state_variant' => $stateVariantMap[$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, - ]; -} - -gridJsonDataResult($preparedRows, $total); +gridJsonDataResult($result['rows'], $result['total']); diff --git a/modules/helpdesk/pages/helpdesk/domains/export().php b/modules/helpdesk/pages/helpdesk/domains/export().php new file mode 100644 index 0000000..183846d --- /dev/null +++ b/modules/helpdesk/pages/helpdesk/domains/export().php @@ -0,0 +1,48 @@ +all(); +$tenantId = (int) ($session['current_tenant']['id'] ?? 0); + +$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'); +$filters['limit'] = exportCapLimit($request->query('limit'), 5000); +$filters['offset'] = 0; + +$result = app(DomainListService::class)->query($tenantId, $filters); + +$levelLabels = [ + 'niedrig' => t('Low'), + 'normal' => t('Normal'), + 'hoch' => t('High'), + 'kritisch' => t('Critical'), +]; + +$columns = [ + new ExportColumn(t('No.'), static fn (array $row): string => (string) ($row['No'] ?? '')), + new ExportColumn(t('Customer No.'), static fn (array $row): string => (string) ($row['Customer_No'] ?? '')), + new ExportColumn(t('Customer Name'), static fn (array $row): string => (string) ($row['Customer_Name'] ?? '')), + new ExportColumn(t('URL'), static fn (array $row): string => (string) ($row['URL'] ?? '')), + new ExportColumn(t('Contract type'), static fn (array $row): string => (string) ($row['contract_type'] ?? '')), + new ExportColumn(t('Contract No.'), static fn (array $row): string => (string) ($row['contract_no'] ?? '')), + new ExportColumn(t('State'), static fn (array $row): string => (string) ($row['State'] ?? '')), + new ExportColumn(t('Administration'), static fn (array $row): string => (string) ($row['Administration'] ?? '')), + new ExportColumn(t('Security level'), static function (array $row) use ($levelLabels): string { + $level = (string) ($row['security_level'] ?? 'normal'); + return (string) ($levelLabels[$level] ?? $level); + }), + new ExportColumn(t('Note'), static fn (array $row): string => (string) ($row['security_level_note'] ?? '')), +]; + +$filename = 'helpdesk-domains-' . date('Ymd-His') . '.csv'; + +exportSendCsv($filename, $result['rows'], $columns, exportResolveFlavor($request)); diff --git a/modules/helpdesk/pages/helpdesk/domains/index(default).phtml b/modules/helpdesk/pages/helpdesk/domains/index(default).phtml index 7b8e061..9f0b57c 100644 --- a/modules/helpdesk/pages/helpdesk/domains/index(default).phtml +++ b/modules/helpdesk/pages/helpdesk/domains/index(default).phtml @@ -21,6 +21,9 @@ $isConfigured = $isConfigured ?? false; @@ -72,6 +75,7 @@ require templatePath('partials/app-list-filters.phtml'); 'dataUrl' => lurl('helpdesk/domains-data'), 'domainBaseUrl' => lurl('helpdesk/domain/'), 'securityLevelDataUrl' => lurl('helpdesk/domains/security-level-data'), + 'exportUrl' => lurl('helpdesk/domains/export'), 'gridLang' => gridLang(), 'gridSearch' => $searchConfig, 'filterSchema' => $clientFilterSchema, diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/DomainListServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/DomainListServiceTest.php new file mode 100644 index 0000000..eeda1df --- /dev/null +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/DomainListServiceTest.php @@ -0,0 +1,150 @@ +buildService($this->sampleDomains()); + $result = $service->query(self::TENANT_ID, ['state' => 'Aktiv']); + + $this->assertSame(2, $result['total']); + $this->assertEqualsCanonicalizing( + ['DNS001', 'DNS002'], + array_column($result['rows'], 'No') + ); + } + + public function testFiltersBySearchAcrossMultipleColumns(): void + { + $service = $this->buildService($this->sampleDomains()); + $result = $service->query(self::TENANT_ID, ['search' => 'example.com']); + + $this->assertSame(1, $result['total']); + $this->assertSame('DNS001', $result['rows'][0]['No']); + } + + public function testAllValueDisablesFilter(): void + { + $service = $this->buildService($this->sampleDomains()); + $result = $service->query(self::TENANT_ID, ['state' => 'all']); + + $this->assertSame(3, $result['total']); + } + + public function testSortingAscAndDesc(): void + { + $domains = $this->sampleDomains(); + $service = $this->buildService($domains); + + $asc = $service->query(self::TENANT_ID, ['order' => 'No', 'dir' => 'asc']); + $desc = $service->query(self::TENANT_ID, ['order' => 'No', 'dir' => 'desc']); + + $this->assertSame(['DNS001', 'DNS002', 'DNS003'], array_column($asc['rows'], 'No')); + $this->assertSame(['DNS003', 'DNS002', 'DNS001'], array_column($desc['rows'], 'No')); + } + + public function testLimitAndOffsetPaging(): void + { + $service = $this->buildService($this->sampleDomains()); + $result = $service->query(self::TENANT_ID, [ + 'order' => 'No', + 'dir' => 'asc', + 'limit' => 1, + 'offset' => 1, + ]); + + $this->assertSame(3, $result['total'], 'total counts the filtered set before paging'); + $this->assertCount(1, $result['rows']); + $this->assertSame('DNS002', $result['rows'][0]['No']); + } + + public function testEnrichesRowsWithSecurityLevelVariantAndNote(): void + { + $levels = $this->createMock(DomainSecurityLevelService::class); + $levels->method('getAllDetailsForDomains')->willReturn([ + 'DNS001' => ['level' => 'hoch', 'note' => 'important'], + ]); + + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('listDomains')->willReturn($this->sampleDomains()); + $gateway->method('listDomainContractLines')->willReturn([]); + + $service = new DomainListService($gateway, $levels); + $result = $service->query(self::TENANT_ID, ['order' => 'No', 'dir' => 'asc']); + + $first = $result['rows'][0]; + $this->assertSame('hoch', $first['security_level']); + $this->assertSame('important', $first['security_level_note']); + $this->assertSame(DomainSecurityLevelService::getBadgeVariant('hoch'), $first['security_level_variant']); + } + + public function testBcFailureReturnsEmptyResult(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('listDomains')->willThrowException(new \RuntimeException('upstream down')); + + $levels = $this->createMock(DomainSecurityLevelService::class); + + $service = new DomainListService($gateway, $levels); + $result = $service->query(self::TENANT_ID, []); + + $this->assertSame(['rows' => [], 'total' => 0], $result); + } + + public function testSecurityLevelFilterRestrictsTenantScoped(): void + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('listDomains')->willReturn($this->sampleDomains()); + $gateway->method('listDomainContractLines')->willReturn([]); + + $levels = $this->createMock(DomainSecurityLevelService::class); + $levels->expects($this->once()) + ->method('getAllLevelsForDomains') + ->with(self::TENANT_ID, $this->callback(static fn (array $nos): bool => $nos === ['DNS001', 'DNS002', 'DNS003'])) + ->willReturn(['DNS002' => 'hoch']); + $levels->method('getAllDetailsForDomains')->willReturn([]); + + $service = new DomainListService($gateway, $levels); + $result = $service->query(self::TENANT_ID, ['security_level' => 'hoch']); + + $this->assertSame(1, $result['total']); + $this->assertSame('DNS002', $result['rows'][0]['No']); + } + + /** + * @param list> $domains + */ + private function buildService(array $domains): DomainListService + { + $gateway = $this->createMock(BcODataGateway::class); + $gateway->method('listDomains')->willReturn($domains); + $gateway->method('listDomainContractLines')->willReturn([]); + + $levels = $this->createMock(DomainSecurityLevelService::class); + $levels->method('getAllDetailsForDomains')->willReturn([]); + $levels->method('getAllLevelsForDomains')->willReturn([]); + + return new DomainListService($gateway, $levels); + } + + /** + * @return list> + */ + private function sampleDomains(): array + { + return [ + ['No' => 'DNS001', 'Customer_No' => 'C-1', 'Customer_Name' => 'Acme GmbH', 'URL' => 'example.com', 'State' => 'Aktiv', 'Administration' => 'A'], + ['No' => 'DNS002', 'Customer_No' => 'C-2', 'Customer_Name' => 'Beta AG', 'URL' => 'beta.de', 'State' => 'Aktiv', 'Administration' => 'A'], + ['No' => 'DNS003', 'Customer_No' => 'C-3', 'Customer_Name' => 'Gamma KG', 'URL' => 'gamma.io', 'State' => 'In Vorbereitung', 'Administration' => 'B'], + ]; + } +} diff --git a/modules/helpdesk/web/js/pages/helpdesk-domains-index.js b/modules/helpdesk/web/js/pages/helpdesk-domains-index.js index 5d9607a..5c4a998 100644 --- a/modules/helpdesk/web/js/pages/helpdesk-domains-index.js +++ b/modules/helpdesk/web/js/pages/helpdesk-domains-index.js @@ -2,6 +2,7 @@ import { createListPageModule } from '/js/core/app-list-page-module.js'; import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js'; import { postForm } from '/js/core/app-http.js'; import { showAsyncFlash } from '/js/components/app-async-flash.js'; +import { initListExport } from '/js/core/app-list-export.js'; const LEVEL_LABELS = { niedrig: 'Low', @@ -314,6 +315,10 @@ createListPageModule({ gridRef.grid = gridConfig?.grid || null; + if (gridConfig && config.exportUrl) { + initListExport({ gridConfig, exportUrl: config.exportUrl }); + } + return gridConfig; }, }); diff --git a/templates/partials/app-list-export-dropdown.phtml b/templates/partials/app-list-export-dropdown.phtml new file mode 100644 index 0000000..a7b3274 --- /dev/null +++ b/templates/partials/app-list-export-dropdown.phtml @@ -0,0 +1,35 @@ + with two triggers: + * [data-list-export="csv"] — plain CSV + * [data-list-export="excel"] — UTF-8 BOM + semicolon (opens in Excel) + * + * The URL is NOT set here. The JS module `/js/core/app-list-export.js` + * picks up the trigger clicks, reads the grid filter/sort state, and + * navigates to the configured export endpoint. + * + * Consumer contract: + * - Pass the export endpoint to the JS module via page config + * (initListExport({ gridConfig, exportUrl })). + * - Place this partial inside $listTitleActionsHtml. + */ +?> + diff --git a/tests/Service/Export/CsvExportServiceTest.php b/tests/Service/Export/CsvExportServiceTest.php new file mode 100644 index 0000000..fa7c7b4 --- /dev/null +++ b/tests/Service/Export/CsvExportServiceTest.php @@ -0,0 +1,146 @@ +service = new CsvExportService(); + } + + public function testCsvFlavorUsesCommaWithoutBom(): void + { + $output = $this->render( + rows: [ + ['name' => 'Alice', 'city' => 'Berlin'], + ['name' => 'Bob', 'city' => 'Hamburg'], + ], + columns: [ + new ExportColumn('Name', static fn (array $r): string => (string) $r['name']), + new ExportColumn('City', static fn (array $r): string => (string) $r['city']), + ], + flavor: CsvExportService::FLAVOR_CSV, + ); + + $this->assertStringStartsWith('Name,City', $output); + $this->assertStringContainsString("Alice,Berlin", $output); + $this->assertStringContainsString("Bob,Hamburg", $output); + $this->assertStringNotContainsString("\xEF\xBB\xBF", $output, 'CSV flavor must not emit UTF-8 BOM'); + } + + public function testExcelFlavorUsesSemicolonWithBom(): void + { + $output = $this->render( + rows: [['name' => 'Alice', 'city' => 'Berlin']], + columns: [ + new ExportColumn('Name', static fn (array $r): string => (string) $r['name']), + new ExportColumn('City', static fn (array $r): string => (string) $r['city']), + ], + flavor: CsvExportService::FLAVOR_EXCEL, + ); + + $this->assertStringStartsWith("\xEF\xBB\xBF", $output, 'Excel flavor must start with UTF-8 BOM'); + $this->assertStringContainsString('Name;City', $output); + $this->assertStringContainsString('Alice;Berlin', $output); + } + + public function testFormulaInjectionIsEscaped(): void + { + foreach (['=SUM(A1:A10)', '@foo()', '+cmd', '-bad', "\tTAB", "\rCR"] as $payload) { + $this->assertSame( + "'" . $payload, + CsvExportService::escapeValue($payload), + 'Value starting with leading special character must be quoted: ' . bin2hex($payload) + ); + } + } + + public function testHeaderRowIsAlsoEscaped(): void + { + $output = $this->render( + rows: [['v' => 'safe']], + columns: [new ExportColumn('=HYPERLINK("x")', static fn (array $r): string => (string) $r['v'])], + ); + + // The header itself must be escaped — a future consumer may pass user-provided column labels. + $this->assertStringContainsString('"\'=HYPERLINK(""x"")"', $output); + } + + public function testSignedNumericIsAllowedForPhoneLikeColumns(): void + { + $this->assertSame('+49 123 456', CsvExportService::escapeValue('+49 123 456', allowSignedNumeric: true)); + $this->assertSame('-12.5', CsvExportService::escapeValue('-12.5', allowSignedNumeric: true)); + // Non-numeric leading + still gets escaped even when flag set + $this->assertSame("'+SUM(1)", CsvExportService::escapeValue('+SUM(1)', allowSignedNumeric: true)); + } + + public function testNullAndBoolsAreNormalized(): void + { + $this->assertSame('', CsvExportService::escapeValue(null)); + $this->assertSame('1', CsvExportService::escapeValue(true)); + $this->assertSame('0', CsvExportService::escapeValue(false)); + $this->assertSame('', CsvExportService::escapeValue('')); + } + + public function testSpecialCharactersAreQuotedProperly(): void + { + $output = $this->render( + rows: [['value' => 'contains "quotes" and, commas']], + columns: [new ExportColumn('V', static fn (array $r): string => (string) $r['value'])], + ); + + $this->assertStringContainsString('"contains ""quotes"" and, commas"', $output); + } + + public function testMultilineValuesAreQuoted(): void + { + $output = $this->render( + rows: [['note' => "line 1\nline 2"]], + columns: [new ExportColumn('Note', static fn (array $r): string => (string) $r['note'])], + ); + + $this->assertStringContainsString("\"line 1\nline 2\"", $output); + } + + public function testEmptyColumnsThrow(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->render(rows: [['a' => 'b']], columns: []); + } + + public function testIterableRowsSupported(): void + { + $generator = (function () { + yield ['n' => 1]; + yield ['n' => 2]; + })(); + + $output = $this->render( + rows: $generator, + columns: [new ExportColumn('N', static fn (array $r): string => (string) $r['n'])], + ); + + $this->assertStringContainsString("N\n1\n2", $output); + } + + /** + * @param iterable> $rows + * @param list $columns + */ + private function render(iterable $rows, array $columns, string $flavor = CsvExportService::FLAVOR_CSV): string + { + $handle = fopen('php://memory', 'w+'); + $this->service->writeTo($handle, $rows, $columns, $flavor); + rewind($handle); + $contents = stream_get_contents($handle); + fclose($handle); + return (string) $contents; + } +} diff --git a/tests/Service/Export/ExportHelpersTest.php b/tests/Service/Export/ExportHelpersTest.php new file mode 100644 index 0000000..37f4fb2 --- /dev/null +++ b/tests/Service/Export/ExportHelpersTest.php @@ -0,0 +1,46 @@ +assertSame(5000, exportCapLimit(null, 5000)); + $this->assertSame(5000, exportCapLimit('', 5000)); + $this->assertSame(5000, exportCapLimit('abc', 5000)); + } + + public function testExportCapLimitClampsZeroAndNegativeToMax(): void + { + $this->assertSame(5000, exportCapLimit(0, 5000)); + $this->assertSame(5000, exportCapLimit(-1, 5000)); + $this->assertSame(5000, exportCapLimit('-42', 5000)); + } + + public function testExportCapLimitRespectsUserValueWhenInRange(): void + { + $this->assertSame(250, exportCapLimit(250, 5000)); + $this->assertSame(5000, exportCapLimit(5000, 5000)); + $this->assertSame(1, exportCapLimit(1, 5000)); + } + + public function testExportCapLimitClampsAboveMaxToMax(): void + { + $this->assertSame(5000, exportCapLimit(9999, 5000)); + $this->assertSame(100, exportCapLimit(999999, 100)); + } + + public function testExportCapLimitHonoursCustomMax(): void + { + $this->assertSame(50, exportCapLimit(50, 100)); + $this->assertSame(100, exportCapLimit(200, 100)); + } +} diff --git a/web/js/core/app-list-export.js b/web/js/core/app-list-export.js new file mode 100644 index 0000000..3d352e1 --- /dev/null +++ b/web/js/core/app-list-export.js @@ -0,0 +1,79 @@ +/** + * Generic export trigger for Grid.js list pages. + * + * Binds click handlers to `[data-list-export]` elements. Each click builds + * an export URL that mirrors the grid's current filter/search/sort state, + * appends `format=`, and navigates the browser there so the + * Content-Disposition download fires with the user's session cookie intact. + * + * Usage: + * initListExport({ + * gridConfig, // returned by initListPage() + * exportUrl: '/helpdesk/domains/export', + * scope: document, // optional, defaults to document + * }); + */ + +const DEFAULT_TRIGGER_SELECTOR = '[data-list-export]'; + +export function initListExport({ gridConfig, exportUrl, scope = document, triggers = DEFAULT_TRIGGER_SELECTOR } = {}) { + if (!gridConfig || typeof gridConfig.baseUrl !== 'function') { + return; + } + const target = typeof exportUrl === 'string' ? exportUrl.trim() : ''; + if (target === '') { + return; + } + const root = scope || document; + const nodes = root.querySelectorAll(triggers); + if (!nodes.length) { + return; + } + + const onClick = (event) => { + const button = event.currentTarget; + if (!(button instanceof HTMLElement)) { + return; + } + const flavor = (button.dataset.listExport || 'csv').toLowerCase(); + + let url; + try { + url = new URL(target, window.location.href); + } catch { + return; + } + + // Mirror grid filters + search onto the export URL + try { + const dataUrl = new URL(gridConfig.baseUrl(), window.location.href); + dataUrl.searchParams.forEach((value, key) => { + if (key === 'limit' || key === 'offset') { + return; + } + url.searchParams.set(key, value); + }); + } catch { + // ignore malformed grid base URLs — fall through without filters + } + + // Current sort + const sort = typeof gridConfig.getSortParams === 'function' ? gridConfig.getSortParams() : null; + if (sort && sort.order) { + url.searchParams.set('order', sort.order); + url.searchParams.set('dir', sort.dir || 'asc'); + } + + url.searchParams.set('format', flavor === 'excel' ? 'excel' : 'csv'); + + // Close parent
dropdown if present + const details = button.closest('details'); + if (details) { + details.open = false; + } + + window.location.href = url.toString(); + }; + + nodes.forEach((node) => node.addEventListener('click', onClick)); +}