diff --git a/app/Actions/UpdateScheduledCommitSubmittedAt.php b/app/Actions/UpdateScheduledCommitSubmittedAt.php
index f9820ac56..fa6a2bf51 100644
--- a/app/Actions/UpdateScheduledCommitSubmittedAt.php
+++ b/app/Actions/UpdateScheduledCommitSubmittedAt.php
@@ -17,15 +17,18 @@ public function __construct(private CreateCommit $createCommit)
//
}
- public function handle(string $id): bool
+ /**
+ * The author can commit it now, before its scheduled time.
+ */
+ public function handle(string $id, bool $early = false): bool
{
- return DB::transaction(function () use ($id): bool {
+ return DB::transaction(function () use ($id, $early): bool {
// Claim with a conditional write before reading.
// Creation and the claim roll back together if either operation fails.
// Moderation is checked in the claim too, since a timeout or ban can land after the batch was selected...
$claimed = ScheduledCommit::query()->whereKey($id)
->whereNull('submitted_at')->whereNull('canceled_at')
- ->where('scheduled_at', '<=', now())
+ ->unless($early, fn (Builder $query): Builder => $query->where('scheduled_at', '<=', now()))
->whereDoesntHave('user.timeouts', fn (Builder $query): Builder => $query->where('ends_at', '>', now()))
->whereDoesntHave('user.ban')
->update(['submitted_at' => now()]);
diff --git a/app/Http/Controllers/ScheduledCommitSubmissionController.php b/app/Http/Controllers/ScheduledCommitSubmissionController.php
new file mode 100644
index 000000000..e4ba429a1
--- /dev/null
+++ b/app/Http/Controllers/ScheduledCommitSubmissionController.php
@@ -0,0 +1,29 @@
+handle($scheduledCommit->id, early: true);
+
+ if ($submitted) {
+ Inertia::flash('submitted', $scheduledCommit->id);
+ }
+
+ Inertia::flash('toast', ['type' => $submitted ? 'success' : 'info', 'message' => $submitted
+ ? 'Commit submitted. It goes through the usual review process.'
+ : 'This commit was already submitted or canceled. Refresh to see its status.']);
+
+ return back();
+ }
+}
diff --git a/app/Http/Requests/CreateScheduledCommitSubmissionRequest.php b/app/Http/Requests/CreateScheduledCommitSubmissionRequest.php
new file mode 100644
index 000000000..b23d5934a
--- /dev/null
+++ b/app/Http/Requests/CreateScheduledCommitSubmissionRequest.php
@@ -0,0 +1,25 @@
+route('scheduledCommit');
+ assert($scheduled instanceof ScheduledCommit);
+
+ return $this->user()?->id === $scheduled->user_id;
+ }
+
+ /** @return array{} */
+ public function rules(): array
+ {
+ return [];
+ }
+}
diff --git a/resources/js/pages/scheduled-commits/index.tsx b/resources/js/pages/scheduled-commits/index.tsx
index 24bcdb584..519cac76d 100644
--- a/resources/js/pages/scheduled-commits/index.tsx
+++ b/resources/js/pages/scheduled-commits/index.tsx
@@ -1,10 +1,22 @@
import { Head, Link, router, usePoll } from '@inertiajs/react';
-import { ArrowLeft, Clock3, ImageIcon, LockKeyhole } from 'lucide-react';
-import { useState } from 'react';
+import {
+ ArrowLeft,
+ Clock3,
+ ImageIcon,
+ LockKeyhole,
+ Trash2,
+} from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
import LogController from '@/actions/App/Http/Controllers/LogController';
import ScheduledCommitController from '@/actions/App/Http/Controllers/ScheduledCommitController';
+import ScheduledCommitSubmissionController from '@/actions/App/Http/Controllers/ScheduledCommitSubmissionController';
import { FoldedCommitBody } from '@/components/commit-body';
import { Button } from '@/components/ui/button';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
import { useNow } from '@/hooks/use-now';
import AppLayout from '@/layouts/app-layout';
import { backgroundPollOptions } from '@/lib/background-poll';
@@ -34,9 +46,93 @@ export default function Index({
};
}) {
const now = useNow();
- const [canceling, setCanceling] = useState
- {scheduledCommits.data.map((scheduled) => {
+ {rows.map((scheduled) => {
const due =
new Date(scheduled.scheduled_at).getTime() <=
now.getTime();
+ const when = new Date(
+ scheduled.scheduled_at,
+ ).toLocaleString(dateLocale, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ });
return (
{due ? 'Waiting for the scheduler. If submission fails, it is retried automatically.' - : 'Private until submitted. You can cancel before submission.'} + : 'Private until submitted. You can commit it now or cancel it before submission.'}
+ ) : ( + sent.has(scheduled.id) && ( ++ Submitted just now, ahead of its + scheduled time. It goes through the + usual review process. +
+ ) )} ); diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts index 76c7a78a4..1382a50b0 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -17,6 +17,7 @@ declare module '@inertiajs/core' { deleted?: DeletedFlash; restored?: Commit; scheduled?: boolean; + submitted?: string; localSignIn?: boolean; signedUp?: boolean; }; diff --git a/routes/web.php b/routes/web.php index 2b7e08984..8b5018685 100644 --- a/routes/web.php +++ b/routes/web.php @@ -84,6 +84,7 @@ use App\Http\Controllers\ReportedCommitController; use App\Http\Controllers\SafetyController; use App\Http\Controllers\ScheduledCommitController; +use App\Http\Controllers\ScheduledCommitSubmissionController; use App\Http\Controllers\SearchController; use App\Http\Controllers\SessionController; use App\Http\Controllers\SiteActivityController; @@ -280,6 +281,8 @@ Route::post('scheduled-commits', [ScheduledCommitController::class, 'store']) ->middleware([PreventPostingInTimeout::class, RequireCommitRules::class, ThrottleCommits::class])->name('scheduled-commit.store'); Route::delete('scheduled-commits/{scheduledCommit}', [ScheduledCommitController::class, 'destroy'])->name('scheduled-commit.destroy'); + Route::post('scheduled-commits/{scheduledCommit}/submission', [ScheduledCommitSubmissionController::class, 'store']) + ->middleware([PreventPostingInTimeout::class, ThrottleCommits::class])->name('scheduled-commit-submission.store'); Route::get('commit-stars', [CommitStarCountController::class, 'index'])->name('commit-star-count.index'); Route::post('commits', [CommitController::class, 'store']) diff --git a/tests/Feature/ScheduledCommitsTest.php b/tests/Feature/ScheduledCommitsTest.php index 67839b43a..360007334 100644 --- a/tests/Feature/ScheduledCommitsTest.php +++ b/tests/Feature/ScheduledCommitsTest.php @@ -16,6 +16,7 @@ use App\Models\Poll; use App\Models\ScheduledCommit; use App\Models\User; +use App\Models\UserTimeout; use App\Queries\UserActivityQuery; use Illuminate\Database\QueryException; use Illuminate\Http\UploadedFile; @@ -155,6 +156,7 @@ $this->get(route('scheduled-commit.index'))->assertRedirectToRoute('home'); $this->post(route('scheduled-commit.store'), [])->assertRedirectToRoute('home'); $this->delete(route('scheduled-commit.destroy', $scheduled))->assertRedirectToRoute('home'); + $this->post(route('scheduled-commit-submission.store', $scheduled))->assertRedirectToRoute('home'); }); it('rejects invalid times and bodies', function (array $payload, string $field): void { @@ -215,6 +217,7 @@ it('shares the existing commit rate limit', function (): void { $user = User::factory()->create(); + $scheduled = ScheduledCommit::factory()->for($user)->create(); for ($i = 0; $i < 10; $i++) { $this->actingAs($user)->post(route('commit.store'), ['body' => 'An ordinary commit.']); } @@ -222,7 +225,10 @@ $this->post(route('scheduled-commit.store'), [ 'body' => 'Must be throttled.', 'scheduled_at' => now()->addDay()->toIso8601String(), ])->assertRedirect(); - expect(ScheduledCommit::query()->count())->toBe(0); + $this->post(route('scheduled-commit-submission.store', $scheduled))->assertRedirect() + ->assertInertiaFlash('toast.type', 'error')->assertInertiaFlashMissing('submitted'); + expect(ScheduledCommit::query()->count())->toBe(1) + ->and($scheduled->fresh()->submitted_at)->toBeNull(); }); it('keeps schedules out of logs profiles activity previews and moderation until due', function (): void { @@ -494,3 +500,47 @@ expect(collect(DB::getQueryLog())->filter(fn (array $query): bool => str_contains($query['query'], 'scheduled_commits')))->toBeEmpty(); }); + +it('commits a scheduled item now, before its time, through the usual review', function (): void { + $scheduled = ScheduledCommit::factory()->create(['scheduled_at' => now()->addDay(), 'body' => 'Shipping early']); + + $this->actingAs($scheduled->user)->post(route('scheduled-commit-submission.store', $scheduled))->assertRedirect() + ->assertInertiaFlash('toast.type', 'success')->assertInertiaFlash('submitted', $scheduled->id); + + $commit = Commit::query()->sole(); + expect($commit->body)->toBe('Shipping early') + ->and($commit->status)->toBe(CommitStatus::Pending) + ->and($scheduled->fresh()->commit_id)->toBe($commit->id) + ->and($scheduled->fresh()->submitted_at->equalTo(now()))->toBeTrue(); + + $this->post(route('scheduled-commit-submission.store', $scheduled))->assertRedirect() + ->assertInertiaFlash('toast.type', 'info'); + $this->travel(1)->day(); + $this->artisan('commits:submit-scheduled')->assertSuccessful(); + expect(Commit::query()->count())->toBe(1); +}); + +it('does not commit now a canceled item or another authors item', function (): void { + $canceled = ScheduledCommit::factory()->create(); + resolve(DeleteScheduledCommit::class)->handle($canceled->user, $canceled); + $other = ScheduledCommit::factory()->create(); + + $this->actingAs($canceled->user)->post(route('scheduled-commit-submission.store', $canceled))->assertRedirect() + ->assertInertiaFlash('toast.type', 'info')->assertInertiaFlashMissing('submitted'); + $this->post(route('scheduled-commit-submission.store', $other))->assertForbidden(); + $this->actingAs(User::factory()->create(['username' => 'admin']))->post(route('scheduled-commit-submission.store', $other))->assertForbidden(); + + expect(Commit::query()->count())->toBe(0) + ->and($other->fresh()->submitted_at)->toBeNull(); +}); + +it('does not commit now while the author is in timeout', function (): void { + $scheduled = ScheduledCommit::factory()->create(); + UserTimeout::factory()->for($scheduled->user)->create(); + + $this->actingAs($scheduled->user)->post(route('scheduled-commit-submission.store', $scheduled))->assertRedirect() + ->assertInertiaFlash('timeout')->assertInertiaFlashMissing('submitted'); + + expect(Commit::query()->count())->toBe(0) + ->and(resolve(UpdateScheduledCommitSubmittedAt::class)->handle($scheduled->id, early: true))->toBeFalse(); +});