Zum Hauptinhalt springen

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?

SituationKanalBeispiel
Sofortige User-Aktion (Button-Klick)Nur ToastSpeichern, Loeschen, Toggle
Bulk-Aktion abgeschlossenNur Toast"6 Benachrichtigungen geloescht"
Hintergrund-Job fertig (User wartet)Toast + NachrichtenzentraleImport fertig, Sync abgeschlossen
Hintergrund-Job fertig (User wartet nicht)Nur NachrichtenzentraleDaily Scrape, Score-Berechnung
System-Event fuer alle UserNur NachrichtenzentraleFeature-Ankuendigung, Wartung
Admin-AlertNur NachrichtenzentraleServer-Alarm, API-Fehler

Toast-Typen

TypFarbeVerwendung
successGruenErfolgreiche Aktionen
errorRotFehlgeschlagene Aktionen
infoBlauInformative Hinweise
warningGelbWarnungen
$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

MethodeBeschreibung
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

AspektAnnouncementsNotifications
Tabelleannouncementsnotifications (Laravel)
ScopeGeteilt (1 Zeile pro Event)Per-User
Read-Trackingannouncement_reads Tabelleread_at Spalte
Dismissdismissed_at in announcement_readsPhysisches Loeschen
Audienceuser, workspace, all, adminnotifiable_type + notifiable_id

Standard-Ablauf-Zeiten

Announcements verfallen automatisch nach einer typ-spezifischen Frist:

TypAblauf
import_completed7 Tage
daily_scrape3 Tage
sync_completed7 Tage
profile_sanitized14 Tage
profile_unsanitized14 Tage
system_alert30 Tage
account_warning90 Tage
default14 Tage

Quiet Hours

User koennen Ruhezeiten konfigurieren, in denen keine Email-Benachrichtigungen gesendet werden.

UserQuietHours Model

FeldBeschreibung
user_idReferenz zum User
enabledQuiet Hours aktiviert/deaktiviert
window_startStart-Uhrzeit (z.B. "22:00")
window_endEnd-Uhrzeit (z.B. "08:00")
weekdaysArray 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:

KomponenteBeschreibung
NotificationBellGlocken-Icon im Header, zeigt Unread-Count, reagiert auf Reverb-Events
NotificationCenterSlide-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

KomponenteRouteBeschreibung
NotificationBell(Sidebar/Header)Unread-Count Badge, Flyout-Dropdown, Reverb-Listener
NotificationCenter(Slide-Over)Nachrichten-Liste, Read/Dismiss Aktionen
Notifications\Index/notificationsVollseiten-Benachrichtigungsliste
Settings\Notifications/settings/notificationsPer-Typ Preferences, Quiet Hours

NotificationBell: Flyout-Logik

Das Flyout-Dropdown zeigt bis zu 7 Benachrichtigungen. Ungelesene werden priorisiert:

  1. getForUser($user, 35) holt mehr Items als angezeigt (limit × 5)
  2. Ungelesene Notifications/Announcements werden zuerst angezeigt
  3. Restliche Slots werden mit gelesenen Eintraegen aufgefuellt
  4. Gesamt auf DROPDOWN_LIMIT = 7 begrenzt

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):

  1. $loaded-Flag: Notifications werden erst aus der DB geladen wenn openDropdown() aufgerufen wird. Auf dem initialen Render und nach jedem refreshUnreadCount() (WebSocket-Event) wird $loaded=false gesetzt. So enthaelt der @persist-DOM keinen veralteten Notification-Content.

  2. Alpine Loading-State: Beim Klick auf die Glocke wird loading=true synchron gesetzt und ein Spinner gezeigt. Erst nach $wire.openDropdown().then() (Server-Response) wird loading=false und der frische Content sichtbar. Kein Flash von stale Content moeglich.

  3. refreshKey-Mechanismus: Bei jedem openDropdown() wird refreshKey inkrementiert. Das wire:key="notification-list-{{ $refreshKey }}" zwingt Livewire zum vollstaendigen DOM-Replacement statt Morph (Morph kann bei x-show-Containern fehlschlagen).

Location: app/Livewire/NotificationBell.php, app/Livewire/NotificationCenter.php, app/Livewire/Notifications/Index.php, app/Livewire/Settings/Notifications.php