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.
This commit is contained in:
Víctor Falcón 2026-06-18 12:06:08 +02:00 committed by GitHub
parent da88adbee3
commit 0ea54fa0d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 210 additions and 55 deletions

View File

@ -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);
}
}

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('integration_request_votes', function (Blueprint $table) {
// The integration_request_id foreign key relies on the composite
// unique index; give it a standalone index before dropping the
// unique so users may back the same integration multiple times.
$table->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']);
});
}
};

View File

@ -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",

View File

@ -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',
};

View File

@ -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<string, string>,
): Promise<Response> {
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({
</Badge>
)}
</div>
<Button
variant={
item.has_voted ? 'default' : 'outline'
}
size="sm"
disabled={
busy ||
item.status === 'not_doable' ||
(!item.has_voted && outOfActions)
}
onClick={() => handleVote(item)}
aria-pressed={item.has_voted}
>
<ChevronUp className="h-4 w-4" />
{item.votes_count}
</Button>
<div className="flex items-center gap-1">
{item.can_unvote && (
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() =>
handleRemoveVote(item)
}
aria-label={__('Remove one vote')}
>
<ChevronDown className="h-4 w-4" />
</Button>
)}
<Button
variant={
item.has_voted
? 'default'
: 'outline'
}
size="sm"
disabled={
busy ||
item.status === 'not_doable' ||
outOfActions
}
onClick={() => handleVote(item)}
>
<ChevronUp className="h-4 w-4" />
{item.votes_count}
</Button>
</div>
</div>
{item.comment && (
<div className="mt-2 space-y-2 text-sm text-muted-foreground [&_a]:font-medium [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:italic [&_li]:ml-4 [&_li]:list-disc [&_strong]:font-semibold">

View File

@ -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 () {

View File

@ -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 () {