From 071108ff7722e2dea6dd1f5a475aaf5aef370ee3 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 16 Oct 2026 02:06:15 +0200 Subject: [PATCH 01/16] feat: rejection reason templates, review claims and a decision log for moderation --- PRODUCT.md | 1 + app/Actions/CreateRejectionReason.php | 18 + app/Actions/DeleteRejectionReason.php | 15 + app/Actions/UpdateCommitClaim.php | 33 ++ app/Actions/UpdateCommitStatus.php | 5 +- app/Actions/UpdateCommitStatuses.php | 24 +- app/Actions/UpdateRejectionReason.php | 18 + .../Controllers/CommitApprovalController.php | 6 +- .../Controllers/CommitClaimController.php | 22 ++ app/Http/Controllers/CommitController.php | 1 + .../Controllers/CommitDecisionController.php | 21 + .../Controllers/CommitRejectionController.php | 6 +- app/Http/Controllers/ModerationController.php | 8 +- .../Controllers/RejectionReasonController.php | 38 ++ .../Requests/CreateCommitApprovalRequest.php | 23 +- .../Requests/CreateCommitRejectionRequest.php | 20 +- .../Requests/CreateRejectionReasonRequest.php | 30 ++ .../Requests/DeleteRejectionReasonRequest.php | 27 ++ .../Requests/ListCommitDecisionsRequest.php | 27 ++ app/Http/Requests/ShowCommitRequest.php | 5 + .../Requests/UpdateCommitClaimRequest.php | 60 +++ app/Http/Requests/UpdateModerationRequest.php | 9 +- .../Requests/UpdateRejectionReasonRequest.php | 30 ++ app/Models/Commit.php | 9 + app/Models/CommitClaim.php | 69 ++++ app/Models/CommitDecision.php | 70 ++++ app/Models/RejectionReason.php | 43 +++ app/Queries/CommitClaimBuilder.php | 19 + app/Queries/CommitDecisionsQuery.php | 22 ++ app/Rules/Unclaimed.php | 35 ++ database/factories/CommitClaimFactory.php | 35 ++ database/factories/CommitDecisionFactory.php | 30 ++ database/factories/RejectionReasonFactory.php | 25 ++ ..._233856_create_rejection_reasons_table.php | 25 ++ ...0_15_233857_create_commit_claims_table.php | 26 ++ ...5_233858_create_commit_decisions_table.php | 29 ++ .../js/components/commit-decision-item.tsx | 137 +++++++ .../components/keyboard-shortcuts-dialog.tsx | 2 + .../components/rejection-reasons-dialog.tsx | 240 ++++++++++++ resources/js/pages/commit/show.tsx | 32 +- resources/js/pages/moderation/decisions.tsx | 64 +++ resources/js/pages/moderation/show.tsx | 363 ++++++++++++++++-- resources/js/types/index.ts | 1 + resources/js/types/moderation.ts | 26 ++ routes/web.php | 10 + .../CommitApprovalControllerTest.php | 47 +++ .../Controllers/CommitClaimControllerTest.php | 110 ++++++ .../Controllers/CommitControllerTest.php | 36 ++ .../CommitDecisionControllerTest.php | 64 +++ .../CommitRejectionControllerTest.php | 33 ++ .../Controllers/ModerationControllerTest.php | 68 ++++ .../Controllers/ModerationUpdatesTest.php | 34 +- .../RejectionReasonControllerTest.php | 95 +++++ tests/Unit/Actions/UpdateCommitClaimTest.php | 56 +++ tests/Unit/Actions/UpdateCommitStatusTest.php | 9 +- .../Unit/Actions/UpdateCommitStatusesTest.php | 53 ++- .../Unit/Queries/NotificationBuilderTest.php | 5 +- 57 files changed, 2286 insertions(+), 83 deletions(-) create mode 100644 app/Actions/CreateRejectionReason.php create mode 100644 app/Actions/DeleteRejectionReason.php create mode 100644 app/Actions/UpdateCommitClaim.php create mode 100644 app/Actions/UpdateRejectionReason.php create mode 100644 app/Http/Controllers/CommitClaimController.php create mode 100644 app/Http/Controllers/CommitDecisionController.php create mode 100644 app/Http/Controllers/RejectionReasonController.php create mode 100644 app/Http/Requests/CreateRejectionReasonRequest.php create mode 100644 app/Http/Requests/DeleteRejectionReasonRequest.php create mode 100644 app/Http/Requests/ListCommitDecisionsRequest.php create mode 100644 app/Http/Requests/UpdateCommitClaimRequest.php create mode 100644 app/Http/Requests/UpdateRejectionReasonRequest.php create mode 100644 app/Models/CommitClaim.php create mode 100644 app/Models/CommitDecision.php create mode 100644 app/Models/RejectionReason.php create mode 100644 app/Queries/CommitClaimBuilder.php create mode 100644 app/Queries/CommitDecisionsQuery.php create mode 100644 app/Rules/Unclaimed.php create mode 100644 database/factories/CommitClaimFactory.php create mode 100644 database/factories/CommitDecisionFactory.php create mode 100644 database/factories/RejectionReasonFactory.php create mode 100644 database/migrations/2026_10_15_233856_create_rejection_reasons_table.php create mode 100644 database/migrations/2026_10_15_233857_create_commit_claims_table.php create mode 100644 database/migrations/2026_10_15_233858_create_commit_decisions_table.php create mode 100644 resources/js/components/commit-decision-item.tsx create mode 100644 resources/js/components/rejection-reasons-dialog.tsx create mode 100644 resources/js/pages/moderation/decisions.tsx create mode 100644 resources/js/types/moderation.ts create mode 100644 tests/Feature/Controllers/CommitClaimControllerTest.php create mode 100644 tests/Feature/Controllers/CommitDecisionControllerTest.php create mode 100644 tests/Feature/Controllers/RejectionReasonControllerTest.php create mode 100644 tests/Unit/Actions/UpdateCommitClaimTest.php diff --git a/PRODUCT.md b/PRODUCT.md index b7738ad78..e62fabfaf 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -36,6 +36,7 @@ Identity is GitHub. Every account is a real GitHub user, and the profile (avatar - Mentions: `@username` in a commit or reply mentions that developer and links to their profile. Typing `@` in either composer suggests developers (people you follow on a bare `@`, then matches by name or username, people you follow first); ↑↓ to move, ↵ or Tab to pick, Esc to dismiss. Any number of people can be mentioned in one commit. Emails, scoped packages (`@inertiajs/react`) and domains (`@vue.js`) are not mentions. - Notifications: in-app only, for a new follower, for an approved reply to one of your commits (only the direct parent's author; never for replying to yourself), and for an approved commit or reply that mentions you (never for mentioning yourself; the parent's author of a reply hears about it as a reply, not twice). A notification points to the follow, reply, or mention, it never copies it: it disappears when the commit, the replied commit, or either user is deleted, and is hidden while the follow is undone or the commit is rejected. A follower notifies once, ever: unfollowing and following again never pings twice. Pending replies and mentions notify nobody until approved. No notification for moderation outcomes. Unread count in the nav; a page is marked as read once shown. - People search: find developers by name or GitHub username (⌘K). Commits are not searchable. +- Moderation queue (admins only): admins approve or reject pending commits, oldest first. Rejection reasons are shared templates every admin can create, edit, and delete; picking one (click, or `1`–`9` on the focused commit) prefills the editable reason. Focusing or acting on a commit claims it for 5 minutes, one claim per admin; other admins see who holds it and cannot approve or reject it until it expires or they take it over explicitly. Every approval and rejection is logged with the admin and time, listed newest first under Decisions and shown on the commit's page to admins. - Deliberately excluded for now: likes/reactions, reposts, images/media, hashtags, searching commits, DMs, other OAuth providers, public API, moderation tools, blocking/muting, header/banner images. Future work must not design for these as if they exist. - Open: handling GitHub username renames that collide with a stale username. diff --git a/app/Actions/CreateRejectionReason.php b/app/Actions/CreateRejectionReason.php new file mode 100644 index 000000000..8e4f993a7 --- /dev/null +++ b/app/Actions/CreateRejectionReason.php @@ -0,0 +1,18 @@ +create([ + 'title' => $title, + 'body' => $body, + ]); + } +} diff --git a/app/Actions/DeleteRejectionReason.php b/app/Actions/DeleteRejectionReason.php new file mode 100644 index 000000000..99421eb9f --- /dev/null +++ b/app/Actions/DeleteRejectionReason.php @@ -0,0 +1,15 @@ +delete(); + } +} diff --git a/app/Actions/UpdateCommitClaim.php b/app/Actions/UpdateCommitClaim.php new file mode 100644 index 000000000..899ee428d --- /dev/null +++ b/app/Actions/UpdateCommitClaim.php @@ -0,0 +1,33 @@ +where('user_id', $user->id) + ->orWhere('commit_id', $commit->id) + ->delete(); + + return CommitClaim::query()->create([ + 'commit_id' => $commit->id, + 'user_id' => $user->id, + 'expires_at' => now()->addMinutes(CommitClaim::MINUTES), + ]); + }); + } +} diff --git a/app/Actions/UpdateCommitStatus.php b/app/Actions/UpdateCommitStatus.php index 930a481b5..291c34c36 100644 --- a/app/Actions/UpdateCommitStatus.php +++ b/app/Actions/UpdateCommitStatus.php @@ -6,6 +6,7 @@ use App\Enums\CommitStatus; use App\Models\Commit; +use App\Models\User; final readonly class UpdateCommitStatus { @@ -14,8 +15,8 @@ public function __construct(private UpdateCommitStatuses $updateCommitStatuses) // } - public function handle(Commit $commit, CommitStatus $status, ?string $reason = null): void + public function handle(User $moderator, Commit $commit, CommitStatus $status, ?string $reason = null): void { - $this->updateCommitStatuses->handle([$commit->id], $status, $reason); + $this->updateCommitStatuses->handle($moderator, [$commit->id], $status, $reason); } } diff --git a/app/Actions/UpdateCommitStatuses.php b/app/Actions/UpdateCommitStatuses.php index 8d0925489..3be3976f7 100644 --- a/app/Actions/UpdateCommitStatuses.php +++ b/app/Actions/UpdateCommitStatuses.php @@ -7,7 +7,10 @@ use App\Enums\CommitStatus; use App\Enums\NotificationType; use App\Models\Commit; +use App\Models\CommitClaim; +use App\Models\CommitDecision; use App\Models\Notification; +use App\Models\User; use App\Queries\CommitBuilder; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -22,13 +25,17 @@ public function __construct( } /** + * Records the moderator's decision about each commit, and releases the claims on them. + * * @param array $commitIds */ - public function handle(array $commitIds, CommitStatus $status, ?string $reason = null, bool $onlyPending = false): void + public function handle(User $moderator, array $commitIds, CommitStatus $status, ?string $reason = null, bool $onlyPending = false): void { assert($status !== CommitStatus::Pending); - DB::transaction(function () use ($commitIds, $status, $reason, $onlyPending): void { + $reason = $status === CommitStatus::Rejected ? $reason : null; + + DB::transaction(function () use ($moderator, $commitIds, $status, $reason, $onlyPending): void { $commits = Commit::query() ->whereKey($commitIds) ->when($onlyPending, fn (CommitBuilder $query): CommitBuilder => $query->pending()) @@ -43,6 +50,17 @@ public function handle(array $commitIds, CommitStatus $status, ?string $reason = Commit::query()->whereKey($commits->modelKeys())->update(['status' => $status]); + CommitDecision::query()->insert($commits->map(fn (Commit $commit): array => [ + 'commit_id' => $commit->id, + 'user_id' => $moderator->id, + 'status' => $status->value, + 'reason' => $reason, + 'created_at' => now(), + 'updated_at' => now(), + ])->all()); + + CommitClaim::query()->whereIn('commit_id', $commits->modelKeys())->delete(); + Notification::query() ->whereIn('commit_id', $commits->modelKeys()) ->whereIn('type', [NotificationType::Approved, NotificationType::Rejected]) @@ -55,7 +73,7 @@ public function handle(array $commitIds, CommitStatus $status, ?string $reason = 'type' => $status === CommitStatus::Approved ? NotificationType::Approved->value : NotificationType::Rejected->value, 'commit_id' => $commit->id, 'key' => 'moderated:'.$commit->id, - 'reason' => $status === CommitStatus::Rejected ? $reason : null, + 'reason' => $reason, 'created_at' => now(), 'updated_at' => now(), ])->all()); diff --git a/app/Actions/UpdateRejectionReason.php b/app/Actions/UpdateRejectionReason.php new file mode 100644 index 000000000..2583b7fc1 --- /dev/null +++ b/app/Actions/UpdateRejectionReason.php @@ -0,0 +1,18 @@ +update([ + 'title' => $title, + 'body' => $body, + ]); + } +} diff --git a/app/Http/Controllers/CommitApprovalController.php b/app/Http/Controllers/CommitApprovalController.php index f977134b2..00ca60d71 100644 --- a/app/Http/Controllers/CommitApprovalController.php +++ b/app/Http/Controllers/CommitApprovalController.php @@ -8,13 +8,15 @@ use App\Enums\CommitStatus; use App\Http\Requests\CreateCommitApprovalRequest; use App\Models\Commit; +use App\Models\User; +use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\RedirectResponse; final readonly class CommitApprovalController { - public function store(CreateCommitApprovalRequest $request, Commit $commit, UpdateCommitStatus $action): RedirectResponse + public function store(CreateCommitApprovalRequest $request, #[CurrentUser] User $user, Commit $commit, UpdateCommitStatus $action): RedirectResponse { - $action->handle($commit, CommitStatus::Approved); + $action->handle($user, $commit, CommitStatus::Approved); return back(); } diff --git a/app/Http/Controllers/CommitClaimController.php b/app/Http/Controllers/CommitClaimController.php new file mode 100644 index 000000000..7cfae88a1 --- /dev/null +++ b/app/Http/Controllers/CommitClaimController.php @@ -0,0 +1,22 @@ +handle($user, $commit); + + return back(); + } +} diff --git a/app/Http/Controllers/CommitController.php b/app/Http/Controllers/CommitController.php index c99fed9a9..d8c29ad29 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -39,6 +39,7 @@ public function show(ShowCommitRequest $request, #[CurrentUser] ?User $currentUs 'commit' => $commit, 'ancestors' => $ancestors->get($commit, $currentUser), 'replies' => $replies->paginate($commit, $currentUser), + 'decisions' => $request->showsDecisions() ? $commit->decisions()->with('user')->latest('id')->get() : [], ]); } diff --git a/app/Http/Controllers/CommitDecisionController.php b/app/Http/Controllers/CommitDecisionController.php new file mode 100644 index 000000000..4e5d18ac3 --- /dev/null +++ b/app/Http/Controllers/CommitDecisionController.php @@ -0,0 +1,21 @@ + Inertia::scroll(fn (): CursorPaginator => $decisions->paginate()), + ]); + } +} diff --git a/app/Http/Controllers/CommitRejectionController.php b/app/Http/Controllers/CommitRejectionController.php index ab9aa1111..bae676780 100644 --- a/app/Http/Controllers/CommitRejectionController.php +++ b/app/Http/Controllers/CommitRejectionController.php @@ -8,16 +8,18 @@ use App\Enums\CommitStatus; use App\Http\Requests\CreateCommitRejectionRequest; use App\Models\Commit; +use App\Models\User; +use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\RedirectResponse; final readonly class CommitRejectionController { - public function store(CreateCommitRejectionRequest $request, Commit $commit, UpdateCommitStatus $action): RedirectResponse + public function store(CreateCommitRejectionRequest $request, #[CurrentUser] User $user, Commit $commit, UpdateCommitStatus $action): RedirectResponse { /** @var string|null $reason */ $reason = $request->validated('reason'); - $action->handle($commit, CommitStatus::Rejected, $reason); + $action->handle($user, $commit, CommitStatus::Rejected, $reason); return back(); } diff --git a/app/Http/Controllers/ModerationController.php b/app/Http/Controllers/ModerationController.php index 6f764161d..f1c7c0947 100644 --- a/app/Http/Controllers/ModerationController.php +++ b/app/Http/Controllers/ModerationController.php @@ -9,6 +9,8 @@ use App\Http\Requests\ShowModerationRequest; use App\Http\Requests\UpdateModerationRequest; use App\Models\Commit; +use App\Models\CommitClaim; +use App\Models\RejectionReason; use App\Models\User; use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\RedirectResponse; @@ -28,18 +30,20 @@ public function show(ShowModerationRequest $request, #[CurrentUser] User $user): ->oldest() ->oldest('id') ->cursorPaginate(20)), + 'rejectionReasons' => fn () => RejectionReason::query()->orderBy('title')->get(), + 'claims' => fn () => CommitClaim::query()->active()->with('user')->get()->keyBy('commit_id'), ]); } /** * Approves the selected commits that are still in the queue. */ - public function update(UpdateModerationRequest $request, UpdateCommitStatuses $action): RedirectResponse + public function update(UpdateModerationRequest $request, #[CurrentUser] User $user, UpdateCommitStatuses $action): RedirectResponse { /** @var array $commitIds */ $commitIds = $request->validated('commits'); - $action->handle($commitIds, CommitStatus::Approved, onlyPending: true); + $action->handle($user, $commitIds, CommitStatus::Approved, onlyPending: true); return back(); } diff --git a/app/Http/Controllers/RejectionReasonController.php b/app/Http/Controllers/RejectionReasonController.php new file mode 100644 index 000000000..1062a17d3 --- /dev/null +++ b/app/Http/Controllers/RejectionReasonController.php @@ -0,0 +1,38 @@ +handle($request->string('title')->value(), $request->string('body')->value()); + + return back(); + } + + public function update(UpdateRejectionReasonRequest $request, RejectionReason $rejectionReason, UpdateRejectionReason $action): RedirectResponse + { + $action->handle($rejectionReason, $request->string('title')->value(), $request->string('body')->value()); + + return back(); + } + + public function destroy(DeleteRejectionReasonRequest $request, RejectionReason $rejectionReason, DeleteRejectionReason $action): RedirectResponse + { + $action->handle($rejectionReason); + + return back(); + } +} diff --git a/app/Http/Requests/CreateCommitApprovalRequest.php b/app/Http/Requests/CreateCommitApprovalRequest.php index 65dd2473c..240fe7d61 100644 --- a/app/Http/Requests/CreateCommitApprovalRequest.php +++ b/app/Http/Requests/CreateCommitApprovalRequest.php @@ -4,7 +4,10 @@ namespace App\Http\Requests; +use App\Models\Commit; use App\Models\User; +use App\Rules\Unclaimed; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; final class CreateCommitApprovalRequest extends FormRequest @@ -18,10 +21,26 @@ public function authorize(): bool } /** - * @return array{} + * @return array> */ public function rules(): array { - return []; + $user = $this->user(); + assert($user instanceof User); + + return [ + 'commit' => [new Unclaimed($user)], + ]; + } + + /** + * @return array + */ + public function validationData(): array + { + $commit = $this->route('commit'); + assert($commit instanceof Commit); + + return [...$this->all(), 'commit' => $commit->id]; } } diff --git a/app/Http/Requests/CreateCommitRejectionRequest.php b/app/Http/Requests/CreateCommitRejectionRequest.php index 70a261930..cff28d484 100644 --- a/app/Http/Requests/CreateCommitRejectionRequest.php +++ b/app/Http/Requests/CreateCommitRejectionRequest.php @@ -4,7 +4,10 @@ namespace App\Http\Requests; +use App\Models\Commit; use App\Models\User; +use App\Rules\Unclaimed; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; final class CreateCommitRejectionRequest extends FormRequest @@ -18,12 +21,27 @@ public function authorize(): bool } /** - * @return array> + * @return array> */ public function rules(): array { + $user = $this->user(); + assert($user instanceof User); + return [ 'reason' => ['nullable', 'string', 'max:280'], + 'commit' => [new Unclaimed($user)], ]; } + + /** + * @return array + */ + public function validationData(): array + { + $commit = $this->route('commit'); + assert($commit instanceof Commit); + + return [...$this->all(), 'commit' => $commit->id]; + } } diff --git a/app/Http/Requests/CreateRejectionReasonRequest.php b/app/Http/Requests/CreateRejectionReasonRequest.php new file mode 100644 index 000000000..a773343a1 --- /dev/null +++ b/app/Http/Requests/CreateRejectionReasonRequest.php @@ -0,0 +1,30 @@ +user(); + assert($user instanceof User); + + return $user->isAdmin(); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:40'], + 'body' => ['required', 'string', 'max:280'], + ]; + } +} diff --git a/app/Http/Requests/DeleteRejectionReasonRequest.php b/app/Http/Requests/DeleteRejectionReasonRequest.php new file mode 100644 index 000000000..0f911ad73 --- /dev/null +++ b/app/Http/Requests/DeleteRejectionReasonRequest.php @@ -0,0 +1,27 @@ +user(); + assert($user instanceof User); + + return $user->isAdmin(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/ListCommitDecisionsRequest.php b/app/Http/Requests/ListCommitDecisionsRequest.php new file mode 100644 index 000000000..c48a5fead --- /dev/null +++ b/app/Http/Requests/ListCommitDecisionsRequest.php @@ -0,0 +1,27 @@ +user(); + assert($user instanceof User); + + return $user->isAdmin(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/ShowCommitRequest.php b/app/Http/Requests/ShowCommitRequest.php index c725708da..55e3f6472 100644 --- a/app/Http/Requests/ShowCommitRequest.php +++ b/app/Http/Requests/ShowCommitRequest.php @@ -36,4 +36,9 @@ public function rules(): array { return []; } + + public function showsDecisions(): bool + { + return $this->user()?->isAdmin() ?? false; + } } diff --git a/app/Http/Requests/UpdateCommitClaimRequest.php b/app/Http/Requests/UpdateCommitClaimRequest.php new file mode 100644 index 000000000..9413531c7 --- /dev/null +++ b/app/Http/Requests/UpdateCommitClaimRequest.php @@ -0,0 +1,60 @@ +user(); + assert($user instanceof User); + + $commit = $this->route('commit'); + assert($commit instanceof Commit); + + if (! $user->isAdmin()) { + return Response::deny(); + } + + return $commit->status === CommitStatus::Pending ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * Another admin's claim is only taken over when asked to. + * + * @return array> + */ + public function rules(): array + { + $user = $this->user(); + assert($user instanceof User); + + return [ + 'take_over' => ['boolean'], + 'commit' => [Rule::when(! $this->boolean('take_over'), [new Unclaimed($user)])], + ]; + } + + /** + * @return array + */ + public function validationData(): array + { + $commit = $this->route('commit'); + assert($commit instanceof Commit); + + return [...$this->all(), 'commit' => $commit->id]; + } +} diff --git a/app/Http/Requests/UpdateModerationRequest.php b/app/Http/Requests/UpdateModerationRequest.php index b708fdf2c..280257205 100644 --- a/app/Http/Requests/UpdateModerationRequest.php +++ b/app/Http/Requests/UpdateModerationRequest.php @@ -5,6 +5,8 @@ namespace App\Http\Requests; use App\Models\User; +use App\Rules\Unclaimed; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; final class UpdateModerationRequest extends FormRequest @@ -18,13 +20,16 @@ public function authorize(): bool } /** - * @return array> + * @return array> */ public function rules(): array { + $user = $this->user(); + assert($user instanceof User); + return [ 'commits' => ['required', 'array', 'max:100'], - 'commits.*' => ['required', 'uuid', 'distinct'], + 'commits.*' => ['required', 'uuid', 'distinct', new Unclaimed($user)], ]; } } diff --git a/app/Http/Requests/UpdateRejectionReasonRequest.php b/app/Http/Requests/UpdateRejectionReasonRequest.php new file mode 100644 index 000000000..06b3e756a --- /dev/null +++ b/app/Http/Requests/UpdateRejectionReasonRequest.php @@ -0,0 +1,30 @@ +user(); + assert($user instanceof User); + + return $user->isAdmin(); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:40'], + 'body' => ['required', 'string', 'max:280'], + ]; + } +} diff --git a/app/Models/Commit.php b/app/Models/Commit.php index d71ba28cf..246cc0061 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -39,6 +39,7 @@ * @property-read bool $is_saved * @property-read LinkPreview|null $linkPreview * @property-read Collection $images + * @property-read Collection $decisions */ #[UseEloquentBuilder(CommitBuilder::class)] final class Commit extends Model @@ -130,6 +131,14 @@ public function bookmarks(): HasMany return $this->hasMany(Bookmark::class); } + /** + * @return HasMany + */ + public function decisions(): HasMany + { + return $this->hasMany(CommitDecision::class); + } + /** * @return BelongsTo */ diff --git a/app/Models/CommitClaim.php b/app/Models/CommitClaim.php new file mode 100644 index 000000000..4da90d397 --- /dev/null +++ b/app/Models/CommitClaim.php @@ -0,0 +1,69 @@ + */ + use HasFactory; + + public const int MINUTES = 5; + + /** + * @return array + */ + public function casts(): array + { + return [ + 'id' => 'integer', + 'commit_id' => 'string', + 'user_id' => 'string', + 'expires_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function commit(): BelongsTo + { + return $this->belongsTo(Commit::class); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/CommitDecision.php b/app/Models/CommitDecision.php new file mode 100644 index 000000000..db6c52dc4 --- /dev/null +++ b/app/Models/CommitDecision.php @@ -0,0 +1,70 @@ + */ + use HasFactory; + + /** + * @return array + */ + public function casts(): array + { + return [ + 'id' => 'integer', + 'commit_id' => 'string', + 'user_id' => 'string', + 'status' => CommitStatus::class, + 'reason' => 'string', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function commit(): BelongsTo + { + return $this->belongsTo(Commit::class)->withTrashed(); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/RejectionReason.php b/app/Models/RejectionReason.php new file mode 100644 index 000000000..a08b1b013 --- /dev/null +++ b/app/Models/RejectionReason.php @@ -0,0 +1,43 @@ + */ + use HasFactory; + + /** + * @return array + */ + public function casts(): array + { + return [ + 'id' => 'integer', + 'title' => 'string', + 'body' => 'string', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } +} diff --git a/app/Queries/CommitClaimBuilder.php b/app/Queries/CommitClaimBuilder.php new file mode 100644 index 000000000..8bff20ec8 --- /dev/null +++ b/app/Queries/CommitClaimBuilder.php @@ -0,0 +1,19 @@ + + */ +final class CommitClaimBuilder extends Builder +{ + public function active(): self + { + return $this->where($this->qualifyColumn('expires_at'), '>', now()); + } +} diff --git a/app/Queries/CommitDecisionsQuery.php b/app/Queries/CommitDecisionsQuery.php new file mode 100644 index 000000000..f639b7dc3 --- /dev/null +++ b/app/Queries/CommitDecisionsQuery.php @@ -0,0 +1,22 @@ + + */ + public function paginate(): CursorPaginator + { + return CommitDecision::query() + ->with(['user', 'commit.user']) + ->latest('id') + ->cursorPaginate(30); + } +} diff --git a/app/Rules/Unclaimed.php b/app/Rules/Unclaimed.php new file mode 100644 index 000000000..d6871aee5 --- /dev/null +++ b/app/Rules/Unclaimed.php @@ -0,0 +1,35 @@ +active() + ->where('commit_id', $value) + ->whereNot('user_id', $this->user->id) + ->with('user') + ->first(); + + if ($claim instanceof CommitClaim) { + $fail(__('@:username is reviewing this commit.', ['username' => $claim->user->username])); + } + } +} diff --git a/database/factories/CommitClaimFactory.php b/database/factories/CommitClaimFactory.php new file mode 100644 index 000000000..8533beb0b --- /dev/null +++ b/database/factories/CommitClaimFactory.php @@ -0,0 +1,35 @@ + + */ +final class CommitClaimFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'commit_id' => Commit::factory()->pending(), + 'user_id' => User::factory(), + 'expires_at' => now()->addMinutes(CommitClaim::MINUTES), + ]; + } + + public function expired(): self + { + return $this->state(fn (array $attributes): array => [ + 'expires_at' => now()->subSecond(), + ]); + } +} diff --git a/database/factories/CommitDecisionFactory.php b/database/factories/CommitDecisionFactory.php new file mode 100644 index 000000000..d514b8ff9 --- /dev/null +++ b/database/factories/CommitDecisionFactory.php @@ -0,0 +1,30 @@ + + */ +final class CommitDecisionFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'commit_id' => Commit::factory(), + 'user_id' => User::factory(), + 'status' => CommitStatus::Approved, + 'reason' => null, + ]; + } +} diff --git a/database/factories/RejectionReasonFactory.php b/database/factories/RejectionReasonFactory.php new file mode 100644 index 000000000..7db58f710 --- /dev/null +++ b/database/factories/RejectionReasonFactory.php @@ -0,0 +1,25 @@ + + */ +final class RejectionReasonFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'title' => fake()->words(2, asText: true), + 'body' => fake()->sentence(), + ]; + } +} diff --git a/database/migrations/2026_10_15_233856_create_rejection_reasons_table.php b/database/migrations/2026_10_15_233856_create_rejection_reasons_table.php new file mode 100644 index 000000000..e25cf66fe --- /dev/null +++ b/database/migrations/2026_10_15_233856_create_rejection_reasons_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('title', 40); + $table->string('body', 280); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('rejection_reasons'); + } +}; diff --git a/database/migrations/2026_10_15_233857_create_commit_claims_table.php b/database/migrations/2026_10_15_233857_create_commit_claims_table.php new file mode 100644 index 000000000..efcb893ac --- /dev/null +++ b/database/migrations/2026_10_15_233857_create_commit_claims_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignUuid('commit_id')->unique()->constrained()->cascadeOnDelete(); + $table->foreignUuid('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->timestamp('expires_at')->index(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('commit_claims'); + } +}; diff --git a/database/migrations/2026_10_15_233858_create_commit_decisions_table.php b/database/migrations/2026_10_15_233858_create_commit_decisions_table.php new file mode 100644 index 000000000..cb1e10e40 --- /dev/null +++ b/database/migrations/2026_10_15_233858_create_commit_decisions_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignUuid('commit_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('status'); + $table->string('reason', 280)->nullable(); + $table->timestamps(); + + $table->index(['commit_id', 'id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('commit_decisions'); + } +}; diff --git a/resources/js/components/commit-decision-item.tsx b/resources/js/components/commit-decision-item.tsx new file mode 100644 index 000000000..2858dd3c6 --- /dev/null +++ b/resources/js/components/commit-decision-item.tsx @@ -0,0 +1,137 @@ +import { Link } from '@inertiajs/react'; +import { Check, X } from 'lucide-react'; +import CommitController from '@/actions/App/Http/Controllers/CommitController'; +import { UserAvatar } from '@/components/user-avatar'; +import { useNow } from '@/hooks/use-now'; +import { + cn, + formatFullDateTime, + formatRelativeTime, + isDeleted, +} from '@/lib/utils'; +import type { CommitDecision, LoggedCommitDecision } from '@/types'; + +function DecisionIcon({ decision }: { decision: CommitDecision }) { + const Icon = decision.status === 'approved' ? Check : X; + + return ( + + ); +} + +function DecisionTime({ decision }: { decision: CommitDecision }) { + const now = useNow(); + + return ( + + ); +} + +function Moderator({ decision }: { decision: CommitDecision }) { + return decision.user ? ( + + @ + {decision.user.username} + + ) : ( + A former admin + ); +} + +function Reason({ decision }: { decision: CommitDecision }) { + return decision.reason ? ( +

+ Reason: + {decision.reason} +

+ ) : null; +} + +export function CommitDecisionSummary({ + decision, +}: { + decision: CommitDecision; +}) { + return ( +
  • + +
    +

    + {decision.status === 'approved' ? 'Approved' : 'Rejected'}{' '} + by + + · + + +

    + +
    +
  • + ); +} + +export function CommitDecisionItem({ + decision, +}: { + decision: LoggedCommitDecision; +}) { + const { commit } = decision; + + return ( +
  • + +
    +

    + {decision.status}{' '} + {isDeleted(commit) ? ( + 'a commit that was since deleted' + ) : ( + <> + + @{commit.user.username} + + ’s commit + + )} + + · + + +

    + {!isDeleted(commit) && + (commit.status === 'approved' ? ( + + {commit.body} + + ) : ( +

    + {commit.body} +

    + ))} + +
    +
  • + ); +} diff --git a/resources/js/components/keyboard-shortcuts-dialog.tsx b/resources/js/components/keyboard-shortcuts-dialog.tsx index 8f373eada..44068b3d4 100644 --- a/resources/js/components/keyboard-shortcuts-dialog.tsx +++ b/resources/js/components/keyboard-shortcuts-dialog.tsx @@ -28,6 +28,8 @@ const shortcuts: Shortcut[] = [ const moderationShortcuts: Shortcut[] = [ { keys: ['a'], label: 'Approve commit (in the queue)' }, { keys: ['x'], label: 'Reject commit (in the queue)' }, + { keys: ['1–9'], label: 'Use a rejection reason (in the queue)' }, + { keys: ['⌘', '↵'], label: 'Reject with the reason (in the queue)' }, ]; function ShortcutList({ shortcuts }: { shortcuts: Shortcut[] }) { diff --git a/resources/js/components/rejection-reasons-dialog.tsx b/resources/js/components/rejection-reasons-dialog.tsx new file mode 100644 index 000000000..95228efc7 --- /dev/null +++ b/resources/js/components/rejection-reasons-dialog.tsx @@ -0,0 +1,240 @@ +import { Form } from '@inertiajs/react'; +import { Pencil, Plus, Trash2 } from 'lucide-react'; +import { useId, useState } from 'react'; +import RejectionReasonController from '@/actions/App/Http/Controllers/RejectionReasonController'; +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import type { RejectionReason } from '@/types'; + +function RejectionReasonFields({ + reason, + errors, +}: { + reason?: RejectionReason; + errors: Partial>; +}) { + const id = useId(); + + return ( +
    +
    + + + +
    +
    + +