assertNonEmpty( 'eventType', $eventType ); $this->assertNonEmpty( 'auditKind', $auditKind ); $this->assertNonEmpty( 'action', $action ); $this->assertNonEmpty( 'result', $result ); $this->assertNonEmpty( 'safeSummary', $safeSummary ); $this->eventType = $eventType; $this->auditKind = $auditKind; $this->action = $action; $this->result = $result; $this->safeSummary = $safeSummary; $this->actorType = self::nullIfEmpty( $actorType ); $this->actorId = self::nullIfEmpty( $actorId ); $this->targetType = self::nullIfEmpty( $targetType ); $this->targetId = self::nullIfEmpty( $targetId ); $this->changedFieldsJson = self::assertJsonObjectOrNull( 'changedFieldsJson', self::nullIfEmpty( $changedFieldsJson ) ); $this->contextJson = self::assertJsonObjectOrNull( 'contextJson', self::nullIfEmpty( $contextJson ) ); } /** * Convenience factory for "(user `$userId`) did `$action` on * `$targetType:$targetId` with result `$result`" shape. * * Stamps `actorType = 'user'` and `actorId = $userId`. The * `$context` and `$changedFields` arrays are JSON-encoded * into the corresponding slots when non-empty; an empty * array yields a null slot. * * @param string $eventType §10.2 event type. * @param string $auditKind §10.2 audit-kind. * @param string $action §10.2 action verb. * @param string $result §10.2 result token. * @param string $safeSummary §10.2 safe summary. * @param int|string $userId WP user id (will be * coerced to string). * @param string|null $targetType Optional target type. * @param string|null $targetId Optional target id. * @param array $changedFields Optional changed-fields * summary; encoded into * `changedFieldsJson`. * @param array $context Optional auxiliary * context; encoded into * `contextJson`. * * @throws InvalidArgumentException When validation fails per * the constructor contract. */ public static function forUserAction( string $eventType, string $auditKind, string $action, string $result, string $safeSummary, int|string $userId, ?string $targetType = null, ?string $targetId = null, array $changedFields = array(), array $context = array() ): self { return new self( $eventType, $auditKind, $action, $result, $safeSummary, self::ACTOR_TYPE_USER, (string) $userId, $targetType, $targetId, self::encodeJsonObjectOrNull( 'changedFields', $changedFields ), self::encodeJsonObjectOrNull( 'context', $context ) ); } /** * Convenience factory for unattended "(system) did `$action` * on `$targetType:$targetId`" shape. * * Stamps `actorType = 'system'` and leaves `actorId` null. * Used by plugin lifecycle events (activation, schema * migration) and the retention purge worker that has no * per-row human triggerer. * * @param string $eventType §10.2 event type. * @param string $auditKind §10.2 audit-kind. * @param string $action §10.2 action verb. * @param string $result §10.2 result token. * @param string $safeSummary §10.2 safe summary. * @param string|null $targetType Optional target type. * @param string|null $targetId Optional target id. * @param array $changedFields Optional changed-fields * summary. * @param array $context Optional auxiliary * context. * * @throws InvalidArgumentException When validation fails per * the constructor contract. */ public static function forSystemAction( string $eventType, string $auditKind, string $action, string $result, string $safeSummary, ?string $targetType = null, ?string $targetId = null, array $changedFields = array(), array $context = array() ): self { return new self( $eventType, $auditKind, $action, $result, $safeSummary, self::ACTOR_TYPE_SYSTEM, null, $targetType, $targetId, self::encodeJsonObjectOrNull( 'changedFields', $changedFields ), self::encodeJsonObjectOrNull( 'context', $context ) ); } /** * Convenience factory for "(job `$jobId`) did `$action` on * `$targetType:$targetId`" shape. * * Stamps `actorType = 'job'`, `actorId = $jobId`, AND mirrors * the job id into `contextJson.job_id` so the §12.4 envelope * projection's `job_id` lift surfaces the tracker on the * read side. Additional `$context` keys are merged on top of * the auto-populated `job_id` (caller-supplied `job_id` wins * if explicitly provided). * * @param string $eventType §10.2 event type. * @param string $auditKind §10.2 audit-kind. * @param string $action §10.2 action verb. * @param string $result §10.2 result token. * @param string $safeSummary §10.2 safe summary. * @param int|string $jobId Sync job id (will be * coerced to string). * @param string|null $targetType Optional target type. * @param string|null $targetId Optional target id. * @param array $changedFields Optional changed-fields * summary. * @param array $context Optional auxiliary * context (merged on top * of auto-populated * `job_id`). * * @throws InvalidArgumentException When validation fails per * the constructor contract. */ public static function forJobAction( string $eventType, string $auditKind, string $action, string $result, string $safeSummary, int|string $jobId, ?string $targetType = null, ?string $targetId = null, array $changedFields = array(), array $context = array() ): self { $jobIdString = (string) $jobId; // Auto-populate job_id in the context payload so the // §12.4 envelope projection's `job_id` lift surfaces // the tracker on the read side. Caller-supplied // `job_id` wins when explicitly provided. $mergedContext = array_merge( array( 'job_id' => $jobIdString ), $context ); return new self( $eventType, $auditKind, $action, $result, $safeSummary, self::ACTOR_TYPE_JOB, $jobIdString, $targetType, $targetId, self::encodeJsonObjectOrNull( 'changedFields', $changedFields ), self::encodeJsonObjectOrNull( 'context', $mergedContext ) ); } /** * §10.2 event type discriminator. */ public function eventType(): string { return $this->eventType; } /** * §10.2 audit-kind classification. */ public function auditKind(): string { return $this->auditKind; } /** * §10.2 action verb. */ public function action(): string { return $this->action; } /** * §10.2 action result token. */ public function result(): string { return $this->result; } /** * §10.2 human-readable safe summary. */ public function safeSummary(): string { return $this->safeSummary; } /** * Optional actor classification, or null when not supplied. */ public function actorType(): ?string { return $this->actorType; } /** * Optional actor identifier, or null when not supplied. */ public function actorId(): ?string { return $this->actorId; } /** * Optional target classification, or null when not supplied. */ public function targetType(): ?string { return $this->targetType; } /** * Optional target identifier, or null when not supplied. */ public function targetId(): ?string { return $this->targetId; } /** * Optional JSON-encoded changed-fields summary, or null. */ public function changedFieldsJson(): ?string { return $this->changedFieldsJson; } /** * Optional JSON-encoded auxiliary context, or null. */ public function contextJson(): ?string { return $this->contextJson; } /** * Reject empty / whitespace-only strings for the required * non-nullable columns so a programming error in the call * site surfaces at draft construction. * * @param string $field Field name used in the error message. * @param string $value Candidate value. * * @throws InvalidArgumentException When `$value` is empty * after trimming. */ private function assertNonEmpty( string $field, string $value ): void { if ( trim( $value ) === '' ) { $message = sprintf( 'AuditEventDraft requires a non-empty value for "%s".', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } } /** * Normalize an optional string column: treat the empty * trimmed 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; } /** * Validate that the supplied JSON string decodes to a JSON * object (associative array), or pass through null verbatim. * * Malformed JSON, non-array decoded values, and list-shaped * payloads all raise {@see InvalidArgumentException} so the * §12.4 envelope projection's "JSON object" assumption * holds at the read boundary. * * @param string $field Field name used in the error message. * @param string|null $json Candidate JSON string. * * @throws InvalidArgumentException When `$json` is malformed * or decodes to a non-object * payload. */ private static function assertJsonObjectOrNull( string $field, ?string $json ): ?string { if ( $json === null ) { return null; } $decoded = json_decode( $json, true ); if ( $decoded === null && json_last_error() !== JSON_ERROR_NONE ) { $message = sprintf( 'AuditEventDraft: %s must be well-formed JSON; %s.', $field, json_last_error_msg() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } if ( ! is_array( $decoded ) ) { $message = sprintf( 'AuditEventDraft: %s must decode to a JSON object; got %s.', $field, gettype( $decoded ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } // Reject sequential-list payloads so the §12.4 envelope // projection's `request_id` / `correlation_id` / `job_id` // lift out of `contextJson` is well-defined. if ( $decoded !== array() && array_is_list( $decoded ) ) { $message = sprintf( 'AuditEventDraft: %s must decode to a JSON object (associative array); got a list-shaped payload.', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } return $json; } /** * Encode an optional associative array into a JSON string * for the {@see self::$changedFieldsJson} / * {@see self::$contextJson} slots, returning null when the * input array is empty. * * Rejects list-shaped (sequential) arrays so the §12.4 * envelope projection's "JSON object" assumption holds at * the read boundary. * * @param string $field Field name used in the error message. * @param array $payload Associative payload. * * @throws InvalidArgumentException When `$payload` is a * list-shaped array, or when * JSON encoding fails. */ private static function encodeJsonObjectOrNull( string $field, array $payload ): ?string { if ( $payload === array() ) { return null; } if ( array_is_list( $payload ) ) { $message = sprintf( 'AuditEventDraft: %s must be an associative array; got a list-shaped payload.', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } $encoded = function_exists( 'wp_json_encode' ) ? wp_json_encode( $payload ) // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Pure-unit fallback when WordPress runtime is not loaded. : json_encode( $payload ); if ( ! is_string( $encoded ) ) { $message = sprintf( 'AuditEventDraft: failed to JSON-encode %s payload.', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output. throw new InvalidArgumentException( $message ); } return $encoded; } }