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,44 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace GerbilManager.Tests;
/// <summary>
/// WEB-PUSH: VAPID-Public-Key abrufen + Abo speichern/entfernen.
/// (Das tatsächliche Versenden wird nicht getestet — es geht an externe Push-Dienste.)
/// </summary>
public class PushEndpointTests : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory;
public PushEndpointTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Vapid_public_key_is_exposed()
{
var client = _factory.CreateClient();
var doc = JsonDocument.Parse(await client.GetStringAsync("/push/vapid-public-key")).RootElement;
Assert.True(doc.GetProperty("enabled").GetBoolean());
Assert.False(string.IsNullOrWhiteSpace(doc.GetProperty("publicKey").GetString()));
}
[Fact]
public async Task Subscribe_is_idempotent_and_unsubscribe_works()
{
var client = _factory.CreateClient();
var endpoint = $"https://push.example.com/{Guid.NewGuid()}";
var body = new { endpoint, p256dh = "abc123", auth = "def456" };
var first = await client.PostAsJsonAsync("/push/subscribe", body);
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
// Erneutes Abo mit derselben Endpoint-URL -> weiterhin OK (Upsert, kein Duplikat).
var second = await client.PostAsJsonAsync("/push/subscribe", body);
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
var bad = await client.PostAsJsonAsync("/push/subscribe", new { endpoint = "", p256dh = "", auth = "" });
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
var unsub = await client.PostAsJsonAsync("/push/unsubscribe", new { endpoint });
Assert.Equal(HttpStatusCode.NoContent, unsub.StatusCode);
}
}

View File

@@ -26,6 +26,7 @@ public class ApplicationContext : DbContext
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
public DbSet<Feedback> Feedback => Set<Feedback>();
public DbSet<FeedbackAttachment> FeedbackAttachments => Set<FeedbackAttachment>();
public DbSet<WebPushSubscription> WebPushSubscriptions => Set<WebPushSubscription>();
public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
public DbSet<WaitingListEntry> WaitingListEntries => Set<WaitingListEntry>();

View File

@@ -83,5 +83,11 @@ namespace GerbilManagerWebAPI.Dtos
/// <summary>Set/clear the ticket category. Empty/whitespace clears it.</summary>
string? Category = null,
/// <summary>👍/👎 on a resolved ticket. null leaves it unchanged.</summary>
bool? Helpful = null);
bool? Helpful = null,
/// <summary>
/// Wenn true, wird nach dem Update eine Push-Benachrichtigung an die Züchterin gesendet.
/// Nur die KI/der Betreuer setzt das (z. B. neue Rückfrage / Ticket gelöst); das Frontend
/// setzt es NIE — so löst die Züchterin mit eigenen Aktionen keine Selbst-Pushes aus.
/// </summary>
bool? Notify = null);
}

View File

@@ -91,7 +91,7 @@ namespace GerbilManagerWebAPI.Endpoints
});
group.MapPut("/{id:guid}", async Task<Results<Ok<FeedbackDto>, NotFound, BadRequest<string>>> (
Guid id, FeedbackUpdate input, ApplicationContext db) =>
Guid id, FeedbackUpdate input, ApplicationContext db, Push.PushNotifier push) =>
{
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
if (entity is null)
@@ -205,6 +205,28 @@ namespace GerbilManagerWebAPI.Endpoints
entity.Helpful = input.Helpful;
await db.SaveChangesAsync();
// Push an die Züchterin, wenn die KI das anfordert (notify=true). Nachricht aus dem
// resultierenden Status ableiten. Fehler dürfen die Antwort nicht stören.
if (input.Notify == true && push.Enabled)
{
var name = string.IsNullOrWhiteSpace(entity.EntityName) ? "" : $" ({entity.EntityName})";
var (title, body) = entity.Status switch
{
"NeedsInfo" => ("Neue Rückfrage" + name, Trim(entity.Question) ?? "Bitte schau in deine Tickets."),
"Resolved" => ("Ticket gelöst" + name, Trim(entity.FixNote) ?? Trim(entity.Message) ?? "Erledigt."),
_ => ("Neues zu deinem Ticket" + name, Trim(entity.Message) ?? ""),
};
try
{
await push.NotifyAllAsync(title, body, $"/hilfe/tickets?focus={entity.Id}");
}
catch (Exception)
{
// Push ist best-effort — niemals die API-Antwort daran scheitern lassen.
}
}
return TypedResults.Ok(ToDto(entity));
});
@@ -309,6 +331,14 @@ namespace GerbilManagerWebAPI.Endpoints
private static readonly JsonSerializerOptions ThreadJson = new(JsonSerializerDefaults.Web);
/// <summary>Für Push-Texte: leeren Wert zu null, sonst auf ~140 Zeichen kürzen.</summary>
private static string? Trim(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return null;
var t = s.Trim();
return t.Length > 140 ? t[..139] + "…" : t;
}
/// <summary>
/// Append the ticket's current (Question, Answer) exchange to the thread/history JSON
/// before it gets overwritten by a new round, then clear the current Answer. Only the

