From 0ea54fa0d75dd268c7cb5e59f1da95a9a22976ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Thu, 18 Jun 2026 12:06:08 +0200 Subject: [PATCH] feat(integration-requests): multi-vote, per-plan quota and undo (#554) ## Summary - Raise the monthly integration-action quota for paying users: free plan keeps **3** actions, pro plan gets **9** (resolved via `User::hasProPlan()`, so self-hosted instances with subscriptions disabled get the full quota). - Votes are no longer toggles. Each vote spends one action and pushes the request up the board, so a user can back the same integration multiple times. The `(integration_request_id, user_id)` unique index is dropped (with a standalone FK index added first, since MySQL relied on the unique one). - Let users undo a vote, **but only one cast in the current month**. The refund maps back to the current quota and previous months' tallies stay locked. The board exposes `can_unvote` and shows a `ChevronDown` control next to the upvote button, backed by a new `DELETE /integration-requests/{id}/vote` route. ## Tests - Feature tests for the free (3) and pro (9) quotas, repeated voting up to the limit, undoing a current-month vote (and recovering the action), and the previous-month vote being non-removable. - Updated the board fixture and added the `Remove one vote` Spanish translation. --- .../IntegrationRequestController.php | 58 ++++++++---- ...e_from_integration_request_votes_table.php | 27 ++++++ lang/es.json | 1 + .../integration-requests-board.test.tsx | 1 + .../integration-requests-board.tsx | 83 +++++++++++----- routes/web.php | 1 + tests/Feature/IntegrationRequestTest.php | 94 +++++++++++++++---- 7 files changed, 210 insertions(+), 55 deletions(-) create mode 100644 database/migrations/2026_06_18_092830_drop_unique_user_vote_from_integration_request_votes_table.php diff --git a/app/Http/Controllers/IntegrationRequestController.php b/app/Http/Controllers/IntegrationRequestController.php index 04dbda31..14b88a12 100644 --- a/app/Http/Controllers/IntegrationRequestController.php +++ b/app/Http/Controllers/IntegrationRequestController.php @@ -13,7 +13,9 @@ use Inertia\Response; class IntegrationRequestController extends Controller { - private const MONTHLY_ACTION_LIMIT = 3; + private const FREE_MONTHLY_ACTION_LIMIT = 3; + + private const PRO_MONTHLY_ACTION_LIMIT = 9; public function index(Request $request, DashboardController $dashboard): Response { @@ -37,7 +39,7 @@ class IntegrationRequestController extends Controller // Creating a request also auto-votes it for the author, so it costs two actions. if ($this->actionsRemaining($user) < 2) { - return $this->limitReachedResponse(); + return $this->limitReachedResponse($user); } $integrationRequest = $user->integrationRequests()->create([ @@ -62,16 +64,10 @@ class IntegrationRequestController extends Controller abort(404); } - $vote = $integrationRequest->votes()->where('user_id', $user->id)->first(); - - if ($vote !== null) { - $vote->delete(); - - return $this->payload($user); - } - + // Votes are not toggles: a user may back the same integration as many + // times as they have actions left, each vote pushing it up the board. if ($this->actionsRemaining($user) <= 0) { - return $this->limitReachedResponse(); + return $this->limitReachedResponse($user); } $integrationRequest->votes()->create(['user_id' => $user->id]); @@ -79,6 +75,23 @@ class IntegrationRequestController extends Controller return $this->payload($user); } + public function removeVote(Request $request, IntegrationRequest $integrationRequest): JsonResponse + { + $user = $request->user(); + + // Only votes cast this month can be undone, so the refund maps back to + // the current quota while earlier months' tallies stay locked in. + $vote = $integrationRequest->votes() + ->where('user_id', $user->id) + ->where('created_at', '>=', now()->startOfMonth()) + ->latest() + ->first(); + + $vote?->delete(); + + return $this->payload($user); + } + /** * The board state shared by the page, the drawer and every mutation. * @@ -95,7 +108,11 @@ class IntegrationRequestController extends Controller }); }) ->withCount('votes') - ->withExists(['votes as has_voted' => fn ($query) => $query->where('user_id', $user->id)]) + ->withExists([ + 'votes as has_voted' => fn ($query) => $query->where('user_id', $user->id), + 'votes as can_unvote' => fn ($query) => $query->where('user_id', $user->id) + ->where('created_at', '>=', now()->startOfMonth()), + ]) // 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') @@ -103,12 +120,21 @@ class IntegrationRequestController extends Controller ->get(); } + private function monthlyActionLimit(User $user): int + { + return $user->hasProPlan() + ? self::PRO_MONTHLY_ACTION_LIMIT + : self::FREE_MONTHLY_ACTION_LIMIT; + } + private function actionsRemaining(User $user): int { + $limit = $this->monthlyActionLimit($user); + // The admin has no monthly cap; report a full quota so neither the // backend checks nor the frontend buttons ever gate them. if ($user->isAdmin()) { - return self::MONTHLY_ACTION_LIMIT; + return $limit; } $start = now()->startOfMonth(); @@ -116,7 +142,7 @@ class IntegrationRequestController extends Controller $used = $user->integrationRequests()->where('created_at', '>=', $start)->count() + $user->integrationRequestVotes()->where('created_at', '>=', $start)->count(); - return max(0, self::MONTHLY_ACTION_LIMIT - $used); + return max(0, $limit - $used); } private function payload(User $user, int $status = 200): JsonResponse @@ -127,10 +153,10 @@ class IntegrationRequestController extends Controller ], $status); } - private function limitReachedResponse(): JsonResponse + private function limitReachedResponse(User $user): JsonResponse { return response()->json([ - 'message' => __('You have reached your monthly limit of :count integration actions. Try again next month.', ['count' => self::MONTHLY_ACTION_LIMIT]), + 'message' => __('You have reached your monthly limit of :count integration actions. Try again next month.', ['count' => $this->monthlyActionLimit($user)]), ], 422); } } diff --git a/database/migrations/2026_06_18_092830_drop_unique_user_vote_from_integration_request_votes_table.php b/database/migrations/2026_06_18_092830_drop_unique_user_vote_from_integration_request_votes_table.php new file mode 100644 index 00000000..cffeb44b --- /dev/null +++ b/database/migrations/2026_06_18_092830_drop_unique_user_vote_from_integration_request_votes_table.php @@ -0,0 +1,27 @@ +index('integration_request_id'); + $table->dropUnique(['integration_request_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::table('integration_request_votes', function (Blueprint $table) { + $table->unique(['integration_request_id', 'user_id']); + $table->dropIndex(['integration_request_id']); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index da7b6d9d..2cb11591 100644 --- a/lang/es.json +++ b/lang/es.json @@ -13,6 +13,7 @@ "No integrations requested yet. Be the first!": "Aún no hay integraciones solicitadas. ¡Sé el primero!", "Request a bank or service and vote for the ones you want us to add next.": "Solicita un banco o servicio y vota los que quieres que añadamos a continuación.", "You have reached your monthly limit of :count integration actions. Try again next month.": "Has alcanzado tu límite mensual de :count acciones de integración. Inténtalo de nuevo el mes que viene.", + "Remove one vote": "Quitar un voto", "Categorized by AI": "Categorizado por IA", "Categorized by AI with :confidence% confident": "Categorizado por IA con un :confidence % de confianza", "Only show AI guesses": "Mostrar solo sugerencias de IA", diff --git a/resources/js/components/integration-requests/integration-requests-board.test.tsx b/resources/js/components/integration-requests/integration-requests-board.test.tsx index 7d007761..060675fe 100644 --- a/resources/js/components/integration-requests/integration-requests-board.test.tsx +++ b/resources/js/components/integration-requests/integration-requests-board.test.tsx @@ -14,6 +14,7 @@ const item: IntegrationRequestItem = { '> No ofrece una [API pública](https://degiro.es/api) para conexiones.', votes_count: 3, has_voted: false, + can_unvote: false, created_at: '2026-01-01T00:00:00.000000Z', }; diff --git a/resources/js/components/integration-requests/integration-requests-board.tsx b/resources/js/components/integration-requests/integration-requests-board.tsx index b58c92be..e117196f 100644 --- a/resources/js/components/integration-requests/integration-requests-board.tsx +++ b/resources/js/components/integration-requests/integration-requests-board.tsx @@ -1,5 +1,6 @@ import { data, + removeVote, store, vote, } from '@/actions/App/Http/Controllers/IntegrationRequestController'; @@ -11,7 +12,7 @@ import { Label } from '@/components/ui/label'; import { Spinner } from '@/components/ui/spinner'; import { getCsrfToken } from '@/lib/csrf'; import { __ } from '@/utils/i18n'; -import { ChevronUp } from 'lucide-react'; +import { ChevronDown, ChevronUp } from 'lucide-react'; import { FormEvent, useEffect, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -24,6 +25,7 @@ export interface IntegrationRequestItem { comment: string | null; votes_count: number; has_voted: boolean; + can_unvote: boolean; created_at: string; } @@ -39,7 +41,7 @@ interface Props { async function sendJson( url: string, - method: 'GET' | 'POST', + method: 'GET' | 'POST' | 'DELETE', body?: Record, ): Promise { return fetch(url, { @@ -113,11 +115,7 @@ export function IntegrationRequestsBoard({ }; const handleVote = async (item: IntegrationRequestItem) => { - if ( - busy || - item.status === 'not_doable' || - (!item.has_voted && actionsRemaining <= 0) - ) { + if (busy || item.status === 'not_doable' || actionsRemaining <= 0) { return; } @@ -139,6 +137,29 @@ export function IntegrationRequestsBoard({ } }; + const handleRemoveVote = async (item: IntegrationRequestItem) => { + if (busy || !item.can_unvote) { + return; + } + + setError(null); + setBusy(true); + + try { + const response = await sendJson(removeVote(item.id).url, 'DELETE'); + + if (response.ok) { + apply(await response.json()); + return; + } + + const body = await response.json(); + setError(body.message ?? __('Something went wrong.')); + } finally { + setBusy(false); + } + }; + const outOfActions = actionsRemaining <= 0; // A new request also auto-votes it, so it costs two actions. const cannotRequest = actionsRemaining < 2; @@ -251,22 +272,38 @@ export function IntegrationRequestsBoard({ )} - +
+ {item.can_unvote && ( + + )} + +
{item.comment && (
diff --git a/routes/web.php b/routes/web.php index 376c6764..17100eb1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -127,6 +127,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('integration-requests/data', [IntegrationRequestController::class, 'data'])->name('integration-requests.data'); Route::post('integration-requests', [IntegrationRequestController::class, 'store'])->name('integration-requests.store'); Route::post('integration-requests/{integrationRequest}/vote', [IntegrationRequestController::class, 'vote'])->name('integration-requests.vote'); + Route::delete('integration-requests/{integrationRequest}/vote', [IntegrationRequestController::class, 'removeVote'])->name('integration-requests.vote.destroy'); }); Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () { diff --git a/tests/Feature/IntegrationRequestTest.php b/tests/Feature/IntegrationRequestTest.php index cc1090a8..467741dc 100644 --- a/tests/Feature/IntegrationRequestTest.php +++ b/tests/Feature/IntegrationRequestTest.php @@ -4,11 +4,19 @@ use App\Enums\IntegrationRequestStatus; use App\Models\IntegrationRequest; use App\Models\User; +// With subscriptions enabled, a freshly created user is on the free plan +// (three monthly actions). Subscribed users get the pro quota of nine. +beforeEach(function () { + config(['subscriptions.enabled' => true]); +}); + test('guests cannot access the integration requests page', function () { $this->get('/integration-requests')->assertRedirect(); }); test('the integration requests url renders the dashboard with the drawer open', function () { + // The dashboard route is paywalled; keep this user out of the paywall. + config(['subscriptions.enabled' => false]); $user = User::factory()->onboarded()->create(); $this->actingAs($user) @@ -70,22 +78,40 @@ test('requesting an integration requires a name and a valid url', function () { ->assertJsonValidationErrors(['name', 'url']); }); -test('a user can vote and then remove the vote', function () { +test('a user can vote multiple times on the same request', function () { $user = User::factory()->create(); $request = IntegrationRequest::factory()->approved()->create(); $this->actingAs($user) ->postJson("/integration-requests/{$request->id}/vote") - ->assertOk(); - - $this->assertDatabaseHas('integration_request_votes', [ - 'integration_request_id' => $request->id, - 'user_id' => $user->id, - ]); + ->assertOk() + ->assertJsonPath('requests.0.votes_count', 1) + ->assertJsonPath('actionsRemaining', 2); $this->actingAs($user) ->postJson("/integration-requests/{$request->id}/vote") - ->assertOk(); + ->assertOk() + ->assertJsonPath('requests.0.votes_count', 2) + ->assertJsonPath('actionsRemaining', 1); + + expect($request->votes()->where('user_id', $user->id)->count())->toBe(2); +}); + +test('a user can remove a vote cast this month and recover the action', function () { + $user = User::factory()->create(); + $request = IntegrationRequest::factory()->approved()->create(); + + $this->actingAs($user) + ->postJson("/integration-requests/{$request->id}/vote") + ->assertOk() + ->assertJsonPath('actionsRemaining', 2); + + $this->actingAs($user) + ->deleteJson("/integration-requests/{$request->id}/vote") + ->assertOk() + ->assertJsonPath('requests.0.votes_count', 0) + ->assertJsonPath('requests.0.can_unvote', false) + ->assertJsonPath('actionsRemaining', 3); $this->assertDatabaseMissing('integration_request_votes', [ 'integration_request_id' => $request->id, @@ -93,6 +119,42 @@ test('a user can vote and then remove the vote', function () { ]); }); +test('a vote cast in a previous month cannot be removed', function () { + $user = User::factory()->create(); + $request = IntegrationRequest::factory()->approved()->create(); + + $vote = $request->votes()->create(['user_id' => $user->id]); + $vote->forceFill(['created_at' => now()->subMonth()])->saveQuietly(); + + $this->actingAs($user) + ->getJson('/integration-requests/data') + ->assertJsonPath('requests.0.can_unvote', false); + + $this->actingAs($user) + ->deleteJson("/integration-requests/{$request->id}/vote") + ->assertOk() + ->assertJsonPath('requests.0.votes_count', 1); + + $this->assertDatabaseHas('integration_request_votes', [ + 'id' => $vote->id, + ]); +}); + +test('pro users get nine monthly actions', function () { + $user = User::factory()->create(); + $user->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => 'sub_pro123', + 'stripe_status' => 'active', + 'stripe_price' => 'price_pro123', + ]); + + $this->actingAs($user) + ->getJson('/integration-requests/data') + ->assertOk() + ->assertJsonPath('actionsRemaining', 9); +}); + test('a user cannot exceed the monthly action limit', function () { $user = User::factory()->create(); IntegrationRequest::factory()->count(3)->create(['user_id' => $user->id]); @@ -131,21 +193,21 @@ test('the admin bypasses the monthly limit and their requests are auto-approved' ->assertOk(); }); -test('removing a vote is allowed even at the monthly limit', function () { +test('repeated voting stops once the monthly limit is reached', function () { $user = User::factory()->create(); $request = IntegrationRequest::factory()->approved()->create(); - IntegrationRequest::factory()->count(2)->create(['user_id' => $user->id]); - $request->votes()->create(['user_id' => $user->id]); + foreach (range(1, 3) as $ignored) { + $this->actingAs($user) + ->postJson("/integration-requests/{$request->id}/vote") + ->assertOk(); + } $this->actingAs($user) ->postJson("/integration-requests/{$request->id}/vote") - ->assertOk(); + ->assertStatus(422); - $this->assertDatabaseMissing('integration_request_votes', [ - 'integration_request_id' => $request->id, - 'user_id' => $user->id, - ]); + expect($request->votes()->count())->toBe(3); }); test('pending requests are only visible to their creator', function () {