diff --git a/app/Console/Commands/ReviewIntegrationRequestsCommand.php b/app/Console/Commands/ReviewIntegrationRequestsCommand.php index 3be25253..02afe557 100644 --- a/app/Console/Commands/ReviewIntegrationRequestsCommand.php +++ b/app/Console/Commands/ReviewIntegrationRequestsCommand.php @@ -46,11 +46,12 @@ class ReviewIntegrationRequestsCommand extends Command $approved = 0; $rejected = 0; + $notDoable = 0; foreach ($pending as $request) { $decision = $this->choice( "Review \"{$request->name}\" ({$request->url})", - ['approve', 'reject', 'skip'], + ['approve', 'reject', 'not doable', 'skip'], 'skip', ); @@ -60,11 +61,23 @@ class ReviewIntegrationRequestsCommand extends Command } elseif ($decision === 'reject') { $request->update(['status' => IntegrationRequestStatus::Rejected]); $rejected++; + } elseif ($decision === 'not doable') { + $comment = $this->ask('Why is this integration not doable? (shown to users)'); + + while (blank($comment)) { + $comment = $this->ask('A comment is required to mark an integration as not doable'); + } + + $request->update([ + 'status' => IntegrationRequestStatus::NotDoable, + 'comment' => $comment, + ]); + $notDoable++; } } - $skipped = $pending->count() - $approved - $rejected; - $this->info("Done. Approved: {$approved}, rejected: {$rejected}, skipped: {$skipped}."); + $skipped = $pending->count() - $approved - $rejected - $notDoable; + $this->info("Done. Approved: {$approved}, rejected: {$rejected}, not doable: {$notDoable}, skipped: {$skipped}."); return self::SUCCESS; } diff --git a/app/Enums/IntegrationRequestStatus.php b/app/Enums/IntegrationRequestStatus.php index 6e499eb1..2e535d8d 100644 --- a/app/Enums/IntegrationRequestStatus.php +++ b/app/Enums/IntegrationRequestStatus.php @@ -7,6 +7,7 @@ enum IntegrationRequestStatus: string case Pending = 'pending'; case Approved = 'approved'; case Rejected = 'rejected'; + case NotDoable = 'not_doable'; public function label(): string { @@ -14,6 +15,7 @@ enum IntegrationRequestStatus: string self::Pending => 'Pending', self::Approved => 'Approved', self::Rejected => 'Rejected', + self::NotDoable => 'Not doable', }; } } diff --git a/app/Http/Controllers/IntegrationRequestController.php b/app/Http/Controllers/IntegrationRequestController.php index 5acffc91..4d19c923 100644 --- a/app/Http/Controllers/IntegrationRequestController.php +++ b/app/Http/Controllers/IntegrationRequestController.php @@ -54,8 +54,11 @@ class IntegrationRequestController extends Controller { $user = $request->user(); - if ($integrationRequest->status !== IntegrationRequestStatus::Approved - && $integrationRequest->user_id !== $user->id) { + $canVote = $integrationRequest->status === IntegrationRequestStatus::Approved + || ($integrationRequest->status === IntegrationRequestStatus::Pending + && $integrationRequest->user_id === $user->id); + + if (! $canVote) { abort(404); } @@ -85,7 +88,7 @@ class IntegrationRequestController extends Controller { return IntegrationRequest::query() ->where(function ($query) use ($user) { - $query->where('status', IntegrationRequestStatus::Approved) + $query->whereIn('status', [IntegrationRequestStatus::Approved, IntegrationRequestStatus::NotDoable]) ->orWhere(function ($inner) use ($user) { $inner->where('status', IntegrationRequestStatus::Pending) ->where('user_id', $user->id); @@ -93,6 +96,8 @@ class IntegrationRequestController extends Controller }) ->withCount('votes') ->withExists(['votes as has_voted' => fn ($query) => $query->where('user_id', $user->id)]) + // Not-doable requests sink to the bottom regardless of their votes. + ->orderByRaw('CASE WHEN status = ? THEN 1 ELSE 0 END', [IntegrationRequestStatus::NotDoable->value]) ->orderByDesc('votes_count') ->orderByDesc('created_at') ->get(); diff --git a/app/Models/IntegrationRequest.php b/app/Models/IntegrationRequest.php index a88fb64a..c22e403a 100644 --- a/app/Models/IntegrationRequest.php +++ b/app/Models/IntegrationRequest.php @@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * @property string $name * @property string $url * @property IntegrationRequestStatus $status + * @property ?string $comment * @property string $user_id */ class IntegrationRequest extends Model @@ -26,6 +27,7 @@ class IntegrationRequest extends Model 'name', 'url', 'status', + 'comment', 'user_id', ]; diff --git a/database/factories/IntegrationRequestFactory.php b/database/factories/IntegrationRequestFactory.php index 8399bcb5..4d7e080f 100644 --- a/database/factories/IntegrationRequestFactory.php +++ b/database/factories/IntegrationRequestFactory.php @@ -34,4 +34,12 @@ class IntegrationRequestFactory extends Factory { return $this->state(['status' => IntegrationRequestStatus::Rejected]); } + + public function notDoable(): static + { + return $this->state([ + 'status' => IntegrationRequestStatus::NotDoable, + 'comment' => fake()->sentence(), + ]); + } } diff --git a/database/migrations/2026_06_17_141827_add_comment_to_integration_requests_table.php b/database/migrations/2026_06_17_141827_add_comment_to_integration_requests_table.php new file mode 100644 index 00000000..f5253c10 --- /dev/null +++ b/database/migrations/2026_06_17_141827_add_comment_to_integration_requests_table.php @@ -0,0 +1,28 @@ +text('comment')->nullable()->after('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('integration_requests', function (Blueprint $table) { + $table->dropColumn('comment'); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index 4f4cf7d7..decf2cf1 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2,6 +2,7 @@ "Link": "Enlace", "Submit": "Enviar", "Pending review": "Pendiente de revisión", + "Not doable": "No viable", "Something went wrong.": "Algo salió mal.", "Integration requests": "Solicitudes de integración", "Request integration": "Solicitar integración", diff --git a/resources/js/components/integration-requests/integration-requests-board.tsx b/resources/js/components/integration-requests/integration-requests-board.tsx index 796d36be..3b2bb58d 100644 --- a/resources/js/components/integration-requests/integration-requests-board.tsx +++ b/resources/js/components/integration-requests/integration-requests-board.tsx @@ -18,7 +18,8 @@ export interface IntegrationRequestItem { id: string; name: string; url: string; - status: 'pending' | 'approved' | 'rejected'; + status: 'pending' | 'approved' | 'rejected' | 'not_doable'; + comment: string | null; votes_count: number; has_voted: boolean; created_at: string; @@ -110,7 +111,11 @@ export function IntegrationRequestsBoard({ }; const handleVote = async (item: IntegrationRequestItem) => { - if (busy || (!item.has_voted && actionsRemaining <= 0)) { + if ( + busy || + item.status === 'not_doable' || + (!item.has_voted && actionsRemaining <= 0) + ) { return; } @@ -219,37 +224,50 @@ export function IntegrationRequestsBoard({ ) : ( diff --git a/tests/Feature/IntegrationRequestTest.php b/tests/Feature/IntegrationRequestTest.php index 246625ce..7db58974 100644 --- a/tests/Feature/IntegrationRequestTest.php +++ b/tests/Feature/IntegrationRequestTest.php @@ -185,7 +185,7 @@ test('the review command approves a pending request', function () { ->expectsChoice( "Review \"{$request->name}\" ({$request->url})", 'approve', - ['approve', 'reject', 'skip'], + ['approve', 'reject', 'not doable', 'skip'], ) ->assertSuccessful(); @@ -199,7 +199,7 @@ test('the review command rejects a pending request', function () { ->expectsChoice( "Review \"{$request->name}\" ({$request->url})", 'reject', - ['approve', 'reject', 'skip'], + ['approve', 'reject', 'not doable', 'skip'], ) ->assertSuccessful(); @@ -211,3 +211,69 @@ test('the review command reports when there is nothing to review', function () { ->expectsOutput('No pending integration requests.') ->assertSuccessful(); }); + +test('not doable requests are visible to everyone with their comment', function () { + IntegrationRequest::factory()->notDoable()->create([ + 'name' => 'Impossible Bank', + 'comment' => 'They have no public API.', + ]); + + $this->actingAs(User::factory()->create()) + ->getJson('/integration-requests/data') + ->assertOk() + ->assertJsonCount(1, 'requests') + ->assertJsonPath('requests.0.status', 'not_doable') + ->assertJsonPath('requests.0.comment', 'They have no public API.'); +}); + +test('not doable requests are listed after the rest', function () { + IntegrationRequest::factory()->notDoable()->create(['name' => 'Not doable']); + $approved = IntegrationRequest::factory()->approved()->create(['name' => 'Approved']); + + $this->actingAs(User::factory()->create()) + ->getJson('/integration-requests/data') + ->assertOk() + ->assertJsonPath('requests.0.id', $approved->id) + ->assertJsonPath('requests.1.name', 'Not doable'); +}); + +test('rejected requests never appear in the list', function () { + IntegrationRequest::factory()->rejected()->create(); + + $this->actingAs(User::factory()->create()) + ->getJson('/integration-requests/data') + ->assertOk() + ->assertJsonCount(0, 'requests'); +}); + +test('nobody can vote on a not doable request', function () { + $request = IntegrationRequest::factory()->notDoable()->create(); + + $this->actingAs(User::factory()->create()) + ->postJson("/integration-requests/{$request->id}/vote") + ->assertNotFound(); + + $owner = User::factory()->create(); + $ownRequest = IntegrationRequest::factory()->notDoable()->create(['user_id' => $owner->id]); + + $this->actingAs($owner) + ->postJson("/integration-requests/{$ownRequest->id}/vote") + ->assertNotFound(); +}); + +test('the review command marks a request as not doable with a comment', function () { + $request = IntegrationRequest::factory()->create(['name' => 'Hard Bank']); + + $this->artisan('integration-requests:review') + ->expectsChoice( + "Review \"{$request->name}\" ({$request->url})", + 'not doable', + ['approve', 'reject', 'not doable', 'skip'], + ) + ->expectsQuestion('Why is this integration not doable? (shown to users)', 'No public API available.') + ->assertSuccessful(); + + $request->refresh(); + expect($request->status)->toBe(IntegrationRequestStatus::NotDoable); + expect($request->comment)->toBe('No public API available.'); +});