refactor(audit): extract SystemAuditRowPresenter shared by data + export

Move the row formatting that was duplicated between system-audit's
data() and export() endpoints into a single presenter. Both endpoints
now call presentAll() on the same object, so enum label translation,
actor resolution, and timestamp formatting cannot drift between the
Grid.js UI and the CSV download.

- SystemAuditRowPresenter::present()/presentAll(): canonical row shape
  with outcome + outcome_label + outcome_badge, channel + channel_label,
  actor_user_label with display-name-then-email fallback, and safe
  defaults for every missing field.
- data().php shrinks from ~45 to ~15 lines; export().php drops its
  inline outcome/channel/actor resolvers and reads the already-
  resolved fields from the presenter output.
- AuditContainerRegistrar registers the presenter.
- tests/Module/Audit/Service/SystemAuditRowPresenterTest: 9 cases
  covering enum normalization, unknown-value fallback, actor label
  precedence (display name → email → "-"), whitespace trimming, safe
  defaults for missing keys, and iterable input.
- StatusTaxonomyContractFiles: the taxonomy data contract now points
  at the presenter (the single source of truth for badge/label
  resolution) instead of the thin data endpoint, and the presenter is
  added to the literal-guard file list.

Gates: PHPUnit 1889 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:20:39 +02:00
parent 0386ee08a6
commit 3b6896266e
6 changed files with 216 additions and 57 deletions

View File

