diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 2610bb77f..f1dee154e 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -114,13 +114,13 @@ Sources: [profile page](resources/js/pages/user/show.tsx), [profile settings](re 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 **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. +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, its members also see its pending and rejected 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`. 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`. +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. Its admins and members hear about the organization in their own notifications, all except whoever did it: replies to its commits, quotes of them, stars on them and commits that mention it, said as the organization's, like **replied to @laravel's commit** or **mentioned @laravel**. They all see the organization's pending and rejected commits, like whoever posted them, and all hear when one is rejected, with the reason. Someone removed from the organization keeps what they already heard and hears nothing new, and no longer sees its rejected commits or what they heard about them. Organizations can't schedule commits yet, and their own commits, replies and quotes 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. @@ -178,7 +178,7 @@ Sources: [follow import](app/Actions/CreateUserGitHubFollows.php), [suggestions] Logs are the app's timelines. **Following** contains your commits and those of developers you follow, newest first; it requires sign-in. **Latest** contains the newest public commits across the app. **Trending** draws from the public commits of the last seven days and favors recent ones that attract engagement from distinct other developers across the thread, with diminishing returns from additional engagement. Replies weigh the most, especially replies the author answers, then quotes, then stars. Each further commit by the same author ranks a little lower, so one person does not fill the log, and when you are signed in, commits by people you follow and your own rank a little higher. Pastéis don't affect Trending. Trending reshuffles each time you open or refresh it, on the web and in the app: higher-scoring commits are more likely to appear first, but the order varies between visits, older commits sometimes come back, and the same draw is used while you scroll. -Logs list top-level commits, including quotes, rather than every reply, 20 at a time as you scroll, and show your own pending commits to you. Following and Latest check for new commits every 30 seconds and mark where you left off. Following and Latest load new commits automatically. New arrivals stay above the content you are reading, even when you start at the top or their images and link previews finish loading later. A **New commits** control appears after they load above you and stays until you return to the top or see the newest arrival below the sticky tabs. Switching away from the tab does not mark arrivals as seen. It previews up to three distinct authors from the new commits waiting above you, with a +N circle for additional authors. Each check removes deleted or no-longer-visible arrivals from the announcement and loaded feed; the control disappears if none remain. Select it to scroll to the new commits; it disappears when you reach them. On touch screens, swipe sideways to move between logs. An empty Following log suggests three recent commits from people to follow. Muted and blocked people are filtered for signed-in viewers. The last log you opened is remembered. +Logs list top-level commits, including quotes, rather than every reply, 20 at a time as you scroll, and show your own pending commits to you, and your organizations' to their members. Following and Latest check for new commits every 30 seconds and mark where you left off. Following and Latest load new commits automatically. New arrivals stay above the content you are reading, even when you start at the top or their images and link previews finish loading later. A **New commits** control appears after they load above you and stays until you return to the top or see the newest arrival below the sticky tabs. Switching away from the tab does not mark arrivals as seen. It previews up to three distinct authors from the new commits waiting above you, with a +N circle for additional authors. Each check removes deleted or no-longer-visible arrivals from the announcement and loaded feed; the control disappears if none remain. Select it to scroll to the new commits; it disappears when you reach them. On touch screens, swipe sideways to move between logs. An empty Following log suggests three recent commits from people to follow. Muted and blocked people are filtered for signed-in viewers. The last log you opened is remembered. Browser Back and Forward restore expanded replies and loaded thread continuations. Offscreen commits are measured before scroll restoration so returning to a log or discussion keeps the content in place. @@ -230,7 +230,7 @@ The smiley button in the commit and reply composers opens the same searchable em In commits, replies, and remotes, start a line with `>` to render a quote block. Consecutive quoted lines stay together; an unquoted line ends the block. Quotes keep inline code, links, mentions, and emoji formatting. A `>` inside code or in the middle of a line stays literal. -Typing **@** in a composer opens developer suggestions. Use ↑/↓ to move, Enter or Tab to select, and Esc to dismiss. A bare @ suggests people you follow; typing more searches names and usernames. In replies, matching suggestions put the direct recipient first, then the root commit author, then other visible participants in that thread, followed by ordinary suggestions. Quotes prioritize only the quoted commit author. These priorities also apply to a bare @; typing a term still filters everyone, and yourself, muted developers, and blocked developers are excluded. In an org's messages, suggestions search the loaded member list instantly and update as membership refreshes. Suggestions are limited to the org's members, but you can still type anyone's handle yourself. Mentions link to profiles and can notify the mentioned developer after approval. Mentioning a GitHub organization, like `@laravel`, links to its [organization page](#organization-pages) and notifies nobody: a known organization links right away, one mentioned for the first time once the commit is approved and GitHub confirms it is an organization. Typing @ followed by a few letters also suggests organizations that already have a page, after developers. Organization mentions only link in commits and replies, not in remotes. Email addresses and scoped packages such as `@inertiajs/react` are not treated as mentions. +Typing **@** in a composer opens developer suggestions. Use ↑/↓ to move, Enter or Tab to select, and Esc to dismiss. A bare @ suggests people you follow; typing more searches names and usernames. In replies, matching suggestions put the direct recipient first, then the root commit author, then other visible participants in that thread, followed by ordinary suggestions. Quotes prioritize only the quoted commit author. These priorities also apply to a bare @; typing a term still filters everyone, and yourself, muted developers, and blocked developers are excluded. In an org's messages, suggestions search the loaded member list instantly and update as membership refreshes. Suggestions are limited to the org's members, but you can still type anyone's handle yourself. Mentions link to profiles and can notify the mentioned developer after approval. Mentioning a GitHub organization, like `@laravel`, links to its [organization page](#organization-pages) and notifies its members: a known organization links right away, one mentioned for the first time once the commit is approved and GitHub confirms it is an organization. Typing @ followed by a few letters also suggests organizations that already have a page, after developers. Organization mentions only link in commits and replies, not in remotes. Email addresses and scoped packages such as `@inertiajs/react` are not treated as mentions. Typing **#123** in a composer looks up that exact issue or pull request in the /dev/null repository, `dev-null-sh/dev-null.sh`. The matching title appears beneath the number; click it or use Enter or Tab to insert its GitHub URL, just like choosing an emoji suggestion. A space or punctuation leaves the number as typed and dismisses the suggestion. Missing numbers and failed lookups leave the text unchanged. Code, URL fragments, and references qualified with another repository are not expanded. @@ -360,7 +360,7 @@ Level-up notifications announce your new Unix rank under **All**, rather than on On phones, **Replies**, **Quotes**, and **Stars** share one row with a **More notification filters** (`…`) button for **Mentions**, **Follows**, and **Moderation**. The menu stays open while you toggle filters so you can change several at once. -Pending replies, quotes, and mentions wait for approval before notifying others. Self-actions do not notify you; mentioning the parent or quoted author does not duplicate their reply or quote notification. A new follower notifies only once even if they unfollow and follow again. Deleted or unavailable sources, mutes, and blocks affect which notifications remain visible. Rejected commits can produce a moderation message with a reason, without revealing a moderator's identity. +Pending replies, quotes, and mentions wait for approval before notifying others. Self-actions do not notify you; mentioning the parent or quoted author does not duplicate their reply or quote notification, and someone mentioned both themselves and through an organization hears about it once. Members of an organization also hear about [its commits and mentions](#organization-pages). A new follower notifies only once even if they unfollow and follow again. Deleted or unavailable sources, mutes, and blocks affect which notifications remain visible. Rejected commits can produce a moderation message with a reason, without revealing a moderator's identity. Sources: [notifications page](resources/js/pages/notification/index.tsx), [notification filtering](app/Queries/NotificationBuilder.php), [grouping](app/Queries/NotificationGroupsQuery.php), [moderation notifications](app/Actions/UpdateCommitStatuses.php), [first commit notifications](app/Actions/CreateFirstCommitNotifications.php). @@ -541,7 +541,7 @@ Sources: [API routes](routes/api.php), [API controllers](app/Http/Controllers/Ap An app can use the same `/api/v1` for you with signed requests. It keeps an SSH key on the device and signs every request with it; there are no passwords or API tokens. To sign in, the app opens `/mobile/authorize` on the web. It sends the device's name with the key, and can also send its own name as `app_name`, like "/dev/null for Mac", so you can tell apps apart: the approval page and your devices show both. An app can also send a stable `app_id`, its platform and bundle identifier like `macos:sh.dev-null.mac`, which stays the same when its name changes. It isn't shown, but it is in your data export. A guest goes straight to GitHub and returns to that page; someone already signed in checks the key's fingerprint and approves it. The key then becomes one of your devices, and it does not need to be on your GitHub account. A key that is only on GitHub, such as a GitHub Actions key, cannot use the signed API. -A signed request sees what the web shows you, such as your own pending commits. It can also, with the web's validation, moderation, and rate limits: +A signed request sees what the web shows you, such as your own pending commits and your organizations'. It can also, with the web's validation, moderation, and rate limits: - read your account, logs, replies, notifications (including grouped activity), and starred commits; - commit, reply, and quote, with images, and delete your commits, with the same undo window as the web; @@ -666,7 +666,7 @@ Sources: [repository insights](app/Ai/Tools/RepositoryInsights.php), [repository ### Hello world replies -An approved commit or reply whose entire text is **hello world**, including variants such as **Hello, World!**, can receive an automatic **“Hello, {name}.”** reply from **@world**, which notifies its author like any reply. A pending commit waits for approval. The bot replies only once to that commit. If the real GitHub owner of `world` signs in, the bot moves to **@devnull-world** and keeps replying with its existing profile and history. The human gets a separate account. It is a small greeting feature, separate from Tom's question-answering role. +An approved commit or reply whose entire text is **hello world**, including variants such as **Hello, World!**, can receive an automatic **“Hello, {name}.”** reply from **@world**, which notifies its author like any reply. An organization's commit gets the organization's name, and every member hears about it. A pending commit waits for approval. The bot replies only once to that commit. If the real GitHub owner of `world` signs in, the bot moves to **@devnull-world** and keeps replying with its existing profile and history. The human gets a separate account. It is a small greeting feature, separate from Tom's question-answering role. Sources: [greeting matching](app/Services/HelloWorld.php), [world reply](app/Actions/CreateWorldReply.php). @@ -762,7 +762,7 @@ Originally, Tom had no particular purpose beyond that nod to MySpace. He now has Tom's lookups start with **OpenAI's Decisions API**, which evaluates simple criteria for each read-only tool using the latest message and the same bounded public thread context. Finding social posts and community reactions uses post search; factual platform questions and notable events, including milestones, downtime and security patches, use the knowledge base; current repository numbers and contribution opportunities use repository insights; feature development and attribution questions use repository history; and supported member counts use community statistics. Sources are complementary: a question about a feature's behavior, evolution and community reception can use the knowledge base, posts and repository history together, without needing to request each search explicitly. Tom compiles the relevant evidence into one answer, distinguishing documented facts, community opinions and development history. An inconclusive search can lead to a follow-up with different keywords, and each new reply has a fresh search allowance. When no tool is needed, such as small talk, ambiguous requests or anything outside his role, Tom simply replies as regular chat. -Tag **@tom** in a public commit or reply, or reply directly to one of his posts without tagging him. Once it is approved, Tom can reply under that commit, at most once, addressing its author rather than the thread's root poster. Replies further down the thread still need to tag him unless their direct parent is a Tom post. Mentions in code do not summon him. Selecting him as a reply notification recipient does not summon him either. His replies use the normal commit moderation and notification pipeline. He does not reply to himself, across a block, in private messages, or to deleted or unapproved commits. +Tag **@tom** in a public commit or reply, or reply directly to one of his posts without tagging him. Once it is approved, Tom can reply under that commit, at most once, addressing its author rather than the thread's root poster. For an organization's commit, he addresses the organization, and every member hears about his reply. Replies further down the thread still need to tag him unless their direct parent is a Tom post. Mentions in code do not summon him. Selecting him as a reply notification recipient does not summon him either. His replies use the normal commit moderation and notification pipeline. He does not reply to himself, across a block, in private messages, or to deleted or unapproved commits. If the real GitHub owner of `tom` signs in, the bot moves to **@devnull-tom** with the same profile, posts and friendships. Mention **@devnull-tom** to summon him after that; direct replies and mentions captured before the rename still reach the bot. New mentions of the human **@tom** do not summon him. He remains every newcomer's first friend. diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index e124dd3db..2d3179016 100644 --- a/app/Actions/CreateCommit.php +++ b/app/Actions/CreateCommit.php @@ -93,7 +93,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $this->updateCommitHotScores->handle([$commit->id]); } - // An organization's commits notify no one yet, and get no welcome or answer: each would show who posted it. + // An organization's commits notify no one yet: each would show who posted it. if (! $organization instanceof Organization) { $this->createReplyNotifications->handle($commit); $this->createQuoteNotifications->handle($commit); @@ -105,12 +105,9 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit dispatch(new UnfurlLink($linkPreview)); } - if (! $organization instanceof Organization) { - $this->createWorldReply->handle($commit); - } + $this->createWorldReply->handle($commit); if ($commit->status === CommitStatus::Approved - && ! $organization instanceof Organization && $user->github_id !== CreateTomFriendship::GITHUB_ID ) { $tom = $this->tomQuery->get(); diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 2c6947a2a..289e5abb1 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -408,17 +408,19 @@ private function pendingOrganizationFollows(User $user): array } /** - * Moderators stay anonymous, as they are on the notifications page, and level-ups are the user's own doing. + * Moderators stay anonymous, as they are on the notifications page, and level-ups are the user's own doing. An + * organization's notifications name the organization the user heard about it for. * * @return Generator> */ private function notifications(User $user): Generator { - /** @var LazyCollection $notifications */ + /** @var LazyCollection $notifications */ $notifications = $user->notifications() ->join('profiles as actor_profiles', 'actor_profiles.id', '=', 'notifications.actor_id') + ->leftJoin('profiles as organization_profiles', 'organization_profiles.id', '=', 'notifications.organization_id') ->toBase() - ->select(['notifications.id', 'notifications.type', 'notifications.commit_id', 'notifications.reason', 'notifications.level', 'notifications.read_at', 'notifications.created_at', 'actor_profiles.handle as actor']) + ->select(['notifications.id', 'notifications.type', 'notifications.commit_id', 'notifications.reason', 'notifications.level', 'notifications.read_at', 'notifications.created_at', 'actor_profiles.handle as actor', 'organization_profiles.handle as organization']) ->lazyById(500, 'notifications.id', 'id'); foreach ($notifications as $notification) { @@ -427,6 +429,7 @@ private function notifications(User $user): Generator yield [ 'type' => $notification->type, 'actor' => $isModeration ? null : $notification->actor, + 'organization' => $notification->organization, 'commit_id' => $notification->commit_id, 'reason' => $notification->reason, 'level' => $notification->level === null ? null : (int) $notification->level, diff --git a/app/Actions/CreateFirstCommitNotifications.php b/app/Actions/CreateFirstCommitNotifications.php index f29b59ee7..72f84c457 100644 --- a/app/Actions/CreateFirstCommitNotifications.php +++ b/app/Actions/CreateFirstCommitNotifications.php @@ -6,20 +6,28 @@ use App\Enums\CommitStatus; use App\Enums\NotificationType; +use App\Enums\TopicType; use App\Models\Commit; use App\Models\Notification; use App\Models\User; +use App\Queries\ProfileRecipientsQuery; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Query\Builder as QueryBuilder; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; final readonly class CreateFirstCommitNotifications { + public function __construct(private ProfileRecipientsQuery $recipients) + { + // + } + /** * Tells the author's followers about their first commit, so it gets an answer: a newcomer's followers mostly * came from GitHub, and never chose to follow them here. Only once the commit is approved, and only for the * author's first top-level commit that was ever approved. Never from or to bots, nor to the people it mentions - * or quotes, who already hear about it. Nobody hears from people they mute or from across a block. Safe to call + * or quotes, or the members of the organizations it does, who already hear about it. Nobody hears from people they mute or from across a block. Safe to call * more than once. */ public function handle(Commit ...$commits): void @@ -59,7 +67,13 @@ public function handle(Commit ...$commits): void ->whereNotIn('users.github_id', User::BOT_GITHUB_IDS) ->whereNotIn('users.id', $author->mutedBy()->newPivotQuery()->select('muter_id')) ->whereNotIn('users.id', $author->idsAcrossBlocks()) - ->whereNotIn('users.id', [...array_values($commit->mentions ?? []), ...array_filter([$commit->quotedCommit?->user_id])]) + ->whereNotIn('users.id', DB::query()->fromSub($this->recipients->builder(), 'recipients') + ->whereIn('profile_id', [ + ...array_values($commit->mentions ?? []), + ...array_filter([$commit->quotedCommit?->author_id]), + ...DB::table('commit_topics')->where('commit_id', $commit->id)->where('topic_type', TopicType::Organization)->pluck('topic_id'), + ]) + ->select('user_id')) ->pluck('users.id'); foreach ($followerIds as $followerId) { diff --git a/app/Actions/CreateMentionNotifications.php b/app/Actions/CreateMentionNotifications.php index 4aacaee1b..6cd2fa0de 100644 --- a/app/Actions/CreateMentionNotifications.php +++ b/app/Actions/CreateMentionNotifications.php @@ -6,41 +6,70 @@ use App\Enums\CommitStatus; use App\Enums\NotificationType; +use App\Enums\TopicType; use App\Models\Commit; use App\Models\Notification; use App\Models\User; +use App\Queries\ProfileRecipientsQuery; +use Closure; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; final readonly class CreateMentionNotifications { + public function __construct(private ProfileRecipientsQuery $recipients) + { + // + } + /** - * Notifies everyone mentioned in each commit, but only once the commit is approved, so nobody hears about a - * mention they cannot see yet. Never for mentioning yourself, the parent's author of a reply or the quoted - * author of a quote, who already hear about it as a reply or a quote. Nobody hears from people they mute or - * from across a block either. Safe to call more than once. + * Notifies everyone mentioned in each commit, and every member of the organizations it mentions, but only once the + * commit is approved, so nobody hears about a mention they cannot see yet. Someone mentioned both ways hears about + * it once, for themselves. Never for mentioning yourself, or for whoever already hears about it as a reply or a + * quote. Nobody hears from people they mute or from across a block either. Safe to call more than once. */ public function handle(Commit ...$commits): void { $commits = Commit::query() ->whereKey(array_map(fn (Commit $commit): string => $commit->id, $commits)) ->where('status', CommitStatus::Approved) + ->whereNotNull('user_id') ->with(['parent', 'quotedCommit']) ->get(); - $recipientsByCommit = $commits->mapWithKeys(fn (Commit $commit): array => [ - $commit->id => array_values($commit->mentions ?? []), - ])->filter(); + $organizations = DB::table('commit_topics') + ->whereIn('commit_id', $commits->modelKeys()) + ->where('topic_type', TopicType::Organization) + ->get(['commit_id', 'topic_id']) + ->groupBy('commit_id'); + + /** @var Collection> $mentionedByCommit */ + $mentionedByCommit = $commits->mapWithKeys(fn (Commit $commit): array => [$commit->id => [ + ...array_values($commit->mentions ?? []), + ...$organizations->get($commit->id, new Collection())->pluck('topic_id'), + ]])->filter(); - if ($recipientsByCommit->isEmpty()) { + if ($mentionedByCommit->isEmpty()) { return; } + /** @var Collection> $recipients */ + $recipients = DB::query()->fromSub($this->recipients->builder(), 'recipients') + ->whereIn('profile_id', $mentionedByCommit->flatten() + ->concat($commits->pluck('parent.author_id')) + ->concat($commits->pluck('quotedCommit.author_id')) + ->filter()->unique()->values()->all()) + ->get() + ->groupBy('profile_id'); + /** @var Closure(?string): Collection $recipientsOf */ + $recipientsOf = fn (?string $profileId): Collection => $recipients->get((string) $profileId, new Collection()); + $authorIds = $commits->pluck('user_id')->unique()->values()->all(); $silencedAuthors = fn (BelongsToMany $authors): BelongsToMany => $authors->whereKey($authorIds)->without('profile')->select('users.id'); - $users = User::query() - ->whereKey($recipientsByCommit->flatten()->unique()->values()->all()) + ->whereKey($recipients->flatten()->pluck('user_id')->unique()->values()->all()) ->without('profile') ->with(['muting' => $silencedAuthors, 'blocking' => $silencedAuthors, 'blockers' => $silencedAuthors]) ->get(['id']) @@ -50,15 +79,18 @@ public function handle(Commit ...$commits): void foreach ($commits as $commit) { $authorId = $commit->user_id; + assert($authorId !== null); + $hearsOtherwise = $recipientsOf($commit->parent?->author_id)->concat($recipientsOf($commit->quotedCommit?->author_id))->pluck('user_id'); - if ($authorId === null) { - continue; - } + $mentioned = collect($mentionedByCommit->get($commit->id, [])) + ->flatMap(fn (string $profileId): Collection => $recipientsOf($profileId)) + ->unique('user_id') + ->reject(fn (object $recipient): bool => $recipient->user_id === $authorId || $hearsOtherwise->contains($recipient->user_id)); - foreach ($recipientsByCommit->get($commit->id, []) as $id) { - $user = $users->get($id); + foreach ($mentioned as $recipient) { + $user = $users->get($recipient->user_id); - if (! $user instanceof User || $user->id === $authorId || $user->id === $commit->parent?->user_id || $user->id === $commit->quotedCommit?->user_id || $this->isSilenced($user, $authorId)) { + if (! $user instanceof User || $this->isSilenced($user, $authorId)) { continue; } @@ -66,6 +98,7 @@ public function handle(Commit ...$commits): void 'id' => Str::uuid7()->toString(), 'recipient_id' => $user->id, 'actor_id' => $authorId, + 'organization_id' => $recipient->organization_id, 'type' => NotificationType::Mentioned->value, 'commit_id' => $commit->id, 'key' => 'mentioned:'.$commit->id, diff --git a/app/Actions/CreateQuoteNotifications.php b/app/Actions/CreateQuoteNotifications.php index 06882a304..fab091f64 100644 --- a/app/Actions/CreateQuoteNotifications.php +++ b/app/Actions/CreateQuoteNotifications.php @@ -8,15 +8,22 @@ use App\Enums\NotificationType; use App\Models\Commit; use App\Models\Notification; +use App\Queries\ProfileRecipientsQuery; use App\Queries\SilencedActorQuery; +use Illuminate\Support\Collection; use Illuminate\Support\Str; final readonly class CreateQuoteNotifications { + public function __construct(private ProfileRecipientsQuery $recipients) + { + // + } + /** - * Notifies the author of each quoted commit, but only once the quote is approved, so - * nobody hears about a quote they cannot see yet. Nobody hears from people they mute or - * from across a block either. Safe to call more than once. + * Notifies the author of each quoted commit, every member of it for an organization's, but only once the quote + * is approved, so nobody hears about a quote they cannot see yet. Nobody hears about their own quote, from people + * they mute or from across a block either. Safe to call more than once. */ public function handle(Commit ...$commits): void { @@ -29,41 +36,32 @@ public function handle(Commit ...$commits): void return; } - $silenced = new SilencedActorQuery('quoted.user_id', 'commits.user_id'); + $silenced = new SilencedActorQuery('recipients.user_id', 'commits.user_id'); + /** @var Collection $quotes */ $quotes = Commit::query() - ->select('commits.*') ->join('commits as quoted', 'quoted.id', '=', 'commits.quoted_commit_id') + ->joinSub($this->recipients->builder(), 'recipients', 'recipients.profile_id', '=', 'quoted.author_id') ->whereKey($quoteIds) - ->whereNotNull('quoted.user_id') + ->whereNull('quoted.deleted_at') ->where('commits.status', CommitStatus::Approved) + ->whereColumn('recipients.user_id', '<>', 'commits.user_id') ->whereNotExists($silenced->mutes(...)) ->whereNotExists($silenced->blocking(...)) ->whereNotExists($silenced->blockers(...)) - ->with('quotedCommit') - ->get(); - - $notifications = []; - - foreach ($quotes as $quote) { - $quoted = $quote->quotedCommit; - - if (! $quoted instanceof Commit || $quoted->user_id === $quote->user_id) { - continue; - } - - $notifications[] = [ - 'id' => Str::uuid7()->toString(), - 'recipient_id' => $quoted->user_id, - 'actor_id' => $quote->user_id, - 'type' => NotificationType::Quoted->value, - 'commit_id' => $quote->id, - 'key' => 'quoted:'.$quote->id, - 'created_at' => now(), - 'updated_at' => now(), - ]; - } + ->toBase() + ->get(['commits.id', 'commits.user_id', 'recipients.user_id as recipient_id', 'recipients.organization_id']); - Notification::query()->insertOrIgnore($notifications); + Notification::query()->insertOrIgnore($quotes->map(fn (object $quote): array => [ + 'id' => Str::uuid7()->toString(), + 'recipient_id' => $quote->recipient_id, + 'actor_id' => $quote->user_id, + 'organization_id' => $quote->organization_id, + 'type' => NotificationType::Quoted->value, + 'commit_id' => $quote->id, + 'key' => 'quoted:'.$quote->id, + 'created_at' => now(), + 'updated_at' => now(), + ])->all()); } } diff --git a/app/Actions/CreateReplyNotifications.php b/app/Actions/CreateReplyNotifications.php index b0c5dc6d2..0ef2b21ad 100644 --- a/app/Actions/CreateReplyNotifications.php +++ b/app/Actions/CreateReplyNotifications.php @@ -8,15 +8,22 @@ use App\Enums\NotificationType; use App\Models\Commit; use App\Models\Notification; +use App\Queries\ProfileRecipientsQuery; use App\Queries\SilencedActorQuery; +use Illuminate\Support\Collection; use Illuminate\Support\Str; final readonly class CreateReplyNotifications { + public function __construct(private ProfileRecipientsQuery $recipients) + { + // + } + /** - * Notifies the author of each reply's parent, but only once the reply is approved, so - * nobody hears about a reply they cannot see yet. Nobody hears from people they mute or - * from across a block either. Safe to call more than once. + * Notifies the author of each reply's parent, every member of it for an organization's, but only once the reply + * is approved, so nobody hears about a reply they cannot see yet. Nobody hears about their own reply, from people + * they mute or from across a block either. Safe to call more than once. */ public function handle(Commit ...$commits): void { @@ -29,42 +36,33 @@ public function handle(Commit ...$commits): void return; } - $silenced = new SilencedActorQuery('parents.user_id', 'commits.user_id'); + $silenced = new SilencedActorQuery('recipients.user_id', 'commits.user_id'); + /** @var Collection $replies */ $replies = Commit::query() - ->select('commits.*') ->join('commits as parents', 'parents.id', '=', 'commits.parent_id') + ->joinSub($this->recipients->builder(), 'recipients', 'recipients.profile_id', '=', 'parents.author_id') ->whereKey($replyIds) - ->whereNotNull('parents.user_id') + ->whereNull('parents.deleted_at') ->where('commits.status', CommitStatus::Approved) + ->whereColumn('recipients.user_id', '<>', 'commits.user_id') ->whereNotExists($silenced->mutes(...)) ->whereNotExists($silenced->blocking(...)) ->whereNotExists($silenced->blockers(...)) - ->with('parent') - ->get(); - - $notifications = []; - - foreach ($replies as $reply) { - $parent = $reply->parent; - - if (! $parent instanceof Commit || $parent->user_id === $reply->user_id) { - continue; - } - - $notifications[] = [ - 'id' => Str::uuid7()->toString(), - 'recipient_id' => $parent->user_id, - 'actor_id' => $reply->user_id, - 'type' => NotificationType::Replied->value, - 'commit_id' => $reply->id, - 'subject_id' => $parent->id, - 'key' => 'replied:'.$reply->id, - 'created_at' => now(), - 'updated_at' => now(), - ]; - } + ->toBase() + ->get(['commits.id', 'commits.user_id', 'commits.parent_id', 'recipients.user_id as recipient_id', 'recipients.organization_id']); - Notification::query()->insertOrIgnore($notifications); + Notification::query()->insertOrIgnore($replies->map(fn (object $reply): array => [ + 'id' => Str::uuid7()->toString(), + 'recipient_id' => $reply->recipient_id, + 'actor_id' => $reply->user_id, + 'organization_id' => $reply->organization_id, + 'type' => NotificationType::Replied->value, + 'commit_id' => $reply->id, + 'subject_id' => $reply->parent_id, + 'key' => 'replied:'.$reply->id, + 'created_at' => now(), + 'updated_at' => now(), + ])->all()); } } diff --git a/app/Actions/CreateStarNotification.php b/app/Actions/CreateStarNotification.php index 3dfe84194..0f0d062c4 100644 --- a/app/Actions/CreateStarNotification.php +++ b/app/Actions/CreateStarNotification.php @@ -8,30 +8,41 @@ use App\Models\Commit; use App\Models\Notification; use App\Models\User; +use App\Queries\ProfileRecipientsQuery; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; final readonly class CreateStarNotification { + public function __construct(private ProfileRecipientsQuery $recipients) + { + // + } + /** - * A star notifies the commit's author once while it holds: unstarring removes the notification. + * A star notifies the commit's author, every member of it for an organization's, once while it holds: + * unstarring removes the notification. */ public function handle(User $stargazer, Commit $commit): void { - // 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; - } + /** @var Collection $recipients */ + $recipients = DB::query()->fromSub($this->recipients->builder(), 'recipients') + ->where('profile_id', $commit->author_id) + ->where('user_id', '<>', $stargazer->id) + ->get(['user_id', 'organization_id']); - Notification::query()->insertOrIgnore([ + Notification::query()->insertOrIgnore($recipients->map(fn (object $recipient): array => [ 'id' => Str::uuid7()->toString(), - 'recipient_id' => $commit->user_id, + 'recipient_id' => $recipient->user_id, 'actor_id' => $stargazer->id, + 'organization_id' => $recipient->organization_id, 'type' => NotificationType::Starred->value, 'commit_id' => $commit->id, 'subject_id' => $commit->id, 'key' => 'starred:'.$commit->id.':'.$stargazer->id, 'created_at' => now(), 'updated_at' => now(), - ]); + ])->all()); } } diff --git a/app/Actions/CreateWorldReply.php b/app/Actions/CreateWorldReply.php index db9945549..382f8c01b 100644 --- a/app/Actions/CreateWorldReply.php +++ b/app/Actions/CreateWorldReply.php @@ -28,7 +28,8 @@ public function __construct( } /** - * @world answers an approved hello world, once. A pending one waits until it is approved. + * @world answers an approved hello world, once, greeting its author, an organization's like anyone's. A pending one + * waits until it is approved. */ public function handle(Commit $commit): void { @@ -43,15 +44,15 @@ public function handle(Commit $commit): void return; } - $author = $commit->user; + $author = $commit->author; - if (! $author instanceof User) { + if (! $author instanceof Profile) { return; } $world = $this->world(); - if (! $world instanceof User || $author->is($world)) { + if (! $world instanceof User || $commit->user_id === $world->id) { return; } diff --git a/app/Actions/DeleteOrganization.php b/app/Actions/DeleteOrganization.php index 661cc418f..d6a73375f 100644 --- a/app/Actions/DeleteOrganization.php +++ b/app/Actions/DeleteOrganization.php @@ -6,6 +6,7 @@ use App\Enums\TopicType; use App\Models\Commit; +use App\Models\Notification; use App\Models\Organization; use Illuminate\Support\Facades\DB; @@ -17,9 +18,9 @@ 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. Its own commits are deleted as their posters would, leaving tombstones - * for the replies under them, and lose their author with its profile. + * Deletes an organization GitHub no longer has, with its members and followers and what they heard about it, and + * unlinks the commits that mention 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 { @@ -37,6 +38,7 @@ public function handle(Organization $organization): void ->where('topic_id', $organization->id) ->delete(); + Notification::query()->where('organization_id', $organization->id)->delete(); $organization->members()->detach(); $organization->followers()->detach(); $organization->delete(); diff --git a/app/Actions/UpdateCommitStatuses.php b/app/Actions/UpdateCommitStatuses.php index d8eb8f32f..8df4e29a6 100644 --- a/app/Actions/UpdateCommitStatuses.php +++ b/app/Actions/UpdateCommitStatuses.php @@ -14,7 +14,9 @@ use App\Models\Poll; use App\Models\User; use App\Queries\CommitBuilder; +use App\Queries\ProfileRecipientsQuery; use App\Queries\TomQuery; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -28,6 +30,7 @@ public function __construct( private CreateWorldReply $createWorldReply, private UpdateCommitHotScores $updateCommitHotScores, private TomQuery $tomQuery, + private ProfileRecipientsQuery $recipients, ) { // } @@ -60,18 +63,33 @@ public function handle(array $commitIds, CommitStatus $status, ?string $reason = ->delete(); if ($status === CommitStatus::Rejected) { - // 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, - 'type' => NotificationType::Rejected->value, - 'commit_id' => $commit->id, - 'key' => 'moderated:'.$commit->id, - 'reason' => $reason, - 'created_at' => now(), - 'updated_at' => now(), - ])->values()->all()); + // Every member hears about an organization's rejected commit, each as their own actor, so nothing + // tells them who posted it. + /** @var Collection $recipients */ + $recipients = DB::query()->fromSub($this->recipients->builder(), 'recipients') + ->whereIn('profile_id', $commits->pluck('author_id')->filter()->unique()->values()->all()) + ->get(); + + $notifications = []; + + foreach ($commits as $commit) { + foreach ($recipients->where('profile_id', $commit->author_id) as $recipient) { + $notifications[] = [ + 'id' => Str::uuid7()->toString(), + 'recipient_id' => $recipient->user_id, + 'actor_id' => $recipient->user_id, + 'organization_id' => $recipient->organization_id, + 'type' => NotificationType::Rejected->value, + 'commit_id' => $commit->id, + 'key' => 'moderated:'.$commit->id, + 'reason' => $reason, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + } + + Notification::query()->insert($notifications); } $this->updateCommitHotScores->handle($commits->modelKeys()); @@ -82,7 +100,7 @@ public function handle(array $commitIds, CommitStatus $status, ?string $reason = ->whereNull('opened_at') ->update(['opened_at' => now()]); - // As when they are posted, an organization's commits notify no one yet, and get no welcome or answer. + // As when they are posted, an organization's commits notify no one yet. $personal = $commits->filter(fn (Commit $commit): bool => $commit->author_id === $commit->user_id); $this->createReplyNotifications->handle(...$personal); @@ -90,11 +108,11 @@ public function handle(array $commitIds, CommitStatus $status, ?string $reason = $this->createMentionNotifications->handle(...$personal); $this->createFirstCommitNotifications->handle(...$personal); - $personal->each(fn (Commit $commit) => $this->createWorldReply->handle($commit)); + $commits->each(fn (Commit $commit) => $this->createWorldReply->handle($commit)); LinkCommitTopics::dispatchFor(...$commits->all()); if (($tom = $this->tomQuery->get()) instanceof User) { - foreach ($personal as $commit) { + foreach ($commits as $commit) { if ($commit->user?->github_id !== CreateTomFriendship::GITHUB_ID && $commit->isAddressedTo($tom)) { dispatch(new ReplyAsTom($commit->id)); diff --git a/app/Http/Requests/ShowApiCommitRequest.php b/app/Http/Requests/ShowApiCommitRequest.php index 0e3d46916..290ffd597 100644 --- a/app/Http/Requests/ShowApiCommitRequest.php +++ b/app/Http/Requests/ShowApiCommitRequest.php @@ -11,7 +11,8 @@ final class ShowApiCommitRequest extends FormRequest { /** - * Anyone may see an approved commit. A signed request may also see its user's own pending or rejected commits. + * Anyone may see an approved commit. A signed request may also see its user's own pending or rejected commits, and + * their organizations'. */ public function authorize(): Response { diff --git a/app/Http/Requests/ShowCommitImageRequest.php b/app/Http/Requests/ShowCommitImageRequest.php index 218e2f9f0..b7cfc0771 100644 --- a/app/Http/Requests/ShowCommitImageRequest.php +++ b/app/Http/Requests/ShowCommitImageRequest.php @@ -13,8 +13,8 @@ final class ShowCommitImageRequest extends FormRequest { /** - * Pending and rejected images never leave the server for anyone but their author and moderators, or the temporary - * URL the API gave one of them, until the commit is deleted. + * Pending and rejected images never leave the server for anyone but their author, the members of its organization + * and moderators, or the temporary URL the API gave one of them, until the commit is deleted. */ public function authorize(): Response { diff --git a/app/Http/Requests/ShowCommitRequest.php b/app/Http/Requests/ShowCommitRequest.php index ddf4d350f..dd94aeb68 100644 --- a/app/Http/Requests/ShowCommitRequest.php +++ b/app/Http/Requests/ShowCommitRequest.php @@ -12,7 +12,8 @@ final class ShowCommitRequest extends FormRequest { /** - * Guests only see approved commits, while authors may also see their own pending or rejected commits. + * Guests only see approved commits, while authors may also see their own pending or rejected commits, and members + * their organization's. */ public function authorize(): Response { diff --git a/app/Http/Resources/NotificationResource.php b/app/Http/Resources/NotificationResource.php index 43df53126..b02ec059c 100644 --- a/app/Http/Resources/NotificationResource.php +++ b/app/Http/Resources/NotificationResource.php @@ -6,13 +6,15 @@ use App\Models\Commit; use App\Models\Notification; +use App\Models\Organization; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** * A group of notifications, as the web shows it: the newest one, the group it leads, how many events and actors the * group has and how many events are unread, and in a list the first few actors. The actor says whether the user - * follows them. A rejected commit comes with the reason the moderator gave. + * follows them. A rejected commit comes with the reason the moderator gave. A member hears about an organization's + * commits and mentions with the organization. * * @mixin Notification */ @@ -32,6 +34,7 @@ public function toArray(Request $request): array 'created_at' => $this->created_at->toIso8601ZuluString(), 'actor' => UserResource::make($this->actor), 'actors' => UserResource::collection($this->whenLoaded('actors')), + 'organization' => $this->organization instanceof Organization ? OrganizationResource::make($this->organization) : null, 'group_id' => $this->group_id, 'event_count' => $this->event_count, 'actors_count' => $this->actors_count, diff --git a/app/Models/Commit.php b/app/Models/Commit.php index fa59186ff..0479e5f14 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -212,7 +212,7 @@ public function isOwnedBy(User $user): bool /** * 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. + * is. Whoever posted a commit can also see it, and the members of the organization it is from. */ public function isVisibleTo(?User $user): bool { @@ -220,10 +220,13 @@ public function isVisibleTo(?User $user): bool return true; } + if ($this->status !== CommitStatus::Approved) { + return $user instanceof User && $this->isOwnedBy($user); + } + $author = $this->author?->user; - return $this->status === CommitStatus::Approved - && (! $user instanceof User || ! $author instanceof User || ! $user->hasBlockWith($author)); + return ! $user instanceof User || ! $author instanceof User || ! $user->hasBlockWith($author); } /** @@ -324,7 +327,7 @@ public function imagesAreVisibleTo(?User $user): bool return true; } - return $user instanceof User && ($this->user_id === $user->id || $user->isAdmin()); + return $user instanceof User && ($this->user_id === $user->id || $user->isAdmin() || $this->isOwnedBy($user)); } /** diff --git a/app/Models/Notification.php b/app/Models/Notification.php index da4f9dfe6..20a6379cd 100644 --- a/app/Models/Notification.php +++ b/app/Models/Notification.php @@ -21,6 +21,7 @@ * @property-read string $id * @property-read string $recipient_id * @property-read string $actor_id + * @property-read string|null $organization_id * @property-read NotificationType $type * @property-read string|null $commit_id * @property-read string|null $subject_id @@ -32,6 +33,7 @@ * @property-read CarbonInterface $updated_at * @property-read User $recipient * @property-read User $actor + * @property-read Organization|null $organization * @property-read Commit|null $commit * @property-read string $group_id * @property-read int $event_count @@ -65,6 +67,7 @@ public function casts(): array 'id' => 'string', 'recipient_id' => 'string', 'actor_id' => 'string', + 'organization_id' => 'string', 'type' => NotificationType::class, 'commit_id' => 'string', 'subject_id' => 'string', @@ -107,6 +110,16 @@ public function actor(): BelongsTo return $this->belongsTo(User::class); } + /** + * The organization the recipient hears about it for, as one of its members, or null when it is their own. + * + * @return BelongsTo + */ + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + /** * @return BelongsTo */ diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php index e85bbf863..b1b838c09 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -65,8 +65,8 @@ public function getModels($columns = ['*']): array } /** - * Approved commits are visible to everyone, guests included, while authors may also see their own pending or rejected commits. - * A block hides the commits from each one's profile from the other. + * Approved commits are visible to everyone, guests included, while authors may also see their own pending or rejected commits, + * and the members of an organization the organization's. A block hides the commits from each one's profile from the other. */ public function visibleTo(?User $user): self { @@ -76,7 +76,8 @@ public function visibleTo(?User $user): self return $this->where(fn (Builder $query): Builder => $query ->where($this->qualifyColumn('status'), CommitStatus::Approved) - ->orWhere($this->qualifyColumn('user_id'), $user->id)) + ->orWhere($this->qualifyColumn('user_id'), $user->id) + ->orWhereIn($this->qualifyColumn('author_id'), $user->organizations()->newPivotQuery()->select('organization_id'))) ->where(fn (Builder $query): Builder => $query ->whereNull($this->qualifyColumn('author_id')) ->orWhereNotIn($this->qualifyColumn('author_id'), $user->idsAcrossBlocks())); diff --git a/app/Queries/NotificationBuilder.php b/app/Queries/NotificationBuilder.php index 38c841ebc..15c1a6bed 100644 --- a/app/Queries/NotificationBuilder.php +++ b/app/Queries/NotificationBuilder.php @@ -38,8 +38,15 @@ public function relevant(): self ->orWhere(fn (Builder $query): Builder => $query ->whereIn('type', [NotificationType::Approved, NotificationType::Rejected]) ->whereHas('commit', fn (CommitBuilder $commit): CommitBuilder => $commit - ->whereColumn('commits.user_id', 'notifications.recipient_id') ->whereColumn('commits.status', 'notifications.type') + ->where(fn (CommitBuilder $commit): CommitBuilder => $commit + ->whereColumn('commits.user_id', 'notifications.recipient_id') + // An organization's, while the recipient is still one of its members. + ->orWhereExists(fn (QueryBuilder $members): QueryBuilder => $members + ->from('organization_members') + ->whereColumn('organization_members.organization_id', 'commits.author_id') + ->whereColumn('organization_members.organization_id', 'notifications.organization_id') + ->whereColumn('organization_members.user_id', 'notifications.recipient_id'))) ->offset(0))) ->orWhere('type', NotificationType::LeveledUp)) ->withoutSilencedActors(); diff --git a/app/Queries/NotificationGroupsQuery.php b/app/Queries/NotificationGroupsQuery.php index 927458fb8..062c7b8b1 100644 --- a/app/Queries/NotificationGroupsQuery.php +++ b/app/Queries/NotificationGroupsQuery.php @@ -37,6 +37,7 @@ public function paginate(User $user, string $type = 'all', ?CarbonImmutable $thr 'actor' => fn (BelongsTo $query): BelongsTo => $query ->withExists(['followers as is_followed' => fn (Builder $query): Builder => $query->whereKey($user->id)]), 'commit', + 'organization', ]) ->latest()->latest('id'); $perPage = $through instanceof CarbonImmutable @@ -51,24 +52,29 @@ public function paginate(User $user, string $type = 'all', ?CarbonImmutable $thr } /** - * The inbox shows a quote with the commit it quotes, which is always the recipient's own, so only the recipient's - * commits load there: a reply that quotes someone else never brings along a commit the recipient may not see. + * The inbox shows a quote with the commit it quotes, which is always the recipient's own or the organization's they + * heard about it for, so only those commits load there, as far as the recipient may see them: a reply that quotes + * someone else never brings along a commit the recipient may not see, nor does an organization's rejected one. * * @param CursorPaginator $groups * @return CursorPaginator */ public function withQuotedCommits(User $user, CursorPaginator $groups): CursorPaginator { - new Collection($groups->items())->loadMissing([ - 'commit.quotedCommit' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->whereBelongsTo($user), + $items = new Collection($groups->items()); + $authorIds = $items->pluck('organization_id')->filter()->push($user->id)->unique()->values()->all(); + + $items->loadMissing([ + 'commit.quotedCommit' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->whereIn('author_id', $authorIds) + ->where(fn (CommitBuilder $query): CommitBuilder => $query->visibleTo($user)), ]); return $groups; } /** - * A star is always on the recipient's own commit, so its images may show beside it: an image-only commit has nothing - * else to show. + * A star is always on the recipient's own commit, or the organization's they heard about it for, so its images may + * show beside it: an image-only commit has nothing else to show. * * @param CursorPaginator $groups * @return CursorPaginator @@ -100,8 +106,11 @@ public function unreadCount(User $user, string $type = 'all'): int public function members(User $user, Notification $notification, string $type = 'all'): NotificationBuilder { return $this->group($this->visible($user), $type, $notification) - ->with(['actor' => fn (BelongsTo $query): BelongsTo => $query - ->withExists(['followers as is_followed' => fn (Builder $query): Builder => $query->whereKey($user->id)])]); + ->with([ + 'actor' => fn (BelongsTo $query): BelongsTo => $query + ->withExists(['followers as is_followed' => fn (Builder $query): Builder => $query->whereKey($user->id)]), + 'organization', + ]); } public function events(User $user, string $type = 'all'): NotificationBuilder @@ -151,7 +160,8 @@ private function group(NotificationBuilder $visible, string $type = 'all', ?Noti // Deleted ancestors supply grouping identity only; content comes from relevant notifications. A commit's // recorded root is always one of its ancestors, so the walk up the thread only continues past it when the // recorded root is itself a reply. Follows never group across days, as a busy account gets followed so often - // that its follows would otherwise never stop joining a single group. + // that its follows would otherwise never stop joining a single group. What a member hears about for an + // organization only groups with what they hear about for that organization: each names whose it is. $sql = <<toSql()}), threads AS ( @@ -170,7 +180,9 @@ private function group(NotificationBuilder $visible, string $type = 'all', ?Noti topics AS ( SELECT visible.*, CASE WHEN visible.type = 'starred' THEN 'starred' - WHEN visible.type = 'replied' THEN 'replied' ELSE 'activity' END AS topic_kind, + WHEN visible.type = 'replied' THEN 'replied' + WHEN visible.organization_id IS NOT NULL THEN CONCAT('organization:', visible.organization_id) + ELSE 'activity' END AS topic_kind, CASE WHEN visible.type = 'followed' THEN visible.recipient_id WHEN visible.type = 'starred' THEN visible.commit_id WHEN visible.type = 'replied' THEN visible.id diff --git a/app/Queries/OperationsEventsQuery.php b/app/Queries/OperationsEventsQuery.php index c9320da8b..310f382cb 100644 --- a/app/Queries/OperationsEventsQuery.php +++ b/app/Queries/OperationsEventsQuery.php @@ -184,8 +184,9 @@ private function dismissedReports(CarbonImmutable $since): Builder } /** - * Rejections leave a verdict behind, sent to the author as their own actor, so the moderator is unknown. Approvals - * no longer do, but the ones sent before still show. + * Rejections leave a verdict behind, sent to the author as their own actor, so the moderator is unknown, once for + * each member of an organization: only the one its poster got shows. Approvals no longer do, but the ones sent before + * still show. */ private function verdicts(CarbonImmutable $since): Builder { @@ -201,6 +202,7 @@ private function verdicts(CarbonImmutable $since): Builder notifications.commit_id::text as commit_id SQL, [NotificationType::Approved->value]) ->whereIn('notifications.type', [NotificationType::Approved, NotificationType::Rejected]) + ->where(fn (Builder $query): Builder => $query->whereNull('notifications.organization_id')->orWhereColumn('notifications.recipient_id', 'commits.user_id')) ->where('notifications.created_at', '>=', $since); } diff --git a/app/Queries/ProfileRecipientsQuery.php b/app/Queries/ProfileRecipientsQuery.php new file mode 100644 index 000000000..2e554d664 --- /dev/null +++ b/app/Queries/ProfileRecipientsQuery.php @@ -0,0 +1,21 @@ +select(['id as profile_id', 'id as user_id', DB::raw('cast(null as uuid) as organization_id')]) + ->unionAll(DB::table('organization_members')->select(['organization_id as profile_id', 'user_id', 'organization_id'])); + } +} diff --git a/database/migrations/2026_11_01_100000_add_organization_id_to_notifications_table.php b/database/migrations/2026_11_01_100000_add_organization_id_to_notifications_table.php new file mode 100644 index 000000000..9625d0852 --- /dev/null +++ b/database/migrations/2026_11_01_100000_add_organization_id_to_notifications_table.php @@ -0,0 +1,32 @@ + + ({ + checking: `Running a quick automated check. This usually takes a few seconds, and only ${who} can see this commit until then.`, + review: 'A moderator reviews this commit before anyone else can see it. Commits from established accounts with a following go live right away.', + rejected: `This commit was rejected. Only ${who} can see it.`, + approved: `${whose} commit is now live for everyone.`, + }) as const; /** - * Tells the author of a commit that is not live yet why, and for roughly how long: a pending commit is either still + * Tells the author of a commit that is not live yet, or the members of its organization, why, and for roughly how long: a pending commit is either still * being checked, which takes seconds, or waiting for a moderator, which can take much longer. Inside a link, where a * button cannot go, the explanation is only a title. */ @@ -28,7 +29,9 @@ export function CommitStatusBadge({ size = 'default', isStatic = false, }: { - commit: Pick; + commit: Pick & { + author?: CommitAuthor; + }; size?: 'default' | 'small'; isStatic?: boolean; }) { @@ -41,6 +44,12 @@ export function CommitStatusBadge({ const state = status === 'pending' ? (isChecking ? 'checking' : 'review') : status; + const organization = + commit.author?.type === 'organization' ? commit.author : null; + const explanation = explanations( + organization ? `@${organization.handle}'s members` : 'you', + organization ? `@${organization.handle}'s` : 'Your', + )[state]; const className = cn( 'inline-flex shrink-0 items-center gap-1 self-center rounded-sm font-mono transition-colors', @@ -71,7 +80,7 @@ export function CommitStatusBadge({ return ( {label} @@ -103,7 +112,7 @@ export function CommitStatusBadge({ className="w-64 p-3 text-sm" onOpenAutoFocus={(event) => event.preventDefault()} > - {explanations[state]} + {explanation} ); diff --git a/resources/js/pages/notification/index.tsx b/resources/js/pages/notification/index.tsx index bcf42a65d..327841db1 100644 --- a/resources/js/pages/notification/index.tsx +++ b/resources/js/pages/notification/index.tsx @@ -658,8 +658,8 @@ function OwnCommitExcerpt({ segments }: { segments: BodySegment[] }) { } /** - * A quote always quotes the recipient's own commit, so it shows like the other commits of theirs here, or as the usual - * tombstone once deleted. + * A quote always quotes the recipient's own commit, or their organization's, so it shows like the other commits of + * theirs here, or as the usual tombstone once deleted. */ function QuotedOwnCommit({ commit, @@ -790,7 +790,9 @@ export function NotificationContent({ (!summary && notification.type === 'starred') ? show(actor) : CommitController.show({ - profile: isOwnCommit ? user : actor, + profile: isOwnCommit + ? (notification.organization ?? user) + : actor, commit: notification.commit, }); const time = ( @@ -802,17 +804,22 @@ export function NotificationContent({ {formatRelativeTime(notification.created_at, now)} ); + // A member hears about the organization's commits and mentions as the organization's, not their own. + const organization = notification.organization; + const whose = organization ? `@${organization.handle}'s` : 'your'; const action = { followed: 'followed you', - replied: 'replied to your commit', - quoted: 'quoted your commit', + replied: `replied to ${whose} commit`, + quoted: `quoted ${whose} commit`, starred: isImageOnly - ? `starred your ${starredImages.length === 1 ? 'image' : 'images'}` - : 'starred your commit', - mentioned: 'mentioned you', + ? `starred ${whose} ${starredImages.length === 1 ? 'image' : 'images'}` + : `starred ${whose} commit`, + mentioned: organization + ? `mentioned @${organization.handle}` + : 'mentioned you', first_commit: 'made their first commit', - approved: 'Your commit was approved', - rejected: 'Your commit was rejected', + approved: `${organization ? whose : 'Your'} commit was approved`, + rejected: `${organization ? whose : 'Your'} commit was rejected`, leveled_up: level && `You reached level ${level.level}: ${level.rank}`, }[notification.type]; const excerpt = @@ -844,7 +851,9 @@ export function NotificationContent({

)}

