jobs = $jobs; $this->clock = $clock; $this->defaultPolicy = $defaultPolicy; } /** * Enqueue a `sync_product` job for the supplied product id. * * Validates `$productId > 0`, builds the §10.2 payload (with a * caller-supplied or auto-generated correlation id and the * current UTC timestamp as `enqueued_at`), and persists a fresh * `pending` row through the repository. The returned record * carries the assigned auto-increment id so callers can stamp * audit logs / REST responses without a follow-up lookup. * * @param int $productId Local `affilun_products.id` to * refresh. * @param SyncPriority $priority §13.2 priority for this * enqueue. * @param string|null $correlationId Caller-supplied UUID v4, * or null to auto-generate. * * @throws InvalidArgumentException When `$productId` is not a * positive integer. */ public function enqueueProductSync( int $productId, SyncPriority $priority, ?string $correlationId = null ): SyncJobRecord { if ( $productId <= 0 ) { $message = sprintf( 'SyncDispatcher::enqueueProductSync requires a positive productId; got %d.', $productId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $existing = $this->jobs->findOpenProductSync( $productId ); if ( $existing !== null ) { return $existing; } $now = $this->clock->now()->setTimezone( new DateTimeZone( 'UTC' ) ); $payload = array( 'product_id' => $productId, 'correlation_id' => $correlationId ?? self::generateCorrelationId(), 'enqueued_at' => $now->format( DateTimeImmutable::ATOM ), ); $payloadJson = self::encodePayload( $payload ); $record = SyncJobRecord::createNew( 'sync_product', $payloadJson, $now, array( 'priority' => $priority->weight(), 'maxAttempts' => $this->defaultPolicy->maxAttempts(), ) ); return $this->jobs->save( $record ); } /** * Decode the payload of the supplied {@see SyncJobRecord} into * an associative array. * * Static helper so REST controllers and worker layers can share * one canonical decode path. The returned array is the verbatim * `json_decode(..., true)` result; callers are responsible for * the per-field validation rules they need. * * @param SyncJobRecord $record Record whose payload to decode. * * @return array * * @throws RuntimeException When the payload is not valid JSON * or does not decode to an associative * array. */ public static function payloadFor( SyncJobRecord $record ): array { $decoded = json_decode( $record->payloadJson(), true ); if ( ! is_array( $decoded ) ) { $message = sprintf( 'SyncDispatcher::payloadFor: payload is not a valid JSON object (record id %s).', $record->id() === null ? 'null' : (string) $record->id() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new RuntimeException( $message ); } /** @var array $decoded */ return $decoded; } /** * Generate a fresh UUID v4 for the `correlation_id` payload * field. * * Prefers `wp_generate_uuid4()` when available so the * dispatcher follows the WordPress convention; falls back to a * pure-PHP `random_bytes(16)` formatter otherwise so unit tests * (which run without the WordPress runtime loaded) can still * exercise this path. */ private static function generateCorrelationId(): string { if ( function_exists( 'wp_generate_uuid4' ) ) { return wp_generate_uuid4(); } $bytes = random_bytes( 16 ); // Set the version (4) and variant (RFC 4122) bits per // RFC 4122 §4.4. $bytes[6] = chr( ( ord( $bytes[6] ) & 0x0F ) | 0x40 ); $bytes[8] = chr( ( ord( $bytes[8] ) & 0x3F ) | 0x80 ); $hex = bin2hex( $bytes ); return sprintf( '%s-%s-%s-%s-%s', substr( $hex, 0, 8 ), substr( $hex, 8, 4 ), substr( $hex, 12, 4 ), substr( $hex, 16, 4 ), substr( $hex, 20, 12 ) ); } /** * Encode the §10.2 payload array as a JSON string. * * @param array $payload Payload to encode. * * @throws RuntimeException When JSON encoding fails. */ private static function encodePayload( array $payload ): string { if ( function_exists( 'wp_json_encode' ) ) { $encoded = wp_json_encode( $payload ); } else { // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Pure-unit test environment without WordPress runtime; production code path uses wp_json_encode above. $encoded = json_encode( $payload ); } if ( ! is_string( $encoded ) ) { $message = 'SyncDispatcher: failed to JSON-encode sync_product payload.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new RuntimeException( $message ); } return $encoded; } }