View File

@@ -0,0 +1,74 @@
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Push;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// WEB-PUSH: PWA-Benachrichtigungen für die Züchterin.
/// GET /push/vapid-public-key -> öffentlicher VAPID-Schlüssel (für das Abonnieren im Browser).
/// POST /push/subscribe -> Browser-Abo speichern (idempotent über die Endpoint-URL).
/// POST /push/unsubscribe -> Abo entfernen.
/// Das eigentliche Senden passiert über <see cref="PushNotifier"/> (z. B. wenn die KI eine
/// Rückfrage stellt oder ein Ticket löst — gesteuert über das notify-Flag auf PUT /feedback).
/// </summary>
public static class PushEndpoints
{
public record PushSubscriptionInput(string Endpoint, string P256dh, string Auth);
public record PushUnsubscribeInput(string Endpoint);
public static IEndpointRouteBuilder MapPushEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/push").WithTags("Push");
group.MapGet("/vapid-public-key", (PushNotifier push) =>
TypedResults.Ok(new { publicKey = push.PublicKey, enabled = push.Enabled }));
group.MapPost("/subscribe", async Task<Results<Ok, BadRequest<string>>> (
PushSubscriptionInput input, ApplicationContext db) =>
{
if (string.IsNullOrWhiteSpace(input.Endpoint)
|| string.IsNullOrWhiteSpace(input.P256dh)
|| string.IsNullOrWhiteSpace(input.Auth))
return TypedResults.BadRequest("Endpoint, P256dh und Auth sind erforderlich.");
var existing = await db.WebPushSubscriptions
.FirstOrDefaultAsync(s => s.Endpoint == input.Endpoint);
if (existing is null)
{
db.WebPushSubscriptions.Add(new WebPushSubscription
{
Id = Guid.NewGuid(),
Endpoint = input.Endpoint,
P256dh = input.P256dh,
Auth = input.Auth,
CreatedAt = DateTimeOffset.UtcNow,
});
}
else
{
existing.P256dh = input.P256dh;
existing.Auth = input.Auth;
}
await db.SaveChangesAsync();
return TypedResults.Ok();
});
group.MapPost("/unsubscribe", async (PushUnsubscribeInput input, ApplicationContext db) =>
{
var subs = await db.WebPushSubscriptions
.Where(s => s.Endpoint == input.Endpoint)
.ToListAsync();
if (subs.Count > 0)
{
db.WebPushSubscriptions.RemoveRange(subs);
await db.SaveChangesAsync();
}
return TypedResults.NoContent();
});
return app;
}
}
}

View File

@@ -24,6 +24,7 @@
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
<PackageReference Include="QuestPDF" Version="2026.6.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
<PackageReference Include="WebPush" Version="1.0.13" />
</ItemGroup>
<ItemGroup>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class WebPushSubscriptions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "WebPushSubscriptions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Endpoint = table.Column<string>(type: "text", nullable: false),
P256dh = table.Column<string>(type: "text", nullable: false),
Auth = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_WebPushSubscriptions", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "WebPushSubscriptions");
}
}
}

View File