@@ -23,6 +23,7 @@ use MintyPHP\Module\Audit\Service\AuditMetadataEnricher;
use MintyPHP\Module\Audit\Service\FrontendTelemetryIngestService;
use MintyPHP\Module\Audit\Service\ImportAuditService;
use MintyPHP\Module\Audit\Service\SystemAuditRedactionService;
use MintyPHP\Module\Audit\Service\SystemAuditRowPresenter;
use MintyPHP\Module\Audit\Service\SystemAuditService;
use MintyPHP\Module\Audit\Service\UserLifecycleAuditService;
use MintyPHP\Repository\User\UserReadRepository;
@@ -47,6 +48,7 @@ final class AuditContainerRegistrar implements ContainerRegistrar
// Redaction
$container->set(SystemAuditRedactionService::class, static fn (): SystemAuditRedactionService => new SystemAuditRedactionService());
$container->set(SystemAuditRowPresenter::class, static fn (): SystemAuditRowPresenter => new SystemAuditRowPresenter());
// Concrete audit services
$container->set(SystemAuditService::class, static fn (AppContainer $c): SystemAuditService => new SystemAuditService(

View File

@@ -0,0 +1,86 @@
<?php
namespace MintyPHP\Module\Audit\Service;
use MintyPHP\Module\Audit\Domain\SystemAuditChannel;
use MintyPHP\Module\Audit\Domain\SystemAuditOutcome;
/**
* Converts raw system audit log rows (as returned by the repository)
* into a canonical presentation-ready row shape. One source of truth
* shared by the Grid.js data endpoint and the CSV export endpoint, so
* enum label translation, actor resolution, and timestamp formatting
* can never drift between the UI and the download.
*/
final class SystemAuditRowPresenter
{
/**
* @param array<string,mixed> $row
* @return array{
* id: int,
* created_at: string,
* event_type: string,
* outcome: string,
* outcome_badge: string,
* outcome_label: string,
* channel: string,
* channel_label: string,
* actor_user_id: int,
* actor_user_uuid: string,
* actor_user_label: string,
* actor_user_email: string,
* target_type: string,
* target_uuid: string,
* request_id: string,
* error_code: string
* }
*/
public function present(array $row): array
{
$outcome = SystemAuditOutcome::normalizeOr((string) ($row['outcome'] ?? ''), SystemAuditOutcome::Success);
$channel = SystemAuditChannel::normalizeOr((string) ($row['channel'] ?? ''), SystemAuditChannel::Web);
return [
'id' => (int) ($row['id'] ?? 0),
'created_at' => (string) dt((string) ($row['created_at'] ?? '')),
'event_type' => (string) ($row['event_type'] ?? ''),
'outcome' => $outcome->value,
'outcome_badge' => $outcome->badgeVariant(),
'outcome_label' => (string) t($outcome->labelToken()),
'channel' => strtoupper($channel->labelToken()),
'channel_label' => strtoupper($channel->labelToken()),
'actor_user_id' => (int) ($row['actor_user_id'] ?? 0),
'actor_user_uuid' => (string) ($row['actor_user_uuid'] ?? ''),
'actor_user_label' => $this->resolveActorLabel($row),
'actor_user_email' => (string) ($row['actor_user_email'] ?? ''),
'target_type' => (string) ($row['target_type'] ?? ''),
'target_uuid' => (string) ($row['target_uuid'] ?? ''),
'request_id' => (string) ($row['request_id'] ?? ''),
'error_code' => (string) ($row['error_code'] ?? ''),
];
}
/**
* @api Called from data and export endpoints
* @param iterable<array<string,mixed>> $rows
* @return list<array<string,mixed>>
*/
public function presentAll(iterable $rows): array
{
$result = [];
foreach ($rows as $row) {
$result[] = $this->present($row);
}
return $result;
}
private function resolveActorLabel(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 : '-';
}
}

View File

@@ -1,8 +1,7 @@
<?php
use MintyPHP\Module\Audit\AuditAuthorizationPolicy;
use MintyPHP\Module\Audit\Domain\SystemAuditChannel;
use MintyPHP\Module\Audit\Domain\SystemAuditOutcome;
use MintyPHP\Module\Audit\Service\SystemAuditRowPresenter;
use MintyPHP\Module\Audit\Service\SystemAuditService;
use MintyPHP\Support\Guard;
@@ -13,33 +12,6 @@ gridRequireGetRequest();
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php');
$result = app(SystemAuditService::class)->listPaged($filters);
$rows = [];
foreach ((array) ($result['rows'] ?? []) as $row) {
$outcome = SystemAuditOutcome::normalizeOr((string) ($row['outcome'] ?? ''), SystemAuditOutcome::Success);
$channel = SystemAuditChannel::normalizeOr((string) ($row['channel'] ?? ''), SystemAuditChannel::Web);
$actorLabel = trim((string) ($row['actor_user_display_name'] ?? ''));
if ($actorLabel === '') {
$actorLabel = trim((string) ($row['actor_user_email'] ?? ''));
}
$rows[] = [
'id' => (int) ($row['id'] ?? 0),
'created_at' => dt((string) ($row['created_at'] ?? '')),
'event_type' => (string) ($row['event_type'] ?? ''),
'outcome' => $outcome->value,
'outcome_badge' => $outcome->badgeVariant(),
'outcome_label' => t($outcome->labelToken()),
'channel' => strtoupper($channel->labelToken()),
'actor_user_id' => (int) ($row['actor_user_id'] ?? 0),
'actor_user_uuid' => (string) ($row['actor_user_uuid'] ?? ''),
'actor_user_label' => $actorLabel !== '' ? $actorLabel : '-',
'target_type' => (string) ($row['target_type'] ?? ''),
'target_uuid' => (string) ($row['target_uuid'] ?? ''),
'request_id' => (string) ($row['request_id'] ?? ''),
'error_code' => (string) ($row['error_code'] ?? ''),
];
}
$rows = app(SystemAuditRowPresenter::class)->presentAll((array) ($result['rows'] ?? []));
gridJsonDataResult($rows, (int) ($result['total'] ?? 0));

View File

@@ -1,8 +1,7 @@
<?php
use MintyPHP\Module\Audit\AuditAuthorizationPolicy;
use MintyPHP\Module\Audit\Domain\SystemAuditChannel;
use MintyPHP\Module\Audit\Domain\SystemAuditOutcome;
use MintyPHP\Module\Audit\Service\SystemAuditRowPresenter;
use MintyPHP\Module\Audit\Service\SystemAuditService;
use MintyPHP\Service\Export\ExportColumn;
use MintyPHP\Support\Guard;
@@ -18,33 +17,15 @@ $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 : '';
};
$rows = app(SystemAuditRowPresenter::class)->presentAll((array) ($result['rows'] ?? []));
$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('Created'), static fn (array $r): string => (string) ($r['created_at'] ?? '')),
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('Status'), static fn (array $r): string => (string) ($r['outcome_label'] ?? '')),
new ExportColumn(t('Channel'), static fn (array $r): string => (string) ($r['channel_label'] ?? '')),
new ExportColumn(t('Actor'), static fn (array $r): string => (string) ($r['actor_user_label'] ?? '')),
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'] ?? '')),
@@ -54,4 +35,4 @@ $columns = [
$filename = 'system-audit-' . date('Ymd-His') . '.csv';
exportSendCsv($filename, (array) ($result['rows'] ?? []), $columns, exportResolveFlavor($request));
exportSendCsv($filename, $rows, $columns, exportResolveFlavor($request));

View File

@@ -0,0 +1,115 @@
<?php
namespace MintyPHP\Tests\Module\Audit\Service;
use MintyPHP\Module\Audit\Service\SystemAuditRowPresenter;
use PHPUnit\Framework\TestCase;
class SystemAuditRowPresenterTest extends TestCase
{
private SystemAuditRowPresenter $presenter;
protected function setUp(): void
{
$this->presenter = new SystemAuditRowPresenter();
}
public function testPresentNormalizesEnumsAndFillsDefaults(): void
{
$out = $this->presenter->present([
'id' => 42,
'outcome' => 'success',
'channel' => 'web',
'event_type' => 'user.login',
]);
$this->assertSame(42, $out['id']);
$this->assertSame('success', $out['outcome']);
$this->assertSame('user.login', $out['event_type']);
$this->assertNotSame('', $out['outcome_label'], 'outcome_label should be translated, not empty');
$this->assertNotSame('', $out['outcome_badge'], 'outcome_badge variant should be set');
$this->assertSame(strtoupper($out['channel_label']), $out['channel_label'], 'channel label is uppercased');
$this->assertSame($out['channel'], $out['channel_label'], 'channel and channel_label stay in sync');
}
public function testPresentFallsBackToDefaultsForUnknownEnumValues(): void
{
$out = $this->presenter->present([
'outcome' => 'not-a-valid-outcome',
'channel' => 'not-a-valid-channel',
]);
$this->assertSame('success', $out['outcome'], 'unknown outcome falls back to success');
$this->assertNotSame('', $out['channel'], 'unknown channel falls back to web/default');
}
public function testActorLabelPrefersDisplayName(): void
{
$out = $this->presenter->present([
'actor_user_display_name' => 'Alice Doe',
'actor_user_email' => 'alice@example.com',
]);
$this->assertSame('Alice Doe', $out['actor_user_label']);
$this->assertSame('alice@example.com', $out['actor_user_email']);
}
public function testActorLabelFallsBackToEmail(): void
{
$out = $this->presenter->present([
'actor_user_display_name' => '',
'actor_user_email' => 'bob@example.com',
]);
$this->assertSame('bob@example.com', $out['actor_user_label']);
}
public function testActorLabelFallsBackToDashWhenNothingAvailable(): void
{
$out = $this->presenter->present([]);
$this->assertSame('-', $out['actor_user_label']);
}
public function testActorLabelTrimsWhitespace(): void
{
$out = $this->presenter->present([
'actor_user_display_name' => " \t ",
'actor_user_email' => ' carol@example.com ',
]);
$this->assertSame('carol@example.com', $out['actor_user_label']);
}
public function testMissingFieldsAreSafeDefaults(): void
{
$out = $this->presenter->present([]);
$this->assertSame(0, $out['id']);
$this->assertSame('', $out['event_type']);
$this->assertSame('', $out['target_type']);
$this->assertSame('', $out['target_uuid']);
$this->assertSame('', $out['request_id']);
$this->assertSame('', $out['error_code']);
$this->assertSame(0, $out['actor_user_id']);
}
public function testPresentAllMapsIterableToList(): void
{
$rows = (function () {
yield ['id' => 1, 'outcome' => 'success'];
yield ['id' => 2, 'outcome' => 'failed'];
})();
$out = $this->presenter->presentAll($rows);
$this->assertCount(2, $out);
$this->assertSame(1, $out[0]['id']);
$this->assertSame(2, $out[1]['id']);
$this->assertSame('failed', $out[1]['outcome']);
}
public function testPresentAllWithEmptyIterableReturnsEmptyArray(): void
{
$this->assertSame([], $this->presenter->presentAll([]));
}
}

View File

@@ -62,7 +62,9 @@ final class StatusTaxonomyContractFiles
public static function taxonomyDataEndpointFiles(): array
{
return [
'modules/audit/pages/audit/system-audit/data().php',
// System audit: data + export endpoints delegate badge/label resolution
// to SystemAuditRowPresenter, which is the enforced source of truth.
'modules/audit/lib/Module/Audit/Service/SystemAuditRowPresenter.php',
'modules/audit/pages/audit/user-lifecycle-audit/data().php',
'modules/audit/pages/audit/import-audit/data().php',
'pages/admin/scheduled-jobs/data().php',
@@ -79,6 +81,7 @@ final class StatusTaxonomyContractFiles
{
return [
'modules/audit/lib/Module/Audit/Service/SystemAuditService.php',
'modules/audit/lib/Module/Audit/Service/SystemAuditRowPresenter.php',
'modules/audit/lib/Module/Audit/Repository/SystemAuditLogRepository.php',
'modules/audit/lib/Module/Audit/Http/ApiSystemAuditReporter.php',
'modules/audit/lib/Module/Audit/Service/UserLifecycleAuditService.php',