feat(audit): CSV/Excel export for system-audit via core primitive

Third consumer of the generic export primitive. Adds a
GET /admin/system-audit/export endpoint, the reusable export dropdown
to the list titlebar, and wires initListExport() into the page JS.

- pages/audit/system-audit/export().php: guard + ABILITY_SYSTEM_AUDIT_VIEW,
  reuses the existing filter-schema.php via gridParseFiltersFromSchemaFile
  so exported rows match the grid under the same filters, caps the limit
  at 5000, and declares 11 ExportColumns (ID, created, event, status,
  channel, actor, actor email, target type/uuid, request id, error code)
  with translated enum labels for outcome/channel.
- module.php: new admin/system-audit/export route.
- index(default).phtml: app-list-export-dropdown partial placed above the
  existing purge action; exportUrl added to pageConfig via
  lurl('admin/system-audit/export').
- admin-system-audit-index.js: imports initListExport and invokes it
  whenever config.exportUrl is present.
- i18n de/en: new "Actor email" key (required by TranslationKeysTest).

Gates: PHPUnit 1880 OK, PHPStan 0 errors, module:sync ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 22:15:43 +02:00
parent 2a4ccc8f1a
commit 0386ee08a6
6 changed files with 69 additions and 2 deletions

View File

@@ -68,5 +68,6 @@
"Lifecycle risks (7d)": "Lifecycle-Risiken (7d)",
"Top lifecycle reason codes (7d)": "Top-Lifecycle-Fehlercodes (7d)",
"Recent lifecycle risks (7d)": "Aktuelle Lifecycle-Risiken (7d)",
"Audit & logs": "Audit & Protokolle"
"Audit & logs": "Audit & Protokolle",
"Actor email": "Akteur-E-Mail"
}

View File

@@ -68,5 +68,6 @@
"Lifecycle risks (7d)": "Lifecycle risks (7d)",
"Top lifecycle reason codes (7d)": "Top lifecycle reason codes (7d)",
"Recent lifecycle risks (7d)": "Recent lifecycle risks (7d)",
"Audit & logs": "Audit & logs"
"Audit & logs": "Audit & logs",
"Actor email": "Actor email"
}

View File

@@ -24,6 +24,7 @@ return [
['path' => 'admin/system-audit', 'target' => 'audit/system-audit/index'],
['path' => 'admin/system-audit/view', 'target' => 'audit/system-audit/view'],
['path' => 'admin/system-audit/data', 'target' => 'audit/system-audit/data'],
['path' => 'admin/system-audit/export', 'target' => 'audit/system-audit/export'],
['path' => 'admin/system-audit/purge', 'target' => 'audit/system-audit/purge'],
// Import audit pages
['path' => 'admin/import-audit', 'target' => 'audit/import-audit/index'],

View File

@@ -0,0 +1,57 @@
<?php
use MintyPHP\Module\Audit\AuditAuthorizationPolicy;
use MintyPHP\Module\Audit\Domain\SystemAuditChannel;
use MintyPHP\Module\Audit\Domain\SystemAuditOutcome;
use MintyPHP\Module\Audit\Service\SystemAuditService;
use MintyPHP\Service\Export\ExportColumn;
use MintyPHP\Support\Guard;
Guard::requireLogin();
Guard::requireAbilityOrForbidden(AuditAuthorizationPolicy::ABILITY_SYSTEM_AUDIT_VIEW);
exportRequireGetRequest();
$request = requestInput();
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php');
$filters['limit'] = exportCapLimit($request->query('limit'), 5000);
$filters['offset'] = 0;
$result = app(SystemAuditService::class)->listPaged($filters);
$resolveOutcomeLabel = static function (string $raw): string {
$outcome = SystemAuditOutcome::normalizeOr($raw, SystemAuditOutcome::Success);
return (string) t($outcome->labelToken());
};
$resolveChannelLabel = static function (string $raw): string {
$channel = SystemAuditChannel::normalizeOr($raw, SystemAuditChannel::Web);
return strtoupper($channel->labelToken());
};
$resolveActor = static function (array $row): string {
$name = trim((string) ($row['actor_user_display_name'] ?? ''));
if ($name !== '') {
return $name;
}
$email = trim((string) ($row['actor_user_email'] ?? ''));
return $email !== '' ? $email : '';
};
$columns = [
new ExportColumn(t('ID'), static fn (array $r): string => (string) ($r['id'] ?? '')),
new ExportColumn(t('Created'), static fn (array $r): string => (string) dt((string) ($r['created_at'] ?? ''), 'Y-m-d H:i:s')),
new ExportColumn(t('Event'), static fn (array $r): string => (string) ($r['event_type'] ?? '')),
new ExportColumn(t('Status'), static fn (array $r): string => $resolveOutcomeLabel((string) ($r['outcome'] ?? ''))),
new ExportColumn(t('Channel'), static fn (array $r): string => $resolveChannelLabel((string) ($r['channel'] ?? ''))),
new ExportColumn(t('Actor'), static fn (array $r): string => $resolveActor($r)),
new ExportColumn(t('Actor email'), static fn (array $r): string => (string) ($r['actor_user_email'] ?? '')),
new ExportColumn(t('Target type'), static fn (array $r): string => (string) ($r['target_type'] ?? '')),
new ExportColumn(t('Target UUID'), static fn (array $r): string => (string) ($r['target_uuid'] ?? '')),
new ExportColumn(t('Request ID'), static fn (array $r): string => (string) ($r['request_id'] ?? '')),
new ExportColumn(t('Error code'), static fn (array $r): string => (string) ($r['error_code'] ?? '')),
];
$filename = 'system-audit-' . date('Ymd-His') . '.csv';
exportSendCsv($filename, (array) ($result['rows'] ?? []), $columns, exportResolveFlavor($request));

View File

@@ -15,6 +15,7 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
$listTitle = t('System audit logs');
ob_start();
?>
<?php require templatePath('partials/app-list-export-dropdown.phtml'); ?>
<?php
$listPurgeEnabled = $canPurgeSystemAudit;
$listPurgeFormId = 'system-audit-purge-form';
@@ -45,6 +46,7 @@ $pageConfig = [
'filterSchema' => $clientFilterSchema,
'filterChipMeta' => $filterChipMeta,
'gridLang' => $gridLang,
'exportUrl' => lurl('admin/system-audit/export'),
'labels' => [
'created' => t('Created'),
'status' => t('Status'),

View File

@@ -1,5 +1,6 @@
import { createListPageModule } from '/js/core/app-list-page-module.js';
import { withCurrentListReturn } from '/js/pages/app-list-utils.js';
import { initListExport } from '/js/core/app-list-export.js';
createListPageModule({
configId: 'admin-system-audit-index',
@@ -97,6 +98,10 @@ createListPageModule({
},
}) || {};
if (gridConfig && config.exportUrl) {
initListExport({ gridConfig, exportUrl: config.exportUrl });
}
return gridConfig;
},
});