jobs = $jobs; } /** * Execute the §11.4 get-sync-job lookup. * * Validates that `$id` is positive (non-positive ids are a * programming error at the call site, NOT a not-found outcome), * looks the row up via {@see SyncJobRepository::findById()}, * raises {@see SyncJobNotFoundException} on miss, projects the * record onto the §6.5 `SyncJob` wire shape, and extracts the * two §11.4 correlation axes from the record's `payload_json` * blob. * * @param int $id Sync-job primary key (>= 1). * * @throws InvalidArgumentException When `$id` is non-positive. * @throws SyncJobNotFoundException When no row matches `$id`. */ public function execute( int $id ): GetSyncJobResult { if ( $id <= 0 ) { $message = sprintf( 'GetSyncJobUseCase: id must be a positive integer, got %d.', $id ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $record = $this->jobs->findById( $id ); if ( $record === null ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw SyncJobNotFoundException::forId( $id ); } $payload = $this->decodePayload( $record->payloadJson() ); $correlationId = $this->extractStringField( $payload, self::PAYLOAD_KEY_CORRELATION_ID ); $parentRequestId = $this->extractStringField( $payload, self::PAYLOAD_KEY_PARENT_REQUEST_ID ); return new GetSyncJobResult( $this->projectJob( $record, $payload ), $correlationId, $parentRequestId ); } /** * Project a single {@see SyncJobRecord} onto the §6.5 `SyncJob` * wire shape. * * Mirrors {@see ListSyncJobsUseCase::projectJob()} field-for-field * so the §11.3 list surface and the §11.4 detail surface remain * shape-compatible. Accepts the pre-decoded payload as a second * argument to avoid decoding the JSON blob twice during a * detail-endpoint call. * * @param SyncJobRecord $record Record to project. * @param array $payload Pre-decoded payload map. * * @return array */ private function projectJob( SyncJobRecord $record, array $payload ): array { $status = $record->status(); if ( $status === self::DB_STATUS_PENDING ) { $status = self::WIRE_STATUS_QUEUED; } return array( 'id' => $record->id(), 'job_type' => $record->jobType(), 'status' => $status, 'attempts' => $record->attempts(), 'available_at' => $this->formatTimestamp( $record->availableAt() ), 'locked_at' => $this->formatTimestamp( $record->lockedAt() ), 'finished_at' => $this->formatTimestamp( $record->finishedAt() ), 'last_error' => $record->lastError(), 'payload' => $payload, ); } /** * Format a {@see DateTimeInterface} as an ISO-8601 UTC string, * or return null when the input is null. * * @param DateTimeInterface|null $value Timestamp to format. */ private function formatTimestamp( ?DateTimeInterface $value ): ?string { if ( $value === null ) { return null; } return $value->format( DateTimeInterface::ATOM ); } /** * Decode the record's `payload_json` blob into an associative * array. Returns an empty array when decoding fails or yields a * non-array shape — a malformed blob should not crash the * detail endpoint per §11.4 resilience expectations. * * @param string $payloadJson Raw JSON blob. * * @return array */ private function decodePayload( string $payloadJson ): array { $decoded = json_decode( $payloadJson, true ); if ( ! is_array( $decoded ) ) { return array(); } /** @var array $decoded */ return $decoded; } /** * Extract a string-typed field from the decoded payload, or null * when the key is absent / non-string / empty. * * @param array $payload Decoded payload map. * @param string $key Payload key to read. */ private function extractStringField( array $payload, string $key ): ?string { if ( ! array_key_exists( $key, $payload ) ) { return null; } $value = $payload[ $key ]; if ( ! is_string( $value ) || $value === '' ) { return null; } return $value; } }