diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 0d4e7ba19..452ec288d 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -289,7 +289,9 @@ Add a poll to a top-level commit with **two to four distinct options**, each up Signed-in readers can choose one option and vote once while an approved, visible poll is open; a vote cannot be changed or withdrawn. The author cannot vote in their own poll. Results are visible to the author and people who have voted, and to everyone once the poll closes. Other people's individual votes are not revealed. -Sources: [poll editor](resources/js/components/poll-editor.tsx), [poll results](app/Models/Poll.php), [voting rules](app/Rules/OpenPoll.php), [approval and poll opening](app/Actions/UpdateCommitStatuses.php). +When a poll closes, people who voted are notified that the results are in; the notification opens the poll's commit, so a vote from days before never needs scrolling back for. The author is not notified: they set the duration and see the results throughout. + +Sources: [poll editor](resources/js/components/poll-editor.tsx), [poll results](app/Models/Poll.php), [voting rules](app/Rules/OpenPoll.php), [approval and poll opening](app/Actions/UpdateCommitStatuses.php), [poll result notifications](app/Actions/CreatePollEndedNotifications.php), [closing scan](app/Console/Commands/NotifyEndedPollsCommand.php). ### Scheduled commits @@ -337,7 +339,7 @@ Sources: [Pastéis setting](resources/js/components/pasteis-mode-setting.tsx), [ ### Notifications -Open **Notifications** to read in-app activity: follows, replies, quotes, mentions, stars, and moderation messages about rejected commits (approvals do not notify). The navigation shows unread activity and checks for more every 30 seconds. While you have unread notifications or messages, the browser tab's icon shows an orange dot; a tab in the background checks about every five minutes. Related activity is grouped, with expandable details: stars per commit, follows per day, and replies and mentions per thread. Filters (Replies, Quotes, Stars, Mentions, Follows, Moderation) are remembered, and the unread count follows them. Opening the inbox marks all notifications read. You can follow back or unfollow from follower notifications. +Open **Notifications** to read in-app activity: follows, replies, quotes, mentions, stars, poll results, and moderation messages about rejected commits (approvals do not notify). The navigation shows unread activity and checks for more every 30 seconds. While you have unread notifications or messages, the browser tab's icon shows an orange dot; a tab in the background checks about every five minutes. Related activity is grouped, with expandable details: stars per commit, follows per day, and replies and mentions per thread. Filters (Replies, Quotes, Stars, Mentions, Follows, Moderation) are remembered, and the unread count follows them. Poll results show under **All**, outside the activity filters, like level-ups. Opening the inbox marks all notifications read. You can follow back or unfollow from follower notifications. Level-up notifications announce your new Unix rank under **All**, rather than one of the activity filters. Each level can notify you only once, even if you later lose and regain enough XP to reach it. diff --git a/app/Actions/CreatePollEndedNotifications.php b/app/Actions/CreatePollEndedNotifications.php new file mode 100644 index 000000000..fc737400e --- /dev/null +++ b/app/Actions/CreatePollEndedNotifications.php @@ -0,0 +1,44 @@ +votes()->pluck('user_id')->unique()->values()->all(); + + if ($voterIds === []) { + return; + } + + $notifications = []; + + foreach ($voterIds as $voterId) { + $notifications[] = [ + 'id' => Str::uuid7()->toString(), + 'recipient_id' => $voterId, + 'actor_id' => $poll->commit?->user_id, + 'type' => NotificationType::PollEnded->value, + 'commit_id' => $poll->commit_id, + 'subject_id' => $poll->commit_id, + 'key' => 'poll-ended:'.$poll->commit_id, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + Notification::query()->insertOrIgnore($notifications); + } +} diff --git a/app/Console/Commands/NotifyEndedPollsCommand.php b/app/Console/Commands/NotifyEndedPollsCommand.php new file mode 100644 index 000000000..fe4755e8d --- /dev/null +++ b/app/Console/Commands/NotifyEndedPollsCommand.php @@ -0,0 +1,48 @@ +whereNotNull('opened_at') + ->whereRaw('opened_at + make_interval(mins => duration) <= ?', [now()]) + ->whereRaw('opened_at + make_interval(mins => duration) > ?', [now()->subDay()]) + ->with('commit:id,user_id') + ->lazyById(); + + foreach ($polls as $poll) { + try { + $action->handle($poll); + } catch (Throwable $exception) { + report($exception); + $this->error('Could not notify the voters of poll '.$poll->id.'. It will be retried.'); + $failed = true; + } + + $notified++; + } + + $this->components->info("Notified the voters of {$notified} closed polls."); + + return $failed ? self::FAILURE : self::SUCCESS; + } +} diff --git a/app/Enums/NotificationType.php b/app/Enums/NotificationType.php index 88bb1943b..12ebe1d12 100644 --- a/app/Enums/NotificationType.php +++ b/app/Enums/NotificationType.php @@ -14,4 +14,5 @@ enum NotificationType: string case Quoted = 'quoted'; case Starred = 'starred'; case LeveledUp = 'leveled_up'; + case PollEnded = 'poll_ended'; } diff --git a/app/Queries/NotificationBuilder.php b/app/Queries/NotificationBuilder.php index 5bc74085e..949148cc0 100644 --- a/app/Queries/NotificationBuilder.php +++ b/app/Queries/NotificationBuilder.php @@ -31,7 +31,7 @@ public function relevant(): self ->whereColumn('follows.followed_id', 'notifications.recipient_id') ->offset(0))) ->orWhere(fn (Builder $query): Builder => $query - ->whereIn('type', [NotificationType::Replied, NotificationType::Quoted, NotificationType::Mentioned, NotificationType::Starred]) + ->whereIn('type', [NotificationType::Replied, NotificationType::Quoted, NotificationType::Mentioned, NotificationType::Starred, NotificationType::PollEnded]) ->whereHas('commit', fn (CommitBuilder $commit): CommitBuilder => $commit ->where('status', CommitStatus::Approved) ->offset(0))) diff --git a/database/factories/NotificationFactory.php b/database/factories/NotificationFactory.php index 9fd33bb19..36ba74dc9 100644 --- a/database/factories/NotificationFactory.php +++ b/database/factories/NotificationFactory.php @@ -7,6 +7,8 @@ use App\Enums\NotificationType; use App\Models\Commit; use App\Models\Notification; +use App\Models\Poll; +use App\Models\PollVote; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -37,6 +39,7 @@ public function configure(): self 'key' => match ($notification->type) { NotificationType::Starred => 'starred:'.$notification->commit_id.':'.$notification->actor_id, NotificationType::LeveledUp => 'leveled_up:'.$notification->level, + NotificationType::PollEnded => 'poll-ended:'.$notification->subject_id, default => $notification->type->value.':'.($notification->commit_id ?? $notification->actor_id), }, ])) @@ -123,6 +126,31 @@ public function mentioned(): self ]); } + /** + * Poll results from the author of the poll's commit to the recipient, who voted. + */ + public function pollEnded(): self + { + return $this->state(fn (array $attributes): array => [ + 'type' => NotificationType::PollEnded, + 'commit_id' => function (array $attributes): string { + $poll = Poll::factory() + ->for(Commit::factory()->for(User::query()->whereKey($attributes['actor_id'])->sole())) + ->closed() + ->withOptions('First', 'Second') + ->create(); + + PollVote::factory()->create([ + 'poll_option_id' => $poll->options->firstOrFail()->id, + 'user_id' => $attributes['recipient_id'], + ]); + + return (string) $poll->commit_id; + }, + 'subject_id' => fn (array $attributes): string => Commit::query()->whereKey($attributes['commit_id'])->sole('id')->id, + ]); + } + public function read(): self { return $this->state(fn (array $attributes): array => [ diff --git a/resources/js/pages/notification/index.tsx b/resources/js/pages/notification/index.tsx index 6809b7112..fc91383e2 100644 --- a/resources/js/pages/notification/index.tsx +++ b/resources/js/pages/notification/index.tsx @@ -3,6 +3,7 @@ import { Head, InfiniteScroll, Link, router, usePoll } from '@inertiajs/react'; import { ArrowUp, AtSign, + ChartBar, Check, ChevronDown, ChevronsUp, @@ -70,7 +71,7 @@ const filterOptions: { { type: 'all', label: 'All', - empty: 'Nothing here yet. Follows, stars, replies, quotes, mentions, level-ups, and moderation updates for your commits will show up here.', + empty: 'Nothing here yet. Follows, stars, replies, quotes, mentions, poll results, level-ups, and moderation updates for your commits will show up here.', }, { type: 'replies', @@ -764,6 +765,7 @@ export function NotificationContent({ replied: MessageSquareReply, quoted: Quote, starred: Star, + poll_ended: ChartBar, mentioned: AtSign, approved: Check, rejected: X, @@ -803,6 +805,7 @@ export function NotificationContent({ starred: isImageOnly ? `starred your ${starredImages.length === 1 ? 'image' : 'images'}` : 'starred your commit', + poll_ended: 'closed a poll you voted in — the results are in', mentioned: 'mentioned you', approved: 'Your commit was approved', rejected: 'Your commit was rejected', diff --git a/resources/js/types/notification.ts b/resources/js/types/notification.ts index afb74b230..3818fd775 100644 --- a/resources/js/types/notification.ts +++ b/resources/js/types/notification.ts @@ -28,7 +28,7 @@ export type Notification = commit: null; }) | (BaseNotification & { - type: 'replied' | 'starred'; + type: 'replied' | 'starred' | 'poll_ended'; commit_id: string; subject_id: string; commit: Omit; diff --git a/routes/console.php b/routes/console.php index 4b6746e1b..ed2ca2909 100644 --- a/routes/console.php +++ b/routes/console.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Schedule; Schedule::command('commits:submit-scheduled')->everyMinute()->withoutOverlapping(5); +Schedule::command('polls:notify-ended')->everyMinute()->withoutOverlapping(5); Schedule::job(new EmbedKnowledgeBase())->hourly(); Schedule::job(new EndLiveStreams())->everyFiveMinutes(); diff --git a/tests/Feature/Commands/NotifyEndedPollsTest.php b/tests/Feature/Commands/NotifyEndedPollsTest.php new file mode 100644 index 000000000..d33dc9068 --- /dev/null +++ b/tests/Feature/Commands/NotifyEndedPollsTest.php @@ -0,0 +1,84 @@ +closed()->withOptions('First', 'Second')->create(); + $voter = User::factory()->create(); + PollVote::factory()->create(['poll_option_id' => $poll->options->firstOrFail()->id, 'user_id' => $voter->id]); + + $this->artisan('polls:notify-ended')->expectsOutputToContain('Notified the voters of 1 closed polls.')->assertSuccessful(); + + expect($voter->notifications()->sole()->type)->toBe(NotificationType::PollEnded); +}); + +it('notifies the same poll once across runs', function (): void { + $poll = Poll::factory()->closed()->withOptions('First', 'Second')->create(); + $voter = User::factory()->create(); + PollVote::factory()->create(['poll_option_id' => $poll->options->firstOrFail()->id, 'user_id' => $voter->id]); + + $this->artisan('polls:notify-ended')->assertSuccessful(); + $this->artisan('polls:notify-ended')->assertSuccessful(); + + expect($voter->notifications()->count())->toBeOne(); +}); + +it('leaves polls that are running, unopened, or closed more than a day ago alone', function (): void { + $running = Poll::factory()->withOptions('First', 'Second')->create(); + $unopened = Poll::factory()->unopened()->withOptions('First', 'Second')->create(); + $older = Poll::factory()->closed()->withOptions('First', 'Second')->create(['opened_at' => now()->subDays(4)]); + $voter = User::factory()->create(); + + foreach ([$running, $unopened, $older] as $poll) { + PollVote::factory()->create(['poll_option_id' => $poll->options->firstOrFail()->id, 'user_id' => $voter->id]); + } + + $this->artisan('polls:notify-ended')->expectsOutputToContain('Notified the voters of 0 closed polls.')->assertSuccessful(); + + expect($voter->notifications()->exists())->toBeFalse(); +}); + +it('keeps going and reports when the voters of one poll cannot be notified', function (): void { + Exceptions::fake(); + + $poll = Poll::factory()->closed()->withOptions('First', 'Second')->create(); + $voter = User::factory()->create(); + PollVote::factory()->create(['poll_option_id' => $poll->options->firstOrFail()->id, 'user_id' => $voter->id]); + + DB::beforeExecuting(function (string $query): void { + throw_if(str_contains($query, 'insert') && str_contains($query, '"notifications"'), RuntimeException::class, 'Simulated insert failure.'); + }); + + $this->artisan('polls:notify-ended') + ->expectsOutputToContain('Could not notify the voters of poll '.$poll->id.'. It will be retried.') + ->expectsOutputToContain('Notified the voters of 1 closed polls.') + ->assertFailed(); + + expect($voter->notifications()->exists())->toBeFalse(); + + Exceptions::assertReported(RuntimeException::class); +}); + +it('notifies every closed poll in the window, whatever the backlog', function (): void { + $author = User::factory()->create(); + $voter = User::factory()->create(); + + foreach (range(1, 105) as $i) { + $poll = Poll::factory()->for(Commit::factory()->for($author))->closed()->withOptions('First', 'Second')->create(); + PollVote::factory()->create(['poll_option_id' => $poll->options->firstOrFail()->id, 'user_id' => $voter->id]); + } + + $this->artisan('polls:notify-ended') + ->expectsOutputToContain('Notified the voters of 105 closed polls.') + ->assertSuccessful(); + + expect($voter->notifications()->where('type', 'poll_ended')->count())->toBe(105); +}); diff --git a/tests/Unit/Actions/CreatePollEndedNotificationsTest.php b/tests/Unit/Actions/CreatePollEndedNotificationsTest.php new file mode 100644 index 000000000..c2ac3a9a7 --- /dev/null +++ b/tests/Unit/Actions/CreatePollEndedNotificationsTest.php @@ -0,0 +1,69 @@ +closed()->withOptions('First', 'Second')->create(); + $voters = User::factory()->count(2)->create(); + + foreach ($voters as $index => $voter) { + PollVote::factory()->create(['poll_option_id' => $poll->options[$index]->id, 'user_id' => $voter->id]); + } + + resolve(CreatePollEndedNotifications::class)->handle($poll); + + expect($voters[0]->notifications()->sole()->type)->toBe(NotificationType::PollEnded) + ->and($voters[1]->notifications()->sole()->type)->toBe(NotificationType::PollEnded); +}); + +it("names the poll author as the actor, and the poll's commit as the subject", function (): void { + $poll = Poll::factory()->closed()->withOptions('First', 'Second')->create(); + $voter = User::factory()->create(); + PollVote::factory()->create(['poll_option_id' => $poll->options->firstOrFail()->id, 'user_id' => $voter->id]); + + resolve(CreatePollEndedNotifications::class)->handle($poll); + + $notification = $voter->notifications()->sole(); + + expect($notification->actor_id)->toBe($poll->commit->user_id) + ->and($notification->commit_id)->toBe($poll->commit_id) + ->and($notification->subject_id)->toBe($poll->commit_id); +}); + +it('does not notify the author', function (): void { + $poll = Poll::factory()->closed()->withOptions('First', 'Second')->create(); + $voter = User::factory()->create(); + PollVote::factory()->create(['poll_option_id' => $poll->options->firstOrFail()->id, 'user_id' => $voter->id]); + + resolve(CreatePollEndedNotifications::class)->handle($poll); + + expect($poll->commit->user->notifications()->where('type', NotificationType::PollEnded)->exists())->toBeFalse(); +}); + +it('is idempotent', function (): void { + $poll = Poll::factory()->closed()->withOptions('First', 'Second')->create(); + $voter = User::factory()->create(); + PollVote::factory()->create(['poll_option_id' => $poll->options->firstOrFail()->id, 'user_id' => $voter->id]); + + $action = resolve(CreatePollEndedNotifications::class); + + $action->handle($poll); + $action->handle($poll); + + expect($voter->notifications()->count())->toBeOne(); +}); + +it('notifies nobody when nobody voted', function (): void { + $poll = Poll::factory()->closed()->withOptions('First', 'Second')->create(); + + resolve(CreatePollEndedNotifications::class)->handle($poll); + + expect(Notification::query()->where('type', NotificationType::PollEnded)->doesntExist())->toBeTrue(); +}); diff --git a/tests/Unit/Enums/NotificationTypeTest.php b/tests/Unit/Enums/NotificationTypeTest.php index 5aced3bec..3ca04dd3a 100644 --- a/tests/Unit/Enums/NotificationTypeTest.php +++ b/tests/Unit/Enums/NotificationTypeTest.php @@ -14,5 +14,6 @@ 'Quoted' => 'quoted', 'Starred' => 'starred', 'LeveledUp' => 'leveled_up', + 'PollEnded' => 'poll_ended', ]); }); diff --git a/tests/Unit/NotificationInbox.test.tsx b/tests/Unit/NotificationInbox.test.tsx index b20028dd1..2f5e6a278 100644 --- a/tests/Unit/NotificationInbox.test.tsx +++ b/tests/Unit/NotificationInbox.test.tsx @@ -129,6 +129,21 @@ function categories(html: string, pressed: boolean): Set { assert.match(html, /]*aria-pressed="true"[^>]*>Quotes<\/button>/); }); + test('renders poll results with their poll commit destination', async () => { + const pollEnded: NotificationGroup = { + ...quote, + type: 'poll_ended', + commit_id: 'poll-commit', + subject_id: 'poll-notice', + commit: { ...quoteCommit, id: 'poll-commit', quoted_commit_id: null }, + }; + const html = await renderInbox([pollEnded]); + + assert.match(html, /closed a poll you voted in — the results are in/); + assert.match(html, /href="\/@quoter\/commits\/poll-commit"/); + assert.match(html, /My quote commentary/); +}); + test('shows the quoted commit under a quote, or a tombstone once it is deleted', async () => { const html = await renderInbox([ { diff --git a/tests/Unit/Queries/NotificationBuilderTest.php b/tests/Unit/Queries/NotificationBuilderTest.php index 112c4b936..71170d32a 100644 --- a/tests/Unit/Queries/NotificationBuilderTest.php +++ b/tests/Unit/Queries/NotificationBuilderTest.php @@ -160,6 +160,20 @@ 'starred' => fn (): Notification => Notification::factory()->starred()->create(), ]); +it('keeps poll results for approved polls', function (): void { + $notification = Notification::factory()->pollEnded()->create(); + + expect(Notification::query()->relevant()->sole()->is($notification))->toBeTrue(); +}); + +it('hides poll results once the poll commit is rejected', function (): void { + $notification = Notification::factory()->pollEnded()->create(); + + resolve(UpdateCommitStatus::class)->handle($notification->commit, CommitStatus::Rejected); + + expect(Notification::query()->whereKey($notification->id)->relevant()->exists())->toBeFalse(); +}); + it('deletes notifications along with the recipient', function (): void { $notification = Notification::factory()->create(); @@ -181,6 +195,7 @@ 'replied' => fn (): Notification => Notification::factory()->replied()->create(), 'mentioned' => fn (): Notification => Notification::factory()->mentioned()->create(), 'starred' => fn (): Notification => Notification::factory()->starred()->create(), + 'poll ended' => fn (): Notification => Notification::factory()->pollEnded()->create(), ]); it('hides replies, mentions and stars across a block in either direction', function (string $state, bool $recipientBlocks): void { @@ -191,4 +206,4 @@ : $notification->actor->blocking()->attach($notification->recipient_id); expect(Notification::query()->relevant()->exists())->toBeFalse(); -})->with(['replied', 'mentioned', 'starred'])->with([true, false]); +})->with(['replied', 'mentioned', 'starred', 'pollEnded'])->with([true, false]);