- 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>
77 lines
3.1 KiB
C#
77 lines
3.1 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|