Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
44 changes: 44 additions & 0 deletions app/Actions/CreatePollEndedNotifications.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\NotificationType;
use App\Models\Notification;
use App\Models\Poll;
use Illuminate\Support\Str;

final readonly class CreatePollEndedNotifications
{
/**
* A poll that closed tells every voter the results are in, once. The author is not notified: they set the poll
* and see its results live. Safe to call more than once.
*/
public function handle(Poll $poll): void
{
$voterIds = $poll->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);
}
}
48 changes: 48 additions & 0 deletions app/Console/Commands/NotifyEndedPollsCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Actions\CreatePollEndedNotifications;
use App\Models\Poll;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Throwable;

#[Signature('polls:notify-ended')]
#[Description('Notify the voters of the polls that closed in the past day that the results are in')]
final class NotifyEndedPollsCommand extends Command
{
public function handle(CreatePollEndedNotifications $action): int
{
$failed = false;
$notified = 0;
// The whole window is scanned each run — streamed with lazyById, like ssh-keys:sync — and the
// notification key keeps re-scans duplicate-free. One missed run never loses a notification, no
// closed poll waits behind a batch cap, and a poll stays in the window for a day after it closes.
$polls = Poll::query()
->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;
}
}
1 change: 1 addition & 0 deletions app/Enums/NotificationType.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ enum NotificationType: string
case Quoted = 'quoted';
case Starred = 'starred';
case LeveledUp = 'leveled_up';
case PollEnded = 'poll_ended';
}
2 changes: 1 addition & 1 deletion app/Queries/NotificationBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
28 changes: 28 additions & 0 deletions database/factories/NotificationFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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),
},
]))
Expand Down Expand Up @@ -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 => [
Expand Down
5 changes: 4 additions & 1 deletion resources/js/pages/notification/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Head, InfiniteScroll, Link, router, usePoll } from '@inertiajs/react';
import {
ArrowUp,
AtSign,
ChartBar,
Check,
ChevronDown,
ChevronsUp,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -764,6 +765,7 @@ export function NotificationContent({
replied: MessageSquareReply,
quoted: Quote,
starred: Star,
poll_ended: ChartBar,
mentioned: AtSign,
approved: Check,
rejected: X,
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion resources/js/types/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Commit, 'user'>;
Expand Down
1 change: 1 addition & 0 deletions routes/console.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
84 changes: 84 additions & 0 deletions tests/Feature/Commands/NotifyEndedPollsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

use App\Enums\NotificationType;
use App\Models\Commit;
use App\Models\Poll;
use App\Models\PollVote;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Exceptions;

it('notifies the voters of the polls that closed', 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')->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);
});
69 changes: 69 additions & 0 deletions tests/Unit/Actions/CreatePollEndedNotificationsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

use App\Actions\CreatePollEndedNotifications;
use App\Enums\NotificationType;
use App\Models\Notification;
use App\Models\Poll;
use App\Models\PollVote;
use App\Models\User;

it('tells every voter the results are in', function (): void {
$poll = Poll::factory()->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();
});
1 change: 1 addition & 0 deletions tests/Unit/Enums/NotificationTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@
'Quoted' => 'quoted',
'Starred' => 'starred',
'LeveledUp' => 'leveled_up',
'PollEnded' => 'poll_ended',
]);
});
Loading