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:
44
GerbilManager.Tests/PushEndpointTests.cs
Normal file
44
GerbilManager.Tests/PushEndpointTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
74
GerbilManagerWebAPI/Endpoints/PushEndpoints.cs
Normal file
74
GerbilManagerWebAPI/Endpoints/PushEndpoints.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
1858
GerbilManagerWebAPI/Migrations/20260623093055_WebPushSubscriptions.Designer.cs
generated
Normal file
1858
GerbilManagerWebAPI/Migrations/20260623093055_WebPushSubscriptions.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
26
GerbilManagerWebAPI/Models/WebPushSubscription.cs
Normal file
26
GerbilManagerWebAPI/Models/WebPushSubscription.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
49
gerbil-manager-web/public/sw.js
Normal file
49
gerbil-manager-web/public/sw.js
Normal 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
|
||||
}),
|
||||
)
|
||||
})
|
||||
64
gerbil-manager-web/src/components/PushToggle.tsx
Normal file
64
gerbil-manager-web/src/components/PushToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
|
||||
87
gerbil-manager-web/src/push.ts
Normal file
87
gerbil-manager-web/src/push.ts
Normal 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()
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user