events = $events; } /** * Execute the §12.4 list-audit-events query. * * Calls * {@see AuditEventRepository::findPaginated()} with the * supplied query VO, projects every returned * {@see AuditEventRecord} into the §12.4 envelope row shape * via {@see self::projectRow()}, and wraps the result in a * {@see ListAuditEventsResult} carrying the pagination axes * echoed back from the underlying * {@see \Affilun\Infrastructure\Persistence\AuditEventListResult}. * * @param AuditEventListQuery $query Validated list-query inputs. */ public function execute( AuditEventListQuery $query ): ListAuditEventsResult { $page = $this->events->findPaginated( $query ); $items = array(); foreach ( $page->items() as $record ) { $items[] = self::projectRow( $record ); } return new ListAuditEventsResult( $items, $page->total(), $page->page(), $page->perPage(), $page->pageCount() ); } /** * Project a persisted {@see AuditEventRecord} into the §12.4 * envelope row shape. * * The actor / target slots collapse to null when both their * type and id components are absent so the envelope does not * carry a half-populated `{ type: null, id: null }` map. The * `changed_fields_summary` slot decodes `changed_fields_json` * into the structured summary; malformed JSON is silently * dropped (returns null) so a single bad row cannot break the * page projection. * * Exposed as a public static helper so adjacent consumers * (the §12.5 summary follow-up, integration tests) can reuse * the canonical projection without re-deriving it. * * @param AuditEventRecord $record Persisted audit-event record. * * @return array */ public static function projectRow( AuditEventRecord $record ): array { $actor = self::projectActorOrTarget( $record->actorType(), $record->actorId() ); $target = self::projectActorOrTarget( $record->targetType(), $record->targetId() ); $context = self::decodeJsonObject( $record->contextJson() ); $requestId = self::stringOrNull( $context['request_id'] ?? null ); $correlationId = self::stringOrNull( $context['correlation_id'] ?? null ); $jobId = self::stringOrNull( $context['job_id'] ?? null ); return array( 'id' => $record->id(), 'audit_event_type' => $record->eventType(), 'audit_kind' => $record->auditKind(), 'actor' => $actor, 'target' => $target, 'action' => $record->action(), 'result' => $record->result(), 'safe_summary' => $record->safeSummary(), 'changed_fields_summary' => self::decodeJsonObject( $record->changedFieldsJson() ), 'request_id' => $requestId, 'correlation_id' => $correlationId, 'job_id' => $jobId, 'created_at' => $record->createdAt()->format( DateTimeImmutable::ATOM ), ); } /** * Build an `{ type, id }` actor / target sub-object, or null * when both components are absent. * * @param string|null $type Actor / target type. * @param string|null $id Actor / target id. * * @return array{type: string|null, id: string|null}|null */ private static function projectActorOrTarget( ?string $type, ?string $id ): ?array { if ( $type === null && $id === null ) { return null; } return array( 'type' => $type, 'id' => $id, ); } /** * Decode an optional JSON-encoded object back into an * associative array, or return null when the input is null, * empty, malformed, or decodes to a non-object value. * * Used for both the `changed_fields_json` and `context_json` * slots so a single bad row cannot break the projection. * * @param string|null $json Raw JSON payload. * * @return array|null */ private static function decodeJsonObject( ?string $json ): ?array { if ( $json === null || $json === '' ) { return null; } $decoded = json_decode( $json, true ); if ( ! is_array( $decoded ) ) { return null; } // Reject non-associative arrays so a list-shaped payload // does not leak through as a struct slot. if ( $decoded !== array() && array_is_list( $decoded ) ) { return null; } /** @var array $decoded */ return $decoded; } /** * Coerce a context-slot value to a string, returning null * when the input is null or non-string. Used for the * `request_id` / `correlation_id` / `job_id` lifts out of * `context_json` so the envelope never carries a non-string * tracker token. * * @param mixed $value Candidate value. */ private static function stringOrNull( $value ): ?string { if ( ! is_string( $value ) ) { return null; } $trimmed = trim( $value ); return $trimmed === '' ? null : $trimmed; } }