From f7a1a7724bb4d62cf2584dd51a5419109b52eee0 Mon Sep 17 00:00:00 2001 From: Gulum Date: Tue, 23 Jun 2026 11:32:26 +0200 Subject: [PATCH] feat(tickets): Web-Push-Benachrichtigungen (PWA) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- GerbilManager.Tests/PushEndpointTests.cs | 44 + GerbilManagerWebAPI/ApplicationContext.cs | 1 + GerbilManagerWebAPI/Dtos/FeedbackDtos.cs | 8 +- .../Endpoints/FeedbackEndpoints.cs | 32 +- .../Endpoints/PushEndpoints.cs | 74 + .../GerbilManagerWebAPI.csproj | 1 + ...623093055_WebPushSubscriptions.Designer.cs | 1858 +++++++++++++++++ .../20260623093055_WebPushSubscriptions.cs | 37 + .../ApplicationContextModelSnapshot.cs | 26 + .../Models/WebPushSubscription.cs | 26 + GerbilManagerWebAPI/Program.cs | 2 + GerbilManagerWebAPI/Push/PushNotifier.cs | 76 + GerbilManagerWebAPI/appsettings.json | 7 +- gerbil-manager-web/e2e/mock-api.ts | 7 + gerbil-manager-web/public/sw.js | 49 + .../src/components/PushToggle.tsx | 64 + gerbil-manager-web/src/pages/TicketsPage.tsx | 2 + gerbil-manager-web/src/push.ts | 87 + gerbil-manager-web/src/strings/de.ts | 7 + 19 files changed, 2405 insertions(+), 3 deletions(-) create mode 100644 GerbilManager.Tests/PushEndpointTests.cs create mode 100644 GerbilManagerWebAPI/Endpoints/PushEndpoints.cs create mode 100644 GerbilManagerWebAPI/Migrations/20260623093055_WebPushSubscriptions.Designer.cs create mode 100644 GerbilManagerWebAPI/Migrations/20260623093055_WebPushSubscriptions.cs create mode 100644 GerbilManagerWebAPI/Models/WebPushSubscription.cs create mode 100644 GerbilManagerWebAPI/Push/PushNotifier.cs create mode 100644 gerbil-manager-web/public/sw.js create mode 100644 gerbil-manager-web/src/components/PushToggle.tsx create mode 100644 gerbil-manager-web/src/push.ts diff --git a/GerbilManager.Tests/PushEndpointTests.cs b/GerbilManager.Tests/PushEndpointTests.cs new file mode 100644 index 0000000..b3aa678 --- /dev/null +++ b/GerbilManager.Tests/PushEndpointTests.cs @@ -0,0 +1,44 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace GerbilManager.Tests; + +/// +/// WEB-PUSH: VAPID-Public-Key abrufen + Abo speichern/entfernen. +/// (Das tatsächliche Versenden wird nicht getestet — es geht an externe Push-Dienste.) +/// +public class PushEndpointTests : IClassFixture +{ + 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); + } +} diff --git a/GerbilManagerWebAPI/ApplicationContext.cs b/GerbilManagerWebAPI/ApplicationContext.cs index 801bdb1..7b080a2 100644 --- a/GerbilManagerWebAPI/ApplicationContext.cs +++ b/GerbilManagerWebAPI/ApplicationContext.cs @@ -26,6 +26,7 @@ public class ApplicationContext : DbContext public DbSet MailSettings => Set(); public DbSet Feedback => Set(); public DbSet FeedbackAttachments => Set(); + public DbSet WebPushSubscriptions => Set(); public DbSet AcquisitionRecords => Set(); public DbSet SaleReservations => Set(); public DbSet WaitingListEntries => Set(); diff --git a/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs b/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs index f17bc22..a3b7afa 100644 --- a/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs +++ b/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs @@ -83,5 +83,11 @@ namespace GerbilManagerWebAPI.Dtos /// Set/clear the ticket category. Empty/whitespace clears it. string? Category = null, /// 👍/👎 on a resolved ticket. null leaves it unchanged. - bool? Helpful = null); + bool? Helpful = null, + /// + /// 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. + /// + bool? Notify = null); } diff --git a/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs b/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs index a732005..f075a93 100644 --- a/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs +++ b/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs @@ -91,7 +91,7 @@ namespace GerbilManagerWebAPI.Endpoints }); group.MapPut("/{id:guid}", async Task, NotFound, BadRequest>> ( - 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); + /// Für Push-Texte: leeren Wert zu null, sonst auf ~140 Zeichen kürzen. + private static string? Trim(string? s) + { + if (string.IsNullOrWhiteSpace(s)) return null; + var t = s.Trim(); + return t.Length > 140 ? t[..139] + "…" : t; + } + /// /// 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 diff --git a/GerbilManagerWebAPI/Endpoints/PushEndpoints.cs b/GerbilManagerWebAPI/Endpoints/PushEndpoints.cs new file mode 100644 index 0000000..8b2b1eb --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/PushEndpoints.cs @@ -0,0 +1,74 @@ +using GerbilManagerWebAPI.Models; +using GerbilManagerWebAPI.Push; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// 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 (z. B. wenn die KI eine + /// Rückfrage stellt oder ein Ticket löst — gesteuert über das notify-Flag auf PUT /feedback). + /// + 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>> ( + 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; + } + } +} diff --git a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj index d1f12a0..03de1b1 100644 --- a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj +++ b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj @@ -24,6 +24,7 @@ + diff --git a/GerbilManagerWebAPI/Migrations/20260623093055_WebPushSubscriptions.Designer.cs b/GerbilManagerWebAPI/Migrations/20260623093055_WebPushSubscriptions.Designer.cs new file mode 100644 index 0000000..fd92812 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260623093055_WebPushSubscriptions.Designer.cs @@ -0,0 +1,1858 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + [DbContext(typeof(ApplicationContext))] + [Migration("20260623093055_WebPushSubscriptions")] + partial class WebPushSubscriptions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.AcquisitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("SourceContactId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("AcquisitionRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Block", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("PageId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("PageId"); + + b.ToTable("Blocks"); + + b.HasData( + new + { + Id = new Guid("51720002-0000-0000-0000-000000000001"), + Data = "{\"text\":\"Startseite\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000001"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000002"), + Data = "{\"text\":\"Über die Zucht\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000002"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000003"), + Data = "{\"text\":\"Abgabetiere\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000003"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000004"), + Data = "{\"text\":\"Abgabebedingungen\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000004"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000005"), + Data = "{\"text\":\"Farben & Genetik\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000005"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000006"), + Data = "{\"text\":\"Kontakt\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000006"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000010"), + Data = "{\"mode\":\"auto\",\"intro\":\"\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000003"), + Type = "AbgabetiereList" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000007"), + Data = "{\"text\":\"Impressum\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000007"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000070"), + Data = "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000007"), + Type = "RichText" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000008"), + Data = "{\"text\":\"Datenschutz\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000008"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000080"), + Data = "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000008"), + Type = "RichText" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.BreederSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("City") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Homepage") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NameSuffix") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("ZuchtName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("BreederSettings"); + + b.HasData( + new + { + Id = new Guid("11111111-1111-1111-1111-000000000001"), + Address = "", + City = "", + Email = "", + Homepage = "", + Name = "", + NameSuffix = "", + Phone = "", + ZuchtName = "" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.ColorVariety", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CanonicalGenotype") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ColorVarieties"); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + CanonicalGenotype = "AA chch DD EE GG pp spsp rere", + Name = "REW", + SortOrder = 0 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000002"), + CanonicalGenotype = "aa chch DD EE GG PP spsp rere", + Name = "Hermelin", + SortOrder = 1 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000003"), + CanonicalGenotype = "AA chch DD EE GG PP spsp rere", + Name = "Himalaya", + SortOrder = 2 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000004"), + CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere", + Name = "Zobel", + SortOrder = 3 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000005"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Rotaugenschimmel", + SortOrder = 4 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000006"), + CanonicalGenotype = "AA CC DD EE GG PP spsp rere", + Name = "Agouti", + SortOrder = 5 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000007"), + CanonicalGenotype = "aa CC DD EE GG PP spsp rere", + Name = "Schwarz", + SortOrder = 6 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000008"), + CanonicalGenotype = "AA CC DD EE gg PP spsp rere", + Name = "Silberagouti", + SortOrder = 7 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000009"), + CanonicalGenotype = "aa CC DD EE gg PP spsp rere", + Name = "Anthrazit", + SortOrder = 8 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000010"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs", + SortOrder = 9 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000011"), + CanonicalGenotype = "aa CC dd EE GG PP spsp rere", + Name = "Blau", + SortOrder = 10 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000012"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold", + SortOrder = 11 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000013"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin", + SortOrder = 12 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000014"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs", + SortOrder = 13 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000015"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs", + SortOrder = 14 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000016"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Dilute Gold", + SortOrder = 15 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000017"), + CanonicalGenotype = "aa CC dd EE GG pp spsp rere", + Name = "Dilute Platin", + SortOrder = 16 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000018"), + CanonicalGenotype = "aa CC DD EE gg pp spsp rere", + Name = "Altweiss (REW)", + SortOrder = 17 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000019"), + CanonicalGenotype = "AA CC DD ee gg pp spsp rere", + Name = "Apricot (Blassfuchs)", + SortOrder = 18 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000020"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs", + SortOrder = 19 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000021"), + CanonicalGenotype = "aa CC DD ee gg pp spsp rere", + Name = "C-Separator", + SortOrder = 20 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000022"), + CanonicalGenotype = "AA CC DD EE gg pp spsp rere", + Name = "Elfenbein", + SortOrder = 21 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000023"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs", + SortOrder = 22 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000024"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs", + SortOrder = 23 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000025"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Saphir", + SortOrder = 24 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000026"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Orangeschimmel", + SortOrder = 25 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000027"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Topas", + SortOrder = 26 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000028"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin-Hell", + SortOrder = 27 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000029"), + CanonicalGenotype = "AA CC dd EE GG PP spsp rere", + Name = "Dilute Agouti", + SortOrder = 28 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000030"), + CanonicalGenotype = "AA CC dd EE gg PP spsp rere", + Name = "Dilute Silberagouti", + SortOrder = 29 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000031"), + CanonicalGenotype = "aa CC dd ee GG PP spsp rere", + Name = "Dilute Kohlfuchs", + SortOrder = 30 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000032"), + CanonicalGenotype = "aa CC dd EE gg PP spsp rere", + Name = "Dilute Anthrazit", + SortOrder = 31 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000033"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Silberschimmel", + SortOrder = 36 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000034"), + CanonicalGenotype = "AA CC DD efe gg PP spsp rere", + Name = "Polarfuchsschimmel", + SortOrder = 37 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000035"), + CanonicalGenotype = "AA CC DD efe GG PP spsp rere", + Name = "Algierfuchsschimmel", + SortOrder = 38 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000036"), + CanonicalGenotype = "aa CC DD efe GG PP spsp rere", + Name = "Kohlfuchsschimmel", + SortOrder = 39 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000037"), + CanonicalGenotype = "aa CC DD efe gg PP spsp rere", + Name = "Blaufuchsschimmel", + SortOrder = 40 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000038"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs, hell", + SortOrder = 41 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000039"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs, hell", + SortOrder = 42 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000040"), + CanonicalGenotype = "AA CC DD efe GG pp spsp rere", + Name = "Goldfuchsschimmel", + SortOrder = 43 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000041"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold-Hell", + SortOrder = 44 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000042"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs, hell", + SortOrder = 45 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000043"), + CanonicalGenotype = "aa CC DD efe GG pp spsp rere", + Name = "Rotfuchsschimmel", + SortOrder = 46 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000044"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs, hell", + SortOrder = 47 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000045"), + CanonicalGenotype = "aa CC DD efe GG PP spsp rere", + Name = "Kohlfuchsschimmel, hell", + SortOrder = 48 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000046"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs, hell", + SortOrder = 49 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000047"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs-Hell", + SortOrder = 50 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000048"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs, hell", + SortOrder = 51 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000049"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Dilute Topas", + SortOrder = 52 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000050"), + CanonicalGenotype = "aa CC dd ee gg PP spsp rere", + Name = "Dilute Blaufuchs", + SortOrder = 53 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000051"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Marder", + SortOrder = 54 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000052"), + CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere", + Name = "Siam", + SortOrder = 55 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000053"), + CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere", + Name = "Zobel-Hell", + SortOrder = 56 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000054"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "CP-Agouti", + SortOrder = 57 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000055"), + CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere", + Name = "CP-Silberagouti", + SortOrder = 59 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000056"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "CP-Algierfuchs", + SortOrder = 61 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000057"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "CP-Polarfuchs", + SortOrder = 63 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000058"), + CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere", + Name = "CP-Fuchs", + SortOrder = 65 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000059"), + CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere", + Name = "CP-Fuchs-Hell", + SortOrder = 66 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000060"), + CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere", + Name = "CP-Blaufuchs", + SortOrder = 67 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000061"), + CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere", + Name = "CP-Orangeschimmel", + SortOrder = 68 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000062"), + CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere", + Name = "CP-Agouti-Hell", + SortOrder = 58 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000063"), + CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere", + Name = "CP-Silberagouti-Hell", + SortOrder = 60 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000064"), + CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere", + Name = "CP-Algierfuchs-Hell", + SortOrder = 62 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000065"), + CanonicalGenotype = "AA cchmch DD ee gg PP spsp rere", + Name = "CP-Polarfuchs-Hell", + SortOrder = 64 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000066"), + CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere", + Name = "CP-Orangeschimmel-Hell", + SortOrder = 69 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000067"), + CanonicalGenotype = "AA CC dd ee GG PP spsp rere", + Name = "Dilute Algierfuchs", + SortOrder = 32 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000068"), + CanonicalGenotype = "AA CC dd ee GG pp spsp rere", + Name = "Dilute Goldfuchs", + SortOrder = 33 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000069"), + CanonicalGenotype = "aa CC dd ee GG pp spsp rere", + Name = "Dilute Rotfuchs", + SortOrder = 34 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000070"), + CanonicalGenotype = "AA CC dd ee gg PP spsp rere", + Name = "Dilute Polarfuchs", + SortOrder = 35 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("IsBreeder") + .HasColumnType("boolean"); + + b.Property("IsReceiver") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("NameSuffix") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Provenance") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Capacity") + .HasColumnType("integer"); + + b.Property("CleaningCycleDays") + .HasColumnType("integer"); + + b.Property("LastCleanedDate") + .HasColumnType("date"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Size") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Enclosures"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Caption") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnclosureId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EnclosureId"); + + b.ToTable("EnclosurePhotos"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.ExhibitionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Award") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityName") + .HasColumnType("text"); + + b.Property("EventName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Placement") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("ExhibitionResults"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Feedback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentContext") + .HasColumnType("text"); + + b.Property("Answer") + .HasColumnType("text"); + + b.Property("AnsweredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("ClientTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityName") + .HasColumnType("text"); + + b.Property("FixNote") + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Helpful") + .HasColumnType("boolean"); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Question") + .HasColumnType("text"); + + b.Property("ReopenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Thread") + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserAgent") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.FeedbackAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("FeedbackId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("FeedbackAttachments"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CauseOfDeath") + .HasColumnType("text"); + + b.Property("CharacterNote") + .HasColumnType("text"); + + b.Property("CharacterTraits") + .IsRequired() + .HasColumnType("text"); + + b.Property("ColorVarietyId") + .HasColumnType("uuid"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DateOfDeath") + .HasColumnType("date"); + + b.Property("EnclosureId") + .HasColumnType("uuid"); + + b.Property("ExternalRef") + .HasColumnType("text"); + + b.Property("Gender") + .IsRequired() + .HasColumnType("text"); + + b.Property("Genotype") + .HasColumnType("text"); + + b.Property("GoHomeDate") + .HasColumnType("date"); + + b.Property("ImportSource") + .HasColumnType("text"); + + b.Property("IsCastrated") + .HasColumnType("boolean"); + + b.Property("IsDeaf") + .HasColumnType("boolean"); + + b.Property("IsResident") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("NameSearch") + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OriginBreeder") + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("OriginContactId") + .HasColumnType("uuid"); + + b.Property("Provenance") + .HasColumnType("text"); + + b.Property("RawImportData") + .HasColumnType("text"); + + b.Property("ReceiverContactId") + .HasColumnType("uuid"); + + b.Property("SpottingType") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ColorVarietyId"); + + b.HasIndex("EnclosureId"); + + b.HasIndex("ExternalRef") + .IsUnique() + .HasFilter("\"ExternalRef\" IS NOT NULL"); + + b.HasIndex("LitterId"); + + b.HasIndex("OriginContactId"); + + b.HasIndex("ReceiverContactId"); + + b.ToTable("Gerbils"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Caption") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("GerbilPhotos"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("Veterinarian") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("HealthRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeathsWithin8Weeks") + .HasColumnType("integer"); + + b.Property("ExpectedGoHomeDate") + .HasColumnType("date"); + + b.Property("ExternalRef") + .HasColumnType("text"); + + b.Property("FatherId") + .HasColumnType("uuid"); + + b.Property("LitterLetter") + .HasColumnType("text"); + + b.Property("MotherId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PairingCode") + .HasColumnType("text"); + + b.Property("Provenance") + .HasColumnType("text"); + + b.Property("TotalBorn") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ExternalRef") + .IsUnique() + .HasFilter("\"ExternalRef\" IS NOT NULL"); + + b.HasIndex("FatherId"); + + b.HasIndex("MotherId"); + + b.ToTable("Litters"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.MailSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppPasswordProtected") + .HasColumnType("text"); + + b.Property("BackgroundPollEnabled") + .HasColumnType("boolean"); + + b.Property("Folder") + .IsRequired() + .HasColumnType("text"); + + b.Property("GmailAddress") + .HasColumnType("text"); + + b.Property("LastUid") + .HasColumnType("bigint"); + + b.Property("PollIntervalMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("MailSettings"); + + b.HasData( + new + { + Id = new Guid("ab0c0000-0000-0000-0000-000000000001"), + BackgroundPollEnabled = false, + Folder = "INBOX", + LastUid = 0L, + PollIntervalMinutes = 15 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Media", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Alt") + .HasColumnType("text"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Media"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Page", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("SeoDescription") + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Pages"); + + b.HasData( + new + { + Id = new Guid("51720001-0000-0000-0000-000000000001"), + Slug = "start", + Status = "Published", + Title = "Startseite" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000002"), + Slug = "ueber-die-zucht", + Status = "Published", + Title = "Über die Zucht" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000003"), + Slug = "abgabetiere", + Status = "Published", + Title = "Abgabetiere" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000004"), + Slug = "abgabebedingungen", + Status = "Published", + Title = "Abgabebedingungen" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000005"), + Slug = "farben-genetik", + Status = "Published", + Title = "Farben & Genetik" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000006"), + Slug = "kontakt", + Status = "Published", + Title = "Kontakt" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000007"), + Slug = "impressum", + Status = "Published", + Title = "Impressum" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000008"), + Slug = "datenschutz", + Status = "Published", + Title = "Datenschutz" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Request", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnsweredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedContactId") + .HasColumnType("uuid"); + + b.Property("BodyText") + .HasColumnType("text"); + + b.Property("DraftReply") + .HasColumnType("text"); + + b.Property("FromAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("FromName") + .HasColumnType("text"); + + b.Property("GmailMessageId") + .IsRequired() + .HasColumnType("text"); + + b.Property("InReplyToMessageId") + .HasColumnType("text"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferencesHeader") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .HasColumnType("text"); + + b.Property("ThreadId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AssignedContactId"); + + b.HasIndex("GmailMessageId") + .IsUnique(); + + b.ToTable("Requests"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.ReturnRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromContactId") + .HasColumnType("uuid"); + + b.Property("FromContactName") + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("GerbilName") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OriginalPrice") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("OriginalSaleDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReturnDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReturnPrice") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("GerbilId"); + + b.ToTable("ReturnRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("ContractDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("HandoverDate") + .HasColumnType("date"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("SaleContracts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b => + { + b.Property("SaleContractId") + .HasColumnType("uuid"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("PhotoId") + .HasColumnType("uuid"); + + b.HasKey("SaleContractId", "GerbilId"); + + b.HasIndex("GerbilId"); + + b.ToTable("SaleContractAnimal"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleReservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppointmentDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ContactName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("GerbilName") + .HasColumnType("text"); + + b.Property("HandedOverDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("ReservedForContactId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("SaleReservations"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultLocale") + .IsRequired() + .HasColumnType("text"); + + b.Property("NavOrder") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Sites"); + + b.HasData( + new + { + Id = new Guid("5172e000-0000-0000-0000-000000000001"), + DefaultLocale = "de", + NavOrder = "[\"51720001-0000-0000-0000-000000000001\",\"51720001-0000-0000-0000-000000000002\",\"51720001-0000-0000-0000-000000000003\",\"51720001-0000-0000-0000-000000000004\",\"51720001-0000-0000-0000-000000000005\",\"51720001-0000-0000-0000-000000000006\"]" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.WaitingListEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("ContactName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("WishColor") + .HasColumnType("text"); + + b.Property("WishGender") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("WaitingListEntries"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.WebPushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("P256dh") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("WebPushSubscriptions"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("WeightGrams") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("WeightRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Block", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Page", null) + .WithMany("Blocks") + .HasForeignKey("PageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Enclosure", null) + .WithMany() + .HasForeignKey("EnclosureId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b => + { + b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety") + .WithMany() + .HasForeignKey("ColorVarietyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Enclosure", "Enclosure") + .WithMany("Gerbils") + .HasForeignKey("EnclosureId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Litter", "Litter") + .WithMany() + .HasForeignKey("LitterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Contact", "OriginContact") + .WithMany() + .HasForeignKey("OriginContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GerbilManagerWebAPI.Models.Contact", "ReceiverContact") + .WithMany() + .HasForeignKey("ReceiverContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ColorVariety"); + + b.Navigation("Enclosure"); + + b.Navigation("Litter"); + + b.Navigation("OriginContact"); + + b.Navigation("ReceiverContact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Father") + .WithMany() + .HasForeignKey("FatherId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Mother") + .WithMany() + .HasForeignKey("MotherId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Father"); + + b.Navigation("Mother"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Request", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Contact", "AssignedContact") + .WithMany() + .HasForeignKey("AssignedContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignedContact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Contact", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Gerbil") + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GerbilManagerWebAPI.Models.SaleContract", null) + .WithMany("Animals") + .HasForeignKey("SaleContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Gerbil"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b => + { + b.Navigation("Gerbils"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Page", b => + { + b.Navigation("Blocks"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.Navigation("Animals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260623093055_WebPushSubscriptions.cs b/GerbilManagerWebAPI/Migrations/20260623093055_WebPushSubscriptions.cs new file mode 100644 index 0000000..18023bd --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260623093055_WebPushSubscriptions.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class WebPushSubscriptions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "WebPushSubscriptions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Endpoint = table.Column(type: "text", nullable: false), + P256dh = table.Column(type: "text", nullable: false), + Auth = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WebPushSubscriptions", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "WebPushSubscriptions"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index 5f38aaf..96e4b77 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -1646,6 +1646,32 @@ namespace GerbilManagerWebAPI.Migrations b.ToTable("WaitingListEntries"); }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.WebPushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("P256dh") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("WebPushSubscriptions"); + }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b => { b.Property("Id") diff --git a/GerbilManagerWebAPI/Models/WebPushSubscription.cs b/GerbilManagerWebAPI/Models/WebPushSubscription.cs new file mode 100644 index 0000000..ad19d53 --- /dev/null +++ b/GerbilManagerWebAPI/Models/WebPushSubscription.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace GerbilManagerWebAPI.Models +{ + /// + /// 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. + /// + public class WebPushSubscription + { + [Key] + public Guid Id { get; set; } + + /// Die vom Browser vergebene Push-Endpoint-URL (eindeutig). + public required string Endpoint { get; set; } + + /// Öffentlicher Client-Schlüssel (keys.p256dh). + public required string P256dh { get; set; } + + /// Auth-Secret des Clients (keys.auth). + public required string Auth { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + } +} diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index 788f1e4..55c448b 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -71,6 +71,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); var app = builder.Build(); @@ -127,6 +128,7 @@ app.MapCmsEndpoints(); app.MapRequestEndpoints(); app.MapNamesEndpoints(); app.MapFeedbackEndpoints(); +app.MapPushEndpoints(); app.MapAcquisitionEndpoints(); app.MapSaleReservationEndpoints(); app.MapWaitingListEndpoints(); diff --git a/GerbilManagerWebAPI/Push/PushNotifier.cs b/GerbilManagerWebAPI/Push/PushNotifier.cs new file mode 100644 index 0000000..178c645 --- /dev/null +++ b/GerbilManagerWebAPI/Push/PushNotifier.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using GerbilManagerWebAPI.Models; +using Microsoft.EntityFrameworkCore; +using WebPush; + +namespace GerbilManagerWebAPI.Push +{ + /// + /// 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. + /// + public class PushNotifier + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _log; + private readonly VapidDetails? _vapid; + + public PushNotifier(IConfiguration config, IServiceScopeFactory scopeFactory, ILogger 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); + } + } + + /// true, wenn Push konfiguriert ist (VAPID-Schlüssel vorhanden). + public bool Enabled => _vapid is not null; + + public string? PublicKey => _vapid?.PublicKey; + + /// Eine Benachrichtigung an ALLE Abos senden. Fehler einzelner Abos werden geschluckt. + 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(); + 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(); + 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); + } + } + } +} diff --git a/GerbilManagerWebAPI/appsettings.json b/GerbilManagerWebAPI/appsettings.json index 10f68b8..4751491 100644 --- a/GerbilManagerWebAPI/appsettings.json +++ b/GerbilManagerWebAPI/appsettings.json @@ -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" + } } diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index e1fc7b9..de14a45 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -447,6 +447,13 @@ export async function installMockApi(page: Page): Promise { } 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') { diff --git a/gerbil-manager-web/public/sw.js b/gerbil-manager-web/public/sw.js new file mode 100644 index 0000000..484f92c --- /dev/null +++ b/gerbil-manager-web/public/sw.js @@ -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 + }), + ) +}) diff --git a/gerbil-manager-web/src/components/PushToggle.tsx b/gerbil-manager-web/src/components/PushToggle.tsx new file mode 100644 index 0000000..8238f66 --- /dev/null +++ b/gerbil-manager-web/src/components/PushToggle.tsx @@ -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 ( + + ) +} diff --git a/gerbil-manager-web/src/pages/TicketsPage.tsx b/gerbil-manager-web/src/pages/TicketsPage.tsx index 8fc25dc..d32beb8 100644 --- a/gerbil-manager-web/src/pages/TicketsPage.tsx +++ b/gerbil-manager-web/src/pages/TicketsPage.tsx @@ -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() {

{t.title}

{t.subtitle}

+ {tickets.error &&

{t.loadError}

} diff --git a/gerbil-manager-web/src/push.ts b/gerbil-manager-web/src/push.ts new file mode 100644 index 0000000..33582c1 --- /dev/null +++ b/gerbil-manager-web/src/push.ts @@ -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 { + 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 { + 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 { + 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() +} diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index f71bc64..19d4518 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -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). */