Benachrichtigungen
Postbox verwendet ein Dual-System aus Toast-Benachrichtigungen (sofortige UI-Rueckmeldung) und einer Nachrichtenzentrale (persistente Announcements + Notifications). Alle Echtzeit-Events laufen ueber Laravel Reverb WebSockets.
WICHTIG: Kein session()->flash() verwenden
// RICHTIG: Toast-Dispatch
$this->dispatch('show-toast', ['message' => 'Gespeichert', 'type' => 'success']);
// FALSCH: Flash-Message (VERBOTEN!)
session()->flash('status', 'Gespeichert.'); // <-- Niemals verwenden!
Diese Regel gilt ausnahmslos fuer alle Livewire-Komponenten.
Entscheidungshilfe: Wann welcher Kanal?
| Situation | Kanal | Beispiel |
|---|---|---|
| Sofortige User-Aktion (Button-Klick) | Nur Toast | Speichern, Loeschen, Toggle |
| Bulk-Aktion abgeschlossen | Nur Toast | "6 Benachrichtigungen geloescht" |
| Hintergrund-Job fertig (User wartet) | Toast + Nachrichtenzentrale | Import fertig, Sync abgeschlossen |
| Hintergrund-Job fertig (User wartet nicht) | Nur Nachrichtenzentrale | Daily Scrape, Score-Berechnung |
| System-Event fuer alle User | Nur Nachrichtenzentrale | Feature-Ankuendigung, Wartung |
| Admin-Alert | Nur Nachrichtenzentrale | Server-Alarm, API-Fehler |
Toast-Typen
| Typ | Farbe | Verwendung |
|---|---|---|
success | Gruen | Erfolgreiche Aktionen |
error | Rot | Fehlgeschlagene Aktionen |
info | Blau | Informative Hinweise |
warning | Gelb | Warnungen |
$this->dispatch('show-toast', ['message' => 'Gespeichert', 'type' => 'success']);
$this->dispatch('show-toast', ['message' => 'Fehler aufgetreten', 'type' => 'error']);
$this->dispatch('show-toast', ['message' => 'Wird verarbeitet...', 'type' => 'info']);
$this->dispatch('show-toast', ['message' => 'Limit erreicht', 'type' => 'warning']);
NotificationService
Zentraler Service fuer alle Announcement- und Notification-Operationen.
announceToUser(User $user, string $type, array $data, ?int $expiryDays = null)
Erstellt ein Announcement fuer einen einzelnen User. Prueft automatisch:
- App-Kanal: Ob der User In-App-Benachrichtigungen fuer diesen Typ aktiviert hat
- Email-Kanal: Ob der User Email-Benachrichtigungen fuer diesen Typ aktiviert hat
app(NotificationService::class)->announceToUser($user, 'import_completed', [
'title' => 'Import abgeschlossen',
'body' => '42 Profile importiert',
'icon' => 'check-circle',
'url' => '/watchers',
]);
announceToWorkspace(Workspace $workspace, string $type, array $data)
Erstellt ein Announcement fuer alle Mitglieder eines Workspaces.
announceToAll(string $type, array $data, bool $adminOnly = false)
Erstellt ein Announcement fuer alle User (oder nur Admins).
app(NotificationService::class)->announceToAll('system_alert', $data, adminOnly: true);
Wichtig: announceToAll() erstellt ein geteiltes Announcement (1 Zeile in announcements). Die Per-User-Praeferenzpruefung findet beim Lesen auf SQL-Level statt — getForUser() und calculateUnreadCount() ermitteln deaktivierte Notification-Types per getDisabledAppTypes() und schliessen diese via ->whereNotIn('type', $disabledTypes) direkt in der SQL-Query aus. Zusaetzlich werden dismissed Announcements via notDismissedBy()-Scope ausgeblendet. Diese SQL-Level-Filterung verhindert OOM bei grossen Announcement-Mengen (vorher wurde ->get()->filter() im Speicher gemacht).
Weitere Methoden
| Methode | Beschreibung |
|---|---|
getForUser() | Alle Notifications + Announcements (merged, sortiert, SQL-Preference-Filter) |
getUnreadCount() | Ungelesene Anzahl (5 Min Cache) |
calculateUnreadCount() | Tatsaechliche Berechnung (SQL-Level, keine In-Memory-Filterung) |
markAsRead() | Notification oder Announcement als gelesen markieren |
markAllAsRead() | Alle als gelesen markieren |
delete() | Notification loeschen / Announcement dismissen |
Location: app/Services/Notifications/NotificationService.php
Announcements vs. Notifications
| Aspekt | Announcements | Notifications |
|---|---|---|
| Tabelle | announcements | notifications (Laravel) |
| Scope | Geteilt (1 Zeile pro Event) | Per-User |
| Read-Tracking | announcement_reads Tabelle | read_at Spalte |
| Dismiss | dismissed_at in announcement_reads | Physisches Loeschen |
| Audience | user, workspace, all, admin | notifiable_type + notifiable_id |
Standard-Ablauf-Zeiten
Announcements verfallen automatisch nach einer typ-spezifischen Frist:
| Typ | Ablauf |
|---|---|
import_completed | 7 Tage |
daily_scrape | 3 Tage |
sync_completed | 7 Tage |
profile_sanitized | 14 Tage |
profile_unsanitized | 14 Tage |
system_alert | 30 Tage |
account_warning | 90 Tage |
default | 14 Tage |
Quiet Hours
User koennen Ruhezeiten konfigurieren, in denen keine Email-Benachrichtigungen gesendet werden.
UserQuietHours Model
| Feld | Beschreibung |
|---|---|
user_id | Referenz zum User |
enabled | Quiet Hours aktiviert/deaktiviert |
window_start | Start-Uhrzeit (z.B. "22:00") |
window_end | End-Uhrzeit (z.B. "08:00") |
weekdays | Array der aktiven Wochentage |
Waehrend Quiet Hours werden Email-Jobs verzoegert:
if ($prefService->isInQuietHours($user)) {
$nextActive = $prefService->nextActiveTime($user);
SendNotificationEmail::dispatch($user, $type, $data)
->delay($nextActive)
->onQueue('emails');
}
Location: app/Livewire/Settings/Notifications.php
NotificationPreference
User koennen pro Benachrichtigungstyp die Kanaele (App / Email) individuell steuern:
$prefService = app(NotificationPreferenceService::class);
$shouldApp = $prefService->shouldSendApp($user, $type, $workspaceId);
$shouldEmail = $prefService->shouldSendEmail($user, $type, $workspaceId);
Die Einstellungen werden ueber die Settings-Seite verwaltet.
Location: app/Services/Notifications/NotificationPreferenceService.php, app/Livewire/Settings/Notifications.php
WebSocket-Integration
Echtzeit-Benachrichtigungen laufen ueber Laravel Reverb:
| Komponente | Beschreibung |
|---|---|
NotificationBell | Glocken-Icon im Header, zeigt Unread-Count, reagiert auf Reverb-Events |
NotificationCenter | Slide-Over Panel mit Nachrichten-Liste |
Event-Handling
// NotificationBell reagiert auf diverse Reverb-Events
// und dispatcht 'notification-received' an den NotificationCenter
// NotificationCenter hat Rate-Limited Server-Dispatches
// safeServerDispatch() mit max 40 Calls/Burst
Location: app/Livewire/NotificationBell.php, app/Livewire/NotificationCenter.php
Cleanup
Abgelaufene Benachrichtigungen werden taeglich bereinigt:
# Taeglich 03:00 UTC
php artisan notifications:cleanup
Der Command loescht:
- Announcements mit ueberschrittenem
expires_at - Zugehoerige
announcement_reads-Eintraege - Alte Laravel-Notifications
Location: app/Console/Commands/CleanupNotifications.php
UI-Komponenten
| Komponente | Route | Beschreibung |
|---|---|---|
NotificationBell | (Sidebar/Header) | Unread-Count Badge, Flyout-Dropdown, Reverb-Listener |
NotificationCenter | (Slide-Over) | Nachrichten-Liste, Read/Dismiss Aktionen |
Notifications\Index | /notifications | Vollseiten-Benachrichtigungsliste |
Settings\Notifications | /settings/notifications | Per-Typ Preferences, Quiet Hours |
NotificationBell: Flyout-Logik
Das Flyout-Dropdown zeigt bis zu 7 Benachrichtigungen. Ungelesene werden priorisiert:
getForUser($user, 35)holt mehr Items als angezeigt (limit × 5)- Ungelesene Notifications/Announcements werden zuerst angezeigt
- Restliche Slots werden mit gelesenen Eintraegen aufgefuellt
- Gesamt auf
DROPDOWN_LIMIT = 7begrenzt
Dadurch stimmt die Badge-Zahl (Unread-Count) immer mit den sichtbaren ungelesenen Eintraegen im Flyout ueberein — auch wenn es viele neuere gelesene Notifications gibt.
NotificationBell: @persist und Lazy Loading
Die NotificationBell-Komponente ist in der Sidebar mit @persist('notification-bell-sidebar') eingebunden und ueberlebt wire:navigate-Seitennavigationen. Ohne besondere Vorkehrungen wuerde der initial gerenderte DOM (oft leer) im persisted DOM erhalten bleiben, waehrend der Badge-Count via WebSocket aktualisiert wird — was zu einem leeren Flyout trotz korrektem Badge fuehrt.
Loesung (3 Massnahmen):
-
$loaded-Flag: Notifications werden erst aus der DB geladen wennopenDropdown()aufgerufen wird. Auf dem initialen Render und nach jedemrefreshUnreadCount()(WebSocket-Event) wird$loaded=falsegesetzt. So enthaelt der @persist-DOM keinen veralteten Notification-Content. -
Alpine Loading-State: Beim Klick auf die Glocke wird
loading=truesynchron gesetzt und ein Spinner gezeigt. Erst nach$wire.openDropdown().then()(Server-Response) wirdloading=falseund der frische Content sichtbar. Kein Flash von stale Content moeglich. -
refreshKey-Mechanismus: Bei jedemopenDropdown()wirdrefreshKeyinkrementiert. Daswire:key="notification-list-{{ $refreshKey }}"zwingt Livewire zum vollstaendigen DOM-Replacement statt Morph (Morph kann beix-show-Containern fehlschlagen).
Location: app/Livewire/NotificationBell.php, app/Livewire/NotificationCenter.php, app/Livewire/Notifications/Index.php, app/Livewire/Settings/Notifications.php