From a638cf2c5244461aac7d8dfc764f430c89365c78 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 20:55:28 +0200 Subject: [PATCH] FEAT-NAMEGEN: GET /names/suggest backend (Gemini, 503 NamesKeyMissing, LitterLetter) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NameSuggestionService: Gemini via existing OpenAiChatClient, builds letter/gender/usages/count prompt, strips markdown fences from response, deserialises [{name,meaning,origin}] array; NotConfigured -> 503. - GET /names/suggest?letter=&gender=&usages=&count= -> Ok> | 503 {code:NamesKeyMissing} | 502 {code:NamesUpstreamError}. - Litter.LitterLetter (string?, nullable) + AddLitterLetter migration. - 17 new tests (prompt assembly, fence strip, parse edge cases, 503-not-configured, upstream-error); total 157/157 green. - No Behind-the-Name dependency — Gemini path only (Julian's decision). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- GerbilManager.Tests/NameSuggestionTests.cs | 208 +++ .../Endpoints/NamesEndpoints.cs | 38 + ...20260606185411_AddLitterLetter.Designer.cs | 1402 +++++++++++++++++ .../20260606185411_AddLitterLetter.cs | 28 + .../ApplicationContextModelSnapshot.cs | 3 + GerbilManagerWebAPI/Models/Litter.cs | 4 + GerbilManagerWebAPI/Names/NameSuggestion.cs | 5 + .../Names/NameSuggestionService.cs | 107 ++ GerbilManagerWebAPI/Program.cs | 4 + 9 files changed, 1799 insertions(+) create mode 100644 GerbilManager.Tests/NameSuggestionTests.cs create mode 100644 GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs create mode 100644 GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs create mode 100644 GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.cs create mode 100644 GerbilManagerWebAPI/Names/NameSuggestion.cs create mode 100644 GerbilManagerWebAPI/Names/NameSuggestionService.cs diff --git a/GerbilManager.Tests/NameSuggestionTests.cs b/GerbilManager.Tests/NameSuggestionTests.cs new file mode 100644 index 0000000..af75c53 --- /dev/null +++ b/GerbilManager.Tests/NameSuggestionTests.cs @@ -0,0 +1,208 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using GerbilManagerWebAPI.Names; +using GerbilManagerWebAPI.SaleAd; +using Microsoft.Extensions.Options; + +namespace GerbilManager.Tests +{ + /// + /// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown + /// fence strip), 503-not-configured path, upstream-error path. + /// + public class NameSuggestionTests + { + // ── Prompt assembly ─────────────────────────────────────────────────── + + [Fact] + public void SystemPrompt_verlangt_reines_JSON_ohne_Erklärungen() + { + var prompt = NameSuggestionService.BuildSystemPrompt(); + Assert.Contains("reinen JSON-Array", prompt); + Assert.Contains("KEINE Markdown-Code-Blöcke", prompt); + Assert.Contains("name", prompt); + Assert.Contains("meaning", prompt); + Assert.Contains("origin", prompt); + } + + [Fact] + public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben() + { + var prompt = NameSuggestionService.BuildUserPrompt("A", "female", "norn,mythg", 6); + Assert.Contains("6", prompt); + Assert.Contains("\"A\"", prompt); + Assert.Contains("weibliche", prompt); + Assert.Contains("norn,mythg", prompt); + } + + [Fact] + public void UserPrompt_ohne_optionale_Parameter_ist_gültig() + { + var prompt = NameSuggestionService.BuildUserPrompt(null, null, null, 5); + Assert.Contains("5", prompt); + Assert.DoesNotContain("Buchstaben", prompt); + Assert.DoesNotContain("weibliche", prompt); + Assert.DoesNotContain("männliche", prompt); + } + + [Fact] + public void UserPrompt_gender_any_wird_nicht_im_Prompt_erwähnt() + { + var prompt = NameSuggestionService.BuildUserPrompt(null, "any", null, 3); + Assert.DoesNotContain("weibliche", prompt); + Assert.DoesNotContain("männliche", prompt); + } + + // ── JSON parsing ────────────────────────────────────────────────────── + + [Fact] + public void ParseSuggestions_verarbeitet_reines_JSON() + { + var json = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"}]"""; + var result = NameSuggestionService.ParseSuggestions(json); + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("Astrid", result[0].Name); + Assert.Equal("göttliche Stärke", result[0].Meaning); + Assert.Equal("Altnordisch", result[0].Origin); + } + + [Fact] + public void ParseSuggestions_strippt_json_Markdown_Fence() + { + var fenced = "```json\n[{\"name\":\"Aiko\",\"meaning\":\"kleine Geliebte\",\"origin\":\"Japanisch\"}]\n```"; + var result = NameSuggestionService.ParseSuggestions(fenced); + Assert.NotNull(result); + Assert.Equal("Aiko", result![0].Name); + } + + [Fact] + public void ParseSuggestions_strippt_generische_Markdown_Fence() + { + var fenced = "```\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```"; + var result = NameSuggestionService.ParseSuggestions(fenced); + Assert.NotNull(result); + Assert.Equal("Luna", result![0].Name); + } + + [Fact] + public void ParseSuggestions_toleriert_umgebenden_Text_vor_Array() + { + var messy = "Hier sind die Namen:\n[{\"name\":\"Sol\",\"meaning\":\"Sonne\",\"origin\":\"Nordisch\"}]\nHoffnungslos."; + var result = NameSuggestionService.ParseSuggestions(messy); + Assert.NotNull(result); + Assert.Equal("Sol", result![0].Name); + } + + [Fact] + public void ParseSuggestions_gibt_null_zurück_bei_ungültigem_JSON() + { + Assert.Null(NameSuggestionService.ParseSuggestions("kein json")); + Assert.Null(NameSuggestionService.ParseSuggestions("{\"name\":\"X\"}")); + Assert.Null(NameSuggestionService.ParseSuggestions("")); + } + + // ── 503: nicht konfiguriert ─────────────────────────────────────────── + + [Theory] + [InlineData(null, "key", "model")] + [InlineData("https://api.example.com/v1", null, "model")] + [InlineData("https://api.example.com/v1", "key", null)] + [InlineData(null, null, null)] + public async Task SuggestAsync_gibt_NotConfigured_wenn_AI_Key_fehlt( + string? baseUrl, string? apiKey, string? model) + { + var service = CreateService(baseUrl, apiKey, model, + new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden"))); + + var result = await service.SuggestAsync(null, null, null, 5); + + Assert.Equal(NameSuggestionStatus.NotConfigured, result.Status); + Assert.Null(result.Suggestions); + } + + // ── Gemini-Antwort wird geparst ─────────────────────────────────────── + + [Fact] + public async Task SuggestAsync_parst_valide_JSON_Antwort() + { + var payload = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"},{"name":"Aiko","meaning":"kleine Geliebte","origin":"Japanisch"}]"""; + var handler = new StubHandler(_ => Canned(payload)); + var service = CreateService("https://generativelanguage.googleapis.com/v1beta/openai", "k", "gemini-2.0-flash", handler); + + var result = await service.SuggestAsync("A", "female", "norn,japa", 2); + + Assert.Equal(NameSuggestionStatus.Ok, result.Status); + Assert.NotNull(result.Suggestions); + Assert.Equal(2, result.Suggestions!.Count); + Assert.Equal("Astrid", result.Suggestions[0].Name); + } + + [Fact] + public async Task SuggestAsync_parst_JSON_in_Markdown_Fence() + { + var fenced = "```json\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```"; + var handler = new StubHandler(_ => Canned(fenced)); + var service = CreateService("https://api.example.com/v1", "k", "m", handler); + + var result = await service.SuggestAsync(null, null, null, 1); + + Assert.Equal(NameSuggestionStatus.Ok, result.Status); + Assert.Equal("Luna", result.Suggestions![0].Name); + } + + [Fact] + public async Task SuggestAsync_gibt_UpstreamError_bei_ungültigem_JSON() + { + var handler = new StubHandler(_ => Canned("das ist kein json")); + var service = CreateService("https://api.example.com/v1", "k", "m", handler); + + var result = await service.SuggestAsync(null, null, null, 3); + + Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status); + Assert.Null(result.Suggestions); + } + + [Fact] + public async Task SuggestAsync_gibt_UpstreamError_bei_HTTP_Fehler() + { + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests)); + var service = CreateService("https://api.example.com/v1", "k", "m", handler); + + var result = await service.SuggestAsync(null, null, null, 3); + + Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status); + } + + // ── Helfer ──────────────────────────────────────────────────────────── + + private static NameSuggestionService CreateService( + string? baseUrl, string? apiKey, string? model, StubHandler handler) + { + var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model }); + return new NameSuggestionService(new HttpClient(handler), options); + } + + private static HttpResponseMessage Canned(string content) + { + var completion = new + { + choices = new[] { new { message = new { role = "assistant", content } } }, + }; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(completion), + Encoding.UTF8, "application/json"), + }; + } + + private sealed class StubHandler(Func respond) + : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(respond(request)); + } + } +} diff --git a/GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs b/GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs new file mode 100644 index 0000000..8d9a88b --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs @@ -0,0 +1,38 @@ +using GerbilManagerWebAPI.Names; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// FEAT-NAMEGEN: GET /names/suggest — meaningful gerbil name suggestions via Gemini. + /// + /// 503 {code:"NamesKeyMissing"} while the AI section is unconfigured — the + /// frontend maps exactly this code to its German disabled-state hint. + /// + public static class NamesEndpoints + { + public static IEndpointRouteBuilder MapNamesEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/names/suggest", async ( + string? letter, + string? gender, + string? usages, + int count, + NameSuggestionService service, + CancellationToken ct) => + { + var result = await service.SuggestAsync(letter, gender, usages, count, ct); + return result.Status switch + { + NameSuggestionStatus.Ok => Results.Ok(result.Suggestions), + NameSuggestionStatus.NotConfigured => Results.Json( + new { code = "NamesKeyMissing", message = result.Error ?? "" }, statusCode: 503), + _ => Results.Json( + new { code = "NamesUpstreamError", message = result.Error ?? "" }, statusCode: 502), + }; + }) + .WithTags("Names"); + + return app; + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs b/GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs new file mode 100644 index 0000000..fb51b8a --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs @@ -0,0 +1,1402 @@ +// +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("20260606185411_AddLitterLetter")] + partial class AddLitterLetter + { + /// + 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("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 = "", + 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 = "Pink Eyed White (PEW)", + 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 = "dd Gold", + SortOrder = 15 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000017"), + CanonicalGenotype = "aa CC dd EE GG pp spsp rere", + Name = "dd 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 = "Agouti dd", + SortOrder = 28 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000030"), + CanonicalGenotype = "AA CC dd EE gg PP spsp rere", + Name = "Silberagouti dd", + SortOrder = 29 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000031"), + CanonicalGenotype = "aa CC dd ee GG PP spsp rere", + Name = "Kohlfuchs dd", + SortOrder = 30 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000032"), + CanonicalGenotype = "aa CC dd EE gg PP spsp rere", + Name = "Anthrazit dd", + SortOrder = 31 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000033"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Silberschimmel", + SortOrder = 32 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000034"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Polarfuchsschimmel", + SortOrder = 33 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000035"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Algierfuchsschimmel", + SortOrder = 34 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000036"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel", + SortOrder = 35 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000037"), + CanonicalGenotype = "aa CC DD efef gg PP spsp rere", + Name = "Blaufuchsschimmel", + SortOrder = 36 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000038"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs, hell", + SortOrder = 37 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000039"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs, hell", + SortOrder = 38 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000040"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Goldfuchsschimmel", + SortOrder = 39 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000041"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold-Hell", + SortOrder = 40 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000042"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs, hell", + SortOrder = 41 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000043"), + CanonicalGenotype = "aa CC DD efef GG pp spsp rere", + Name = "Rotfuchsschimmel", + SortOrder = 42 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000044"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs, hell", + SortOrder = 43 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000045"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel, hell", + SortOrder = 44 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000046"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs, hell", + SortOrder = 45 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000047"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs-Hell", + SortOrder = 46 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000048"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs, hell", + SortOrder = 47 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000049"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Topas dd", + SortOrder = 48 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000050"), + CanonicalGenotype = "aa CC dd ee gg pp spsp rere", + Name = "Blaufuchs dd", + SortOrder = 49 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000051"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Marder", + SortOrder = 50 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000052"), + CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere", + Name = "Siam", + SortOrder = 51 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000053"), + CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere", + Name = "Zobel-Hell", + SortOrder = 52 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000054"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "CP-Agouti", + SortOrder = 53 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000055"), + CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere", + Name = "CP-Silberagouti", + SortOrder = 54 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000056"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "CP-Algierfuchs", + SortOrder = 55 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000057"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "CP-Polarfuchs", + SortOrder = 56 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000058"), + CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere", + Name = "CP-Fuchs", + SortOrder = 57 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000059"), + CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere", + Name = "CP-Fuchs-Hell", + SortOrder = 58 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000060"), + CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere", + Name = "CP-Blaufuchs", + SortOrder = 59 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000061"), + CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere", + Name = "CP-Orangeschimmel", + SortOrder = 60 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000062"), + CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere", + Name = "CP-Agouti-Hell", + SortOrder = 61 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000063"), + CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere", + Name = "CP-Silberagouti-Hell", + SortOrder = 62 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000064"), + CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere", + Name = "CP-Algierfuchs-Hell", + SortOrder = 63 + }, + 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 = 65 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Enclosures"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.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("IsDeaf") + .HasColumnType("boolean"); + + b.Property("IsResident") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("NameSearch") + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OriginBreeder") + .HasColumnType("text") + .UseCollation("de-x-icu"); + + b.Property("OriginContactId") + .HasColumnType("uuid"); + + b.Property("RawImportData") + .HasColumnType("text"); + + b.Property("ReceiverContactId") + .HasColumnType("uuid"); + + b.Property("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("ExpectedGoHomeDate") + .HasColumnType("date"); + + b.Property("ExternalRef") + .HasColumnType("text"); + + b.Property("FatherId") + .HasColumnType("uuid"); + + b.Property("LitterLetter") + .HasColumnType("text"); + + b.Property("MotherId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PairingCode") + .HasColumnType("text"); + + b.Property("TotalBorn") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ExternalRef") + .IsUnique() + .HasFilter("\"ExternalRef\" IS NOT NULL"); + + b.HasIndex("FatherId"); + + b.HasIndex("MotherId"); + + b.ToTable("Litters"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.MailSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppPasswordProtected") + .HasColumnType("text"); + + b.Property("BackgroundPollEnabled") + .HasColumnType("boolean"); + + b.Property("Folder") + .IsRequired() + .HasColumnType("text"); + + b.Property("GmailAddress") + .HasColumnType("text"); + + b.Property("LastUid") + .HasColumnType("bigint"); + + b.Property("PollIntervalMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("MailSettings"); + + b.HasData( + new + { + Id = new Guid("ab0c0000-0000-0000-0000-000000000001"), + BackgroundPollEnabled = false, + Folder = "INBOX", + LastUid = 0L, + PollIntervalMinutes = 15 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Media", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Alt") + .HasColumnType("text"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Media"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Page", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("SeoDescription") + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Pages"); + + b.HasData( + new + { + Id = new Guid("51720001-0000-0000-0000-000000000001"), + Slug = "start", + Status = "Published", + Title = "Startseite" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000002"), + Slug = "ueber-die-zucht", + Status = "Published", + Title = "Über die Zucht" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000003"), + Slug = "abgabetiere", + Status = "Published", + Title = "Abgabetiere" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000004"), + Slug = "abgabebedingungen", + Status = "Published", + Title = "Abgabebedingungen" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000005"), + Slug = "farben-genetik", + Status = "Published", + Title = "Farben & Genetik" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000006"), + Slug = "kontakt", + Status = "Published", + Title = "Kontakt" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000007"), + Slug = "impressum", + Status = "Published", + Title = "Impressum" + }, + new + { + Id = new Guid("51720001-0000-0000-0000-000000000008"), + Slug = "datenschutz", + Status = "Published", + Title = "Datenschutz" + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Request", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnsweredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedContactId") + .HasColumnType("uuid"); + + b.Property("BodyText") + .HasColumnType("text"); + + b.Property("DraftReply") + .HasColumnType("text"); + + b.Property("FromAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("FromName") + .HasColumnType("text"); + + b.Property("GmailMessageId") + .IsRequired() + .HasColumnType("text"); + + b.Property("InReplyToMessageId") + .HasColumnType("text"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferencesHeader") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .HasColumnType("text"); + + b.Property("ThreadId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AssignedContactId"); + + b.HasIndex("GmailMessageId") + .IsUnique(); + + b.ToTable("Requests"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("ContractDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("HandoverDate") + .HasColumnType("date"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("SaleContracts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b => + { + b.Property("SaleContractId") + .HasColumnType("uuid"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.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.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/20260606185411_AddLitterLetter.cs b/GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.cs new file mode 100644 index 0000000..0dac358 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class AddLitterLetter : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LitterLetter", + table: "Litters", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LitterLetter", + table: "Litters"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index 043c9e7..2e52a23 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -905,6 +905,9 @@ namespace GerbilManagerWebAPI.Migrations b.Property("FatherId") .HasColumnType("uuid"); + b.Property("LitterLetter") + .HasColumnType("text"); + b.Property("MotherId") .HasColumnType("uuid"); diff --git a/GerbilManagerWebAPI/Models/Litter.cs b/GerbilManagerWebAPI/Models/Litter.cs index 943286f..978041a 100644 --- a/GerbilManagerWebAPI/Models/Litter.cs +++ b/GerbilManagerWebAPI/Models/Litter.cs @@ -30,5 +30,9 @@ namespace GerbilManagerWebAPI.Models /// nulls allowed for manually-entered litters). Primary idempotency key for re-imports; /// Name+Date is the fallback for litters created before this column existed. public string? ExternalRef { get; set; } + + /// FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses + /// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention). + public string? LitterLetter { get; set; } } } diff --git a/GerbilManagerWebAPI/Names/NameSuggestion.cs b/GerbilManagerWebAPI/Names/NameSuggestion.cs new file mode 100644 index 0000000..0fb43e8 --- /dev/null +++ b/GerbilManagerWebAPI/Names/NameSuggestion.cs @@ -0,0 +1,5 @@ +namespace GerbilManagerWebAPI.Names +{ + /// FEAT-NAMEGEN: a single name suggestion returned by GET /names/suggest. + public sealed record NameSuggestion(string Name, string Meaning, string Origin); +} diff --git a/GerbilManagerWebAPI/Names/NameSuggestionService.cs b/GerbilManagerWebAPI/Names/NameSuggestionService.cs new file mode 100644 index 0000000..19d1d48 --- /dev/null +++ b/GerbilManagerWebAPI/Names/NameSuggestionService.cs @@ -0,0 +1,107 @@ +using System.Text; +using System.Text.Json; +using GerbilManagerWebAPI.Ai; +using GerbilManagerWebAPI.SaleAd; +using Microsoft.Extensions.Options; + +namespace GerbilManagerWebAPI.Names +{ + /// + /// FEAT-NAMEGEN: generates meaningful gerbil name suggestions via Gemini + /// (the same OpenAiChatClient used by sale-ads and reply-drafts). + /// Returns NotConfigured when the AI section is missing — callers map to 503. + /// + public sealed class NameSuggestionService(HttpClient http, IOptions options) + { + private readonly OpenAiChatClient _client = new(http, options); + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + }; + + public async Task SuggestAsync( + string? letter, string? gender, string? usages, int count, + CancellationToken ct = default) + { + var aiResult = await _client.CompleteAsync( + BuildSystemPrompt(), + BuildUserPrompt(letter, gender, usages, count), + ct); + + if (aiResult.Status == AiCallStatus.NotConfigured) + return new NameSuggestionResult(NameSuggestionStatus.NotConfigured, null, aiResult.Error); + if (aiResult.Status != AiCallStatus.Ok || aiResult.Text is null) + return new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, aiResult.Error); + + var suggestions = ParseSuggestions(aiResult.Text); + return suggestions is null + ? new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, "Ungültiges JSON in KI-Antwort.") + : new NameSuggestionResult(NameSuggestionStatus.Ok, suggestions, null); + } + + internal static string BuildSystemPrompt() => + "Du bist ein Helfer für Rennmaus-Züchter. " + + "Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " + + "KEINE Erklärungen, KEIN Text außerhalb des Arrays. " + + "Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch)."; + + internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count) + { + var sb = new StringBuilder(); + sb.Append($"Schlage {count} Rennmaus-Namen vor"); + if (!string.IsNullOrWhiteSpace(letter)) + sb.Append($" die mit dem Buchstaben \"{letter.ToUpperInvariant()}\" beginnen"); + if (!string.IsNullOrWhiteSpace(gender) && + !gender.Equals("any", StringComparison.OrdinalIgnoreCase)) + sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere"); + if (!string.IsNullOrWhiteSpace(usages)) + sb.Append($", aus den Kulturkreisen: {usages}"); + sb.Append(". Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben "); + sb.Append("(keine erfundenen oder zufälligen Namen). "); + sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: "); + sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]"); + return sb.ToString(); + } + + /// + /// Strips optional markdown fences (```json ... ```) Gemini sometimes wraps around + /// its JSON output, then deserialises the array. + /// + internal static List? ParseSuggestions(string raw) + { + var text = raw.Trim(); + + // Strip ```json ... ``` or ``` ... ``` fences. + if (text.StartsWith("```", StringComparison.Ordinal)) + { + var firstNewline = text.IndexOf('\n'); + if (firstNewline >= 0) text = text[(firstNewline + 1)..]; + if (text.EndsWith("```", StringComparison.Ordinal)) + text = text[..^3].TrimEnd(); + } + + // Find the JSON array bounds defensively. + var start = text.IndexOf('['); + var end = text.LastIndexOf(']'); + if (start < 0 || end <= start) return null; + text = text[start..(end + 1)]; + + try + { + return JsonSerializer.Deserialize>(text, JsonOpts); + } + catch (JsonException) + { + return null; + } + } + } + + public enum NameSuggestionStatus { Ok, NotConfigured, UpstreamError } + + public sealed record NameSuggestionResult( + NameSuggestionStatus Status, + List? Suggestions, + string? Error); +} diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index b7ee181..63541cb 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -45,6 +45,9 @@ builder.Services.AddHttpClient( // INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client). builder.Services.AddHttpClient( http => http.Timeout = TimeSpan.FromSeconds(60)); +// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client). +builder.Services.AddHttpClient( + http => http.Timeout = TimeSpan.FromSeconds(60)); // INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection. // AR-3: persist the key ring so encrypted passwords survive image redeployments. @@ -100,6 +103,7 @@ app.MapSettingsEndpoints(); app.MapExportEndpoints(); app.MapCmsEndpoints(); app.MapRequestEndpoints(); +app.MapNamesEndpoints(); app.Run();