@@ -1646,6 +1646,32 @@ namespace GerbilManagerWebAPI.Migrations
b.ToTable("WaitingListEntries");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.WebPushSubscription", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Auth")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Endpoint")
.IsRequired()
.HasColumnType("text");
b.Property<string>("P256dh")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("WebPushSubscriptions");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
{
b.Property<Guid>("Id")

View File

@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>
/// A browser's Web-Push subscription (PWA-Benachrichtigungen). Single-user app, daher i. d. R.
/// nur wenige Einträge (ein Gerät der Züchterin). Endpoint ist eindeutig; veraltete Abos werden
/// beim Senden (404/410) automatisch entfernt.
/// </summary>
public class WebPushSubscription
{
[Key]
public Guid Id { get; set; }
/// <summary>Die vom Browser vergebene Push-Endpoint-URL (eindeutig).</summary>
public required string Endpoint { get; set; }
/// <summary>Öffentlicher Client-Schlüssel (keys.p256dh).</summary>
public required string P256dh { get; set; }
/// <summary>Auth-Secret des Clients (keys.auth).</summary>
public required string Auth { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
}

View File

@@ -71,6 +71,7 @@ builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilMan
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailSender, GerbilManagerWebAPI.Inbox.GmailMailSender>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.SendReplyService>();
builder.Services.AddSingleton<GerbilManagerWebAPI.Push.PushNotifier>();
var app = builder.Build();
@@ -127,6 +128,7 @@ app.MapCmsEndpoints();
app.MapRequestEndpoints();
app.MapNamesEndpoints();
app.MapFeedbackEndpoints();
app.MapPushEndpoints();
app.MapAcquisitionEndpoints();
app.MapSaleReservationEndpoints();
app.MapWaitingListEndpoints();

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

View File

@@ -5,5 +5,10 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"WebPush": {
"Subject": "mailto:zucht-kleine-chaoten@example.com",
"PublicKey": "BGRqOfadYjRZFJaFYjPq2cThD7MpMHJHiRPrv9ZGSHJAl5eeqeCCZHj4I4_h-RcrjCF2MXhN34dI_RcACz36qnk",
"PrivateKey": "_HJTA1qtsJ-azLAaRFyxJqmJCym2qBQaSbYFCLjVSAA"
}
}

View File

@@ -447,6 +447,13 @@ export async function installMockApi(page: Page): Promise<MockDb> {
}
return json(route, 405)
}
// WEB-PUSH: im Mock deaktiviert (keine VAPID-Schlüssel) → PushToggle blendet sich aus.
if (path === '/push/vapid-public-key' && method === 'GET') {
return json(route, 200, { enabled: false, publicKey: null })
}
if ((path === '/push/subscribe' || path === '/push/unsubscribe') && method === 'POST') {
return json(route, 200, {})
}
// FEEDBACK-ANHÄNGE: hochladen (POST /feedback/{id}/attachments).
const attUpload = path.match(/^\/feedback\/([^/]+)\/attachments$/)
if (attUpload && method === 'POST') {

View File

@@ -0,0 +1,49 @@
/*
* Service Worker — NUR für Web-Push-Benachrichtigungen.
* BEWUSST OHNE fetch-Handler/Caching, damit das Laden der App niemals beeinflusst wird.
*/
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()))
self.addEventListener('push', (event) => {
let data = {}
try {
data = event.data ? event.data.json() : {}
} catch {
data = {}
}
const title = data.title || 'Zucht der kleinen Chaoten'
const body = data.body || ''
const url = data.url || '/hilfe/tickets'
event.waitUntil(
self.registration.showNotification(title, {
body,
icon: '/icon-192.png',
badge: '/icon-192.png',
data: { url },
}),
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const url = (event.notification.data && event.notification.data.url) || '/hilfe/tickets'
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if ('focus' in client) {
if ('navigate' in client) {
try {
client.navigate(url)
} catch {
/* ignorieren */
}
}
return client.focus()
}
}
if (self.clients.openWindow) return self.clients.openWindow(url)
return undefined
}),
)
})

View File

@@ -0,0 +1,64 @@
/**
* Schalter „Benachrichtigungen aktivieren" (Web-Push / PWA).
* Rendert nichts, wenn Push nicht unterstützt wird ODER der Server keine VAPID-Schlüssel hat
* (z. B. im e2e-Mock) — so bleibt die UI dort unverändert.
*/
import { useEffect, useState } from 'react'
import { de } from '../strings/de'
import { useToast } from './toast'
import { disablePush, enablePush, fetchPushConfig, isPushSupported, isSubscribed } from '../push'
export default function PushToggle() {
const t = de.feedback.tickets
const toast = useToast()
const [available, setAvailable] = useState(false)
const [subscribed, setSubscribed] = useState(false)
const [busy, setBusy] = useState(false)
useEffect(() => {
let cancelled = false
;(async () => {
if (!isPushSupported()) return
const cfg = await fetchPushConfig()
if (cancelled || !cfg.enabled) return
setAvailable(true)
setSubscribed(await isSubscribed())
})()
return () => {
cancelled = true
}
}, [])
if (!available) return null
async function toggle() {
setBusy(true)
try {
if (subscribed) {
await disablePush()
setSubscribed(false)
toast.success(t.pushDisabledToast)
} else {
const res = await enablePush()
if (res === 'enabled') {
setSubscribed(true)
toast.success(t.pushEnabledToast)
} else if (res === 'denied') {
toast.error(t.pushDenied)
} else {
toast.error(t.pushUnavailable)
}
}
} catch {
toast.error(t.pushUnavailable)
} finally {
setBusy(false)
}
}
return (
<button type="button" className="btn tickets-push-toggle" onClick={toggle} disabled={busy}>
{subscribed ? `🔕 ${t.pushDisable}` : `🔔 ${t.pushEnable}`}
</button>
)
}

