= 1). * * - `perPage` — page size, bounded by * {@see self::DEFAULT_PER_PAGE} / {@see self::MAX_PER_PAGE}. * Wider than the public products list (50 in §5.1) because * the audit viewer is an admin-only diagnostics surface, * matching {@see LogListQuery}'s policy. * * Out of scope (deferred): * * - `request_id` / `correlation_id` / `job_id` filters from * §12.4 (Phase 2 KI-006 follow-up): exposed as the * {@see self::requestId()} / {@see self::correlationId()} / * {@see self::jobId()} accessors. These three values are * stored inside `context_json` rather than first-class * columns, so the persistence layer honors them via either a * `JSON_EXTRACT()` query (Wpdb implementation) or a * JSON-decode + key-lookup pass (in-memory implementation). * Original out-of-scope note follows for historical context. * The honoring path requires either a `JSON_EXTRACT()` query * or a denormalized index column. Both * options are larger than the KI-006 closeout warrants and * are tracked as a follow-up alongside the §12.5 summary * endpoint. * * Failure semantics: * * - Every validation failure raises * {@see InvalidArgumentException} at construction time so a * caller observing a `LogListQuery` shape (REST controller, * admin page) can route the failure through one catch * boundary without re-validating the same axes downstream. * * @package Affilun\Infrastructure\Persistence */ declare(strict_types=1); namespace Affilun\Infrastructure\Persistence; use InvalidArgumentException; /** * Validated query surface for the §12.4 audit-events listing. * * See the file-level docblock for the per-axis vocabulary, the * pagination bounds, and the deferred `request_id` / * `correlation_id` / `job_id` filters. */ final class AuditEventListQuery { /** * Default per-page count when the caller does not supply * `per_page`. Mirrors {@see LogListQuery::DEFAULT_PER_PAGE}. */ public const DEFAULT_PER_PAGE = 20; /** * Maximum per-page count the audit list accepts. Mirrors the * {@see LogListQuery} ceiling (100) because the audit viewer * is an admin-only diagnostics surface, wider than the public * products list ceiling of 50. */ public const MAX_PER_PAGE = 100; /** * Closed vocabulary for the §10.2 `audit_kind` filter axis. * * Held as a public class constant so REST controllers, * admin-page parsers, diagnostics surfaces, and tests share * one source of truth. * * @var list */ public const VALID_AUDIT_KINDS = array( 'configuration_change', 'destructive_action', 'security_event', 'operational_action', 'compliance_relevant_action', ); /** * Optional `audit_kind` exact-match filter. */ private ?string $auditKind; /** * Optional `actor_id` exact-match filter. */ private ?string $actorId; /** * Optional `target_type` exact-match filter. */ private ?string $targetType; /** * Optional `target_id` exact-match filter. */ private ?string $targetId; /** * 1-based page number (>= 1). */ private int $page; /** * Page size (1 .. {@see self::MAX_PER_PAGE}). */ private int $perPage; /** * Optional `context_json.$.request_id` exact-match filter. * * Phase 2 KI-006 follow-up: §12.4 lists `request_id` as a * supported query parameter. The value is not a first-class * column on `affilun_audit_events`; the persistence layer honors * the filter via a JSON-key lookup (`JSON_EXTRACT` for the * Wpdb implementation, decode + key-match for the in-memory * implementation). */ private ?string $requestId; /** * Optional `context_json.$.correlation_id` exact-match filter. * * Phase 2 KI-006 follow-up — see {@see self::$requestId} for * the persistence-side honoring contract. */ private ?string $correlationId; /** * Optional `context_json.$.job_id` exact-match filter. * * Phase 2 KI-006 follow-up — see {@see self::$requestId} for * the persistence-side honoring contract. Particularly useful * for "show me every audit row produced by sync job N" drill- * downs since {@see \Affilun\Application\Audit\AuditEventDraft::forJobAction()} * auto-populates `context_json.job_id`. */ private ?string $jobId; /** * Construct a validated audit-events list query. * * Every axis is optional. Filter strings are trimmed; an * empty trimmed value is treated as "no filter" rather than * "empty-string filter" so a stray `?actor_id=` query * parameter does not silently match every row whose actor id * is the empty string. * * The Phase 2 KI-006 follow-up `requestId` / `correlationId` / * `jobId` axes are appended AFTER `page` / `perPage` to * preserve every existing positional caller — both the page * pair and the four first-class axes were positional before * the follow-up, and re-shuffling them would silently change * the meaning of unnamed callers. * * @param string|null $auditKind §10.2 audit-kind filter; rejected * when off-whitelist. * @param string|null $actorId §10.2 actor-id filter. * @param string|null $targetType §10.2 target-type filter. * @param string|null $targetId §10.2 target-id filter. * @param int $page 1-based page number. * @param int $perPage Page size (1 .. MAX_PER_PAGE). * @param string|null $requestId §12.4 `context_json.$.request_id` * filter (Phase 2 follow-up). * @param string|null $correlationId §12.4 `context_json.$.correlation_id` * filter (Phase 2 follow-up). * @param string|null $jobId §12.4 `context_json.$.job_id` * filter (Phase 2 follow-up). * * @throws InvalidArgumentException When `$auditKind` is off-whitelist, * when `$page < 1`, or when * `$perPage` is out of range. */ public function __construct( ?string $auditKind = null, ?string $actorId = null, ?string $targetType = null, ?string $targetId = null, int $page = 1, int $perPage = self::DEFAULT_PER_PAGE, ?string $requestId = null, ?string $correlationId = null, ?string $jobId = null ) { $normalizedAuditKind = self::normalizeOptional( $auditKind ); $normalizedActorId = self::normalizeOptional( $actorId ); $normalizedTargetType = self::normalizeOptional( $targetType ); $normalizedTargetId = self::normalizeOptional( $targetId ); $normalizedRequestId = self::normalizeOptional( $requestId ); $normalizedCorrelationId = self::normalizeOptional( $correlationId ); $normalizedJobId = self::normalizeOptional( $jobId ); if ( $normalizedAuditKind !== null && ! in_array( $normalizedAuditKind, self::VALID_AUDIT_KINDS, true ) ) { $message = sprintf( 'AuditEventListQuery: auditKind must be one of [%s]; got "%s".', implode( ', ', self::VALID_AUDIT_KINDS ), $normalizedAuditKind ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } if ( $page < 1 ) { $message = sprintf( 'AuditEventListQuery: page must be >= 1; got %d.', $page ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } if ( $perPage < 1 || $perPage > self::MAX_PER_PAGE ) { $message = sprintf( 'AuditEventListQuery: perPage must be 1..%d; got %d.', self::MAX_PER_PAGE, $perPage ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } $this->auditKind = $normalizedAuditKind; $this->actorId = $normalizedActorId; $this->targetType = $normalizedTargetType; $this->targetId = $normalizedTargetId; $this->page = $page; $this->perPage = $perPage; $this->requestId = $normalizedRequestId; $this->correlationId = $normalizedCorrelationId; $this->jobId = $normalizedJobId; } /** * Closed §10.2 audit-kind vocabulary used by REST validators * and admin-page parsers that need to enumerate the accepted * filter values. * * @return list */ public static function validAuditKinds(): array { return self::VALID_AUDIT_KINDS; } /** * Default query: page 1, {@see self::DEFAULT_PER_PAGE} per * page, no filters on any axis (including the Phase 2 * KI-006 follow-up `requestId` / `correlationId` / `jobId` * context filters). * * Used by the admin-page query parser as a graceful-fallback * value when a tampered or stale `$_GET` URL would otherwise * raise from {@see self::__construct()}; the admin page then * still renders a usable view rather than fataling on a * bookmarked URL that survived a vocabulary tweak. * * Mirrors {@see \Affilun\Infrastructure\Persistence\LogListQuery::defaults()} * so the §6 Logs admin page and the (Phase 2 follow-up) §10 * Audit Events admin page share the same fallback contract. */ public static function defaults(): self { return new self(); } /** * Optional `audit_kind` exact-match filter, or null when absent. */ public function auditKind(): ?string { return $this->auditKind; } /** * Optional `actor_id` exact-match filter, or null when absent. */ public function actorId(): ?string { return $this->actorId; } /** * Optional `target_type` exact-match filter, or null when absent. */ public function targetType(): ?string { return $this->targetType; } /** * Optional `target_id` exact-match filter, or null when absent. */ public function targetId(): ?string { return $this->targetId; } /** * Optional `context_json.$.request_id` exact-match filter, * or null when absent. * * Phase 2 KI-006 follow-up. */ public function requestId(): ?string { return $this->requestId; } /** * Optional `context_json.$.correlation_id` exact-match filter, * or null when absent. * * Phase 2 KI-006 follow-up. */ public function correlationId(): ?string { return $this->correlationId; } /** * Optional `context_json.$.job_id` exact-match filter, or * null when absent. * * Phase 2 KI-006 follow-up. */ public function jobId(): ?string { return $this->jobId; } /** * 1-based page number. */ public function page(): int { return $this->page; } /** * Number of records per page. */ public function perPage(): int { return $this->perPage; } /** * Zero-based row offset derived from * `(page - 1) * perPage`. Convenience accessor so the * repository's `LIMIT` / `OFFSET` clause does not have to * re-derive the value from the `(page, perPage)` pair. */ public function offset(): int { return ( $this->page - 1 ) * $this->perPage; } /** * Produce a copy with the given `$page` value. * * @param int $page 1-based page number (>= 1). * * @throws InvalidArgumentException When `$page < 1`. */ public function withPage( int $page ): self { return new self( $this->auditKind, $this->actorId, $this->targetType, $this->targetId, $page, $this->perPage, $this->requestId, $this->correlationId, $this->jobId ); } /** * Produce a copy with the given `$perPage` value. * * @param int $perPage Page size (1 .. MAX_PER_PAGE). * * @throws InvalidArgumentException When `$perPage` is out of range. */ public function withPerPage( int $perPage ): self { return new self( $this->auditKind, $this->actorId, $this->targetType, $this->targetId, $this->page, $perPage, $this->requestId, $this->correlationId, $this->jobId ); } /** * Whether this query is structurally identical to `$other`. * * @param self $other Query to compare. */ public function equals( self $other ): bool { return $this->auditKind === $other->auditKind && $this->actorId === $other->actorId && $this->targetType === $other->targetType && $this->targetId === $other->targetId && $this->requestId === $other->requestId && $this->correlationId === $other->correlationId && $this->jobId === $other->jobId && $this->page === $other->page && $this->perPage === $other->perPage; } /** * Trim an optional string filter, returning null when the * trimmed result is empty so a stray `?key=` query parameter * is treated as "no filter" rather than "empty-string filter". * * @param string|null $value Raw filter value. */ private static function normalizeOptional( ?string $value ): ?string { if ( $value === null ) { return null; } $trimmed = trim( $value ); return $trimmed === '' ? null : $trimmed; } }