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:
2026-06-23 11:32:26 +02:00
parent 750619d3d7
commit f7a1a7724b
19 changed files with 2405 additions and 3 deletions

View 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);
}
}
}
}