user->id; } public function viaQueue(): string { return (string) config('ai_categorization.queue'); } public static function cacheKeyForJobId(string $userId, string $jobId): string { return "categorize_transactions_job_{$userId}_{$jobId}"; } public function handle(AiCategorizationGate $gate, AiCategorizer $categorizer): void { if (! $gate->allows($this->user)) { $this->updateProgress('done', 0, 0, 0); return; } $result = $categorizer->backfill( $this->user, fn (int $processed, int $total, int $applied) => $this->updateProgress('processing', $processed, $total, $applied), ); $this->updateProgress('done', $result['processed'], $result['total'], $result['applied']); } /** * Mark the run as failed so the polling client stops waiting instead of * spinning until the cache entry expires. */ public function failed(?Throwable $exception): void { $progress = Cache::get(self::cacheKeyForJobId($this->user->id, $this->jobId), [ 'processed' => 0, 'total' => 0, 'applied' => 0, ]); $this->updateProgress( 'failed', $progress['processed'] ?? 0, $progress['total'] ?? 0, $progress['applied'] ?? 0, ); } /** * @param 'processing'|'done'|'failed' $status */ private function updateProgress(string $status, int $processed, int $total, int $applied): void { Cache::put(self::cacheKeyForJobId($this->user->id, $this->jobId), [ 'status' => $status, 'processed' => $processed, 'total' => $total, 'applied' => $applied, ], now()->addHour()); } }