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();