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:
parent
da88adbee3
commit
0ea54fa0d7
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue