From 75862c6761826de5bdb3d7b397f6b4521521951d Mon Sep 17 00:00:00 2001 From: Thijs Kok Date: Mon, 27 Jul 2026 21:21:46 +0200 Subject: [PATCH 1/3] Refactor notification summary handling to use Summary value object --- README.md | 33 ++++--- resources/views/summary.blade.php | 16 +++- src/Concerns/Gateable.php | 4 +- src/Contracts/Gated.php | 4 +- src/Notifications/Summary.php | 109 ++++++++++++++++++++++ src/Notifications/SummaryNotification.php | 6 +- tests/FlushingNotificationsTest.php | 2 +- tests/Notifications/TestNotification.php | 10 +- tests/SummaryNotificationTest.php | 81 +++++++++++++++- 9 files changed, 234 insertions(+), 31 deletions(-) create mode 100644 src/Notifications/Summary.php diff --git a/README.md b/README.md index f832556..5dfd274 100644 --- a/README.md +++ b/README.md @@ -102,20 +102,28 @@ That's all the setup required. The floodgate will now buffer notifications withi ### Sending a Summary -Implement `toSummary` on your notification to define what the summary looks like. It receives all buffered notification instances and should return an array shaped like your `toArray` method: +Implement `toSummary` on your notification to define what the summary looks like. It receives all buffered notification instances and should return a `Summary` value object: ```php -public function toSummary(array $items): array +use TestMonitor\Floodgate\Notifications\Summary; + +public function toSummary(array $items): Summary { - return [ - 'message' => ':count issues have been assigned to you', - 'url' => route('issues.index'), - 'icon' => 'exclamation-circle', - 'properties' => ['count' => count($items)], - ]; + return (new Summary) + ->title('Issue Activity') + ->message(':count issues have been assigned to you') + ->with(['count' => count($items)]) + ->subject('Your issue activity summary') + ->action('View Issues', route('issues.index')); } ``` +`title`, `subject` and `action` are all optional: + +- `title`, when set, is rendered as a heading with `message` as regular text below it; otherwise only `message` is rendered. +- `subject`, when omitted, falls back to "You have new notifications". +- `action`, when omitted, renders no button. + When a summary is sent, the `toArray` method on each individual notification is passed to the summary mail view as `$items`, allowing you to include per-item detail alongside the grouped summary. ### Customizing the Buffer Window @@ -164,20 +172,23 @@ For full control, replace the summary class in the configuration: 'summary' => App\Notifications\IssueSummaryNotification::class, ``` -Your custom class receives `$summary`, `$notifications`, and `$channels` in its constructor. Extend the default to override only what you need: +Your custom class receives a `Summary` value object, the buffered `$notifications`, and `$channels` in its constructor. Extend the default to override only what you need, for example the mail view: ```php +use Illuminate\Notifications\Messages\MailMessage; use TestMonitor\Floodgate\Notifications\SummaryNotification; class IssueSummaryNotification extends SummaryNotification { - protected function subject(): string + public function toMail(mixed $notifiable): MailMessage { - return 'Your issue activity summary'; + return parent::toMail($notifiable)->cc('team@example.com'); } } ``` +To customize the subject on a per-notification basis, set it via the `subject` property when building the `Summary` in `toSummary()` instead (see [Sending a Summary](#sending-a-summary)). + ## Tests The package contains a full test suite. Run it using: diff --git a/resources/views/summary.blade.php b/resources/views/summary.blade.php index 8b5f155..0226dff 100644 --- a/resources/views/summary.blade.php +++ b/resources/views/summary.blade.php @@ -1,16 +1,22 @@ @component('mail::message') -# {{ __($summary['message'] ?? 'You have new notifications', $summary['properties'] ?? []) }} +@if ($summary->title) +# {{ __($summary->title, $summary->data) }} +@endif + +{{ __($summary->message, $summary->data) }} @component('mail::table') -| # | Notification | +| # | @lang('Notification') | | - | ----------- | @foreach ($items as $index => $item) -| {{ $index + 1 }} | [{{ __($item['message'] ?? '', $item['properties'] ?? []) }}]({{ $item['url'] ?? '#' }}) | +| {{ $index + 1 }} | [{{ __($item['message'] ?? '', $item['data'] ?? []) }}]({{ $item['url'] ?? '#' }}) | @endforeach @endcomponent -@component('mail::button', ['url' => $summary['url'] ?? '/']) -View +@if ($summary->actionText) +@component('mail::button', ['url' => $summary->actionUrl]) +@lang($summary->actionText) @endcomponent +@endif @endcomponent diff --git a/src/Concerns/Gateable.php b/src/Concerns/Gateable.php index 1843935..542cee2 100644 --- a/src/Concerns/Gateable.php +++ b/src/Concerns/Gateable.php @@ -2,6 +2,8 @@ namespace TestMonitor\Floodgate\Concerns; +use TestMonitor\Floodgate\Notifications\Summary; + trait Gateable { protected bool $floodgateExempt = false; @@ -27,5 +29,5 @@ public function isFloodgateExempt(): bool /* * Return a grouped summary of multiple buffered notifications of this type. */ - abstract public function toSummary(array $items): array; + abstract public function toSummary(array $items): Summary; } diff --git a/src/Contracts/Gated.php b/src/Contracts/Gated.php index 5b07eda..135752f 100644 --- a/src/Contracts/Gated.php +++ b/src/Contracts/Gated.php @@ -2,6 +2,8 @@ namespace TestMonitor\Floodgate\Contracts; +use TestMonitor\Floodgate\Notifications\Summary; + interface Gated { /* @@ -17,5 +19,5 @@ public function isFloodgateExempt(): bool; /* * Return a grouped summary of multiple buffered notifications of this type. */ - public function toSummary(array $items): array; + public function toSummary(array $items): Summary; } diff --git a/src/Notifications/Summary.php b/src/Notifications/Summary.php new file mode 100644 index 0000000..8737745 --- /dev/null +++ b/src/Notifications/Summary.php @@ -0,0 +1,109 @@ +title = $title; + + return $this; + } + + /* + * Set the summary message, using the same :placeholder syntax as toArray(). + */ + public function message(string $message): static + { + $this->message = $message; + + return $this; + } + + /* + * Merge additional replacement values for the message's :placeholders. + */ + public function with(array|string $key, mixed $value = null): static + { + if (is_array($key)) { + $this->data = array_merge($this->data, $key); + } else { + $this->data[$key] = $value; + } + + return $this; + } + + /* + * Set the mail subject. + */ + public function subject(string $subject): static + { + $this->subject = $subject; + + return $this; + } + + /* + * Set the call-to-action button's text and URL. + */ + public function action(string $text, string $url): static + { + $this->actionText = $text; + $this->actionUrl = $url; + + return $this; + } + + /* + * Return the summary as an array, suitable for the database channel. + */ + public function toArray(): array + { + return array_filter([ + 'title' => $this->title, + 'message' => $this->message, + 'data' => $this->data, + 'url' => $this->actionUrl, + ], fn ($value) => ! is_null($value)); + } +} diff --git a/src/Notifications/SummaryNotification.php b/src/Notifications/SummaryNotification.php index cce8a03..68b0b01 100644 --- a/src/Notifications/SummaryNotification.php +++ b/src/Notifications/SummaryNotification.php @@ -8,7 +8,7 @@ class SummaryNotification extends Notification { public function __construct( - protected array $summary, + protected Summary $summary, protected array $notifications, protected array $channels, ) {} @@ -26,7 +26,7 @@ public function via(mixed $notifiable): array */ public function toArray(mixed $notifiable): array { - return $this->summary; + return $this->summary->toArray(); } /* @@ -40,7 +40,7 @@ public function toMail(mixed $notifiable): MailMessage ); return (new MailMessage) - ->subject('You have new notifications') + ->subject($this->summary->subject ?? __('You have new notifications')) ->markdown('floodgate::summary', [ 'summary' => $this->summary, 'items' => $items, diff --git a/tests/FlushingNotificationsTest.php b/tests/FlushingNotificationsTest.php index 5dd5cb9..716f6a3 100644 --- a/tests/FlushingNotificationsTest.php +++ b/tests/FlushingNotificationsTest.php @@ -47,7 +47,7 @@ public function it_sends_a_summary_when_multiple_notifications_were_buffered(): Notification::assertSentTo($user, SummaryNotification::class, function ($notification) use ($user) { return $notification->toArray($user) === [ 'message' => ':count test notifications', - 'properties' => ['count' => 3], + 'data' => ['count' => 3], ]; }); Notification::assertNotSentTo($user, TestNotification::class); diff --git a/tests/Notifications/TestNotification.php b/tests/Notifications/TestNotification.php index 2fd3f79..ea4eae5 100644 --- a/tests/Notifications/TestNotification.php +++ b/tests/Notifications/TestNotification.php @@ -8,6 +8,7 @@ use Illuminate\Notifications\Notification; use TestMonitor\Floodgate\Concerns\Gateable; use TestMonitor\Floodgate\Middleware\ThrottlesNotifications; +use TestMonitor\Floodgate\Notifications\Summary; class TestNotification extends Notification implements ShouldQueue { @@ -35,11 +36,10 @@ public function toArray(mixed $notifiable): array return ['message' => 'Test notification']; } - public function toSummary(array $items): array + public function toSummary(array $items): Summary { - return [ - 'message' => ':count test notifications', - 'properties' => ['count' => count($items)], - ]; + return (new Summary) + ->message(':count test notifications') + ->with(['count' => count($items)]); } } diff --git a/tests/SummaryNotificationTest.php b/tests/SummaryNotificationTest.php index 10ffddf..38c7474 100644 --- a/tests/SummaryNotificationTest.php +++ b/tests/SummaryNotificationTest.php @@ -4,6 +4,7 @@ use Illuminate\Notifications\Messages\MailMessage; use PHPUnit\Framework\Attributes\Test; +use TestMonitor\Floodgate\Notifications\Summary; use TestMonitor\Floodgate\Notifications\SummaryNotification; use TestMonitor\Floodgate\Tests\Notifications\TestNotification; @@ -13,7 +14,7 @@ class SummaryNotificationTest extends TestCase public function it_returns_the_configured_channels(): void { // Given - $notification = new SummaryNotification([], [], ['mail', 'database']); + $notification = new SummaryNotification(new Summary, [], ['mail', 'database']); // When $channels = $notification->via($this->createUser()); @@ -26,7 +27,7 @@ public function it_returns_the_configured_channels(): void public function it_returns_the_summary_array(): void { // Given - $summary = ['message' => ':count issues assigned', 'properties' => ['count' => 3]]; + $summary = (new Summary)->message(':count issues assigned')->with(['count' => 3]); $notification = new SummaryNotification($summary, [], ['mail']); $user = $this->createUser(); @@ -34,7 +35,7 @@ public function it_returns_the_summary_array(): void $result = $notification->toArray($user); // Then - $this->assertEquals($summary, $result); + $this->assertEquals(['message' => ':count issues assigned', 'data' => ['count' => 3]], $result); } #[Test] @@ -42,7 +43,7 @@ public function it_builds_a_mail_message_with_summary_and_items(): void { // Given $user = $this->createUser(); - $summary = ['message' => ':count issues assigned', 'properties' => ['count' => 2]]; + $summary = (new Summary)->message(':count issues assigned')->with(['count' => 2]); $notifications = [new TestNotification, new TestNotification]; $notification = new SummaryNotification($summary, $notifications, ['mail']); @@ -55,4 +56,76 @@ public function it_builds_a_mail_message_with_summary_and_items(): void $this->assertCount(2, $mail->viewData['items']); $this->assertEquals($summary, $mail->viewData['summary']); } + + #[Test] + public function it_uses_the_custom_subject_from_the_summary(): void + { + // Given + $user = $this->createUser(); + $summary = (new Summary) + ->message(':count issues assigned') + ->with(['count' => 2]) + ->subject('Your issue activity summary'); + $notification = new SummaryNotification($summary, [], ['mail']); + + // When + $mail = $notification->toMail($user); + + // Then + $this->assertEquals('Your issue activity summary', $mail->subject); + } + + #[Test] + public function it_includes_the_title_in_the_rendered_mail(): void + { + // Given + $user = $this->createUser(); + $summary = (new Summary) + ->title('Issue Activity') + ->message(':count issues assigned') + ->with(['count' => 2]); + $notification = new SummaryNotification($summary, [], ['mail']); + + // When + $rendered = $notification->toMail($user)->render(); + + // Then + $this->assertStringContainsString('Issue Activity', $rendered); + $this->assertStringContainsString('2 issues assigned', $rendered); + } + + #[Test] + public function it_renders_the_action_button_when_set(): void + { + // Given + $user = $this->createUser(); + $summary = (new Summary) + ->message(':count issues assigned') + ->with(['count' => 2]) + ->action('View Issues', 'https://example.test/issues'); + $notification = new SummaryNotification($summary, [], ['mail']); + + // When + $rendered = $notification->toMail($user)->render(); + + // Then + $this->assertStringContainsString('class="action"', $rendered); + $this->assertStringContainsString('View Issues', $rendered); + $this->assertStringContainsString('https://example.test/issues', $rendered); + } + + #[Test] + public function it_omits_the_action_button_when_not_set(): void + { + // Given + $user = $this->createUser(); + $summary = (new Summary)->message(':count issues assigned')->with(['count' => 2]); + $notification = new SummaryNotification($summary, [], ['mail']); + + // When + $rendered = $notification->toMail($user)->render(); + + // Then + $this->assertStringNotContainsString('class="action"', $rendered); + } } From 35fa77dc8e4cbcc4d0a2277e80788d29769a3068 Mon Sep 17 00:00:00 2001 From: Thijs Kok Date: Mon, 27 Jul 2026 21:28:20 +0200 Subject: [PATCH 2/3] Swap test method names and update assertions --- tests/SummaryNotificationTest.php | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/SummaryNotificationTest.php b/tests/SummaryNotificationTest.php index 38c7474..d8601d2 100644 --- a/tests/SummaryNotificationTest.php +++ b/tests/SummaryNotificationTest.php @@ -58,40 +58,40 @@ public function it_builds_a_mail_message_with_summary_and_items(): void } #[Test] - public function it_uses_the_custom_subject_from_the_summary(): void + public function it_includes_the_title_in_the_rendered_mail(): void { // Given $user = $this->createUser(); $summary = (new Summary) + ->title('Issue Activity') ->message(':count issues assigned') - ->with(['count' => 2]) - ->subject('Your issue activity summary'); + ->with(['count' => 2]); $notification = new SummaryNotification($summary, [], ['mail']); // When - $mail = $notification->toMail($user); + $rendered = $notification->toMail($user)->render(); // Then - $this->assertEquals('Your issue activity summary', $mail->subject); + $this->assertStringContainsString('Issue Activity', $rendered); + $this->assertStringContainsString('2 issues assigned', $rendered); } #[Test] - public function it_includes_the_title_in_the_rendered_mail(): void + public function it_uses_the_custom_subject_from_the_summary(): void { // Given $user = $this->createUser(); $summary = (new Summary) - ->title('Issue Activity') ->message(':count issues assigned') - ->with(['count' => 2]); + ->with(['count' => 2]) + ->subject('Your issue activity summary'); $notification = new SummaryNotification($summary, [], ['mail']); // When - $rendered = $notification->toMail($user)->render(); + $mail = $notification->toMail($user); // Then - $this->assertStringContainsString('Issue Activity', $rendered); - $this->assertStringContainsString('2 issues assigned', $rendered); + $this->assertEquals('Your issue activity summary', $mail->subject); } #[Test] From 4d45f57e8f6f3c8ef24ae9dbf836f5692725696d Mon Sep 17 00:00:00 2001 From: Thijs Kok Date: Mon, 27 Jul 2026 21:34:59 +0200 Subject: [PATCH 3/3] Refactor SummaryNotificationTest and add SummaryTest for value object validation --- tests/SummaryNotificationTest.php | 7 ++--- tests/SummaryTest.php | 48 +++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 tests/SummaryTest.php diff --git a/tests/SummaryNotificationTest.php b/tests/SummaryNotificationTest.php index d8601d2..0b2419d 100644 --- a/tests/SummaryNotificationTest.php +++ b/tests/SummaryNotificationTest.php @@ -24,18 +24,17 @@ public function it_returns_the_configured_channels(): void } #[Test] - public function it_returns_the_summary_array(): void + public function it_delegates_array_conversion_to_the_summary(): void { // Given $summary = (new Summary)->message(':count issues assigned')->with(['count' => 3]); $notification = new SummaryNotification($summary, [], ['mail']); - $user = $this->createUser(); // When - $result = $notification->toArray($user); + $result = $notification->toArray($this->createUser()); // Then - $this->assertEquals(['message' => ':count issues assigned', 'data' => ['count' => 3]], $result); + $this->assertEquals($summary->toArray(), $result); } #[Test] diff --git a/tests/SummaryTest.php b/tests/SummaryTest.php new file mode 100644 index 0000000..4339fbd --- /dev/null +++ b/tests/SummaryTest.php @@ -0,0 +1,48 @@ +with('count', 3); + + // Then + $this->assertEquals(['count' => 3], $summary->data); + } + + #[Test] + public function it_merges_an_array_of_data_via_with(): void + { + // Given + $summary = (new Summary)->with('count', 3); + + // When + $summary->with(['name' => 'Acme']); + + // Then + $this->assertEquals(['count' => 3, 'name' => 'Acme'], $summary->data); + } + + #[Test] + public function it_converts_to_an_array_omitting_unset_values(): void + { + // Given + $summary = (new Summary)->message(':count issues assigned')->with(['count' => 3]); + + // When + $result = $summary->toArray(); + + // Then + $this->assertEquals(['message' => ':count issues assigned', 'data' => ['count' => 3]], $result); + } +}