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

@@ -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"
}
}