From b9e5b031c4b28c7882b3da56711b35ad484102e5 Mon Sep 17 00:00:00 2001 From: Gulum Date: Mon, 22 Jun 2026 15:30:42 +0200 Subject: [PATCH] feat(feedback): Fehler-melden-Dialog + ID-kopieren (Stammbaum/Akte/Wurf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rechtsklick auf eine Maus im Stammbaum öffnet ein Kontextmenü mit „ID kopieren" (Clipboard + Toast) und „Fehler melden". Zusätzlich „Fehler melden"-Buttons in der Rennmausakte und der Wurf-Ansicht. Der Dialog erfasst eine Beschreibung und sendet sie samt Debug-Kontext (Ansicht, Tier-/Wurf-ID + Name, URL, Zeitstempel, User-Agent) an POST /feedback; gespeichert in einer neuen Feedback-Tabelle. Backend: Feedback-Entity (lose nullable GerbilId/LitterId ohne FK), Endpoints POST/GET /feedback, EF-Migration AddFeedback. Die Tabelle wird vom Import-Ingest NICHT geleert — Feedback überlebt Re-Ingests (Test deckt das ab). Tests: FeedbackEndpointTests (persistiert, 400 bei leer, übersteht Ingest-Wipe); e2e feedback.spec.ts. tsc/eslint/vitest(129)/playwright(6)/dotnet(212) grün. Co-Authored-By: Claude Opus 4.8 --- GerbilManager.Tests/FeedbackEndpointTests.cs | 162 ++ GerbilManagerWebAPI/ApplicationContext.cs | 10 + GerbilManagerWebAPI/Dtos/FeedbackDtos.cs | 25 + .../Endpoints/FeedbackEndpoints.cs | 62 + .../20260622131928_AddFeedback.Designer.cs | 1536 +++++++++++++++++ .../Migrations/20260622131928_AddFeedback.cs | 47 + .../ApplicationContextModelSnapshot.cs | 42 + GerbilManagerWebAPI/Models/Feedback.cs | 44 + GerbilManagerWebAPI/Program.cs | 1 + gerbil-manager-web/e2e/feedback.spec.ts | 85 + gerbil-manager-web/e2e/mock-api.ts | 19 + gerbil-manager-web/e2e/mock-data.ts | 3 + gerbil-manager-web/src/api/feedback.ts | 35 + .../src/components/ReportErrorDialog.tsx | 171 ++ .../src/components/reportErrorDialog.css | 149 ++ .../src/pages/GerbilDetailPage.tsx | 11 + .../src/pages/StammbaumPage.tsx | 104 +- .../src/pages/WurfDetailPage.tsx | 11 + gerbil-manager-web/src/pages/stammbaum.css | 35 + gerbil-manager-web/src/strings/de.ts | 36 + 20 files changed, 2586 insertions(+), 2 deletions(-) create mode 100644 GerbilManager.Tests/FeedbackEndpointTests.cs create mode 100644 GerbilManagerWebAPI/Dtos/FeedbackDtos.cs create mode 100644 GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs create mode 100644 GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.Designer.cs create mode 100644 GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.cs create mode 100644 GerbilManagerWebAPI/Models/Feedback.cs create mode 100644 gerbil-manager-web/e2e/feedback.spec.ts create mode 100644 gerbil-manager-web/src/api/feedback.ts create mode 100644 gerbil-manager-web/src/components/ReportErrorDialog.tsx create mode 100644 gerbil-manager-web/src/components/reportErrorDialog.css diff --git a/GerbilManager.Tests/FeedbackEndpointTests.cs b/GerbilManager.Tests/FeedbackEndpointTests.cs new file mode 100644 index 0000000..3282bde --- /dev/null +++ b/GerbilManager.Tests/FeedbackEndpointTests.cs @@ -0,0 +1,162 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using GerbilManagerWebAPI.Import; +using GerbilManagerWebAPI.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace GerbilManager.Tests; + +/// +/// FEEDBACK: the "Fehler melden" report sink. +/// - POST /feedback persists a row (with captured debug context) and GET /feedback returns it. +/// - Validation: an empty message is rejected with 400. +/// - CRITICAL: feedback rows survive the import re-ingest wipe (loose, FK-free GerbilId/LitterId). +/// +public class FeedbackEndpointTests : IClassFixture +{ + private readonly ApiFactory _factory; + public FeedbackEndpointTests(ApiFactory factory) => _factory = factory; + + [Fact] + public async Task Post_feedback_persists_row_with_debug_context() + { + var client = _factory.CreateClient(); + + var gerbilId = Guid.NewGuid(); + var resp = await client.PostAsJsonAsync("/feedback", new + { + message = "Der Stammbaum zeigt den falschen Vater.", + context = "stammbaum", + gerbilId, + litterId = (Guid?)null, + entityName = "Krümel", + url = "http://localhost:5173/rennmaeuse/kruemel/stammbaum", + clientTimestamp = "2026-06-22T12:00:00Z", + }); + + Assert.Equal(HttpStatusCode.Created, resp.StatusCode); + var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement; + Assert.False(string.IsNullOrEmpty(created.GetProperty("id").GetString())); + Assert.Equal("stammbaum", created.GetProperty("context").GetString()); + Assert.Equal("Krümel", created.GetProperty("entityName").GetString()); + Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString()); + + // GET returns it (newest first) + var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement; + Assert.Contains(listed.EnumerateArray(), + f => f.GetProperty("entityName").GetString() == "Krümel" + && f.GetProperty("message").GetString() == "Der Stammbaum zeigt den falschen Vater."); + } + + [Fact] + public async Task Post_feedback_rejects_empty_message() + { + var client = _factory.CreateClient(); + var resp = await client.PostAsJsonAsync("/feedback", new { message = " ", context = "gerbil-detail" }); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + } + + [Fact] + public async Task Feedback_survives_ingest_wipe() + { + // Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests). + var dir = Path.Combine(Path.GetTempPath(), "feedback-ingest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var contactId = Guid.NewGuid(); + var fatherId = Guid.NewGuid(); + var motherId = Guid.NewGuid(); + var litterId = Guid.NewGuid(); + + var data = new + { + Contacts = new[] + { + new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false } + }, + Litters = new[] + { + new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" } + }, + Gerbils = new[] + { + Animal(fatherId, "Papa", "male", contactId), + Animal(motherId, "Mama", "female", contactId), + }, + GerbilPhotos = Array.Empty(), + }; + File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data)); + + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase("feedback-ingest-" + Guid.NewGuid().ToString("N")) + .Options; + using var db = new ApplicationContext(opts); + db.Database.EnsureCreated(); + + // A feedback report referencing the gerbil + litter that the wipe will delete. + var feedbackId = Guid.NewGuid(); + db.Feedback.Add(new Feedback + { + Id = feedbackId, + Message = "Bitte prüfen.", + Context = "gerbil-detail", + GerbilId = fatherId, + LitterId = litterId, + EntityName = "Papa", + Url = "http://localhost/rennmaeuse/papa", + CreatedAt = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Import:SourcePath", dir } }) + .Build(); + + // Run the ingest wipe + reload. + var result = await new IngestResolvedService(db, config, null!).RunAsync(); + Assert.Contains("Ingestion successful!", result); + + // Gerbils/litters were wiped & re-created, but feedback is untouched. + var survivor = await db.Feedback.SingleAsync(); + Assert.Equal(feedbackId, survivor.Id); + Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated + Assert.Equal(litterId, survivor.LitterId); + Assert.Equal("Papa", survivor.EntityName); + } + finally + { + try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ } + } + } + + private static object Animal(Guid id, string name, string gender, Guid contactId) => new + { + Id = id, + Name = name, + Gender = gender, + Status = "Breeding", + LitterId = (Guid?)null, + OriginContactId = contactId, + ReceiverContactId = (Guid?)null, + EnclosureId = (Guid?)null, + ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), + DateOfBirth = "2025-01-01", + DateOfDeath = (string?)null, + CauseOfDeath = (string?)null, + GoHomeDate = (string?)null, + Genotype = "aa CC DD EE GG PP spsp rere", + Notes = "", + ImportSource = "docx-export", + ExternalRef = "ext-" + name, + RawImportData = "{}", + OriginBreeder = "Test Zucht", + NameSearch = name.ToLowerInvariant(), + CharacterTraits = Array.Empty(), + CharacterNote = (string?)null, + IsDeaf = false, + IsResident = true, + }; +} diff --git a/GerbilManagerWebAPI/ApplicationContext.cs b/GerbilManagerWebAPI/ApplicationContext.cs index c037927..a620842 100644 --- a/GerbilManagerWebAPI/ApplicationContext.cs +++ b/GerbilManagerWebAPI/ApplicationContext.cs @@ -24,6 +24,7 @@ public class ApplicationContext : DbContext public DbSet Media => Set(); public DbSet Requests => Set(); public DbSet MailSettings => Set(); + public DbSet Feedback => Set(); // Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key), // so it can never drift from Name regardless of which code path mutates the entity. @@ -202,6 +203,15 @@ public class ApplicationContext : DbContext modelBuilder.Entity() .HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId }); + // FEEDBACK: deliberately relationship-free. GerbilId/LitterId are plain nullable + // Guid columns (no navigation properties → EF creates NO foreign key), so the + // import re-ingest wipe of Gerbils/Litters never cascades into — or breaks — + // feedback rows. They survive re-ingest, which is the whole point. + modelBuilder.Entity(e => + { + e.HasIndex(f => f.CreatedAt); + }); + // DB-4: German collation on remaining searched/sorted text columns (Npgsql-only). if (isNpgsql) { diff --git a/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs b/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs new file mode 100644 index 0000000..67fc2a2 --- /dev/null +++ b/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs @@ -0,0 +1,25 @@ +namespace GerbilManagerWebAPI.Dtos +{ + /// FEEDBACK: payload for POST /feedback (the "Fehler melden" dialog). + public record FeedbackInput( + string Message, + string Context, + Guid? GerbilId, + Guid? LitterId, + string? EntityName, + string? Url, + DateTimeOffset? ClientTimestamp); + + /// FEEDBACK: response DTO for a stored report. + public record FeedbackDto( + Guid Id, + string Message, + string Context, + Guid? GerbilId, + Guid? LitterId, + string? EntityName, + string? Url, + DateTimeOffset? ClientTimestamp, + string? UserAgent, + DateTimeOffset CreatedAt); +} diff --git a/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs b/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs new file mode 100644 index 0000000..09fab69 --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs @@ -0,0 +1,62 @@ +using GerbilManagerWebAPI.Dtos; +using GerbilManagerWebAPI.Models; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// FEEDBACK: the "Fehler melden" report sink. + /// POST /feedback -> persist a user bug report (with captured debug context), returns 201. + /// GET /feedback -> list reports, newest first (for later review). + /// Feedback is decoupled from gerbils/litters (loose nullable Guid columns, no FK), so + /// rows survive the import re-ingest wipe. + /// + public static class FeedbackEndpoints + { + public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/feedback").WithTags("Feedback"); + + group.MapPost("/", async Task, BadRequest>> ( + FeedbackInput input, ApplicationContext db, HttpContext http) => + { + if (string.IsNullOrWhiteSpace(input.Message)) + return TypedResults.BadRequest("Message darf nicht leer sein."); + + var entity = new Feedback + { + Id = Guid.NewGuid(), + Message = input.Message.Trim(), + Context = string.IsNullOrWhiteSpace(input.Context) ? "unknown" : input.Context.Trim(), + GerbilId = input.GerbilId, + LitterId = input.LitterId, + EntityName = input.EntityName, + Url = input.Url, + ClientTimestamp = input.ClientTimestamp, + UserAgent = http.Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null, + CreatedAt = DateTimeOffset.UtcNow, + }; + db.Feedback.Add(entity); + await db.SaveChangesAsync(); + return TypedResults.Created($"/feedback/{entity.Id}", ToDto(entity)); + }); + + group.MapGet("/", async (ApplicationContext db) => + { + // Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column. + var rows = await db.Feedback.AsNoTracking().ToListAsync(); + return TypedResults.Ok(rows + .OrderByDescending(f => f.CreatedAt) + .Select(ToDto) + .ToList()); + }); + + return app; + } + + private static FeedbackDto ToDto(Feedback f) => + new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.EntityName, f.Url, + f.ClientTimestamp, f.UserAgent, f.CreatedAt); + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.Designer.cs b/GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.Designer.cs new file mode 100644 index 0000000..c9950c3 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.Designer.cs @@ -0,0 +1,1536 @@ +// +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("20260622131928_AddFeedback")] + partial class AddFeedback + { + /// + 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.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 efef gg PP spsp rere", + Name = "Polarfuchsschimmel", + SortOrder = 37 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000035"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Algierfuchsschimmel", + SortOrder = 38 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000036"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel", + SortOrder = 39 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000037"), + CanonicalGenotype = "aa CC DD efef 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 efef 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 efef 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 efef 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.HasKey("Id"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .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.Feedback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Context") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityName") + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Message") + .IsRequired() + .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.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("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("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.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.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.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/20260622131928_AddFeedback.cs b/GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.cs new file mode 100644 index 0000000..dd06c0a --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.cs @@ -0,0 +1,47 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class AddFeedback : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Feedback", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Message = table.Column(type: "text", nullable: false), + Context = table.Column(type: "text", nullable: false), + GerbilId = table.Column(type: "uuid", nullable: true), + LitterId = table.Column(type: "uuid", nullable: true), + EntityName = table.Column(type: "text", nullable: true), + Url = table.Column(type: "text", nullable: true), + ClientTimestamp = table.Column(type: "timestamp with time zone", nullable: true), + UserAgent = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Feedback", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Feedback_CreatedAt", + table: "Feedback", + column: "CreatedAt"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Feedback"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index af36b60..11980d5 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -793,6 +793,48 @@ namespace GerbilManagerWebAPI.Migrations b.ToTable("EnclosurePhotos"); }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.Feedback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Context") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityName") + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Message") + .IsRequired() + .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.Gerbil", b => { b.Property("Id") diff --git a/GerbilManagerWebAPI/Models/Feedback.cs b/GerbilManagerWebAPI/Models/Feedback.cs new file mode 100644 index 0000000..818b09d --- /dev/null +++ b/GerbilManagerWebAPI/Models/Feedback.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; + +namespace GerbilManagerWebAPI.Models +{ + /// + /// FEEDBACK: a user-submitted "Fehler melden" report. Decoupled from the rest of the + /// model on purpose — GerbilId/LitterId are plain nullable Guid columns (NOT enforced + /// foreign keys), so the import re-ingest wipe (IngestResolvedService) can delete + /// gerbils/litters without deleting or breaking feedback rows. The captured EntityName + /// keeps the report human-readable even after the referenced animal is gone. + /// + public class Feedback + { + [Key] + public Guid Id { get; set; } + + /// The user's free-text description of the problem. + public required string Message { get; set; } + + /// Which view the report came from: stammbaum | gerbil-detail | litter-detail. + public required string Context { get; set; } + + /// Loose reference (no FK) to the gerbil the report is about, if any. + public Guid? GerbilId { get; set; } + + /// Loose reference (no FK) to the litter the report is about, if any. + public Guid? LitterId { get; set; } + + /// Captured name of the referenced animal/litter (survives an ingest wipe). + public string? EntityName { get; set; } + + /// The client URL/route the report was filed from. + public string? Url { get; set; } + + /// Client-supplied timestamp (when the user submitted, in their browser). + public DateTimeOffset? ClientTimestamp { get; set; } + + /// Optional browser user-agent for diagnostics. + public string? UserAgent { get; set; } + + /// Server-side creation time. + public DateTimeOffset CreatedAt { get; set; } + } +} diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index 8b05e87..ecb8493 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -126,6 +126,7 @@ app.MapExportEndpoints(); app.MapCmsEndpoints(); app.MapRequestEndpoints(); app.MapNamesEndpoints(); +app.MapFeedbackEndpoints(); app.Run(); diff --git a/gerbil-manager-web/e2e/feedback.spec.ts b/gerbil-manager-web/e2e/feedback.spec.ts new file mode 100644 index 0000000..db9540c --- /dev/null +++ b/gerbil-manager-web/e2e/feedback.spec.ts @@ -0,0 +1,85 @@ +/** FEEDBACK: "Fehler melden" Dialog + Stammbaum-Rechtsklick ("ID kopieren" / "Fehler melden"). */ +import { de, expect, skipUnlessMock, test } from './fixtures' + +const f = de.feedback +const st = de.pages.stammbaum + +test('Tierakte: "Fehler melden"-Button öffnet den Dialog, Senden zeigt Erfolg', async ({ page, mockDb }) => { + skipUnlessMock() + await page.goto('/rennmaeuse/kruemel') + + // Button in den Aktionen öffnet den Dialog. + await page.getByRole('button', { name: f.button }).click() + const dialog = page.getByRole('dialog', { name: f.dialogTitle }) + await expect(dialog).toBeVisible() + + // Debug-Kontext ist sichtbar (automatisch erfasst). + await expect(dialog).toContainText(f.debugTitle) + await expect(dialog).toContainText(f.contexts['gerbil-detail']) + + // Beschreibung eintragen + senden. + await dialog.getByLabel(f.label).fill('Der Farbschlag stimmt nicht.') + await dialog.getByRole('button', { name: f.submit }).click() + + // Erfolgs-Toast + Dialog schließt. + await expect(page.getByText(f.success)).toBeVisible() + await expect(dialog).not.toBeVisible() + + // Der Bericht wurde mit Debug-Kontext gespeichert. + expect(mockDb).not.toBeNull() + const reports = mockDb!.feedback + expect(reports.length).toBe(1) + expect(reports[0]).toMatchObject({ + message: 'Der Farbschlag stimmt nicht.', + context: 'gerbil-detail', + gerbilId: 'kruemel', + }) + expect(reports[0].url).toContain('/rennmaeuse/kruemel') + expect(typeof reports[0].clientTimestamp).toBe('string') +}) + +test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wurf-Kontext', async ({ page, mockDb }) => { + skipUnlessMock() + await page.goto('/wuerfe/w-kruemel') + + await page.getByRole('button', { name: f.button }).click() + const dialog = page.getByRole('dialog', { name: f.dialogTitle }) + await expect(dialog).toBeVisible() + await expect(dialog).toContainText(f.contexts['litter-detail']) + + await dialog.getByLabel(f.label).fill('Die Wurfstärke ist falsch.') + await dialog.getByRole('button', { name: f.submit }).click() + await expect(page.getByText(f.success)).toBeVisible() + + const reports = mockDb!.feedback + expect(reports.length).toBe(1) + expect(reports[0]).toMatchObject({ context: 'litter-detail', litterId: 'w-kruemel' }) +}) + +test('Stammbaum: Rechtsklick auf eine Karte zeigt "ID kopieren" + "Fehler melden"', async ({ page, mockDb }) => { + skipUnlessMock() + await page.goto('/rennmaeuse/kruemel/stammbaum') + await expect(page.locator('.pedigree-card').first()).toBeVisible() + + // Rechtsklick auf die Wurzelkarte (Krümel). + await page.locator('.pedigree-card--root').click({ button: 'right' }) + + const menu = page.locator('.stammbaum-context-menu') + await expect(menu).toBeVisible() + await expect(menu.getByRole('menuitem', { name: st.contextMenu.copyId })).toBeVisible() + await expect(menu.getByRole('menuitem', { name: st.contextMenu.reportError })).toBeVisible() + + // "Fehler melden" öffnet den Dialog mit Stammbaum-Kontext. + await menu.getByRole('menuitem', { name: st.contextMenu.reportError }).click() + const dialog = page.getByRole('dialog', { name: f.dialogTitle }) + await expect(dialog).toBeVisible() + await expect(dialog).toContainText(f.contexts.stammbaum) + + await dialog.getByLabel(f.label).fill('Stammbaum-Bug.') + await dialog.getByRole('button', { name: f.submit }).click() + await expect(page.getByText(f.success)).toBeVisible() + + const reports = mockDb!.feedback + expect(reports.length).toBe(1) + expect(reports[0]).toMatchObject({ context: 'stammbaum', gerbilId: 'kruemel' }) +}) diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index 36958ae..78faa7a 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -397,6 +397,25 @@ export async function installMockApi(page: Page): Promise { return json(route, 200, result) } + // FEEDBACK: "Fehler melden" — POST persistiert, GET listet (neueste zuerst). + if (path === '/feedback') { + if (method === 'POST') { + const body = request.postDataJSON() as Row + const created = { + id: newId('feedback'), + ...body, + userAgent: request.headers()['user-agent'] ?? null, + createdAt: new Date().toISOString(), + } + db.feedback.push(created) + return json(route, 201, created) + } + if (method === 'GET') { + return json(route, 200, [...db.feedback].reverse()) + } + return json(route, 405) + } + // Generische Kollektionen: / und // m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/) const col = m ? collections[m[1]] : undefined diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts index 73845cf..00cc2b1 100644 --- a/gerbil-manager-web/e2e/mock-data.ts +++ b/gerbil-manager-web/e2e/mock-data.ts @@ -76,6 +76,8 @@ export interface MockDb { saleAdConfigured: boolean // FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren namesConfigured: boolean + // FEEDBACK: "Fehler melden" — gesammelte Berichte (POST /feedback) + feedback: Record[] } function gerbil( @@ -324,5 +326,6 @@ export function seedDb(): MockDb { contracts: [], saleAdConfigured: true, namesConfigured: true, + feedback: [], } } diff --git a/gerbil-manager-web/src/api/feedback.ts b/gerbil-manager-web/src/api/feedback.ts new file mode 100644 index 0000000..b1c89fd --- /dev/null +++ b/gerbil-manager-web/src/api/feedback.ts @@ -0,0 +1,35 @@ +/** FEEDBACK: API client for the "Fehler melden" report sink (POST /feedback). */ +import { api } from './client' + +const RESOURCE = '/feedback' + +/** Which view a report was filed from (matches the backend Context contract). */ +export type FeedbackContext = 'stammbaum' | 'gerbil-detail' | 'litter-detail' + +/** Payload for POST /feedback. Debug fields are captured automatically by the caller. */ +export interface FeedbackInput { + message: string + context: FeedbackContext + gerbilId?: string | null + litterId?: string | null + entityName?: string | null + url?: string | null + clientTimestamp?: string | null +} + +export interface Feedback { + id: string + message: string + context: string + gerbilId: string | null + litterId: string | null + entityName: string | null + url: string | null + clientTimestamp: string | null + userAgent: string | null + createdAt: string +} + +export function submitFeedback(body: FeedbackInput): Promise { + return api.post(RESOURCE, body) +} diff --git a/gerbil-manager-web/src/components/ReportErrorDialog.tsx b/gerbil-manager-web/src/components/ReportErrorDialog.tsx new file mode 100644 index 0000000..ad7301e --- /dev/null +++ b/gerbil-manager-web/src/components/ReportErrorDialog.tsx @@ -0,0 +1,171 @@ +/** + * FEEDBACK: "Fehler melden" — a small accessible modal with a textarea + submit. + * + * The caller passes the debug context (which view, the gerbil/litter id + name); the + * dialog captures the current URL and a client timestamp itself and POSTs everything + * to /feedback. Success/error is surfaced via the existing toast system. + */ +import { useEffect, useRef, useState } from 'react' +import { de } from '../strings/de' +import { ApiError } from '../api/client' +import { submitFeedback, type FeedbackContext } from '../api/feedback' +import { useToast } from './toast' +import './reportErrorDialog.css' + +export interface ReportErrorContext { + context: FeedbackContext + gerbilId?: string | null + litterId?: string | null + entityName?: string | null +} + +interface ReportErrorDialogProps { + open: boolean + onClose: () => void + context: ReportErrorContext +} + +/** + * Thin wrapper: mounts the dialog body only while open, so its form state starts + * fresh on every open (no reset-in-effect needed). + */ +export default function ReportErrorDialog({ open, onClose, context }: ReportErrorDialogProps) { + if (!open) return null + return +} + +function ReportErrorDialogBody({ + onClose, + context, +}: { + onClose: () => void + context: ReportErrorContext +}) { + const t = de.feedback + const toast = useToast() + const [message, setMessage] = useState('') + const [submitting, setSubmitting] = useState(false) + const textareaRef = useRef(null) + + // Focus the textarea once on mount. + useEffect(() => { + const id = window.setTimeout(() => textareaRef.current?.focus(), 0) + return () => window.clearTimeout(id) + }, []) + + // Close on Escape. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + + const contextLabel = t.contexts[context.context] + + async function handleSubmit() { + const trimmed = message.trim() + if (!trimmed) { + toast.error(t.emptyMessage) + textareaRef.current?.focus() + return + } + setSubmitting(true) + try { + await submitFeedback({ + message: trimmed, + context: context.context, + gerbilId: context.gerbilId ?? null, + litterId: context.litterId ?? null, + entityName: context.entityName ?? null, + url: window.location.href, + clientTimestamp: new Date().toISOString(), + }) + toast.success(t.success) + onClose() + } catch (err) { + toast.error(err instanceof ApiError ? err.message : t.error) + setSubmitting(false) + } + } + + return ( +
+
e.stopPropagation()} + > +
+

+ {t.dialogTitle} +

+ +
+ +

{t.intro}

+ + +