feat(integration-requests): add done status and fix review command crash on orphaned author (#601)

## Summary

- **Fix:** `integration-requests:review` crashed with `Attempt to read
property "email" on null` when a request's author no longer exists. The
command now renders a dash instead of assuming the `user` relation is
present.
- **Feature:** new terminal `done` status that marks an integration
request as shipped.

## The `done` status

Modeled after `not_doable` (a frozen, terminal state):

- Shown on the board, sinking to the bottom regardless of votes.
- Can no longer be voted on or unvoted (`removeVote` returns 404).
- Drops its public comment when set, so the board shows no stale note.
- Available in both `integration-requests:review` flows (pending review
and `--all`).

Frontend gets a `Done` badge and an `isFrozen()` helper that replaces
the scattered `=== 'not_doable'` checks gating the vote/unvote buttons.

## Tests

- 6 new feature tests: visibility without comment, bottom ordering,
vote/unvote blocked, command sets `done` and clears the comment, and the
command tolerating an orphaned author.
- `php artisan test tests/Feature/IntegrationRequestTest.php` → 34
passed.
- Board vitest suite, Pint, ESLint and Prettier all green.
This commit is contained in:
Víctor Falcón 2026-06-27 16:42:09 +02:00 committed by GitHub
parent 756b4816a6
commit e4be39be12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 122 additions and 18 deletions

View File

@ -42,7 +42,7 @@ class ReviewIntegrationRequestsCommand extends Command
foreach ($pending as $request) {
$decision = $this->choice(
"Review \"{$request->name}\" ({$request->url})",
['approve', 'in progress', 'reject', 'not doable', 'skip'],
['approve', 'in progress', 'reject', 'not doable', 'done', 'skip'],
'skip',
);
@ -78,7 +78,7 @@ class ReviewIntegrationRequestsCommand extends Command
$this->apply($request, $this->choice(
"New status for \"{$request->name}\"",
['approve', 'in progress', 'reject', 'not doable'],
['approve', 'in progress', 'reject', 'not doable', 'done'],
));
$this->info("\"{$request->name}\" is now {$request->status->label()}.");
@ -93,6 +93,7 @@ class ReviewIntegrationRequestsCommand extends Command
'in progress' => IntegrationRequestStatus::InProgress,
'reject' => IntegrationRequestStatus::Rejected,
'not doable' => IntegrationRequestStatus::NotDoable,
'done' => IntegrationRequestStatus::Done,
default => null,
};
@ -124,7 +125,7 @@ class ReviewIntegrationRequestsCommand extends Command
return $this->ask('Add a comment for this request (optional, shown to users)') ?: null;
}
// Approving, rejecting or re-queuing drops any stale public comment.
// Approving, rejecting, re-queuing or marking done drops any stale public comment.
return null;
}
@ -136,7 +137,9 @@ class ReviewIntegrationRequestsCommand extends Command
{
return $query
->withCount('votes')
->with('user:id,email')
// Authors who deleted their account are soft-deleted, but their requests
// linger; load them trashed so the table still shows who submitted each one.
->with(['user' => fn ($user) => $user->withTrashed()->select('id', 'email')])
->orderBy('created_at')
->get();
}

View File

@ -9,6 +9,7 @@ enum IntegrationRequestStatus: string
case InProgress = 'in_progress';
case Rejected = 'rejected';
case NotDoable = 'not_doable';
case Done = 'done';
public function label(): string
{
@ -18,6 +19,7 @@ enum IntegrationRequestStatus: string
self::InProgress => 'In progress',
self::Rejected => 'Rejected',
self::NotDoable => 'Not doable',
self::Done => 'Done',
};
}
}

View File

