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<MailSettings> MailSettings => Set<MailSettings>();
|
||||||
public DbSet<Feedback> Feedback => Set<Feedback>();
|
public DbSet<Feedback> Feedback => Set<Feedback>();
|
||||||
public DbSet<FeedbackAttachment> FeedbackAttachments => Set<FeedbackAttachment>();
|
public DbSet<FeedbackAttachment> FeedbackAttachments => Set<FeedbackAttachment>();
|
||||||
|
public DbSet<WebPushSubscription> WebPushSubscriptions => Set<WebPushSubscription>();
|
||||||
public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
|
public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
|
||||||
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
|
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
|
||||||
public DbSet<WaitingListEntry> WaitingListEntries => Set<WaitingListEntry>();
|
public DbSet<WaitingListEntry> WaitingListEntries => Set<WaitingListEntry>();
|
||||||
|
|||||||
@@ -83,5 +83,11 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
/// <summary>Set/clear the ticket category. Empty/whitespace clears it.</summary>
|
/// <summary>Set/clear the ticket category. Empty/whitespace clears it.</summary>
|
||||||
string? Category = null,
|
string? Category = null,
|
||||||
/// <summary>👍/👎 on a resolved ticket. null leaves it unchanged.</summary>
|
/// <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>>> (
|
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);
|
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||||
if (entity is null)
|
if (entity is null)
|
||||||
@@ -205,6 +205,28 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
entity.Helpful = input.Helpful;
|
entity.Helpful = input.Helpful;
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
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));
|
return TypedResults.Ok(ToDto(entity));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,6 +331,14 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
|
|
||||||
private static readonly JsonSerializerOptions ThreadJson = new(JsonSerializerDefaults.Web);
|
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>
|
/// <summary>
|
||||||
/// Append the ticket's current (Question, Answer) exchange to the thread/history JSON
|
/// 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
|
/// 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="Gridify.EntityFramework" Version="2.19.1" />
|
||||||
<PackageReference Include="QuestPDF" Version="2026.6.0" />
|
<PackageReference Include="QuestPDF" Version="2026.6.0" />
|
||||||
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
||||||
|
<PackageReference Include="WebPush" Version="1.0.13" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<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");
|
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 =>
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
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.RequestSyncService>();
|
||||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailSender, GerbilManagerWebAPI.Inbox.GmailMailSender>();
|
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailSender, GerbilManagerWebAPI.Inbox.GmailMailSender>();
|
||||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.SendReplyService>();
|
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.SendReplyService>();
|
||||||
|
builder.Services.AddSingleton<GerbilManagerWebAPI.Push.PushNotifier>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -127,6 +128,7 @@ app.MapCmsEndpoints();
|
|||||||
app.MapRequestEndpoints();
|
app.MapRequestEndpoints();
|
||||||
app.MapNamesEndpoints();
|
app.MapNamesEndpoints();
|
||||||
app.MapFeedbackEndpoints();
|
app.MapFeedbackEndpoints();
|
||||||
|
app.MapPushEndpoints();
|
||||||
app.MapAcquisitionEndpoints();
|
app.MapAcquisitionEndpoints();
|
||||||
app.MapSaleReservationEndpoints();
|
app.MapSaleReservationEndpoints();
|
||||||
app.MapWaitingListEndpoints();
|
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"
|
"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)
|
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).
|
// FEEDBACK-ANHÄNGE: hochladen (POST /feedback/{id}/attachments).
|
||||||
const attUpload = path.match(/^\/feedback\/([^/]+)\/attachments$/)
|
const attUpload = path.match(/^\/feedback\/([^/]+)\/attachments$/)
|
||||||
if (attUpload && method === 'POST') {
|
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 { REF_ROUTE, REF_TOKEN_RE, resolveRefs, type RefType } from '../api/refs'
|
||||||
import { useApi } from '../hooks/useApi'
|
import { useApi } from '../hooks/useApi'
|
||||||
import { useToast } from '../components/toast'
|
import { useToast } from '../components/toast'
|
||||||
|
import PushToggle from '../components/PushToggle'
|
||||||
import './tickets.css'
|
import './tickets.css'
|
||||||
|
|
||||||
/** Aufgelöster Kurz-Verweis (Shortlink): null = unbekannt/mehrdeutig. */
|
/** Aufgelöster Kurz-Verweis (Shortlink): null = unbekannt/mehrdeutig. */
|
||||||
@@ -568,6 +569,7 @@ export default function TicketsPage() {
|
|||||||
</nav>
|
</nav>
|
||||||
<h2>{t.title}</h2>
|
<h2>{t.title}</h2>
|
||||||
<p className="tickets-subtitle">{t.subtitle}</p>
|
<p className="tickets-subtitle">{t.subtitle}</p>
|
||||||
|
<PushToggle />
|
||||||
|
|
||||||
{tickets.error && <p className="tickets-error">{t.loadError}</p>}
|
{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.',
|
attachmentAdded: 'Anhang hinzugefügt.',
|
||||||
attachmentRemoved: 'Anhang entfernt.',
|
attachmentRemoved: 'Anhang entfernt.',
|
||||||
attachmentError: 'Anhang konnte nicht verarbeitet werden.',
|
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. */
|
/** Changelog (fixNote) auf geschlossenen Tickets. */
|
||||||
changelogLabel: 'Was wurde geändert',
|
changelogLabel: 'Was wurde geändert',
|
||||||
/** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */
|
/** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */
|
||||||
|
|||||||
Reference in New Issue
Block a user