fix(integration-requests): freeze votes on not-doable requests (#555)

## What
Prevent adding or modifying votes on requests marked as **not-doable**.

- **Backend** (`removeVote`): a vote cast before a request was marked
not-doable could still be removed, mutating a frozen tally. It now
returns `404`, mirroring `vote()` which already rejected not-doable
requests.
- **Frontend**: hide the unvote (chevron-down) button for not-doable
requests; the vote button was already disabled for them.

## Test
Added a feature test: vote → request becomes not-doable → `DELETE` vote
returns `404` and the tally is unchanged.
This commit is contained in:
Víctor Falcón 2026-06-18 12:54:56 +02:00 committed by GitHub
parent 0ea54fa0d7
commit 89c1ab1ca8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 36 additions and 14 deletions

View File

@ -79,6 +79,11 @@ class IntegrationRequestController extends Controller
{
$user = $request->user();
// Not-doable requests are frozen: their tally can no longer be touched.
if ($integrationRequest->status === IntegrationRequestStatus::NotDoable) {
abort(404);
}
// 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()

View File

@ -138,7 +138,7 @@ export function IntegrationRequestsBoard({
};
const handleRemoveVote = async (item: IntegrationRequestItem) => {
if (busy || !item.can_unvote) {
if (busy || !item.can_unvote || item.status === 'not_doable') {
return;
}
@ -273,19 +273,22 @@ export function IntegrationRequestsBoard({
)}
</div>
<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>
)}
{item.can_unvote &&
item.status !== 'not_doable' && (
<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

View File

@ -323,6 +323,20 @@ test('nobody can vote on a not doable request', function () {
->assertNotFound();
});
test('a vote on a request later marked not doable cannot be removed', function () {
$user = User::factory()->create();
$request = IntegrationRequest::factory()->approved()->create();
$request->votes()->create(['user_id' => $user->id]);
$request->update(['status' => IntegrationRequestStatus::NotDoable]);
$this->actingAs($user)
->deleteJson("/integration-requests/{$request->id}/vote")
->assertNotFound();
expect($request->votes()->count())->toBe(1);
});
test('the review command marks a request as not doable with a comment', function () {
$request = IntegrationRequest::factory()->create(['name' => 'Hard Bank']);