- Only you can see this update and your rejected commit. + {organization + ? `Only @${organization.handle}'s members can see this update and its rejected commit.` + : 'Only you can see this update and your rejected commit.'}

); diff --git a/resources/js/types/notification.ts b/resources/js/types/notification.ts index 4944fb023..054daddbd 100644 --- a/resources/js/types/notification.ts +++ b/resources/js/types/notification.ts @@ -1,5 +1,6 @@ import type { User } from '@/types/auth'; import type { Commit, DeletedCommit } from '@/types/commit'; +import type { Organization } from '@/types/organization'; type BaseNotification = { id: string; @@ -9,6 +10,8 @@ type BaseNotification = { created_at: string; updated_at: string; actor: User & { is_followed: boolean }; + // The organization the user hears about it for, as one of its members, or null when it is their own. + organization: Organization | null; reason: string | null; }; diff --git a/tests/Feature/Controllers/Api/NotificationControllerTest.php b/tests/Feature/Controllers/Api/NotificationControllerTest.php index dc070a4e0..32d404e89 100644 --- a/tests/Feature/Controllers/Api/NotificationControllerTest.php +++ b/tests/Feature/Controllers/Api/NotificationControllerTest.php @@ -53,6 +53,7 @@ 'created_at' => now()->toIso8601ZuluString(), 'actor' => [...$user($replied->actor), 'is_followed' => false], 'actors' => [[...$user($replied->actor), 'is_followed' => false]], + 'organization' => null, 'group_id' => $replied->id, 'event_count' => 1, 'actors_count' => 1, diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 5a6487f4e..fe16a754c 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -386,6 +386,7 @@ ->and($notifications['followed'])->toBe([ 'type' => 'followed', 'actor' => 'friendly-dev', + 'organization' => null, 'commit_id' => null, 'reason' => null, 'level' => null, @@ -395,6 +396,7 @@ ->and($notifications['rejected'])->toBe([ 'type' => 'rejected', 'actor' => null, + 'organization' => null, 'commit_id' => $commit->id, 'reason' => 'Looks like spam.', 'level' => null, @@ -404,6 +406,7 @@ ->and($notifications['leveled_up'])->toBe([ 'type' => 'leveled_up', 'actor' => null, + 'organization' => null, 'commit_id' => null, 'reason' => null, 'level' => 3, diff --git a/tests/Feature/OrganizationCommitsTest.php b/tests/Feature/OrganizationCommitsTest.php index 5c038e254..f01494032 100644 --- a/tests/Feature/OrganizationCommitsTest.php +++ b/tests/Feature/OrganizationCommitsTest.php @@ -143,7 +143,7 @@ ->and($tombstone->author_id)->toBeNull(); }); -it('notifies no one about the commits of an organization whose poster deleted their account', function (): void { +it('notifies no one about the commits of an organization whose only member, its 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(); diff --git a/tests/Feature/OrganizationNotificationsTest.php b/tests/Feature/OrganizationNotificationsTest.php new file mode 100644 index 000000000..cbe040f6a --- /dev/null +++ b/tests/Feature/OrganizationNotificationsTest.php @@ -0,0 +1,312 @@ +organization = Organization::factory()->create(['handle' => 'laravel']); + $this->admin = User::factory()->create(); + $this->member = User::factory()->create(); + $this->organization->members()->attach($this->admin->id, ['role' => OrganizationRole::Admin]); + $this->organization->members()->attach($this->member->id, ['role' => OrganizationRole::Member]); + $this->commit = Commit::factory()->for($this->admin)->create(['author_id' => $this->organization->id]); +}); + +/** + * @return array + */ +function heardBy(NotificationType $type): array +{ + return Notification::query()->where('type', $type)->pluck('organization_id', 'recipient_id')->all(); +} + +it('tells every member of the organization about replies to its commits, but the one who replied', function (): void { + $reply = Commit::factory()->for($this->commit, 'parent')->create(); + $ownReply = Commit::factory()->for($this->commit, 'parent')->for($this->member)->create(); + + resolve(CreateReplyNotifications::class)->handle($reply, $ownReply); + + expect(Notification::query()->where('commit_id', $reply->id)->pluck('organization_id', 'recipient_id')->all())->toEqualCanonicalizing([ + $this->admin->id => $this->organization->id, + $this->member->id => $this->organization->id, + ])->and(Notification::query()->where('commit_id', $ownReply->id)->pluck('recipient_id')->all())->toBe([$this->admin->id]); +}); + +it('tells every member of the organization about quotes of its commits and stars on them, while they hold', function (): void { + $stargazer = User::factory()->create(); + $quote = Commit::factory()->create(['quoted_commit_id' => $this->commit->id]); + + resolve(CreateQuoteNotifications::class)->handle($quote); + resolve(CreateCommitStar::class)->handle($stargazer, $this->commit); + + $everyone = [$this->admin->id => $this->organization->id, $this->member->id => $this->organization->id]; + + expect(heardBy(NotificationType::Quoted))->toEqualCanonicalizing($everyone) + ->and(heardBy(NotificationType::Starred))->toEqualCanonicalizing($everyone); + + resolve(DeleteCommitStar::class)->handle($stargazer, $this->commit); + + expect(heardBy(NotificationType::Starred))->toBe([]); +}); + +it('tells every member of an organization it is mentioned, once, and not again those who hear about it otherwise', function (): void { + $reply = Commit::factory()->for($this->commit, 'parent')->create([ + 'body' => 'Thanks @laravel and @'.$this->member->handle, + 'mentions' => [mb_strtolower($this->member->handle) => $this->member->id], + ]); + $outsider = Commit::factory()->for(User::factory())->create(['body' => 'Hello @laravel']); + $own = Commit::factory()->for($this->member)->create(['body' => 'Proud of @laravel']); + + foreach ([$reply, $outsider, $own] as $commit) { + resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => $this->organization->id]); + } + + resolve(CreateMentionNotifications::class)->handle($reply, $outsider, $own); + + expect(Notification::query()->where('commit_id', $reply->id)->exists())->toBeFalse() + ->and(Notification::query()->where('commit_id', $outsider->id)->pluck('organization_id', 'recipient_id')->all())->toEqualCanonicalizing([ + $this->admin->id => $this->organization->id, + $this->member->id => $this->organization->id, + ]) + ->and(Notification::query()->where('commit_id', $own->id)->pluck('organization_id', 'recipient_id')->all())->toBe([ + $this->admin->id => $this->organization->id, + ]); +}); + +it('tells someone mentioned both themselves and as a member of the organization once, for themselves', function (): void { + $commit = Commit::factory()->create([ + 'body' => '@laravel @'.$this->member->handle, + 'mentions' => [mb_strtolower($this->member->handle) => $this->member->id], + ]); + resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => $this->organization->id]); + + resolve(CreateMentionNotifications::class)->handle($commit); + + expect($this->member->notifications()->sole()->organization_id)->toBeNull() + ->and($this->admin->notifications()->sole()->organization_id)->toBe($this->organization->id); +}); + +it('keeps each member from hearing from people they mute or across a block', function (): void { + $troll = User::factory()->create(); + $blocked = User::factory()->create(); + $blocker = User::factory()->create(); + $this->member->muting()->attach($troll); + $this->member->blocking()->attach($blocked); + $blocker->blocking()->attach($this->member); + $replies = collect([$troll, $blocked, $blocker])->map(fn (User $user): Commit => Commit::factory()->for($this->commit, 'parent')->for($user)->create()); + + resolve(CreateReplyNotifications::class)->handle(...$replies); + + expect(Notification::query()->pluck('recipient_id')->unique()->values()->all())->toBe([$this->admin->id]); +}); + +it('stops telling members once they are removed, keeping what they already heard', function (): void { + $first = Commit::factory()->for($this->commit, 'parent')->create(); + resolve(CreateReplyNotifications::class)->handle($first); + + resolve(DeleteOrganizationMember::class)->handle($this->organization, $this->member); + + $second = Commit::factory()->for($this->commit, 'parent')->create(); + resolve(CreateReplyNotifications::class)->handle($second); + + expect($this->member->notifications()->pluck('commit_id')->all())->toBe([$first->id]) + ->and($this->admin->notifications()->count())->toBe(2); +}); + +it('still tells the other members once the poster deleted their account', function (): void { + resolve(DeleteUser::class)->handle($this->admin); + $reply = Commit::factory()->for($this->commit->refresh(), 'parent')->pending()->create(); + + resolve(UpdateCommitStatuses::class)->handle([$reply->id], CommitStatus::Approved); + + expect(Notification::query()->pluck('recipient_id')->all())->toBe([$this->member->id]); +}); + +it('tells every member of the organization about its rejected commit, each as their own actor, while they are members', function (): void { + resolve(UpdateCommitStatuses::class)->handle([$this->commit->id], CommitStatus::Rejected, 'Spam'); + + expect(heardBy(NotificationType::Rejected))->toEqualCanonicalizing([ + $this->admin->id => $this->organization->id, + $this->member->id => $this->organization->id, + ])->and(Notification::query()->whereColumn('actor_id', '<>', 'recipient_id')->exists())->toBeFalse(); + + $this->actingAs($this->member) + ->get(route('notification.index', ['type' => 'moderation'])) + ->assertInertia(fn ($page) => $page->where('notifications.data.0.reason', 'Spam')); + $this->get(route('commit.show', ['profile' => 'laravel', 'commit' => $this->commit]))->assertOk(); + + resolve(DeleteOrganizationMember::class)->handle($this->organization, $this->member); + + $this->get(route('notification.index', ['type' => 'moderation'])) + ->assertInertia(fn ($page) => $page->has('notifications.data', 0)); + $this->get(route('commit.show', ['profile' => 'laravel', 'commit' => $this->commit]))->assertNotFound(); +}); + +it('deletes what the members heard about an organization GitHub no longer has', function (): void { + $mention = Commit::factory()->create(['body' => 'Hello @laravel']); + resolve(CreateCommitTopics::class)->handle($mention, ['laravel' => $this->organization->id]); + resolve(CreateMentionNotifications::class)->handle($mention); + + $followed = Notification::factory()->for($this->member, 'recipient')->create(); + + resolve(DeleteOrganization::class)->handle($this->organization); + + expect(Notification::query()->pluck('id')->all())->toBe([$followed->id]); +}); + +it('names the organization a notification is for, in the inbox and the API', function (): void { + $reply = Commit::factory()->for($this->commit, 'parent')->create(); + resolve(CreateReplyNotifications::class)->handle($reply); + + $this->actingAs($this->member) + ->get(route('notification.index')) + ->assertInertia(fn ($page) => $page + ->where('notifications.data.0.organization.handle', 'laravel') + ->where('notifications.data.0.organization.id', $this->organization->id)); + + signedApiRequest(apiKeyPairFor($this->member), 'GET', route('api-notification.index')) + ->assertOk() + ->assertJsonPath('data.0.organization.handle', 'laravel'); +}); + +it('exports the organization each notification was for', function (): void { + $reply = Commit::factory()->for($this->commit, 'parent')->create(); + resolve(CreateReplyNotifications::class)->handle($reply); + + $response = $this->actingAs($this->member)->get(route('export')); + $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); + + expect($data['notifications'][0]['organization'])->toBe('laravel'); +}); + +it('shows every member the commit of the organization a quote quotes, rejected too, until they leave it', function (): void { + $quote = Commit::factory()->create(['quoted_commit_id' => $this->commit->id]); + resolve(CreateQuoteNotifications::class)->handle($quote); + $this->commit->update(['status' => CommitStatus::Rejected]); + + $this->actingAs($this->member) + ->get(route('notification.index')) + ->assertInertia(fn ($page) => $page->where('notifications.data.0.commit.quoted_commit.id', $this->commit->id)); + + resolve(DeleteOrganizationMember::class)->handle($this->organization, $this->member); + + $this->get(route('notification.index')) + ->assertInertia(fn ($page) => $page->where('notifications.data.0.commit.quoted_commit', null)); +}); + +it('groups what a member hears about for the organization apart from what they hear about for themselves', function (): void { + $thread = Commit::factory()->create(); + $personal = Commit::factory()->for($thread, 'parent')->create([ + 'body' => 'Thanks @'.$this->admin->handle, + 'mentions' => [mb_strtolower($this->admin->handle) => $this->admin->id], + ]); + $organizational = Commit::factory()->for($thread, 'parent')->create(['body' => 'Thanks @laravel', 'created_at' => now()->addMinute()]); + resolve(CreateCommitTopics::class)->handle($organizational, ['laravel' => $this->organization->id]); + resolve(CreateMentionNotifications::class)->handle($personal, $organizational); + + $this->actingAs($this->admin) + ->get(route('notification.index', ['type' => 'mentions'])) + ->assertInertia(fn ($page) => $page + ->has('notifications.data', 2) + ->where('notifications.data.0.organization.handle', 'laravel') + ->where('notifications.data.1.organization', null)); +}); + +it('groups what a member hears about for each of their organizations apart', function (): void { + $symfony = Organization::factory()->create(['handle' => 'symfony']); + $symfony->members()->attach($this->admin->id, ['role' => OrganizationRole::Member]); + $thread = Commit::factory()->create(); + $mentions = collect(['laravel' => $this->organization, 'symfony' => $symfony])->values()->map(function (Organization $organization, int $index) use ($thread): Commit { + $commit = Commit::factory()->for($thread, 'parent')->create(['body' => 'Thanks @'.$organization->handle, 'created_at' => now()->addMinutes($index)]); + resolve(CreateCommitTopics::class)->handle($commit, [$organization->handle => $organization->id]); + + return $commit; + }); + resolve(CreateMentionNotifications::class)->handle(...$mentions); + + $this->actingAs($this->admin) + ->get(route('notification.index', ['type' => 'mentions'])) + ->assertInertia(fn ($page) => $page + ->has('notifications.data', 2) + ->where('notifications.data.0.organization.handle', 'symfony') + ->where('notifications.data.1.organization.handle', 'laravel')); +}); + +it("tells no member about a newcomer's first commit that mentions the organization twice", function (): void { + $newcomer = User::factory()->create(); + $newcomer->followers()->attach($this->member); + $commit = Commit::factory()->for($newcomer)->pending()->create(['body' => 'Hello @laravel']); + resolve(CreateCommitTopics::class)->handle($commit, ['laravel' => $this->organization->id]); + + resolve(UpdateCommitStatuses::class)->handle([$commit->id], CommitStatus::Approved); + + expect($this->member->notifications()->pluck('type')->all())->toBe([NotificationType::Mentioned]); +}); + +it('lets the members of an organization see its pending commits and their images, as their own, and no one else', function (): void { + $pending = Commit::factory()->for($this->admin)->pending()->create(['author_id' => $this->organization->id]); + $outsider = User::factory()->create(); + + expect($pending->isVisibleTo($this->member))->toBeTrue() + ->and($pending->imagesAreVisibleTo($this->member))->toBeTrue() + ->and($pending->isVisibleTo($outsider))->toBeFalse() + ->and($pending->imagesAreVisibleTo($outsider))->toBeFalse() + ->and(Commit::query()->visibleTo($this->member)->whereKey($pending->id)->exists())->toBeTrue() + ->and(Commit::query()->visibleTo($outsider)->whereKey($pending->id)->exists())->toBeFalse(); +}); + +it("lists an organization's rejected commit once in the operations feed, as its poster's", function (): void { + resolve(UpdateCommitStatuses::class)->handle([$this->commit->id], CommitStatus::Rejected, 'Spam'); + + expect(collect(resolve(OperationsEventsQuery::class)->get(now()->subDay()->toImmutable(), OperationsEventSource::Moderation)) + ->where('message', 'Commit rejected')->pluck('actor')->all())->toBe([$this->admin->handle]); +}); + +it('greets the organization for its hello world, which tells every member', function (): void { + $commit = Commit::factory()->for($this->member)->create(['author_id' => $this->organization->id, 'body' => 'Hello, World!']); + + resolve(CreateWorldReply::class)->handle($commit); + + $reply = $commit->replies()->sole(); + + expect($reply->body)->toBe("Hello, {$this->organization->name}.") + ->and(Notification::query()->where('commit_id', $reply->id)->pluck('organization_id', 'recipient_id')->all())->toEqualCanonicalizing([ + $this->admin->id => $this->organization->id, + $this->member->id => $this->organization->id, + ]); +}); + +it("asks Tom to answer the organization's commits addressed to him, as anyone's", function (): void { + config(['ai.providers.opencode.key' => 'test', 'ai.providers.openai.key' => 'test']); + $tom = User::factory()->create(['handle' => 'tom', 'github_id' => CreateTomFriendship::GITHUB_ID]); + $reply = Commit::factory()->pending()->for(Commit::factory()->for($tom), 'parent')->for($this->member)->create(['author_id' => $this->organization->id]); + Queue::fake([ReplyAsTom::class, CheckUserLevel::class]); + + resolve(UpdateCommitStatuses::class)->handle([$reply->id], CommitStatus::Approved); + + Queue::assertPushed(ReplyAsTom::class, fn (ReplyAsTom $job): bool => $job->commitId === $reply->id); +}); diff --git a/tests/Unit/Models/NotificationTest.php b/tests/Unit/Models/NotificationTest.php index 723ae2c74..6966aed9f 100644 --- a/tests/Unit/Models/NotificationTest.php +++ b/tests/Unit/Models/NotificationTest.php @@ -21,6 +21,7 @@ 'reason', 'subject_id', 'level', + 'organization_id', 'rank', 'rank_description', ]); diff --git a/tests/Unit/NotificationInbox.test.tsx b/tests/Unit/NotificationInbox.test.tsx index b59565011..7621c5bb9 100644 --- a/tests/Unit/NotificationInbox.test.tsx +++ b/tests/Unit/NotificationInbox.test.tsx @@ -53,6 +53,7 @@ const quote: NotificationGroup = { group_id: 'notice', type: 'quoted', actor: { ...actor, is_followed: false }, + organization: null, actors: [actor], actors_count: 1, event_count: 1, diff --git a/tests/Unit/NotificationIndex.test.tsx b/tests/Unit/NotificationIndex.test.tsx index 74fea9e50..95e37c9e6 100644 --- a/tests/Unit/NotificationIndex.test.tsx +++ b/tests/Unit/NotificationIndex.test.tsx @@ -14,6 +14,7 @@ import type { Commit, Notification, NotificationGroup, + Organization, User, } from '../../resources/js/types'; @@ -278,6 +279,60 @@ test('links the actor to their profile apart from the commit link', async () => assert.match(body, /starred your commit<\/a>/); }); +test("says a member hears about the organization's commits and mentions for the organization", async () => { + const organization = { + id: 'laravel', + handle: 'laravel', + name: 'Laravel', + lane: null, + } as Organization; + const body = await render( + (['replied', 'quoted', 'starred', 'mentioned'] as const).map( + (type) => + ({ + ...notification, + id: type, + group_id: type, + type, + organization, + }) as NotificationGroup, + ), + ); + + assert.match(body, /replied to @laravel's commit<\/a>/); + assert.match(body, /quoted @laravel's commit<\/a>/); + assert.match( + body, + /href="\/@laravel\/commits\/reply"[^>]*>starred @laravel's commit<\/a>/, + ); + assert.match(body, /mentioned @laravel<\/a>/); + assert.doesNotMatch(body, /your commit|mentioned you/); +}); + +test('tells every member of the organization about its rejected commit', async () => { + const organization = { + id: 'laravel', + handle: 'laravel', + name: 'Laravel', + lane: null, + } as Organization; + const body = await render([ + { + ...notification, + type: 'rejected', + reason: 'Spam', + organization, + } as NotificationGroup, + ]); + + assert.match(body, /@laravel's commit was rejected/); + assert.match( + body, + /Only @laravel's members can see this update and its rejected commit\./, + ); + assert.doesNotMatch(body, /Your commit|Only you/); +}); + test('previews the person of a single reply from the avatar and the name', async () => { const body = await render([notification]); diff --git a/tests/Unit/QuotedCommit.test.tsx b/tests/Unit/QuotedCommit.test.tsx index 134c41fab..a790caf25 100644 --- a/tests/Unit/QuotedCommit.test.tsx +++ b/tests/Unit/QuotedCommit.test.tsx @@ -70,7 +70,7 @@ async function render( } for (const isPreview of [false, true]) { - test(`renders a quoted code block highlighted ${isPreview ? 'in the composer' : 'in the log'}`, async () => { + test(`renders a quoted code block highlighted ${isPreview ? 'in the composer' : 'in the log'}`, async () => { const body = await render(isPreview); assert.match(body, /]*>return<\/span>/); @@ -78,14 +78,14 @@ for (const isPreview of [false, true]) { }); } - test('keeps the quote a single link', async () => { +test('keeps the quote a single link', async () => { const body = await render(false); assert.equal(body.match(/ { +test('shows the images, link card and embed of a quoted commit inside the single quote link', async () => { const image = { id: 'image', url: '/storage/commit-images/sunset.webp', @@ -132,7 +132,7 @@ for (const isPreview of [false, true]) { } }); - test('cuts a long quoted commit off at its preview', async () => { +test('cuts a long quoted commit off at its preview', async () => { const body = await render(false, { ...commit, body: 'We now have long posts! You can write long articles on /dev/null.', @@ -149,7 +149,7 @@ for (const isPreview of [false, true]) { assert.doesNotMatch(body, /long articles/); }); - test('shows the options of a quoted poll without letting anyone vote inside the quote', async () => { +test('shows the options of a quoted poll without letting anyone vote inside the quote', async () => { const body = await render(false, { ...commit, poll }); assert.match(body, /data-test="commit-poll"/); @@ -159,7 +159,7 @@ for (const isPreview of [false, true]) { assert.doesNotMatch(body, /<(input|button|fieldset|label)\b/); }); - test('shows the results of a quoted poll once the viewer may see them', async () => { +test('shows the results of a quoted poll once the viewer may see them', async () => { const body = await render(true, { ...commit, poll: { @@ -176,3 +176,27 @@ for (const isPreview of [false, true]) { assert.match(body, />75%/); assert.match(body, /4 votes/); }); + +test("tells who may see a rejected commit: the person who posted it, or the organization's members", async () => { + const personal = await render(false, { ...commit, status: 'rejected' }); + const organizational = await render(false, { + ...commit, + status: 'rejected', + author: { + type: 'organization', + id: 'laravel', + handle: 'laravel', + name: 'Laravel', + lane: null, + }, + } as QuotedCommitData); + + assert.match( + personal, + /title="This commit was rejected. Only you can see it."/, + ); + assert.match( + organizational, + /title="This commit was rejected. Only @laravel's members can see it."/, + ); +});