From c0a7b1a5eefe223c7fa0f12965dad8fadb749dbd Mon Sep 17 00:00:00 2001 From: Gulum Date: Tue, 23 Jun 2026 11:02:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(tickets):=20Wieder=C3=B6ffnen=E2=86=92R?= =?UTF-8?q?=C3=BCckfrage=20mit=20Zeitstempel=20+=20Soft-Delete-Papierkorb?= =?UTF-8?q?=20+=20Scroll-Erhalt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wiederöffnen eines gelösten Tickets: - landet jetzt in „Rückfragen" (NeedsInfo) statt „Offen". - War es ein echt gelöstes Ticket OHNE Rückfrage, wird um mehr Infos gebeten und „Wieder geöffnet am" (ReopenedAt) gestempelt. - Hatte es bereits eine offene Rückfrage (z. B. manuell gelöste Rückfrage), kehrt es ohne neuen Hinweis und ohne Zeitstempel in genau diese Frage zurück. Löschen über die UI = Soft-Delete: - Tickets wandern in die neue Kategorie „Gelöscht" (Papierkorb) statt entfernt zu werden. - Von dort wiederherstellbar (POST /feedback/{id}/restore → DeletedAt = null). - Countdown „Wird in N Tagen endgültig gelöscht"; nach 30 Tagen endgültige Löschung (lazy beim Abruf in GET /feedback). Außerdem: Scrollposition bleibt beim Zurück-Navigieren (z. B. nach Klick auf einen Tier-Link) erhalten, statt an den Seitenanfang zu springen. EF-Migration FeedbackReopenedAndSoftDelete (ReopenedAt, DeletedAt — beide nullable). Tests: 260 Backend grün (+2 Reopen-Fälle, CRUD auf Soft-Delete umgestellt), e2e Tickets Desktop+Phone grün (+Soft-Delete/Restore/Reopen), vitest 149. Co-Authored-By: Claude Opus 4.8 --- GerbilManager.Tests/FeedbackEndpointTests.cs | 75 +- GerbilManagerWebAPI/Dtos/FeedbackDtos.cs | 6 +- .../Endpoints/FeedbackEndpoints.cs | 52 +- ..._FeedbackReopenedAndSoftDelete.Designer.cs | 1794 +++++++++++++++++ ...623085942_FeedbackReopenedAndSoftDelete.cs | 39 + .../ApplicationContextModelSnapshot.cs | 6 + GerbilManagerWebAPI/Models/Feedback.cs | 15 + gerbil-manager-web/e2e/mock-api.ts | 25 +- gerbil-manager-web/e2e/tickets.spec.ts | 46 +- gerbil-manager-web/src/api/feedback.ts | 17 +- gerbil-manager-web/src/pages/TicketsPage.tsx | 175 +- gerbil-manager-web/src/pages/tickets.css | 12 + gerbil-manager-web/src/strings/de.ts | 18 +- 13 files changed, 2237 insertions(+), 43 deletions(-) create mode 100644 GerbilManagerWebAPI/Migrations/20260623085942_FeedbackReopenedAndSoftDelete.Designer.cs create mode 100644 GerbilManagerWebAPI/Migrations/20260623085942_FeedbackReopenedAndSoftDelete.cs diff --git a/GerbilManager.Tests/FeedbackEndpointTests.cs b/GerbilManager.Tests/FeedbackEndpointTests.cs index 5bce2a0..e958540 100644 --- a/GerbilManager.Tests/FeedbackEndpointTests.cs +++ b/GerbilManager.Tests/FeedbackEndpointTests.cs @@ -100,15 +100,80 @@ public class FeedbackEndpointTests : IClassFixture Assert.Equal("Open", reopened.GetProperty("status").GetString()); Assert.Equal(JsonValueKind.Null, reopened.GetProperty("resolvedAt").ValueKind); - // Delete -> 204, then 404 on subsequent edit/delete + // Soft-Delete -> 204; das Ticket bleibt erhalten (DeletedAt gesetzt) und ist über GET noch da. var del = await client.DeleteAsync($"/feedback/{id}"); Assert.Equal(HttpStatusCode.NoContent, del.StatusCode); - var delAgain = await client.DeleteAsync($"/feedback/{id}"); - Assert.Equal(HttpStatusCode.NotFound, delAgain.StatusCode); + var afterDelete = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement; + var deletedRow = afterDelete.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id); + Assert.NotEqual(JsonValueKind.Null, deletedRow.GetProperty("deletedAt").ValueKind); - var editGone = await client.PutAsJsonAsync($"/feedback/{id}", new { message = "noch da?" }); - Assert.Equal(HttpStatusCode.NotFound, editGone.StatusCode); + // Wiederherstellen -> DeletedAt wieder null, Ticket weiterhin bearbeitbar. + var restore = await client.PostAsync($"/feedback/{id}/restore", null); + Assert.Equal(HttpStatusCode.OK, restore.StatusCode); + var restored = JsonDocument.Parse(await restore.Content.ReadAsStringAsync()).RootElement; + Assert.Equal(JsonValueKind.Null, restored.GetProperty("deletedAt").ValueKind); + + // Delete/Restore eines unbekannten Tickets -> 404. + var delMissing = await client.DeleteAsync($"/feedback/{Guid.NewGuid()}"); + Assert.Equal(HttpStatusCode.NotFound, delMissing.StatusCode); + var restoreMissing = await client.PostAsync($"/feedback/{Guid.NewGuid()}/restore", null); + Assert.Equal(HttpStatusCode.NotFound, restoreMissing.StatusCode); + } + + [Fact] + public async Task Reopen_resolved_ticket_without_Rueckfrage_sets_NeedsInfo_and_ReopenedAt() + { + var client = _factory.CreateClient(); + var create = await client.PostAsJsonAsync("/feedback", new + { + message = "Farbschlag stimmt nicht.", + context = "gerbil-detail", + }); + var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); + + // Direkt (ohne Rückfrage) lösen … + await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved", fixNote = "behoben" }); + + // … und wieder öffnen: Frontend schickt NeedsInfo + Bitte-um-Infos-Frage. + var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new + { + status = "NeedsInfo", + question = "Bitte beschreibe, was noch fehlt.", + }); + var r = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement; + Assert.Equal("NeedsInfo", r.GetProperty("status").GetString()); + Assert.Equal("Bitte beschreibe, was noch fehlt.", r.GetProperty("question").GetString()); + // Echt gelöstes Ticket ohne vorherige Rückfrage -> ReopenedAt gesetzt. + Assert.NotEqual(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind); + } + + [Fact] + public async Task Reopen_resolved_ticket_that_had_a_Rueckfrage_keeps_question_and_no_ReopenedAt() + { + var client = _factory.CreateClient(); + var create = await client.PostAsJsonAsync("/feedback", new + { + message = "Welches Tier ist gemeint?", + context = "gerbil-detail", + }); + var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString(); + + // Rückfrage stellen (NeedsInfo) … + await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Meinst du A oder B?" }); + // … manuell lösen … + await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" }); + // … und wieder öffnen: Frontend schickt dieselbe Frage erneut (keine Bitte-um-Infos). + var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new + { + status = "NeedsInfo", + question = "Meinst du A oder B?", + }); + var r = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement; + Assert.Equal("NeedsInfo", r.GetProperty("status").GetString()); + Assert.Equal("Meinst du A oder B?", r.GetProperty("question").GetString()); + // Hatte bereits eine offene Rückfrage -> KEIN „Wieder geöffnet am"-Zeitstempel. + Assert.Equal(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind); } [Fact] diff --git a/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs b/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs index dc13dd8..bda0b49 100644 --- a/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs +++ b/GerbilManagerWebAPI/Dtos/FeedbackDtos.cs @@ -43,7 +43,11 @@ namespace GerbilManagerWebAPI.Dtos /// INTERNAL agent working memory — exposed for tooling, NEVER shown to the breeder. string? AgentContext, /// Earlier Q&A rounds, oldest first; the current open exchange stays in Question/Answer. - IReadOnlyList Thread); + IReadOnlyList Thread, + /// When a genuinely-resolved ticket (no pending Rückfrage) was reopened; else null. + DateTimeOffset? ReopenedAt = null, + /// Soft-delete marker: when the ticket was deleted via the UI (recoverable); else null. + DateTimeOffset? DeletedAt = null); /// /// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status, diff --git a/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs b/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs index d1b47ca..78042eb 100644 --- a/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs +++ b/GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs @@ -18,6 +18,9 @@ namespace GerbilManagerWebAPI.Endpoints /// public static class FeedbackEndpoints { + /// Aufbewahrungsfrist im Papierkorb: danach werden Tickets endgültig gelöscht. + private const int TrashRetentionDays = 30; + public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/feedback").WithTags("Feedback"); @@ -51,8 +54,21 @@ namespace GerbilManagerWebAPI.Endpoints group.MapGet("/", async (ApplicationContext db) => { - // Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column. - var rows = await db.Feedback.AsNoTracking().ToListAsync(); + // In memory verarbeiten: SQLite (Test-Host) kann weder ORDER BY noch WHERE-Vergleiche + // auf DateTimeOffset-Spalten übersetzen. + var rows = await db.Feedback.ToListAsync(); + + // Aufräumen: soft-gelöschte Tickets werden nach TrashRetentionDays endgültig entfernt + // (lazy beim Abruf — genügt für Single-User, kein Hintergrunddienst nötig). + var cutoff = DateTimeOffset.UtcNow.AddDays(-TrashRetentionDays); + var expired = rows.Where(f => f.DeletedAt is { } d && d < cutoff).ToList(); + if (expired.Count > 0) + { + db.Feedback.RemoveRange(expired); + await db.SaveChangesAsync(); + rows = rows.Except(expired).ToList(); + } + return TypedResults.Ok(rows .OrderByDescending(f => f.CreatedAt) .Select(ToDto) @@ -66,6 +82,10 @@ namespace GerbilManagerWebAPI.Endpoints if (entity is null) return TypedResults.NotFound(); + // Zustand VOR den Mutationen merken — für die Wiederöffnen-Erkennung weiter unten. + var wasResolved = entity.Status.Equals("Resolved", StringComparison.OrdinalIgnoreCase); + var hadOpenRueckfrage = !string.IsNullOrWhiteSpace(entity.Question); + if (input.Message is not null) { if (string.IsNullOrWhiteSpace(input.Message)) @@ -133,6 +153,13 @@ namespace GerbilManagerWebAPI.Endpoints entity.ResolvedAt = resolved ? (entity.ResolvedAt ?? DateTimeOffset.UtcNow) : null; + + // Wiederöffnen-Zeitstempel: nur, wenn ein ECHT gelöstes Ticket (ohne offene + // Rückfrage) wieder geöffnet wird. Tickets, die nur eine offene Rückfrage hatten + // und manuell auf gelöst gesetzt wurden, kehren beim Wiederöffnen einfach in + // ihre Rückfrage zurück und bekommen KEINEN Zeitstempel. + if (!resolved && wasResolved && !hadOpenRueckfrage) + entity.ReopenedAt = DateTimeOffset.UtcNow; } // FIX-NOTE: breeder-friendly changelog (typically set together with status=Resolved). @@ -155,6 +182,9 @@ namespace GerbilManagerWebAPI.Endpoints return TypedResults.Ok(ToDto(entity)); }); + // SOFT-DELETE: das Ticket wird NICHT entfernt, sondern als gelöscht markiert + // (DeletedAt = jetzt) und wandert in die „Gelöscht"-Kategorie. Wiederherstellbar + // über POST /feedback/{id}/restore. group.MapDelete("/{id:guid}", async Task> ( Guid id, ApplicationContext db) => { @@ -162,11 +192,25 @@ namespace GerbilManagerWebAPI.Endpoints if (entity is null) return TypedResults.NotFound(); - db.Feedback.Remove(entity); + entity.DeletedAt ??= DateTimeOffset.UtcNow; await db.SaveChangesAsync(); return TypedResults.NoContent(); }); + // WIEDERHERSTELLEN: hebt das Soft-Delete auf (DeletedAt → null); das Ticket kehrt in + // seinen vorherigen Status (Offen/Rückfrage/…) zurück. + group.MapPost("/{id:guid}/restore", async Task, NotFound>> ( + Guid id, ApplicationContext db) => + { + var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id); + if (entity is null) + return TypedResults.NotFound(); + + entity.DeletedAt = null; + await db.SaveChangesAsync(); + return TypedResults.Ok(ToDto(entity)); + }); + return app; } @@ -220,6 +264,6 @@ namespace GerbilManagerWebAPI.Endpoints 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.Question, f.Answer, f.AnsweredAt, f.FixNote, f.AgentContext, - DeserializeThread(f.Thread)); + DeserializeThread(f.Thread), f.ReopenedAt, f.DeletedAt); } } diff --git a/GerbilManagerWebAPI/Migrations/20260623085942_FeedbackReopenedAndSoftDelete.Designer.cs b/GerbilManagerWebAPI/Migrations/20260623085942_FeedbackReopenedAndSoftDelete.Designer.cs new file mode 100644 index 0000000..151d400 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260623085942_FeedbackReopenedAndSoftDelete.Designer.cs @@ -0,0 +1,1794 @@ +// +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("20260623085942_FeedbackReopenedAndSoftDelete")] + partial class FeedbackReopenedAndSoftDelete + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.AcquisitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("SourceContactId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("AcquisitionRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Block", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("PageId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("PageId"); + + b.ToTable("Blocks"); + + b.HasData( + new + { + Id = new Guid("51720002-0000-0000-0000-000000000001"), + Data = "{\"text\":\"Startseite\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000001"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000002"), + Data = "{\"text\":\"Über die Zucht\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000002"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000003"), + Data = "{\"text\":\"Abgabetiere\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000003"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000004"), + Data = "{\"text\":\"Abgabebedingungen\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000004"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000005"), + Data = "{\"text\":\"Farben & Genetik\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000005"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000006"), + Data = "{\"text\":\"Kontakt\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000006"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000010"), + Data = "{\"mode\":\"auto\",\"intro\":\"\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000003"), + Type = "AbgabetiereList" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000007"), + Data = "{\"text\":\"Impressum\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000007"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000070"), + Data = "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000007"), + Type = "RichText" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000008"), + Data = "{\"text\":\"Datenschutz\",\"level\":1}", + Order = 0, + PageId = new Guid("51720001-0000-0000-0000-000000000008"), + Type = "Heading" + }, + new + { + Id = new Guid("51720002-0000-0000-0000-000000000080"), + Data = "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", + Order = 1, + PageId = new Guid("51720001-0000-0000-0000-000000000008"), + Type = "RichText" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.BreederSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("City") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Homepage") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NameSuffix") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("ZuchtName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("BreederSettings"); + + b.HasData( + new + { + Id = new Guid("11111111-1111-1111-1111-000000000001"), + Address = "", + City = "", + Email = "", + Homepage = "", + Name = "", + NameSuffix = "", + Phone = "", + ZuchtName = "" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.ColorVariety", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CanonicalGenotype") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ColorVarieties"); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + CanonicalGenotype = "AA chch DD EE GG pp spsp rere", + Name = "REW", + SortOrder = 0 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000002"), + CanonicalGenotype = "aa chch DD EE GG PP spsp rere", + Name = "Hermelin", + SortOrder = 1 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000003"), + CanonicalGenotype = "AA chch DD EE GG PP spsp rere", + Name = "Himalaya", + SortOrder = 2 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000004"), + CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere", + Name = "Zobel", + SortOrder = 3 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000005"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Rotaugenschimmel", + SortOrder = 4 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000006"), + CanonicalGenotype = "AA CC DD EE GG PP spsp rere", + Name = "Agouti", + SortOrder = 5 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000007"), + CanonicalGenotype = "aa CC DD EE GG PP spsp rere", + Name = "Schwarz", + SortOrder = 6 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000008"), + CanonicalGenotype = "AA CC DD EE gg PP spsp rere", + Name = "Silberagouti", + SortOrder = 7 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000009"), + CanonicalGenotype = "aa CC DD EE gg PP spsp rere", + Name = "Anthrazit", + SortOrder = 8 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000010"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs", + SortOrder = 9 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000011"), + CanonicalGenotype = "aa CC dd EE GG PP spsp rere", + Name = "Blau", + SortOrder = 10 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000012"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold", + SortOrder = 11 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000013"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin", + SortOrder = 12 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000014"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs", + SortOrder = 13 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000015"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs", + SortOrder = 14 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000016"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Dilute Gold", + SortOrder = 15 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000017"), + CanonicalGenotype = "aa CC dd EE GG pp spsp rere", + Name = "Dilute Platin", + SortOrder = 16 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000018"), + CanonicalGenotype = "aa CC DD EE gg pp spsp rere", + Name = "Altweiss (REW)", + SortOrder = 17 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000019"), + CanonicalGenotype = "AA CC DD ee gg pp spsp rere", + Name = "Apricot (Blassfuchs)", + SortOrder = 18 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000020"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs", + SortOrder = 19 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000021"), + CanonicalGenotype = "aa CC DD ee gg pp spsp rere", + Name = "C-Separator", + SortOrder = 20 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000022"), + CanonicalGenotype = "AA CC DD EE gg pp spsp rere", + Name = "Elfenbein", + SortOrder = 21 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000023"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs", + SortOrder = 22 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000024"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs", + SortOrder = 23 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000025"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Saphir", + SortOrder = 24 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000026"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Orangeschimmel", + SortOrder = 25 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000027"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Topas", + SortOrder = 26 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000028"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin-Hell", + SortOrder = 27 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000029"), + CanonicalGenotype = "AA CC dd EE GG PP spsp rere", + Name = "Dilute Agouti", + SortOrder = 28 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000030"), + CanonicalGenotype = "AA CC dd EE gg PP spsp rere", + Name = "Dilute Silberagouti", + SortOrder = 29 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000031"), + CanonicalGenotype = "aa CC dd ee GG PP spsp rere", + Name = "Dilute Kohlfuchs", + SortOrder = 30 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000032"), + CanonicalGenotype = "aa CC dd EE gg PP spsp rere", + Name = "Dilute Anthrazit", + SortOrder = 31 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000033"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Silberschimmel", + SortOrder = 36 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000034"), + CanonicalGenotype = "AA CC DD efe gg PP spsp rere", + Name = "Polarfuchsschimmel", + SortOrder = 37 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000035"), + CanonicalGenotype = "AA CC DD efe GG PP spsp rere", + Name = "Algierfuchsschimmel", + SortOrder = 38 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000036"), + CanonicalGenotype = "aa CC DD efe GG PP spsp rere", + Name = "Kohlfuchsschimmel", + SortOrder = 39 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000037"), + CanonicalGenotype = "aa CC DD efe gg PP spsp rere", + Name = "Blaufuchsschimmel", + SortOrder = 40 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000038"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs, hell", + SortOrder = 41 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000039"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs, hell", + SortOrder = 42 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000040"), + CanonicalGenotype = "AA CC DD efe GG pp spsp rere", + Name = "Goldfuchsschimmel", + SortOrder = 43 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000041"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold-Hell", + SortOrder = 44 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000042"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs, hell", + SortOrder = 45 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000043"), + CanonicalGenotype = "aa CC DD efe GG pp spsp rere", + Name = "Rotfuchsschimmel", + SortOrder = 46 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000044"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs, hell", + SortOrder = 47 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000045"), + CanonicalGenotype = "aa CC DD efe GG PP spsp rere", + Name = "Kohlfuchsschimmel, hell", + SortOrder = 48 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000046"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs, hell", + SortOrder = 49 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000047"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs-Hell", + SortOrder = 50 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000048"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs, hell", + SortOrder = 51 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000049"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Dilute Topas", + SortOrder = 52 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000050"), + CanonicalGenotype = "aa CC dd ee gg PP spsp rere", + Name = "Dilute Blaufuchs", + SortOrder = 53 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000051"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Marder", + SortOrder = 54 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000052"), + CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere", + Name = "Siam", + SortOrder = 55 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000053"), + CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere", + Name = "Zobel-Hell", + SortOrder = 56 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000054"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "CP-Agouti", + SortOrder = 57 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000055"), + CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere", + Name = "CP-Silberagouti", + SortOrder = 59 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000056"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "CP-Algierfuchs", + SortOrder = 61 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000057"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "CP-Polarfuchs", + SortOrder = 63 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000058"), + CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere", + Name = "CP-Fuchs", + SortOrder = 65 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000059"), + CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere", + Name = "CP-Fuchs-Hell", + SortOrder = 66 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000060"), + CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere", + Name = "CP-Blaufuchs", + SortOrder = 67 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000061"), + CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere", + Name = "CP-Orangeschimmel", + SortOrder = 68 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000062"), + CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere", + Name = "CP-Agouti-Hell", + SortOrder = 58 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000063"), + CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere", + Name = "CP-Silberagouti-Hell", + SortOrder = 60 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000064"), + CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere", + Name = "CP-Algierfuchs-Hell", + SortOrder = 62 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000065"), + CanonicalGenotype = "AA cchmch DD ee gg PP spsp rere", + Name = "CP-Polarfuchs-Hell", + SortOrder = 64 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000066"), + CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere", + Name = "CP-Orangeschimmel-Hell", + SortOrder = 69 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000067"), + CanonicalGenotype = "AA CC dd ee GG PP spsp rere", + Name = "Dilute Algierfuchs", + SortOrder = 32 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000068"), + CanonicalGenotype = "AA CC dd ee GG pp spsp rere", + Name = "Dilute Goldfuchs", + SortOrder = 33 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000069"), + CanonicalGenotype = "aa CC dd ee GG pp spsp rere", + Name = "Dilute Rotfuchs", + SortOrder = 34 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000070"), + CanonicalGenotype = "AA CC dd ee gg PP spsp rere", + Name = "Dilute Polarfuchs", + SortOrder = 35 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("IsBreeder") + .HasColumnType("boolean"); + + b.Property("IsReceiver") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("NameSuffix") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Provenance") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Capacity") + .HasColumnType("integer"); + + b.Property("CleaningCycleDays") + .HasColumnType("integer"); + + b.Property("LastCleanedDate") + .HasColumnType("date"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Size") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Enclosures"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Caption") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnclosureId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EnclosureId"); + + b.ToTable("EnclosurePhotos"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.ExhibitionResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Award") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityName") + .HasColumnType("text"); + + b.Property("EventName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Placement") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("ExhibitionResults"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Feedback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentContext") + .HasColumnType("text"); + + b.Property("Answer") + .HasColumnType("text"); + + b.Property("AnsweredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ClientTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("Context") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityName") + .HasColumnType("text"); + + b.Property("FixNote") + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Question") + .HasColumnType("text"); + + b.Property("ReopenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Thread") + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("UserAgent") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CauseOfDeath") + .HasColumnType("text"); + + b.Property("CharacterNote") + .HasColumnType("text"); + + b.Property("CharacterTraits") + .IsRequired() + .HasColumnType("text"); + + b.Property("ColorVarietyId") + .HasColumnType("uuid"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DateOfDeath") + .HasColumnType("date"); + + b.Property("EnclosureId") + .HasColumnType("uuid"); + + b.Property("ExternalRef") + .HasColumnType("text"); + + b.Property("Gender") + .IsRequired() + .HasColumnType("text"); + + b.Property("Genotype") + .HasColumnType("text"); + + b.Property("GoHomeDate") + .HasColumnType("date"); + + b.Property("ImportSource") + .HasColumnType("text"); + + b.Property("IsCastrated") + .HasColumnType("boolean"); + + b.Property("IsDeaf") + .HasColumnType("boolean"); + + b.Property("IsResident") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("NameSearch") + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OriginBreeder") + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("OriginContactId") + .HasColumnType("uuid"); + + b.Property("Provenance") + .HasColumnType("text"); + + b.Property("RawImportData") + .HasColumnType("text"); + + b.Property("ReceiverContactId") + .HasColumnType("uuid"); + + b.Property("SpottingType") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ColorVarietyId"); + + b.HasIndex("EnclosureId"); + + b.HasIndex("ExternalRef") + .IsUnique() + .HasFilter("\"ExternalRef\" IS NOT NULL"); + + b.HasIndex("LitterId"); + + b.HasIndex("OriginContactId"); + + b.HasIndex("ReceiverContactId"); + + b.ToTable("Gerbils"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Caption") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("GerbilPhotos"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("Veterinarian") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("HealthRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeathsWithin8Weeks") + .HasColumnType("integer"); + + b.Property("ExpectedGoHomeDate") + .HasColumnType("date"); + + b.Property("ExternalRef") + .HasColumnType("text"); + + b.Property("FatherId") + .HasColumnType("uuid"); + + b.Property("LitterLetter") + .HasColumnType("text"); + + b.Property("MotherId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PairingCode") + .HasColumnType("text"); + + b.Property("Provenance") + .HasColumnType("text"); + + b.Property("TotalBorn") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ExternalRef") + .IsUnique() + .HasFilter("\"ExternalRef\" IS NOT NULL"); + + b.HasIndex("FatherId"); + + b.HasIndex("MotherId"); + + b.ToTable("Litters"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.MailSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppPasswordProtected") + .HasColumnType("text"); + + b.Property("BackgroundPollEnabled") + .HasColumnType("boolean"); + + b.Property("Folder") + .IsRequired() + .HasColumnType("text"); + + b.Property("GmailAddress") + .HasColumnType("text"); + + b.Property("LastUid") + .HasColumnType("bigint"); + + b.Property("PollIntervalMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("MailSettings"); + + b.HasData( + new + { + Id = new Guid("ab0c0000-0000-0000-0000-000000000001"), + BackgroundPollEnabled = false, + Folder = "INBOX", + LastUid = 0L, + PollIntervalMinutes = 15 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Media", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Alt") + .HasColumnType("text"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Media"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Page", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("SeoDescription") + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Pages"); + + b.HasData( + new + { + Id = new Guid("51720001-0000-0000-0000-000000000001"), + Slug = "start", + Status = "Published", + Title = "Startseite" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000002"), + Slug = "ueber-die-zucht", + Status = "Published", + Title = "Über die Zucht" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000003"), + Slug = "abgabetiere", + Status = "Published", + Title = "Abgabetiere" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000004"), + Slug = "abgabebedingungen", + Status = "Published", + Title = "Abgabebedingungen" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000005"), + Slug = "farben-genetik", + Status = "Published", + Title = "Farben & Genetik" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000006"), + Slug = "kontakt", + Status = "Published", + Title = "Kontakt" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000007"), + Slug = "impressum", + Status = "Published", + Title = "Impressum" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000008"), + Slug = "datenschutz", + Status = "Published", + Title = "Datenschutz" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Request", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnsweredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedContactId") + .HasColumnType("uuid"); + + b.Property("BodyText") + .HasColumnType("text"); + + b.Property("DraftReply") + .HasColumnType("text"); + + b.Property("FromAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("FromName") + .HasColumnType("text"); + + b.Property("GmailMessageId") + .IsRequired() + .HasColumnType("text"); + + b.Property("InReplyToMessageId") + .HasColumnType("text"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferencesHeader") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .HasColumnType("text"); + + b.Property("ThreadId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AssignedContactId"); + + b.HasIndex("GmailMessageId") + .IsUnique(); + + b.ToTable("Requests"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.ReturnRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromContactId") + .HasColumnType("uuid"); + + b.Property("FromContactName") + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("GerbilName") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OriginalPrice") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("OriginalSaleDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReturnDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReturnPrice") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("GerbilId"); + + b.ToTable("ReturnRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("ContractDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("HandoverDate") + .HasColumnType("date"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("SaleContracts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b => + { + b.Property("SaleContractId") + .HasColumnType("uuid"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("PhotoId") + .HasColumnType("uuid"); + + b.HasKey("SaleContractId", "GerbilId"); + + b.HasIndex("GerbilId"); + + b.ToTable("SaleContractAnimal"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleReservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppointmentDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ContactName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("GerbilName") + .HasColumnType("text"); + + b.Property("HandedOverDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("ReservedForContactId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("SaleReservations"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultLocale") + .IsRequired() + .HasColumnType("text"); + + b.Property("NavOrder") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Sites"); + + b.HasData( + new + { + Id = new Guid("5172e000-0000-0000-0000-000000000001"), + DefaultLocale = "de", + NavOrder = "[\"51720001-0000-0000-0000-000000000001\",\"51720001-0000-0000-0000-000000000002\",\"51720001-0000-0000-0000-000000000003\",\"51720001-0000-0000-0000-000000000004\",\"51720001-0000-0000-0000-000000000005\",\"51720001-0000-0000-0000-000000000006\"]" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.WaitingListEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("ContactName") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("WishColor") + .HasColumnType("text"); + + b.Property("WishGender") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("WaitingListEntries"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.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/20260623085942_FeedbackReopenedAndSoftDelete.cs b/GerbilManagerWebAPI/Migrations/20260623085942_FeedbackReopenedAndSoftDelete.cs new file mode 100644 index 0000000..b397fcf --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260623085942_FeedbackReopenedAndSoftDelete.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class FeedbackReopenedAndSoftDelete : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "Feedback", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "ReopenedAt", + table: "Feedback", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "Feedback"); + + migrationBuilder.DropColumn( + name: "ReopenedAt", + table: "Feedback"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index 9d60eca..ea3976e 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -906,6 +906,9 @@ namespace GerbilManagerWebAPI.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + b.Property("EntityName") .HasColumnType("text"); @@ -925,6 +928,9 @@ namespace GerbilManagerWebAPI.Migrations b.Property("Question") .HasColumnType("text"); + b.Property("ReopenedAt") + .HasColumnType("timestamp with time zone"); + b.Property("ResolvedAt") .HasColumnType("timestamp with time zone"); diff --git a/GerbilManagerWebAPI/Models/Feedback.cs b/GerbilManagerWebAPI/Models/Feedback.cs index 2948801..43c5428 100644 --- a/GerbilManagerWebAPI/Models/Feedback.cs +++ b/GerbilManagerWebAPI/Models/Feedback.cs @@ -55,6 +55,21 @@ namespace GerbilManagerWebAPI.Models /// When the ticket was marked resolved; null while open. public DateTimeOffset? ResolvedAt { get; set; } + /// + /// When the ticket was reopened FROM a resolved state that had no pending Rückfrage — + /// i.e. a genuinely-fixed ticket the breeder wants reworked. Stays null for tickets that + /// merely had an open question, got marked resolved, and were reopened (those just return + /// to their existing Rückfrage). Shown as "Wieder geöffnet am" in the UI. + /// + public DateTimeOffset? ReopenedAt { get; set; } + + /// + /// SOFT-DELETE: when the breeder deleted the ticket via the UI. Soft-deleted tickets are + /// NOT removed from the DB — they move into the "Gelöscht" category and can be restored + /// (DeletedAt → null) from there. null = not deleted. + /// + public DateTimeOffset? DeletedAt { get; set; } + /// A clarifying question (Rückfrage) a maintainer attaches to the ticket; null if none. public string? Question { get; set; } diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index 8d237ac..33ce97b 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -431,6 +431,8 @@ export async function installMockApi(page: Page): Promise { fixNote: null, agentContext: null, thread: [], + reopenedAt: null, + deletedAt: null, } db.feedback.push(created) return json(route, 201, created) @@ -440,6 +442,18 @@ export async function installMockApi(page: Page): Promise { } return json(route, 405) } + // FEEDBACK-TICKETS: Wiederherstellen aus dem Papierkorb (Soft-Delete aufheben). + const restoreMatch = path.match(/^\/feedback\/([^/]+)\/restore$/) + if (restoreMatch) { + const fid = decodeURIComponent(restoreMatch[1]) + const row = db.feedback.find((f) => f.id === fid) + if (!row) return json(route, 404, { title: 'Not Found' }) + if (method === 'POST') { + row.deletedAt = null + return json(route, 200, row) + } + return json(route, 405) + } // FEEDBACK-TICKETS: "Meine Tickets" — PUT (Nachricht/Status) und DELETE. const fm = path.match(/^\/feedback\/([^/]+)$/) if (fm) { @@ -456,6 +470,9 @@ export async function installMockApi(page: Page): Promise { agentContext?: string } const row = db.feedback[idx] + // Zustand vor den Mutationen (für die Wiederöffnen-Erkennung). + const wasResolved = row.status === 'Resolved' + const hadOpenRueckfrage = typeof row.question === 'string' && !!(row.question as string).trim() if (typeof body.message === 'string') { if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.') row.message = body.message.trim() @@ -504,6 +521,10 @@ export async function installMockApi(page: Page): Promise { ? s : 'Open' row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null + // „Wieder geöffnet am" nur, wenn ein echt gelöstes Ticket OHNE offene Rückfrage + // wieder geöffnet wird (s. Backend). + if (!resolved && wasResolved && !hadOpenRueckfrage) + row.reopenedAt = new Date().toISOString() } // Changelog (fixNote) — laienverständlich, sichtbar. if (typeof body.fixNote === 'string') { @@ -518,7 +539,9 @@ export async function installMockApi(page: Page): Promise { return json(route, 200, row) } if (method === 'DELETE') { - db.feedback.splice(idx, 1) + // Soft-Delete: in den Papierkorb verschieben (nicht entfernen). + const target = db.feedback[idx] + if (!target.deletedAt) target.deletedAt = new Date().toISOString() return json(route, 204) } return json(route, 405) diff --git a/gerbil-manager-web/e2e/tickets.spec.ts b/gerbil-manager-web/e2e/tickets.spec.ts index b166a1f..170aebc 100644 --- a/gerbil-manager-web/e2e/tickets.spec.ts +++ b/gerbil-manager-web/e2e/tickets.spec.ts @@ -188,7 +188,9 @@ test.describe('Meine Tickets', () => { await expect(card.locator('.ticket-card__answer-form textarea')).toBeVisible() }) - test('Ticket löschen', async ({ page }) => { + test('Ticket löschen = Soft-Delete: wandert in den Papierkorb, mit Countdown + Wiederherstellen', async ({ + page, + }) => { await page.goto('/hilfe/tickets') await selectView(page, tt.filters.closed) @@ -200,8 +202,50 @@ test.describe('Meine Tickets', () => { acceptNextDialog(page) await card.getByRole('button', { name: tt.delete }).click() + // Aus „Geschlossen" verschwunden … await expect( page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'), ).toHaveCount(0) + + // … aber im Papierkorb („Gelöscht") vorhanden, mit Countdown + Wiederherstellen-Button. + await selectView(page, tt.filters.deleted) + const trashed = page + .locator('.ticket-card') + .filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' }) + await expect(trashed).toBeVisible() + await expect(trashed.locator('.ticket-card__trash-notice')).toBeVisible() + await expect(trashed.getByText('endgültig gelöscht')).toBeVisible() + + // Wiederherstellen → zurück aus dem Papierkorb (kehrt in „Geschlossen" zurück). + await trashed.getByRole('button', { name: tt.restore }).click() + await expect( + page.locator('.ticket-card').filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' }), + ).toHaveCount(0) + await selectView(page, tt.filters.closed) + await expect( + page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'), + ).toBeVisible() + }) + + test('Wiederöffnen eines gelösten Tickets ohne Rückfrage → Rückfragen + Bitte um Infos + Zeitstempel', async ({ + page, + }) => { + await page.goto('/hilfe/tickets') + await selectView(page, tt.filters.closed) + + // „Krümel"-Ticket ist Resolved und hatte KEINE Rückfrage (question === null). + const card = page + .locator('.ticket-card') + .filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' }) + await card.getByRole('button', { name: tt.reopen }).click() + + // Landet in den „Rückfragen" mit der Bitte um mehr Infos + „Wieder geöffnet am". + await selectView(page, tt.filters.dialog) + const reopened = page + .locator('.ticket-card') + .filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' }) + await expect(reopened.locator('.ticket-badge--needsinfo')).toBeVisible() + await expect(reopened.getByText(tt.reopenRequest)).toBeVisible() + await expect(reopened.getByText(tt.reopenedLabel)).toBeVisible() }) }) diff --git a/gerbil-manager-web/src/api/feedback.ts b/gerbil-manager-web/src/api/feedback.ts index 626025e..ef3b9e4 100644 --- a/gerbil-manager-web/src/api/feedback.ts +++ b/gerbil-manager-web/src/api/feedback.ts @@ -50,6 +50,16 @@ export interface Feedback { createdAt: string status: FeedbackStatus resolvedAt: string | null + /** + * Zeitpunkt des Wiederöffnens — gesetzt, wenn ein ECHT gelöstes Ticket (ohne offene + * Rückfrage) wieder geöffnet wurde; null sonst. Wird als „Wieder geöffnet am" angezeigt. + */ + reopenedAt: string | null + /** + * Soft-Delete-Marker: gesetzt, wenn das Ticket über die UI gelöscht wurde (wiederherstellbar). + * Solche Tickets erscheinen nur in der „Gelöscht"-Kategorie. null = nicht gelöscht. + */ + deletedAt: 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). */ @@ -106,7 +116,12 @@ export function answerTicket(id: string, answer: string): Promise { return updateFeedback(id, { answer }) } -/** Delete a ticket. */ +/** Soft-delete a ticket (moves it into the "Gelöscht" category; recoverable via restore). */ export function deleteFeedback(id: string): Promise { return api.delete(`${RESOURCE}/${id}`) } + +/** Restore a soft-deleted ticket (DeletedAt → null); it returns to its prior status. */ +export function restoreFeedback(id: string): Promise { + return api.post(`${RESOURCE}/${id}/restore`, {}) +} diff --git a/gerbil-manager-web/src/pages/TicketsPage.tsx b/gerbil-manager-web/src/pages/TicketsPage.tsx index 9959791..9e2e7c5 100644 --- a/gerbil-manager-web/src/pages/TicketsPage.tsx +++ b/gerbil-manager-web/src/pages/TicketsPage.tsx @@ -11,7 +11,7 @@ * Rückfragen kommen hierher zurück), „Geschlossen" (Resolved, zeigt den Changelog/fixNote). * Der Frage/Antwort-Verlauf (thread) wird angezeigt; das interne `agentContext` NIEMALS. */ -import { createContext, Fragment, useContext, useEffect, useMemo, useState, type ReactNode } from 'react' +import { createContext, Fragment, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { de } from '../strings/de' import { ApiError } from '../api/client' @@ -19,6 +19,7 @@ import { listFeedback, updateFeedback, deleteFeedback, + restoreFeedback, type FeedbackTicket, } from '../api/feedback' import { getGerbil } from '../api/gerbils' @@ -227,19 +228,24 @@ function formatDate(iso: string | null): string { } /** Gefilterte Ansichten (Filter-Chips). */ -type TicketView = 'dialog' | 'open' | 'closed' +type TicketView = 'dialog' | 'open' | 'closed' | 'deleted' + +/** Auto-Fallback-Reihenfolge, wenn die gewünschte Ansicht leer ist — „Gelöscht" NICHT (Papierkorb). */ +const FALLBACK_VIEWS: TicketView[] = ['open', 'dialog', 'closed'] /** - * Zuordnung Ticket-Status → gefilterte Ansicht. + * Zuordnung Ticket → gefilterte Ansicht. + * - „Gelöscht" (deleted): soft-gelöscht (deletedAt gesetzt) — hat Vorrang vor dem Status. * - „Rückfragen" (dialog): NUR NeedsInfo — wartet auf die Antwort der Züchterin. * - „Offen" (open): Open + Answered — von der KI zu erledigen. Sobald die Züchterin * geantwortet hat (Answered), ist SIE fertig → das Ticket wandert zurück nach „Offen" * (mit „Beantwortet"-Badge), statt in ihrer Rückfragen-Liste zu verbleiben. * - „Geschlossen" (closed): Resolved. */ -function viewOf(status: FeedbackTicket['status']): TicketView { - if (status === 'Resolved') return 'closed' - if (status === 'NeedsInfo') return 'dialog' +function viewOf(ticket: Pick): TicketView { + if (ticket.deletedAt) return 'deleted' + if (ticket.status === 'Resolved') return 'closed' + if (ticket.status === 'NeedsInfo') return 'dialog' return 'open' } @@ -251,13 +257,30 @@ export default function TicketsPage() { // selbst gewählt hat (userPicked), darf ein ?focus=-Sprung die Ansicht bestimmen. const [view, setView] = useState('open') const [userPicked, setUserPicked] = useState(false) + // Scrollposition über das Verlassen/Zurückkehren (z. B. Klick auf einen Tier-Link und dann + // Browser-Zurück) hinweg erhalten. Erst NACH dem Wiederherstellen wieder mitschreiben, damit + // der Sprung an den Seitenanfang beim Re-Mount die gemerkte Position nicht überschreibt. + const scrollRestoredRef = useRef(false) async function handleToggleStatus(ticket: FeedbackTicket) { - const next = ticket.status === 'Resolved' ? 'Open' : 'Resolved' try { - await updateFeedback(ticket.id, { status: next }) - tickets.reload() - toast.success(next === 'Resolved' ? t.resolvedToast : t.reopenedToast) + if (ticket.status === 'Resolved') { + // Wiederöffnen → zurück in die „Rückfragen" (NeedsInfo). Hatte das Ticket bereits eine + // offene Rückfrage (z. B. eine manuell gelöste Rückfrage), wird genau diese Frage wieder + // geöffnet (kein neuer Hinweis, kein Zeitstempel). War es dagegen ein echt gelöstes Ticket + // ohne Rückfrage, wird um mehr Infos gebeten — und das Backend stempelt „Wieder geöffnet am". + const hadRueckfrage = !!ticket.question && ticket.question.trim().length > 0 + await updateFeedback(ticket.id, { + status: 'NeedsInfo', + question: hadRueckfrage ? ticket.question! : t.reopenRequest, + }) + tickets.reload() + toast.success(t.reopenedToast) + } else { + await updateFeedback(ticket.id, { status: 'Resolved' }) + tickets.reload() + toast.success(t.resolvedToast) + } } catch (err) { toast.error(err instanceof ApiError ? err.message : t.updateError) } @@ -286,6 +309,16 @@ export default function TicketsPage() { } } + async function handleRestore(ticket: FeedbackTicket) { + try { + await restoreFeedback(ticket.id) + tickets.reload() + toast.success(t.restoredToast) + } catch (err) { + toast.error(err instanceof ApiError ? err.message : t.restoreError) + } + } + const rows = tickets.data // Verlinkung von Ticket zu Ticket: /hilfe/tickets?focus= wechselt in die passende @@ -300,8 +333,8 @@ export default function TicketsPage() { // Gruppierung + Zählung je gefilterter Ansicht (für die Badges an den Filter-Chips). const counts = useMemo(() => { - const c: Record = { dialog: 0, open: 0, closed: 0 } - for (const ticket of rows ?? []) c[viewOf(ticket.status)] += 1 + const c: Record = { dialog: 0, open: 0, closed: 0, deleted: 0 } + for (const ticket of rows ?? []) c[viewOf(ticket)] += 1 return c }, [rows]) @@ -309,7 +342,7 @@ export default function TicketsPage() { const focusView = useMemo(() => { if (!focusId || !rows) return null const target = rows.find((r) => r.id === focusId) - return target ? viewOf(target.status) : null + return target ? viewOf(target) : null }, [focusId, rows]) // Sichtbare Ansicht: Fokus-Sprung hat Vorrang (bis die Nutzerin selbst filtert), @@ -318,9 +351,9 @@ export default function TicketsPage() { const activeView: TicketView = counts[desiredView] > 0 ? desiredView - : (['open', 'dialog', 'closed'] as TicketView[]).find((v) => counts[v] > 0) ?? desiredView + : FALLBACK_VIEWS.find((v) => counts[v] > 0) ?? desiredView - const visible = (rows ?? []).filter((ticket) => viewOf(ticket.status) === activeView) + const visible = (rows ?? []).filter((ticket) => viewOf(ticket) === activeView) // Nach dem Rendern der sichtbaren Liste zum fokussierten Ticket scrollen + kurz hervorheben. useEffect(() => { @@ -333,6 +366,41 @@ export default function TicketsPage() { return () => clearTimeout(timer) }, [focusId, visible]) + // Scrollposition laufend in der Session merken (für die Zurück-Navigation). + const SCROLL_KEY = 'tickets-scroll-y' + useEffect(() => { + const onScroll = () => { + if (!scrollRestoredRef.current) return + try { + sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)) + } catch { + /* sessionStorage nicht verfügbar — Position dann nicht gemerkt */ + } + } + window.addEventListener('scroll', onScroll, { passive: true }) + return () => window.removeEventListener('scroll', onScroll) + }, []) + + // Nach dem Laden der Tickets die gemerkte Scrollposition EINMALIG wiederherstellen — außer es + // steht ein ?focus=-Sprung an (der hat Vorrang). Erst danach beginnt das Mitschreiben (s. o.). + useEffect(() => { + if (scrollRestoredRef.current || rows === null) return + if (focusId) { + scrollRestoredRef.current = true + return + } + let y = NaN + try { + y = Number(sessionStorage.getItem(SCROLL_KEY)) + } catch { + /* ignorieren */ + } + scrollRestoredRef.current = true + if (Number.isFinite(y) && y > 0) { + requestAnimationFrame(() => window.scrollTo(0, y)) + } + }, [rows, focusId]) + // Nackte Tier-IDs in den sichtbaren Tickets sammeln und ihre Namen einmalig auflösen, // damit die KI im Text einfach eine Tier-ID hinterlegen kann (→ Name + Link zur Akte). const candidateGerbilIds = useMemo(() => { @@ -418,6 +486,7 @@ export default function TicketsPage() { dialog: t.emptyDialog, open: t.emptyOpen, closed: t.emptyClosed, + deleted: t.emptyDeleted, } return ( @@ -437,7 +506,7 @@ export default function TicketsPage() { ) : ( <>
- {(['dialog', 'open', 'closed'] as TicketView[]).map((v) => ( + {(['dialog', 'open', 'closed', 'deleted'] as TicketView[]).map((v) => (
)} + {deleted && trashDaysLeft !== null && ( +

+ {trashDaysLeft === 0 + ? t.trashCountdownToday + : trashDaysLeft === 1 + ? t.trashCountdownTomorrow + : t.trashCountdownDays.replace('{n}', String(trashDaysLeft))} +

+ )} +
{t.createdLabel}
@@ -736,19 +835,39 @@ function TicketCard({ ticket, onToggleStatus, onSaveMessage, onAnswer, onDelete
{formatDate(ticket.resolvedAt)}
)} + {ticket.reopenedAt && ( +
+
{t.reopenedLabel}
+
{formatDate(ticket.reopenedAt)}
+
+ )} + {deleted && ( +
+
{t.deletedLabel}
+
{formatDate(ticket.deletedAt)}
+
+ )}
- - {canEditMessage && ( - + ) : ( + <> + + {canEditMessage && ( + + )} + + )} -
)} diff --git a/gerbil-manager-web/src/pages/tickets.css b/gerbil-manager-web/src/pages/tickets.css index e5c4f51..8415840 100644 --- a/gerbil-manager-web/src/pages/tickets.css +++ b/gerbil-manager-web/src/pages/tickets.css @@ -311,6 +311,18 @@ word-break: break-word; } +/* Papierkorb-Countdown: Hinweis, wann das Ticket endgültig gelöscht wird. */ +.ticket-card__trash-notice { + border-left: 3px solid var(--color-danger, #ef4444); + background: var(--color-danger-bg, #fef2f2); + color: var(--color-danger-text, #991b1b); + padding: 0.55rem 0.75rem; + border-radius: 0 8px 8px 0; + margin: 0 0 0.85rem; + font-size: 0.9rem; + font-weight: 600; +} + .ticket-card__meta { display: flex; flex-wrap: wrap; diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index a88ba1b..75be3d8 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -1221,20 +1221,31 @@ export const de = { entityLabel: 'Datensatz', createdLabel: 'Gesendet am', resolvedLabel: 'Gelöst am', + reopenedLabel: 'Wieder geöffnet am', markResolved: 'Als gelöst markieren', reopen: 'Wieder öffnen', + reopenRequest: + 'Du hast dieses Ticket wieder geöffnet. Bitte beschreibe kurz, was noch fehlt oder nicht stimmt — dann schaue ich es mir erneut an.', edit: 'Bearbeiten', save: 'Speichern', cancel: 'Abbrechen', saving: 'Wird gespeichert …', delete: 'Löschen', - confirmDelete: 'Diesen Fehlerbericht wirklich löschen?', + confirmDelete: 'Dieses Ticket in den Papierkorb verschieben? Du kannst es unter „Gelöscht" wiederherstellen.', editTitle: 'Fehlerbericht bearbeiten', editLabel: 'Beschreibung', saved: 'Änderung gespeichert.', resolvedToast: 'Ticket als gelöst markiert.', reopenedToast: 'Ticket wieder geöffnet.', - deletedToast: 'Ticket gelöscht.', + deletedToast: 'Ticket in den Papierkorb verschoben.', + restore: 'Wiederherstellen', + restoredToast: 'Ticket wiederhergestellt.', + restoreError: 'Ticket konnte nicht wiederhergestellt werden.', + /** Countdown im Papierkorb bis zur endgültigen Löschung. {n} = verbleibende Tage. */ + trashCountdownDays: 'Wird in {n} Tagen endgültig gelöscht.', + trashCountdownTomorrow: 'Wird morgen endgültig gelöscht.', + trashCountdownToday: 'Wird heute endgültig gelöscht.', + deletedLabel: 'Gelöscht am', updateError: 'Ticket konnte nicht aktualisiert werden.', deleteError: 'Ticket konnte nicht gelöscht werden.', emptyMessage: 'Bitte beschreibe den Fehler.', @@ -1257,11 +1268,14 @@ export const de = { open: 'Offen', /** Gruppe „Geschlossen": erledigte Tickets mit Changelog. */ closed: 'Geschlossen', + /** Gruppe „Gelöscht": in den Papierkorb verschobene Tickets (wiederherstellbar). */ + deleted: 'Gelöscht', }, /** Leertext je gefilterter Ansicht. */ emptyDialog: 'Aktuell keine Tickets im Dialog.', emptyOpen: 'Keine offenen Tickets.', emptyClosed: 'Noch keine geschlossenen Tickets.', + emptyDeleted: 'Der Papierkorb ist leer.', /** Changelog (fixNote) auf geschlossenen Tickets. */ changelogLabel: 'Was wurde geändert', /** Überschrift des Frage/Antwort-Verlaufs (frühere Runden). */