From 4feab96765a1858449f907d2f9b70ef041c50fee Mon Sep 17 00:00:00 2001 From: myrmidex Date: Mon, 9 Mar 2026 02:21:24 +0100 Subject: [PATCH] 92 - Add in-app notification infrastructure with model, service, and bell component --- app/Enums/NotificationSeverityEnum.php | 10 ++ app/Enums/NotificationTypeEnum.php | 21 +++ app/Livewire/NotificationBell.php | 42 +++++ app/Models/Notification.php | 92 ++++++++++ .../Notification/NotificationService.php | 69 ++++++++ database/factories/NotificationFactory.php | 51 ++++++ ...1_01_000006_create_notifications_table.php | 33 ++++ resources/views/layouts/app.blade.php | 4 + .../livewire/notification-bell.blade.php | 90 ++++++++++ .../Feature/Livewire/NotificationBellTest.php | 85 +++++++++ tests/Feature/NotificationTest.php | 166 ++++++++++++++++++ 11 files changed, 663 insertions(+) create mode 100644 app/Enums/NotificationSeverityEnum.php create mode 100644 app/Enums/NotificationTypeEnum.php create mode 100644 app/Livewire/NotificationBell.php create mode 100644 app/Models/Notification.php create mode 100644 app/Services/Notification/NotificationService.php create mode 100644 database/factories/NotificationFactory.php create mode 100644 database/migrations/2024_01_01_000006_create_notifications_table.php create mode 100644 resources/views/livewire/notification-bell.blade.php create mode 100644 tests/Feature/Livewire/NotificationBellTest.php create mode 100644 tests/Feature/NotificationTest.php diff --git a/app/Enums/NotificationSeverityEnum.php b/app/Enums/NotificationSeverityEnum.php new file mode 100644 index 0000000..dbbefcb --- /dev/null +++ b/app/Enums/NotificationSeverityEnum.php @@ -0,0 +1,10 @@ + 'General', + self::FEED_STALE => 'Feed Stale', + self::PUBLISH_FAILED => 'Publish Failed', + self::CREDENTIAL_EXPIRED => 'Credential Expired', + }; + } +} diff --git a/app/Livewire/NotificationBell.php b/app/Livewire/NotificationBell.php new file mode 100644 index 0000000..6c82c08 --- /dev/null +++ b/app/Livewire/NotificationBell.php @@ -0,0 +1,42 @@ +markAsRead(); + } + + public function markAllAsRead(): void + { + Notification::markAllAsRead(); + } + + #[Computed] + public function unreadCount(): int + { + return Notification::unread()->count(); + } + + /** + * @return Collection + */ + #[Computed] + public function notifications(): Collection + { + return Notification::recent()->get(); + } + + public function render(): View + { + return view('livewire.notification-bell'); + } +} diff --git a/app/Models/Notification.php b/app/Models/Notification.php new file mode 100644 index 0000000..f6d463e --- /dev/null +++ b/app/Models/Notification.php @@ -0,0 +1,92 @@ +|null $data + * @property string|null $notifiable_type + * @property int|null $notifiable_id + * @property Carbon|null $read_at + * @property Carbon $created_at + * @property Carbon $updated_at + */ +class Notification extends Model +{ + /** @use HasFactory */ + use HasFactory; + + protected $fillable = [ + 'type', + 'severity', + 'title', + 'message', + 'data', + 'notifiable_type', + 'notifiable_id', + 'read_at', + ]; + + protected $casts = [ + 'type' => NotificationTypeEnum::class, + 'severity' => NotificationSeverityEnum::class, + 'data' => 'array', + 'read_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * @return MorphTo + */ + public function notifiable(): MorphTo + { + return $this->morphTo(); + } + + public function isRead(): bool + { + return $this->read_at !== null; + } + + public function markAsRead(): void + { + $this->update(['read_at' => now()]); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeUnread(Builder $query): Builder + { + return $query->whereNull('read_at'); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeRecent(Builder $query): Builder + { + return $query->latest()->limit(50); + } + + public static function markAllAsRead(): void + { + static::unread()->update(['read_at' => now()]); + } +} diff --git a/app/Services/Notification/NotificationService.php b/app/Services/Notification/NotificationService.php new file mode 100644 index 0000000..4e0017b --- /dev/null +++ b/app/Services/Notification/NotificationService.php @@ -0,0 +1,69 @@ + $data + */ + public function send( + NotificationTypeEnum $type, + NotificationSeverityEnum $severity, + string $title, + string $message, + ?Model $notifiable = null, + array $data = [], + ): Notification { + return Notification::create([ + 'type' => $type, + 'severity' => $severity, + 'title' => $title, + 'message' => $message, + 'data' => $data ?: null, + 'notifiable_type' => $notifiable?->getMorphClass(), + 'notifiable_id' => $notifiable?->getKey(), + ]); + } + + /** + * @param array $data + */ + public function info( + string $title, + string $message, + ?Model $notifiable = null, + array $data = [], + ): Notification { + return $this->send(NotificationTypeEnum::GENERAL, NotificationSeverityEnum::INFO, $title, $message, $notifiable, $data); + } + + /** + * @param array $data + */ + public function warning( + string $title, + string $message, + ?Model $notifiable = null, + array $data = [], + ): Notification { + return $this->send(NotificationTypeEnum::GENERAL, NotificationSeverityEnum::WARNING, $title, $message, $notifiable, $data); + } + + /** + * @param array $data + */ + public function error( + string $title, + string $message, + ?Model $notifiable = null, + array $data = [], + ): Notification { + return $this->send(NotificationTypeEnum::GENERAL, NotificationSeverityEnum::ERROR, $title, $message, $notifiable, $data); + } +} diff --git a/database/factories/NotificationFactory.php b/database/factories/NotificationFactory.php new file mode 100644 index 0000000..a722e1a --- /dev/null +++ b/database/factories/NotificationFactory.php @@ -0,0 +1,51 @@ + + */ +class NotificationFactory extends Factory +{ + protected $model = Notification::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'type' => fake()->randomElement(NotificationTypeEnum::cases()), + 'severity' => fake()->randomElement(NotificationSeverityEnum::cases()), + 'title' => fake()->sentence(3), + 'message' => fake()->sentence(), + 'data' => null, + 'read_at' => null, + ]; + } + + public function read(): static + { + return $this->state(['read_at' => now()]); + } + + public function unread(): static + { + return $this->state(['read_at' => null]); + } + + public function severity(NotificationSeverityEnum $severity): static + { + return $this->state(['severity' => $severity]); + } + + public function type(NotificationTypeEnum $type): static + { + return $this->state(['type' => $type]); + } +} diff --git a/database/migrations/2024_01_01_000006_create_notifications_table.php b/database/migrations/2024_01_01_000006_create_notifications_table.php new file mode 100644 index 0000000..0e14bc7 --- /dev/null +++ b/database/migrations/2024_01_01_000006_create_notifications_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('type'); + $table->string('severity'); + $table->string('title'); + $table->text('message'); + $table->json('data')->nullable(); + $table->string('notifiable_type')->nullable(); + $table->unsignedBigInteger('notifiable_id')->nullable(); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + + $table->index('read_at'); + $table->index('created_at'); + $table->index(['notifiable_type', 'notifiable_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 8849a7d..78d305a 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -66,6 +66,9 @@ class="p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100" +
+ +
@@ -98,6 +101,7 @@ class="px-4 border-r border-gray-200 text-gray-500 focus:outline-none focus:ring

