diff --git a/GerbilManager.Tests/FeedbackEndpointTests.cs b/GerbilManager.Tests/FeedbackEndpointTests.cs index ae29d73..0b0761a 100644 --- a/GerbilManager.Tests/FeedbackEndpointTests.cs +++ b/GerbilManager.Tests/FeedbackEndpointTests.cs @@ -111,6 +111,54 @@ public class FeedbackEndpointTests : IClassFixture Assert.Equal(HttpStatusCode.NotFound, editGone.StatusCode); } + [Fact] + public async Task Put_question_sets_NeedsInfo_and_answer_sets_Answered() + { + var client = _factory.CreateClient(); + + // Create an open ticket. + var create = await client.PostAsJsonAsync("/feedback", new + { + message = "Wurf B hat ein falsches Geburtsdatum.", + context = "litter-detail", + entityName = "Wurf B", + }); + Assert.Equal(HttpStatusCode.Created, create.StatusCode); + var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); + + // Attach a clarifying question -> Status becomes NeedsInfo, question echoed back, no answer yet. + var ask = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Welches Datum stimmt?" }); + Assert.Equal(HttpStatusCode.OK, ask.StatusCode); + var asked = JsonDocument.Parse(await ask.Content.ReadAsStringAsync()).RootElement; + Assert.Equal("NeedsInfo", asked.GetProperty("status").GetString()); + Assert.Equal("Welches Datum stimmt?", asked.GetProperty("question").GetString()); + Assert.Equal(JsonValueKind.Null, asked.GetProperty("answer").ValueKind); + Assert.Equal(JsonValueKind.Null, asked.GetProperty("answeredAt").ValueKind); + + // Breeder answers -> Status becomes Answered, AnsweredAt stamped, answer echoed, question retained. + var reply = await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Der 2. Juni." }); + Assert.Equal(HttpStatusCode.OK, reply.StatusCode); + var answered = JsonDocument.Parse(await reply.Content.ReadAsStringAsync()).RootElement; + Assert.Equal("Answered", answered.GetProperty("status").GetString()); + Assert.Equal("Der 2. Juni.", answered.GetProperty("answer").GetString()); + Assert.Equal("Welches Datum stimmt?", answered.GetProperty("question").GetString()); + Assert.NotEqual(JsonValueKind.Null, answered.GetProperty("answeredAt").ValueKind); + } + + [Fact] + public async Task Put_question_on_resolved_ticket_keeps_it_resolved() + { + var client = _factory.CreateClient(); + var create = await client.PostAsJsonAsync("/feedback", new { message = "x", context = "gerbil-detail" }); + var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); + + await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" }); + var put = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Noch eine Frage?" }); + var dto = JsonDocument.Parse(await put.Content.ReadAsStringAsync()).RootElement; + Assert.Equal("Resolved", dto.GetProperty("status").GetString()); + Assert.Equal("Noch eine Frage?", dto.GetProperty("question").GetString()); + } + [Fact] public async Task Put_and_delete_unknown_id_return_404() { @@ -178,8 +226,11 @@ public class FeedbackEndpointTests : IClassFixture EntityName = "Papa", Url = "http://localhost/rennmaeuse/papa", CreatedAt = DateTimeOffset.UtcNow, - Status = "Resolved", - ResolvedAt = DateTimeOffset.UtcNow, + Status = "Answered", + ResolvedAt = null, + Question = "Welches Datum stimmt?", + Answer = "Der 2. Juni.", + AnsweredAt = DateTimeOffset.UtcNow, }); // A contact-scoped feedback report — the ContactId is a loose (FK-free) id, // so it must survive the contact-table wipe just like gerbil/litter ids. @@ -209,8 +260,11 @@ public class FeedbackEndpointTests : IClassFixture 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); - Assert.Equal("Resolved", survivor.Status); // ticket status column survives the wipe - Assert.NotNull(survivor.ResolvedAt); + Assert.Equal("Answered", survivor.Status); // ticket status column survives the wipe + // The new question/answer columns survive the wipe too. + Assert.Equal("Welches Datum stimmt?", survivor.Question); + Assert.Equal("Der 2. Juni.", survivor.Answer); + Assert.NotNull(survivor.AnsweredAt); // The contact-scoped report also survives the contacts wipe (loose ContactId). var contactSurvivor = await db.Feedback.SingleAsync(f => f.Id == contactFeedbackId); diff --git a/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs b/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs index 09a7c93..bfa302d 100644 --- a/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs +++ b/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs @@ -25,10 +25,18 @@ namespace GerbilManagerWebAPI.Dtos string? UserAgent, DateTimeOffset CreatedAt, string Status, - DateTimeOffset? ResolvedAt); + DateTimeOffset? ResolvedAt, + string? Question, + string? Answer, + DateTimeOffset? AnsweredAt); - /// FEEDBACK: payload for PUT /feedback/{id} (edit message and/or toggle status). + /// + /// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status, + /// attach a clarifying question (Rückfrage), or submit the breeder's answer. + /// public record FeedbackUpdate( string? Message, - string? Status); + string? Status, + string? Question, + string? Answer); } diff --git a/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs b/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs index b4842bb..2149637 100644 --- a/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs +++ b/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs @@ -9,7 +9,8 @@ namespace GerbilManagerWebAPI.Endpoints /// FEEDBACK: the "Fehler melden" report sink + ticket management ("Meine Tickets"). /// POST /feedback -> persist a user bug report (with captured debug context), returns 201. /// GET /feedback -> list reports, newest first (for the ticket list). - /// PUT /feedback/{id} -> edit the message and/or toggle status Open/Resolved (sets/clears ResolvedAt). + /// PUT /feedback/{id} -> edit message, toggle status, attach a clarifying question (Rückfrage), + /// or submit the breeder's answer. Question -> NeedsInfo; Answer -> Answered + AnsweredAt. /// DELETE /feedback/{id} -> remove a report. 404 on missing id. /// Feedback is decoupled from gerbils/litters (loose nullable Guid columns, no FK), so /// rows survive the import re-ingest wipe. @@ -71,11 +72,53 @@ namespace GerbilManagerWebAPI.Endpoints entity.Message = input.Message.Trim(); } + // Attach a clarifying question (Rückfrage). A non-empty question moves the ticket to + // NeedsInfo (waiting on the breeder) unless it is already Resolved. A blank/whitespace + // question clears it. + if (input.Question is not null) + { + var q = input.Question.Trim(); + entity.Question = q.Length == 0 ? null : q; + if (entity.Question is not null && !entity.Status.Equals("Resolved", StringComparison.OrdinalIgnoreCase)) + { + entity.Status = "NeedsInfo"; + entity.ResolvedAt = null; + } + } + + // The breeder's answer. A non-empty answer stamps AnsweredAt and moves to Answered. + if (input.Answer is not null) + { + var a = input.Answer.Trim(); + if (a.Length == 0) + { + entity.Answer = null; + entity.AnsweredAt = null; + } + else + { + entity.Answer = a; + entity.AnsweredAt = DateTimeOffset.UtcNow; + entity.Status = "Answered"; + entity.ResolvedAt = null; + } + } + if (input.Status is not null) { - // Normalize to the two known states; resolving stamps ResolvedAt, reopening clears it. - var resolved = input.Status.Trim().Equals("Resolved", StringComparison.OrdinalIgnoreCase); - entity.Status = resolved ? "Resolved" : "Open"; + // Normalize to a known lifecycle state. Resolving stamps ResolvedAt; any other + // state clears it. Open/NeedsInfo/Answered/Resolved are accepted (case-insensitive), + // anything else falls back to Open. + var status = input.Status.Trim(); + var resolved = status.Equals("Resolved", StringComparison.OrdinalIgnoreCase); + var normalized = status switch + { + _ when status.Equals("Resolved", StringComparison.OrdinalIgnoreCase) => "Resolved", + _ when status.Equals("NeedsInfo", StringComparison.OrdinalIgnoreCase) => "NeedsInfo", + _ when status.Equals("Answered", StringComparison.OrdinalIgnoreCase) => "Answered", + _ => "Open", + }; + entity.Status = normalized; entity.ResolvedAt = resolved ? (entity.ResolvedAt ?? DateTimeOffset.UtcNow) : null; @@ -102,6 +145,7 @@ namespace GerbilManagerWebAPI.Endpoints private static FeedbackDto ToDto(Feedback f) => new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url, - f.ClientTimestamp, f.UserAgent, f.CreatedAt, f.Status, f.ResolvedAt); + f.ClientTimestamp, f.UserAgent, f.CreatedAt, f.Status, f.ResolvedAt, + f.Question, f.Answer, f.AnsweredAt); } } diff --git a/GerbilManagerWebAPI/Migrations/20260622174959_AddFeedbackQuestionAnswer.Designer.cs b/GerbilManagerWebAPI/Migrations/20260622174959_AddFeedbackQuestionAnswer.Designer.cs new file mode 100644 index 0000000..3a7ba80 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260622174959_AddFeedbackQuestionAnswer.Designer.cs @@ -0,0 +1,1564 @@ +// +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("20260622174959_AddFeedbackQuestionAnswer")] + partial class AddFeedbackQuestionAnswer + { + /// + 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.Property("Provenance") + .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("Answer") + .HasColumnType("text"); + + b.Property("AnsweredAt") + .HasColumnType("timestamp with time zone"); + + 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("EntityName") + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Question") + .HasColumnType("text"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .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("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.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/20260622174959_AddFeedbackQuestionAnswer.cs b/GerbilManagerWebAPI/Migrations/20260622174959_AddFeedbackQuestionAnswer.cs new file mode 100644 index 0000000..b33d6a5 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260622174959_AddFeedbackQuestionAnswer.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class AddFeedbackQuestionAnswer : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Answer", + table: "Feedback", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "AnsweredAt", + table: "Feedback", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "Question", + table: "Feedback", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Answer", + table: "Feedback"); + + migrationBuilder.DropColumn( + name: "AnsweredAt", + table: "Feedback"); + + migrationBuilder.DropColumn( + name: "Question", + table: "Feedback"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index 9823dc4..d3b6ea1 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -802,6 +802,12 @@ namespace GerbilManagerWebAPI.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("Answer") + .HasColumnType("text"); + + b.Property("AnsweredAt") + .HasColumnType("timestamp with time zone"); + b.Property("ClientTimestamp") .HasColumnType("timestamp with time zone"); @@ -828,6 +834,9 @@ namespace GerbilManagerWebAPI.Migrations .IsRequired() .HasColumnType("text"); + b.Property("Question") + .HasColumnType("text"); + b.Property("ResolvedAt") .HasColumnType("timestamp with time zone"); diff --git a/GerbilManagerWebAPI/Models/Feedback.cs b/GerbilManagerWebAPI/Models/Feedback.cs index aeddf3d..96283e5 100644 --- a/GerbilManagerWebAPI/Models/Feedback.cs +++ b/GerbilManagerWebAPI/Models/Feedback.cs @@ -45,12 +45,23 @@ namespace GerbilManagerWebAPI.Models public DateTimeOffset CreatedAt { get; set; } /// - /// Ticket lifecycle status: "Open" | "Resolved" (default "Open"). Plain string, - /// no FK — keeps feedback decoupled and ingest-surviving like the rest of the row. + /// Ticket lifecycle status: "Open" | "NeedsInfo" | "Answered" | "Resolved" (default "Open"). + /// "NeedsInfo" = a maintainer attached a clarifying question (Rückfrage) and is waiting on + /// the breeder; "Answered" = the breeder replied. Plain string, no FK — keeps feedback + /// decoupled and ingest-surviving like the rest of the row. /// public string Status { get; set; } = "Open"; /// When the ticket was marked resolved; null while open. public DateTimeOffset? ResolvedAt { get; set; } + + /// A clarifying question (Rückfrage) a maintainer attaches to the ticket; null if none. + public string? Question { get; set; } + + /// The breeder's (Züchterin) reply to the clarifying question; null until answered. + public string? Answer { get; set; } + + /// When the breeder answered the clarifying question; null until answered. + public DateTimeOffset? AnsweredAt { get; set; } } } diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index d189e2f..35c3fb5 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -409,6 +409,9 @@ export async function installMockApi(page: Page): Promise { createdAt: new Date().toISOString(), status: 'Open', resolvedAt: null, + question: null, + answer: null, + answeredAt: null, } db.feedback.push(created) return json(route, 201, created) @@ -425,15 +428,47 @@ export async function installMockApi(page: Page): Promise { const idx = db.feedback.findIndex((f) => f.id === fid) if (idx < 0) return json(route, 404, { title: 'Not Found' }) if (method === 'PUT') { - const body = request.postDataJSON() as { message?: string; status?: string } + const body = request.postDataJSON() as { + message?: string + status?: string + question?: string + answer?: string + } const row = db.feedback[idx] if (typeof body.message === 'string') { if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.') row.message = body.message.trim() } + // Rückfrage anhängen → NeedsInfo (außer bereits Resolved). + if (typeof body.question === 'string') { + const q = body.question.trim() + row.question = q.length === 0 ? null : q + if (row.question && row.status !== 'Resolved') { + row.status = 'NeedsInfo' + row.resolvedAt = null + } + } + // Antwort der Züchterin → Answered + answeredAt. + if (typeof body.answer === 'string') { + const a = body.answer.trim() + if (a.length === 0) { + row.answer = null + row.answeredAt = null + } else { + row.answer = a + row.answeredAt = new Date().toISOString() + row.status = 'Answered' + row.resolvedAt = null + } + } if (typeof body.status === 'string') { - const resolved = body.status.toLowerCase() === 'resolved' - row.status = resolved ? 'Resolved' : 'Open' + const s = body.status + const resolved = s.toLowerCase() === 'resolved' + row.status = resolved + ? 'Resolved' + : s === 'NeedsInfo' || s === 'Answered' + ? s + : 'Open' row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null } return json(route, 200, row) diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts index 44fa4ce..aa3a378 100644 --- a/gerbil-manager-web/e2e/mock-data.ts +++ b/gerbil-manager-web/e2e/mock-data.ts @@ -408,6 +408,28 @@ export function seedDb(): MockDb { createdAt: '2026-06-10T09:00:00Z', status: 'Resolved', resolvedAt: '2026-06-12T14:00:00Z', + question: null, + answer: null, + answeredAt: null, + }, + { + // Ticket mit offener Rückfrage (NeedsInfo): die Züchterin soll hier antworten können. + id: 'feedback-needsinfo', + message: 'Der Wurf B hat ein falsches Geburtsdatum.', + context: 'litter-detail', + gerbilId: null, + litterId: 'wurf-b', + contactId: null, + entityName: 'Wurf B', + url: 'http://localhost:5173/wuerfe/wurf-b', + clientTimestamp: '2026-06-14T08:00:00Z', + userAgent: null, + createdAt: '2026-06-14T08:00:00Z', + status: 'NeedsInfo', + resolvedAt: null, + question: 'Welches Geburtsdatum ist korrekt — der 1. oder der 2. Juni?', + answer: null, + answeredAt: null, }, { id: 'feedback-open', @@ -423,6 +445,9 @@ export function seedDb(): MockDb { createdAt: '2026-06-15T11:30:00Z', status: 'Open', resolvedAt: null, + question: null, + answer: null, + answeredAt: null, }, ], } diff --git a/gerbil-manager-web/e2e/tickets.spec.ts b/gerbil-manager-web/e2e/tickets.spec.ts index 1654bf2..3c2dde2 100644 --- a/gerbil-manager-web/e2e/tickets.spec.ts +++ b/gerbil-manager-web/e2e/tickets.spec.ts @@ -57,6 +57,28 @@ test.describe('Meine Tickets', () => { await expect(page.getByText('Korrigierte Beschreibung des Fehlers.')).toBeVisible() }) + test('Rückfrage beantworten: Züchterin sieht Frage, antwortet, Badge wird „Beantwortet"', async ({ + page, + }) => { + await page.goto('/hilfe/tickets') + + const card = page.locator('.ticket-card').filter({ hasText: 'Wurf B' }) + await expect(card).toBeVisible() + + // Rückfrage ist sichtbar; Status-Badge „Rückfrage offen". + await expect(card.getByText('Welches Geburtsdatum ist korrekt')).toBeVisible() + await expect(card.getByText(tt.statusNeedsInfo, { exact: true })).toBeVisible() + + // Antworten. + await card.locator('textarea').fill('Korrekt ist der 2. Juni.') + await card.getByRole('button', { name: tt.answerButton }).click() + + // Antwort wird angezeigt + Badge wechselt auf „Beantwortet". + await expect(card.getByText('Korrekt ist der 2. Juni.')).toBeVisible() + await expect(card.locator('.ticket-badge--answered')).toBeVisible() + await expect(card.getByText(tt.statusAnswered, { exact: true })).toBeVisible() + }) + test('Ticket löschen', async ({ page }) => { await page.goto('/hilfe/tickets') diff --git a/gerbil-manager-web/src/api/feedback.ts b/gerbil-manager-web/src/api/feedback.ts index 5587471..9075b21 100644 --- a/gerbil-manager-web/src/api/feedback.ts +++ b/gerbil-manager-web/src/api/feedback.ts @@ -18,8 +18,14 @@ export interface FeedbackInput { clientTimestamp?: string | null } -/** Ticket lifecycle status (matches the backend Status contract). */ -export type FeedbackStatus = 'Open' | 'Resolved' +/** + * Ticket lifecycle status (matches the backend Status contract). + * - Open — neu, noch keine Rückfrage. + * - NeedsInfo — eine Rückfrage wurde gestellt, wartet auf die Antwort der Züchterin. + * - Answered — die Züchterin hat geantwortet. + * - Resolved — erledigt. + */ +export type FeedbackStatus = 'Open' | 'NeedsInfo' | 'Answered' | 'Resolved' export interface Feedback { id: string @@ -35,15 +41,28 @@ export interface Feedback { createdAt: string status: FeedbackStatus resolvedAt: string | null + /** Rückfrage einer/eines Betreuenden an die Züchterin (falls vorhanden). */ + question: string | null + /** Antwort der Züchterin auf die Rückfrage (falls vorhanden). */ + answer: string | null + /** Zeitpunkt der Antwort der Züchterin. */ + answeredAt: string | null } /** Alias used by the "Meine Tickets" area for readability. */ export type FeedbackTicket = Feedback -/** Payload for PUT /feedback/{id}: edit the message and/or toggle the status. */ +/** + * Payload for PUT /feedback/{id}: edit the message, toggle the status, attach a + * clarifying question (Rückfrage) or submit the breeder's answer. + */ export interface FeedbackUpdate { message?: string status?: FeedbackStatus + /** Rückfrage anhängen (nicht leer ⇒ Status wird serverseitig auf NeedsInfo gesetzt). */ + question?: string + /** Antwort der Züchterin (nicht leer ⇒ Status Answered + answeredAt). */ + answer?: string } export function submitFeedback(body: FeedbackInput): Promise { @@ -60,6 +79,11 @@ export function updateFeedback(id: string, body: FeedbackUpdate): Promise(`${RESOURCE}/${id}`, body) } +/** Convenience: submit the breeder's answer to a ticket's clarifying question. */ +export function answerTicket(id: string, answer: string): Promise { + return updateFeedback(id, { answer }) +} + /** Delete a ticket. */ export function deleteFeedback(id: string): Promise { return api.delete(`${RESOURCE}/${id}`) diff --git a/gerbil-manager-web/src/pages/TicketsPage.tsx b/gerbil-manager-web/src/pages/TicketsPage.tsx index 972bfdb..8b07ffa 100644 --- a/gerbil-manager-web/src/pages/TicketsPage.tsx +++ b/gerbil-manager-web/src/pages/TicketsPage.tsx @@ -57,6 +57,12 @@ export default function TicketsPage() { toast.success(t.saved) } + async function handleAnswer(ticket: FeedbackTicket, answer: string) { + await updateFeedback(ticket.id, { answer }) + tickets.reload() + toast.success(t.answeredToast) + } + async function handleDelete(ticket: FeedbackTicket) { if (!window.confirm(t.confirmDelete)) return try { @@ -92,6 +98,7 @@ export default function TicketsPage() { ticket={ticket} onToggleStatus={() => handleToggleStatus(ticket)} onSaveMessage={(message) => handleSaveMessage(ticket, message)} + onAnswer={(answer) => handleAnswer(ticket, answer)} onDelete={() => handleDelete(ticket)} /> ))} @@ -105,15 +112,36 @@ interface TicketCardProps { ticket: FeedbackTicket onToggleStatus: () => void onSaveMessage: (message: string) => Promise + onAnswer: (answer: string) => Promise onDelete: () => void } -function TicketCard({ ticket, onToggleStatus, onSaveMessage, onDelete }: TicketCardProps) { +/** Status-Badge: Beschriftung + Modifier-Klasse je Lebenszyklus-Zustand. */ +function statusBadge(status: FeedbackTicket['status']): { label: string; modifier: string } { + switch (status) { + case 'Resolved': + return { label: `✓ ${t.statusResolved}`, modifier: 'ticket-badge--resolved' } + case 'NeedsInfo': + return { label: t.statusNeedsInfo, modifier: 'ticket-badge--needsinfo' } + case 'Answered': + return { label: t.statusAnswered, modifier: 'ticket-badge--answered' } + default: + return { label: t.statusOpen, modifier: 'ticket-badge--open' } + } +} + +function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete }: TicketCardProps) { const toast = useToast() const [editing, setEditing] = useState(false) const [draft, setDraft] = useState(ticket.message) const [saving, setSaving] = useState(false) + const [answerDraft, setAnswerDraft] = useState('') + const [answering, setAnswering] = useState(false) const resolved = ticket.status === 'Resolved' + const badge = statusBadge(ticket.status) + // Die Züchterin darf antworten, solange eine Rückfrage offen ist und noch keine + // Antwort vorliegt (NeedsInfo, oder eine Rückfrage ohne Antwort). + const canAnswer = !ticket.answer && (ticket.status === 'NeedsInfo' || (!!ticket.question && !resolved)) function startEdit() { setDraft(ticket.message) @@ -137,14 +165,27 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onDelete }: TicketC } } + async function submitAnswer() { + const trimmed = answerDraft.trim() + if (!trimmed) { + toast.error(t.emptyAnswer) + return + } + setAnswering(true) + try { + await onAnswer(trimmed) + setAnswerDraft('') + } catch (err) { + toast.error(err instanceof ApiError ? err.message : t.updateError) + } finally { + setAnswering(false) + } + } + return (
  • - - {resolved ? `✓ ${t.statusResolved}` : t.statusOpen} - + {badge.label} {contextLabel(ticket.context)} {ticket.entityName && {ticket.entityName}}
    @@ -174,6 +215,50 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onDelete }: TicketC ) : ( <>

    {ticket.message}

    + + {ticket.question && ( +
    + {t.questionLabel} +

    {ticket.question}

    +
    + )} + + {ticket.answer ? ( +
    + {t.answerLabel} +

    {ticket.answer}

    + {ticket.answeredAt && ( + + {t.answeredOn}: {formatDate(ticket.answeredAt)} + + )} +
    + ) : canAnswer ? ( +
    + +