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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user