assertNonEmpty( 'eventType', $eventType ); $this->assertNonEmpty( 'auditKind', $auditKind ); $this->assertNonEmpty( 'action', $action ); $this->assertNonEmpty( 'result', $result ); $this->assertNonEmpty( 'safeSummary', $safeSummary ); $this->id = $id; $this->eventType = $eventType; $this->auditKind = $auditKind; $this->actorType = self::nullIfEmpty( $actorType ); $this->actorId = self::nullIfEmpty( $actorId ); $this->targetType = self::nullIfEmpty( $targetType ); $this->targetId = self::nullIfEmpty( $targetId ); $this->action = $action; $this->result = $result; $this->safeSummary = $safeSummary; $this->changedFieldsJson = self::nullIfEmpty( $changedFieldsJson ); $this->contextJson = self::nullIfEmpty( $contextJson ); $this->createdAt = self::normalizeToUtc( $createdAt ) ?? $createdAt; } /** * Ergonomic named constructor for fresh (unpersisted) records. * * Required columns are taken as positional arguments; * everything optional flows through an overrides array so * callers that just want "record this audit event now" can * stay terse. * * Defaults: * * - `actorType`, `actorId`, `targetType`, `targetId`, * `changedFieldsJson`, `contextJson` all default to null. * * @param string $eventType §10.2 event type discriminator. * @param string $auditKind §10.2 audit-kind classification. * @param string $action §10.2 action verb. * @param string $result §10.2 action result. * @param string $safeSummary §10.2 safe summary. * @param DateTimeImmutable $now Current UTC timestamp. * @param array $optional Optional column overrides * keyed by property name. */ public static function createNew( string $eventType, string $auditKind, string $action, string $result, string $safeSummary, DateTimeImmutable $now, array $optional = array() ): self { $actorType = null; $actorId = null; $targetType = null; $targetId = null; $changedFieldsJson = null; $contextJson = null; if ( array_key_exists( 'actorType', $optional ) && $optional['actorType'] !== null ) { $actorType = (string) $optional['actorType']; } if ( array_key_exists( 'actorId', $optional ) && $optional['actorId'] !== null ) { $actorId = (string) $optional['actorId']; } if ( array_key_exists( 'targetType', $optional ) && $optional['targetType'] !== null ) { $targetType = (string) $optional['targetType']; } if ( array_key_exists( 'targetId', $optional ) && $optional['targetId'] !== null ) { $targetId = (string) $optional['targetId']; } if ( array_key_exists( 'changedFieldsJson', $optional ) && $optional['changedFieldsJson'] !== null ) { $changedFieldsJson = (string) $optional['changedFieldsJson']; } if ( array_key_exists( 'contextJson', $optional ) && $optional['contextJson'] !== null ) { $contextJson = (string) $optional['contextJson']; } return new self( null, $eventType, $auditKind, $actorType, $actorId, $targetType, $targetId, $action, $result, $safeSummary, $changedFieldsJson, $contextJson, $now ); } /** * Reconstitute a record from a row-shaped associative array. * * Intended as the single decode seam used by * {@see WpdbAuditEventRepository} when mapping * `$wpdb->get_row()` results back into records. Unknown keys * are ignored so callers can pass raw result rows without * filtering them; missing non-nullable keys raise * {@see InvalidArgumentException}. * * `created_at` accepts either a `DateTimeImmutable` instance * (produced by in-memory repositories) or a `Y-m-d H:i:s` * string (produced by `$wpdb`). Invalid strings raise * {@see InvalidArgumentException}. * * @param array $row Row-shaped associative array. * * @throws InvalidArgumentException When a required key is missing * or `created_at` cannot be parsed. */ public static function fromArray( array $row ): self { $requireString = static function ( string $key ) use ( $row ): string { if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) { $message = sprintf( 'AuditEventRecord::fromArray() missing required key "%s".', $key ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return (string) $row[ $key ]; }; $optionalString = static function ( string $key ) use ( $row ): ?string { if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) { return null; } $value = (string) $row[ $key ]; return $value === '' ? null : $value; }; $idRaw = $row['id'] ?? null; $id = $idRaw === null ? null : (int) $idRaw; if ( ! array_key_exists( 'created_at', $row ) || $row['created_at'] === null ) { $message = 'AuditEventRecord::fromArray() missing required timestamp "created_at".'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $createdAt = self::parseDateTime( 'created_at', $row['created_at'] ); return new self( $id, $requireString( 'event_type' ), $requireString( 'audit_kind' ), $optionalString( 'actor_type' ), $optionalString( 'actor_id' ), $optionalString( 'target_type' ), $optionalString( 'target_id' ), $requireString( 'action' ), $requireString( 'result' ), $requireString( 'safe_summary' ), $optionalString( 'changed_fields_json' ), $optionalString( 'context_json' ), $createdAt ); } /** * Encode the record as a row-shaped associative array keyed * by the canonical snake_case column names from §10.2. * * Used by {@see WpdbAuditEventRepository::save()} to produce * the payload handed to `$wpdb->insert()`. The `created_at` * timestamp is formatted via {@see self::formatDateTime()} * so the array is round-trippable through * {@see self::fromArray()}. * * @return array */ public function toArray(): array { return array( 'id' => $this->id, 'event_type' => $this->eventType, 'audit_kind' => $this->auditKind, 'actor_type' => $this->actorType, 'actor_id' => $this->actorId, 'target_type' => $this->targetType, 'target_id' => $this->targetId, 'action' => $this->action, 'result' => $this->result, 'safe_summary' => $this->safeSummary, 'changed_fields_json' => $this->changedFieldsJson, 'context_json' => $this->contextJson, 'created_at' => self::formatDateTime( $this->createdAt ), ); } /** * Produce a copy of this record with the given primary-key id * assigned. Used by the repository's `save()` path * immediately after an insert to surface the database-assigned * id to callers. * * @param int $id Newly-assigned primary key. Must be positive. * * @throws InvalidArgumentException When `$id` is non-positive. */ public function withId( int $id ): self { if ( $id <= 0 ) { $message = sprintf( 'AuditEventRecord::withId() requires a positive integer, got %d.', $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $clone = clone $this; $clone->id = $id; return $clone; } /** * Auto-increment primary key, or null for unpersisted records. */ public function id(): ?int { return $this->id; } /** * Report whether this record has been assigned a primary key. */ public function isPersisted(): bool { return $this->id !== null; } /** * §10.2 event type discriminator (e.g. `settings.updated`). */ public function eventType(): string { return $this->eventType; } /** * §10.2 audit-kind classification (e.g. `configuration_change`). */ public function auditKind(): string { return $this->auditKind; } /** * Optional actor classification (`user` / `system` / `job`), * or null when not known. */ public function actorType(): ?string { return $this->actorType; } /** * Optional actor identifier, or null when not known. */ public function actorId(): ?string { return $this->actorId; } /** * Optional target classification, or null when the event has * no specific target. */ public function targetType(): ?string { return $this->targetType; } /** * Optional target identifier, or null when the event has no * specific target. */ public function targetId(): ?string { return $this->targetId; } /** * §10.2 action verb (e.g. `update`, `clear`, `delete`). */ public function action(): string { return $this->action; } /** * §10.2 action result (`success` / `failed` / `rejected`). */ public function result(): string { return $this->result; } /** * §10.2 human-readable safe summary. */ public function safeSummary(): string { return $this->safeSummary; } /** * Optional JSON-encoded changed-fields summary, or null. */ public function changedFieldsJson(): ?string { return $this->changedFieldsJson; } /** * Optional JSON-encoded auxiliary context (`request_id` / * `correlation_id` / `job_id` etc.), or null. */ public function contextJson(): ?string { return $this->contextJson; } /** * UTC `created_at` timestamp for this record. */ public function createdAt(): DateTimeImmutable { return $this->createdAt; } /** * Reject empty / whitespace-only strings for the required * non-nullable columns so a programming error in the writer * surfaces at construction time. * * @param string $field Field name used in the error message. * @param string $value Candidate value. * * @throws InvalidArgumentException When `$value` is the empty * string after trimming. */ private function assertNonEmpty( string $field, string $value ): void { if ( trim( $value ) === '' ) { $message = sprintf( 'AuditEventRecord requires a non-empty value for "%s".', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } } /** * Normalize an optional string column: trim it and treat the * empty result as null so the persisted row stays canonical. * * @param string|null $value Candidate value. */ private static function nullIfEmpty( ?string $value ): ?string { if ( $value === null ) { return null; } $trimmed = trim( $value ); return $trimmed === '' ? null : $value; } /** * Normalize a `\DateTimeImmutable` to UTC, returning null when * the conversion would fail. Mirrors the pattern used by * {@see LogRecord} and {@see SyncJobRecord} so every AFFILUN row * record maintains the same UTC invariant on its timestamp * slots. * * @param DateTimeImmutable|null $value Value to normalize. */ private static function normalizeToUtc( ?DateTimeImmutable $value ): ?DateTimeImmutable { if ( $value === null ) { return null; } try { return $value->setTimezone( new DateTimeZone( 'UTC' ) ); } catch ( \Exception $unused ) { unset( $unused ); return null; } } /** * Parse a `created_at` value into a UTC `\DateTimeImmutable`, * accepting either an existing `DateTimeImmutable` or a * `Y-m-d H:i:s` string. * * @param string $key Column name used in the error message. * @param mixed $value Raw value from the row array. * * @throws InvalidArgumentException When `$value` is neither a * `DateTimeImmutable` nor a * parseable string. */ private static function parseDateTime( string $key, $value ): DateTimeImmutable { if ( $value instanceof DateTimeImmutable ) { return self::normalizeToUtc( $value ) ?? $value; } if ( is_string( $value ) && $value !== '' ) { $parsed = DateTimeImmutable::createFromFormat( self::MYSQL_DATETIME_FORMAT, $value, new DateTimeZone( 'UTC' ) ); if ( $parsed instanceof DateTimeImmutable ) { return $parsed; } } $message = sprintf( 'AuditEventRecord::fromArray() failed to parse timestamp "%s" from value of type %s.', $key, is_object( $value ) ? get_class( $value ) : gettype( $value ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } /** * Format a `\DateTimeImmutable` as a MySQL `DATETIME` string * in UTC. Mirrors {@see LogRecord::formatDateTime()} so the * persistence layer's storage boundary stays consistent * across every AFFILUN row record. * * @param DateTimeImmutable $value UTC instant to format. */ private static function formatDateTime( DateTimeImmutable $value ): string { return self::normalizeToUtc( $value ) ?->format( self::MYSQL_DATETIME_FORMAT ) ?? $value->format( self::MYSQL_DATETIME_FORMAT ); } }