FFR

+
diff --git a/resources/views/livewire/notification-bell.blade.php b/resources/views/livewire/notification-bell.blade.php new file mode 100644 index 0000000..0df0c6f --- /dev/null +++ b/resources/views/livewire/notification-bell.blade.php @@ -0,0 +1,90 @@ +
+ + + +
diff --git a/tests/Feature/Livewire/NotificationBellTest.php b/tests/Feature/Livewire/NotificationBellTest.php new file mode 100644 index 0000000..e8913fd --- /dev/null +++ b/tests/Feature/Livewire/NotificationBellTest.php @@ -0,0 +1,85 @@ +assertStatus(200) + ->assertSee('Notifications'); + } + + public function test_it_shows_unread_count_badge(): void + { + Notification::factory()->count(3)->unread()->create(); + + Livewire::test(NotificationBell::class) + ->assertSee('3'); + } + + public function test_it_hides_badge_when_no_unread(): void + { + Notification::factory()->count(2)->read()->create(); + + Livewire::test(NotificationBell::class) + ->assertDontSee('Mark all as read'); + } + + public function test_it_shows_empty_state_when_no_notifications(): void + { + Livewire::test(NotificationBell::class) + ->assertSee('No notifications'); + } + + public function test_mark_as_read(): void + { + $notification = Notification::factory()->unread()->create(); + + Livewire::test(NotificationBell::class) + ->call('markAsRead', $notification->id); + + $this->assertTrue($notification->fresh()->isRead()); + } + + public function test_mark_all_as_read(): void + { + Notification::factory()->count(3)->unread()->create(); + + $this->assertEquals(3, Notification::unread()->count()); + + Livewire::test(NotificationBell::class) + ->call('markAllAsRead'); + + $this->assertEquals(0, Notification::unread()->count()); + } + + public function test_it_displays_notification_title_and_message(): void + { + Notification::factory()->create([ + 'title' => 'Test Notification Title', + 'message' => 'Test notification message body', + ]); + + Livewire::test(NotificationBell::class) + ->assertSee('Test Notification Title') + ->assertSee('Test notification message body'); + } + + public function test_it_caps_badge_at_99_plus(): void + { + Notification::factory()->count(100)->unread()->create(); + + Livewire::test(NotificationBell::class) + ->assertSee('99+'); + } +} diff --git a/tests/Feature/NotificationTest.php b/tests/Feature/NotificationTest.php new file mode 100644 index 0000000..bf14d85 --- /dev/null +++ b/tests/Feature/NotificationTest.php @@ -0,0 +1,166 @@ +service = new NotificationService; + } + + public function test_send_creates_notification_with_all_fields(): void + { + $notification = $this->service->send( + NotificationTypeEnum::FEED_STALE, + NotificationSeverityEnum::WARNING, + 'Feed is stale', + 'Feed "VRT" has not returned new articles in 24 hours.', + data: ['hours_stale' => 24], + ); + + $this->assertDatabaseHas('notifications', [ + 'type' => 'feed_stale', + 'severity' => 'warning', + 'title' => 'Feed is stale', + ]); + $this->assertEquals(['hours_stale' => 24], $notification->data); + $this->assertNull($notification->read_at); + } + + public function test_info_convenience_method(): void + { + $notification = $this->service->info('Test title', 'Test message'); + + $this->assertEquals(NotificationTypeEnum::GENERAL, $notification->type); + $this->assertEquals(NotificationSeverityEnum::INFO, $notification->severity); + } + + public function test_warning_convenience_method(): void + { + $notification = $this->service->warning('Warning title', 'Warning message'); + + $this->assertEquals(NotificationSeverityEnum::WARNING, $notification->severity); + } + + public function test_error_convenience_method(): void + { + $notification = $this->service->error('Error title', 'Error message'); + + $this->assertEquals(NotificationSeverityEnum::ERROR, $notification->severity); + } + + public function test_mark_as_read(): void + { + $notification = Notification::factory()->unread()->create(); + + $this->assertFalse($notification->isRead()); + + $notification->markAsRead(); + + $this->assertTrue($notification->fresh()->isRead()); + $this->assertNotNull($notification->fresh()->read_at); + } + + public function test_mark_all_as_read(): void + { + Notification::factory()->count(3)->unread()->create(); + Notification::factory()->count(2)->read()->create(); + + $this->assertEquals(3, Notification::unread()->count()); + + Notification::markAllAsRead(); + + $this->assertEquals(0, Notification::unread()->count()); + } + + public function test_unread_scope(): void + { + Notification::factory()->count(2)->unread()->create(); + Notification::factory()->count(3)->read()->create(); + + $this->assertEquals(2, Notification::unread()->count()); + } + + public function test_recent_scope_limits_to_50(): void + { + Notification::factory()->count(60)->create(); + + $this->assertCount(50, Notification::recent()->get()); + } + + public function test_enums_cast_correctly(): void + { + $notification = Notification::factory()->create([ + 'type' => NotificationTypeEnum::PUBLISH_FAILED, + 'severity' => NotificationSeverityEnum::ERROR, + ]); + + $fresh = Notification::find($notification->id); + $this->assertInstanceOf(NotificationTypeEnum::class, $fresh->type); + $this->assertInstanceOf(NotificationSeverityEnum::class, $fresh->severity); + $this->assertEquals(NotificationTypeEnum::PUBLISH_FAILED, $fresh->type); + $this->assertEquals(NotificationSeverityEnum::ERROR, $fresh->severity); + } + + public function test_notifiable_morph_to_relationship(): void + { + $feed = Feed::factory()->create(); + + $notification = $this->service->send( + NotificationTypeEnum::FEED_STALE, + NotificationSeverityEnum::WARNING, + 'Feed stale', + 'No new articles', + notifiable: $feed, + ); + + $fresh = Notification::find($notification->id); + $this->assertInstanceOf(Feed::class, $fresh->notifiable); + $this->assertEquals($feed->id, $fresh->notifiable->id); + } + + public function test_notification_without_notifiable(): void + { + $notification = $this->service->info('General notice', 'Something happened'); + + $this->assertNull($notification->notifiable_type); + $this->assertNull($notification->notifiable_id); + $this->assertNull($notification->notifiable); + } + + public function test_notification_type_enum_labels(): void + { + $this->assertEquals('General', NotificationTypeEnum::GENERAL->label()); + $this->assertEquals('Feed Stale', NotificationTypeEnum::FEED_STALE->label()); + $this->assertEquals('Publish Failed', NotificationTypeEnum::PUBLISH_FAILED->label()); + $this->assertEquals('Credential Expired', NotificationTypeEnum::CREDENTIAL_EXPIRED->label()); + } + + public function test_data_stores_null_when_empty(): void + { + $notification = $this->service->info('Title', 'Message'); + + $this->assertNull($notification->data); + } + + public function test_data_stores_array_when_provided(): void + { + $notification = $this->service->info('Title', 'Message', data: ['key' => 'value']); + + $this->assertEquals(['key' => 'value'], $notification->data); + } +}