From 3124d9728e49585ff00256b30ce9bddfe417dfcb Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Sat, 31 Oct 2026 21:59:24 +0100 Subject: [PATCH 1/7] feat: let members post commits as their organization --- KNOWLEDGE_BASE.md | 10 +- PRODUCT.md | 2 +- app/Actions/CreateCommit.php | 26 ++- app/Actions/CreateCommitStar.php | 3 +- app/Actions/CreateExport.php | 33 ++- app/Actions/CreateQuoteNotifications.php | 1 + app/Actions/CreateReplyNotifications.php | 1 + app/Actions/CreateStarNotification.php | 3 +- app/Actions/DeleteOrganization.php | 12 +- app/Actions/DeleteUser.php | 6 +- app/Actions/UpdateCommitStatuses.php | 20 +- app/Http/Controllers/Api/CommitController.php | 2 +- app/Http/Controllers/CommitController.php | 5 +- .../Controllers/CommitReplyController.php | 5 +- .../Controllers/OrganizationController.php | 9 +- app/Http/Middleware/HandleInertiaRequests.php | 5 + .../Requests/CreateApiCommitPastelRequest.php | 4 +- .../Requests/CreateApiCommitReportRequest.php | 2 +- app/Http/Requests/CreateApiCommitRequest.php | 14 ++ .../CreateApiCommitRestorationRequest.php | 2 +- .../Requests/CreateApiCommitStarRequest.php | 2 +- .../Requests/CreateCommitPastelRequest.php | 4 +- .../Requests/CreateCommitReplyRequest.php | 16 ++ .../Requests/CreateCommitReportRequest.php | 2 +- app/Http/Requests/CreateCommitRequest.php | 14 ++ .../CreateCommitRestorationRequest.php | 2 +- app/Http/Requests/CreateCommitStarRequest.php | 2 +- .../Requests/DeleteApiCommitPinRequest.php | 2 +- app/Http/Requests/DeleteApiCommitRequest.php | 2 +- app/Http/Requests/DeleteCommitPinRequest.php | 2 +- .../Requests/DeleteCommitReplyRequest.php | 2 +- app/Http/Requests/DeleteCommitRequest.php | 2 +- app/Http/Requests/ListCommitStarsRequest.php | 2 +- app/Http/Requests/ShowOrganizationRequest.php | 6 +- .../Requests/UpdateApiCommitPinRequest.php | 2 +- app/Http/Requests/UpdateCommitPinRequest.php | 2 +- app/Models/Commit.php | 8 + app/Queries/LatestLogCommitsQuery.php | 5 +- app/Queries/OrganizationCommitsQuery.php | 28 +++ app/Rules/OpenPoll.php | 2 +- app/Rules/OrganizationToPostAs.php | 33 +++ resources/js/components/author-switcher.tsx | 91 ++++++++ resources/js/components/commit-card.tsx | 3 +- resources/js/components/commit-composer.tsx | 55 ++++- .../js/components/commit-pastel-button.tsx | 3 +- resources/js/components/commit-poll.tsx | 4 +- .../js/components/commit-quote-button.tsx | 4 +- .../js/components/commit-report-button.tsx | 4 +- .../js/components/commit-star-button.tsx | 3 +- resources/js/components/commit-thread.tsx | 25 ++- resources/js/components/committed-toast.tsx | 2 +- resources/js/components/graph-rail.tsx | 6 +- resources/js/hooks/use-last-visit.ts | 4 +- resources/js/hooks/use-owns-commit.ts | 16 ++ resources/js/pages/log/show.tsx | 5 +- resources/js/pages/organization/show.tsx | 36 +++- resources/js/types/auth.ts | 5 + resources/js/types/commit.ts | 5 + resources/js/types/ui.ts | 2 +- .../Controllers/CommitControllerTest.php | 4 +- .../Controllers/CommitReplyControllerTest.php | 2 +- .../Controllers/ExportControllerTest.php | 3 +- .../OrganizationControllerTest.php | 6 +- tests/Feature/OrganizationCommitsTest.php | 196 ++++++++++++++++++ tests/Unit/CommitSecondaryActions.test.tsx | 4 +- tests/Unit/CommitStarButton.test.tsx | 4 +- tests/Unit/CommitThread.test.tsx | 4 +- tests/Unit/CommittedToast.test.tsx | 2 +- tests/Unit/ComposerPreview.test.tsx | 4 +- tests/Unit/LogInsertion.test.tsx | 2 +- tests/Unit/QuoteReply.test.tsx | 2 +- tests/Unit/StandaloneCommitGraphs.test.tsx | 2 +- 72 files changed, 700 insertions(+), 113 deletions(-) create mode 100644 app/Queries/OrganizationCommitsQuery.php create mode 100644 app/Rules/OrganizationToPostAs.php create mode 100644 resources/js/components/author-switcher.tsx create mode 100644 resources/js/hooks/use-owns-commit.ts create mode 100644 tests/Feature/OrganizationCommitsTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index fd5aa791c..9377c804c 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -112,19 +112,21 @@ Sources: [profile page](resources/js/pages/user/show.tsx), [profile settings](re ### Organization pages -GitHub organizations, like `@laravel`, have a page at `/@{handle}` too, with their GitHub avatar (square, so they never pass for a person), name, description, location, website, and a link to GitHub. An organization gets its page once an approved commit mentions it, from its public GitHub profile, and the profile refreshes when commits keep mentioning it, at most once a day. Nobody signs in as an organization, and everything on its page comes from GitHub, except its color. Shared links to an organization's page preview as a card with its square avatar, name, handle, and description, like a developer's profile. +GitHub organizations, like `@laravel`, have a page at `/@{handle}` too, with their GitHub avatar (square, so they never pass for a person), name, description, location, website, and a link to GitHub. An organization gets its page once an approved commit mentions it, from its public GitHub profile, and the profile refreshes when commits keep mentioning it, at most once a day. Nobody signs in as an organization: its members post for it. Everything on its page comes from GitHub, except its color and its commits. Shared links to an organization's page preview as a card with its square avatar, name, handle, and description, like a developer's profile. -The **Mentions** tab lists the commits and replies that mention the organization, newest first, a reply with the commit it answers. A commit stays listed after the organization is renamed, since it is linked to the organization rather than to the handle. It follows the usual visibility: guests see approved commits only, you also see your own pending ones, and commits across a block or by people you muted are left out. +The **Commits** tab lists the organization's own commits, newest first. The **Mentions** tab lists the commits and replies that mention the organization, newest first, a reply with the commit it answers. A commit stays listed after the organization is renamed, since it is linked to the organization rather than to the handle. It follows the usual visibility: guests see approved commits only, you also see your own pending ones, and commits across a block or by people you muted are left out. A member of an organization can **Claim** its page, which lists them as its admin. Only the first member to claim it becomes its admin: once a page has one, it can't be claimed again, and the Claim button is gone. Claiming asks for no extra GitHub permission: it checks that GitHub lists you among the organization's public members. GitHub keeps membership private by default, so if yours is, the page links to the organization's People page on GitHub, where you make your membership public, then claim again. You can make it private again once you're the admin. GitHub is only the way in: leaving the organization on GitHub doesn't remove you as an admin on /dev/null. After that, members are managed on /dev/null, not mirrored from GitHub. An organization's members open its **Settings** from its page, where the **Members** tab lists everyone, each an **admin** or a **member**. Admins add people by their /dev/null handle, searching by handle or name as they type, except people they blocked or who blocked them, make them admins or members, and remove them. Any member can leave. Its last admin can't leave or become a member. Only its members see who its members are; its page lists its admins to everyone. Your organizations and your role in each are in your data export and go when you delete your account. +Its admins and members can **post as the organization**: in the composer, and in a reply's, they open their avatar and pick the organization, then switch back to themselves the same way. Every commit and reply starts as yourself. A commit from an organization shows the organization alone, with its square avatar and color, at `/@{handle}/commits/{id}`: nobody else sees which member posted it. It belongs to the organization: any of its members can delete it, and none can star it, report it, vote in its poll or give it pastéis. It counts toward no one's level, and blocking or muting the person who posted it doesn't hide it. Moderation still holds the person who posted it to account, and if they delete their account, the organization's commits they posted stay. Organizations can't schedule commits yet, and their commits don't notify anyone yet. Apps can post as an organization through the API too, by sending its handle as `author`. + Every organization has a color, /dev/null's blue until an admin chooses another in the **Profile** tab of its **Settings**, where its admins land: one of the same six lane colors as developers, or a custom one. It rings the organization's avatar, leads its handle, and colors its link preview. -Signed in, you can **Follow** an organization from its page, which shows how many people follow it. Once you follow it, the button opens a menu where you can unfollow. Following will put the organization's own commits in your Following log once organizations can commit, which isn't possible yet. **Watch**, which will also show the commits that mention the organization, is planned and shown as coming soon. The organizations you follow are listed on your Following list, above the people, a few at a time with **Show more organizations**, and count toward your following count. They are in your data export and go when you delete your account. +Signed in, you can **Follow** an organization from its page, which shows how many people follow it. Once you follow it, the button opens a menu where you can unfollow. Following puts the organization's own commits in your Following log, and ranks them higher in Trending, like the people you follow. **Watch**, which will also show the commits that mention the organization, is planned and shown as coming soon. The organizations you follow are listed on your Following list, above the people, a few at a time with **Show more organizations**, and count toward your following count. They are in your data export and go when you delete your account. -Sources: [organization page](resources/js/pages/organization/show.tsx), [mentions](app/Queries/OrganizationMentionsQuery.php), [organization lookups](app/Actions/UpdateCommitTopics.php), [claims](app/Http/Controllers/OrganizationAdminController.php), [color](app/Http/Controllers/OrganizationLaneController.php), [members](app/Http/Controllers/OrganizationMemberController.php). +Sources: [organization page](resources/js/pages/organization/show.tsx), [commits](app/Queries/OrganizationCommitsQuery.php), [posting as an organization](app/Rules/OrganizationToPostAs.php), [mentions](app/Queries/OrganizationMentionsQuery.php), [organization lookups](app/Actions/UpdateCommitTopics.php), [claims](app/Http/Controllers/OrganizationAdminController.php), [color](app/Http/Controllers/OrganizationLaneController.php), [members](app/Http/Controllers/OrganizationMemberController.php). ### Going live diff --git a/PRODUCT.md b/PRODUCT.md index f12b19345..13dbe7437 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -35,7 +35,7 @@ What exists, one line each (details in the knowledge base): - **Logs:** Following, Latest, and Trending. - **Engagement:** follows, stars, saved commits, pins, mentions, and pastéis (an opt-in daily allowance of pastries). - **Profiles:** mirrored from GitHub, with an activity graph, streaks, achievements, a Unix rank, and a live marker while you stream on YouTube. -- **Organization pages:** GitHub organizations get a page mirrored from GitHub once an approved commit mentions them, and `@organization` mentions link to it. +- **Organization pages:** GitHub organizations get a page mirrored from GitHub once an approved commit mentions them, and `@organization` mentions link to it. Their members post commits as the organization, which readers see from the organization alone. - **Remotes:** private one-to-one and group messages (orgs), and calls, one-to-one or in an org. - **Notifications:** in-app activity and on-site Remotes alerts by default; off-site browser and PWA notifications require a separate opt-in. Sound is controlled independently and plays while the site is open. - **Search:** developers by name, commits by meaning. diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index c49cc60be..e124dd3db 100644 --- a/app/Actions/CreateCommit.php +++ b/app/Actions/CreateCommit.php @@ -15,6 +15,7 @@ use App\Jobs\UnfurlLink; use App\Models\Commit; use App\Models\LinkPreview; +use App\Models\Organization; use App\Models\User; use App\Queries\MentionedOrganizationsQuery; use App\Queries\MentionedUsersQuery; @@ -53,7 +54,7 @@ public function __construct( * @param array $imageDescriptions * @param array $replyRecipients */ - public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null, array $replyRecipients = []): Commit + public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null, array $replyRecipients = [], ?Organization $organization = null): Commit { $url = match ($linkPreview) { true => $this->urls->first($body), @@ -61,14 +62,15 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit default => $linkPreview, }; - return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll, $replyRecipients): Commit { + return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll, $replyRecipients, $organization): Commit { $linkPreview = $this->linkPreviewFor($url); $createdAt = now(); $hasGoodReputation = $user->hasGoodReputation(); $mentions = $this->mentionedUsers->get($body); - $commit = $user->commits()->create([ + $commit = Commit::query()->create([ 'user_id' => $user->id, + 'author_id' => $organization->id ?? $user->id, 'body' => $this->emojiShortcodes->replace($body), 'mentions' => [...$replyRecipients, ...$mentions], 'parent_id' => $parent?->id, @@ -91,18 +93,24 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $this->updateCommitHotScores->handle([$commit->id]); } - $this->createReplyNotifications->handle($commit); - $this->createQuoteNotifications->handle($commit); - $this->createMentionNotifications->handle($commit); - $this->createFirstCommitNotifications->handle($commit); + // An organization's commits notify no one yet, and get no welcome or answer: each would show who posted it. + if (! $organization instanceof Organization) { + $this->createReplyNotifications->handle($commit); + $this->createQuoteNotifications->handle($commit); + $this->createMentionNotifications->handle($commit); + $this->createFirstCommitNotifications->handle($commit); + } if ($linkPreview instanceof LinkPreview && $linkPreview->fetched_at === null) { dispatch(new UnfurlLink($linkPreview)); } - $this->createWorldReply->handle($commit); + if (! $organization instanceof Organization) { + $this->createWorldReply->handle($commit); + } if ($commit->status === CommitStatus::Approved + && ! $organization instanceof Organization && $user->github_id !== CreateTomFriendship::GITHUB_ID ) { $tom = $this->tomQuery->get(); @@ -112,7 +120,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit } } - if ($commit->status === CommitStatus::Approved) { + if ($commit->status === CommitStatus::Approved && ! $organization instanceof Organization) { dispatch(new CheckUserLevel($user))->afterCommit(); } diff --git a/app/Actions/CreateCommitStar.php b/app/Actions/CreateCommitStar.php index 666ff313c..fb0ebdc2a 100644 --- a/app/Actions/CreateCommitStar.php +++ b/app/Actions/CreateCommitStar.php @@ -40,7 +40,8 @@ public function handle(User $user, Commit $commit): void $this->updateCommitHotScores->handle([$commit->id]); if ($commit->user_id !== $user->id) { - $author = $commit->user()->first(); + // Stars on an organization's commit count toward no one's level. + $author = $commit->author_id === $commit->user_id ? $commit->user()->first() : null; DB::afterCommit(function () use ($user, $author): void { dispatch(new CheckUserLevel($user)); diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index b88f3bfcf..91fe0f22a 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -5,6 +5,7 @@ namespace App\Actions; use App\Enums\NotificationType; +use App\Models\Commit; use App\Models\CommitImage; use App\Models\CommitReport; use App\Models\Conversation; @@ -39,6 +40,7 @@ * profile: array, * active_days: Generator, * commits: Generator>, + * organization_commits: Generator>, * polls: Generator, * poll_votes: Generator, * stars: Generator, @@ -72,7 +74,7 @@ public function handle(User $user): array { return [ - 'schema_version' => 4, + 'schema_version' => 5, 'exported_at' => now()->toIso8601String(), 'profile' => [ 'github_id' => $user->github_id, @@ -110,6 +112,7 @@ public function handle(User $user): array ], 'active_days' => $this->activeDays($user), 'commits' => $this->commits($user), + 'organization_commits' => $this->organizationCommits($user), 'polls' => $this->polls($user), 'poll_votes' => $this->pollVotes($user), 'stars' => $this->stars($user), @@ -213,6 +216,34 @@ private function commits(User $user): Generator } } + /** + * What the user posted for organizations, which is theirs to read back, though the organizations' own. + * + * @return Generator> + */ + private function organizationCommits(User $user): Generator + { + $commits = Commit::query() + ->withTrashed() + ->where('user_id', $user->id) + ->whereColumn('author_id', '!=', 'user_id') + ->with('author') + ->lazyById(500); + + foreach ($commits as $commit) { + yield [ + 'id' => $commit->id, + 'organization' => $commit->author?->handle, + 'parent_id' => $commit->parent_id, + 'quoted_commit_id' => $commit->quoted_commit_id, + 'body' => $commit->body, + 'status' => $commit->status->value, + 'created_at' => $commit->created_at->toIso8601String(), + 'deleted_at' => $commit->deleted_at?->toIso8601String(), + ]; + } + } + /** * @return Generator */ diff --git a/app/Actions/CreateQuoteNotifications.php b/app/Actions/CreateQuoteNotifications.php index 80736b492..06882a304 100644 --- a/app/Actions/CreateQuoteNotifications.php +++ b/app/Actions/CreateQuoteNotifications.php @@ -35,6 +35,7 @@ public function handle(Commit ...$commits): void ->select('commits.*') ->join('commits as quoted', 'quoted.id', '=', 'commits.quoted_commit_id') ->whereKey($quoteIds) + ->whereNotNull('quoted.user_id') ->where('commits.status', CommitStatus::Approved) ->whereNotExists($silenced->mutes(...)) ->whereNotExists($silenced->blocking(...)) diff --git a/app/Actions/CreateReplyNotifications.php b/app/Actions/CreateReplyNotifications.php index 53f038aa3..b0c5dc6d2 100644 --- a/app/Actions/CreateReplyNotifications.php +++ b/app/Actions/CreateReplyNotifications.php @@ -35,6 +35,7 @@ public function handle(Commit ...$commits): void ->select('commits.*') ->join('commits as parents', 'parents.id', '=', 'commits.parent_id') ->whereKey($replyIds) + ->whereNotNull('parents.user_id') ->where('commits.status', CommitStatus::Approved) ->whereNotExists($silenced->mutes(...)) ->whereNotExists($silenced->blocking(...)) diff --git a/app/Actions/CreateStarNotification.php b/app/Actions/CreateStarNotification.php index fa228c710..3dfe84194 100644 --- a/app/Actions/CreateStarNotification.php +++ b/app/Actions/CreateStarNotification.php @@ -17,7 +17,8 @@ */ public function handle(User $stargazer, Commit $commit): void { - if ($commit->user_id === $stargazer->id) { + // Nobody is told about stars on an organization's commit whose poster has since deleted their account. + if ($commit->user_id === null || $commit->user_id === $stargazer->id) { return; } diff --git a/app/Actions/DeleteOrganization.php b/app/Actions/DeleteOrganization.php index 4ba6640b0..6f5f8b30a 100644 --- a/app/Actions/DeleteOrganization.php +++ b/app/Actions/DeleteOrganization.php @@ -5,18 +5,28 @@ namespace App\Actions; use App\Enums\TopicType; +use App\Models\Commit; use App\Models\Organization; use Illuminate\Support\Facades\DB; final readonly class DeleteOrganization { + public function __construct(private DeleteCommit $deleteCommit) + { + // + } + /** * Deletes an organization GitHub no longer has, with its members and followers, and unlinks the commits that mention - * it, so none links to a page that is gone. + * it, so none links to a page that is gone. Its own commits are deleted as their posters would, leaving tombstones + * for the replies under them, and lose their author with its profile. */ public function handle(Organization $organization): void { DB::transaction(function () use ($organization): void { + $organization->profile->commits()->get()->each(fn (Commit $commit): Commit => $this->deleteCommit->handle($commit)); + $organization->profile->commits()->withTrashed()->update(['author_id' => null]); + DB::table('commit_topics') ->where('topic_type', TopicType::Organization->value) ->where('topic_id', $organization->id) diff --git a/app/Actions/DeleteUser.php b/app/Actions/DeleteUser.php index 456f69ed7..67607876f 100644 --- a/app/Actions/DeleteUser.php +++ b/app/Actions/DeleteUser.php @@ -31,6 +31,7 @@ public function __construct(private UpdateCommitHotScores $updateCommitHotScores /** * The user's top-level commits are erased with every reply under them, and so are their replies, except the ones * other people replied to: those stay behind as tombstones with no author or body, so the replies under them keep their thread. + * What they posted for organizations stays, and only forgets who posted it. * The database cascades the image rows of erased commits and the user's scheduled commits, and the voice notes of their * messages, but the files are erased here. Orgs the user was the last one in go too, with everything sent in them, * as they do when the last member leaves. @@ -66,7 +67,10 @@ public function handle(User $user): void } $user->commits()->delete(); - $user->commits()->withTrashed()->update(['user_id' => null, 'body' => '', 'mentions' => null, 'embedding' => null, 'link_preview_id' => null]); + $user->commits()->withTrashed()->update(['user_id' => null, 'author_id' => null, 'body' => '', 'mentions' => null, 'embedding' => null, 'link_preview_id' => null]); + + // What they posted for organizations is the organizations' own, so it stays, without who posted it. + Commit::query()->withTrashed()->where('user_id', $user->id)->update(['user_id' => null]); $this->deleteOrgs->handle($this->emptyOrgs($user)); diff --git a/app/Actions/UpdateCommitStatuses.php b/app/Actions/UpdateCommitStatuses.php index 162615799..d8eb8f32f 100644 --- a/app/Actions/UpdateCommitStatuses.php +++ b/app/Actions/UpdateCommitStatuses.php @@ -60,7 +60,8 @@ public function handle(array $commitIds, CommitStatus $status, ?string $reason = ->delete(); if ($status === CommitStatus::Rejected) { - Notification::query()->insert($commits->map(fn (Commit $commit): array => [ + // An organization's commit whose poster has since deleted their account has no one to tell. + Notification::query()->insert($commits->whereNotNull('user_id')->map(fn (Commit $commit): array => [ 'id' => Str::uuid7()->toString(), 'recipient_id' => $commit->user_id, 'actor_id' => $commit->user_id, @@ -81,16 +82,19 @@ public function handle(array $commitIds, CommitStatus $status, ?string $reason = ->whereNull('opened_at') ->update(['opened_at' => now()]); - $this->createReplyNotifications->handle(...$commits); - $this->createQuoteNotifications->handle(...$commits); - $this->createMentionNotifications->handle(...$commits); - $this->createFirstCommitNotifications->handle(...$commits); + // As when they are posted, an organization's commits notify no one yet, and get no welcome or answer. + $personal = $commits->filter(fn (Commit $commit): bool => $commit->author_id === $commit->user_id); - $commits->each(fn (Commit $commit) => $this->createWorldReply->handle($commit)); + $this->createReplyNotifications->handle(...$personal); + $this->createQuoteNotifications->handle(...$personal); + $this->createMentionNotifications->handle(...$personal); + $this->createFirstCommitNotifications->handle(...$personal); + + $personal->each(fn (Commit $commit) => $this->createWorldReply->handle($commit)); LinkCommitTopics::dispatchFor(...$commits->all()); if (($tom = $this->tomQuery->get()) instanceof User) { - foreach ($commits as $commit) { + foreach ($personal as $commit) { if ($commit->user?->github_id !== CreateTomFriendship::GITHUB_ID && $commit->isAddressedTo($tom)) { dispatch(new ReplyAsTom($commit->id)); @@ -98,7 +102,7 @@ public function handle(array $commitIds, CommitStatus $status, ?string $reason = } } - User::query()->whereKey($commits->pluck('user_id')->filter()->unique()->values()->all())->get() + User::query()->whereKey($personal->pluck('user_id')->filter()->unique()->values()->all())->get() ->each(fn (User $author) => dispatch(new CheckUserLevel($author))->afterCommit()); } }); diff --git a/app/Http/Controllers/Api/CommitController.php b/app/Http/Controllers/Api/CommitController.php index 5aab59513..146be1c58 100644 --- a/app/Http/Controllers/Api/CommitController.php +++ b/app/Http/Controllers/Api/CommitController.php @@ -51,7 +51,7 @@ public function show(ShowApiCommitRequest $request, #[CurrentUser] ?User $curren */ public function store(CreateApiCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action, CommitPageQuery $page): JsonResponse { - $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), $request->quotedCommit(), $request->images(), $request->imageDescriptions(), poll: $request->poll(), replyRecipients: $request->replyRecipients()); + $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), $request->quotedCommit(), $request->images(), $request->imageDescriptions(), poll: $request->poll(), replyRecipients: $request->replyRecipients(), organization: $request->organization()); return CommitResource::make($page->get($commit, $user)) ->response() diff --git a/app/Http/Controllers/CommitController.php b/app/Http/Controllers/CommitController.php index bd9cb83e2..ecfe35a1e 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -67,14 +67,15 @@ public function store(CreateCommitRequest $request, #[CurrentUser] User $user, C { $before = $achievements->unlockedBadgeNames($user); - $commit = $action->handle($user, $request->string('body')->value(), quotedCommit: $request->quotedCommit(), images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), poll: $request->poll()); + $organization = $request->organization(); + $commit = $action->handle($user, $request->string('body')->value(), quotedCommit: $request->quotedCommit(), images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), poll: $request->poll(), organization: $organization); $unlocked = $achievements->unlockedBadgeNames($user)->diff($before)->values(); Inertia::flash('committed', [ 'id' => $commit->id, 'body' => $commit->body, - 'user' => ['handle' => $user->handle], + 'author' => ['handle' => ($organization ?? $user)->handle], ]); if ($unlocked->isNotEmpty()) { diff --git a/app/Http/Controllers/CommitReplyController.php b/app/Http/Controllers/CommitReplyController.php index 7cc5bcc73..9a5b63926 100644 --- a/app/Http/Controllers/CommitReplyController.php +++ b/app/Http/Controllers/CommitReplyController.php @@ -27,14 +27,15 @@ public function index(ListCommitRepliesRequest $request, #[CurrentUser] ?User $u /** Creates a reply addressed to the parent author and the validated selection of conversation participants. */ public function store(CreateCommitReplyRequest $request, #[CurrentUser] User $user, Commit $commit, CreateCommit $action, CommitThreadQuery $thread): JsonResponse { - $reply = $action->handle($user, $request->string('body')->value(), $commit, images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), replyRecipients: $request->replyRecipients()); + $organization = $request->organization(); + $reply = $action->handle($user, $request->string('body')->value(), $commit, images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), replyRecipients: $request->replyRecipients(), organization: $organization); $parent = $thread->get($commit, $user); return response()->json([ 'committed' => [ 'id' => $reply->id, 'body' => $reply->body, - 'user' => ['handle' => $user->handle], + 'author' => ['handle' => ($organization ?? $user)->handle], ], 'parent' => [ 'id' => $parent->id, diff --git a/app/Http/Controllers/OrganizationController.php b/app/Http/Controllers/OrganizationController.php index b616dc8fc..77e626f4d 100644 --- a/app/Http/Controllers/OrganizationController.php +++ b/app/Http/Controllers/OrganizationController.php @@ -8,6 +8,7 @@ use App\Http\Requests\ShowOrganizationRequest; use App\Models\Organization; use App\Models\User; +use App\Queries\OrganizationCommitsQuery; use App\Queries\OrganizationMentionsQuery; use App\Services\PageMetadata; use Illuminate\Container\Attributes\CurrentUser; @@ -19,12 +20,12 @@ final readonly class OrganizationController { /** - * Organization pages are public, at the "@handle" of users, and show the organization's GitHub profile and the - * commits that mention it, in the lane its admins chose or /dev/null's blue, with its admins, first one first, how + * Organization pages are public, at the "@handle" of users, and show the organization's GitHub profile, its own + * commits and the commits that mention it, in the lane its admins chose or /dev/null's blue, with its admins, first one first, how * many follow it and whether the viewer does, and the organization's link preview, and its members the way to its * settings. Like users' pages, they move for good to the handle's own case. */ - public function show(ShowOrganizationRequest $request, #[CurrentUser] ?User $currentUser, OrganizationMentionsQuery $mentions, PageMetadata $metadata): RedirectResponse|Response + public function show(ShowOrganizationRequest $request, #[CurrentUser] ?User $currentUser, OrganizationCommitsQuery $organizationCommits, OrganizationMentionsQuery $mentions, PageMetadata $metadata): RedirectResponse|Response { $organization = $request->organization(); assert($organization instanceof Organization); @@ -39,7 +40,7 @@ public function show(ShowOrganizationRequest $request, #[CurrentUser] ?User $cur return Inertia::render('organization/show', [ 'organization' => $organization, 'tab' => $request->tab(...), - 'commits' => Inertia::scroll(fn () => $mentions->builder($organization, $currentUser) + 'commits' => Inertia::scroll(fn () => ($request->tab() === 'mentions' ? $mentions : $organizationCommits)->builder($organization, $currentUser) ->cursorPaginate(20) ->appends(['tab' => $request->tab()])), 'claimUrl' => route('organization-admin.store', $organization), diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index aac05f251..75cee4737 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -9,6 +9,7 @@ use App\Enums\RepliesPerPage; use App\Http\Requests\ListNotificationsRequest; use App\Models\Commit; +use App\Models\Organization; use App\Models\User; use App\Queries\BioProfilesQuery; use App\Queries\ConversationsQuery; @@ -93,6 +94,10 @@ public function share(Request $request): array 'auth' => [ 'user' => fn (): ?User => $request->user()?->loadCounts(), 'bioProfiles' => fn (): array => $user instanceof User ? resolve(BioProfilesQuery::class)->get($user) : [], + // The organizations the user can post as, shown like the commits they author. + 'organizations' => fn (): array => $user instanceof User ? $user->organizations()->get() + ->map(fn (Organization $organization): array => [...$organization->toArray(), 'type' => 'organization', 'id' => $organization->id, 'lane' => $organization->lane()]) + ->all() : [], 'isAdmin' => $isAdmin, 'automaticTranslation' => $user->automatic_translation ?? true, 'mustReadCommitRules' => $user instanceof User && $user->commit_rules_accepted_at === null, diff --git a/app/Http/Requests/CreateApiCommitPastelRequest.php b/app/Http/Requests/CreateApiCommitPastelRequest.php index 5b0c63965..c32601544 100644 --- a/app/Http/Requests/CreateApiCommitPastelRequest.php +++ b/app/Http/Requests/CreateApiCommitPastelRequest.php @@ -25,8 +25,8 @@ public function authorize(): Response return match (true) { ! $user->pasteis_mode => Response::deny(__('Turn on pastéis mode to give pastéis.')), - $commit->user_id === $user->id => Response::deny(__('You cannot award your own commit.')), - $commit->user_id === null, ! $commit->isVisibleTo($user) => Response::denyAsNotFound(), + $commit->isOwnedBy($user) => Response::deny(__('You cannot award your own commit.')), + $commit->user_id === null, $commit->author_id !== $commit->user_id, ! $commit->isVisibleTo($user) => Response::denyAsNotFound(), default => Response::allow(), }; } diff --git a/app/Http/Requests/CreateApiCommitReportRequest.php b/app/Http/Requests/CreateApiCommitReportRequest.php index 1c68a5d5f..dea936068 100644 --- a/app/Http/Requests/CreateApiCommitReportRequest.php +++ b/app/Http/Requests/CreateApiCommitReportRequest.php @@ -37,7 +37,7 @@ public function authorize(): bool assert($commit instanceof Commit); throw_if($commit->trashed() || $commit->status !== CommitStatus::Approved || ! $commit->isVisibleTo($user), fn (): ApiException => ApiException::commitUnavailable()); - throw_if($commit->user_id === $user->id, fn (): ApiException => new ApiException(ApiErrorCode::OwnCommit, __('You cannot report your own commit.'))); + throw_if($commit->isOwnedBy($user), fn (): ApiException => new ApiException(ApiErrorCode::OwnCommit, __('You cannot report your own commit.'))); return true; } diff --git a/app/Http/Requests/CreateApiCommitRequest.php b/app/Http/Requests/CreateApiCommitRequest.php index d5d3d8d40..6dc22c0fc 100644 --- a/app/Http/Requests/CreateApiCommitRequest.php +++ b/app/Http/Requests/CreateApiCommitRequest.php @@ -7,8 +7,10 @@ use App\Dto\NewPoll; use App\Enums\PollDuration; use App\Models\Commit; +use App\Models\Organization; use App\Models\User; use App\Queries\MentionedUsersQuery; +use App\Rules\OrganizationToPostAs; use App\Rules\VisibleCommit; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; @@ -52,6 +54,7 @@ public function rules(): array return [ 'body' => CreateCommitRequest::bodyRules($this->hasFile('images')), + 'author' => ['nullable', 'string', new OrganizationToPostAs($user)], 'parent_id' => ['bail', 'nullable', 'uuid', new VisibleCommit($user)], 'reply_recipients' => ['sometimes', 'list', 'max:'.CreateCommitReplyRequest::MAX_REPLY_RECIPIENTS], 'reply_recipients.*' => ['required', 'string', 'distinct', Rule::in(array_values($this->parentMentions()))], @@ -167,6 +170,17 @@ public function replyRecipients(): array return array_intersect($this->parentMentions(), $recipients); } + /** + * The organization the user posts as, or null when they post as themselves. + */ + public function organization(): ?Organization + { + $user = $this->user(); + assert($user instanceof User); + + return OrganizationToPostAs::of($user, $this->input('author')); + } + /** * Who the parent was addressed to, by handle, before the parent itself is validated, so a recipient without one * never passes. Only a parent the user can see counts, so the answer never tells who a hidden commit mentions. diff --git a/app/Http/Requests/CreateApiCommitRestorationRequest.php b/app/Http/Requests/CreateApiCommitRestorationRequest.php index f764b13e0..8852232af 100644 --- a/app/Http/Requests/CreateApiCommitRestorationRequest.php +++ b/app/Http/Requests/CreateApiCommitRestorationRequest.php @@ -21,7 +21,7 @@ public function authorize(): Response $user = $this->user(); assert($user instanceof User); - return $this->commit()->user_id === $user->id ? Response::allow() : Response::denyAsNotFound(); + return $this->commit()->isOwnedBy($user) ? Response::allow() : Response::denyAsNotFound(); } /** diff --git a/app/Http/Requests/CreateApiCommitStarRequest.php b/app/Http/Requests/CreateApiCommitStarRequest.php index 164ebff76..18147f24b 100644 --- a/app/Http/Requests/CreateApiCommitStarRequest.php +++ b/app/Http/Requests/CreateApiCommitStarRequest.php @@ -23,7 +23,7 @@ public function authorize(): Response assert($commit instanceof Commit); return match (true) { - $commit->user_id === $user->id => Response::deny(), + $commit->isOwnedBy($user) => Response::deny(), $commit->isVisibleTo($user) => Response::allow(), default => Response::denyAsNotFound(), }; diff --git a/app/Http/Requests/CreateCommitPastelRequest.php b/app/Http/Requests/CreateCommitPastelRequest.php index 5fffb34cd..ea51408f9 100644 --- a/app/Http/Requests/CreateCommitPastelRequest.php +++ b/app/Http/Requests/CreateCommitPastelRequest.php @@ -21,8 +21,8 @@ public function authorize(): Response return match (true) { ! $user->pasteis_mode => Response::deny(), - $commit->user_id === $user->id => Response::deny('You cannot award your own commit.'), - $commit->user_id === null, ! $commit->isVisibleTo($user) => Response::denyAsNotFound(), + $commit->isOwnedBy($user) => Response::deny('You cannot award your own commit.'), + $commit->user_id === null, $commit->author_id !== $commit->user_id, ! $commit->isVisibleTo($user) => Response::denyAsNotFound(), default => Response::allow(), }; } diff --git a/app/Http/Requests/CreateCommitReplyRequest.php b/app/Http/Requests/CreateCommitReplyRequest.php index d862583ed..aea37cdf3 100644 --- a/app/Http/Requests/CreateCommitReplyRequest.php +++ b/app/Http/Requests/CreateCommitReplyRequest.php @@ -6,8 +6,10 @@ use App\Actions\CreateCommitImages; use App\Models\Commit; +use App\Models\Organization; use App\Models\User; use App\Queries\MentionedUsersQuery; +use App\Rules\OrganizationToPostAs; use App\Services\Urls; use Illuminate\Auth\Access\Response; use Illuminate\Contracts\Validation\ValidationRule; @@ -46,11 +48,14 @@ public function prepareForValidation(): void */ public function rules(): array { + $user = $this->user(); + assert($user instanceof User); $body = $this->input('body'); $parent = $this->route('commit'); assert($parent instanceof Commit); return [ + 'author' => ['nullable', 'string', new OrganizationToPostAs($user)], 'reply_recipients' => ['sometimes', 'list', 'max:'.self::MAX_REPLY_RECIPIENTS], 'reply_recipients.*' => ['required', 'string', 'distinct', Rule::in(array_values($parent->mentions ?? []))], 'body' => CreateCommitRequest::bodyRules($this->hasFile('images')), @@ -157,4 +162,15 @@ public function imageDescriptions(): array return $descriptions; } + + /** + * The organization the user posts as, or null when they post as themselves. + */ + public function organization(): ?Organization + { + $user = $this->user(); + assert($user instanceof User); + + return OrganizationToPostAs::of($user, $this->input('author')); + } } diff --git a/app/Http/Requests/CreateCommitReportRequest.php b/app/Http/Requests/CreateCommitReportRequest.php index 5af15eec4..af7ca2cee 100644 --- a/app/Http/Requests/CreateCommitReportRequest.php +++ b/app/Http/Requests/CreateCommitReportRequest.php @@ -35,7 +35,7 @@ public function authorize(): Response return Response::denyAsNotFound(); } - return $commit->user_id !== $user->id + return ! $commit->isOwnedBy($user) ? Response::allow() : Response::deny('You cannot report your own commit.'); } diff --git a/app/Http/Requests/CreateCommitRequest.php b/app/Http/Requests/CreateCommitRequest.php index 338db8c30..26264c458 100644 --- a/app/Http/Requests/CreateCommitRequest.php +++ b/app/Http/Requests/CreateCommitRequest.php @@ -8,8 +8,10 @@ use App\Dto\NewPoll; use App\Enums\PollDuration; use App\Models\Commit; +use App\Models\Organization; use App\Models\User; use App\Rules\CommitBodyLength; +use App\Rules\OrganizationToPostAs; use App\Rules\VisibleCommit; use App\Services\CommitBodies; use App\Services\Urls; @@ -88,6 +90,7 @@ public function rules(): array return [ 'body' => self::bodyRules($this->hasFile('images')), + 'author' => ['nullable', 'string', new OrganizationToPostAs($user)], 'quoted_commit_id' => ['nullable', 'uuid', 'prohibits:parent_id', new VisibleCommit($user, 'That commit is no longer available to quote.')], 'link_preview_url' => ['nullable', 'string', Rule::in(is_string($body) ? resolve(Urls::class)->all($body) : [])], ...$attachmentRules, @@ -173,4 +176,15 @@ public function prepareForValidation(): void { self::normalizeBody($this); } + + /** + * The organization the user posts as, or null when they post as themselves. + */ + public function organization(): ?Organization + { + $user = $this->user(); + assert($user instanceof User); + + return OrganizationToPostAs::of($user, $this->input('author')); + } } diff --git a/app/Http/Requests/CreateCommitRestorationRequest.php b/app/Http/Requests/CreateCommitRestorationRequest.php index e3f16f35b..bcd0b0249 100644 --- a/app/Http/Requests/CreateCommitRestorationRequest.php +++ b/app/Http/Requests/CreateCommitRestorationRequest.php @@ -17,7 +17,7 @@ public function authorize(): bool $user = $this->user(); assert($user instanceof User); - return $user->id === $this->commit()->user_id; + return $this->commit()->isOwnedBy($user); } /** diff --git a/app/Http/Requests/CreateCommitStarRequest.php b/app/Http/Requests/CreateCommitStarRequest.php index 0b5690b43..92574914c 100644 --- a/app/Http/Requests/CreateCommitStarRequest.php +++ b/app/Http/Requests/CreateCommitStarRequest.php @@ -20,7 +20,7 @@ public function authorize(): Response assert($commit instanceof Commit); return match (true) { - $commit->user_id === $user->id => Response::deny(), + $commit->isOwnedBy($user) => Response::deny(), $commit->isVisibleTo($user) => Response::allow(), default => Response::deny(), }; diff --git a/app/Http/Requests/DeleteApiCommitPinRequest.php b/app/Http/Requests/DeleteApiCommitPinRequest.php index cccb02dcf..17975cde6 100644 --- a/app/Http/Requests/DeleteApiCommitPinRequest.php +++ b/app/Http/Requests/DeleteApiCommitPinRequest.php @@ -23,7 +23,7 @@ public function authorize(): Response assert($commit instanceof Commit); return match (true) { - $commit->user_id === $user->id && $commit->parent_id === null => Response::allow(), + $commit->author_id === $user->id && $commit->parent_id === null => Response::allow(), $commit->isVisibleTo($user) => Response::deny(), default => Response::denyAsNotFound(), }; diff --git a/app/Http/Requests/DeleteApiCommitRequest.php b/app/Http/Requests/DeleteApiCommitRequest.php index 3522e2e9e..7bf4f952a 100644 --- a/app/Http/Requests/DeleteApiCommitRequest.php +++ b/app/Http/Requests/DeleteApiCommitRequest.php @@ -18,7 +18,7 @@ public function authorize(): bool $commit = $this->route('commit'); assert($commit instanceof Commit); - return $user->id === $commit->user_id; + return $commit->isOwnedBy($user); } /** diff --git a/app/Http/Requests/DeleteCommitPinRequest.php b/app/Http/Requests/DeleteCommitPinRequest.php index 280a838c9..8c8de2c32 100644 --- a/app/Http/Requests/DeleteCommitPinRequest.php +++ b/app/Http/Requests/DeleteCommitPinRequest.php @@ -18,7 +18,7 @@ public function authorize(): bool $commit = $this->route('commit'); assert($commit instanceof Commit); - return $commit->user_id === $user->id && $commit->parent_id === null; + return $commit->author_id === $user->id && $commit->parent_id === null; } /** diff --git a/app/Http/Requests/DeleteCommitReplyRequest.php b/app/Http/Requests/DeleteCommitReplyRequest.php index fe5fb8f91..11ea0c0fa 100644 --- a/app/Http/Requests/DeleteCommitReplyRequest.php +++ b/app/Http/Requests/DeleteCommitReplyRequest.php @@ -29,7 +29,7 @@ public function authorize(): Response return Response::denyAsNotFound(); } - return $reply->user_id === $user->id ? Response::allow() : Response::deny(); + return $reply->isOwnedBy($user) ? Response::allow() : Response::deny(); } /** diff --git a/app/Http/Requests/DeleteCommitRequest.php b/app/Http/Requests/DeleteCommitRequest.php index 5d9ab199d..04b62258a 100644 --- a/app/Http/Requests/DeleteCommitRequest.php +++ b/app/Http/Requests/DeleteCommitRequest.php @@ -18,7 +18,7 @@ public function authorize(): bool $commit = $this->route('commit'); assert($commit instanceof Commit); - return $user->id === $commit->user_id; + return $commit->isOwnedBy($user); } /** diff --git a/app/Http/Requests/ListCommitStarsRequest.php b/app/Http/Requests/ListCommitStarsRequest.php index b603f242f..1162ce863 100644 --- a/app/Http/Requests/ListCommitStarsRequest.php +++ b/app/Http/Requests/ListCommitStarsRequest.php @@ -22,7 +22,7 @@ public function authorize(): Response $commit = $this->route('commit'); assert($commit instanceof Commit); - return $commit->user_id === $user->id ? Response::allow() : Response::denyAsNotFound(); + return $commit->isOwnedBy($user) ? Response::allow() : Response::denyAsNotFound(); } /** diff --git a/app/Http/Requests/ShowOrganizationRequest.php b/app/Http/Requests/ShowOrganizationRequest.php index a641a2a25..8ef8066c6 100644 --- a/app/Http/Requests/ShowOrganizationRequest.php +++ b/app/Http/Requests/ShowOrganizationRequest.php @@ -23,16 +23,16 @@ public function authorize(): Response public function rules(): array { return [ - 'tab' => ['nullable', 'string', 'in:mentions'], + 'tab' => ['nullable', 'string', 'in:commits,mentions'], ]; } /** - * Mentions is the only tab so far, so it's the default. + * The organization's own commits first, then the commits that mention it. */ public function tab(): string { - return 'mentions'; + return $this->string('tab')->value() === 'mentions' ? 'mentions' : 'commits'; } /** diff --git a/app/Http/Requests/UpdateApiCommitPinRequest.php b/app/Http/Requests/UpdateApiCommitPinRequest.php index 84d8f7ae5..855cb9e31 100644 --- a/app/Http/Requests/UpdateApiCommitPinRequest.php +++ b/app/Http/Requests/UpdateApiCommitPinRequest.php @@ -23,7 +23,7 @@ public function authorize(): Response assert($commit instanceof Commit); return match (true) { - $commit->user_id === $user->id && $commit->parent_id === null => Response::allow(), + $commit->author_id === $user->id && $commit->parent_id === null => Response::allow(), $commit->isVisibleTo($user) => Response::deny(), default => Response::denyAsNotFound(), }; diff --git a/app/Http/Requests/UpdateCommitPinRequest.php b/app/Http/Requests/UpdateCommitPinRequest.php index da6b822ce..d1653657d 100644 --- a/app/Http/Requests/UpdateCommitPinRequest.php +++ b/app/Http/Requests/UpdateCommitPinRequest.php @@ -18,7 +18,7 @@ public function authorize(): bool $commit = $this->route('commit'); assert($commit instanceof Commit); - return $commit->user_id === $user->id && $commit->parent_id === null; + return $commit->author_id === $user->id && $commit->parent_id === null; } /** diff --git a/app/Models/Commit.php b/app/Models/Commit.php index 0410be261..11a84c66f 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -202,6 +202,14 @@ public function isAddressedTo(User $user): bool return in_array($user->id, $explicitMentions, true) || $this->parent?->author_id === $user->id; } + /** + * A commit is its author's own: a person's, or each member's of the organization it is from. + */ + public function isOwnedBy(User $user): bool + { + return $this->author_id !== null && ($this->author_id === $user->id || $user->organizations()->whereKey($this->author_id)->exists()); + } + /** * Approved commits are visible to everyone but people across a block with their author, which an organization never * is. Whoever posted a commit can also see it. diff --git a/app/Queries/LatestLogCommitsQuery.php b/app/Queries/LatestLogCommitsQuery.php index fa97ee2fd..b4ffd1eb4 100644 --- a/app/Queries/LatestLogCommitsQuery.php +++ b/app/Queries/LatestLogCommitsQuery.php @@ -22,7 +22,10 @@ public function count(Log $log, ?User $user, Commit $since): int return $log->query()->builder($user) ->newerThan($since) - ->when($user, fn (CommitBuilder $query, User $user): CommitBuilder => $query->where($query->qualifyColumn('user_id'), '!=', $user->id)) + // The user's own commits aren't news to them, nor are their organizations', as the log's page tells them apart. + ->when($user, fn (CommitBuilder $query, User $user): CommitBuilder => $query + ->where($query->qualifyColumn('author_id'), '!=', $user->id) + ->whereNotIn($query->qualifyColumn('author_id'), $user->organizations()->select('organizations.id'))) ->count(); } diff --git a/app/Queries/OrganizationCommitsQuery.php b/app/Queries/OrganizationCommitsQuery.php new file mode 100644 index 000000000..a4ff2f68f --- /dev/null +++ b/app/Queries/OrganizationCommitsQuery.php @@ -0,0 +1,28 @@ +where('author_id', $organization->id) + ->visibleTo($viewer) + ->topLevel() + ->withVisibleReplyCount($viewer) + ->withReplyPreviews($viewer) + ->withCardDataFor($viewer) + ->latest() + ->latest('id'); + } +} diff --git a/app/Rules/OpenPoll.php b/app/Rules/OpenPoll.php index 819714dbb..c47fbe1c5 100644 --- a/app/Rules/OpenPoll.php +++ b/app/Rules/OpenPoll.php @@ -31,7 +31,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void if ($this->commit->status !== CommitStatus::Approved || $poll->opened_at === null) { $fail('This poll is not open for votes.'); - } elseif ($this->commit->user_id === $this->voter->id) { + } elseif ($this->commit->isOwnedBy($this->voter)) { $fail("You can't vote on your own poll."); } elseif ($poll->isClosed()) { $fail('This poll has closed.'); diff --git a/app/Rules/OrganizationToPostAs.php b/app/Rules/OrganizationToPostAs.php new file mode 100644 index 000000000..96f9f718a --- /dev/null +++ b/app/Rules/OrganizationToPostAs.php @@ -0,0 +1,33 @@ +organizations()->whereRelation('profile', 'handle', $handle)->first() : null; + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! self::of($this->user, $value) instanceof Organization) { + $fail('You can only post as an organization you are a member of.'); + } + } +} diff --git a/resources/js/components/author-switcher.tsx b/resources/js/components/author-switcher.tsx new file mode 100644 index 000000000..b0b91b821 --- /dev/null +++ b/resources/js/components/author-switcher.tsx @@ -0,0 +1,91 @@ +import { usePage } from '@inertiajs/react'; +import { ChevronDown } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { OrganizationAvatar } from '@/components/organization-avatar'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { UserAvatar } from '@/components/user-avatar'; +import type { OrganizationAuthor, User } from '@/types'; + +/** + * The avatar of a composer, which members of an organization open to post as it instead of themselves. Everyone else + * just sees their own. + */ +export function AuthorSwitcher({ + user, + author, + onChange, + children, + fallback, +}: { + user: User; + author: OrganizationAuthor | null; + onChange: (author: OrganizationAuthor | null) => void; + children: ReactNode; + fallback: ReactNode; +}) { + const { auth } = usePage().props; + + if (auth.organizations.length === 0) { + return fallback; + } + + return ( + + + {children} + + + + + + + Post as + + onChange(null)} + > + + + + {auth.organizations.map((organization) => ( + onChange(organization)} + > + + + + ))} + + + ); +} + +function Identity({ name, handle }: { name: string; handle: string }) { + return ( + + {name} + + @{handle} + + + ); +} diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index f6d0f51da..3f2089a59 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -54,6 +54,7 @@ import { useIsCommitDeleted, } from '@/hooks/use-deleted-resources'; import { useNow } from '@/hooks/use-now'; +import { useOwnsCommit } from '@/hooks/use-owns-commit'; import { clickedCommit } from '@/lib/commit-click'; import { commitMutationOptions } from '@/lib/commit-mutation-options'; import { measureCommitSize } from '@/lib/commit-size'; @@ -158,7 +159,7 @@ export function CommitCard({ const endsOnDot = hasRailTail && railNodePosition !== 'bottom' && endsWithDot; const graphEndId = `${commit.id}:end`; - const isOwn = commit.author.id === auth.user?.id; + const isOwn = useOwnsCommit()(commit); const isFresh = isOwn && isWithinSeconds(commit.created_at, 15); const isDeletable = isOwn && canDelete; const hash = shortHash(commit.id); diff --git a/resources/js/components/commit-composer.tsx b/resources/js/components/commit-composer.tsx index 7f0ae2e56..760bc26b4 100644 --- a/resources/js/components/commit-composer.tsx +++ b/resources/js/components/commit-composer.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from 'react'; import CommitController from '@/actions/App/Http/Controllers/CommitController'; import ScheduledCommitController from '@/actions/App/Http/Controllers/ScheduledCommitController'; import { ActionTooltip } from '@/components/action-tooltip'; +import { AuthorSwitcher } from '@/components/author-switcher'; import { BodyCounter } from '@/components/body-counter'; import { ImageDropOverlay, @@ -64,9 +65,10 @@ import { cn, isRejectedSubmission, laneColor, + organizationLaneColor, } from '@/lib/utils'; import { show } from '@/routes/user'; -import type { User } from '@/types'; +import type { OrganizationAuthor, User } from '@/types'; // The ring moves quickly while a post starts and slows toward the limit, yet only closes at the limit. function bodyProgress(count: number): number { @@ -96,6 +98,8 @@ function CommitComposerBody({ id, isInDialog, user, + author, + onAuthorChange, stash, attachments, linkCard, @@ -113,6 +117,8 @@ function CommitComposerBody({ id: string; isInDialog: boolean; user: User; + author: OrganizationAuthor | null; + onAuthorChange: (author: OrganizationAuthor | null) => void; stash: UseStashReturn; attachments: ReturnType; linkCard: UseLinkCardReturn; @@ -179,6 +185,20 @@ function CommitComposerBody({ return () => observer.disconnect(); }, [isInDialog]); + // Scheduled commits are always one's own, for now. + const postingAs = isScheduling ? null : author; + const ownNode = isInDialog ? ( + + ) : ( + + + + ); + const insertEmoji = useEmojiInsertion({ body, updateBody, @@ -195,21 +215,30 @@ function CommitComposerBody({ )} + isScheduling ? ( + ownNode ) : ( - - - + {author === null ? ( + + ) : ( + + )} + ) } /> @@ -590,6 +619,7 @@ export function CommitComposer({ const { isScheduling, scheduledAt, setScheduledAt, setIsScheduling } = schedule; const { user } = useUser(); + const [author, setAuthor] = useState(null); const stash = useStash(user.id, 'commit'); const { body } = stash; const linkCard = useLinkCard(body); @@ -693,6 +723,8 @@ export function CommitComposer({ transform={(data) => ({ ...data, commit_rules_accepted: commitRules.hasAccepted(), + ...(author !== null && + !isScheduling && { author: author.handle }), ...(hasImages && { images: attachments.images, image_descriptions: attachments.descriptions, @@ -710,6 +742,7 @@ export function CommitComposer({ } drop(); + setAuthor(null); setScheduledAt(''); setIsScheduling(false); attachments.clear(); @@ -730,6 +763,8 @@ export function CommitComposer({ id={id} isInDialog={isInDialog} user={user} + author={author} + onAuthorChange={setAuthor} stash={stash} attachments={attachments} linkCard={linkCard} diff --git a/resources/js/components/commit-pastel-button.tsx b/resources/js/components/commit-pastel-button.tsx index 1d46f7bda..14b4a1e36 100644 --- a/resources/js/components/commit-pastel-button.tsx +++ b/resources/js/components/commit-pastel-button.tsx @@ -47,7 +47,8 @@ export function CommitPastelButton({ const busy = useRef(false); const emoji = useRef(null); - if (!auth.pasteis || !auth.user) { + // An organization's commits take no pastéis: they would go to whoever posted them. + if (!auth.pasteis || !auth.user || commit.author.type === 'organization') { return null; } diff --git a/resources/js/components/commit-poll.tsx b/resources/js/components/commit-poll.tsx index 436f13a41..02fe77983 100644 --- a/resources/js/components/commit-poll.tsx +++ b/resources/js/components/commit-poll.tsx @@ -7,6 +7,7 @@ import { BodySegments } from '@/components/commit-body'; import { Button } from '@/components/ui/button'; import { useCommitNotFound } from '@/hooks/use-deleted-resources'; import { useNow } from '@/hooks/use-now'; +import { useOwnsCommit } from '@/hooks/use-owns-commit'; import { bodySegments } from '@/lib/code'; import { exactCountTitle, formatCount, formatExactCount } from '@/lib/numbers'; import { cn } from '@/lib/utils'; @@ -69,6 +70,7 @@ export function CommitPoll({ isStatic?: boolean; }) { const { auth } = usePage().props; + const ownsCommit = useOwnsCommit(); const commitNotFound = useCommitNotFound(); const now = useNow(); const id = useId(); @@ -84,7 +86,7 @@ export function CommitPoll({ const canVote = auth.user !== null && commit.status === 'approved' && - commit.author.id !== auth.user.id && + !ownsCommit(commit) && poll.closes_at !== null && !poll.has_voted && !hasExpired; diff --git a/resources/js/components/commit-quote-button.tsx b/resources/js/components/commit-quote-button.tsx index 332311487..844df1fdc 100644 --- a/resources/js/components/commit-quote-button.tsx +++ b/resources/js/components/commit-quote-button.tsx @@ -33,6 +33,7 @@ import { UserAvatar } from '@/components/user-avatar'; import { useMarkCommitDeleted } from '@/hooks/use-deleted-resources'; import { useImageAttachments } from '@/hooks/use-image-attachments'; import { useLinkCard } from '@/hooks/use-link-card'; +import { useOwnsCommit } from '@/hooks/use-owns-commit'; import { moveCaretToEnd, useHasStash, useStash } from '@/hooks/use-stash'; import { useUser } from '@/hooks/use-user'; import { signIn } from '@/lib/auth'; @@ -56,10 +57,11 @@ export function CommitQuoteButton({ className?: string; }) { const { auth } = usePage().props; + const ownsCommit = useOwnsCommit(); const [isOpen, setIsOpen] = useState(false); const hasStash = useHasStash(auth.user?.id ?? null, `quote-${commit.id}`); - if (commit.status !== 'approved' && commit.author.id !== auth.user?.id) { + if (commit.status !== 'approved' && !ownsCommit(commit)) { return null; } diff --git a/resources/js/components/commit-report-button.tsx b/resources/js/components/commit-report-button.tsx index d56cc1f64..9453010d3 100644 --- a/resources/js/components/commit-report-button.tsx +++ b/resources/js/components/commit-report-button.tsx @@ -29,6 +29,7 @@ import { } from '@/components/ui/tooltip'; import { useCommitNotFound } from '@/hooks/use-deleted-resources'; import { useImageAttachments } from '@/hooks/use-image-attachments'; +import { useOwnsCommit } from '@/hooks/use-owns-commit'; import type { Commit } from '@/types'; const reasons = [ @@ -40,11 +41,12 @@ const reasons = [ export function useCanReport(commit: Commit): boolean { const { auth } = usePage().props; + const ownsCommit = useOwnsCommit(); return ( auth.user !== null && commit.status === 'approved' && - commit.author.id !== auth.user.id + !ownsCommit(commit) ); } diff --git a/resources/js/components/commit-star-button.tsx b/resources/js/components/commit-star-button.tsx index dcecc5194..be3f33800 100644 --- a/resources/js/components/commit-star-button.tsx +++ b/resources/js/components/commit-star-button.tsx @@ -8,6 +8,7 @@ import { CommitStarPollingContext } from '@/components/commit-star-polling'; import { CommitStargazersDialog } from '@/components/commit-stargazers-dialog'; import { StarredRemovalContext } from '@/components/starred-removal-context'; import { useCommitNotFound } from '@/hooks/use-deleted-resources'; +import { useOwnsCommit } from '@/hooks/use-owns-commit'; import { refreshSharedState, useSharedState } from '@/hooks/use-shared-state'; import { signIn } from '@/lib/auth'; import { createCommitStarMutations } from '@/lib/commit-star-mutations'; @@ -40,7 +41,7 @@ export function CommitStarButton({ }) { const { auth } = usePage().props; const commitNotFound = useCommitNotFound(); - const isOwn = commit.author.id === auth.user?.id; + const isOwn = useOwnsCommit()(commit); const [starred, setStarred] = useSharedState( commitStateKey('starred', commit.id), commit.starred, diff --git a/resources/js/components/commit-thread.tsx b/resources/js/components/commit-thread.tsx index e9c808e74..a7c63db30 100644 --- a/resources/js/components/commit-thread.tsx +++ b/resources/js/components/commit-thread.tsx @@ -21,6 +21,7 @@ import CommitLinkPreviewController from '@/actions/App/Http/Controllers/CommitLi import CommitReplyController from '@/actions/App/Http/Controllers/CommitReplyController'; import { ActionTooltip } from '@/components/action-tooltip'; import { AuthorAvatar } from '@/components/author-avatar'; +import { AuthorSwitcher } from '@/components/author-switcher'; import { BodyCounter } from '@/components/body-counter'; import { FoldedCommitBody } from '@/components/commit-body'; import { @@ -56,6 +57,7 @@ import InputError from '@/components/input-error'; import { LinkCardPicker } from '@/components/link-card-picker'; import { LinkPreview } from '@/components/link-preview'; import { MentionTextarea } from '@/components/mention-textarea'; +import { OrganizationAvatar } from '@/components/organization-avatar'; import { ReplyRecipientPicker } from '@/components/reply-recipient-picker'; import { StashLabel } from '@/components/stash-label'; import { showTimeout, timeoutFrom } from '@/components/timeout-dialog'; @@ -66,6 +68,7 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; +import { UserAvatar } from '@/components/user-avatar'; import { UserPreview } from '@/components/user-preview'; import { useCommitNotFound, @@ -76,6 +79,7 @@ import { useImageAttachments } from '@/hooks/use-image-attachments'; import { focusCommit } from '@/hooks/use-keyboard-shortcuts'; import { useLinkCard } from '@/hooks/use-link-card'; import { useNow } from '@/hooks/use-now'; +import { useOwnsCommit } from '@/hooks/use-owns-commit'; import { useSharedState } from '@/hooks/use-shared-state'; import { moveCaretToEnd, useHasStash, useStash } from '@/hooks/use-stash'; import { useUser } from '@/hooks/use-user'; @@ -104,6 +108,7 @@ import type { CursorPaginated, DeletedFlash, FlashToast, + OrganizationAuthor, ThreadCommit, } from '@/types'; @@ -1105,7 +1110,7 @@ function CommitReply({ [nodeRef], ); - const isOwn = reply.author.id === auth.user?.id; + const isOwn = useOwnsCommit()(reply); const isFlattened = depth > maxIndentDepth; const isQuote = reply.quoted_commit_id === parent.id; const permalink = CommitController.show({ @@ -1352,6 +1357,7 @@ function ReplyComposer({ onReplied: (parent: RepliedParent) => void; }) { const { user } = useUser(); + const [author, setAuthor] = useState(null); const commitNotFound = useCommitNotFound(); const participants = Array.from( new Map( @@ -1482,6 +1488,7 @@ function ReplyComposer({ ...(linkCard.isChosen ? { link_preview_url: linkCard.link ?? '' } : {}), + ...(author !== null && { author: author.handle }), })); request @@ -1489,6 +1496,7 @@ function ReplyComposer({ onSuccess: (reply) => { attachments.clear(); drop(); + setAuthor(null); linkCard.reset(); returnFocusToCommit(); router.flash(() => reply); @@ -1604,6 +1612,21 @@ function ReplyComposer({ />
+ + {author === null ? ( + + ) : ( + + )} + - | Extract; + user: Pick | OrganizationAuthor; // Fills the ring clockwise from 0 to 1, as a post grows toward its limit. At 0 the ring stays dashed. progress?: number; }) { diff --git a/resources/js/hooks/use-last-visit.ts b/resources/js/hooks/use-last-visit.ts index 771a3bbf7..5b56ac696 100644 --- a/resources/js/hooks/use-last-visit.ts +++ b/resources/js/hooks/use-last-visit.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useOwnsCommit } from '@/hooks/use-owns-commit'; import { readStorage, writeStorage } from '@/lib/storage'; import type { Commit, Log } from '@/types'; @@ -11,6 +12,7 @@ export function useLastVisit( commits: Commit[], ): string | null { const key = keyFor(userId, log); + const ownsCommit = useOwnsCommit(); const [lastVisitAt] = useState(() => readStorage(key)); const newestAt = commits[0]?.created_at; @@ -29,7 +31,7 @@ export function useLastVisit( ); const isNewFromOthers = commits .slice(0, Math.max(firstSeen, 0)) - .some((commit) => commit.author.id !== userId); + .some((commit) => !ownsCommit(commit)); return isNewFromOthers ? commits[firstSeen].id : null; } diff --git a/resources/js/hooks/use-owns-commit.ts b/resources/js/hooks/use-owns-commit.ts new file mode 100644 index 000000000..4aaf8f460 --- /dev/null +++ b/resources/js/hooks/use-owns-commit.ts @@ -0,0 +1,16 @@ +import { usePage } from '@inertiajs/react'; + +/** + * Whether the signed-in user owns a commit: one from them, or from an organization they can post as. + */ +export function useOwnsCommit(): (commit: { + author: { id: string }; +}) => boolean { + const { auth } = usePage().props; + + return (commit) => + commit.author.id === auth.user?.id || + auth.organizations.some( + (organization) => organization.id === commit.author.id, + ); +} diff --git a/resources/js/pages/log/show.tsx b/resources/js/pages/log/show.tsx index 3930aecb3..28cff9c92 100644 --- a/resources/js/pages/log/show.tsx +++ b/resources/js/pages/log/show.tsx @@ -21,6 +21,7 @@ import { WhoToFollowInLog } from '@/components/who-to-follow'; import { useComposerVisibility } from '@/hooks/use-composer-visibility'; import { useLaneSwipe } from '@/hooks/use-lane-swipe'; import { useLastVisit } from '@/hooks/use-last-visit'; +import { useOwnsCommit } from '@/hooks/use-owns-commit'; import AppLayout from '@/layouts/app-layout'; import { backgroundPollOptions } from '@/lib/background-poll'; import { emojify } from '@/lib/emoji'; @@ -42,6 +43,7 @@ export default function Show({ props: { auth }, } = usePage(); const user = auth.user; + const ownsCommit = useOwnsCommit(); const [notice, setNotice] = useState<{ commits: Commit[]; leaving: boolean; @@ -357,8 +359,7 @@ export default function Show({ .commits as CursorPaginated; const arrivals = incoming.data.filter( (commit) => - !known.has(commit.id) && - commit.author.id !== user?.id, + !known.has(commit.id) && !ownsCommit(commit), ); if ( arrivals.length === 0 || diff --git a/resources/js/pages/organization/show.tsx b/resources/js/pages/organization/show.tsx index e77e93b52..6f7411f3d 100644 --- a/resources/js/pages/organization/show.tsx +++ b/resources/js/pages/organization/show.tsx @@ -29,7 +29,7 @@ export default function Show({ isFollowing, }: { organization: Organization; - tab: 'mentions'; + tab: 'commits' | 'mentions'; commits: CursorPaginated; claimUrl: string; settingsUrl: string | null; @@ -257,8 +257,15 @@ export default function Show({ reset={['commits']} tabs={[ { - title: 'Mentions', + title: 'Commits', href: show(organization), + isActive: tab === 'commits', + }, + { + title: 'Mentions', + href: show(organization, { + query: { tab: 'mentions' }, + }), isActive: tab === 'mentions', }, ]} @@ -267,14 +274,25 @@ export default function Show({ - Nobody has mentioned @{organization.handle} yet. - Mention it in a commit and it shows up here. - + tab === 'mentions' ? ( + + Nobody has mentioned @{organization.handle} yet. + Mention it in a commit and it shows up here. + + ) : ( + + @{organization.handle} hasn't committed anything + yet. + + ) } />
diff --git a/resources/js/types/auth.ts b/resources/js/types/auth.ts index d1ace0527..2e5deceb3 100644 --- a/resources/js/types/auth.ts +++ b/resources/js/types/auth.ts @@ -1,3 +1,4 @@ +import type { OrganizationAuthor } from '@/types/commit'; import type { RemotesFrom } from '@/types/conversation'; import type { Log } from '@/types/log'; @@ -45,6 +46,10 @@ export type Profile = User & { export type Auth = { user: Profile | null; bioProfiles: Record; + /** + * The organizations the user can post as. + */ + organizations: OrganizationAuthor[]; isAdmin: boolean; automaticTranslation: boolean; mustReadCommitRules: boolean; diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts index df338ab1a..cc7945a76 100644 --- a/resources/js/types/commit.ts +++ b/resources/js/types/commit.ts @@ -79,6 +79,11 @@ export type CommitAuthor = | (User & { type: 'user' }) | (Organization & { type: 'organization' }); +export type OrganizationAuthor = Extract< + CommitAuthor, + { type: 'organization' } +>; + export type Commit = { id: string; parent_id: string | null; diff --git a/resources/js/types/ui.ts b/resources/js/types/ui.ts index 350a78642..3980184d0 100644 --- a/resources/js/types/ui.ts +++ b/resources/js/types/ui.ts @@ -19,7 +19,7 @@ export type TimeoutFlash = { export type CommittedFlash = { id: string; body: string; - user: { handle: string }; + author: { handle: string }; }; export type DeletedFlash = DeletedCommit & { diff --git a/tests/Feature/Controllers/CommitControllerTest.php b/tests/Feature/Controllers/CommitControllerTest.php index 4a4efc18f..8edc654c7 100644 --- a/tests/Feature/Controllers/CommitControllerTest.php +++ b/tests/Feature/Controllers/CommitControllerTest.php @@ -48,7 +48,7 @@ $response->assertInertiaFlash('committed', [ 'id' => $commit->id, 'body' => 'Initial commit', - 'user' => ['handle' => $user->handle], + 'author' => ['handle' => $user->handle], ])->assertInertiaFlash('achievementsUnlocked', ['init']); }); @@ -67,7 +67,7 @@ expect($page['flash']['committed'] ?? null)->toBe([ 'id' => $user->commits()->sole()->id, 'body' => 'Initial commit', - 'user' => ['handle' => $user->handle], + 'author' => ['handle' => $user->handle], ]); }); diff --git a/tests/Feature/Controllers/CommitReplyControllerTest.php b/tests/Feature/Controllers/CommitReplyControllerTest.php index af8a52210..c69d00932 100644 --- a/tests/Feature/Controllers/CommitReplyControllerTest.php +++ b/tests/Feature/Controllers/CommitReplyControllerTest.php @@ -371,7 +371,7 @@ $response->assertCreated() ->assertExactJson([ - 'committed' => ['id' => $reply->id, 'body' => 'Agreed, ship it.', 'user' => ['handle' => $user->handle]], + 'committed' => ['id' => $reply->id, 'body' => 'Agreed, ship it.', 'author' => ['handle' => $user->handle]], 'parent' => ['id' => $parent->id, 'replies_count' => 1, 'continuation_id' => null], 'toast' => [ 'type' => 'info', diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index d9b12098d..5a6487f4e 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -90,7 +90,7 @@ $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); expect($data)->toBe([ - 'schema_version' => 4, + 'schema_version' => 5, 'exported_at' => now()->toIso8601String(), 'profile' => [ 'github_id' => $user->github_id, @@ -128,6 +128,7 @@ ], 'active_days' => [], 'commits' => [], + 'organization_commits' => [], 'polls' => [], 'poll_votes' => [], 'stars' => [], diff --git a/tests/Feature/Controllers/OrganizationControllerTest.php b/tests/Feature/Controllers/OrganizationControllerTest.php index dc32fc11c..737bc08d2 100644 --- a/tests/Feature/Controllers/OrganizationControllerTest.php +++ b/tests/Feature/Controllers/OrganizationControllerTest.php @@ -159,7 +159,7 @@ $mention(Commit::factory()->create(['body' => 'Deleted @laravel']))->delete(); $mention(Commit::factory()->create(['body' => 'Thanks @symfony']), $symfony); - $this->get('/@laravel') + $this->get('/@laravel?tab=mentions') ->assertOk() ->assertInertia(fn ($page) => $page ->component('organization/show') @@ -186,7 +186,7 @@ $mention(Commit::factory()->for($muted)->create(['body' => 'Muted @laravel'])); $mention(Commit::factory()->for($blocked)->create(['body' => 'Blocked @laravel'])); - $this->actingAs($viewer)->get('/@laravel') + $this->actingAs($viewer)->get('/@laravel?tab=mentions') ->assertOk() ->assertInertia(fn ($page) => $page ->component('organization/show') @@ -217,7 +217,7 @@ $organization->profile->forceFill(['handle' => 'laravel-php'])->save(); - $this->get('/@laravel-php') + $this->get('/@laravel-php?tab=mentions') ->assertOk() ->assertInertia(fn ($page) => $page ->component('organization/show') diff --git a/tests/Feature/OrganizationCommitsTest.php b/tests/Feature/OrganizationCommitsTest.php new file mode 100644 index 000000000..32d310825 --- /dev/null +++ b/tests/Feature/OrganizationCommitsTest.php @@ -0,0 +1,196 @@ +organization = Organization::factory()->create(['handle' => 'Laravel']); + $this->member = User::factory()->create(['pasteis_mode' => true]); + $this->organization->members()->attach($this->member->id, ['role' => OrganizationRole::Member]); +}); + +it('lets a member commit as the organization, by its handle in any case', function (): void { + $mentioned = User::factory()->create(['handle' => 'taylor']); + + $this->actingAs($this->member) + ->fromRoute('log.show', Log::Following) + ->post(route('commit.store'), ['body' => 'Laravel 13 is out, thanks @taylor', 'author' => 'laravel']) + ->assertRedirectToRoute('log.show', Log::Following) + ->assertInertiaFlash('committed.author', ['handle' => 'Laravel']); + + $commit = Commit::query()->sole(); + + expect($commit->author_id)->toBe($this->organization->id) + ->and($commit->user_id)->toBe($this->member->id) + ->and(Notification::query()->where('recipient_id', $mentioned->id)->exists())->toBeFalse(); +}); + +it('only lets members commit as an organization', function (): void { + $this->actingAs(User::factory()->create()) + ->post(route('commit.store'), ['body' => 'Not mine to post', 'author' => 'laravel']) + ->assertSessionHasErrors(['author' => 'You can only post as an organization you are a member of.']); + + expect(Commit::query()->exists())->toBeFalse(); +}); + +it('lets a member reply as the organization', function (): void { + $parent = Commit::factory()->create(); + + $this->actingAs($this->member) + ->postJson(route('commit-reply.store', $parent), ['body' => 'Fixed in 13.1', 'author' => 'laravel']) + ->assertCreated() + ->assertJsonPath('committed.author.handle', 'Laravel'); + + expect($parent->replies()->sole()->author_id)->toBe($this->organization->id) + ->and(Notification::query()->where('recipient_id', $parent->user_id)->exists())->toBeFalse(); +}); + +it('lets apps commit as the organization through the API', function (): void { + signedApiRequest(apiKeyPairFor($this->member), 'POST', route('api-commit.store'), ['body' => 'Laravel 13 is out', 'author' => 'laravel']) + ->assertCreated() + ->assertJsonPath('data.author.type', 'organization') + ->assertJsonPath('data.author.handle', 'Laravel'); +}); + +it('lets the members of an organization delete its commits, and only them', function (): void { + $commit = Commit::factory()->for(User::factory())->create(['author_id' => $this->organization->id]); + + $this->actingAs(User::factory()->create())->delete(route('commit.destroy', $commit))->assertForbidden(); + $this->actingAs($this->member)->delete(route('commit.destroy', $commit))->assertRedirect(); + + expect($commit->refresh()->trashed())->toBeTrue(); +}); + +it("treats the commits of an organization as its members' own", function (): void { + $commit = Commit::factory()->for(User::factory())->create(['author_id' => $this->organization->id]); + + $this->actingAs($this->member); + + $this->post(route('commit-star.store', $commit))->assertForbidden(); + $this->post(route('commit-report.store', $commit), ['reason' => 'spam'])->assertForbidden(); + $this->post(route('commit-pastel.store', $commit))->assertForbidden(); + $this->getJson(route('commit-star.index', $commit))->assertOk(); +}); + +it("takes no pastéis and pins on no one's profile", function (): void { + $commit = Commit::factory()->for($this->member)->create(['author_id' => $this->organization->id]); + + $this->actingAs(User::factory()->create(['pasteis_mode' => true]))->post(route('commit-pastel.store', $commit))->assertNotFound(); + $this->actingAs($this->member)->put(route('commit-pin.update', $commit))->assertForbidden(); +}); + +it("raises no one's level for stars on the commits of an organization", function (): void { + Bus::fake([CheckUserLevel::class]); + $stargazer = User::factory()->create(); + $commit = Commit::factory()->for($this->member)->create(['author_id' => $this->organization->id]); + + $this->actingAs($stargazer)->post(route('commit-star.store', $commit)); + + Bus::assertDispatched(CheckUserLevel::class, fn (CheckUserLevel $job): bool => $job->user->is($stargazer)); + Bus::assertNotDispatched(CheckUserLevel::class, fn (CheckUserLevel $job): bool => $job->user->is($this->member)); +}); + +it('keeps the commits someone posted for an organization when they delete their account', function (): void { + $commit = Commit::factory()->for($this->member)->create(['author_id' => $this->organization->id]); + $own = Commit::factory()->for($this->member)->for(Commit::factory(), 'parent')->create(); + Commit::factory()->for($own, 'parent')->create(); + + resolve(DeleteUser::class)->handle($this->member); + + $tombstone = Commit::withTrashed()->findOrFail($own->id); + + expect($commit->refresh()->user_id)->toBeNull() + ->and($commit->author_id)->toBe($this->organization->id) + ->and($commit->trashed())->toBeFalse() + ->and($tombstone->author_id)->toBeNull(); +}); + +it('notifies no one about the commits of an organization whose poster deleted their account', function (): void { + $commit = Commit::factory()->for($this->member)->create(['author_id' => $this->organization->id]); + resolve(DeleteUser::class)->handle($this->member); + $reply = Commit::factory()->for($commit, 'parent')->pending()->create(); + $quote = Commit::factory()->pending()->create(['quoted_commit_id' => $commit->id]); + + resolve(CreateCommitStar::class)->handle(User::factory()->create(), $commit->refresh()); + resolve(UpdateCommitStatuses::class)->handle([$reply->id, $quote->id], CommitStatus::Approved); + resolve(UpdateCommitStatuses::class)->handle([$commit->id], CommitStatus::Rejected); + + expect($commit->refresh()->status)->toBe(CommitStatus::Rejected) + ->and(Notification::query()->exists())->toBeFalse(); +}); + +it("tells a member of new commits that are not their organization's", function (): void { + $seen = Commit::factory()->create(['created_at' => now()->subMinute()]); + Commit::factory()->for(User::factory())->create(['author_id' => $this->organization->id]); + Commit::factory()->create(); + + expect(resolve(LatestLogCommitsQuery::class)->count(Log::Latest, $this->member, $seen))->toBe(1); +}); + +it('deletes the commits of an organization GitHub no longer has, keeping the replies under them', function (): void { + $commit = Commit::factory()->for($this->member)->create(['author_id' => $this->organization->id]); + $reply = Commit::factory()->for($commit, 'parent')->create(); + + resolve(DeleteOrganization::class)->handle($this->organization); + + $commit = Commit::withTrashed()->findOrFail($commit->id); + + expect($commit->trashed())->toBeTrue() + ->and($commit->author_id)->toBeNull() + ->and($reply->refresh()->trashed())->toBeFalse(); +}); + +it('exports the commits someone posted for an organization', function (): void { + $commit = Commit::factory()->for($this->member)->create(['author_id' => $this->organization->id, 'body' => 'Laravel 13 is out']); + + $export = resolve(CreateExport::class)->handle($this->member); + + expect(iterator_to_array($export['organization_commits']))->toBe([[ + 'id' => $commit->id, + 'organization' => 'Laravel', + 'parent_id' => null, + 'quoted_commit_id' => null, + 'body' => 'Laravel 13 is out', + 'status' => 'approved', + 'created_at' => $commit->created_at->toIso8601String(), + 'deleted_at' => null, + ]])->and(iterator_to_array($export['commits']))->toBe([]); +}); + +it('lists the commits of an organization on its page, first', function (): void { + $commit = Commit::factory()->create(['author_id' => $this->organization->id]); + Commit::factory()->for($commit, 'parent')->create(['author_id' => $this->organization->id]); + + $this->get('/@Laravel') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->where('tab', 'commits') + ->has('commits.data', 1) + ->where('commits.data.0.id', $commit->id)); +}); + +it('shares the organizations a member can post as', function (): void { + $this->actingAs($this->member) + ->get(route('log.show', Log::Following)) + ->assertInertia(fn ($page) => $page + ->has('auth.organizations', 1) + ->where('auth.organizations.0.type', 'organization') + ->where('auth.organizations.0.id', $this->organization->id) + ->where('auth.organizations.0.handle', 'Laravel') + ->where('auth.organizations.0.lane', null)); +}); diff --git a/tests/Unit/CommitSecondaryActions.test.tsx b/tests/Unit/CommitSecondaryActions.test.tsx index f939a2e31..96dfbb2e9 100644 --- a/tests/Unit/CommitSecondaryActions.test.tsx +++ b/tests/Unit/CommitSecondaryActions.test.tsx @@ -16,11 +16,11 @@ const commit = { async function desktopActionsClasses(isReply: boolean): Promise { const result = await createInertiaApp<{ - auth: { user: { id: string } | null }; + auth: { organizations: [], user: { id: string } | null }; }>({ page: { component: 'actions', - props: { auth: { user: { id: 'viewer' } }, errors: {} }, + props: { auth: { organizations: [], user: { id: 'viewer' } }, errors: {} }, url: '/@author/commit', version: null, clearHistory: false, diff --git a/tests/Unit/CommitStarButton.test.tsx b/tests/Unit/CommitStarButton.test.tsx index d9feb6e96..55796d0c4 100644 --- a/tests/Unit/CommitStarButton.test.tsx +++ b/tests/Unit/CommitStarButton.test.tsx @@ -13,11 +13,11 @@ async function render( commit: Partial = {}, ): Promise { const result = await createInertiaApp<{ - auth: { user: { id: string } | null }; + auth: { organizations: [], user: { id: string } | null }; }>({ page: { component: 'star', - props: { auth: { user }, errors: {} }, + props: { auth: { organizations: [], user }, errors: {} }, url: '/@author/commit', version: null, clearHistory: false, diff --git a/tests/Unit/CommitThread.test.tsx b/tests/Unit/CommitThread.test.tsx index 1b74ff7db..6807679d6 100644 --- a/tests/Unit/CommitThread.test.tsx +++ b/tests/Unit/CommitThread.test.tsx @@ -67,7 +67,7 @@ async function renderThread( const result = await createInertiaApp({ page: { component: 'test', - props: { auth: { user: null }, errors: {}, repliesPerPage }, + props: { auth: { organizations: [], user: null }, errors: {}, repliesPerPage }, url: '/', version: null, clearHistory: false, @@ -107,7 +107,7 @@ async function renderReplyComposer(mentions: Record | null) { page: { component: 'test', props: { - auth: { user: commit.author, log: {} }, + auth: { organizations: [], user: commit.author, log: {} }, errors: {}, repliesPerPage: 3, }, diff --git a/tests/Unit/CommittedToast.test.tsx b/tests/Unit/CommittedToast.test.tsx index cd51b7e1d..3357ff324 100644 --- a/tests/Unit/CommittedToast.test.tsx +++ b/tests/Unit/CommittedToast.test.tsx @@ -36,7 +36,7 @@ function render(): LinkProps { committed: { id: '019a0000-0000-7000-8000-00000c5efedb', body: 'Ship it :rocket:', - user: { handle: 'ada' }, + author: { handle: 'ada' }, }, toastId: 'committed-019a0000-0000-7000-8000-00000c5efedb', }) as ReactElement diff --git a/tests/Unit/ComposerPreview.test.tsx b/tests/Unit/ComposerPreview.test.tsx index ed5bb0511..36128ef8b 100644 --- a/tests/Unit/ComposerPreview.test.tsx +++ b/tests/Unit/ComposerPreview.test.tsx @@ -36,7 +36,7 @@ function renderPage( version: null, props: { errors: {}, - auth: { + auth: { organizations: [], user: null, pasteis: pasteisMode ? { remaining: 0, day: '2026-10-16' } @@ -145,7 +145,7 @@ function renderComposer(body: string, isInDialog = false): string { version: null, props: { errors: {}, - auth: { + auth: { organizations: [], user: { id: 'preview-user', name: 'Preview User', diff --git a/tests/Unit/LogInsertion.test.tsx b/tests/Unit/LogInsertion.test.tsx index 73d738e27..43f51fb02 100644 --- a/tests/Unit/LogInsertion.test.tsx +++ b/tests/Unit/LogInsertion.test.tsx @@ -77,7 +77,7 @@ function scheduleEffect( vi.doMock('@inertiajs/react', () => ({ Head: () => null, Link: () => null, - usePage: () => ({ props: { auth: { user: viewer } } }), + usePage: () => ({ props: { auth: { organizations: [], user: viewer } } }), usePoll: (_interval: number, options: () => ReloadOptions) => { pollOptions = options; return { stop: vi.fn() }; diff --git a/tests/Unit/QuoteReply.test.tsx b/tests/Unit/QuoteReply.test.tsx index 2e86ac212..b1bfaea3a 100644 --- a/tests/Unit/QuoteReply.test.tsx +++ b/tests/Unit/QuoteReply.test.tsx @@ -58,7 +58,7 @@ async function renderReplies(replies: Commit[], automaticTranslation = true) { page: { component: 'test', props: { - auth: { + auth: { organizations: [], user: automaticTranslation ? null : original.author, automaticTranslation, }, diff --git a/tests/Unit/StandaloneCommitGraphs.test.tsx b/tests/Unit/StandaloneCommitGraphs.test.tsx index 96af59a42..9583a4156 100644 --- a/tests/Unit/StandaloneCommitGraphs.test.tsx +++ b/tests/Unit/StandaloneCommitGraphs.test.tsx @@ -36,7 +36,7 @@ async function render(Page: ComponentType): Promise { const result = await createInertiaApp({ page: { component: 'test', - props: { auth: { user: null }, errors: {} }, + props: { auth: { organizations: [], user: null }, errors: {} }, url: '/', version: null, clearHistory: false, From 5dc8cf19448c9c3ad7348a86e94f24004439dc01 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Sun, 1 Nov 2026 00:18:46 +0100 Subject: [PATCH 2/7] refactor: share the organizations a member can post as the way organizations serialize --- app/Http/Middleware/HandleInertiaRequests.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 75cee4737..695601373 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -96,7 +96,7 @@ public function share(Request $request): array 'bioProfiles' => fn (): array => $user instanceof User ? resolve(BioProfilesQuery::class)->get($user) : [], // The organizations the user can post as, shown like the commits they author. 'organizations' => fn (): array => $user instanceof User ? $user->organizations()->get() - ->map(fn (Organization $organization): array => [...$organization->toArray(), 'type' => 'organization', 'id' => $organization->id, 'lane' => $organization->lane()]) + ->map(fn (Organization $organization): array => ['type' => 'organization', ...$organization->toArray()]) ->all() : [], 'isAdmin' => $isAdmin, 'automaticTranslation' => $user->automatic_translation ?? true, From b6dadd73168d0d91fd9d52cca8633f894faa7ae7 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Sun, 1 Nov 2026 13:58:04 +0100 Subject: [PATCH 3/7] fix: export the images, polls and orphaned commits someone posted for an organization, and lock it while deleting it --- app/Actions/CreateExport.php | 12 ++++++++---- app/Actions/DeleteOrganization.php | 3 +++ tests/Feature/OrganizationCommitsTest.php | 24 +++++++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 91fe0f22a..2c6947a2a 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -217,7 +217,8 @@ private function commits(User $user): Generator } /** - * What the user posted for organizations, which is theirs to read back, though the organizations' own. + * What the user posted for organizations, which is theirs to read back, though the organizations' own, even once + * GitHub no longer has the organization and its commits lost their author. * * @return Generator> */ @@ -226,8 +227,8 @@ private function organizationCommits(User $user): Generator $commits = Commit::query() ->withTrashed() ->where('user_id', $user->id) - ->whereColumn('author_id', '!=', 'user_id') - ->with('author') + ->where(fn (CommitBuilder $query): CommitBuilder => $query->whereColumn('author_id', '!=', 'user_id')->orWhereNull('author_id')) + ->with(['author', 'images:id,commit_id']) ->lazyById(500); foreach ($commits as $commit) { @@ -238,6 +239,7 @@ private function organizationCommits(User $user): Generator 'quoted_commit_id' => $commit->quoted_commit_id, 'body' => $commit->body, 'status' => $commit->status->value, + 'images' => $commit->images->map(fn (CommitImage $image): string => $image->url())->all(), 'created_at' => $commit->created_at->toIso8601String(), 'deleted_at' => $commit->deleted_at?->toIso8601String(), ]; @@ -245,12 +247,14 @@ private function organizationCommits(User $user): Generator } /** + * The polls of every commit the user posted, theirs or an organization's. + * * @return Generator */ private function polls(User $user): Generator { $polls = Poll::query() - ->whereHas('commit', fn (CommitBuilder $query): CommitBuilder => $query->where('author_id', $user->id)) + ->whereHas('commit', fn (CommitBuilder $query): CommitBuilder => $query->where('user_id', $user->id)) ->with('options') ->lazyById(500); diff --git a/app/Actions/DeleteOrganization.php b/app/Actions/DeleteOrganization.php index 6f5f8b30a..8c5987542 100644 --- a/app/Actions/DeleteOrganization.php +++ b/app/Actions/DeleteOrganization.php @@ -24,6 +24,9 @@ public function __construct(private DeleteCommit $deleteCommit) public function handle(Organization $organization): void { DB::transaction(function () use ($organization): void { + // A commit posted for it meanwhile waits on the profile, then fails, rather than slipping past the deletion + // and losing its author while still live. + $organization->profile()->lockForUpdate()->first(); $organization->profile->commits()->get()->each(fn (Commit $commit): Commit => $this->deleteCommit->handle($commit)); $organization->profile->commits()->withTrashed()->update(['author_id' => null]); diff --git a/tests/Feature/OrganizationCommitsTest.php b/tests/Feature/OrganizationCommitsTest.php index 32d310825..5340f9c16 100644 --- a/tests/Feature/OrganizationCommitsTest.php +++ b/tests/Feature/OrganizationCommitsTest.php @@ -12,8 +12,10 @@ use App\Enums\OrganizationRole; use App\Jobs\CheckUserLevel; use App\Models\Commit; +use App\Models\CommitImage; use App\Models\Notification; use App\Models\Organization; +use App\Models\Poll; use App\Models\User; use App\Queries\LatestLogCommitsQuery; use Illuminate\Support\Facades\Bus; @@ -167,11 +169,33 @@ 'quoted_commit_id' => null, 'body' => 'Laravel 13 is out', 'status' => 'approved', + 'images' => [], 'created_at' => $commit->created_at->toIso8601String(), 'deleted_at' => null, ]])->and(iterator_to_array($export['commits']))->toBe([]); }); +it('exports the images and polls of what someone posted for an organization, even once GitHub no longer has it', function (): void { + $commit = Commit::factory()->for($this->member)->create(['author_id' => $this->organization->id]); + $image = CommitImage::factory()->for($commit)->create(); + Poll::factory()->for($commit)->withOptions('Tabs', 'Spaces')->create(); + + $export = resolve(CreateExport::class)->handle($this->member); + $exported = iterator_to_array($export['organization_commits'])[0]; + + expect($exported['images'])->toBe([route('commit-image.show', $image)]) + ->and(iterator_to_array($export['polls'])[0]['options'])->toBe(['Tabs', 'Spaces']); + + resolve(DeleteOrganization::class)->handle($this->organization); + + $exported = iterator_to_array(resolve(CreateExport::class)->handle($this->member)['organization_commits']); + + expect($exported)->toHaveCount(1) + ->and($exported[0]['id'])->toBe($commit->id) + ->and($exported[0]['organization'])->toBeNull() + ->and($exported[0]['deleted_at'])->not->toBeNull(); +}); + it('lists the commits of an organization on its page, first', function (): void { $commit = Commit::factory()->create(['author_id' => $this->organization->id]); Commit::factory()->for($commit, 'parent')->create(['author_id' => $this->organization->id]); From 9418129af76f13910b64de3b0cdc4dd9eabd543d Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Sun, 1 Nov 2026 14:24:02 +0100 Subject: [PATCH 4/7] fix: never restore a commit of an organization GitHub no longer has --- app/Actions/DeleteOrganization.php | 8 +++++--- app/Models/Commit.php | 5 ++++- tests/Feature/OrganizationCommitsTest.php | 13 +++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/app/Actions/DeleteOrganization.php b/app/Actions/DeleteOrganization.php index 8c5987542..661cc418f 100644 --- a/app/Actions/DeleteOrganization.php +++ b/app/Actions/DeleteOrganization.php @@ -24,10 +24,12 @@ public function __construct(private DeleteCommit $deleteCommit) public function handle(Organization $organization): void { DB::transaction(function () use ($organization): void { - // A commit posted for it meanwhile waits on the profile, then fails, rather than slipping past the deletion - // and losing its author while still live. + // A commit posted for it meanwhile waits on the profile, and one restored meanwhile on its own row, then + // fails or stays deleted, rather than slipping past the deletion and losing its author while still live. $organization->profile()->lockForUpdate()->first(); - $organization->profile->commits()->get()->each(fn (Commit $commit): Commit => $this->deleteCommit->handle($commit)); + $organization->profile->commits()->withTrashed()->orderBy('id')->lockForUpdate()->get() + ->reject(fn (Commit $commit): bool => $commit->trashed()) + ->each(fn (Commit $commit): Commit => $this->deleteCommit->handle($commit)); $organization->profile->commits()->withTrashed()->update(['author_id' => null]); DB::table('commit_topics') diff --git a/app/Models/Commit.php b/app/Models/Commit.php index 11a84c66f..111376b3b 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -237,9 +237,12 @@ public function isBeingChecked(): bool && $this->created_at->greaterThan(now()->subMinutes(self::CHECKING_MINUTES)); } + /** + * A deleted commit comes back within seconds, unless its account or organization has gone since. + */ public function isRestorable(): bool { - return $this->user_id !== null && $this->deleted_at !== null && now()->lessThan($this->deleted_at->copy()->addSeconds(self::RESTORABLE_SECONDS)); + return $this->user_id !== null && $this->author_id !== null && $this->deleted_at !== null && now()->lessThan($this->deleted_at->copy()->addSeconds(self::RESTORABLE_SECONDS)); } /** diff --git a/tests/Feature/OrganizationCommitsTest.php b/tests/Feature/OrganizationCommitsTest.php index 5340f9c16..bbe8c561f 100644 --- a/tests/Feature/OrganizationCommitsTest.php +++ b/tests/Feature/OrganizationCommitsTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Actions\CreateCommitRestoration; use App\Actions\CreateCommitStar; use App\Actions\CreateExport; use App\Actions\DeleteOrganization; @@ -218,3 +219,15 @@ ->where('auth.organizations.0.handle', 'Laravel') ->where('auth.organizations.0.lane', null)); }); + +it('never restores a commit of an organization GitHub no longer has, even within the undo window', function (): void { + $live = Commit::factory()->for($this->member)->create(['author_id' => $this->organization->id]); + $deleted = Commit::factory()->for($this->member)->create(['author_id' => $this->organization->id]); + $deleted->delete(); + + resolve(DeleteOrganization::class)->handle($this->organization); + + foreach ([$live, $deleted] as $commit) { + expect(resolve(CreateCommitRestoration::class)->handle($commit)->trashed())->toBeTrue(); + } +}); From fadd6b19d6aad80a5d87b125919e0b4e39fbb603 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Sun, 1 Nov 2026 14:33:18 +0100 Subject: [PATCH 5/7] feat: keep posting as an organization behind a feature switch, off until it ships --- KNOWLEDGE_BASE.md | 2 +- app/Enums/Feature.php | 11 +++++++++- app/Http/Middleware/HandleInertiaRequests.php | 6 ++++-- app/Rules/OrganizationToPostAs.php | 11 +++++++++- app/Services/Features.php | 21 +++++++++---------- resources/js/pages/operations/features.tsx | 5 +++++ resources/js/types/operations.ts | 2 +- .../Controllers/FeatureControllerTest.php | 17 ++++++++++++--- tests/Feature/OrganizationCommitsTest.php | 15 +++++++++++++ 9 files changed, 70 insertions(+), 20 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 9377c804c..2610bb77f 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -120,7 +120,7 @@ A member of an organization can **Claim** its page, which lists them as its admi After that, members are managed on /dev/null, not mirrored from GitHub. An organization's members open its **Settings** from its page, where the **Members** tab lists everyone, each an **admin** or a **member**. Admins add people by their /dev/null handle, searching by handle or name as they type, except people they blocked or who blocked them, make them admins or members, and remove them. Any member can leave. Its last admin can't leave or become a member. Only its members see who its members are; its page lists its admins to everyone. Your organizations and your role in each are in your data export and go when you delete your account. -Its admins and members can **post as the organization**: in the composer, and in a reply's, they open their avatar and pick the organization, then switch back to themselves the same way. Every commit and reply starts as yourself. A commit from an organization shows the organization alone, with its square avatar and color, at `/@{handle}/commits/{id}`: nobody else sees which member posted it. It belongs to the organization: any of its members can delete it, and none can star it, report it, vote in its poll or give it pastéis. It counts toward no one's level, and blocking or muting the person who posted it doesn't hide it. Moderation still holds the person who posted it to account, and if they delete their account, the organization's commits they posted stay. Organizations can't schedule commits yet, and their commits don't notify anyone yet. Apps can post as an organization through the API too, by sending its handle as `author`. +Its admins and members can **post as the organization**: in the composer, and in a reply's, they open their avatar and pick the organization, then switch back to themselves the same way. Every commit and reply starts as yourself. A commit from an organization shows the organization alone, with its square avatar and color, at `/@{handle}/commits/{id}`: nobody else sees which member posted it. It belongs to the organization: any of its members can delete it, and none can star it, report it, vote in its poll or give it pastéis. It counts toward no one's level, and blocking or muting the person who posted it doesn't hide it. Moderation still holds the person who posted it to account, and if they delete their account, the organization's commits they posted stay. Organizations can't schedule commits yet, and their commits don't notify anyone yet. Apps can post as an organization through the API too, by sending its handle as `author`. Posting as an organization is still being rolled out: until administrators switch it on for everyone, the **Post as** menu doesn't show and the API refuses `author`. Every organization has a color, /dev/null's blue until an admin chooses another in the **Profile** tab of its **Settings**, where its admins land: one of the same six lane colors as developers, or a custom one. It rings the organization's avatar, leads its handle, and colors its link preview. diff --git a/app/Enums/Feature.php b/app/Enums/Feature.php index 0853d6f8e..70485a003 100644 --- a/app/Enums/Feature.php +++ b/app/Enums/Feature.php @@ -5,9 +5,18 @@ namespace App\Enums; /** - * A feature administrators can switch off for everyone from the operations dashboard. + * A feature administrators can switch on or off for everyone from the operations dashboard. */ enum Feature: string { case Translations = 'translations'; + case OrganizationCommits = 'organization_commits'; + + /** + * Features are on until switched off, except one still being built, which stays off until switched on. + */ + public function isEnabledByDefault(): bool + { + return $this !== self::OrganizationCommits; + } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 695601373..074d040e6 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -5,6 +5,7 @@ namespace App\Http\Middleware; use App\Dto\ReaderClock; +use App\Enums\Feature; use App\Enums\Log; use App\Enums\RepliesPerPage; use App\Http\Requests\ListNotificationsRequest; @@ -17,6 +18,7 @@ use App\Queries\LiveFollowingQuery; use App\Queries\NotificationGroupsQuery; use App\Queries\SuggestedUsersQuery; +use App\Services\Features; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -94,8 +96,8 @@ public function share(Request $request): array 'auth' => [ 'user' => fn (): ?User => $request->user()?->loadCounts(), 'bioProfiles' => fn (): array => $user instanceof User ? resolve(BioProfilesQuery::class)->get($user) : [], - // The organizations the user can post as, shown like the commits they author. - 'organizations' => fn (): array => $user instanceof User ? $user->organizations()->get() + // The organizations the user can post as, shown like the commits they author, once the feature is on. + 'organizations' => fn (): array => $user instanceof User && resolve(Features::class)->enabled(Feature::OrganizationCommits) ? $user->organizations()->get() ->map(fn (Organization $organization): array => ['type' => 'organization', ...$organization->toArray()]) ->all() : [], 'isAdmin' => $isAdmin, diff --git a/app/Rules/OrganizationToPostAs.php b/app/Rules/OrganizationToPostAs.php index 96f9f718a..9a5dbc186 100644 --- a/app/Rules/OrganizationToPostAs.php +++ b/app/Rules/OrganizationToPostAs.php @@ -4,13 +4,16 @@ namespace App\Rules; +use App\Enums\Feature; use App\Models\Organization; use App\Models\User; +use App\Services\Features; use Closure; use Illuminate\Contracts\Validation\ValidationRule; /** - * Admins and members alike may post as their organization, by its handle, whatever its case. + * Admins and members alike may post as their organization, by its handle, whatever its case, once administrators + * switch the feature on. */ final readonly class OrganizationToPostAs implements ValidationRule { @@ -26,6 +29,12 @@ public static function of(User $user, mixed $handle): ?Organization public function validate(string $attribute, mixed $value, Closure $fail): void { + if (! resolve(Features::class)->enabled(Feature::OrganizationCommits)) { + $fail("Posting as an organization isn't available yet."); + + return; + } + if (! self::of($this->user, $value) instanceof Organization) { $fail('You can only post as an organization you are a member of.'); } diff --git a/app/Services/Features.php b/app/Services/Features.php index dd8013a5b..1b8bcfd94 100644 --- a/app/Services/Features.php +++ b/app/Services/Features.php @@ -9,15 +9,15 @@ use Illuminate\Support\Facades\DB; /** - * Every feature is on until an administrator switches it off, so only the switched off ones are stored. + * A feature keeps its default until an administrator switches it, so only the switched ones are stored. */ final readonly class Features { - private const string CACHE_KEY = 'features:disabled'; + private const string CACHE_KEY = 'features:switched'; public function enabled(Feature $feature): bool { - return ! in_array($feature->value, $this->disabled(), true); + return $this->switched()[$feature->value] ?? $feature->isEnabledByDefault(); } /** @return list */ @@ -40,16 +40,15 @@ public function update(Feature $feature, bool $enabled): void Cache::memo()->forget(self::CACHE_KEY); } - /** @return list */ - private function disabled(): array + /** @return array */ + private function switched(): array { - /** @var list $disabled */ - $disabled = Cache::memo()->rememberForever(self::CACHE_KEY, fn (): array => DB::table('features') - ->where('enabled', false) - ->pluck('name') - ->values() + /** @var array $switched */ + $switched = Cache::memo()->rememberForever(self::CACHE_KEY, fn (): array => DB::table('features') + ->pluck('enabled', 'name') + ->map(fn (mixed $enabled): bool => (bool) $enabled) ->all()); - return $disabled; + return $switched; } } diff --git a/resources/js/pages/operations/features.tsx b/resources/js/pages/operations/features.tsx index f5e78186c..fdf9f6398 100644 --- a/resources/js/pages/operations/features.tsx +++ b/resources/js/pages/operations/features.tsx @@ -18,6 +18,11 @@ const FEATURES: Record< description: 'The Translate control, cached translations, and background language detection. Off stops new detection jobs from being queued and skips any already waiting.', }, + organization_commits: { + label: 'organization commits', + description: + "Members posting and replying as their organization, from the composer's Post as menu and the API. Off until the rest of organization commits ships; commits already posted as an organization stay up either way.", + }, }; /** diff --git a/resources/js/types/operations.ts b/resources/js/types/operations.ts index a3dd01db1..2d6946958 100644 --- a/resources/js/types/operations.ts +++ b/resources/js/types/operations.ts @@ -36,7 +36,7 @@ export type Connectivity = { }; export type Feature = { - name: 'translations'; + name: 'translations' | 'organization_commits'; enabled: boolean; }; diff --git a/tests/Feature/Controllers/FeatureControllerTest.php b/tests/Feature/Controllers/FeatureControllerTest.php index 33782e4b7..53fc43a38 100644 --- a/tests/Feature/Controllers/FeatureControllerTest.php +++ b/tests/Feature/Controllers/FeatureControllerTest.php @@ -16,7 +16,7 @@ ->assertOk() ->assertInertia(fn ($page) => $page ->component('operations/features') - ->where('features', [['name' => 'translations', 'enabled' => true]])); + ->where('features', [['name' => 'translations', 'enabled' => true], ['name' => 'organization_commits', 'enabled' => false]])); }); it('hides the features from non admins', function (): void { @@ -33,7 +33,7 @@ $admin = User::factory()->create(['handle' => 'admin']); $this->actingAs($admin)->get(route('feature.index')) - ->assertInertia(fn ($page) => $page->where('features', [['name' => 'translations', 'enabled' => true]])); + ->assertInertia(fn ($page) => $page->where('features', [['name' => 'translations', 'enabled' => true], ['name' => 'organization_commits', 'enabled' => false]])); $this->fromRoute('feature.index') ->put(route('feature.update', 'translations'), ['enabled' => false]) @@ -42,7 +42,7 @@ expect(resolve(Features::class)->enabled(Feature::Translations))->toBeFalse(); $this->assertDatabaseHas('features', ['name' => 'translations', 'enabled' => false]); $this->get(route('feature.index')) - ->assertInertia(fn ($page) => $page->where('features', [['name' => 'translations', 'enabled' => false]])); + ->assertInertia(fn ($page) => $page->where('features', [['name' => 'translations', 'enabled' => false], ['name' => 'organization_commits', 'enabled' => false]])); $this->put(route('feature.update', 'translations'), ['enabled' => true])->assertRedirect(); @@ -80,3 +80,14 @@ it('requires authentication', function (): void { $this->putJson(route('feature.update', 'translations'), ['enabled' => false])->assertUnauthorized(); }); + +it('keeps organization commits off until admins switch them on', function (): void { + expect(resolve(Features::class)->enabled(Feature::OrganizationCommits))->toBeFalse(); + + $this->actingAs(User::factory()->create(['handle' => 'admin'])) + ->put(route('feature.update', 'organization_commits'), ['enabled' => true]) + ->assertRedirect(); + + expect(resolve(Features::class)->enabled(Feature::OrganizationCommits))->toBeTrue() + ->and(resolve(Features::class)->enabled(Feature::Translations))->toBeTrue(); +}); diff --git a/tests/Feature/OrganizationCommitsTest.php b/tests/Feature/OrganizationCommitsTest.php index bbe8c561f..201d61aab 100644 --- a/tests/Feature/OrganizationCommitsTest.php +++ b/tests/Feature/OrganizationCommitsTest.php @@ -9,6 +9,7 @@ use App\Actions\DeleteUser; use App\Actions\UpdateCommitStatuses; use App\Enums\CommitStatus; +use App\Enums\Feature; use App\Enums\Log; use App\Enums\OrganizationRole; use App\Jobs\CheckUserLevel; @@ -19,9 +20,11 @@ use App\Models\Poll; use App\Models\User; use App\Queries\LatestLogCommitsQuery; +use App\Services\Features; use Illuminate\Support\Facades\Bus; beforeEach(function (): void { + resolve(Features::class)->update(Feature::OrganizationCommits, true); $this->organization = Organization::factory()->create(['handle' => 'Laravel']); $this->member = User::factory()->create(['pasteis_mode' => true]); $this->organization->members()->attach($this->member->id, ['role' => OrganizationRole::Member]); @@ -43,6 +46,18 @@ ->and(Notification::query()->where('recipient_id', $mentioned->id)->exists())->toBeFalse(); }); +it('lets no one post as an organization until administrators switch it on', function (): void { + resolve(Features::class)->update(Feature::OrganizationCommits, false); + + $this->actingAs($this->member) + ->post(route('commit.store'), ['body' => 'Not yet', 'author' => 'laravel']) + ->assertSessionHasErrors(['author' => "Posting as an organization isn't available yet."]); + $this->get(route('log.show', Log::Following)) + ->assertInertia(fn ($page) => $page->where('auth.organizations', [])); + + expect(Commit::query()->exists())->toBeFalse(); +}); + it('only lets members commit as an organization', function (): void { $this->actingAs(User::factory()->create()) ->post(route('commit.store'), ['body' => 'Not mine to post', 'author' => 'laravel']) From d95a4e3b3014c8fc047bd3dec89be29f1181094c Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Sun, 1 Nov 2026 14:34:12 +0100 Subject: [PATCH 6/7] fix: let members restore the organization's commits whose poster has gone --- app/Models/Commit.php | 6 ++++-- tests/Feature/OrganizationCommitsTest.php | 12 ++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/app/Models/Commit.php b/app/Models/Commit.php index 111376b3b..fa59186ff 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -238,11 +238,13 @@ public function isBeingChecked(): bool } /** - * A deleted commit comes back within seconds, unless its account or organization has gone since. + * A deleted commit comes back within seconds, unless its author has gone since: a deleted account takes its own + * commits' authors with it, and an organization GitHub no longer has its own. Whoever posted an organization's + * commit may have gone, while its members still can bring it back. */ public function isRestorable(): bool { - return $this->user_id !== null && $this->author_id !== null && $this->deleted_at !== null && now()->lessThan($this->deleted_at->copy()->addSeconds(self::RESTORABLE_SECONDS)); + return $this->author_id !== null && $this->deleted_at !== null && now()->lessThan($this->deleted_at->copy()->addSeconds(self::RESTORABLE_SECONDS)); } /** diff --git a/tests/Feature/OrganizationCommitsTest.php b/tests/Feature/OrganizationCommitsTest.php index 201d61aab..ca03d3e36 100644 --- a/tests/Feature/OrganizationCommitsTest.php +++ b/tests/Feature/OrganizationCommitsTest.php @@ -246,3 +246,15 @@ expect(resolve(CreateCommitRestoration::class)->handle($commit)->trashed())->toBeTrue(); } }); + +it('lets a member restore a commit of the organization whose poster deleted their account', function (): void { + $poster = User::factory()->create(); + $this->organization->members()->attach($poster->id, ['role' => OrganizationRole::Member]); + $commit = Commit::factory()->for($poster)->create(['author_id' => $this->organization->id]); + resolve(DeleteUser::class)->handle($poster); + + $this->actingAs($this->member)->delete(route('commit.destroy', $commit))->assertRedirect(); + $this->post(route('commit-restoration.store', $commit))->assertRedirect(); + + expect($commit->refresh()->trashed())->toBeFalse(); +}); From d3d8f9757b03cad9669d66b13ef01b2b1a1fce25 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Sun, 1 Nov 2026 14:38:52 +0100 Subject: [PATCH 7/7] fix: keep the organizations a member belongs to while posting as them is switched off, hiding only the Post as menu --- app/Http/Middleware/HandleInertiaRequests.php | 6 ++++-- resources/js/components/author-switcher.tsx | 6 +++--- resources/js/types/auth.ts | 4 +++- tests/Feature/OrganizationCommitsTest.php | 7 ++++++- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 074d040e6..12a2c00cf 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -96,10 +96,12 @@ public function share(Request $request): array 'auth' => [ 'user' => fn (): ?User => $request->user()?->loadCounts(), 'bioProfiles' => fn (): array => $user instanceof User ? resolve(BioProfilesQuery::class)->get($user) : [], - // The organizations the user can post as, shown like the commits they author, once the feature is on. - 'organizations' => fn (): array => $user instanceof User && resolve(Features::class)->enabled(Feature::OrganizationCommits) ? $user->organizations()->get() + // The organizations the user is a member of, shown like the commits they author: theirs too, and who they + // can post as once the feature is on. + 'organizations' => fn (): array => $user instanceof User ? $user->organizations()->get() ->map(fn (Organization $organization): array => ['type' => 'organization', ...$organization->toArray()]) ->all() : [], + 'canPostAsOrganization' => fn (): bool => resolve(Features::class)->enabled(Feature::OrganizationCommits), 'isAdmin' => $isAdmin, 'automaticTranslation' => $user->automatic_translation ?? true, 'mustReadCommitRules' => $user instanceof User && $user->commit_rules_accepted_at === null, diff --git a/resources/js/components/author-switcher.tsx b/resources/js/components/author-switcher.tsx index b0b91b821..527691ecc 100644 --- a/resources/js/components/author-switcher.tsx +++ b/resources/js/components/author-switcher.tsx @@ -13,8 +13,8 @@ import { UserAvatar } from '@/components/user-avatar'; import type { OrganizationAuthor, User } from '@/types'; /** - * The avatar of a composer, which members of an organization open to post as it instead of themselves. Everyone else - * just sees their own. + * The avatar of a composer, which members of an organization open to post as it instead of themselves, once posting as + * an organization is switched on. Everyone else just sees their own. */ export function AuthorSwitcher({ user, @@ -31,7 +31,7 @@ export function AuthorSwitcher({ }) { const { auth } = usePage().props; - if (auth.organizations.length === 0) { + if (!auth.canPostAsOrganization || auth.organizations.length === 0) { return fallback; } diff --git a/resources/js/types/auth.ts b/resources/js/types/auth.ts index 2e5deceb3..fe9c40530 100644 --- a/resources/js/types/auth.ts +++ b/resources/js/types/auth.ts @@ -47,9 +47,11 @@ export type Auth = { user: Profile | null; bioProfiles: Record; /** - * The organizations the user can post as. + * The organizations the user is a member of, whose commits are theirs too. */ organizations: OrganizationAuthor[]; + // Whether members may post as their organization yet, while it is being rolled out. + canPostAsOrganization: boolean; isAdmin: boolean; automaticTranslation: boolean; mustReadCommitRules: boolean; diff --git a/tests/Feature/OrganizationCommitsTest.php b/tests/Feature/OrganizationCommitsTest.php index ca03d3e36..5c038e254 100644 --- a/tests/Feature/OrganizationCommitsTest.php +++ b/tests/Feature/OrganizationCommitsTest.php @@ -52,8 +52,13 @@ $this->actingAs($this->member) ->post(route('commit.store'), ['body' => 'Not yet', 'author' => 'laravel']) ->assertSessionHasErrors(['author' => "Posting as an organization isn't available yet."]); + signedApiRequest(apiKeyPairFor($this->member), 'POST', route('api-commit.store'), ['body' => 'Not yet', 'author' => 'laravel']) + ->assertUnprocessable() + ->assertJsonValidationErrors('author'); $this->get(route('log.show', Log::Following)) - ->assertInertia(fn ($page) => $page->where('auth.organizations', [])); + ->assertInertia(fn ($page) => $page + ->where('auth.canPostAsOrganization', false) + ->has('auth.organizations', 1)); expect(Commit::query()->exists())->toBeFalse(); });