feat(tickets): Web-Push-Benachrichtigungen (PWA)
- Die Züchterin kann auf der Tickets-Seite Push-Benachrichtigungen aktivieren (🔔-Schalter). Service Worker ist BEWUSST push-only (kein fetch/Caching), damit das Laden der App nie beeinträchtigt wird. - Backend: WebPush-Bibliothek + VAPID; PushNotifier sendet an alle Abos, räumt veraltete (404/410) auf. Endpoints: GET /push/vapid-public-key, POST /push/subscribe|unsubscribe. - Ausgelöst über ein notify-Flag auf PUT /feedback: NUR die KI setzt es (z. B. neue Rückfrage / Ticket gelöst) → keine Selbst-Pushes durch Aktionen der Züchterin. Text wird aus dem Status abgeleitet, Klick öffnet das Ticket (?focus=). - Schalter/Logik blenden sich aus, wenn das Gerät kein Push kann oder der Server keine VAPID-Schlüssel hat (z. B. e2e-Mock). Migration WebPushSubscriptions. VAPID-Schlüssel in appsettings.json (lokale Single-User-App im Heimnetz — bewusste, vertretbare Vereinfachung). Tests: 264 Backend grün (+Push-Endpoints), e2e Tickets Desktop grün, vitest 149. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
76
GerbilManagerWebAPI/Push/PushNotifier.cs
Normal file
76
GerbilManagerWebAPI/Push/PushNotifier.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WebPush;
|
||||
|
||||
namespace GerbilManagerWebAPI.Push
|
||||
{
|
||||
/// <summary>
|
||||
/// Versendet Web-Push-Benachrichtigungen an alle gespeicherten Abos (PWA der Züchterin).
|
||||
/// Ist kein VAPID-Schlüsselpaar konfiguriert, sind die Methoden No-Ops (Push deaktiviert).
|
||||
/// Veraltete Abos (404/410) werden beim Senden entfernt.
|
||||
/// </summary>
|
||||
public class PushNotifier
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<PushNotifier> _log;
|
||||
private readonly VapidDetails? _vapid;
|
||||
|
||||
public PushNotifier(IConfiguration config, IServiceScopeFactory scopeFactory, ILogger<PushNotifier> log)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_log = log;
|
||||
var subject = config["WebPush:Subject"];
|
||||
var publicKey = config["WebPush:PublicKey"];
|
||||
var privateKey = config["WebPush:PrivateKey"];
|
||||
if (!string.IsNullOrWhiteSpace(subject)
|
||||
&& !string.IsNullOrWhiteSpace(publicKey)
|
||||
&& !string.IsNullOrWhiteSpace(privateKey))
|
||||
{
|
||||
_vapid = new VapidDetails(subject, publicKey, privateKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>true, wenn Push konfiguriert ist (VAPID-Schlüssel vorhanden).</summary>
|
||||
public bool Enabled => _vapid is not null;
|
||||
|
||||
public string? PublicKey => _vapid?.PublicKey;
|
||||
|
||||
/// <summary>Eine Benachrichtigung an ALLE Abos senden. Fehler einzelner Abos werden geschluckt.</summary>
|
||||
public async Task NotifyAllAsync(string title, string body, string url, CancellationToken ct = default)
|
||||
{
|
||||
if (_vapid is null) return;
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||
var subs = await db.WebPushSubscriptions.ToListAsync(ct);
|
||||
if (subs.Count == 0) return;
|
||||
|
||||
var client = new WebPushClient();
|
||||
var payload = JsonSerializer.Serialize(new { title, body, url });
|
||||
var stale = new List<WebPushSubscription>();
|
||||
foreach (var s in subs)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pushSub = new WebPush.PushSubscription(s.Endpoint, s.P256dh, s.Auth);
|
||||
await client.SendNotificationAsync(pushSub, payload, _vapid);
|
||||
}
|
||||
catch (WebPushException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound
|
||||
or System.Net.HttpStatusCode.Gone)
|
||||
{
|
||||
stale.Add(s); // Abo abgelaufen/abgemeldet -> aufräumen
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "Push an {Endpoint} fehlgeschlagen", s.Endpoint);
|
||||
}
|
||||
}
|
||||
if (stale.Count > 0)
|
||||
{
|
||||
db.WebPushSubscriptions.RemoveRange(stale);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user