92 - Add in-app notification infrastructure with model, service, and bell component

This commit is contained in:
myrmidex 2026-03-09 02:21:24 +01:00
parent ec09711a6f
commit 4feab96765
11 changed files with 663 additions and 0 deletions

View file

@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum NotificationSeverityEnum: string
{
case INFO = 'info';
case WARNING = 'warning';
case ERROR = 'error';
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum NotificationTypeEnum: string
{
case GENERAL = 'general';
case FEED_STALE = 'feed_stale';
case PUBLISH_FAILED = 'publish_failed';
case CREDENTIAL_EXPIRED = 'credential_expired';
public function label(): string
{
return match ($this) {
self::GENERAL => 'General',
self::FEED_STALE => 'Feed Stale',
self::PUBLISH_FAILED => 'Publish Failed',
self::CREDENTIAL_EXPIRED => 'Credential Expired',
};
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Livewire;
use App\Models\Notification;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Computed;
use Livewire\Component;
class NotificationBell extends Component
{
public function markAsRead(int $id): void
{
Notification::findOrFail($id)->markAsRead();
}
public function markAllAsRead(): void
{
Notification::markAllAsRead();
}
#[Computed]
public function unreadCount(): int
{
return Notification::unread()->count();
}
/**
* @return Collection<int, Notification>
*/
#[Computed]
public function notifications(): Collection
{
return Notification::recent()->get();
}
public function render(): View
{
return view('livewire.notification-bell');
}
}

View file

@ -0,0 +1,92 @@
<?php
namespace App\Models;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use Database\Factories\NotificationFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property NotificationTypeEnum $type
* @property NotificationSeverityEnum $severity
* @property string $title
* @property string $message
* @property array<string, mixed>|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<NotificationFactory> */
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<Model, $this>
*/
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<Notification> $query
* @return Builder<Notification>
*/
public function scopeUnread(Builder $query): Builder
{
return $query->whereNull('read_at');
}
/**
* @param Builder<Notification> $query
* @return Builder<Notification>
*/
public function scopeRecent(Builder $query): Builder
{
return $query->latest()->limit(50);
}
public static function markAllAsRead(): void
{
static::unread()->update(['read_at' => now()]);
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Services\Notification;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Models\Notification;
use Illuminate\Database\Eloquent\Model;
class NotificationService
{
/**
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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);
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace Database\Factories;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Models\Notification;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Notification>
*/
class NotificationFactory extends Factory
{
protected $model = Notification::class;
/**
* @return array<string, mixed>
*/
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]);
}
}

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->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');
}
};

View file

@ -66,6 +66,9 @@ class="p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100"
<nav class="mt-5 flex-1 px-2 bg-white">
@include('layouts.navigation-items')
</nav>
<div class="flex-shrink-0 px-4 py-3 border-t border-gray-200">
<livewire:notification-bell />
</div>
<div class="flex-shrink-0 p-4 border-t border-gray-200">
<div class="flex items-center">
<div class="flex-1 min-w-0">
@ -98,6 +101,7 @@ class="px-4 border-r border-gray-200 text-gray-500 focus:outline-none focus:ring
</button>
<div class="flex-1 px-4 flex justify-between items-center">
<h1 class="text-lg font-medium text-gray-900">FFR</h1>
<livewire:notification-bell />
</div>
</div>

View file

@ -0,0 +1,90 @@
<div class="relative" x-data="{ open: false }">
<button
@click="open = !open"
class="relative p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Notifications"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
</svg>
@if ($this->unreadCount > 0)
<span class="absolute -top-1 -right-1 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold leading-none text-white bg-red-500 rounded-full">
{{ $this->unreadCount > 99 ? '99+' : $this->unreadCount }}
</span>
@endif
</button>
<div
x-show="open"
@click.away="open = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 z-50 mt-2 w-80 bg-white rounded-lg shadow-lg ring-1 ring-black ring-opacity-5"
style="display: none;"
>
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<h3 class="text-sm font-semibold text-gray-900">Notifications</h3>
@if ($this->unreadCount > 0)
<button
wire:click="markAllAsRead"
class="text-xs text-blue-600 hover:text-blue-800"
>
Mark all as read
</button>
@endif
</div>
<div class="max-h-96 overflow-y-auto">
@forelse ($this->notifications as $notification)
<div
class="px-4 py-3 border-b border-gray-50 last:border-b-0 {{ $notification->isRead() ? 'bg-white' : 'bg-blue-50' }}"
wire:key="notification-{{ $notification->id }}"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-0.5">
@if ($notification->severity === \App\Enums\NotificationSeverityEnum::ERROR)
<svg class="h-5 w-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
</svg>
@elseif ($notification->severity === \App\Enums\NotificationSeverityEnum::WARNING)
<svg class="h-5 w-5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
@else
<svg class="h-5 w-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
</svg>
@endif
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900">{{ $notification->title }}</p>
<p class="text-sm text-gray-500 mt-0.5">{{ $notification->message }}</p>
<div class="flex items-center justify-between mt-1">
<span class="text-xs text-gray-400">{{ $notification->created_at->diffForHumans() }}</span>
@unless ($notification->isRead())
<button
wire:click="markAsRead({{ $notification->id }})"
class="text-xs text-blue-600 hover:text-blue-800"
>
Mark read
</button>
@endunless
</div>
</div>
</div>
</div>
@empty
<div class="px-4 py-8 text-center">
<svg class="mx-auto h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
</svg>
<p class="mt-2 text-sm text-gray-500">No notifications</p>
</div>
@endforelse
</div>
</div>
</div>

View file

@ -0,0 +1,85 @@
<?php
namespace Tests\Feature\Livewire;
use App\Livewire\NotificationBell;
use App\Models\Notification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class NotificationBellTest extends TestCase
{
use RefreshDatabase;
public function test_it_renders_successfully(): void
{
Livewire::test(NotificationBell::class)
->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+');
}
}

View file

@ -0,0 +1,166 @@
<?php
namespace Tests\Feature;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Models\Feed;
use App\Models\Notification;
use App\Services\Notification\NotificationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class NotificationTest extends TestCase
{
use RefreshDatabase;
private NotificationService $service;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}