View File

@@ -30,6 +30,7 @@ import { getGerbil } from '../api/gerbils'
import { REF_ROUTE, REF_TOKEN_RE, resolveRefs, type RefType } from '../api/refs'
import { useApi } from '../hooks/useApi'
import { useToast } from '../components/toast'
import PushToggle from '../components/PushToggle'
import './tickets.css'
/** Aufgelöster Kurz-Verweis (Shortlink): null = unbekannt/mehrdeutig. */
@@ -568,6 +569,7 @@ export default function TicketsPage() {
</nav>
<h2>{t.title}</h2>
<p className="tickets-subtitle">{t.subtitle}</p>
<PushToggle />
{tickets.error && <p className="tickets-error">{t.loadError}</p>}

View File

@@ -0,0 +1,87 @@
/**
* WEB-PUSH (PWA-Benachrichtigungen) — Client-Seite.
* Registriert den Service Worker (push-only) und verwaltet das Abo gegen /push/*.
*/
import { API_BASE_URL } from './api/client'
export function isPushSupported(): boolean {
return (
typeof navigator !== 'undefined' &&
'serviceWorker' in navigator &&
typeof window !== 'undefined' &&
'PushManager' in window &&
'Notification' in window
)
}
/** Server-Status: ist Push konfiguriert + welcher VAPID-Public-Key. */
export async function fetchPushConfig(): Promise<{ enabled: boolean; publicKey: string | null }> {
try {
const r = await fetch(`${API_BASE_URL}/push/vapid-public-key`)
if (!r.ok) return { enabled: false, publicKey: null }
return await r.json()
} catch {
return { enabled: false, publicKey: null }
}
}
function urlBase64ToUint8Array(base64: string): Uint8Array {
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(b64)
const arr = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i)
return arr
}
export async function isSubscribed(): Promise<boolean> {
if (!isPushSupported()) return false
const reg = await navigator.serviceWorker.getRegistration()
const sub = await reg?.pushManager.getSubscription()
return !!sub
}
export type EnableResult = 'enabled' | 'denied' | 'unavailable'
export async function enablePush(): Promise<EnableResult> {
if (!isPushSupported()) return 'unavailable'
const cfg = await fetchPushConfig()
if (!cfg.enabled || !cfg.publicKey) return 'unavailable'
const permission = await Notification.requestPermission()
if (permission !== 'granted') return 'denied'
const reg = await navigator.serviceWorker.register('/sw.js')
await navigator.serviceWorker.ready
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(cfg.publicKey),
})
const json = sub.toJSON()
await fetch(`${API_BASE_URL}/push/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: sub.endpoint,
p256dh: json.keys?.p256dh ?? '',
auth: json.keys?.auth ?? '',
}),
})
return 'enabled'
}
export async function disablePush(): Promise<void> {
if (!isPushSupported()) return
const reg = await navigator.serviceWorker.getRegistration()
const sub = await reg?.pushManager.getSubscription()
if (!sub) return
try {
await fetch(`${API_BASE_URL}/push/unsubscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: sub.endpoint }),
})
} catch {
/* trotzdem lokal abmelden */
}
await sub.unsubscribe()
}

View File

@@ -1300,6 +1300,13 @@ export const de = {
attachmentAdded: 'Anhang hinzugefügt.',
attachmentRemoved: 'Anhang entfernt.',
attachmentError: 'Anhang konnte nicht verarbeitet werden.',
/** Push-Benachrichtigungen (PWA). */
pushEnable: 'Benachrichtigungen aktivieren',
pushDisable: 'Benachrichtigungen aus',
pushEnabledToast: 'Benachrichtigungen aktiviert — du wirst informiert, sobald ich antworte.',
pushDisabledToast: 'Benachrichtigungen deaktiviert.',
pushDenied: 'Benachrichtigungen wurden im Browser blockiert. Bitte in den Browser-Einstellungen erlauben.',
pushUnavailable: 'Benachrichtigungen sind auf diesem Gerät nicht verfügbar.',
/** Changelog (fixNote) auf geschlossenen Tickets. */
changelogLabel: 'Was wurde geändert',
/** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */