refactor(ai): move backfill kickoff into a StartCategorizationBackfill action
The consent controller owned gating, the count query, UUID generation, cache seeding and dispatch. Extract that into an App\Actions\Ai action so the controller just records consent and delegates, and the job-id/cache-key contract has a single owner. Add a test covering the no-paid-plan gate path.
This commit is contained in:
parent
3126f1763f
commit
03fd41fda9
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Ai;
|
||||
|
||||
use App\Jobs\CategorizeUncategorizedTransactionsJob;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Services\Ai\AiCategorizationGate;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class StartCategorizationBackfill
|
||||
{
|
||||
public function __construct(private readonly AiCategorizationGate $gate) {}
|
||||
|
||||
/**
|
||||
* Dispatch a categorization backfill when the user is eligible and has
|
||||
* something to categorize, seeding the progress cache the client polls.
|
||||
*
|
||||
* @return array{job_id: string, total: int}|null
|
||||
*/
|
||||
public function handle(User $user): ?array
|
||||
{
|
||||
if (! $this->gate->allows($user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$total = Transaction::query()
|
||||
->where('user_id', $user->id)
|
||||
->pendingAiCategorization()
|
||||
->count();
|
||||
|
||||
if ($total === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$jobId = (string) Str::uuid();
|
||||
|
||||
Cache::put(
|
||||
CategorizeUncategorizedTransactionsJob::cacheKeyForJobId($jobId),
|
||||
['status' => 'pending', 'processed' => 0, 'total' => $total, 'applied' => 0],
|
||||
now()->addHour(),
|
||||
);
|
||||
|
||||
CategorizeUncategorizedTransactionsJob::dispatch($user, $jobId);
|
||||
|
||||
return ['job_id' => $jobId, 'total' => $total];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,15 +2,10 @@
|
|||
|
||||
namespace App\Http\Controllers\Ai;
|
||||
|
||||
use App\Actions\Ai\StartCategorizationBackfill;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\CategorizeUncategorizedTransactionsJob;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Services\Ai\AiCategorizationGate;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AiConsentController extends Controller
|
||||
{
|
||||
|
|
@ -18,14 +13,14 @@ class AiConsentController extends Controller
|
|||
* Record the user's broad "use AI to help understand my finances" consent
|
||||
* and kick off a backfill of their uncategorized transactions.
|
||||
*/
|
||||
public function store(Request $request, AiCategorizationGate $gate): JsonResponse
|
||||
public function store(Request $request, StartCategorizationBackfill $startBackfill): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$user->recordAiConsent();
|
||||
|
||||
return response()->json([
|
||||
'consented' => true,
|
||||
'categorization' => $this->startCategorization($user, $gate),
|
||||
'categorization' => $startBackfill->handle($user),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -38,39 +33,4 @@ class AiConsentController extends Controller
|
|||
|
||||
return response()->json(['consented' => false]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the backfill job when the user is eligible and has anything to
|
||||
* categorize, returning the job id the client polls for progress.
|
||||
*
|
||||
* @return array{job_id: string, total: int}|null
|
||||
*/
|
||||
private function startCategorization(User $user, AiCategorizationGate $gate): ?array
|
||||
{
|
||||
if (! $gate->allows($user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$total = Transaction::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereNull('category_id')
|
||||
->whereNull('description_iv')
|
||||
->count();
|
||||
|
||||
if ($total === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$jobId = (string) Str::uuid();
|
||||
|
||||
Cache::put(
|
||||
CategorizeUncategorizedTransactionsJob::cacheKeyForJobId($jobId),
|
||||
['status' => 'pending', 'processed' => 0, 'total' => $total, 'applied' => 0],
|
||||
now()->addHour(),
|
||||
);
|
||||
|
||||
CategorizeUncategorizedTransactionsJob::dispatch($user, $jobId);
|
||||
|
||||
return ['job_id' => $jobId, 'total' => $total];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,24 @@ it('does not dispatch a backfill when nothing is uncategorized', function () {
|
|||
Bus::assertNotDispatched(CategorizeUncategorizedTransactionsJob::class);
|
||||
});
|
||||
|
||||
it('does not dispatch a backfill for a user without a paid plan', function () {
|
||||
config(['subscriptions.enabled' => true]);
|
||||
Bus::fake();
|
||||
$user = User::factory()->create();
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => null,
|
||||
]);
|
||||
|
||||
actingAs($user)->postJson(route('ai.consent.store'))
|
||||
->assertOk()
|
||||
->assertJson(['consented' => true])
|
||||
->assertJsonPath('categorization', null);
|
||||
|
||||
expect($user->hasActiveAiConsent())->toBeTrue();
|
||||
Bus::assertNotDispatched(CategorizeUncategorizedTransactionsJob::class);
|
||||
});
|
||||
|
||||
it('does not dispatch a backfill when AI categorization is disabled', function () {
|
||||
config(['ai_categorization.enabled' => false]);
|
||||
Bus::fake();
|
||||
|
|
|
|||
Loading…
Reference in New Issue