@ -79,8 +79,8 @@ class IntegrationRequestController extends Controller
{
$user = $request->user();
// Not-doable requests are frozen: their tally can no longer be touched.
if ($integrationRequest->status === IntegrationRequestStatus::NotDoable) {
// Closed requests (not-doable or done) are frozen: their tally can no longer be touched.
if (in_array($integrationRequest->status, [IntegrationRequestStatus::NotDoable, IntegrationRequestStatus::Done], true)) {
abort(404);
}
@ -106,7 +106,7 @@ class IntegrationRequestController extends Controller
{
return IntegrationRequest::query()
->where(function ($query) use ($user) {
$query->whereIn('status', [IntegrationRequestStatus::Approved, IntegrationRequestStatus::InProgress, IntegrationRequestStatus::NotDoable])
$query->whereIn('status', [IntegrationRequestStatus::Approved, IntegrationRequestStatus::InProgress, IntegrationRequestStatus::NotDoable, IntegrationRequestStatus::Done])
->orWhere(function ($inner) use ($user) {
$inner->where('status', IntegrationRequestStatus::Pending)
->where('user_id', $user->id);
@ -118,8 +118,8 @@ class IntegrationRequestController extends Controller
'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])
// Closed requests (not-doable or done) sink to the bottom regardless of their votes.
->orderByRaw('CASE WHEN status IN (?, ?) THEN 1 ELSE 0 END', [IntegrationRequestStatus::NotDoable->value, IntegrationRequestStatus::Done->value])
->orderByDesc('votes_count')
->orderByDesc('created_at')
->get();

View File

@ -47,4 +47,9 @@ class IntegrationRequestFactory extends Factory
'comment' => fake()->sentence(),
]);
}
public function done(): static
{
return $this->state(['status' => IntegrationRequestStatus::Done]);
}
}

View File

@ -35,6 +35,7 @@
"Pending review": "Pendiente de revisión",
"Not doable": "No viable",
"In progress": "En proceso",
"Done": "Hecho",
"Something went wrong.": "Algo salió mal.",
"Integration requests": "Solicitudes de integración",
"Request integration": "Solicitar integración",

View File

@ -21,7 +21,13 @@ export interface IntegrationRequestItem {
id: string;
name: string;
url: string;
status: 'pending' | 'approved' | 'in_progress' | 'rejected' | 'not_doable';
status:
| 'pending'
| 'approved'
| 'in_progress'
| 'rejected'
| 'not_doable'
| 'done';
comment: string | null;
votes_count: number;
has_voted: boolean;
@ -34,6 +40,11 @@ interface BoardPayload {
actionsRemaining: number;
}
// Closed requests (not-doable or done) are frozen: no more votes in or out.
function isFrozen(status: IntegrationRequestItem['status']): boolean {
return status === 'not_doable' || status === 'done';
}
interface Props {
initialRequests?: IntegrationRequestItem[];
initialActionsRemaining?: number;
@ -115,7 +126,7 @@ export function IntegrationRequestsBoard({
};
const handleVote = async (item: IntegrationRequestItem) => {
if (busy || item.status === 'not_doable' || actionsRemaining <= 0) {
if (busy || isFrozen(item.status) || actionsRemaining <= 0) {
return;
}
@ -138,7 +149,7 @@ export function IntegrationRequestsBoard({
};
const handleRemoveVote = async (item: IntegrationRequestItem) => {
if (busy || !item.can_unvote || item.status === 'not_doable') {
if (busy || !item.can_unvote || isFrozen(item.status)) {
return;
}
@ -271,10 +282,13 @@ export function IntegrationRequestsBoard({
{__('Not doable')}
</Badge>
)}
{item.status === 'done' && (
<Badge>{__('Done')}</Badge>
)}
</div>
<div className="flex items-center gap-1">
{item.can_unvote &&
item.status !== 'not_doable' && (
!isFrozen(item.status) && (
<Button
variant="ghost"
size="sm"
@ -298,7 +312,7 @@ export function IntegrationRequestsBoard({
size="sm"
disabled={
busy ||
item.status === 'not_doable' ||
isFrozen(item.status) ||
outOfActions
}
onClick={() => handleVote(item)}

View File

@ -247,7 +247,7 @@ test('the review command approves a pending request', function () {
->expectsChoice(
"Review \"{$request->name}\" ({$request->url})",
'approve',
['approve', 'in progress', 'reject', 'not doable', 'skip'],
['approve', 'in progress', 'reject', 'not doable', 'done', 'skip'],
)
->assertSuccessful();
@ -261,7 +261,7 @@ test('the review command rejects a pending request', function () {
->expectsChoice(
"Review \"{$request->name}\" ({$request->url})",
'reject',
['approve', 'in progress', 'reject', 'not doable', 'skip'],
['approve', 'in progress', 'reject', 'not doable', 'done', 'skip'],
)
->assertSuccessful();
@ -344,7 +344,7 @@ test('the review command marks a request as not doable with a comment', function
->expectsChoice(
"Review \"{$request->name}\" ({$request->url})",
'not doable',
['approve', 'in progress', 'reject', 'not doable', 'skip'],
['approve', 'in progress', 'reject', 'not doable', 'done', 'skip'],
)
->expectsQuestion('Why is this integration not doable? (shown to users)', 'No public API available.')
->assertSuccessful();
@ -362,7 +362,7 @@ test('the review command with --all moves any request to a new status with a com
->expectsChoice(
'New status for "Revolut"',
'in progress',
['approve', 'in progress', 'reject', 'not doable'],
['approve', 'in progress', 'reject', 'not doable', 'done'],
)
->expectsQuestion('Add a comment for this request (optional, shown to users)', 'Building it now.')
->assertSuccessful();
@ -397,3 +397,82 @@ test('a user can vote on an in progress request', function () {
'integration_request_id' => $request->id,
]);
});
test('done requests are visible to everyone without a comment', function () {
IntegrationRequest::factory()->done()->create(['name' => 'Shipped Bank']);
$this->actingAs(User::factory()->create())
->getJson('/integration-requests/data')
->assertOk()
->assertJsonCount(1, 'requests')
->assertJsonPath('requests.0.status', 'done')
->assertJsonPath('requests.0.comment', null);
});
test('done requests are listed after the rest', function () {
IntegrationRequest::factory()->done()->create(['name' => 'Done']);
$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', 'Done');
});
test('nobody can vote on a done request', function () {
$request = IntegrationRequest::factory()->done()->create();
$this->actingAs(User::factory()->create())
->postJson("/integration-requests/{$request->id}/vote")
->assertNotFound();
});
test('a vote on a request later marked done cannot be removed', function () {
$user = User::factory()->create();
$request = IntegrationRequest::factory()->approved()->create();
$request->votes()->create(['user_id' => $user->id]);
$request->update(['status' => IntegrationRequestStatus::Done]);
$this->actingAs($user)
->deleteJson("/integration-requests/{$request->id}/vote")
->assertNotFound();
expect($request->votes()->count())->toBe(1);
});
test('the review command marks a request as done and drops its comment', function () {
$request = IntegrationRequest::factory()->inProgress()->create([
'name' => 'Revolut',
'comment' => 'Working on it.',
]);
$this->artisan('integration-requests:review --all')
->expectsQuestion('Which request do you want to update? (number)', '1')
->expectsChoice(
'New status for "Revolut"',
'done',
['approve', 'in progress', 'reject', 'not doable', 'done'],
)
->assertSuccessful();
$request->refresh();
expect($request->status)->toBe(IntegrationRequestStatus::Done);
expect($request->comment)->toBeNull();
});
test('the review command still shows the author of requests whose account was deleted', function () {
$author = User::factory()->create(['email' => 'gone@whisper.test']);
IntegrationRequest::factory()->approved()->create(['user_id' => $author->id]);
// Soft-deleting the account would otherwise null the user relation and crash
// the table render; the command loads trashed authors so their email stays visible.
$author->delete();
$this->artisan('integration-requests:review --all')
->expectsOutputToContain('gone@whisper.test')
->expectsQuestion('Which request do you want to update? (number)', '0')
->expectsOutput('That number is not on the list.')
->assertFailed();
});