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:
parent
756b4816a6
commit
e4be39be12
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -47,4 +47,9 @@ class IntegrationRequestFactory extends Factory
|
|||
'comment' => fake()->sentence(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function done(): static
|
||||
{
|
||||
return $this->state(['status' => IntegrationRequestStatus::Done]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue