diff --git a/PRODUCT.md b/PRODUCT.md index d7c7352fe..ff68c1965 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -49,6 +49,7 @@ Identity is GitHub. Every account is a real GitHub user, and the profile (avatar - Links: URLs and bare domains in a commit or reply link out. Like X, a long one shows shortened: no `https://` or `www.`, the whole host, then at most 20 characters ending in `…`. Hovering shows the full URL, and copying the text gives it. Links in code stay as written. Link previews (title text and image) shorten them the same way. - 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), for an approved quote of one of your commits (never for quoting yourself), and for an approved commit or reply that mentions you (never for mentioning yourself; the parent's author of a reply or the quoted author of a quote hears about it as a reply or quote, not twice). A notification points to the follow, reply, quote, or mention, it never copies it: it disappears when the commit, the commit it replies to or quotes, 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, quotes, and mentions notify nobody until approved. No notification for moderation outcomes. A new follower can be followed back, or unfollowed, right from the list. 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 admin approval and rejection is logged with the admin and time, listed newest first under Decisions and shown on the commit's page to admins. - Rate limits, per user: stars and saves 60/min each, pins 20/min, follows 30/min, people lookups (search, mention suggestions, hover cards) 120/min combined, exports 3/hour. Throttled actions keep the page and show a toast; throttled pages show the 429 page. - Public read API and Atom feeds: read-only, so nothing can be created, starred, or followed through them, and there are no API tokens. Lists are cursor-paginated, 20 per page. - Deliberately excluded for now: likes/reactions, reposts without a comment of your own (quotes are in), images/media, hashtags, searching commits, DMs, other OAuth providers, a write API or API tokens, moderation tools, blocking/muting, header/banner images. Future work must not design for these as if they exist. 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/UpdateCommitAiDecision.php b/app/Actions/UpdateCommitAiDecision.php index 4fd653746..c707058d0 100644 --- a/app/Actions/UpdateCommitAiDecision.php +++ b/app/Actions/UpdateCommitAiDecision.php @@ -53,7 +53,7 @@ public function handle(Commit $commit): void ]); if ($scores !== [] && max($scores) >= self::AUTO_REJECT_THRESHOLD) { - $this->updateCommitStatuses->handle([$commit->id], CommitStatus::Rejected, onlyPending: true); + $this->updateCommitStatuses->handle(null, [$commit->id], CommitStatus::Rejected, onlyPending: true); } } } diff --git a/app/Actions/UpdateCommitClaim.php b/app/Actions/UpdateCommitClaim.php new file mode 100644 index 000000000..f4ff71ee9 --- /dev/null +++ b/app/Actions/UpdateCommitClaim.php @@ -0,0 +1,41 @@ +id]; + + if ($commit->series_id !== null) { + $parts = Commit::query()->where('series_id', $commit->series_id); + $commitIds = $parts->pluck('id')->all(); + $commit = $parts->pending()->oldest('series_position')->firstOrFail(); + } + + CommitClaim::query() + ->where('user_id', $user->id) + ->orWhereIn('commit_id', $commitIds) + ->delete(); + + return CommitClaim::query()->create([ + 'commit_id' => $commit->id, + 'user_id' => $user->id, + 'expires_at' => now()->addMinutes(CommitClaim::MINUTES), + ]); + }); + } +} diff --git a/app/Actions/UpdateCommitEmbedding.php b/app/Actions/UpdateCommitEmbedding.php deleted file mode 100644 index dcaf5c1ca..000000000 --- a/app/Actions/UpdateCommitEmbedding.php +++ /dev/null @@ -1,25 +0,0 @@ -update(['embedding' => $this->openAI->embed($commit->body)]); - } -} 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 d4f50a0c3..ea4b1cc98 100644 --- a/app/Actions/UpdateCommitStatuses.php +++ b/app/Actions/UpdateCommitStatuses.php @@ -7,8 +7,11 @@ 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\Poll; +use App\Models\User; use App\Queries\CommitBuilder; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -26,15 +29,17 @@ public function __construct( } /** - * A series is moderated as a whole, so no part is left hanging off a part nobody else can see. Its author hears about it once. + * Moderates a series as a whole and releases its claims. Human decisions are logged; automated rejections have no moderator. * * @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() ->where(fn (CommitBuilder $query): CommitBuilder => $query ->whereKey($commitIds) @@ -51,6 +56,19 @@ public function handle(array $commitIds, CommitStatus $status, ?string $reason = Commit::query()->whereKey($commits->modelKeys())->update(['status' => $status]); + if ($moderator instanceof User) { + 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]) 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/Console/Commands/EmbedCommitsCommand.php b/app/Console/Commands/EmbedCommitsCommand.php deleted file mode 100644 index 7cd592f8b..000000000 --- a/app/Console/Commands/EmbedCommitsCommand.php +++ /dev/null @@ -1,61 +0,0 @@ -commits()->chunkById(self::BATCH_SIZE, function (Collection $commits) use ($openAI, &$embedded): void { - $this->embed($openAI, $commits); - - $embedded += $commits->count(); - }); - - $this->components->info("Embedded {$embedded} commits."); - - return self::SUCCESS; - } - - /** - * Deleted commits are left out, but pending and rejected ones are embedded too: nothing re-embeds a commit once it - * is approved, and a pending commit may still become searchable. - * - * @return Builder - */ - private function commits(): Builder - { - return Commit::query() - ->unless($this->option('all'), fn (Builder $query): Builder => $query->whereNull('embedding')); - } - - /** - * One request embeds the whole batch, so the API is called once per hundred commits rather than once per commit. - * - * @param Collection $commits - */ - private function embed(OpenAI $openAI, Collection $commits): void - { - $embeddings = $openAI->embedMany(array_values($commits->map(fn (Commit $commit): string => $commit->body)->all())); - - $commits->each(function (Commit $commit, int $index) use ($embeddings): void { - $commit->update(['embedding' => $embeddings[$index]]); - }); - } -} 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 4da8eb2a6..f847ad82e 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -49,6 +49,7 @@ public function show(ShowCommitRequest $request, #[CurrentUser] ?User $currentUs 'ancestors' => $isInSeries ? $series->before($commit, $currentUser) : $ancestors->get($commit, $currentUser), 'series' => $series->after($commit, $currentUser), 'replies' => $threadReplies, + 'decisions' => $request->showsDecisions() ? $commit->decisions()->with('user')->latest('id')->get() : [], ])->withViewData(['preview' => $preview]); } 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/CommitSearchController.php b/app/Http/Controllers/CommitSearchController.php deleted file mode 100644 index 0f8eaea4b..000000000 --- a/app/Http/Controllers/CommitSearchController.php +++ /dev/null @@ -1,21 +0,0 @@ -json([ - 'commits' => $search->get($request->string('q')->value(), $user, $request->limit()), - ]); - } -} diff --git a/app/Http/Controllers/ModerationController.php b/app/Http/Controllers/ModerationController.php index 5dd81232f..fe83f7ddb 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\Database\Eloquent\Relations\BelongsTo; @@ -43,18 +45,20 @@ public function show(ShowModerationRequest $request, #[CurrentUser] User $user): ->oldest('id') ->cursorPaginate(20) ->through($this->withAiDecision(...))), + '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/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php deleted file mode 100644 index 2c293ec64..000000000 --- a/app/Http/Controllers/SearchController.php +++ /dev/null @@ -1,22 +0,0 @@ - $request->term(), - ]); - } -} 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/ListSearchRequest.php b/app/Http/Requests/CreateRejectionReasonRequest.php similarity index 50% rename from app/Http/Requests/ListSearchRequest.php rename to app/Http/Requests/CreateRejectionReasonRequest.php index 7ead4b585..a773343a1 100644 --- a/app/Http/Requests/ListSearchRequest.php +++ b/app/Http/Requests/CreateRejectionReasonRequest.php @@ -4,13 +4,17 @@ namespace App\Http\Requests; +use App\Models\User; use Illuminate\Foundation\Http\FormRequest; -final class ListSearchRequest extends FormRequest +final class CreateRejectionReasonRequest extends FormRequest { public function authorize(): bool { - return true; + $user = $this->user(); + assert($user instanceof User); + + return $user->isAdmin(); } /** @@ -19,12 +23,8 @@ public function authorize(): bool public function rules(): array { return [ - 'q' => ['nullable', 'string', 'max:100'], + 'title' => ['required', 'string', 'max:40'], + 'body' => ['required', 'string', 'max:280'], ]; } - - public function term(): string - { - return $this->string('q')->trim()->value(); - } } 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/ListCommitSearchRequest.php b/app/Http/Requests/ListCommitSearchRequest.php deleted file mode 100644 index a26bfa202..000000000 --- a/app/Http/Requests/ListCommitSearchRequest.php +++ /dev/null @@ -1,38 +0,0 @@ -> - */ - public function rules(): array - { - return [ - 'q' => ['nullable', 'string', 'max:100'], - 'limit' => ['nullable', 'integer', 'min:1', 'max:'.self::MAX_LIMIT], - ]; - } - - /** - * The search dialog asks for a handful, the full list for a page. - */ - public function limit(): int - { - return $this->filled('limit') ? $this->integer('limit') : self::DEFAULT_LIMIT; - } -} diff --git a/app/Http/Requests/ShowCommitRequest.php b/app/Http/Requests/ShowCommitRequest.php index 7a08e1118..35dd20b57 100644 --- a/app/Http/Requests/ShowCommitRequest.php +++ b/app/Http/Requests/ShowCommitRequest.php @@ -30,4 +30,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/Jobs/EmbedCommit.php b/app/Jobs/EmbedCommit.php deleted file mode 100644 index d17461ab2..000000000 --- a/app/Jobs/EmbedCommit.php +++ /dev/null @@ -1,39 +0,0 @@ -commit->trashed()) { - return; - } - - $action->handle($this->commit); - } -} diff --git a/app/Models/Commit.php b/app/Models/Commit.php index 02249ec79..1abda0df3 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -53,6 +53,7 @@ * @property-read Commit|null $quotedCommit * @property-read Collection $quotes * @property-read Collection $images + * @property-read Collection $decisions * @property-read Collection $seriesParts */ #[Hidden([ @@ -197,6 +198,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/Queries/CommitSearchQuery.php b/app/Queries/CommitSearchQuery.php deleted file mode 100644 index 5b0d4f4d4..000000000 --- a/app/Queries/CommitSearchQuery.php +++ /dev/null @@ -1,96 +0,0 @@ - - */ - public function get(string $term, ?User $viewer, int $limit = 20): Collection - { - $term = mb_trim($term); - - if (mb_strlen($term) < self::MINIMUM_TERM_LENGTH) { - return new Collection(); - } - - $candidates = Commit::query() - ->visibleTo($viewer) - ->withoutMutedAuthorsFor($viewer) - ->whereNotNull('embedding') - ->withVisibleReplyCount($viewer) - ->withSavedState($viewer) - ->with(['user', 'linkPreview', 'images']) - ->withQuotedCommitFor($viewer) - ->withStarsFor($viewer) - ->orderByVectorDistance('embedding', $this->embedding($term)) - ->limit($limit) - ->get() - ->toBase(); - - return $this->onlyRelevant($term, $candidates); - } - - /** - * The vector search is generous: the nearest by meaning can include posts that merely share a word with the term. - * The decision model reads the term against each candidate and keeps only the ones actually about it, so the - * caller may get fewer than it asked for. - * - * @param Collection $candidates - * @return Collection - */ - private function onlyRelevant(string $term, Collection $candidates): Collection - { - if ($candidates->isEmpty()) { - return $candidates; - } - - $relevant = $this->openAI->decideRelevance($term, array_values($candidates->map(fn (Commit $commit): string => $commit->body)->all())); - - return $candidates - ->filter(fn (Commit $commit, int $index): bool => $relevant[$index] ?? true) - ->values(); - } - - /** - * @return list - */ - private function embedding(string $term): array - { - return Cache::remember( - 'commit-search:'.hash('xxh3', $term), - self::CACHE_SECONDS, - fn (): array => $this->openAI->embed($term), - ); - } -} diff --git a/app/Rules/Unclaimed.php b/app/Rules/Unclaimed.php new file mode 100644 index 000000000..8b695a57f --- /dev/null +++ b/app/Rules/Unclaimed.php @@ -0,0 +1,41 @@ +active() + ->whereIn('commit_id', Commit::query() + ->where(fn (CommitBuilder $query): CommitBuilder => $query + ->whereKey($value) + ->orWhereIn('series_id', Commit::query()->whereKey($value)->whereNotNull('series_id')->select('series_id'))) + ->select('id')) + ->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/database/migrations/2026_10_16_122908_add_embedding_to_commits_table.php b/database/migrations/2026_10_16_122908_add_embedding_to_commits_table.php deleted file mode 100644 index 388cd9a50..000000000 --- a/database/migrations/2026_10_16_122908_add_embedding_to_commits_table.php +++ /dev/null @@ -1,35 +0,0 @@ -vector('embedding', dimensions: 1536)->nullable(); - $table->vectorIndex('embedding', 'commits_embedding_index'); - }); - } - - /** - * Embeddings are derived data: dropping them costs the API calls to make them again, nothing more. - */ - public function down(): void - { - Schema::table('commits', function (Blueprint $table): void { - $table->dropVectorIndex('commits_embedding_index'); - $table->dropColumn('embedding'); - }); - } -}; diff --git a/resources/js/components/app-nav.tsx b/resources/js/components/app-nav.tsx index 7801fdcc2..c4b6dba61 100644 --- a/resources/js/components/app-nav.tsx +++ b/resources/js/components/app-nav.tsx @@ -101,9 +101,11 @@ function useNavItems(): (NavItem & { isActive: boolean })[] { user !== null && (item.title === 'Settings' ? isCurrentOrParentUrl('/settings') - : item.title === 'Log' - ? isCurrentOrParentUrl('/log') - : isCurrentUrl(item.href)), + : item.title === 'Moderation' + ? isCurrentOrParentUrl('/moderation') + : item.title === 'Log' + ? isCurrentOrParentUrl('/log') + : isCurrentUrl(item.href)), })); } diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index 45886bc47..c43d94fd9 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -50,6 +50,7 @@ export function CommitCard({ isDimmed = false, isPreviousDimmed = false, isHighlighted = false, + isRemoving = false, isGroupedWithPrevious = false, onAuthorHover, actions, @@ -69,6 +70,7 @@ export function CommitCard({ isDimmed?: boolean; isPreviousDimmed?: boolean; isHighlighted?: boolean; + isRemoving?: boolean; isGroupedWithPrevious?: boolean; onAuthorHover?: (isHovering: boolean) => void; actions?: ReactNode; @@ -130,7 +132,9 @@ export function CommitCard({ { 'bg-primary-soft/60': isHighlighted }, deletion === 'deleting' ? 'grid-rows-[0fr] opacity-0 delay-400' - : 'grid-rows-[1fr]', + : isRemoving + ? 'grid-rows-[0fr] opacity-0 ease-in-out' + : 'grid-rows-[1fr]', )} tabIndex={-1} data-commit={commit.id} diff --git a/resources/js/components/commit-decision-item.tsx b/resources/js/components/commit-decision-item.tsx new file mode 100644 index 000000000..3645d0919 --- /dev/null +++ b/resources/js/components/commit-decision-item.tsx @@ -0,0 +1,159 @@ +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} +

    + ))} + +
    +
  • + ); +} + +export function CommitDecisionSkeleton() { + return ( +
    + +
    +

    + + + ​ +

    +

    + + ​ +

    +
    +
    + ); +} diff --git a/resources/js/components/commit-feed.tsx b/resources/js/components/commit-feed.tsx index ae7c8ea1f..7a3664ba6 100644 --- a/resources/js/components/commit-feed.tsx +++ b/resources/js/components/commit-feed.tsx @@ -27,6 +27,7 @@ export function CommitFeed({ latestCommits = [], isRanked = false, interlude, + removingIds, }: { commits: CursorPaginated; empty: ReactNode; @@ -40,6 +41,7 @@ export function CommitFeed({ latestCommits?: Commit[]; isRanked?: boolean; interlude?: (lane: string, isDimmed: boolean) => ReactNode; + removingIds?: ReadonlySet; }) { const now = useNow(); const page = usePage(); @@ -185,6 +187,7 @@ export function CommitFeed({ highlightedPeriod !== null && commit.created_at.startsWith(highlightedPeriod) } + isRemoving={removingIds?.has(commit.id)} actions={actions?.(commit)} threadActions={threadActions?.(commit)} onAuthorHover={(isHovering) => diff --git a/resources/js/components/guest-composer.tsx b/resources/js/components/guest-composer.tsx deleted file mode 100644 index 55f0bdba5..000000000 --- a/resources/js/components/guest-composer.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import AppLogoIcon from '@/components/app-logo-icon'; -import { GitHubIcon } from '@/components/github-icon'; -import { GraphRail } from '@/components/graph-rail'; -import { Button } from '@/components/ui/button'; -import { login } from '@/routes'; - -/** - * Guests read the same log everyone does, so the composer on top of it is where they sign in. Signing in leaves for - * GitHub, which an Inertia visit cannot follow, so these are plain links. - */ -export function GuestComposer() { - return ( -
    - - - - - - - - } - /> - -
    -

    - A social network for developers. -

    -

    - Everyone below signed in with GitHub and committed plain - text, 280 characters at a time. Your turn. -

    - - - What did you ship? - - - -
    -

    - Your profile comes from GitHub. Nothing to fill in. -

    - -
    -
    -
    - ); -} diff --git a/resources/js/components/keyboard-shortcuts-dialog.tsx b/resources/js/components/keyboard-shortcuts-dialog.tsx index cc3505d54..77188ab60 100644 --- a/resources/js/components/keyboard-shortcuts-dialog.tsx +++ b/resources/js/components/keyboard-shortcuts-dialog.tsx @@ -37,6 +37,8 @@ const shortcuts = (keys: KeyLabels): 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..37bce4270 --- /dev/null +++ b/resources/js/components/rejection-reasons-dialog.tsx @@ -0,0 +1,287 @@ +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 { EmptyFeed } from '@/components/commit-feed'; +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 { cn } from '@/lib/utils'; +import type { RejectionReason } from '@/types'; + +const scrollIntoViewOnMount = (node: HTMLElement | null) => + node?.scrollIntoView({ block: 'nearest' }); + +function RejectionReasonFields({ + reason, + errors, +}: { + reason?: RejectionReason; + errors: Partial>; +}) { + const id = useId(); + + return ( +
    +
    + + + +
    +
    + +