From 3afb04f64611b2b0d88740c8db1ac4b264fd1c0f Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 09:10:30 +0200 Subject: [PATCH] FEAT-14a: Charakterbogen on Gerbil (traits + note) + sale-ad request extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gerbil.CharacterTraits (List, JSON text column, opaque labels) + CharacterNote (string?). On GerbilDto + Input; round-trips on GET/POST/PUT. Additive migration. - SaleAdAnimal gains Traits (German labels) + CharacterNote; prompt builder emits "Charakter: …" + "Charakter-Notiz: …" lines for the AI Verkaufstext. - Tests: traits/note round-trip (POST->GET via SQLite host), empty-default, prompt inclusion. --- GerbilManager.Tests/GerbilCharacterTests.cs | 75 ++ GerbilManagerWebAPI/ApplicationContext.cs | 13 + GerbilManagerWebAPI/Dtos/ApiDtos.cs | 8 +- .../Endpoints/GerbilEndpoints.cs | 5 +- ...60606070929_AddCharacterFields.Designer.cs | 1043 +++++++++++++++++ .../20260606070929_AddCharacterFields.cs | 39 + .../ApplicationContextModelSnapshot.cs | 7 + GerbilManagerWebAPI/Models/Gerbil.cs | 7 + GerbilManagerWebAPI/SaleAd/SaleAdModels.cs | 6 +- .../SaleAd/SaleAdPromptBuilder.cs | 4 + 10 files changed, 1203 insertions(+), 4 deletions(-) create mode 100644 GerbilManager.Tests/GerbilCharacterTests.cs create mode 100644 GerbilManagerWebAPI/Migrations/20260606070929_AddCharacterFields.Designer.cs create mode 100644 GerbilManagerWebAPI/Migrations/20260606070929_AddCharacterFields.cs diff --git a/GerbilManager.Tests/GerbilCharacterTests.cs b/GerbilManager.Tests/GerbilCharacterTests.cs new file mode 100644 index 0000000..7bbafd7 --- /dev/null +++ b/GerbilManager.Tests/GerbilCharacterTests.cs @@ -0,0 +1,75 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.RegularExpressions; +using GerbilManagerWebAPI.SaleAd; + +namespace GerbilManager.Tests; + +/// FEAT-14a: Charakterbogen (traits + note) persists/round-trips, and the +/// sale-ad prompt includes the character info. +public class GerbilCharacterTests : IClassFixture +{ + private readonly HttpClient _client; + + public GerbilCharacterTests(ApiFactory factory) => _client = factory.CreateClient(); + + [Fact] + public async Task CharacterTraits_and_note_round_trip() + { + var create = await _client.PostAsync("/gerbils", JsonContent.Create(new + { + name = "Charakter Test", + gender = "female", + characterTraits = new[] { "zutraulich", "handzahm" }, + characterNote = "sehr aktiv und neugierig", + })); + Assert.Equal(HttpStatusCode.Created, create.StatusCode); + + var created = await create.Content.ReadAsStringAsync(); + // POST response is the created GerbilDto — traits serialise straight back + Assert.Contains("zutraulich", created); + Assert.Contains("handzahm", created); + Assert.Contains("sehr aktiv und neugierig", created); + + var id = Regex.Match(created, "\"id\":\"([^\"]+)\"").Groups[1].Value; + Assert.NotEqual("", id); + + // re-read from the DB to prove persistence (not just echo) + var fetched = await _client.GetStringAsync($"/gerbils/{id}"); + Assert.Contains("zutraulich", fetched); + Assert.Contains("handzahm", fetched); + Assert.Contains("sehr aktiv und neugierig", fetched); + } + + [Fact] + public async Task Omitting_traits_defaults_to_empty_list() + { + var create = await _client.PostAsync("/gerbils", JsonContent.Create(new + { + name = "Ohne Charakter", + gender = "male", + })); + Assert.Equal(HttpStatusCode.Created, create.StatusCode); + var body = await create.Content.ReadAsStringAsync(); + Assert.Contains("\"characterTraits\":[]", body.Replace(" ", "")); + } + + [Fact] + public void SaleAd_prompt_includes_traits_and_note() + { + var request = new SaleAdRequest( + Animals: new List + { + new("Bella", "Agouti", "2025-01-15", "verspielt", + Traits: new List { "zutraulich", "handzahm" }, + CharacterNote: "liebt Sonnenblumenkerne"), + }, + StatusLine: "FREI", + Hints: ""); + + var prompt = SaleAdPromptBuilder.BuildUserPrompt(request); + + Assert.Contains("Charakter: zutraulich, handzahm", prompt); + Assert.Contains("liebt Sonnenblumenkerne", prompt); + } +} diff --git a/GerbilManagerWebAPI/ApplicationContext.cs b/GerbilManagerWebAPI/ApplicationContext.cs index a9b49c0..3a58929 100644 --- a/GerbilManagerWebAPI/ApplicationContext.cs +++ b/GerbilManagerWebAPI/ApplicationContext.cs @@ -49,6 +49,19 @@ public class ApplicationContext : DbContext e.Property(g => g.Gender).HasConversion(); e.Property(g => g.Status).HasConversion(); + // FEAT-14: character traits stored as a JSON text column (works on both + // Npgsql and the SQLite test host; opaque labels, no backend vocabulary). + var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter, string>( + v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null), + v => string.IsNullOrEmpty(v) + ? new List() + : System.Text.Json.JsonSerializer.Deserialize>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List()); + var traitsComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer>( + (a, b) => (a ?? new List()).SequenceEqual(b ?? new List()), + v => v == null ? 0 : v.Aggregate(0, (h, s) => HashCode.Combine(h, s.GetHashCode())), + v => v.ToList()); + e.Property(g => g.CharacterTraits).HasConversion(traitsConverter, traitsComparer); + e.HasOne(g => g.Litter).WithMany() .HasForeignKey(g => g.LitterId).OnDelete(DeleteBehavior.SetNull); e.HasOne(g => g.OriginContact).WithMany() diff --git a/GerbilManagerWebAPI/Dtos/ApiDtos.cs b/GerbilManagerWebAPI/Dtos/ApiDtos.cs index 7ecb1aa..8208080 100644 --- a/GerbilManagerWebAPI/Dtos/ApiDtos.cs +++ b/GerbilManagerWebAPI/Dtos/ApiDtos.cs @@ -25,7 +25,9 @@ namespace GerbilManagerWebAPI.Dtos string? Notes, string? ImportSource, string? ExternalRef, - string? OriginBreeder); + string? OriginBreeder, + List CharacterTraits, + string? CharacterNote); public record LitterDto( Guid Id, @@ -71,7 +73,9 @@ namespace GerbilManagerWebAPI.Dtos string? Notes, string? ImportSource, string? ExternalRef, - string? OriginBreeder); + string? OriginBreeder, + List? CharacterTraits, + string? CharacterNote); public record LitterInput( string Name, diff --git a/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs b/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs index 70fe80d..efeaac9 100644 --- a/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs +++ b/GerbilManagerWebAPI/Endpoints/GerbilEndpoints.cs @@ -95,11 +95,14 @@ namespace GerbilManagerWebAPI.Endpoints g.ImportSource = i.ImportSource; g.ExternalRef = i.ExternalRef; g.OriginBreeder = i.OriginBreeder; + g.CharacterTraits = i.CharacterTraits ?? new List(); + g.CharacterNote = i.CharacterNote; } internal static GerbilDto ToDto(Gerbil g) => new( g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId, g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath, - g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder); + g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder, + g.CharacterTraits, g.CharacterNote); } } diff --git a/GerbilManagerWebAPI/Migrations/20260606070929_AddCharacterFields.Designer.cs b/GerbilManagerWebAPI/Migrations/20260606070929_AddCharacterFields.Designer.cs new file mode 100644 index 0000000..d4b0548 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260606070929_AddCharacterFields.Designer.cs @@ -0,0 +1,1043 @@ +// +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("20260606070929_AddCharacterFields")] + partial class AddCharacterFields + { + /// + 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.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"); + + 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 = "Schwarzschimmel", + SortOrder = 4 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000006"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Rotaugenschimmel", + SortOrder = 5 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000007"), + CanonicalGenotype = "AA CC DD EE GG PP spsp rere", + Name = "Agouti", + SortOrder = 6 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000008"), + CanonicalGenotype = "aa CC DD EE GG PP spsp rere", + Name = "Schwarz", + SortOrder = 7 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000009"), + CanonicalGenotype = "AA CC DD EE gg PP spsp rere", + Name = "Silberagouti", + SortOrder = 8 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000010"), + CanonicalGenotype = "aa CC DD EE gg PP spsp rere", + Name = "Anthrazit", + SortOrder = 9 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000011"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs", + SortOrder = 10 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000012"), + CanonicalGenotype = "aa CC dd EE GG PP spsp rere", + Name = "Blau", + SortOrder = 11 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000013"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold", + SortOrder = 12 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000014"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin", + SortOrder = 13 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000015"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs", + SortOrder = 14 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000016"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs", + SortOrder = 15 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000017"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "dd Gold", + SortOrder = 16 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000018"), + CanonicalGenotype = "aa CC dd EE GG pp spsp rere", + Name = "dd Platin", + SortOrder = 17 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000019"), + CanonicalGenotype = "aa CC DD EE gg pp spsp rere", + Name = "Altweiss (REW)", + SortOrder = 18 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000020"), + CanonicalGenotype = "AA CC DD ee gg pp spsp rere", + Name = "Apricot (Blassfuchs)", + SortOrder = 19 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000021"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs", + SortOrder = 20 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000022"), + CanonicalGenotype = "aa CC DD ee gg pp spsp rere", + Name = "C-Separator", + SortOrder = 21 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000023"), + CanonicalGenotype = "AA CC DD EE gg pp spsp rere", + Name = "Elfenbein", + SortOrder = 22 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000024"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs", + SortOrder = 23 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000025"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Marder", + SortOrder = 24 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000026"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Siam (Marder-Hell)", + SortOrder = 25 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000027"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs", + SortOrder = 26 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000028"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Saphir", + SortOrder = 27 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000029"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Schimmel (Orangeschimmel)", + SortOrder = 28 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000030"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Topas", + SortOrder = 29 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000031"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin-Hell", + SortOrder = 30 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000032"), + CanonicalGenotype = "AA CC dd EE GG PP spsp rere", + Name = "Agouti dd", + SortOrder = 31 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000033"), + CanonicalGenotype = "AA CC dd EE gg PP spsp rere", + Name = "Silberagouti dd", + SortOrder = 32 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000034"), + CanonicalGenotype = "aa CC dd ee GG PP spsp rere", + Name = "Kohlfuchs dd", + SortOrder = 33 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000035"), + CanonicalGenotype = "aa CC dd EE gg PP spsp rere", + Name = "Anthrazit dd", + SortOrder = 34 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000036"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "Agouti CP-Hell", + SortOrder = 35 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000037"), + CanonicalGenotype = "aa cchmcchm DD ee gg PP spsp rere", + Name = "Blaufuchs CP", + SortOrder = 36 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000038"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Polarfuchsschimmel", + SortOrder = 37 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000039"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Silberschimmel", + SortOrder = 38 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000040"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Algierfuchsschimmel", + SortOrder = 39 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000041"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "Polarfuchs-Hell CP", + SortOrder = 40 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000042"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel", + SortOrder = 41 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000043"), + CanonicalGenotype = "aa CC DD efef gg PP spsp rere", + Name = "Blaufuchsschimmel", + SortOrder = 42 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000044"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs, hell", + SortOrder = 43 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000045"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs, hell", + SortOrder = 44 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000046"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Goldfuchsschimmel", + SortOrder = 45 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000047"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold-Hell", + SortOrder = 46 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000048"), + CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere", + Name = "Siam (Marder-Hell) dd", + SortOrder = 47 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000049"), + CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere", + Name = "Marder dd", + SortOrder = 48 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000050"), + CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere", + Name = "Zobel-Hell", + SortOrder = 49 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000051"), + CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere", + Name = "Silberagouti dd CP", + SortOrder = 50 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000052"), + CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere", + Name = "Silberagouti-Hell dd CP", + SortOrder = 51 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000053"), + CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere", + Name = "Agouti dd CP", + SortOrder = 52 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000054"), + CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere", + Name = "Agouti-Hell dd CP", + SortOrder = 53 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000055"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs, hell", + SortOrder = 54 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000056"), + CanonicalGenotype = "aa CC DD efef GG pp spsp rere", + Name = "Rotfuchsschimmel", + SortOrder = 55 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000057"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs, hell", + SortOrder = 56 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000058"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel, hell", + SortOrder = 57 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000059"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs, hell", + SortOrder = 58 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000060"), + CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere", + Name = "Zobel dd", + SortOrder = 59 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000061"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs-Hell", + SortOrder = 60 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000062"), + CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere", + Name = "Kohlfuchs CP", + SortOrder = 61 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000063"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "Algierfuchs CP", + SortOrder = 62 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000064"), + CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere", + Name = "Silberagouti CP", + SortOrder = 63 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000065"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "Agouti CP", + SortOrder = 64 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000066"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "Algierfuchs-Hell CP", + SortOrder = 65 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000067"), + CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere", + Name = "Kohlfuchs,hell CP", + SortOrder = 66 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000068"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "Polarfuchs CP", + SortOrder = 67 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000069"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs, hell", + SortOrder = 68 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000070"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Topas dd", + SortOrder = 69 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000071"), + CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere", + Name = "Zobel-Hell dd", + SortOrder = 70 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000072"), + CanonicalGenotype = "aa cchmcchm DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel CP", + SortOrder = 71 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000073"), + CanonicalGenotype = "aa CC dd ee gg PP spsp rere", + Name = "Blaufuchs dd", + SortOrder = 72 + }); + }); + + 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"); + + 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("LitterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NameSearch") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OriginBreeder") + .HasColumnType("text"); + + 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("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("FatherId") + .HasColumnType("uuid"); + + 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("FatherId"); + + b.HasIndex("MotherId"); + + b.ToTable("Litters"); + }); + + 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.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.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.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.SaleContract", b => + { + b.Navigation("Animals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260606070929_AddCharacterFields.cs b/GerbilManagerWebAPI/Migrations/20260606070929_AddCharacterFields.cs new file mode 100644 index 0000000..8b9ec66 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260606070929_AddCharacterFields.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class AddCharacterFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CharacterNote", + table: "Gerbils", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "CharacterTraits", + table: "Gerbils", + type: "text", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CharacterNote", + table: "Gerbils"); + + migrationBuilder.DropColumn( + name: "CharacterTraits", + table: "Gerbils"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index 9595e65..bcba41a 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -661,6 +661,13 @@ namespace GerbilManagerWebAPI.Migrations b.Property("CauseOfDeath") .HasColumnType("text"); + b.Property("CharacterNote") + .HasColumnType("text"); + + b.Property("CharacterTraits") + .IsRequired() + .HasColumnType("text"); + b.Property("ColorVarietyId") .HasColumnType("uuid"); diff --git a/GerbilManagerWebAPI/Models/Gerbil.cs b/GerbilManagerWebAPI/Models/Gerbil.cs index d5c70a2..b1c75ca 100644 --- a/GerbilManagerWebAPI/Models/Gerbil.cs +++ b/GerbilManagerWebAPI/Models/Gerbil.cs @@ -60,6 +60,13 @@ namespace GerbilManagerWebAPI.Models /// Kept in sync automatically on save (see ApplicationContext.SaveChanges). Gridify-filterable /// so "clan kleine chaoten" matches "Clan-Kleine-Chaoten" (client strips separators too). public string? NameSearch { get; set; } + + /// FEAT-14: Charakterbogen trait labels (opaque to the backend — the + /// {key,label} vocabulary lives frontend-side). Stored as a JSON text column. + public List CharacterTraits { get; set; } = new(); + + /// FEAT-14: free-text character note; feeds the AI Verkaufstext. + public string? CharacterNote { get; set; } } /// Shared normalisation for the separator-insensitive name search. diff --git a/GerbilManagerWebAPI/SaleAd/SaleAdModels.cs b/GerbilManagerWebAPI/SaleAd/SaleAdModels.cs index ed161a1..54d018e 100644 --- a/GerbilManagerWebAPI/SaleAd/SaleAdModels.cs +++ b/GerbilManagerWebAPI/SaleAd/SaleAdModels.cs @@ -9,7 +9,11 @@ namespace GerbilManagerWebAPI.SaleAd string? Farbschlag, /// ISO "YYYY-MM-DD" (frontend sends the DTO string verbatim). string? DateOfBirth, - string? Notes); + string? Notes, + /// FEAT-14: Charakterbogen trait LABELS (German, human-readable) for the prompt. + List? Traits = null, + /// FEAT-14: free-text character note for the prompt. + string? CharacterNote = null); public sealed record SaleAdRequest( List Animals, diff --git a/GerbilManagerWebAPI/SaleAd/SaleAdPromptBuilder.cs b/GerbilManagerWebAPI/SaleAd/SaleAdPromptBuilder.cs index a39a3af..757d7b3 100644 --- a/GerbilManagerWebAPI/SaleAd/SaleAdPromptBuilder.cs +++ b/GerbilManagerWebAPI/SaleAd/SaleAdPromptBuilder.cs @@ -87,6 +87,10 @@ namespace GerbilManagerWebAPI.SaleAd sb.Append($" | geboren am {FormatGermanDate(animal.DateOfBirth)}"); if (!string.IsNullOrWhiteSpace(animal.Notes)) sb.Append($" | Notizen: {animal.Notes}"); + if (animal.Traits is { Count: > 0 }) + sb.Append($" | Charakter: {string.Join(", ", animal.Traits)}"); + if (!string.IsNullOrWhiteSpace(animal.CharacterNote)) + sb.Append($" | Charakter-Notiz: {animal.CharacterNote}"); sb.AppendLine(); } if (!string.IsNullOrWhiteSpace(request.Hints))