Compare commits
106 Commits
45b213e11d
...
feature/ux
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eb82da90c | |||
| 0a1e63bec6 | |||
| 5b30407257 | |||
| 13eb17b453 | |||
| 9ed68ba38a | |||
| dfcd296119 | |||
| db85a6e0dc | |||
| 0c94cfcbf1 | |||
| 5eadd89bb6 | |||
| f9a68deb7a | |||
| a693095886 | |||
| d5544412bd | |||
| d93e8d1586 | |||
| 114bbd92c8 | |||
| a8d8ae0dfc | |||
| 522f2eec51 | |||
| 3f71d8e28e | |||
| 0e7ec5ab61 | |||
| 12604fba7c | |||
| 311c5461fd | |||
| 3634d6ef9a | |||
| aa345aa796 | |||
| 0317c5d454 | |||
| ea8c3e9966 | |||
| 4d3383a4f1 | |||
| 19a0a6c79b | |||
| b9033238c9 | |||
| 594923e10f | |||
| aa20d6f809 | |||
| afd321ec9f | |||
| ae825ae80a | |||
| e055061d85 | |||
| df13136955 | |||
| 4aca1d528b | |||
| 4e02133d95 | |||
| f3efd4aa2b | |||
| 461d7feb2d | |||
| 7536aba474 | |||
| 972570b142 | |||
| 029ffb9895 | |||
| 6decd59ce4 | |||
| f015274d28 | |||
| fd48f7fc09 | |||
| 105e63b9c4 | |||
| 0334fce275 | |||
| aaaf5b6fb7 | |||
| c20e6b5426 | |||
| 36454f3747 | |||
| 3335502ef1 | |||
| 0766f8303c | |||
| 6357e77060 | |||
| 5edbd67c96 | |||
| 0651e54343 | |||
| 4dcd416ff6 | |||
| 5fd8cf4395 | |||
| 76fcad6168 | |||
| 91fa9058de | |||
| 6775707387 | |||
| 8125c453d0 | |||
| 8101cc7be5 | |||
| ea79703dbf | |||
| ed576c0339 | |||
| c35a6eca04 | |||
| 2e20df624f | |||
| a57b481a7e | |||
| 27424c574f | |||
| fd28b2a954 | |||
| 8aab9d34f7 | |||
| 6dabb5572c | |||
| 36db013b76 | |||
| 60d034cde4 | |||
| 5ec04984c9 | |||
| e2fd32ea12 | |||
| 2f089a902d | |||
| 7aac59f3e5 | |||
| a0b720df73 | |||
| 1e7dc8f91d | |||
| c93c24f37c | |||
| bd9cc83205 | |||
| 8d0fc43c0a | |||
| 98596b5586 | |||
| 9013ff488e | |||
| 2a1c4a9eb7 | |||
| d79f039b8b | |||
| 8202dfc0e4 | |||
| 68366ad8c9 | |||
| dcf849cfa7 | |||
| 958a2dbac0 | |||
| 5d7cbc66bf | |||
| 84181ed74d | |||
| d510b0f822 | |||
| 3716dcb1f3 | |||
| d6e35f40ec | |||
| 70f7c88419 | |||
| acd7f4c386 | |||
| f73ac0d4d8 | |||
| 94f055f9bf | |||
| cab8c133c8 | |||
| 13ff97a12b | |||
| 681e8c28fb | |||
| ba81f48e0f | |||
| 3afb04f646 | |||
| 5d8bbfa93b | |||
| 5b11780074 | |||
| f858a02f50 | |||
| 09f11cb4e3 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -131,3 +131,6 @@ $RECYCLE.BIN/
|
||||
# MemPalace per-project files (issue #185)
|
||||
mempalace.yaml
|
||||
entities.json
|
||||
|
||||
# Runtime photo store (uploaded/imported gerbil photos) — never commit
|
||||
GerbilManagerWebAPI/photo-storage/
|
||||
|
||||
@@ -19,6 +19,13 @@ public sealed class ApiFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly SqliteConnection _connection = new("DataSource=:memory:");
|
||||
|
||||
/// <summary>
|
||||
/// INBOX-2: Test-spezifische DI-Überschreibungen (z. B. AI-Stub-Handler).
|
||||
/// Init-Property statt Konstruktor — xUnit-Klassen-Fixtures erlauben nur
|
||||
/// EINEN öffentlichen (parameterlosen) Konstruktor.
|
||||
/// </summary>
|
||||
public Action<IServiceCollection>? ConfigureTestServices { get; init; }
|
||||
|
||||
public string ContractRoot { get; } =
|
||||
Path.Combine(Path.GetTempPath(), $"gerbil-contract-tests-{Guid.NewGuid():N}");
|
||||
|
||||
@@ -46,6 +53,8 @@ public sealed class ApiFactory : WebApplicationFactory<Program>
|
||||
using var provider = services.BuildServiceProvider();
|
||||
using var scope = provider.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.EnsureCreated();
|
||||
|
||||
ConfigureTestServices?.Invoke(services);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
69
GerbilManager.Tests/CmsPreviewTests.cs
Normal file
69
GerbilManager.Tests/CmsPreviewTests.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WEB-3: Round-Trips für die lokale Webseiten-Vorschau (GET /api/preview/…):
|
||||
/// veröffentlichte Seiten werden als HTML ausgeliefert, Entwürfe nicht,
|
||||
/// styles.css kommt mit CSS-Content-Type.
|
||||
/// </summary>
|
||||
public class CmsPreviewTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
|
||||
public CmsPreviewTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
private static object PageInput(string slug, string title, string status) =>
|
||||
new { slug, title, seoDescription = (string?)null, status };
|
||||
|
||||
private sealed record PageRow(Guid Id, string Slug);
|
||||
|
||||
/// <summary>Seite anlegen oder (falls von WEB-0b geseedet) auf den Zielzustand setzen.</summary>
|
||||
private static async Task UpsertPageAsync(HttpClient client, string slug, string title, string status)
|
||||
{
|
||||
var existing = (await client.GetFromJsonAsync<List<PageRow>>("/api/pages"))!
|
||||
.FirstOrDefault(p => p.Slug == slug);
|
||||
if (existing is null)
|
||||
{
|
||||
var created = await client.PostAsJsonAsync("/api/pages", PageInput(slug, title, status));
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
var updated = await client.PutAsJsonAsync($"/api/pages/{existing.Id}", PageInput(slug, title, status));
|
||||
Assert.Equal(HttpStatusCode.NoContent, updated.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Vorschau_LiefertVeroeffentlichteSeiteAlsHtml_UndKeineEntwuerfe()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
await UpsertPageAsync(client, "start", "Willkommen bei der Zucht", "Published");
|
||||
await UpsertPageAsync(client, "e2e-entwurf", "Unsere Zucht", "Draft");
|
||||
|
||||
// Startseite: /api/preview/ → index.html (text/html, enthält den Titel)
|
||||
var index = await client.GetAsync("/api/preview/");
|
||||
Assert.Equal(HttpStatusCode.OK, index.StatusCode);
|
||||
Assert.StartsWith("text/html", index.Content.Headers.ContentType!.ToString());
|
||||
Assert.Contains("Willkommen bei der Zucht", await index.Content.ReadAsStringAsync());
|
||||
|
||||
// Expliziter Pfad funktioniert ebenso
|
||||
var explicitIndex = await client.GetAsync("/api/preview/index.html");
|
||||
Assert.Equal(HttpStatusCode.OK, explicitIndex.StatusCode);
|
||||
|
||||
// CSS mit korrektem Content-Type (Renderer: SiteRenderer.CssPath)
|
||||
var css = await client.GetAsync("/api/preview/assets/site.css");
|
||||
Assert.Equal(HttpStatusCode.OK, css.StatusCode);
|
||||
Assert.StartsWith("text/css", css.Content.Headers.ContentType!.ToString());
|
||||
|
||||
// Entwurf wird NICHT gerendert (Vorschau zeigt nur Veröffentlichtes)
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/e2e-entwurf/index.html")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/e2e-entwurf/")).StatusCode);
|
||||
|
||||
// Unsinnige Pfade → 404
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/gibt-es-nicht.html")).StatusCode);
|
||||
}
|
||||
}
|
||||
83
GerbilManager.Tests/CmsTests.cs
Normal file
83
GerbilManager.Tests/CmsTests.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>WEB-0: CMS snapshot shape, Abgabetiere auto-resolution from ForSale, block reorder.</summary>
|
||||
public class CmsTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
public CmsTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_has_site_nav_and_six_seeded_pages()
|
||||
{
|
||||
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString());
|
||||
var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList();
|
||||
Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav);
|
||||
|
||||
var pages = root.GetProperty("pages").EnumerateArray().ToList();
|
||||
Assert.Equal(6, pages.Count);
|
||||
// abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array
|
||||
var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere");
|
||||
var listBlock = abg.GetProperty("blocks").EnumerateArray()
|
||||
.Single(b => b.GetProperty("type").GetString() == "AbgabetiereList");
|
||||
Assert.Equal("auto", listBlock.GetProperty("data").GetProperty("mode").GetString());
|
||||
Assert.Equal(JsonValueKind.Array, listBlock.GetProperty("data").GetProperty("animals").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Abgabetiere_resolves_ForSale_animals_with_becken_group()
|
||||
{
|
||||
// a Becken + a ForSale gerbil in it
|
||||
var encResp = await _client.PostAsJsonAsync("/enclosures", new { name = "Verkaufsbecken" });
|
||||
var encId = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Verkaufsmaus", gender = "female", status = "ForSale", enclosureId = encId });
|
||||
// a non-ForSale gerbil must NOT appear
|
||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Active" });
|
||||
|
||||
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
||||
var animals = doc.RootElement.GetProperty("pages").EnumerateArray()
|
||||
.Single(p => p.GetProperty("slug").GetString() == "abgabetiere")
|
||||
.GetProperty("blocks").EnumerateArray()
|
||||
.Single(b => b.GetProperty("type").GetString() == "AbgabetiereList")
|
||||
.GetProperty("data").GetProperty("animals").EnumerateArray().ToList();
|
||||
|
||||
var sale = animals.SingleOrDefault(a => a.GetProperty("name").GetString() == "Verkaufsmaus");
|
||||
Assert.Equal(JsonValueKind.Object, sale.ValueKind);
|
||||
Assert.Equal("Verkaufsbecken", sale.GetProperty("group").GetString());
|
||||
Assert.Equal(JsonValueKind.Array, sale.GetProperty("photos").ValueKind);
|
||||
Assert.DoesNotContain(animals, a => a.GetProperty("name").GetString() == "Bleibtmaus");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Block_crud_and_reorder()
|
||||
{
|
||||
// create a page + two blocks
|
||||
var pageResp = await _client.PostAsJsonAsync("/api/pages", new { slug = "test-page", title = "Testseite" });
|
||||
Assert.Equal(HttpStatusCode.Created, pageResp.StatusCode);
|
||||
var pageId = JsonDocument.Parse(await pageResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
async Task<Guid> AddBlock(string type, object data) =>
|
||||
JsonDocument.Parse(await (await _client.PostAsJsonAsync($"/api/pages/{pageId}/blocks", new { type, data }))
|
||||
.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetGuid();
|
||||
|
||||
var b1 = await AddBlock("Heading", new { text = "Erstes", level = 2 });
|
||||
var b2 = await AddBlock("RichText", new { markdown = "**hallo**" });
|
||||
|
||||
// reorder: b2 before b1
|
||||
var reorder = await _client.PutAsJsonAsync($"/api/pages/{pageId}/blocks/order", new { blockIds = new[] { b2, b1 } });
|
||||
Assert.Equal(HttpStatusCode.NoContent, reorder.StatusCode);
|
||||
|
||||
var blocks = JsonDocument.Parse(await _client.GetStringAsync("/api/pages/test-page"))
|
||||
.RootElement.GetProperty("blocks").EnumerateArray().ToList();
|
||||
Assert.Equal(b2.ToString(), blocks[0].GetProperty("id").GetString());
|
||||
Assert.Equal(b1.ToString(), blocks[1].GetProperty("id").GetString());
|
||||
// RichText markdown round-trips inside data
|
||||
Assert.Equal("**hallo**", blocks[0].GetProperty("data").GetProperty("markdown").GetString());
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal(1, root.GetProperty("gerbils").GetArrayLength());
|
||||
Assert.Equal(1, root.GetProperty("litters").GetArrayLength());
|
||||
Assert.Equal(1, root.GetProperty("contacts").GetArrayLength());
|
||||
Assert.True(root.GetProperty("colorVarieties").GetArrayLength() >= 70); // HasData-Seed
|
||||
Assert.True(root.GetProperty("colorVarieties").GetArrayLength() >= 60); // HasData-Seed (61 nach GEN-3f)
|
||||
var g = root.GetProperty("gerbils")[0];
|
||||
Assert.Equal("Aa CC Dd EE GG Pp Spsp rere", g.GetProperty("genotype").GetString());
|
||||
Assert.Equal("Stammbaum von Akio Kids.xlsx", g.GetProperty("importSource").GetString());
|
||||
|
||||
75
GerbilManager.Tests/GerbilCharacterTests.cs
Normal file
75
GerbilManager.Tests/GerbilCharacterTests.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>FEAT-14a: Charakterbogen (traits + note) persists/round-trips, and the
|
||||
/// sale-ad prompt includes the character info.</summary>
|
||||
public class GerbilCharacterTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
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<SaleAdAnimal>
|
||||
{
|
||||
new("Bella", "Agouti", "2025-01-15", "verspielt",
|
||||
Traits: new List<string> { "zutraulich", "handzahm" },
|
||||
CharacterNote: "liebt Sonnenblumenkerne"),
|
||||
},
|
||||
StatusLine: "FREI",
|
||||
Hints: "");
|
||||
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
|
||||
|
||||
Assert.Contains("Charakter: zutraulich, handzahm", prompt);
|
||||
Assert.Contains("liebt Sonnenblumenkerne", prompt);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
@@ -102,6 +103,208 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal(2, await db.Litters.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
|
||||
{
|
||||
using var db = NewDb();
|
||||
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||
|
||||
var a1 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "a1");
|
||||
// GEN-3b: deafness is a persisted phenotype flag (NOT a genotype locus).
|
||||
Assert.True(a1.IsDeaf);
|
||||
// Sls (2nd spotting locus) + provenance tags are preserved in RawImportData
|
||||
// (kept out of the 8-locus compact Genotype contract until GEN-3a adopts them).
|
||||
Assert.Contains("Sls", a1.RawImportData!);
|
||||
Assert.Contains("WFNZ", a1.RawImportData!);
|
||||
// GEN-3a contract: a WP/Sls carrier appends the trailing "Slsl" token (Kevin).
|
||||
Assert.EndsWith("Slsl", a1.Genotype!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposeGenotype_appends_Slsl_only_for_carriers()
|
||||
{
|
||||
// wild-type sl/sl is omitted -> plain 8-locus string
|
||||
var wild = new SourceGenotype { Mapped8locus = new() { ["A"] = new() { "a", "a" }, ["Sls"] = new() { "sl", "sl" } } };
|
||||
Assert.DoesNotContain("Sl", ImportService.ComposeGenotype(wild));
|
||||
// WP heterozygote -> trailing Slsl
|
||||
var wp = new SourceGenotype { Mapped8locus = new() { ["A"] = new() { "a", "a" }, ["Sls"] = new() { "Sl", "sl" } } };
|
||||
Assert.EndsWith("Slsl", ImportService.ComposeGenotype(wp));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Synthesizes_litter_from_chart_parentRefs_links_offspring_and_parents()
|
||||
{
|
||||
// Offspring 'C' has chart-position parentRefs to father 'Papa' (loaded) and mother
|
||||
// 'Mama' (loaded), but NO Wurfchronik litterRef -> the loader must synthesize a derived
|
||||
// litter, link C to it, and set the litter's Father/Mother (PEDIGREE-LINK structural fix).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "pedlink-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"","gender":"male","zuchtCanon":"test",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"","gender":"female","zuchtCanon":"test",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"ext","name":"Fremd of Foreign","dob":"03.03.2022","death":"","farbschlag":"","zuchtCanon":"foreign",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"parentRefs":[
|
||||
{"name":"Papa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
|
||||
{"name":"Mama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
|
||||
]}
|
||||
]
|
||||
""");
|
||||
using var db = NewDb();
|
||||
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
|
||||
Assert.Equal(1, report.Animals.ParentLinksFromChart);
|
||||
Assert.Equal(1, report.Litters.DerivedFromChart);
|
||||
|
||||
var papa = await db.Gerbils.SingleAsync(g => g.ExternalRef == "papa");
|
||||
var mama = await db.Gerbils.SingleAsync(g => g.ExternalRef == "mama");
|
||||
var c = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c");
|
||||
Assert.NotNull(c.LitterId); // C no longer "unbekannt"
|
||||
|
||||
// box-colour sex flows through (blue=male, white=female)
|
||||
Assert.Equal(Gender.male, papa.Gender);
|
||||
Assert.Equal(Gender.female, mama.Gender);
|
||||
|
||||
var litter = await db.Litters.SingleAsync(l => l.Id == c.LitterId);
|
||||
Assert.Equal(papa.Id, litter.FatherId);
|
||||
Assert.Equal(mama.Id, litter.MotherId);
|
||||
Assert.Contains("Diagramm", litter.Notes!); // transparent + reversible
|
||||
|
||||
// OWNERSHIP/RESIDENCY: C is Clan (rule a); its foreign-Zucht parents flip to
|
||||
// resident (rule b); the unrelated foreign animal stays external.
|
||||
Assert.True(c.IsResident); // rule (a)
|
||||
Assert.True(papa.IsResident); // rule (b) parent exception
|
||||
Assert.True(mama.IsResident); // rule (b)
|
||||
Assert.False((await db.Gerbils.SingleAsync(g => g.ExternalRef == "ext")).IsResident);
|
||||
Assert.NotNull(report.Residency);
|
||||
Assert.Equal(3, report.Residency!.Resident);
|
||||
Assert.Equal(1, report.Residency.External);
|
||||
Assert.Equal(2, report.Residency.FlippedByParentRule);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_on_relational_db_with_new_chart_parents_does_not_FK_throw()
|
||||
{
|
||||
// Regression for FK_Litters_Gerbils_FatherId: a derived litter references parent gerbils
|
||||
// created in the SAME run, so they must be inserted before the litter. The EF in-memory
|
||||
// provider does NOT enforce FKs (which masked the bug), so this uses SQLite — which does.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "fkfix-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"parentRefs":[
|
||||
{"name":"Papa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
|
||||
{"name":"Mama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
|
||||
]}
|
||||
]
|
||||
""");
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync(); // schema WITH enforced FK constraints
|
||||
|
||||
// must not throw FK_Litters_Gerbils_FatherId (parents inserted before the litter)
|
||||
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
|
||||
var c = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c");
|
||||
Assert.NotNull(c.LitterId);
|
||||
var litter = await db.Litters.SingleAsync(l => l.Id == c.LitterId);
|
||||
Assert.Equal((await db.Gerbils.SingleAsync(g => g.ExternalRef == "papa")).Id, litter.FatherId);
|
||||
Assert.Equal(0, report.Litters.ParentFksDropped);
|
||||
Assert.Equal(0, report.Litters.DerivedSkipped);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw()
|
||||
{
|
||||
// A chart parentRef pointing to a QUARANTINED (conflict) animal must not become an FK —
|
||||
// the derived litter keeps that side null; if both sides are unresolvable, no litter.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "fkq-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"badpa","name":"BadPapa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true},
|
||||
{"id":"goodma","name":"GoodMama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"c2","name":"C2","dob":"29.04.2024","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"parentRefs":[
|
||||
{"name":"BadPapa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
|
||||
{"name":"GoodMama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
|
||||
]}
|
||||
]
|
||||
""");
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
await new ImportService(db, dir, dir).RunAsync(execute: true); // must not throw
|
||||
|
||||
Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "badpa")); // quarantined
|
||||
var c2 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c2");
|
||||
Assert.NotNull(c2.LitterId); // still linked (via mother)
|
||||
var litter = await db.Litters.SingleAsync(l => l.Id == c2.LitterId);
|
||||
Assert.Null(litter.FatherId); // quarantined father -> null FK
|
||||
Assert.Equal((await db.Gerbils.SingleAsync(g => g.ExternalRef == "goodma")).Id, litter.MotherId);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Decision_resolved_animal_loads_and_is_counted()
|
||||
{
|
||||
// extract.py clears the conflict + sets resolvedByDecision when a human conflict-decision
|
||||
// un-quarantines an animal; the loader must then LOAD it and surface the count.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dec-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"firefly","name":"Firefly","dob":"18.12.2019","death":"","farbschlag":"Agouti",
|
||||
"genotype":{"mapped8locus":{"A":["A","a"],"D":["D","?"]},"rawGenotype":"Aa D-","unmappedTokens":[]},
|
||||
"conflict":false,"resolvedByDecision":true}
|
||||
]
|
||||
""");
|
||||
using var db = NewDb();
|
||||
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
|
||||
Assert.Equal(1, report.Animals.ConflictsResolvedByDecision);
|
||||
Assert.True(await db.Gerbils.AnyAsync(g => g.ExternalRef == "firefly")); // loaded, not quarantined
|
||||
Assert.Equal(0, report.Animals.Quarantined.Conflicts);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposeGenotype_strips_carets_and_fills_missing_loci()
|
||||
{
|
||||
@@ -118,6 +321,230 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal("aa Ccchm ?? eef ?? ?? ?? ??", ImportService.ComposeGenotype(g));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParentFkBackfill_fills_null_litter_parent_on_reimport()
|
||||
{
|
||||
// Run 1: litter "Wurf A" has sire "Vater" (conflict=true — not loaded) and dam "Mutter"
|
||||
// (conflict=false — loaded). After run 1: litter.FatherId = null.
|
||||
// Run 2: sire "Vater" now conflict=false → loaded as NEW in run 2. Backfill via
|
||||
// createdAnimalByName sets FatherId. (god steering point 3: run-2 path.)
|
||||
var dir = Path.Combine(Path.GetTempPath(), "backfill-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
var littersJson = """
|
||||
[{"id":"L-A","litterId":"A","date":"01.05.2023","damName":"Mutter [ZdkC]","sireName":"Vater [ZdkC]","totalBorn":3,"zuchtnummer":"","note":""}]
|
||||
""";
|
||||
// Run 1: Vater is in conflict -> not loaded
|
||||
var animals1 = """
|
||||
[
|
||||
{"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true},
|
||||
{"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
|
||||
]
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var report1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(0, report1.Litters.ParentFksBackfilled);
|
||||
var litter1 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
|
||||
Assert.Null(litter1.FatherId); // Vater was quarantined -> null FK
|
||||
Assert.NotNull(litter1.MotherId); // Mutter was loaded -> set
|
||||
|
||||
// Run 2: Vater now conflict=false -> loaded as NEW animal in this run
|
||||
var animals2 = """
|
||||
[
|
||||
{"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
|
||||
]
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);
|
||||
|
||||
var report2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(1, report2.Litters.ParentFksBackfilled); // backfill happened
|
||||
var vater = await db.Gerbils.SingleAsync(g => g.ExternalRef == "vater");
|
||||
var litter2 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
|
||||
Assert.Equal(vater.Id, litter2.FatherId); // FK now set
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParentFkBackfill_uses_allDb_lookup_when_parent_not_in_current_loadable()
|
||||
{
|
||||
// god steering point 3: the main case — parent was loaded in a PREVIOUS run (not in
|
||||
// the current run's animals.json at all). Backfill must find them via allDbNormToGid.
|
||||
//
|
||||
// Run 1: litter "Wurf C" + dam loaded, sire quarantined -> FatherId null.
|
||||
// Run 2: sire loaded (new animal).
|
||||
// Run 3: animals.json has ONLY the kind (sire absent from extract). Sire is in DB
|
||||
// from run 2 but NOT in the current run's loadable/createdAnimalByName.
|
||||
// Backfill must use allDbNormToGid to find him.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "backfill-db-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
var littersJson = """
|
||||
[{"id":"L-C","litterId":"C","date":"10.06.2023","damName":"Dame [ZdkC]","sireName":"Herr [ZdkC]","totalBorn":2,"zuchtnummer":"","note":""}]
|
||||
""";
|
||||
// Run 1: sire quarantined
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"herr","name":"Herr [ZdkC]","dob":"06.06.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}
|
||||
]
|
||||
""");
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Null((await db.Litters.SingleAsync(l => l.Name == "Wurf C")).FatherId);
|
||||
|
||||
// Run 2: sire now loaded
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"herr","name":"Herr [ZdkC]","dob":"06.06.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}
|
||||
]
|
||||
""");
|
||||
await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
var herrId = (await db.Gerbils.SingleAsync(g => g.ExternalRef == "herr")).Id;
|
||||
// Run 2 itself may or may not backfill (depends on name normalization alignment).
|
||||
// For the test we care about run 3.
|
||||
|
||||
// Run 3: sire NOT in animals.json at all (absent from new extract).
|
||||
// litter still has FatherId=null if run 2 didn't backfill; if it did, we simulate
|
||||
// by manually resetting FatherId to null so run 3 must fix it.
|
||||
var litter3 = await db.Litters.SingleAsync(l => l.Name == "Wurf C");
|
||||
litter3.FatherId = null;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}
|
||||
]
|
||||
""");
|
||||
// Run 3: sire absent from loadable (NOT in createdAnimalByName), but IS in DB.
|
||||
var report3 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(1, report3.Litters.ParentFksBackfilled); // allDbNormToGid path
|
||||
var litter3After = await db.Litters.SingleAsync(l => l.Name == "Wurf C");
|
||||
Assert.Equal(herrId, litter3After.FatherId); // FK set from DB lookup
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParentFkBackfill_dry_run_counts_without_writing()
|
||||
{
|
||||
// Dry-run on a DB with an existing null-parent litter should predict the backfill count.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "backfill-dr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
var littersJson = """
|
||||
[{"id":"L-B","litterId":"B","date":"15.06.2023","damName":"Mami [ZdkC]","sireName":"Papi [ZdkC]","totalBorn":2,"zuchtnummer":"","note":""}]
|
||||
""";
|
||||
var animals1 = """
|
||||
[
|
||||
{"id":"mami","name":"Mami [ZdkC]","dob":"03.03.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"papi","name":"Papi [ZdkC]","dob":"04.04.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}
|
||||
]
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
await new ImportService(db, dir, dir).RunAsync(execute: true); // run 1
|
||||
|
||||
// Run 2 dry-run with papi un-quarantined
|
||||
var animals2 = animals1.Replace("\"conflict\":true", "\"conflict\":false");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);
|
||||
var dry = await new ImportService(db, dir, dir).RunAsync(execute: false);
|
||||
|
||||
Assert.Equal(1, dry.Litters.ParentFksBackfilled); // predicted but not written
|
||||
var litter = await db.Litters.SingleAsync(l => l.Name == "Wurf B");
|
||||
Assert.Null(litter.FatherId); // not written in dry-run
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UndatedLitters_counted_as_WithoutDate_not_Created()
|
||||
{
|
||||
// COUNTER-BUG regression: litters with no parseable date must go into WithoutDate,
|
||||
// NOT Created. On re-import, Created must be 0 (not 31-phantom-phantom-phantom...).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "undated-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
// One dated litter, one undated litter (blank date field)
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), """
|
||||
[
|
||||
{"id":"L-dated","litterId":"A","date":"01.02.2020","damName":"Mutter","sireName":"Vater","totalBorn":3,"zuchtnummer":"","note":""},
|
||||
{"id":"L-undated","litterId":"B","date":"","damName":"Mutter","sireName":"Vater","totalBorn":0,"zuchtnummer":"","note":""}
|
||||
]
|
||||
""");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), "[]");
|
||||
|
||||
using var db = NewDb();
|
||||
|
||||
// First run
|
||||
var r1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(2, r1.Litters.InSource);
|
||||
Assert.Equal(1, r1.Litters.Created); // only the dated one
|
||||
Assert.Equal(0, r1.Litters.AlreadyImported);
|
||||
Assert.Equal(1, r1.Litters.WithoutDate); // the undated one
|
||||
Assert.Equal(1, await db.Litters.CountAsync()); // only 1 persisted
|
||||
|
||||
// Second run (re-import): dated litter is now existing, undated still WithoutDate
|
||||
var r2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(0, r2.Litters.Created); // no phantom "created"
|
||||
Assert.Equal(1, r2.Litters.AlreadyImported);
|
||||
Assert.Equal(1, r2.Litters.WithoutDate);
|
||||
Assert.Equal(1, await db.Litters.CountAsync()); // still only 1 row
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("01.02.2020", 2020, 2, 1)]
|
||||
[InlineData("5.3.21", 2021, 3, 5)]
|
||||
@@ -139,7 +566,8 @@ namespace GerbilManager.Tests
|
||||
[
|
||||
{"id":"a1","name":"Kind Eins","dob":"01.02.2020","death":"","gender":null,
|
||||
"farbschlag":"Agouti","farbschlagVariants":["Agouti"],
|
||||
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","?"],"E":["e","e^f"]},"rawGenotype":"aa CC D- ee[f]","unmappedTokens":[]},
|
||||
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","?"],"E":["e","e^f"],"Sls":["Sl","sl"]},"rawGenotype":"aa CC D- ee[f] WP dea WFNZ","unmappedTokens":[]},
|
||||
"deaf":true,"tags":["WFNZ"],
|
||||
"zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false,
|
||||
"litterRef":{"litterId":"L1","method":"geburtsdatum+eltern","confidence":"hoch"}},
|
||||
{"id":"a2","name":"Streit","dob":"01.01.2019","death":"","gender":null,
|
||||
|
||||
217
GerbilManager.Tests/InboxDraftTests.cs
Normal file
217
GerbilManager.Tests/InboxDraftTests.cs
Normal file
@@ -0,0 +1,217 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Inbox;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// INBOX-2: KI-Antwortentwurf — Prompt-Assembly (inkl. Datenminimierung +
|
||||
/// Zitat-/Signatur-Stripping), 503-unkonfiguriert, und der Endpoint-Round-Trip
|
||||
/// gegen einen Stub-Anbieter (Entwurf wird gespeichert UND zurückgegeben).
|
||||
/// </summary>
|
||||
public class InboxDraftTests
|
||||
{
|
||||
private static Request SampleRequest() => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GmailMessageId = $"<{Guid.NewGuid():N}@mail.example>",
|
||||
FromAddress = "anna.musterfrau@example.de",
|
||||
FromName = "Anna Musterfrau",
|
||||
Subject = "Anfrage: zwei Weibchen?",
|
||||
BodyText = "Hallo! Habt ihr aktuell zwei junge Weibchen zur Abgabe?\nViele Grüße, Anna",
|
||||
ReceivedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
// ── Zitat-/Signatur-Stripping ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void StripQuotedText_entfernt_Zitate_Signatur_und_Outlook_Header()
|
||||
{
|
||||
var body = string.Join('\n',
|
||||
"Hallo, habt ihr Tiere abzugeben?",
|
||||
"> alte zitierte Zeile",
|
||||
"Von: jemand@example.de",
|
||||
"Danke!",
|
||||
"-- ",
|
||||
"Anna Musterfrau",
|
||||
"Musterweg 1");
|
||||
var stripped = DraftReplyService.StripQuotedText(body);
|
||||
|
||||
Assert.Contains("Hallo, habt ihr Tiere abzugeben?", stripped);
|
||||
Assert.Contains("Danke!", stripped);
|
||||
Assert.DoesNotContain("alte zitierte Zeile", stripped);
|
||||
Assert.DoesNotContain("jemand@example.de", stripped);
|
||||
Assert.DoesNotContain("Musterweg 1", stripped); // Signatur weg
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StripQuotedText_schneidet_beim_Gmail_Zitat_Intro_ab()
|
||||
{
|
||||
var body = "Meine Frage steht oben.\nAm 05.06.2026 um 10:00 schrieb Zucht der kleinen Chaoten:\n> früherer Verlauf";
|
||||
var stripped = DraftReplyService.StripQuotedText(body);
|
||||
|
||||
Assert.Equal("Meine Frage steht oben.", stripped);
|
||||
}
|
||||
|
||||
// ── Prompt-Assembly + Datenminimierung ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_enthält_Anfrage_und_Abgabeliste_aber_keine_Mailadresse()
|
||||
{
|
||||
var prompt = DraftReplyService.BuildUserPrompt(SampleRequest(),
|
||||
[new DraftReplyService.ForSaleAnimal("Frieda", "Gold"),
|
||||
new DraftReplyService.ForSaleAnimal("Fine", null)]);
|
||||
|
||||
Assert.Contains("Von: Anna Musterfrau", prompt);
|
||||
Assert.Contains("Betreff: Anfrage: zwei Weibchen?", prompt);
|
||||
Assert.Contains("zwei junge Weibchen zur Abgabe", prompt);
|
||||
Assert.Contains("- Frieda (Gold)", prompt);
|
||||
Assert.Contains("- Fine", prompt);
|
||||
// Datenminimierung: die E-Mail-Adresse der Absenderin geht NICHT zum Anbieter
|
||||
Assert.DoesNotContain("anna.musterfrau@example.de", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_ohne_Abgabetiere_enthält_keine_Liste()
|
||||
{
|
||||
var prompt = DraftReplyService.BuildUserPrompt(SampleRequest(), []);
|
||||
Assert.DoesNotContain("abzugebende Tiere", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemPrompt_verlangt_Entwurf_ohne_Preise_und_ohne_erfundene_Fakten()
|
||||
{
|
||||
var prompt = DraftReplyService.BuildSystemPrompt();
|
||||
Assert.Contains("ENTWERFEN", prompt);
|
||||
Assert.Contains("KEINE Fakten erfinden", prompt);
|
||||
Assert.Contains("Keine Preise", prompt);
|
||||
Assert.Contains("keine Adressen", prompt);
|
||||
}
|
||||
|
||||
// ── 503: unkonfiguriert (Service-Ebene) ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DraftAsync_meldet_NotConfigured_ohne_AI_Konfiguration()
|
||||
{
|
||||
var service = new DraftReplyService(
|
||||
new HttpClient(new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden"))),
|
||||
Options.Create(new AiOptions()));
|
||||
|
||||
var result = await service.DraftAsync(SampleRequest(), []);
|
||||
|
||||
Assert.Equal(GerbilManagerWebAPI.Ai.AiCallStatus.NotConfigured, result.Status);
|
||||
}
|
||||
|
||||
// ── Endpoint-Round-Trip ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_Endpoint_meldet_503_AiKeyMissing_wenn_unkonfiguriert()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var id = await SeedRequestAsync(factory);
|
||||
|
||||
var response = await factory.CreateClient().PostAsync($"/api/requests/{id}/draft", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
Assert.Contains("AiKeyMissing", await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_Endpoint_speichert_und_liefert_den_Entwurf_des_Stubs()
|
||||
{
|
||||
const string canned = "Hallo Anna,\n\nschön, dass du fragst — aktuell suchen Frieda und Fine ein Zuhause.\n\nHerzliche Grüße";
|
||||
var stub = new StubHandler(_ => Canned(canned));
|
||||
using var factory = new ApiFactory
|
||||
{
|
||||
ConfigureTestServices = services =>
|
||||
{
|
||||
services.PostConfigure<AiOptions>(o =>
|
||||
{
|
||||
o.BaseUrl = "https://api.example.com/v1";
|
||||
o.ApiKey = "test";
|
||||
o.Model = "test-model";
|
||||
});
|
||||
services.AddHttpClient<DraftReplyService>()
|
||||
.ConfigurePrimaryHttpMessageHandler(() => stub);
|
||||
},
|
||||
};
|
||||
|
||||
var id = await SeedRequestAsync(factory, alsoForSaleGerbil: true);
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync($"/api/requests/{id}/draft", null);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var dto = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
Assert.Equal(canned, dto.RootElement.GetProperty("draftReply").GetString());
|
||||
|
||||
// Persistiert: erneutes GET liefert den Entwurf
|
||||
var again = await client.GetFromJsonAsync<JsonElement>($"/api/requests/{id}");
|
||||
Assert.Equal(canned, again.GetProperty("draftReply").GetString());
|
||||
|
||||
// Wire-Privacy: Anfragetext + ForSale-Tier gingen zum Anbieter, die
|
||||
// Mailadresse der Absenderin NICHT.
|
||||
Assert.NotNull(stub.LastRequestBody);
|
||||
Assert.Contains("zwei junge Weibchen", stub.LastRequestBody);
|
||||
Assert.Contains("Aki", stub.LastRequestBody);
|
||||
Assert.DoesNotContain("anna.musterfrau@example.de", stub.LastRequestBody);
|
||||
}
|
||||
|
||||
// ── Helfer ─────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<Guid> SeedRequestAsync(ApiFactory factory, bool alsoForSaleGerbil = false)
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||
var request = SampleRequest();
|
||||
db.Add(request);
|
||||
if (alsoForSaleGerbil)
|
||||
{
|
||||
db.Add(new Gerbil
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Aki",
|
||||
Gender = Gender.female,
|
||||
Status = GerbilStatus.ForSale,
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return request.Id;
|
||||
}
|
||||
|
||||
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"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>HttpMessageHandler-Stub (Variante von SaleAdTests, hier mit Body-Capture).</summary>
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
public string? LastRequestBody { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequestBody = request.Content is null
|
||||
? null
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
return respond(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
82
GerbilManager.Tests/RequestEndpointsTests.cs
Normal file
82
GerbilManager.Tests/RequestEndpointsTests.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Inbox;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>INBOX-0 HTTP: sync endpoint imports (via a canned reader), status filter + triage,
|
||||
/// and the Gmail App Password is never returned by GET /api/mail-settings.</summary>
|
||||
public class RequestEndpointsTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public RequestEndpointsTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
/// <summary>Test double for the IMAP reader: returns whatever the test stages, filtered by UID.</summary>
|
||||
private sealed class CannedReader : IGmailMailReader
|
||||
{
|
||||
public static List<MailSummary> Messages { get; set; } = new();
|
||||
public Task<IReadOnlyList<MailSummary>> FetchAsync(MailConnection c, uint sinceUid, CancellationToken ct = default)
|
||||
=> Task.FromResult((IReadOnlyList<MailSummary>)Messages.Where(m => m.Uid > sinceUid).ToList());
|
||||
}
|
||||
|
||||
private HttpClient ClientWithCannedReader() =>
|
||||
_factory.WithWebHostBuilder(b => b.ConfigureTestServices(s =>
|
||||
{
|
||||
s.RemoveAll<IGmailMailReader>();
|
||||
s.AddScoped<IGmailMailReader, CannedReader>();
|
||||
})).CreateClient();
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_imports_then_triage_assigns_contact()
|
||||
{
|
||||
var client = ClientWithCannedReader();
|
||||
CannedReader.Messages = new()
|
||||
{
|
||||
new("<triage@1>", null, null, null, "interessent@example.de", "Anna", "Suche Rennmäuse", "Hallo!",
|
||||
DateTimeOffset.UtcNow, Uid: 1001),
|
||||
};
|
||||
|
||||
// configure mail (otherwise sync reports MailNotConfigured)
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await client.PutAsJsonAsync("/api/mail-settings",
|
||||
new { gmailAddress = "zucht@gmail.com", appPassword = "app-pw-xyz" })).StatusCode);
|
||||
|
||||
var sync = await client.PostAsync("/api/requests/sync", null);
|
||||
Assert.Equal(HttpStatusCode.OK, sync.StatusCode);
|
||||
Assert.True(JsonDocument.Parse(await sync.Content.ReadAsStringAsync()).RootElement.GetProperty("imported").GetInt32() >= 1);
|
||||
|
||||
// find the imported request via the status filter
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/api/requests?filter=status==New&pageSize=100"));
|
||||
var item = listed.RootElement.GetProperty("items").EnumerateArray()
|
||||
.Single(r => r.GetProperty("gmailMessageId").GetString() == "<triage@1>");
|
||||
var reqId = item.GetProperty("id").GetString();
|
||||
|
||||
// a contact + triage
|
||||
var contactId = JsonDocument.Parse(await (await client.PostAsJsonAsync("/contacts", new { name = "Anna" }))
|
||||
.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
var put = await client.PutAsJsonAsync($"/api/requests/{reqId}", new { status = "Assigned", assignedContactId = contactId });
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
var got = JsonDocument.Parse(await client.GetStringAsync($"/api/requests/{reqId}")).RootElement;
|
||||
Assert.Equal("Assigned", got.GetProperty("status").GetString());
|
||||
Assert.Equal(contactId, got.GetProperty("assignedContactId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MailSettings_stores_password_but_never_returns_it()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var put = await client.PutAsJsonAsync("/api/mail-settings",
|
||||
new { gmailAddress = "sekret@gmail.com", appPassword = "abcd efgh ijkl mnop" });
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
var json = await client.GetStringAsync("/api/mail-settings");
|
||||
Assert.Contains("sekret@gmail.com", json);
|
||||
Assert.Contains("\"hasAppPassword\":true", json.Replace(" ", ""));
|
||||
Assert.DoesNotContain("abcd", json); // the app password must never leave the server
|
||||
Assert.DoesNotContain("appPassword", json);
|
||||
}
|
||||
}
|
||||
86
GerbilManager.Tests/RequestSyncServiceTests.cs
Normal file
86
GerbilManager.Tests/RequestSyncServiceTests.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using GerbilManagerWebAPI.Inbox;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>INBOX-0: sync dedup + idempotency + config-guard, against an in-memory DB and a
|
||||
/// fake IGmailMailReader (the live Gmail/MailKit path is gated on Julian's App Password).</summary>
|
||||
public class RequestSyncServiceTests
|
||||
{
|
||||
private static ApplicationContext NewDb()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("inbox-" + Guid.NewGuid().ToString("N")).Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated(); // seeds the MailSettings singleton
|
||||
return db;
|
||||
}
|
||||
|
||||
private sealed class FakeReader : IGmailMailReader
|
||||
{
|
||||
public List<MailSummary> Canned { get; set; } = new();
|
||||
public Task<IReadOnlyList<MailSummary>> FetchAsync(MailConnection c, uint sinceUid, CancellationToken ct = default)
|
||||
=> Task.FromResult((IReadOnlyList<MailSummary>)Canned.Where(m => m.Uid > sinceUid).ToList());
|
||||
}
|
||||
|
||||
private static MailSummary M(uint uid, string msgId, string from) =>
|
||||
new(msgId, ThreadId: null, InReplyTo: null, References: null,
|
||||
FromAddress: from, FromName: null, Subject: "Anfrage", BodyText: "Hallo",
|
||||
ReceivedAt: DateTimeOffset.UtcNow, Uid: uid);
|
||||
|
||||
private static async Task<(ApplicationContext db, RequestSyncService sync, FakeReader reader)> SetupAsync(bool configured = true)
|
||||
{
|
||||
var db = NewDb();
|
||||
var settings = new MailSettingsService(db, new EphemeralDataProtectionProvider());
|
||||
var s = await settings.GetAsync();
|
||||
if (configured)
|
||||
{
|
||||
s.GmailAddress = "zucht@gmail.com";
|
||||
settings.SetPassword(s, "app-pw-1234");
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
var reader = new FakeReader();
|
||||
return (db, new RequestSyncService(db, reader, settings), reader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_imports_new_and_dedups_within_batch()
|
||||
{
|
||||
var (db, sync, reader) = await SetupAsync();
|
||||
reader.Canned = new() { M(1, "<a@x>", "a@x.de"), M(2, "<b@y>", "b@y.de"), M(3, "<b@y>", "b@y.de") };
|
||||
|
||||
var res = await sync.SyncAsync();
|
||||
|
||||
Assert.Null(res.Error);
|
||||
Assert.Equal(2, res.Imported); // <b@y> deduped
|
||||
Assert.Equal(2, await db.Requests.CountAsync());
|
||||
Assert.All(await db.Requests.ToListAsync(), r => Assert.Equal(RequestStatus.New, r.Status));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resync_imports_nothing_new()
|
||||
{
|
||||
var (db, sync, reader) = await SetupAsync();
|
||||
reader.Canned = new() { M(1, "<a@x>", "a@x.de"), M(2, "<b@y>", "b@y.de") };
|
||||
await sync.SyncAsync();
|
||||
|
||||
var second = await sync.SyncAsync(); // LastUid advanced -> reader returns nothing newer
|
||||
|
||||
Assert.Equal(0, second.Imported);
|
||||
Assert.Equal(2, await db.Requests.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_without_config_reports_not_configured()
|
||||
{
|
||||
var (_, sync, reader) = await SetupAsync(configured: false);
|
||||
reader.Canned = new() { M(1, "<a@x>", "a@x.de") };
|
||||
|
||||
var res = await sync.SyncAsync();
|
||||
|
||||
Assert.Equal("MailNotConfigured", res.Error);
|
||||
Assert.Equal(0, res.Imported);
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,63 @@ namespace GerbilManager.Tests
|
||||
Assert.Contains("Großer und kleiner Bruder Dynamik", prompt);
|
||||
}
|
||||
|
||||
// ── FEAT-14c: Charakterbogen (Traits + Notiz) im Prompt ────────────
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_enthält_Traits_und_Charakternotiz()
|
||||
{
|
||||
var request = new SaleAdRequest(
|
||||
[new SaleAdAnimal("Balu", "CP-Agouti", "2024-03-12", "alte Verwaltungsnotiz",
|
||||
Traits: ["zutraulich", "buddelt gern"],
|
||||
CharacterNote: "klettert abends auf die Hand")],
|
||||
"FREI", "");
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
|
||||
|
||||
Assert.Contains("Charakter: zutraulich, buddelt gern", prompt);
|
||||
Assert.Contains("Charakter-Notiz: klettert abends auf die Hand", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_bevorzugt_Charakterbogen_vor_generischen_Notizen()
|
||||
{
|
||||
// Traits vorhanden -> die generische Notiz (oft Verwaltungs-Info)
|
||||
// gehört NICHT in den Prompt.
|
||||
var request = new SaleAdRequest(
|
||||
[new SaleAdAnimal("Balu", null, null, "Käfig wurde am 3.5. gereinigt",
|
||||
Traits: ["neugierig"])],
|
||||
"FREI", "");
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
|
||||
|
||||
Assert.Contains("Charakter: neugierig", prompt);
|
||||
Assert.DoesNotContain("Notizen:", prompt);
|
||||
Assert.DoesNotContain("Käfig wurde", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_fällt_ohne_Charakterbogen_auf_Notizen_zurück()
|
||||
{
|
||||
// Kein Charakterbogen (null bzw. leer) -> Notizen wie bisher.
|
||||
var request = new SaleAdRequest(
|
||||
[
|
||||
new SaleAdAnimal("Mysti", null, null, "sehr verschmust", Traits: null),
|
||||
new SaleAdAnimal("Bo", null, null, "mag Kolbenhirse", Traits: []),
|
||||
],
|
||||
"FREI", "");
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
|
||||
|
||||
Assert.Contains("Notizen: sehr verschmust", prompt);
|
||||
Assert.Contains("Notizen: mag Kolbenhirse", prompt);
|
||||
Assert.DoesNotContain("Charakter:", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemPrompt_verlangt_fließende_Prosa_statt_Stichwortliste()
|
||||
{
|
||||
var prompt = SaleAdPromptBuilder.BuildSystemPrompt();
|
||||
Assert.Contains("Charakter-Eigenschaften", prompt);
|
||||
Assert.Contains("niemals als Aufzählung", prompt);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2024-03-12", "12.03.2024")]
|
||||
[InlineData("kaputt", "kaputt")] // unparsebar -> unverändert durchreichen
|
||||
|
||||
150
GerbilManager.Tests/SearchVariationsTests.cs
Normal file
150
GerbilManager.Tests/SearchVariationsTests.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// SEARCH verification — every search variation Julian asked about, against the
|
||||
/// REAL endpoints (SQLite host). Covers: substring/contains, case-insensitivity,
|
||||
/// the no-trailing-* contains fix, separator-insensitive nameSearch (spaces ==
|
||||
/// hyphens), umlauts, the OriginBreeder (Herkunft) filter, the /gerbils/breeders
|
||||
/// distinct dropdown source, paged envelope, and a combined status+name filter.
|
||||
/// Each test uses a unique token so the shared class DB doesn't cross-contaminate.
|
||||
/// </summary>
|
||||
public class SearchVariationsTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
public SearchVariationsTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
private async Task Create(string name, string? originBreeder = null, string status = "Active")
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync("/gerbils",
|
||||
new { name, gender = "female", status, originBreeder });
|
||||
resp.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>GET /gerbils?filter=... → the list of returned names.</summary>
|
||||
private async Task<List<string>> Names(string filter)
|
||||
{
|
||||
var url = $"/gerbils?filter={Uri.EscapeDataString(filter)}&page=1&pageSize=200";
|
||||
var doc = JsonDocument.Parse(await _client.GetStringAsync(url));
|
||||
return doc.RootElement.GetProperty("items").EnumerateArray()
|
||||
.Select(x => x.GetProperty("name").GetString()!).ToList();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Contains_matches_substring_case_insensitively()
|
||||
{
|
||||
await Create("SuchSchokoEins");
|
||||
await Create("SuchSchokoladeZwei");
|
||||
await Create("SuchKeksDrei");
|
||||
|
||||
// lowercase query, capitalised names → exercises /i contains
|
||||
var hits = await Names("name=*suchschoko/i");
|
||||
Assert.Contains("SuchSchokoEins", hits);
|
||||
Assert.Contains("SuchSchokoladeZwei", hits);
|
||||
Assert.DoesNotContain("SuchKeksDrei", hits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Contains_single_letter_returns_matches_not_the_trailing_star_bug()
|
||||
{
|
||||
// Regression guard for the contains hotfix (0ee2b53): the frontend used to
|
||||
// build name=*VALUE* which Gridify read as literal "VALUE*" → 0 rows.
|
||||
// Correct form name=*VALUE must return every name containing VALUE.
|
||||
await Create("BugGuardAlpha");
|
||||
await Create("BugGuardBeta");
|
||||
var hits = await Names("name=*bugguard/i");
|
||||
Assert.Equal(2, hits.Count(n => n.StartsWith("BugGuard")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NameSearch_is_separator_insensitive_spaces_equal_hyphens()
|
||||
{
|
||||
// The headline ask: "clan kleine chaoten" must match "clan-kleine-chaoten".
|
||||
await Create("SepClan-Kleine-Chaoten");
|
||||
await Create("SepClan Kleine Chaoten");
|
||||
await Create("SepClanKleineChaoten");
|
||||
await Create("SepUnrelatedMaus");
|
||||
|
||||
// Frontend normalizes the typed term the same way the backend stores it:
|
||||
// lowercase + strip whitespace/.-_ → "sepclankleinechaoten".
|
||||
var hits = await Names("nameSearch=*sepclankleinechaoten");
|
||||
|
||||
Assert.Contains("SepClan-Kleine-Chaoten", hits); // hyphens
|
||||
Assert.Contains("SepClan Kleine Chaoten", hits); // spaces
|
||||
Assert.Contains("SepClanKleineChaoten", hits); // none
|
||||
Assert.DoesNotContain("SepUnrelatedMaus", hits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NameSearch_dotted_and_underscored_separators_also_match()
|
||||
{
|
||||
await Create("SepDot.Von.Den.Chaoten");
|
||||
await Create("SepDot_Von_Den_Chaoten");
|
||||
var hits = await Names("nameSearch=*sepdotvondenchaoten");
|
||||
Assert.Equal(2, hits.Count(n => n.StartsWith("SepDot")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NameSearch_handles_umlauts()
|
||||
{
|
||||
await Create("SuchKrümel von den Chaoten");
|
||||
// nameSearch is stored pre-lowercased; the frontend sends a pre-lowercased
|
||||
// normalized term, so this works without provider unicode case-folding.
|
||||
var hits = await Names("nameSearch=*suchkrümelvondenchaoten");
|
||||
Assert.Contains("SuchKrümel von den Chaoten", hits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OriginBreeder_filter_returns_only_that_breeder()
|
||||
{
|
||||
await Create("HerkunftA1", originBreeder: "Zucht HerkunftTest Eins");
|
||||
await Create("HerkunftA2", originBreeder: "Zucht HerkunftTest Eins");
|
||||
await Create("HerkunftB1", originBreeder: "Zucht HerkunftTest Zwei");
|
||||
|
||||
var hits = await Names("originBreeder==Zucht HerkunftTest Eins");
|
||||
Assert.Contains("HerkunftA1", hits);
|
||||
Assert.Contains("HerkunftA2", hits);
|
||||
Assert.DoesNotContain("HerkunftB1", hits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Breeders_endpoint_returns_distinct_nonempty_sorted_values()
|
||||
{
|
||||
await Create("DistinctX1", originBreeder: "ZZZ Distinct Eins");
|
||||
await Create("DistinctX2", originBreeder: "ZZZ Distinct Eins"); // duplicate breeder
|
||||
await Create("DistinctX3", originBreeder: "ZZZ Distinct Zwei");
|
||||
await Create("DistinctNoBreeder", originBreeder: null);
|
||||
|
||||
var list = JsonDocument.Parse(await _client.GetStringAsync("/gerbils/breeders"))
|
||||
.RootElement.EnumerateArray().Select(x => x.GetString()!).ToList();
|
||||
|
||||
Assert.Contains("ZZZ Distinct Eins", list);
|
||||
Assert.Contains("ZZZ Distinct Zwei", list);
|
||||
Assert.Equal(1, list.Count(v => v == "ZZZ Distinct Eins")); // de-duplicated
|
||||
Assert.DoesNotContain(list, string.IsNullOrWhiteSpace); // no empty/null
|
||||
Assert.Equal(list.OrderBy(v => v, StringComparer.Ordinal).ToList(), list); // sorted
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_returns_paged_envelope()
|
||||
{
|
||||
var doc = JsonDocument.Parse(await _client.GetStringAsync("/gerbils?page=1&pageSize=5"));
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.TryGetProperty("items", out _));
|
||||
Assert.True(root.TryGetProperty("totalCount", out _));
|
||||
Assert.Equal(1, root.GetProperty("page").GetInt32());
|
||||
Assert.Equal(5, root.GetProperty("pageSize").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Combined_status_and_name_filter()
|
||||
{
|
||||
await Create("KombiVerkauf", status: "ForSale");
|
||||
await Create("KombiAktiv", status: "Active");
|
||||
var hits = await Names("status==ForSale,nameSearch=*kombi");
|
||||
Assert.Contains("KombiVerkauf", hits);
|
||||
Assert.DoesNotContain("KombiAktiv", hits);
|
||||
}
|
||||
}
|
||||
120
GerbilManager.Tests/SendReplyServiceTests.cs
Normal file
120
GerbilManager.Tests/SendReplyServiceTests.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using GerbilManagerWebAPI.Inbox;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using MailKit.Security;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>INBOX-3: threaded reply send — correct In-Reply-To/References/Subject, status
|
||||
/// transition to Answered, and the not-configured / auth-failure error paths. Fake SMTP
|
||||
/// sender (live Gmail gated on Julian's App Password).</summary>
|
||||
public class SendReplyServiceTests
|
||||
{
|
||||
private sealed class CapturingSender : IGmailMailSender
|
||||
{
|
||||
public OutgoingMail? Last;
|
||||
public bool ThrowAuth;
|
||||
public Task SendAsync(MailConnection c, OutgoingMail m, CancellationToken ct = default)
|
||||
{
|
||||
if (ThrowAuth) throw new AuthenticationException("bad app password");
|
||||
Last = m;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static ApplicationContext NewDb()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("send-" + Guid.NewGuid().ToString("N")).Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
return db;
|
||||
}
|
||||
|
||||
private static async Task<(ApplicationContext db, SendReplyService svc, CapturingSender sender, Request req)>
|
||||
SetupAsync(bool configured = true, string subject = "Anfrage Rennmäuse", string? refs = "<a@x> <b@y>")
|
||||
{
|
||||
var db = NewDb();
|
||||
var settings = new MailSettingsService(db, new EphemeralDataProtectionProvider());
|
||||
var s = await settings.GetAsync();
|
||||
if (configured) { s.GmailAddress = "zucht@gmail.com"; settings.SetPassword(s, "app-pw"); }
|
||||
var req = new Request
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GmailMessageId = "<orig@gmail>",
|
||||
ReferencesHeader = refs,
|
||||
FromAddress = "kunde@web.de",
|
||||
Subject = subject,
|
||||
Status = RequestStatus.New,
|
||||
ReceivedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.Requests.Add(req);
|
||||
await db.SaveChangesAsync();
|
||||
var sender = new CapturingSender();
|
||||
return (db, new SendReplyService(db, sender, settings), sender, req);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_threads_and_marks_answered()
|
||||
{
|
||||
var (db, svc, sender, req) = await SetupAsync();
|
||||
|
||||
var outcome = await svc.SendAsync(req, "Hallo, sehr gerne!");
|
||||
|
||||
Assert.True(outcome.Sent);
|
||||
Assert.Null(outcome.Error);
|
||||
Assert.NotNull(sender.Last);
|
||||
Assert.Equal("kunde@web.de", sender.Last!.To);
|
||||
Assert.Equal("Re: Anfrage Rennmäuse", sender.Last.Subject);
|
||||
Assert.Equal("<orig@gmail>", sender.Last.InReplyTo);
|
||||
Assert.Equal("<a@x> <b@y> <orig@gmail>", sender.Last.References); // chain + original
|
||||
|
||||
var saved = await db.Requests.SingleAsync();
|
||||
Assert.Equal(RequestStatus.Answered, saved.Status);
|
||||
Assert.NotNull(saved.AnsweredAt);
|
||||
Assert.Equal("Hallo, sehr gerne!", saved.DraftReply);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_does_not_double_prefix_re_subject()
|
||||
{
|
||||
var (_, svc, sender, req) = await SetupAsync(subject: "Re: läuft schon", refs: null);
|
||||
await svc.SendAsync(req, "ok");
|
||||
Assert.Equal("Re: läuft schon", sender.Last!.Subject);
|
||||
Assert.Equal("<orig@gmail>", sender.Last.References); // no prior chain -> just the original
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_without_config_fails_and_keeps_status()
|
||||
{
|
||||
var (db, svc, sender, req) = await SetupAsync(configured: false);
|
||||
var outcome = await svc.SendAsync(req, "ok");
|
||||
Assert.Equal("MailNotConfigured", outcome.Error);
|
||||
Assert.Null(sender.Last);
|
||||
Assert.Equal(RequestStatus.New, (await db.Requests.SingleAsync()).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Send_auth_failure_does_not_mark_answered()
|
||||
{
|
||||
var (db, svc, sender, req) = await SetupAsync();
|
||||
sender.ThrowAuth = true;
|
||||
var outcome = await svc.SendAsync(req, "ok");
|
||||
Assert.Equal("MailAuthFailed", outcome.Error);
|
||||
Assert.Equal(RequestStatus.New, (await db.Requests.SingleAsync()).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMimeMessage_sets_threading_headers()
|
||||
{
|
||||
var msg = GmailMailSender.BuildMimeMessage("zucht@gmail.com",
|
||||
new OutgoingMail("kunde@web.de", "Re: Test", "Hallo", "<orig@gmail>", "<a@x> <orig@gmail>"));
|
||||
|
||||
Assert.Equal("Re: Test", msg.Subject);
|
||||
Assert.Contains("kunde@web.de", msg.To.ToString());
|
||||
Assert.Contains("orig@gmail", msg.InReplyTo);
|
||||
Assert.Contains("orig@gmail", string.Join(' ', msg.References));
|
||||
Assert.Contains("a@x", string.Join(' ', msg.References));
|
||||
}
|
||||
}
|
||||
331
GerbilManager.Tests/SiteRendererTests.cs
Normal file
331
GerbilManager.Tests/SiteRendererTests.cs
Normal file
@@ -0,0 +1,331 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Cms;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WEB-1: static site renderer — snapshot → HTML file map.
|
||||
/// All tests are pure unit tests (no DB, no HTTP); they build JsonObject
|
||||
/// snapshots directly and assert on rendered HTML.
|
||||
/// </summary>
|
||||
public class SiteRendererTests
|
||||
{
|
||||
// ── Snapshot builder helpers ─────────────────────────────────────────────
|
||||
|
||||
private static JsonObject MakeSnapshot(params (string slug, string title, JsonArray blocks)[] pages)
|
||||
{
|
||||
var navOrder = new JsonArray();
|
||||
var pagesArr = new JsonArray();
|
||||
foreach (var (slug, title, blocks) in pages)
|
||||
{
|
||||
navOrder.Add(slug);
|
||||
pagesArr.Add(new JsonObject
|
||||
{
|
||||
["slug"] = slug,
|
||||
["title"] = title,
|
||||
["seoDescription"] = $"SEO für {title}",
|
||||
["status"] = "Published",
|
||||
["blocks"] = blocks,
|
||||
});
|
||||
}
|
||||
return new JsonObject
|
||||
{
|
||||
["site"] = new JsonObject { ["defaultLocale"] = "de", ["navOrder"] = navOrder },
|
||||
["pages"] = pagesArr,
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonArray Blocks(params JsonObject[] blocks)
|
||||
{
|
||||
var arr = new JsonArray();
|
||||
int i = 0;
|
||||
foreach (var b in blocks) { b["order"] = i++; arr.Add(b); }
|
||||
return arr;
|
||||
}
|
||||
|
||||
private static JsonObject HeadingBlock(string text, int level = 1) => new()
|
||||
{
|
||||
["type"] = "Heading",
|
||||
["data"] = new JsonObject { ["text"] = text, ["level"] = level },
|
||||
};
|
||||
|
||||
private static JsonObject RichTextBlock(string md) => new()
|
||||
{
|
||||
["type"] = "RichText",
|
||||
["data"] = new JsonObject { ["markdown"] = md },
|
||||
};
|
||||
|
||||
private static JsonObject ImageBlock(string url, string alt) => new()
|
||||
{
|
||||
["type"] = "Image",
|
||||
["data"] = new JsonObject { ["url"] = url, ["alt"] = alt },
|
||||
};
|
||||
|
||||
private static JsonObject ContactInfoBlock(string name, string email, string phone) => new()
|
||||
{
|
||||
["type"] = "ContactInfo",
|
||||
["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone },
|
||||
};
|
||||
|
||||
private static JsonObject AbgabetiereBlock(string intro, params (string name, string farbe, string? group, string? photo)[] animals)
|
||||
{
|
||||
var animalArr = new JsonArray();
|
||||
foreach (var (name, farbe, group, photo) in animals)
|
||||
{
|
||||
var photos = new JsonArray();
|
||||
if (photo is not null) photos.Add(photo);
|
||||
animalArr.Add(new JsonObject
|
||||
{
|
||||
["name"] = name,
|
||||
["farbschlag"] = farbe,
|
||||
["group"] = group,
|
||||
["photos"] = photos,
|
||||
["aiSaleText"] = (JsonNode?)null,
|
||||
});
|
||||
}
|
||||
return new JsonObject
|
||||
{
|
||||
["type"] = "AbgabetiereList",
|
||||
["data"] = new JsonObject { ["mode"] = "auto", ["intro"] = intro, ["animals"] = animalArr },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Render_produces_index_html_for_start_page()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Startseite", Blocks(HeadingBlock("Willkommen"))));
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey("index.html"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_produces_slug_subfolder_for_non_start_pages()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Start", Blocks()),
|
||||
("abgabetiere", "Abgabetiere", Blocks()));
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey("abgabetiere/index.html"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_includes_css_asset()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks()));
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey(SiteRenderer.CssPath));
|
||||
Assert.Contains("site-header", files[SiteRenderer.CssPath]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_html_is_valid_doctype_and_contains_title()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Startseite", Blocks(HeadingBlock("Hallo"))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.StartsWith("<!DOCTYPE html>", html);
|
||||
Assert.Contains("<title>Startseite |", html);
|
||||
Assert.Contains("lang=\"de\"", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_html_includes_seo_description()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks()));
|
||||
// seoDescription is set to "SEO für Start" by our helper
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("meta name=\"description\"", html);
|
||||
Assert.Contains("SEO für Start", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nav_links_are_rendered_with_all_slugs()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Start", Blocks()),
|
||||
("abgabetiere", "Abgabetiere", Blocks()),
|
||||
("kontakt", "Kontakt", Blocks()));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("Abgabetiere", html);
|
||||
Assert.Contains("Kontakt", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Current_page_nav_link_has_aria_current()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Start", Blocks()),
|
||||
("abgabetiere", "Abgabetiere", Blocks()));
|
||||
var abgHtml = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||
Assert.Contains("aria-current=\"page\"", abgHtml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Heading_block_renders_h_tag_with_text()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks(HeadingBlock("Meine Rennmäuse", 2))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("<h2", html);
|
||||
Assert.Contains("Meine Rennmäuse", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RichText_block_renders_markdown_bold_and_paragraph()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks(RichTextBlock("**Hallo** Welt"))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("<strong>Hallo</strong>", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RichText_block_renders_markdown_list()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks(RichTextBlock("- Eins\n- Zwei"))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("<ul>", html);
|
||||
Assert.Contains("<li>", html);
|
||||
Assert.Contains("Eins", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Image_block_renders_img_with_alt()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks(ImageBlock("/img/maus.jpg", "Eine Rennmaus"))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("src=\"/img/maus.jpg\"", html);
|
||||
Assert.Contains("alt=\"Eine Rennmaus\"", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContactInfo_block_renders_name_and_email()
|
||||
{
|
||||
var snap = MakeSnapshot(("kontakt", "Kontakt",
|
||||
Blocks(ContactInfoBlock("Kleine Chaoten", "info@kleine-chaoten.de", "+49 123 456789"))));
|
||||
var html = SiteRenderer.Render(snap)["kontakt/index.html"];
|
||||
Assert.Contains("Kleine Chaoten", html);
|
||||
Assert.Contains("info@kleine-chaoten.de", html);
|
||||
Assert.Contains("+49 123 456789", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbgabetiereList_renders_animal_cards_with_name_and_farbschlag()
|
||||
{
|
||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg")))));
|
||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||
Assert.Contains("Krümel", html);
|
||||
Assert.Contains("CP-Agouti", html);
|
||||
Assert.Contains("Großbecken", html);
|
||||
Assert.Contains("/photos/files/abc.jpg", html);
|
||||
Assert.Contains("cms-animal-card", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbgabetiereList_empty_shows_placeholder_text()
|
||||
{
|
||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||
Blocks(AbgabetiereBlock(""))));
|
||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||
Assert.Contains("cms-abgabe-empty", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Draft_pages_are_excluded_from_output()
|
||||
{
|
||||
var pages = new JsonArray
|
||||
{
|
||||
new JsonObject { ["slug"] = "start", ["title"] = "Start", ["status"] = "Published",
|
||||
["seoDescription"] = (JsonNode?)null, ["blocks"] = new JsonArray() },
|
||||
new JsonObject { ["slug"] = "entwurf", ["title"] = "Entwurf", ["status"] = "Draft",
|
||||
["seoDescription"] = (JsonNode?)null, ["blocks"] = new JsonArray() },
|
||||
};
|
||||
var snap = new JsonObject
|
||||
{
|
||||
["site"] = new JsonObject { ["defaultLocale"] = "de", ["navOrder"] = new JsonArray { "start", "entwurf" } },
|
||||
["pages"] = pages,
|
||||
};
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey("index.html"));
|
||||
Assert.False(files.ContainsKey("entwurf/index.html"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Html_encoding_prevents_xss_in_page_title()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "<script>alert(1)</script>", Blocks()));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.DoesNotContain("<script>", html);
|
||||
Assert.Contains("<script>", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_six_seeded_pages_produces_expected_paths()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Startseite", Blocks()),
|
||||
("ueber-die-zucht", "Über die Zucht", Blocks()),
|
||||
("abgabetiere", "Abgabetiere", Blocks(AbgabetiereBlock(""))),
|
||||
("abgabebedingungen", "Abgabebedingungen", Blocks()),
|
||||
("farben-genetik", "Farben & Genetik", Blocks()),
|
||||
("kontakt", "Kontakt", Blocks()));
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey("index.html"));
|
||||
Assert.True(files.ContainsKey("ueber-die-zucht/index.html"));
|
||||
Assert.True(files.ContainsKey("abgabetiere/index.html"));
|
||||
Assert.True(files.ContainsKey("abgabebedingungen/index.html"));
|
||||
Assert.True(files.ContainsKey("farben-genetik/index.html"));
|
||||
Assert.True(files.ContainsKey("kontakt/index.html"));
|
||||
Assert.True(files.ContainsKey(SiteRenderer.CssPath));
|
||||
// 6 pages + 1 CSS
|
||||
Assert.Equal(7, files.Count);
|
||||
}
|
||||
|
||||
// ── Markdown tests ───────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("**fett**", "<strong>fett</strong>")]
|
||||
[InlineData("*kursiv*", "<em>kursiv</em>")]
|
||||
[InlineData("`code`", "<code>code</code>")]
|
||||
public void Markdown_inline_renders_correctly(string md, string expectedFragment)
|
||||
{
|
||||
var html = SiteRenderer.MarkdownToHtml(md);
|
||||
Assert.Contains(expectedFragment, html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Markdown_heading_renders_h_tag()
|
||||
{
|
||||
var html = SiteRenderer.MarkdownToHtml("## Abschnitt");
|
||||
Assert.Contains("<h2>Abschnitt</h2>", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Markdown_xss_text_is_encoded()
|
||||
{
|
||||
var html = SiteRenderer.MarkdownToHtml("<script>alert(1)</script>");
|
||||
Assert.DoesNotContain("<script>", html);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>WEB-1: smoke-test the /api/render-site endpoint via ApiFactory.</summary>
|
||||
public class RenderSiteEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
public RenderSiteEndpointTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
[Fact]
|
||||
public async Task RenderSite_returns_file_list_with_css_and_pages()
|
||||
{
|
||||
var json = await _client.GetStringAsync("/api/render-site");
|
||||
var files = JsonSerializer.Deserialize<List<RenderEntry>>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
Assert.Contains(files, f => f.Path == SiteRenderer.CssPath);
|
||||
Assert.Contains(files, f => f.Path == "index.html");
|
||||
Assert.All(files, f => Assert.True(f.Size > 0));
|
||||
}
|
||||
|
||||
private record RenderEntry(string Path, int Size);
|
||||
}
|
||||
99
GerbilManagerWebAPI/Ai/OpenAiChatClient.cs
Normal file
99
GerbilManagerWebAPI/Ai/OpenAiChatClient.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManagerWebAPI.Ai
|
||||
{
|
||||
public enum AiCallStatus
|
||||
{
|
||||
Ok,
|
||||
/// <summary>AI section not (fully) configured -> callers map to 503 "AiKeyMissing".</summary>
|
||||
NotConfigured,
|
||||
/// <summary>Provider call failed -> callers map to 502 "AiUpstreamError".</summary>
|
||||
UpstreamError,
|
||||
}
|
||||
|
||||
public sealed record AiCallResult(AiCallStatus Status, string? Text, string? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// INBOX-2: provider-agnostic chat client, extracted from SaleAdService so the
|
||||
/// reply-draft (and future AI features) reuse the SAME wire implementation:
|
||||
/// plain JSON POST to {AI:BaseUrl}/chat/completions with a Bearer key — covers
|
||||
/// Gemini (compat endpoint), Groq, Mistral, local Ollama; no vendor SDK.
|
||||
/// Configuration stays the single AI section (AiOptions, env-only).
|
||||
/// </summary>
|
||||
public sealed class OpenAiChatClient(HttpClient http, IOptions<AiOptions> options)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
public async Task<AiCallResult> CompleteAsync(
|
||||
string systemPrompt, string userPrompt, CancellationToken ct = default)
|
||||
{
|
||||
var ai = options.Value;
|
||||
if (!ai.IsConfigured)
|
||||
{
|
||||
return new AiCallResult(AiCallStatus.NotConfigured, null,
|
||||
"KI-Anbieter ist nicht konfiguriert (AI__BaseUrl / AI__ApiKey / AI__Model).");
|
||||
}
|
||||
|
||||
var payload = new ChatRequest(
|
||||
Model: ai.Model!,
|
||||
Messages:
|
||||
[
|
||||
new ChatMessage("system", systemPrompt),
|
||||
new ChatMessage("user", userPrompt),
|
||||
],
|
||||
Temperature: 0.7);
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, BuildCompletionsUri(ai.BaseUrl!))
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions),
|
||||
Encoding.UTF8, "application/json"),
|
||||
};
|
||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ai.ApiKey);
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await http.SendAsync(httpRequest, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new AiCallResult(AiCallStatus.UpstreamError, null,
|
||||
$"KI-Anbieter antwortete mit HTTP {(int)response.StatusCode}.");
|
||||
}
|
||||
|
||||
var completion = JsonSerializer.Deserialize<ChatResponse>(body, JsonOptions);
|
||||
var text = completion?.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
|
||||
return string.IsNullOrWhiteSpace(text)
|
||||
? new AiCallResult(AiCallStatus.UpstreamError, null,
|
||||
"KI-Antwort enthielt keinen Text.")
|
||||
: new AiCallResult(AiCallStatus.Ok, text);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
|
||||
{
|
||||
return new AiCallResult(AiCallStatus.UpstreamError, null,
|
||||
$"KI-Anbieter nicht erreichbar: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>{BaseUrl}/chat/completions — tolerant of a trailing slash on BaseUrl.</summary>
|
||||
internal static Uri BuildCompletionsUri(string baseUrl) =>
|
||||
new($"{baseUrl.TrimEnd('/')}/chat/completions");
|
||||
|
||||
// ── OpenAI-compatible wire records (request + the slice of the response we read) ──
|
||||
internal sealed record ChatRequest(string Model, List<ChatMessage> Messages, double? Temperature);
|
||||
|
||||
internal sealed record ChatMessage(string Role, string Content);
|
||||
|
||||
internal sealed record ChatResponse(List<ChatChoice>? Choices);
|
||||
|
||||
internal sealed record ChatChoice(ChatMessage? Message);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,35 @@ public class ApplicationContext : DbContext
|
||||
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
|
||||
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
|
||||
public DbSet<BreederSettings> BreederSettings => Set<BreederSettings>();
|
||||
public DbSet<Site> Sites => Set<Site>();
|
||||
public DbSet<Page> Pages => Set<Page>();
|
||||
public DbSet<Block> Blocks => Set<Block>();
|
||||
public DbSet<Media> Media => Set<Media>();
|
||||
public DbSet<Request> Requests => Set<Request>();
|
||||
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
|
||||
|
||||
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
|
||||
// so it can never drift from Name regardless of which code path mutates the entity.
|
||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||
{
|
||||
SyncNameSearch();
|
||||
return base.SaveChanges(acceptAllChangesOnSuccess);
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
|
||||
{
|
||||
SyncNameSearch();
|
||||
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
|
||||
}
|
||||
|
||||
private void SyncNameSearch()
|
||||
{
|
||||
foreach (var entry in ChangeTracker.Entries<Gerbil>())
|
||||
{
|
||||
if (entry.State is EntityState.Added or EntityState.Modified)
|
||||
entry.Entity.NameSearch = GerbilSearch.Normalize(entry.Entity.Name);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -26,6 +55,22 @@ public class ApplicationContext : DbContext
|
||||
e.Property(g => g.Gender).HasConversion<string>();
|
||||
e.Property(g => g.Status).HasConversion<string>();
|
||||
|
||||
// Residency defaults to true (own stock unless explicitly marked external).
|
||||
e.Property(g => g.IsResident).HasDefaultValue(true);
|
||||
|
||||
// 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<List<string>, string>(
|
||||
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
|
||||
v => string.IsNullOrEmpty(v)
|
||||
? new List<string>()
|
||||
: System.Text.Json.JsonSerializer.Deserialize<List<string>>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List<string>());
|
||||
var traitsComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<List<string>>(
|
||||
(a, b) => (a ?? new List<string>()).SequenceEqual(b ?? new List<string>()),
|
||||
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()
|
||||
@@ -86,9 +131,98 @@ public class ApplicationContext : DbContext
|
||||
modelBuilder.Entity<BreederSettings>()
|
||||
.HasData(new BreederSettings { Id = GerbilManagerWebAPI.Models.BreederSettings.SingletonId });
|
||||
|
||||
// WEB epic: CMS content model.
|
||||
modelBuilder.Entity<Site>(e =>
|
||||
{
|
||||
var navConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<Guid>, string>(
|
||||
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
|
||||
v => string.IsNullOrEmpty(v)
|
||||
? new List<Guid>()
|
||||
: System.Text.Json.JsonSerializer.Deserialize<List<Guid>>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List<Guid>());
|
||||
var navComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<List<Guid>>(
|
||||
(a, b) => (a ?? new List<Guid>()).SequenceEqual(b ?? new List<Guid>()),
|
||||
v => v == null ? 0 : v.Aggregate(0, (h, x) => HashCode.Combine(h, x.GetHashCode())),
|
||||
v => v.ToList());
|
||||
e.Property(s => s.NavOrder).HasConversion(navConverter, navComparer);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Page>(e =>
|
||||
{
|
||||
e.HasIndex(p => p.Slug).IsUnique();
|
||||
e.Property(p => p.Status).HasConversion<string>();
|
||||
e.HasMany(p => p.Blocks).WithOne()
|
||||
.HasForeignKey(b => b.PageId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Block>(e => e.Property(b => b.Type).HasConversion<string>());
|
||||
|
||||
SeedCms(modelBuilder);
|
||||
|
||||
// INBOX epic: Gmail request inbox.
|
||||
modelBuilder.Entity<Request>(e =>
|
||||
{
|
||||
e.Property(r => r.Status).HasConversion<string>();
|
||||
e.HasIndex(r => r.GmailMessageId).IsUnique();
|
||||
e.HasOne(r => r.AssignedContact).WithMany()
|
||||
.HasForeignKey(r => r.AssignedContactId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
// exactly one MailSettings row, fixed id.
|
||||
modelBuilder.Entity<MailSettings>()
|
||||
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
|
||||
|
||||
SeedColorVarieties(modelBuilder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seed the public website's CMS: one Site + the 6 Jimdo-mirror pages, each with a
|
||||
/// placeholder Heading; the abgabetiere page also carries an auto AbgabetiereList block
|
||||
/// (resolved from live ForSale animals at snapshot time). Hand-editable afterwards.
|
||||
/// </summary>
|
||||
private static void SeedCms(ModelBuilder modelBuilder)
|
||||
{
|
||||
Guid Pid(int n) => new($"51720001-0000-0000-0000-{n:D12}");
|
||||
Guid Bid(int n) => new($"51720002-0000-0000-0000-{n:D12}");
|
||||
|
||||
(int n, string Slug, string Title)[] pages =
|
||||
{
|
||||
(1, "start", "Startseite"),
|
||||
(2, "ueber-die-zucht", "Über die Zucht"),
|
||||
(3, "abgabetiere", "Abgabetiere"),
|
||||
(4, "abgabebedingungen", "Abgabebedingungen"),
|
||||
(5, "farben-genetik", "Farben & Genetik"),
|
||||
(6, "kontakt", "Kontakt"),
|
||||
};
|
||||
|
||||
var pageRows = new List<Page>();
|
||||
var blockRows = new List<Block>();
|
||||
foreach (var p in pages)
|
||||
{
|
||||
pageRows.Add(new Page { Id = Pid(p.n), Slug = p.Slug, Title = p.Title, Status = PageStatus.Published });
|
||||
// placeholder Heading per page
|
||||
blockRows.Add(new Block
|
||||
{
|
||||
Id = Bid(p.n), PageId = Pid(p.n), Order = 0, Type = BlockType.Heading,
|
||||
Data = $"{{\"text\":\"{p.Title}\",\"level\":1}}",
|
||||
});
|
||||
}
|
||||
// abgabetiere (n=3): auto AbgabetiereList as the second block
|
||||
blockRows.Add(new Block
|
||||
{
|
||||
Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList,
|
||||
Data = "{\"mode\":\"auto\",\"intro\":\"\"}",
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Page>().HasData(pageRows);
|
||||
modelBuilder.Entity<Block>().HasData(blockRows);
|
||||
|
||||
modelBuilder.Entity<Site>().HasData(new Site
|
||||
{
|
||||
Id = Site.SingletonId,
|
||||
DefaultLocale = "de",
|
||||
NavOrder = pages.Select(p => Pid(p.n)).ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seed the colour-variety catalog. Source of truth = Kevin's GEN-2 generated list
|
||||
/// (gerbil-manager-web/src/genetics/colorVarietySeed.generated.json): 73 varieties
|
||||
@@ -103,7 +237,6 @@ public class ApplicationContext : DbContext
|
||||
("Hermelin", "aa chch DD EE GG PP spsp rere"),
|
||||
("Himalaya", "AA chch DD EE GG PP spsp rere"),
|
||||
("Zobel", "aa cchmcchm DD EE gg PP spsp rere"),
|
||||
("Schwarzschimmel", "AA CC DD efef GG PP spsp rere"),
|
||||
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere"),
|
||||
("Agouti", "AA CC DD EE GG PP spsp rere"),
|
||||
("Schwarz", "aa CC DD EE GG PP spsp rere"),
|
||||
@@ -123,55 +256,44 @@ public class ApplicationContext : DbContext
|
||||
("C-Separator", "aa CC DD ee gg pp spsp rere"),
|
||||
("Elfenbein", "AA CC DD EE gg pp spsp rere"),
|
||||
("Kohlfuchs", "aa CC DD ee GG PP spsp rere"),
|
||||
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
|
||||
("Siam (Marder-Hell)", "aa cchmcchm DD EE GG PP spsp rere"),
|
||||
("Polarfuchs", "AA CC DD ee gg PP spsp rere"),
|
||||
("Saphir", "aa CC DD EE GG pp spsp rere"),
|
||||
("Schimmel (Orangeschimmel)", "AA CC DD efef GG PP spsp rere"),
|
||||
("Orangeschimmel", "AA CC DD efef GG PP spsp rere"),
|
||||
("Topas", "AA CC DD EE GG pp spsp rere"),
|
||||
("Platin-Hell", "aa CC DD EE GG pp spsp rere"),
|
||||
("Agouti dd", "AA CC dd EE GG PP spsp rere"),
|
||||
("Silberagouti dd", "AA CC dd EE gg PP spsp rere"),
|
||||
("Kohlfuchs dd", "aa CC dd ee GG PP spsp rere"),
|
||||
("Anthrazit dd", "aa CC dd EE gg PP spsp rere"),
|
||||
("Agouti CP-Hell", "AA cchmcchm DD EE GG PP spsp rere"),
|
||||
("Blaufuchs CP", "aa cchmcchm DD ee gg PP spsp rere"),
|
||||
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
|
||||
("Silberschimmel", "AA CC DD efef gg PP spsp rere"),
|
||||
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
|
||||
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"),
|
||||
("Polarfuchs-Hell CP", "AA cchmcchm DD ee gg PP spsp rere"),
|
||||
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere"),
|
||||
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere"),
|
||||
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere"),
|
||||
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere"),
|
||||
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere"),
|
||||
("Gold-Hell", "AA CC DD EE GG pp spsp rere"),
|
||||
("Siam (Marder-Hell) dd", "aa cchmcchm dd EE GG PP spsp rere"),
|
||||
("Marder dd", "aa cchmcchm dd EE GG PP spsp rere"),
|
||||
("Zobel-Hell", "aa cchmcchm DD EE gg PP spsp rere"),
|
||||
("Silberagouti dd CP", "AA cchmcchm dd EE gg PP spsp rere"),
|
||||
("Silberagouti-Hell dd CP", "AA cchmcchm dd EE gg PP spsp rere"),
|
||||
("Agouti dd CP", "AA cchmcchm dd EE GG PP spsp rere"),
|
||||
("Agouti-Hell dd CP", "AA cchmcchm dd EE GG PP spsp rere"),
|
||||
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere"),
|
||||
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"),
|
||||
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"),
|
||||
("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"),
|
||||
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere"),
|
||||
("Zobel dd", "aa cchmcchm dd EE gg PP spsp rere"),
|
||||
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"),
|
||||
("Kohlfuchs CP", "aa cchmcchm DD ee GG PP spsp rere"),
|
||||
("Algierfuchs CP", "AA cchmcchm DD ee GG PP spsp rere"),
|
||||
("Silberagouti CP", "AA cchmcchm DD EE gg PP spsp rere"),
|
||||
("Agouti CP", "AA cchmcchm DD EE GG PP spsp rere"),
|
||||
("Algierfuchs-Hell CP", "AA cchmcchm DD ee GG PP spsp rere"),
|
||||
("Kohlfuchs,hell CP", "aa cchmcchm DD ee GG PP spsp rere"),
|
||||
("Polarfuchs CP", "AA cchmcchm DD ee gg PP spsp rere"),
|
||||
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere"),
|
||||
("Topas dd", "AA CC dd EE GG pp spsp rere"),
|
||||
("Zobel-Hell dd", "aa cchmcchm dd EE gg PP spsp rere"),
|
||||
("Kohlfuchsschimmel CP", "aa cchmcchm DD efef GG PP spsp rere"),
|
||||
("Blaufuchs dd", "aa CC dd ee gg PP spsp rere"),
|
||||
("Blaufuchs dd", "aa CC dd ee gg pp spsp rere"),
|
||||
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
|
||||
("Siam", "aa cchmch DD EE GG PP spsp rere"),
|
||||
("Zobel-Hell", "aa cchmch DD EE gg PP spsp rere"),
|
||||
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere"),
|
||||
("CP-Silberagouti", "AA cchmcchm DD EE gg PP spsp rere"),
|
||||
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere"),
|
||||
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere"),
|
||||
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere"),
|
||||
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
|
||||
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"),
|
||||
("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
|
||||
};
|
||||
|
||||
var rows = new ColorVariety[catalog.Length];
|
||||
|
||||
484
GerbilManagerWebAPI/Cms/SiteRenderer.cs
Normal file
484
GerbilManagerWebAPI/Cms/SiteRenderer.cs
Normal file
@@ -0,0 +1,484 @@
|
||||
// System.Net.WebUtility intentionally NOT used — its HtmlEncode encodes all non-ASCII
|
||||
// characters as named entities (e.g. ä→ä), which is wrong for UTF-8 pages.
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GerbilManagerWebAPI.Cms
|
||||
{
|
||||
/// <summary>
|
||||
/// WEB-1: Deterministic static site renderer. Consumes a CMS snapshot (from
|
||||
/// SiteSnapshotService.BuildAsync) and produces a flat path→content dictionary
|
||||
/// ready for upload to Cloudflare Pages. No external dependencies; Markdown
|
||||
/// support handles the common subset used in RichText blocks.
|
||||
/// </summary>
|
||||
public static class SiteRenderer
|
||||
{
|
||||
public const string SiteName = "Kleine Chaoten";
|
||||
public const string CssPath = "assets/site.css";
|
||||
|
||||
// ── Public entry point ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Renders the snapshot to a flat file map (relative-path → file-content).
|
||||
/// Only <c>Published</c> pages are included. The "start" page maps to
|
||||
/// <c>index.html</c>; all others to <c>{slug}/index.html</c>.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, string> Render(JsonObject snapshot)
|
||||
{
|
||||
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
files[CssPath] = SiteCss();
|
||||
|
||||
var navSlugs = NavSlugs(snapshot);
|
||||
var pages = snapshot["pages"] as JsonArray ?? [];
|
||||
|
||||
foreach (var node in pages)
|
||||
{
|
||||
if (node is not JsonObject page) continue;
|
||||
if (Str(page["status"]) != "Published") continue;
|
||||
|
||||
var slug = Str(page["slug"]) ?? "page";
|
||||
var title = Str(page["title"]) ?? slug;
|
||||
var seoDes = Str(page["seoDescription"]);
|
||||
var blocks = page["blocks"] as JsonArray ?? [];
|
||||
|
||||
var cssHref = slug == "start" ? CssPath : $"../{CssPath}";
|
||||
var body = RenderBlocks(blocks, slug);
|
||||
var html = PageHtml(title, seoDes, body, navSlugs, slug, cssHref);
|
||||
var path = slug == "start" ? "index.html" : $"{slug}/index.html";
|
||||
files[path] = html;
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
// ── Block rendering ──────────────────────────────────────────────────
|
||||
|
||||
private static string RenderBlocks(JsonArray blocks, string currentSlug)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var node in blocks.OrderBy(b => b?["order"]?.GetValue<int>() ?? 0))
|
||||
{
|
||||
if (node is not JsonObject b) continue;
|
||||
var type = Str(b["type"]);
|
||||
var data = b["data"] as JsonObject ?? [];
|
||||
sb.Append(type switch
|
||||
{
|
||||
"Heading" => RenderHeading(data),
|
||||
"RichText" => RenderRichText(data),
|
||||
"Image" => RenderImage(data),
|
||||
"Gallery" => RenderGallery(data),
|
||||
"ContactInfo" => RenderContactInfo(data),
|
||||
"AbgabetiereList" => RenderAbgabetiereList(data),
|
||||
_ => string.Empty,
|
||||
});
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderHeading(JsonObject d)
|
||||
{
|
||||
var level = d["level"]?.GetValue<int>() ?? 2;
|
||||
level = Math.Clamp(level, 1, 6);
|
||||
var text = H(Str(d["text"]) ?? "");
|
||||
return $"\n<h{level} class=\"cms-heading\">{text}</h{level}>\n";
|
||||
}
|
||||
|
||||
private static string RenderRichText(JsonObject d)
|
||||
{
|
||||
var md = Str(d["markdown"]) ?? "";
|
||||
return $"\n<div class=\"cms-richtext\">{MarkdownToHtml(md)}</div>\n";
|
||||
}
|
||||
|
||||
private static string RenderImage(JsonObject d)
|
||||
{
|
||||
var url = H(Str(d["url"]) ?? "");
|
||||
var alt = H(Str(d["alt"]) ?? "");
|
||||
return $"\n<figure class=\"cms-image\"><img src=\"{url}\" alt=\"{alt}\" loading=\"lazy\"></figure>\n";
|
||||
}
|
||||
|
||||
private static string RenderGallery(JsonObject d)
|
||||
{
|
||||
var images = d["images"] as JsonArray ?? [];
|
||||
var sb = new StringBuilder("\n<div class=\"cms-gallery\">\n");
|
||||
foreach (var img in images)
|
||||
{
|
||||
if (img is not JsonObject o) continue;
|
||||
var url = H(Str(o["url"]) ?? "");
|
||||
var alt = H(Str(o["alt"]) ?? "");
|
||||
sb.Append($" <figure class=\"cms-gallery-item\"><img src=\"{url}\" alt=\"{alt}\" loading=\"lazy\"></figure>\n");
|
||||
}
|
||||
sb.Append("</div>\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderContactInfo(JsonObject d)
|
||||
{
|
||||
var sb = new StringBuilder("\n<address class=\"cms-contact\">\n");
|
||||
void Row(string? val, string icon) { if (!string.IsNullOrWhiteSpace(val)) sb.AppendLine($" <span class=\"cms-contact-row\">{icon} {H(val)}</span>"); }
|
||||
Row(Str(d["name"]), "👤");
|
||||
Row(Str(d["address"]), "📍");
|
||||
var phone = Str(d["phone"]);
|
||||
if (!string.IsNullOrWhiteSpace(phone))
|
||||
sb.AppendLine($" <span class=\"cms-contact-row\">📞 <a href=\"tel:{H(phone)}\">{H(phone)}</a></span>");
|
||||
var email = Str(d["email"]);
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
sb.AppendLine($" <span class=\"cms-contact-row\">✉️ <a href=\"mailto:{H(email)}\">{H(email)}</a></span>");
|
||||
sb.Append("</address>\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderAbgabetiereList(JsonObject d)
|
||||
{
|
||||
var intro = Str(d["intro"]);
|
||||
var animals = d["animals"] as JsonArray ?? [];
|
||||
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(intro))
|
||||
sb.Append($" <p class=\"cms-abgabe-intro\">{H(intro)}</p>\n");
|
||||
|
||||
if (animals.Count == 0)
|
||||
{
|
||||
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(" <div class=\"cms-animal-grid\">\n");
|
||||
foreach (var node in animals)
|
||||
{
|
||||
if (node is not JsonObject a) continue;
|
||||
var name = H(Str(a["name"]) ?? "");
|
||||
var farbe = H(Str(a["farbschlag"]) ?? "");
|
||||
var group = Str(a["group"]);
|
||||
var saleText = Str(a["aiSaleText"]);
|
||||
var photos = a["photos"] as JsonArray ?? [];
|
||||
|
||||
sb.Append(" <article class=\"cms-animal-card\">\n");
|
||||
|
||||
// Profile photo (first photo)
|
||||
var firstPhoto = photos.FirstOrDefault()?.GetValue<string>();
|
||||
if (!string.IsNullOrEmpty(firstPhoto))
|
||||
sb.Append($" <img class=\"cms-animal-photo\" src=\"{H(firstPhoto)}\" alt=\"{name}\" loading=\"lazy\">\n");
|
||||
|
||||
sb.Append(" <div class=\"cms-animal-info\">\n");
|
||||
sb.Append($" <h3 class=\"cms-animal-name\">{name}</h3>\n");
|
||||
if (!string.IsNullOrEmpty(farbe))
|
||||
sb.Append($" <p class=\"cms-animal-farbe\">{farbe}</p>\n");
|
||||
if (!string.IsNullOrWhiteSpace(group))
|
||||
sb.Append($" <p class=\"cms-animal-group\">Gruppe: {H(group)}</p>\n");
|
||||
if (!string.IsNullOrWhiteSpace(saleText))
|
||||
sb.Append($" <p class=\"cms-animal-text\">{H(saleText)}</p>\n");
|
||||
sb.Append(" </div>\n");
|
||||
sb.Append(" </article>\n");
|
||||
}
|
||||
sb.Append(" </div>\n");
|
||||
}
|
||||
|
||||
sb.Append("</section>\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ── Page template ────────────────────────────────────────────────────
|
||||
|
||||
private static string PageHtml(
|
||||
string title, string? seoDesc, string body,
|
||||
IReadOnlyList<string> navSlugs, string currentSlug, string cssHref)
|
||||
{
|
||||
var fullTitle = H($"{title} | {SiteName}");
|
||||
var metaDesc = seoDesc is null ? ""
|
||||
: $"\n <meta name=\"description\" content=\"{H(seoDesc)}\">";
|
||||
|
||||
var navBase = currentSlug == "start" ? "" : "../";
|
||||
var navHtml = BuildNav(navSlugs, currentSlug, navBase);
|
||||
|
||||
return $"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">{metaDesc}
|
||||
<title>{fullTitle}</title>
|
||||
<link rel="stylesheet" href="{H(cssHref)}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a href="{navBase}index.html" class="site-logo">🐭 {H(SiteName)}</a>
|
||||
<nav class="site-nav" aria-label="Hauptnavigation">
|
||||
{navHtml}
|
||||
</nav>
|
||||
</header>
|
||||
<main class="site-main">
|
||||
{body}
|
||||
</main>
|
||||
<footer class="site-footer">
|
||||
<p>© {H(SiteName)} — Mongolische Rennmäuse</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildNav(IReadOnlyList<string> slugs, string current, string navBase)
|
||||
{
|
||||
var labels = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["start"] = "Start",
|
||||
["ueber-die-zucht"] = "Über die Zucht",
|
||||
["abgabetiere"] = "Abgabetiere",
|
||||
["abgabebedingungen"] = "Abgabebedingungen",
|
||||
["farben-genetik"] = "Farben & Genetik",
|
||||
["kontakt"] = "Kontakt",
|
||||
};
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (var slug in slugs)
|
||||
{
|
||||
var label = labels.TryGetValue(slug, out var l) ? l : slug;
|
||||
var href = slug == "start" ? $"{navBase}index.html" : $"{navBase}{slug}/index.html";
|
||||
var active = slug == current ? " aria-current=\"page\"" : "";
|
||||
sb.AppendLine($" <a href=\"{H(href)}\" class=\"site-nav-link\"{active}>{H(label)}</a>");
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
// ── Minimal Markdown → HTML ──────────────────────────────────────────
|
||||
|
||||
internal static string MarkdownToHtml(string md)
|
||||
{
|
||||
if (string.IsNullOrEmpty(md)) return "";
|
||||
var lines = md.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n');
|
||||
var sb = new StringBuilder();
|
||||
var inList = false;
|
||||
var isOrdered = false;
|
||||
|
||||
void CloseList()
|
||||
{
|
||||
if (!inList) return;
|
||||
sb.AppendLine(isOrdered ? "</ol>" : "</ul>");
|
||||
inList = false;
|
||||
}
|
||||
|
||||
foreach (var raw in lines)
|
||||
{
|
||||
var line = raw;
|
||||
|
||||
// ATX headings
|
||||
var hm = Regex.Match(line, @"^(#{1,6})\s+(.+)$");
|
||||
if (hm.Success)
|
||||
{
|
||||
CloseList();
|
||||
var lvl = hm.Groups[1].Length;
|
||||
sb.AppendLine($"<h{lvl}>{InlineHtml(hm.Groups[2].Value)}</h{lvl}>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
var ulm = Regex.Match(line, @"^[-*+]\s+(.+)$");
|
||||
if (ulm.Success)
|
||||
{
|
||||
if (!inList || isOrdered) { CloseList(); sb.AppendLine("<ul>"); inList = true; isOrdered = false; }
|
||||
sb.AppendLine($"<li>{InlineHtml(ulm.Groups[1].Value)}</li>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
var olm = Regex.Match(line, @"^\d+\.\s+(.+)$");
|
||||
if (olm.Success)
|
||||
{
|
||||
if (!inList || !isOrdered) { CloseList(); sb.AppendLine("<ol>"); inList = true; isOrdered = true; }
|
||||
sb.AppendLine($"<li>{InlineHtml(olm.Groups[1].Value)}</li>");
|
||||
continue;
|
||||
}
|
||||
|
||||
CloseList();
|
||||
|
||||
// Blank line
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
sb.AppendLine();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (Regex.IsMatch(line, @"^(-{3,}|\*{3,}|_{3,})$"))
|
||||
{
|
||||
sb.AppendLine("<hr>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
sb.AppendLine($"<p>{InlineHtml(line)}</p>");
|
||||
}
|
||||
|
||||
CloseList();
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
|
||||
private static string InlineHtml(string text)
|
||||
{
|
||||
// First HTML-encode, then selectively un-encode our safe inline patterns
|
||||
// to avoid encoding the tags we are about to add.
|
||||
// Order matters: bold before italic.
|
||||
text = H(text);
|
||||
// Bold **text**
|
||||
text = Regex.Replace(text, @"\*\*(.+?)\*\*", "<strong>$1</strong>");
|
||||
// Italic *text* (single, not preceded/followed by *)
|
||||
text = Regex.Replace(text, @"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", "<em>$1</em>");
|
||||
// Inline code `text`
|
||||
text = Regex.Replace(text, @"`(.+?)`", "<code>$1</code>");
|
||||
// Links [text](url) — note: url is already HTML-encoded by H()
|
||||
text = Regex.Replace(text, @"\[(.+?)\]\((.+?)\)", "<a href=\"$2\">$1</a>");
|
||||
return text;
|
||||
}
|
||||
|
||||
// ── CSS ──────────────────────────────────────────────────────────────
|
||||
|
||||
internal static string SiteCss() => """
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--color-bg: #fdf8f3;
|
||||
--color-surface: #fff;
|
||||
--color-text: #2c2c2c;
|
||||
--color-muted: #6b6b6b;
|
||||
--color-accent: #c0392b;
|
||||
--color-border: #e0d8d0;
|
||||
--font-body: system-ui, sans-serif;
|
||||
--max-w: 860px;
|
||||
}
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
line-height: 1.65;
|
||||
}
|
||||
a { color: var(--color-accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
img { max-width: 100%; height: auto; display: block; }
|
||||
|
||||
/* ── Header ── */
|
||||
.site-header {
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: .75rem 1rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: .5rem 1.5rem;
|
||||
}
|
||||
.site-logo {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.site-nav { display: flex; flex-wrap: wrap; gap: .25rem .75rem; }
|
||||
.site-nav-link {
|
||||
font-size: .9rem;
|
||||
color: var(--color-muted);
|
||||
padding: .2rem .4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.site-nav-link:hover, .site-nav-link[aria-current="page"] {
|
||||
color: var(--color-accent);
|
||||
background: #fef0ee;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
.site-main {
|
||||
max-width: var(--max-w);
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem 3rem;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: 1.5rem 1rem;
|
||||
text-align: center;
|
||||
font-size: .85rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* ── CMS blocks ── */
|
||||
.cms-heading { margin: 1.5rem 0 .5rem; }
|
||||
h1.cms-heading { font-size: 1.8rem; }
|
||||
h2.cms-heading { font-size: 1.4rem; }
|
||||
.cms-richtext { margin: 1rem 0; }
|
||||
.cms-richtext p { margin-bottom: .75rem; }
|
||||
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
|
||||
.cms-richtext li { margin-bottom: .3rem; }
|
||||
.cms-image { margin: 1.5rem 0; }
|
||||
.cms-image img { border-radius: 6px; }
|
||||
.cms-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: .75rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
.cms-gallery-item img { border-radius: 4px; aspect-ratio: 1; object-fit: cover; }
|
||||
.cms-contact {
|
||||
font-style: normal;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .4rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.cms-contact-row { display: flex; gap: .5rem; align-items: flex-start; }
|
||||
|
||||
/* ── Abgabetiere ── */
|
||||
.cms-abgabe { margin: 1rem 0; }
|
||||
.cms-abgabe-intro { margin-bottom: 1.25rem; font-size: 1.05rem; }
|
||||
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
|
||||
.cms-animal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.cms-animal-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cms-animal-photo { width: 100%; aspect-ratio: 4/3; object-fit: cover; }
|
||||
.cms-animal-info { padding: .875rem; }
|
||||
.cms-animal-name { font-size: 1.1rem; font-weight: 600; margin-bottom: .25rem; }
|
||||
.cms-animal-farbe { font-size: .9rem; color: var(--color-muted); margin-bottom: .25rem; }
|
||||
.cms-animal-group { font-size: .85rem; color: var(--color-muted); }
|
||||
.cms-animal-text { font-size: .9rem; margin-top: .5rem; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
h1.cms-heading { font-size: 1.4rem; }
|
||||
.cms-animal-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
""";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private static IReadOnlyList<string> NavSlugs(JsonObject snapshot)
|
||||
{
|
||||
var navArr = snapshot["site"]?["navOrder"] as JsonArray;
|
||||
if (navArr is null) return [];
|
||||
return navArr.Select(n => n?.GetValue<string>()).Where(s => s is not null).ToList()!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTML-encode only the 5 HTML special characters. Non-ASCII characters (e.g. German
|
||||
/// umlauts) are left as-is — the page declares UTF-8, so no entity encoding needed.
|
||||
/// </summary>
|
||||
private static string H(string? s)
|
||||
{
|
||||
if (s is null) return "";
|
||||
return s.Replace("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">")
|
||||
.Replace("\"", """)
|
||||
.Replace("'", "'");
|
||||
}
|
||||
|
||||
/// <summary>Extract a string value from a JsonNode; returns null if not a string.</summary>
|
||||
private static string? Str(JsonNode? node) =>
|
||||
node is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
|
||||
}
|
||||
}
|
||||
124
GerbilManagerWebAPI/Cms/SiteSnapshotService.cs
Normal file
124
GerbilManagerWebAPI/Cms/SiteSnapshotService.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Cms
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the full CMS snapshot consumed by the static site renderer (WEB-1). The
|
||||
/// abgabetiere page's auto AbgabetiereList block is resolved here from live ForSale
|
||||
/// animals — nothing private is ever exposed (publish = snapshot of public content).
|
||||
/// </summary>
|
||||
public sealed class SiteSnapshotService
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
|
||||
public SiteSnapshotService(ApplicationContext db) => _db = db;
|
||||
|
||||
public async Task<JsonObject> BuildAsync()
|
||||
{
|
||||
var site = await _db.Sites.AsNoTracking().FirstOrDefaultAsync()
|
||||
?? new Site { Id = Site.SingletonId };
|
||||
var pages = await _db.Pages.AsNoTracking().Include(p => p.Blocks).ToListAsync();
|
||||
var pageById = pages.ToDictionary(p => p.Id);
|
||||
|
||||
var navOrder = new JsonArray();
|
||||
foreach (var id in site.NavOrder)
|
||||
if (pageById.TryGetValue(id, out var p))
|
||||
navOrder.Add(p.Slug);
|
||||
|
||||
JsonArray? forSale = null; // resolved lazily, reused across blocks
|
||||
|
||||
int NavIndex(Guid id) { var i = site.NavOrder.IndexOf(id); return i < 0 ? int.MaxValue : i; }
|
||||
|
||||
var pagesArr = new JsonArray();
|
||||
foreach (var page in pages.OrderBy(p => NavIndex(p.Id)).ThenBy(p => p.Slug))
|
||||
{
|
||||
var blocks = new JsonArray();
|
||||
foreach (var b in page.Blocks.OrderBy(b => b.Order))
|
||||
{
|
||||
var data = JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data) as JsonObject
|
||||
?? new JsonObject();
|
||||
if (b.Type == BlockType.AbgabetiereList && AsString(data["mode"]) == "auto")
|
||||
{
|
||||
forSale ??= await BuildForSaleAsync();
|
||||
data["animals"] = forSale.DeepClone();
|
||||
}
|
||||
blocks.Add(new JsonObject
|
||||
{
|
||||
["id"] = b.Id.ToString(),
|
||||
["order"] = b.Order,
|
||||
["type"] = b.Type.ToString(),
|
||||
["data"] = data,
|
||||
});
|
||||
}
|
||||
pagesArr.Add(new JsonObject
|
||||
{
|
||||
["slug"] = page.Slug,
|
||||
["title"] = page.Title,
|
||||
["seoDescription"] = page.SeoDescription,
|
||||
["status"] = page.Status.ToString(),
|
||||
["blocks"] = blocks,
|
||||
});
|
||||
}
|
||||
|
||||
return new JsonObject
|
||||
{
|
||||
["site"] = new JsonObject
|
||||
{
|
||||
["defaultLocale"] = site.DefaultLocale,
|
||||
["navOrder"] = navOrder,
|
||||
},
|
||||
["pages"] = pagesArr,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<JsonArray> BuildForSaleAsync()
|
||||
{
|
||||
var animals = await _db.Gerbils.AsNoTracking()
|
||||
.Where(g => g.Status == GerbilStatus.ForSale)
|
||||
.OrderBy(g => g.Name)
|
||||
.Select(g => new
|
||||
{
|
||||
g.Id,
|
||||
g.Name,
|
||||
Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null,
|
||||
// group = Becken (enclosure) name — matches how the Abgabe composer
|
||||
// groups ForSale animals by enclosure-mates (god ruling). null = single.
|
||||
Group = g.Enclosure != null ? g.Enclosure.Name : null,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var ids = animals.Select(a => a.Id).ToList();
|
||||
var photos = await _db.GerbilPhotos.AsNoTracking()
|
||||
.Where(p => ids.Contains(p.GerbilId))
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.Select(p => new { p.GerbilId, p.FileName })
|
||||
.ToListAsync();
|
||||
var photosByGerbil = photos
|
||||
.GroupBy(p => p.GerbilId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(x => $"/photos/files/{x.FileName}").ToList());
|
||||
|
||||
var arr = new JsonArray();
|
||||
foreach (var a in animals)
|
||||
{
|
||||
var photoArr = new JsonArray();
|
||||
if (photosByGerbil.TryGetValue(a.Id, out var urls))
|
||||
foreach (var u in urls) photoArr.Add(u);
|
||||
|
||||
arr.Add(new JsonObject
|
||||
{
|
||||
["name"] = a.Name,
|
||||
["farbschlag"] = a.Farbschlag,
|
||||
["group"] = a.Group,
|
||||
["photos"] = photoArr,
|
||||
["aiSaleText"] = null, // populated by WEB-5 once a Gemini key exists
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
private static string? AsString(JsonNode? node) =>
|
||||
node is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,12 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? Genotype,
|
||||
string? Notes,
|
||||
string? ImportSource,
|
||||
string? ExternalRef);
|
||||
string? ExternalRef,
|
||||
string? OriginBreeder,
|
||||
List<string> CharacterTraits,
|
||||
string? CharacterNote,
|
||||
bool? IsDeaf,
|
||||
bool IsResident);
|
||||
|
||||
public record LitterDto(
|
||||
Guid Id,
|
||||
@@ -69,7 +74,12 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? Genotype,
|
||||
string? Notes,
|
||||
string? ImportSource,
|
||||
string? ExternalRef);
|
||||
string? ExternalRef,
|
||||
string? OriginBreeder,
|
||||
List<string>? CharacterTraits,
|
||||
string? CharacterNote,
|
||||
bool? IsDeaf,
|
||||
bool? IsResident);
|
||||
|
||||
public record LitterInput(
|
||||
string Name,
|
||||
|
||||
26
GerbilManagerWebAPI/Dtos/CmsDtos.cs
Normal file
26
GerbilManagerWebAPI/Dtos/CmsDtos.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
// Response DTOs ----------------------------------------------------------
|
||||
|
||||
public record PageSummaryDto(Guid Id, string Slug, string Title, string? SeoDescription, PageStatus Status);
|
||||
|
||||
public record PageDto(
|
||||
Guid Id, string Slug, string Title, string? SeoDescription, PageStatus Status,
|
||||
IReadOnlyList<BlockDto> Blocks);
|
||||
|
||||
/// <summary>Block.Data is emitted as a parsed JSON object (not a string).</summary>
|
||||
public record BlockDto(Guid Id, int Order, BlockType Type, JsonNode? Data);
|
||||
|
||||
// Request DTOs -----------------------------------------------------------
|
||||
|
||||
public record PageInput(string Slug, string Title, string? SeoDescription, PageStatus? Status);
|
||||
|
||||
/// <summary>Data is the type-specific JSON object (e.g. {"markdown":"…"} for RichText).</summary>
|
||||
public record BlockInput(BlockType Type, JsonElement Data, int? Order);
|
||||
|
||||
public record BlockOrderInput(List<Guid> BlockIds);
|
||||
}
|
||||
41
GerbilManagerWebAPI/Dtos/InboxDtos.cs
Normal file
41
GerbilManagerWebAPI/Dtos/InboxDtos.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
public record RequestDto(
|
||||
Guid Id,
|
||||
string GmailMessageId,
|
||||
string? ThreadId,
|
||||
string FromAddress,
|
||||
string? FromName,
|
||||
string? Subject,
|
||||
string? BodyText,
|
||||
DateTimeOffset ReceivedAt,
|
||||
RequestStatus Status,
|
||||
Guid? AssignedContactId,
|
||||
string? DraftReply,
|
||||
DateTimeOffset? AnsweredAt);
|
||||
|
||||
/// <summary>Triage update: change status and/or assign a contact.</summary>
|
||||
public record RequestTriageInput(RequestStatus? Status, Guid? AssignedContactId);
|
||||
|
||||
/// <summary>Mail settings read shape — the App Password is NEVER returned (only HasAppPassword).</summary>
|
||||
public record MailSettingsDto(
|
||||
string? GmailAddress,
|
||||
int PollIntervalMinutes,
|
||||
string Folder,
|
||||
bool BackgroundPollEnabled,
|
||||
bool HasAppPassword);
|
||||
|
||||
/// <summary>Body of the (edited) reply to send for POST /api/requests/{id}/send.</summary>
|
||||
public record SendReplyInput(string Body);
|
||||
|
||||
/// <summary>Mail settings write shape. AppPassword is write-only; null/omitted leaves it unchanged,
|
||||
/// empty string clears it.</summary>
|
||||
public record MailSettingsInput(
|
||||
string? GmailAddress,
|
||||
string? AppPassword,
|
||||
int? PollIntervalMinutes,
|
||||
string? Folder,
|
||||
bool? BackgroundPollEnabled);
|
||||
}
|
||||
186
GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs
Normal file
186
GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Cms;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// WEB-0: CMS for the public website. GET /api/site-snapshot is the publish-time export
|
||||
/// (with Abgabetiere resolved from live ForSale animals); the /api/pages + /api/blocks
|
||||
/// CRUD edits content (hand-edit admin UI in WEB-0b, AI tools later). LAN-only like the
|
||||
/// rest of the API.
|
||||
/// </summary>
|
||||
public static class CmsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapCmsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var api = app.MapGroup("/api").WithTags("CMS");
|
||||
|
||||
// ---- snapshot (consumed by the static renderer) ----
|
||||
api.MapGet("/site-snapshot", async (ApplicationContext db) =>
|
||||
TypedResults.Ok(await new SiteSnapshotService(db).BuildAsync()));
|
||||
|
||||
// ---- static render dry-run (WEB-1: returns rendered file map as JSON) ----
|
||||
api.MapGet("/render-site", async (ApplicationContext db) =>
|
||||
{
|
||||
var snapshot = await new SiteSnapshotService(db).BuildAsync();
|
||||
var files = SiteRenderer.Render(snapshot);
|
||||
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
|
||||
});
|
||||
|
||||
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
|
||||
// und liefert die Datei mit passendem Content-Type aus. Relative
|
||||
// Links/CSS der gerenderten Seite funktionieren dadurch im
|
||||
// Vorschau-iframe genauso wie später auf der echten Webseite. ----
|
||||
api.MapGet("/preview/{**path}", async (string? path, ApplicationContext db) =>
|
||||
{
|
||||
var snapshot = await new SiteSnapshotService(db).BuildAsync();
|
||||
var files = SiteRenderer.Render(snapshot);
|
||||
|
||||
var key = string.IsNullOrWhiteSpace(path) ? "index.html" : path.TrimEnd('/');
|
||||
if (!files.TryGetValue(key, out var content) &&
|
||||
!files.TryGetValue($"{key}/index.html", out content))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
return Results.Content(content, PreviewContentType(key));
|
||||
});
|
||||
|
||||
// ---- pages ----
|
||||
api.MapGet("/pages", async (ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Pages.AsNoTracking().OrderBy(p => p.Slug)
|
||||
.Select(p => new PageSummaryDto(p.Id, p.Slug, p.Title, p.SeoDescription, p.Status))
|
||||
.ToListAsync()));
|
||||
|
||||
api.MapGet("/pages/{slug}", async Task<Results<Ok<PageDto>, NotFound>> (string slug, ApplicationContext db) =>
|
||||
{
|
||||
var p = await db.Pages.AsNoTracking().Include(x => x.Blocks)
|
||||
.FirstOrDefaultAsync(x => x.Slug == slug);
|
||||
return p is null ? TypedResults.NotFound() : TypedResults.Ok(ToPageDto(p));
|
||||
});
|
||||
|
||||
api.MapPost("/pages", async Task<Results<Created<PageDto>, ValidationProblem, Conflict<string>>> (PageInput input, ApplicationContext db) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Slug) || string.IsNullOrWhiteSpace(input.Title))
|
||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["slug"] = ["Slug and title are required."] });
|
||||
if (await db.Pages.AnyAsync(p => p.Slug == input.Slug))
|
||||
return TypedResults.Conflict($"A page with slug '{input.Slug}' already exists.");
|
||||
|
||||
var page = new Page
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Slug = input.Slug,
|
||||
Title = input.Title,
|
||||
SeoDescription = input.SeoDescription,
|
||||
Status = input.Status ?? PageStatus.Draft,
|
||||
};
|
||||
db.Pages.Add(page);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/api/pages/{page.Slug}", ToPageDto(page));
|
||||
});
|
||||
|
||||
api.MapPut("/pages/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, PageInput input, ApplicationContext db) =>
|
||||
{
|
||||
var page = await db.Pages.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (page is null) return TypedResults.NotFound();
|
||||
page.Slug = input.Slug;
|
||||
page.Title = input.Title;
|
||||
page.SeoDescription = input.SeoDescription;
|
||||
if (input.Status is PageStatus s) page.Status = s;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
api.MapDelete("/pages/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var page = await db.Pages.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (page is null) return TypedResults.NotFound();
|
||||
db.Pages.Remove(page); // cascades blocks
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// ---- blocks ----
|
||||
api.MapPost("/pages/{pageId:guid}/blocks", async Task<Results<Created<BlockDto>, NotFound, BadRequest<string>>> (
|
||||
Guid pageId, BlockInput input, ApplicationContext db) =>
|
||||
{
|
||||
if (!await db.Pages.AnyAsync(p => p.Id == pageId)) return TypedResults.NotFound();
|
||||
if (input.Data.ValueKind is not (JsonValueKind.Object or JsonValueKind.Undefined))
|
||||
return TypedResults.BadRequest("Block data must be a JSON object.");
|
||||
|
||||
int order = input.Order ?? ((await db.Blocks.Where(b => b.PageId == pageId)
|
||||
.Select(b => (int?)b.Order).MaxAsync() ?? -1) + 1);
|
||||
var block = new Block
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PageId = pageId,
|
||||
Order = order,
|
||||
Type = input.Type,
|
||||
Data = input.Data.ValueKind == JsonValueKind.Object ? input.Data.GetRawText() : "{}",
|
||||
};
|
||||
db.Blocks.Add(block);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/api/blocks/{block.Id}", ToBlockDto(block));
|
||||
});
|
||||
|
||||
api.MapPut("/blocks/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<string>>> (
|
||||
Guid id, BlockInput input, ApplicationContext db) =>
|
||||
{
|
||||
var block = await db.Blocks.FirstOrDefaultAsync(b => b.Id == id);
|
||||
if (block is null) return TypedResults.NotFound();
|
||||
if (input.Data.ValueKind is not (JsonValueKind.Object or JsonValueKind.Undefined))
|
||||
return TypedResults.BadRequest("Block data must be a JSON object.");
|
||||
block.Type = input.Type;
|
||||
if (input.Data.ValueKind == JsonValueKind.Object) block.Data = input.Data.GetRawText();
|
||||
if (input.Order is int o) block.Order = o;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
api.MapDelete("/blocks/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var block = await db.Blocks.FirstOrDefaultAsync(b => b.Id == id);
|
||||
if (block is null) return TypedResults.NotFound();
|
||||
db.Blocks.Remove(block);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// reorder: body = ordered block ids; Order set to the list index
|
||||
api.MapPut("/pages/{pageId:guid}/blocks/order", async Task<Results<NoContent, NotFound, BadRequest<string>>> (
|
||||
Guid pageId, BlockOrderInput input, ApplicationContext db) =>
|
||||
{
|
||||
var blocks = await db.Blocks.Where(b => b.PageId == pageId).ToListAsync();
|
||||
if (blocks.Count == 0 && !await db.Pages.AnyAsync(p => p.Id == pageId))
|
||||
return TypedResults.NotFound();
|
||||
var byId = blocks.ToDictionary(b => b.Id);
|
||||
if (input.BlockIds.Count != blocks.Count || input.BlockIds.Any(bid => !byId.ContainsKey(bid)))
|
||||
return TypedResults.BadRequest("blockIds must list exactly the page's block ids.");
|
||||
for (int i = 0; i < input.BlockIds.Count; i++)
|
||||
byId[input.BlockIds[i]].Order = i;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
|
||||
private static string PreviewContentType(string path) =>
|
||||
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"
|
||||
: path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ? "application/xml; charset=utf-8"
|
||||
: path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ? "text/plain; charset=utf-8"
|
||||
: "text/html; charset=utf-8";
|
||||
|
||||
private static BlockDto ToBlockDto(Block b) =>
|
||||
new(b.Id, b.Order, b.Type, JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data));
|
||||
|
||||
private static PageDto ToPageDto(Page p) => new(
|
||||
p.Id, p.Slug, p.Title, p.SeoDescription, p.Status,
|
||||
p.Blocks.OrderBy(b => b.Order).Select(ToBlockDto).ToList());
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,13 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
TypedResults.Ok(await db.Gerbils.AsNoTracking()
|
||||
.ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
// GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown
|
||||
group.MapGet("/breeders", async (ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Gerbils.AsNoTracking()
|
||||
.Where(g => g.OriginBreeder != null && g.OriginBreeder != "")
|
||||
.Select(g => g.OriginBreeder!)
|
||||
.Distinct().OrderBy(b => b).ToListAsync()));
|
||||
|
||||
// GET /gerbils/{id}
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
@@ -87,11 +94,17 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
g.Notes = i.Notes;
|
||||
g.ImportSource = i.ImportSource;
|
||||
g.ExternalRef = i.ExternalRef;
|
||||
g.OriginBreeder = i.OriginBreeder;
|
||||
g.CharacterTraits = i.CharacterTraits ?? new List<string>();
|
||||
g.CharacterNote = i.CharacterNote;
|
||||
g.IsDeaf = i.IsDeaf;
|
||||
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
||||
}
|
||||
|
||||
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.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
||||
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident);
|
||||
}
|
||||
}
|
||||
|
||||
129
GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs
Normal file
129
GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Inbox;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// INBOX-0: Gmail request inbox. Sync imports mail into Request rows; the list/detail/triage
|
||||
/// endpoints drive the in-app triage (frontend INBOX-1). LAN-only like the rest of the API.
|
||||
/// </summary>
|
||||
public static class RequestEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRequestEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var api = app.MapGroup("/api").WithTags("Inbox");
|
||||
|
||||
// POST /api/requests/sync — fetch from Gmail + import (dedup on Message-Id)
|
||||
api.MapPost("/requests/sync", async (RequestSyncService sync) =>
|
||||
TypedResults.Ok(await sync.SyncAsync()));
|
||||
|
||||
// GET /api/requests?filter=status==New&orderBy=receivedAt desc (Gridify paged)
|
||||
api.MapGet("/requests", async ([Microsoft.AspNetCore.Http.AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Requests.AsNoTracking()
|
||||
.ToPagedResultAsync(query, ToDto)));
|
||||
|
||||
api.MapGet("/requests/{id:guid}", async Task<Results<Ok<RequestDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.Requests.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return r is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(r));
|
||||
});
|
||||
|
||||
// PUT /api/requests/{id} — triage: status and/or assigned contact
|
||||
api.MapPut("/requests/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<string>>> (
|
||||
Guid id, RequestTriageInput input, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.Requests.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (r is null) return TypedResults.NotFound();
|
||||
if (input.AssignedContactId is Guid cid && !await db.Contacts.AnyAsync(c => c.Id == cid))
|
||||
return TypedResults.BadRequest("Assigned contact does not exist.");
|
||||
|
||||
r.AssignedContactId = input.AssignedContactId;
|
||||
if (input.Status is RequestStatus s)
|
||||
{
|
||||
r.Status = s;
|
||||
if (s == RequestStatus.Answered) r.AnsweredAt ??= DateTimeOffset.UtcNow;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// POST /api/requests/{id}/draft — INBOX-2: KI-Antwortentwurf. NUR Entwurf
|
||||
// (human-in-the-loop, Versand ist /send). 503 AiKeyMissing solange die
|
||||
// AI-Sektion unkonfiguriert ist (gleiches UI-Muster wie sale-ad).
|
||||
api.MapPost("/requests/{id:guid}/draft", async Task<IResult> (
|
||||
Guid id, DraftReplyService drafter, ApplicationContext db, CancellationToken ct) =>
|
||||
{
|
||||
var r = await db.Requests.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (r is null) return TypedResults.NotFound();
|
||||
|
||||
// Datenminimierung: NUR die ForSale-Liste (Name + Farbschlag) geht
|
||||
// zusätzlich zum Anfragetext an den Anbieter (arch §privacy).
|
||||
var forSale = await db.Gerbils.AsNoTracking()
|
||||
.Where(g => g.Status == GerbilStatus.ForSale)
|
||||
.OrderBy(g => g.Name)
|
||||
.Select(g => new DraftReplyService.ForSaleAnimal(g.Name, g.ColorVariety!.Name))
|
||||
.ToListAsync(ct);
|
||||
|
||||
var result = await drafter.DraftAsync(r, forSale, ct);
|
||||
if (result.Status == Ai.AiCallStatus.NotConfigured)
|
||||
return Results.Json(new { code = "AiKeyMissing", message = result.Error }, statusCode: 503);
|
||||
if (result.Status != Ai.AiCallStatus.Ok)
|
||||
return Results.Json(new { code = "AiUpstreamError", message = result.Error }, statusCode: 502);
|
||||
|
||||
r.DraftReply = result.Text;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return TypedResults.Ok(ToDto(r));
|
||||
});
|
||||
|
||||
// POST /api/requests/{id}/send — send the (edited) reply, threaded, mark Answered
|
||||
api.MapPost("/requests/{id:guid}/send", async Task<Results<Ok<RequestDto>, NotFound, ProblemHttpResult>> (
|
||||
Guid id, SendReplyInput input, SendReplyService sender, ApplicationContext db) =>
|
||||
{
|
||||
var r = await db.Requests.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (r is null) return TypedResults.NotFound();
|
||||
|
||||
var outcome = await sender.SendAsync(r, input.Body ?? "");
|
||||
return outcome.Error switch
|
||||
{
|
||||
null => TypedResults.Ok(ToDto(r)),
|
||||
"MailNotConfigured" => TypedResults.Problem(
|
||||
"Gmail ist noch nicht konfiguriert.", statusCode: 503, title: "MailNotConfigured"),
|
||||
"MailAuthFailed" => TypedResults.Problem(
|
||||
"Gmail-Anmeldung fehlgeschlagen — App-Passwort erneut eingeben.", statusCode: 502, title: "MailAuthFailed"),
|
||||
var code => TypedResults.Problem(code, statusCode: 500, title: code),
|
||||
};
|
||||
});
|
||||
|
||||
// GET/PUT /api/mail-settings — App Password never leaves the server
|
||||
api.MapGet("/mail-settings", async (MailSettingsService svc) =>
|
||||
{
|
||||
var s = await svc.GetAsync();
|
||||
return TypedResults.Ok(new MailSettingsDto(
|
||||
s.GmailAddress, s.PollIntervalMinutes, s.Folder, s.BackgroundPollEnabled, svc.HasAppPassword(s)));
|
||||
});
|
||||
|
||||
api.MapPut("/mail-settings", async (MailSettingsInput input, MailSettingsService svc, ApplicationContext db) =>
|
||||
{
|
||||
var s = await svc.GetAsync();
|
||||
if (input.GmailAddress is not null) s.GmailAddress = string.IsNullOrWhiteSpace(input.GmailAddress) ? null : input.GmailAddress.Trim();
|
||||
if (input.PollIntervalMinutes is int m) s.PollIntervalMinutes = m;
|
||||
if (input.Folder is not null) s.Folder = string.IsNullOrWhiteSpace(input.Folder) ? "INBOX" : input.Folder.Trim();
|
||||
if (input.BackgroundPollEnabled is bool b) s.BackgroundPollEnabled = b;
|
||||
// AppPassword: null => leave unchanged; "" => clear; value => set (encrypted)
|
||||
if (input.AppPassword is not null) svc.SetPassword(s, input.AppPassword);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static RequestDto ToDto(Request r) => new(
|
||||
r.Id, r.GmailMessageId, r.ThreadId, r.FromAddress, r.FromName, r.Subject, r.BodyText,
|
||||
r.ReceivedAt, r.Status, r.AssignedContactId, r.DraftReply, r.AnsweredAt);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
||||
|
||||
@@ -16,11 +16,21 @@ namespace GerbilManagerWebAPI.Import
|
||||
public List<string> FarbschlagVariants { get; set; } = new();
|
||||
public SourceGenotype Genotype { get; set; } = new();
|
||||
public string Zucht { get; set; } = "";
|
||||
public string ZuchtCanon { get; set; } = ""; // declension-folded Zucht key (residency rule a)
|
||||
public List<SourceParentRef> ParentRefs { get; set; } = new();
|
||||
public List<string> Photos { get; set; } = new();
|
||||
public List<string> SourceFiles { get; set; } = new();
|
||||
public bool Conflict { get; set; }
|
||||
public SourceLitterRef? LitterRef { get; set; }
|
||||
|
||||
// GEN-3b normalization: hearing/deaf phenotype flag (null = not stated) and
|
||||
// provenance/breeding tags (WFNZ/RV/GV/DP) — neither is genotype.
|
||||
public bool? Deaf { get; set; }
|
||||
public List<string> Tags { get; set; } = new();
|
||||
|
||||
// Set by extract.py when a human conflict-decision (conflict-decisions.json) un-quarantined
|
||||
// this animal (its genotype/farbschlag are then authoritative). For reporting.
|
||||
public bool ResolvedByDecision { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SourceGenotype
|
||||
@@ -34,7 +44,9 @@ namespace GerbilManagerWebAPI.Import
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Dob { get; set; } = "";
|
||||
public string RoleGuess { get; set; } = "";
|
||||
public string RoleGuess { get; set; } = ""; // "father" | "mother"
|
||||
public string Method { get; set; } = ""; // e.g. "chart-position"
|
||||
public string Confidence { get; set; } = ""; // "hoch" | "medium" | "niedrig"
|
||||
}
|
||||
|
||||
public sealed class SourceLitterRef
|
||||
@@ -66,9 +78,16 @@ namespace GerbilManagerWebAPI.Import
|
||||
AnimalSummary Animals,
|
||||
PhotoSummary Photos,
|
||||
IReadOnlyList<string> Samples,
|
||||
IReadOnlyList<string> Notes);
|
||||
IReadOnlyList<string> Notes,
|
||||
ResidencySummary? Residency = null);
|
||||
|
||||
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported);
|
||||
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
|
||||
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).</summary>
|
||||
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
|
||||
|
||||
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
||||
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
||||
int ParentFksBackfilled = 0, int WithoutDate = 0);
|
||||
|
||||
public sealed record AnimalSummary(
|
||||
int InSource,
|
||||
@@ -77,7 +96,9 @@ namespace GerbilManagerWebAPI.Import
|
||||
int FarbschlagMatched,
|
||||
int FarbschlagUnmatched,
|
||||
int AlreadyImported,
|
||||
QuarantineSummary Quarantined);
|
||||
QuarantineSummary Quarantined,
|
||||
int ParentLinksFromChart = 0,
|
||||
int ConflictsResolvedByDecision = 0);
|
||||
|
||||
public sealed record QuarantineSummary(
|
||||
int Conflicts,
|
||||
|
||||
@@ -94,11 +94,17 @@ namespace GerbilManagerWebAPI.Import
|
||||
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
|
||||
|
||||
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
|
||||
int littersCreated = 0, littersExisting = 0;
|
||||
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
|
||||
// so their existingLitterKeySet key was always "" → they were always counted as
|
||||
// "created" even though the execute block skipped them (date is DateOnly d = false).
|
||||
// Fix: skip undated litters early — they can never be created or linked to animals.
|
||||
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
|
||||
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
||||
foreach (var sl in litters)
|
||||
{
|
||||
var date = ParseDate(sl.Date);
|
||||
if (date is null) { littersWithoutDate++; continue; } // undated: skip entirely
|
||||
|
||||
var name = $"Wurf {sl.LitterId}".Trim();
|
||||
var key = $"{name}|{date:yyyy-MM-dd}";
|
||||
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
|
||||
@@ -106,19 +112,19 @@ namespace GerbilManagerWebAPI.Import
|
||||
var id = Guid.NewGuid();
|
||||
litterIdMap[sl.Id] = id;
|
||||
littersCreated++;
|
||||
if (execute && date is DateOnly d)
|
||||
if (execute)
|
||||
{
|
||||
_db.Litters.Add(new Litter
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Date = d,
|
||||
Date = date.Value,
|
||||
TotalBorn = sl.TotalBorn,
|
||||
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
||||
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
||||
});
|
||||
}
|
||||
if (samples.Count < 8 && date is not null)
|
||||
if (samples.Count < 8)
|
||||
samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}");
|
||||
}
|
||||
if (execute) await _db.SaveChangesAsync();
|
||||
@@ -128,67 +134,238 @@ namespace GerbilManagerWebAPI.Import
|
||||
int photosAttached = 0, photosMissing = 0;
|
||||
var createdAnimalByName = new Dictionary<string, Guid>(); // normalized name -> gerbil id (for litter back-link)
|
||||
|
||||
// name+DOB -> gid index, across EXISTING rows AND this run's planned animals, so that
|
||||
// chart-position parentRefs (PEDIGREE-LINK) can resolve a parent to a real gerbil id.
|
||||
var existingRows = await _db.Gerbils
|
||||
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId }).ToListAsync();
|
||||
var gidByNameDob = new Dictionary<string, Guid>();
|
||||
foreach (var g in existingRows)
|
||||
gidByNameDob[NameDobKey(g.Name, g.DateOfBirth)] = g.Id;
|
||||
var existingLitterByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
|
||||
|
||||
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
|
||||
var plan = new List<AnimalPlan>();
|
||||
foreach (var a in loadable)
|
||||
{
|
||||
if (existingGerbilSet.Contains(a.Id)) { animalsExisting++; continue; }
|
||||
animalsCreated++;
|
||||
bool exists = existingGerbilSet.Contains(a.Id);
|
||||
var gid = exists ? gidByNameDob[NameDobKey(a.Name, ParseDate(a.Dob))] : Guid.NewGuid();
|
||||
|
||||
Guid? litterId = null;
|
||||
Guid? wurfLitterId = null;
|
||||
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
|
||||
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
|
||||
{
|
||||
litterId = lid;
|
||||
linked++;
|
||||
}
|
||||
wurfLitterId = lid;
|
||||
|
||||
Guid? colorVarietyId = null;
|
||||
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||
foreach (var fb in fbCandidates)
|
||||
{
|
||||
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
|
||||
{ colorVarietyId = vid; break; }
|
||||
}
|
||||
if (colorVarietyId is null) fbUnmatched++; else fbMatched++;
|
||||
|
||||
var gender = InferGender(a, sireNames, damNames);
|
||||
var gid = Guid.NewGuid();
|
||||
var norm = Normalize(StripZucht(a.Name));
|
||||
if (norm.Length > 0) createdAnimalByName.TryAdd(norm, gid);
|
||||
if (!exists) gidByNameDob.TryAdd(NameDobKey(a.Name, ParseDate(a.Dob)), gid);
|
||||
|
||||
var currentLitter = exists && existingLitterByExtRef.TryGetValue(a.Id, out var el) ? el : null;
|
||||
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender));
|
||||
}
|
||||
|
||||
// PASS 1.5: PEDIGREE-LINK — synthesize/reuse a litter from chart-position parentRefs for
|
||||
// any animal that has no Wurfchronik litter and isn't already litter-linked. Siblings
|
||||
// (same father+mother+dob) share one derived litter. Computed for dry-run counts too.
|
||||
int parentLinksAdded = 0, derivedLitters = 0;
|
||||
var synthLitterForGid = new Dictionary<Guid, Guid>(); // offspring gid -> synth litter id
|
||||
var synthLitters = new Dictionary<string, SynthLitter>(); // parents+date key -> synth litter
|
||||
foreach (var p in plan)
|
||||
{
|
||||
if (p.WurfLitterId is not null || p.CurrentLitterId is not null) continue;
|
||||
if (p.A.ParentRefs is not { Count: > 0 }) continue;
|
||||
var father = ResolveParentGid(p.A, "father", gidByNameDob);
|
||||
var mother = ResolveParentGid(p.A, "mother", gidByNameDob);
|
||||
if (father is null && mother is null) continue; // nothing resolvable to link
|
||||
var dob = ParseDate(p.A.Dob);
|
||||
var conf = p.A.ParentRefs.FirstOrDefault()?.Confidence ?? "medium";
|
||||
var key = $"{father}|{mother}|{dob:yyyy-MM-dd}";
|
||||
if (!synthLitters.TryGetValue(key, out var sl))
|
||||
{
|
||||
sl = new SynthLitter(Guid.NewGuid(), father, mother, dob, conf);
|
||||
synthLitters[key] = sl;
|
||||
derivedLitters++;
|
||||
}
|
||||
synthLitterForGid[p.Gid] = sl.Id;
|
||||
parentLinksAdded++;
|
||||
}
|
||||
|
||||
// FK-INTEGRITY (PEDIGREE-LINK bug fix): a litter's Father/MotherId must resolve to a
|
||||
// gerbil that is created-or-existing, or Postgres throws FK_Litters_Gerbils_*. Compute
|
||||
// the persisted set (existing DB rows + this run's loadable animals) and drop any parent
|
||||
// FK that isn't in it; SKIP a derived litter whose BOTH parents are unresolvable (its
|
||||
// offspring then load with LitterId=null — still better than 'unbekannt' won't regress).
|
||||
// This runs in dry-run too, so a green dry-run GUARANTEES /import/execute won't FK-fault.
|
||||
var persisted = new HashSet<Guid>(existingRows.Select(r => r.Id));
|
||||
foreach (var p in plan) persisted.Add(p.Gid);
|
||||
int litterParentFksDropped = 0, derivedLittersSkipped = 0;
|
||||
foreach (var key in synthLitters.Keys.ToList())
|
||||
{
|
||||
var sl = synthLitters[key];
|
||||
var f = sl.Father is Guid gf && persisted.Contains(gf) ? sl.Father : null;
|
||||
var m = sl.Mother is Guid gm && persisted.Contains(gm) ? sl.Mother : null;
|
||||
if (sl.Father is not null && f is null) litterParentFksDropped++;
|
||||
if (sl.Mother is not null && m is null) litterParentFksDropped++;
|
||||
if (f is null && m is null)
|
||||
{
|
||||
derivedLittersSkipped++; derivedLitters--;
|
||||
foreach (var gid in synthLitterForGid.Where(kv => kv.Value == sl.Id).Select(kv => kv.Key).ToList())
|
||||
synthLitterForGid.Remove(gid);
|
||||
synthLitters.Remove(key);
|
||||
continue;
|
||||
}
|
||||
synthLitters[key] = sl with { Father = f, Mother = m };
|
||||
}
|
||||
parentLinksAdded = synthLitterForGid.Count;
|
||||
|
||||
// litter id -> (father, mother) gids, across synthesized + Wurfchronik (by name) litters,
|
||||
// each FK guarded by the persisted set. Used by residency rule (b) below; augmented with
|
||||
// existing DB litters under execute.
|
||||
var litterParents = new Dictionary<Guid, (Guid? F, Guid? M)>();
|
||||
foreach (var sl in synthLitters.Values)
|
||||
litterParents[sl.Id] = (sl.Father, sl.Mother);
|
||||
foreach (var sl in litters)
|
||||
if (litterIdMap.TryGetValue(sl.Id, out var lid))
|
||||
{
|
||||
Guid? f = createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fid) && persisted.Contains(fid) ? fid : null;
|
||||
Guid? m = createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mid) && persisted.Contains(mid) ? mid : null;
|
||||
litterParents[lid] = (f, m);
|
||||
}
|
||||
|
||||
// PASS 2: stage synthesized litters (parents already guarded above). DO NOT save them
|
||||
// before the animals — the offspring AND the parent gerbils are created in the loop
|
||||
// below, so a single SaveChanges at the end lets EF order parents→litters→offspring
|
||||
// (all FKs are nullable). Saving litters first is exactly what caused the FK fault.
|
||||
if (execute)
|
||||
{
|
||||
// reuse an existing litter with the same parents+date instead of duplicating.
|
||||
var existingLitterRows = await _db.Litters
|
||||
.Select(l => new { l.Id, l.FatherId, l.MotherId, l.Date }).ToListAsync();
|
||||
var litterByParentsDate = new Dictionary<string, Guid>();
|
||||
foreach (var l in existingLitterRows)
|
||||
{
|
||||
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{l.Date:yyyy-MM-dd}"] = l.Id;
|
||||
litterParents[l.Id] = (l.FatherId, l.MotherId);
|
||||
}
|
||||
|
||||
foreach (var sl in synthLitters.Values.ToList())
|
||||
{
|
||||
var reuseKey = $"{sl.Father}|{sl.Mother}|{sl.Date:yyyy-MM-dd}";
|
||||
if (litterByParentsDate.TryGetValue(reuseKey, out var existingId))
|
||||
{
|
||||
// remap offspring to the existing litter; don't create a duplicate.
|
||||
foreach (var g in synthLitterForGid.Where(kv => kv.Value == sl.Id).Select(kv => kv.Key).ToList())
|
||||
synthLitterForGid[g] = existingId;
|
||||
derivedLitters--;
|
||||
continue;
|
||||
}
|
||||
_db.Litters.Add(new Litter
|
||||
{
|
||||
Id = sl.Id,
|
||||
Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(),
|
||||
Date = sl.Date ?? default,
|
||||
FatherId = sl.Father,
|
||||
MotherId = sl.Mother,
|
||||
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
|
||||
});
|
||||
}
|
||||
// NOTE: no SaveChanges here — staged with the gerbils below.
|
||||
}
|
||||
|
||||
// OWNERSHIP/RESIDENCY (runs AFTER litter links exist): (a) Zuchtname matches the Clan
|
||||
// kennel (zuchtCanon contains 'kleinechaote'); (b) parent of a Clan offspring, even if
|
||||
// the parent's own Zuchtname is foreign. See hive/agents/god/OWNERSHIP-residency.md.
|
||||
static bool ClanCanon(string? zc) =>
|
||||
(zc ?? "").Contains("kleinechaote", StringComparison.OrdinalIgnoreCase);
|
||||
var resident = new HashSet<Guid>();
|
||||
foreach (var p in plan) if (ClanCanon(p.A.ZuchtCanon)) resident.Add(p.Gid); // (a)
|
||||
int residentByA = resident.Count, flippedByParentRule = 0;
|
||||
foreach (var p in plan)
|
||||
{
|
||||
if (!ClanCanon(p.A.ZuchtCanon)) continue;
|
||||
var litId = p.WurfLitterId ?? (synthLitterForGid.TryGetValue(p.Gid, out var s) ? s : (Guid?)null);
|
||||
if (litId is null || !litterParents.TryGetValue(litId.Value, out var par)) continue;
|
||||
if (par.F is Guid gf && resident.Add(gf)) flippedByParentRule++; // (b)
|
||||
if (par.M is Guid gm && resident.Add(gm)) flippedByParentRule++;
|
||||
}
|
||||
int residentTotal = plan.Count(p => resident.Contains(p.Gid));
|
||||
int externalTotal = plan.Count - residentTotal;
|
||||
|
||||
foreach (var p in plan)
|
||||
{
|
||||
Guid? litterId = p.WurfLitterId
|
||||
?? (synthLitterForGid.TryGetValue(p.Gid, out var slid) ? slid : (Guid?)null);
|
||||
if (litterId is not null) linked++;
|
||||
bool isResident = resident.Contains(p.Gid);
|
||||
|
||||
if (p.ColorVarietyId is null) fbUnmatched++; else fbMatched++;
|
||||
|
||||
if (samples.Count < 16)
|
||||
samples.Add($"Tier: {a.Name} (*{a.Dob}), Genotyp {ComposeGenotype(a.Genotype)}"
|
||||
+ (litterId is not null ? ", Wurf-verknüpft" : "")
|
||||
+ (colorVarietyId is not null ? $", Farbschlag „{a.Farbschlag}\"" : ""));
|
||||
samples.Add($"Tier: {p.A.Name} (*{p.A.Dob}), Genotyp {ComposeGenotype(p.A.Genotype)}"
|
||||
+ (litterId is not null ? (p.WurfLitterId is not null ? ", Wurf-verknüpft" : ", Eltern aus Diagramm") : "")
|
||||
+ (p.ColorVarietyId is not null ? $", Farbschlag „{p.A.Farbschlag}\"" : ""));
|
||||
|
||||
if (p.Exists)
|
||||
{
|
||||
animalsExisting++;
|
||||
// sweep idempotency: re-link a now-linkable animal + refresh its residency.
|
||||
if (execute)
|
||||
{
|
||||
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == p.Gid);
|
||||
if (row is not null)
|
||||
{
|
||||
if (p.CurrentLitterId is null && litterId is not null) row.LitterId = litterId;
|
||||
row.IsResident = isResident;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
animalsCreated++;
|
||||
|
||||
if (execute)
|
||||
{
|
||||
_db.Gerbils.Add(new Gerbil
|
||||
{
|
||||
Id = gid,
|
||||
Name = a.Name,
|
||||
Gender = gender,
|
||||
Id = p.Gid,
|
||||
Name = p.A.Name,
|
||||
Gender = p.Gender,
|
||||
Status = GerbilStatus.Active,
|
||||
DateOfBirth = ParseDate(a.Dob),
|
||||
DateOfDeath = ParseDate(a.Death),
|
||||
DateOfBirth = ParseDate(p.A.Dob),
|
||||
DateOfDeath = ParseDate(p.A.Death),
|
||||
LitterId = litterId,
|
||||
ColorVarietyId = colorVarietyId,
|
||||
Genotype = ComposeGenotype(a.Genotype),
|
||||
ColorVarietyId = p.ColorVarietyId,
|
||||
Genotype = ComposeGenotype(p.A.Genotype),
|
||||
IsDeaf = p.A.Deaf,
|
||||
IsResident = isResident,
|
||||
ImportSource = ImportSourceTag,
|
||||
ExternalRef = a.Id,
|
||||
ExternalRef = p.A.Id,
|
||||
OriginBreeder = string.IsNullOrWhiteSpace(p.A.Zucht) ? null : p.A.Zucht.Trim(),
|
||||
RawImportData = JsonSerializer.Serialize(new
|
||||
{
|
||||
a.Genotype.RawGenotype,
|
||||
a.Genotype.UnmappedTokens,
|
||||
a.Zucht,
|
||||
a.SourceFiles,
|
||||
FarbschlagRaw = a.Farbschlag,
|
||||
p.A.Genotype.RawGenotype,
|
||||
p.A.Genotype.UnmappedTokens,
|
||||
// GEN-3b: Sls (2nd spotting locus) preserved here until Kevin's GEN-3a
|
||||
// parser adopts it into the compact Genotype contract; tags + deaf too.
|
||||
Sls = p.A.Genotype.Mapped8locus.TryGetValue("Sls", out var sls) ? sls : null,
|
||||
p.A.Tags,
|
||||
p.A.Deaf,
|
||||
p.A.Zucht,
|
||||
p.A.SourceFiles,
|
||||
FarbschlagRaw = p.A.Farbschlag,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// photos
|
||||
foreach (var rel in a.Photos)
|
||||
foreach (var rel in p.A.Photos)
|
||||
{
|
||||
var src = Path.Combine(_sourceDir, rel.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!File.Exists(src)) { photosMissing++; continue; }
|
||||
@@ -201,7 +378,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
_db.GerbilPhotos.Add(new GerbilPhoto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = gid,
|
||||
GerbilId = p.Gid,
|
||||
FileName = fileName,
|
||||
SortOrder = 0,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
@@ -211,7 +388,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
}
|
||||
if (execute) await _db.SaveChangesAsync();
|
||||
|
||||
// ---- back-link litter parents by name (best effort) ----
|
||||
// ---- back-link Wurfchronik litter parents by name (best effort) ----
|
||||
if (execute)
|
||||
{
|
||||
foreach (var sl in litters)
|
||||
@@ -219,26 +396,95 @@ namespace GerbilManagerWebAPI.Import
|
||||
if (!litterIdMap.TryGetValue(sl.Id, out var lid)) continue;
|
||||
var litter = await _db.Litters.FirstOrDefaultAsync(l => l.Id == lid);
|
||||
if (litter is null) continue;
|
||||
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fId))
|
||||
// guard: only link parents that are actually persisted (avoid an orphan FK).
|
||||
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fId) && persisted.Contains(fId))
|
||||
litter.FatherId = fId;
|
||||
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mId))
|
||||
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mId) && persisted.Contains(mId))
|
||||
litter.MotherId = mId;
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// PARENT-FK BACKFILL (idempotent re-run): already-imported Wurfchronik litters that
|
||||
// have null Father/MotherId because the parent was previously quarantined may now be
|
||||
// resolvable. Two lookup sources — must check BOTH:
|
||||
// (a) createdAnimalByName: animals loaded/re-linked in THIS run (new or existing).
|
||||
// (b) allDbNormToGid: ALL gerbils already in the DB, for parents loaded in an
|
||||
// EARLIER run who are no longer in the current extract (e.g. alreadyImported
|
||||
// animals absent from this run's animals.json, or name normalization mismatch
|
||||
// between animals.json and the Wurfchronik sire/dam field).
|
||||
// Counted for dry-run too; writes only when execute=true.
|
||||
int parentFksBackfilled = 0;
|
||||
{
|
||||
// Build DB-wide normalized-name lookup (supplementary to createdAnimalByName).
|
||||
var allDbNormToGid = existingRows
|
||||
.GroupBy(g => Normalize(StripZucht(g.Name)))
|
||||
.ToDictionary(grp => grp.Key, grp => grp.First().Id);
|
||||
|
||||
var existingWithNullParent = await _db.Litters
|
||||
.Where(l => l.FatherId == null || l.MotherId == null)
|
||||
.Select(l => new { l.Id, l.Name, l.FatherId, l.MotherId })
|
||||
.ToListAsync();
|
||||
var sourceByName = litters
|
||||
.GroupBy(sl => $"Wurf {sl.LitterId}".Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
Guid? ResolveParentForBackfill(string rawName)
|
||||
{
|
||||
var n = Normalize(StripZucht(rawName));
|
||||
if (n.Length == 0) return null;
|
||||
if (createdAnimalByName.TryGetValue(n, out var fromLoadable) && persisted.Contains(fromLoadable))
|
||||
return fromLoadable;
|
||||
if (allDbNormToGid.TryGetValue(n, out var fromDb) && persisted.Contains(fromDb))
|
||||
return fromDb;
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var el in existingWithNullParent)
|
||||
{
|
||||
if (!sourceByName.TryGetValue(el.Name, out var sl)) continue;
|
||||
var newF = el.FatherId == null ? ResolveParentForBackfill(sl.SireName) : null;
|
||||
var newM = el.MotherId == null ? ResolveParentForBackfill(sl.DamName) : null;
|
||||
if (newF is null && newM is null) continue;
|
||||
parentFksBackfilled++;
|
||||
if (execute)
|
||||
{
|
||||
var row = await _db.Litters.FirstOrDefaultAsync(l => l.Id == el.Id);
|
||||
if (row is not null)
|
||||
{
|
||||
if (newF is not null) row.FatherId = newF;
|
||||
if (newM is not null) row.MotherId = newM;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (littersWithoutDate > 0)
|
||||
notes.Add($"Würfe ohne Datum: {littersWithoutDate} Wurfchronik-Einträge ohne parsbares Geburtsdatum übersprungen (weder erstellt noch verknüpft).");
|
||||
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
||||
if (parentLinksAdded > 0)
|
||||
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
|
||||
notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher.");
|
||||
if (parentFksBackfilled > 0)
|
||||
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
||||
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
||||
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
||||
if (conflictsResolvedByDecision > 0)
|
||||
notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich).");
|
||||
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
|
||||
|
||||
return new ImportReport(
|
||||
Executed: execute,
|
||||
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting),
|
||||
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled, littersWithoutDate),
|
||||
Animals: new AnimalSummary(
|
||||
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
||||
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs)),
|
||||
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
||||
parentLinksAdded, conflictsResolvedByDecision),
|
||||
Photos: new PhotoSummary(photosAttached, photosMissing),
|
||||
Samples: samples,
|
||||
Notes: notes);
|
||||
Notes: notes,
|
||||
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule));
|
||||
}
|
||||
|
||||
private T? Load<T>(string file)
|
||||
@@ -256,7 +502,16 @@ namespace GerbilManagerWebAPI.Import
|
||||
if (g.Mapped8locus.TryGetValue(locus, out var pair) && pair.Count == 2)
|
||||
return StripCaret(pair[0]) + StripCaret(pair[1]);
|
||||
return "??";
|
||||
});
|
||||
}).ToList();
|
||||
|
||||
// GEN-3a contract (Kevin): Sls is appended LAST and ONLY for carriers — the
|
||||
// wild-type sl/sl is omitted so existing 8-locus strings stay unchanged. WP het
|
||||
// renders as the trailing token "Slsl"; S(l)S(l) is lethal so never appears.
|
||||
if (g.Mapped8locus.TryGetValue("Sls", out var sls) && sls.Count == 2
|
||||
&& !(sls[0] == "sl" && sls[1] == "sl"))
|
||||
{
|
||||
tokens.Add(StripCaret(sls[0]) + StripCaret(sls[1]));
|
||||
}
|
||||
return string.Join(' ', tokens);
|
||||
}
|
||||
|
||||
@@ -264,6 +519,11 @@ namespace GerbilManagerWebAPI.Import
|
||||
|
||||
private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams)
|
||||
{
|
||||
// Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it
|
||||
// over sire/dam name inference (PEDIGREE-LINK, Julian 2026-06-06).
|
||||
if (string.Equals(a.Gender, "male", StringComparison.OrdinalIgnoreCase)) return Gender.male;
|
||||
if (string.Equals(a.Gender, "female", StringComparison.OrdinalIgnoreCase)) return Gender.female;
|
||||
|
||||
var n = Normalize(StripZucht(a.Name));
|
||||
bool isSire = sires.Contains(n), isDam = dams.Contains(n);
|
||||
if (isSire && !isDam) return Gender.male;
|
||||
@@ -297,5 +557,26 @@ namespace GerbilManagerWebAPI.Import
|
||||
n = Regex.Replace(n, @"[^a-z0-9äöüß]", "");
|
||||
return n;
|
||||
}
|
||||
|
||||
/// <summary>Dedup identity for parent resolution: normalized call-name + DOB.</summary>
|
||||
private static string NameDobKey(string name, DateOnly? dob) =>
|
||||
$"{Normalize(StripZucht(name))}|{dob:yyyy-MM-dd}";
|
||||
|
||||
/// <summary>Resolve a chart-position parentRef (by role) to a known gerbil id, or null.</summary>
|
||||
private static Guid? ResolveParentGid(SourceAnimal a, string role, Dictionary<string, Guid> gidByNameDob)
|
||||
{
|
||||
var pr = a.ParentRefs.FirstOrDefault(p =>
|
||||
string.Equals(p.RoleGuess, role, StringComparison.OrdinalIgnoreCase));
|
||||
if (pr is null || string.IsNullOrWhiteSpace(pr.Name)) return null;
|
||||
return gidByNameDob.TryGetValue(NameDobKey(pr.Name, ParseDate(pr.Dob)), out var id) ? id : null;
|
||||
}
|
||||
|
||||
/// <summary>Per-animal plan computed before any write so synthesis can run in dry-run too.</summary>
|
||||
private sealed record AnimalPlan(
|
||||
SourceAnimal A, Guid Gid, bool Exists, Guid? WurfLitterId,
|
||||
Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender);
|
||||
|
||||
/// <summary>A litter synthesized from chart-position parentRefs (PEDIGREE-LINK).</summary>
|
||||
private sealed record SynthLitter(Guid Id, Guid? Father, Guid? Mother, DateOnly? Date, string Confidence);
|
||||
}
|
||||
}
|
||||
|
||||
108
GerbilManagerWebAPI/Inbox/DraftReplyService.cs
Normal file
108
GerbilManagerWebAPI/Inbox/DraftReplyService.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using GerbilManagerWebAPI.Ai;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManagerWebAPI.Inbox
|
||||
{
|
||||
/// <summary>
|
||||
/// INBOX-2: AI reply DRAFT for an incoming inquiry (human-in-the-loop — the
|
||||
/// breeder edits and sends herself, NEVER auto-send; send is INBOX-3).
|
||||
///
|
||||
/// PRIVACY / data minimization (INBOX-architecture.md §privacy): the prompt
|
||||
/// carries ONLY the inquiry's own text (signatures/quoted history stripped),
|
||||
/// the sender's display name for the greeting, a short German context, and
|
||||
/// optionally the current ForSale animals (name + Farbschlag). NOT the
|
||||
/// contact DB, NOT other requests, NOT any addresses.
|
||||
/// </summary>
|
||||
public sealed class DraftReplyService(HttpClient http, IOptions<AiOptions> options)
|
||||
{
|
||||
private readonly OpenAiChatClient _client = new(http, options);
|
||||
|
||||
public sealed record ForSaleAnimal(string Name, string? Farbschlag);
|
||||
|
||||
public Task<AiCallResult> DraftAsync(
|
||||
Request request, IReadOnlyList<ForSaleAnimal> forSale, CancellationToken ct = default)
|
||||
=> _client.CompleteAsync(BuildSystemPrompt(), BuildUserPrompt(request, forSale), ct);
|
||||
|
||||
internal static string BuildSystemPrompt() => """
|
||||
Du hilfst einer Hobby-Rennmaus-Züchterin („Zucht der kleinen Chaoten“), eine
|
||||
freundliche deutsche Antwort auf eine Anfrage zu ENTWERFEN. Sie liest den
|
||||
Entwurf, passt ihn an und versendet selbst.
|
||||
|
||||
Regeln:
|
||||
- Ton: warm, persönlich, hilfsbereit — wie eine erfahrene Hobby-Züchterin,
|
||||
nicht wie ein Unternehmen. Anrede per „du“, Gruß mit dem Namen der
|
||||
anfragenden Person, falls bekannt.
|
||||
- KEINE Fakten erfinden: Verfügbarkeit, Tiere und Eigenschaften NUR aus den
|
||||
mitgelieferten Daten. Ist keine Abgabetier-Liste mitgeliefert oder passt
|
||||
nichts, verweise freundlich darauf, dass sie aktuelle Infos persönlich gibt.
|
||||
- Keine Preise/Schutzgebühren nennen und keine Adressen oder sonstige
|
||||
persönliche Daten — solche Details klärt sie selbst im Gespräch.
|
||||
- Gib NUR den Antworttext aus (ohne Betreff, ohne Erklärungen) und beende
|
||||
mit einem herzlichen Gruß ohne Namens-Signatur.
|
||||
""";
|
||||
|
||||
internal static string BuildUserPrompt(Request request, IReadOnlyList<ForSaleAnimal> forSale)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Entwirf eine Antwort auf folgende Anfrage:");
|
||||
sb.AppendLine();
|
||||
if (!string.IsNullOrWhiteSpace(request.FromName))
|
||||
sb.AppendLine($"Von: {request.FromName}");
|
||||
if (!string.IsNullOrWhiteSpace(request.Subject))
|
||||
sb.AppendLine($"Betreff: {request.Subject}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Anfrage-Text:");
|
||||
sb.AppendLine(StripQuotedText(request.BodyText ?? ""));
|
||||
if (forSale.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Aktuell abzugebende Tiere (einzige zulässige Quelle für Verfügbarkeits-Aussagen):");
|
||||
foreach (var a in forSale)
|
||||
{
|
||||
sb.AppendLine(string.IsNullOrWhiteSpace(a.Farbschlag)
|
||||
? $"- {a.Name}"
|
||||
: $"- {a.Name} ({a.Farbschlag})");
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ── Signatur-/Zitat-Stripping (best effort, bewusst konservativ) ────
|
||||
|
||||
private static readonly Regex QuoteIntro = new(
|
||||
@"^Am .+ schrieb .+:\s*$", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex OutlookHeader = new(
|
||||
@"^(Von|Gesendet|An|Betreff):\s", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Drops quoted history ('>'-lines, the German Gmail "Am … schrieb …:"
|
||||
/// intro, Outlook-style forwarded headers) and everything below a signature
|
||||
/// delimiter ("-- "). Keeps the sender's own words untouched.
|
||||
/// </summary>
|
||||
internal static string StripQuotedText(string body)
|
||||
{
|
||||
var lines = body.Replace("\r\n", "\n").Split('\n');
|
||||
var kept = new List<string>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = line.TrimEnd();
|
||||
if (trimmed == "--" || trimmed == "-- ")
|
||||
break; // signature delimiter: everything below is signature
|
||||
if (QuoteIntro.IsMatch(trimmed))
|
||||
break; // quoted history follows
|
||||
if (trimmed.StartsWith('>'))
|
||||
continue;
|
||||
if (OutlookHeader.IsMatch(trimmed))
|
||||
continue;
|
||||
kept.Add(line);
|
||||
}
|
||||
// collapse the whitespace the stripping may have left behind
|
||||
return string.Join("\n", kept).Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
58
GerbilManagerWebAPI/Inbox/GmailMailReader.cs
Normal file
58
GerbilManagerWebAPI/Inbox/GmailMailReader.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Search;
|
||||
using MailKit.Security;
|
||||
|
||||
namespace GerbilManagerWebAPI.Inbox
|
||||
{
|
||||
/// <summary>
|
||||
/// Live Gmail reader over IMAP (App Password auth). Reads UIDs newer than the last seen,
|
||||
/// fetches envelope + Gmail thread id + the plain-text body. Gated on Julian's App Password
|
||||
/// (tests use a fake IGmailMailReader).
|
||||
/// </summary>
|
||||
public sealed class GmailMailReader : IGmailMailReader
|
||||
{
|
||||
public async Task<IReadOnlyList<MailSummary>> FetchAsync(MailConnection connection, uint sinceUid, CancellationToken ct = default)
|
||||
{
|
||||
using var client = new ImapClient();
|
||||
await client.ConnectAsync("imap.gmail.com", 993, SecureSocketOptions.SslOnConnect, ct);
|
||||
await client.AuthenticateAsync(connection.GmailAddress, connection.AppPassword, ct);
|
||||
|
||||
var folder = string.Equals(connection.Folder, "INBOX", StringComparison.OrdinalIgnoreCase)
|
||||
? client.Inbox
|
||||
: await client.GetFolderAsync(connection.Folder, ct);
|
||||
await folder.OpenAsync(FolderAccess.ReadOnly, ct);
|
||||
|
||||
var range = new UniqueIdRange(new UniqueId(sinceUid + 1), UniqueId.MaxValue);
|
||||
var uids = await folder.SearchAsync(SearchQuery.Uids(range), ct);
|
||||
|
||||
const MessageSummaryItems items = MessageSummaryItems.Envelope
|
||||
| MessageSummaryItems.UniqueId
|
||||
| MessageSummaryItems.GMailThreadId
|
||||
| MessageSummaryItems.InternalDate;
|
||||
var summaries = await folder.FetchAsync(uids, items, ct);
|
||||
|
||||
var result = new List<MailSummary>();
|
||||
foreach (var s in summaries)
|
||||
{
|
||||
var env = s.Envelope;
|
||||
var msg = await folder.GetMessageAsync(s.UniqueId, ct); // body + threading headers
|
||||
var from = env?.From?.Mailboxes?.FirstOrDefault();
|
||||
result.Add(new MailSummary(
|
||||
MessageId: env?.MessageId ?? msg.MessageId ?? "",
|
||||
ThreadId: s.GMailThreadId?.ToString(),
|
||||
InReplyTo: msg.InReplyTo,
|
||||
References: msg.References is { Count: > 0 } ? string.Join(' ', msg.References) : null,
|
||||
FromAddress: from?.Address ?? "",
|
||||
FromName: string.IsNullOrWhiteSpace(from?.Name) ? null : from!.Name,
|
||||
Subject: env?.Subject ?? msg.Subject,
|
||||
BodyText: msg.TextBody,
|
||||
ReceivedAt: s.InternalDate ?? env?.Date ?? DateTimeOffset.UtcNow,
|
||||
Uid: s.UniqueId.Id));
|
||||
}
|
||||
|
||||
await client.DisconnectAsync(true, ct);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
43
GerbilManagerWebAPI/Inbox/GmailMailSender.cs
Normal file
43
GerbilManagerWebAPI/Inbox/GmailMailSender.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using MimeKit;
|
||||
|
||||
namespace GerbilManagerWebAPI.Inbox
|
||||
{
|
||||
/// <summary>
|
||||
/// Live Gmail sender over SMTP (App Password auth, STARTTLS). Builds a correctly-threaded
|
||||
/// reply so it lands in the same Gmail conversation. Gated on Julian's App Password
|
||||
/// (tests use a fake IGmailMailSender + assert BuildMimeMessage).
|
||||
/// </summary>
|
||||
public sealed class GmailMailSender : IGmailMailSender
|
||||
{
|
||||
public async Task SendAsync(MailConnection connection, OutgoingMail mail, CancellationToken ct = default)
|
||||
{
|
||||
var message = BuildMimeMessage(connection.GmailAddress, mail);
|
||||
using var client = new SmtpClient();
|
||||
await client.ConnectAsync("smtp.gmail.com", 587, SecureSocketOptions.StartTls, ct);
|
||||
await client.AuthenticateAsync(connection.GmailAddress, connection.AppPassword, ct);
|
||||
await client.SendAsync(message, ct);
|
||||
await client.DisconnectAsync(true, ct);
|
||||
}
|
||||
|
||||
/// <summary>Builds the reply MimeMessage with In-Reply-To / References / Subject set for
|
||||
/// Gmail threading. Public so the threading headers are unit-testable without SMTP.</summary>
|
||||
public static MimeMessage BuildMimeMessage(string fromAddress, OutgoingMail mail)
|
||||
{
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(MailboxAddress.Parse(fromAddress));
|
||||
message.To.Add(MailboxAddress.Parse(mail.To));
|
||||
message.Subject = mail.Subject;
|
||||
message.Body = new TextPart("plain") { Text = mail.Body };
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(mail.InReplyTo))
|
||||
message.InReplyTo = mail.InReplyTo;
|
||||
if (!string.IsNullOrWhiteSpace(mail.References))
|
||||
foreach (var id in mail.References.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
message.References.Add(id);
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
29
GerbilManagerWebAPI/Inbox/MailContracts.cs
Normal file
29
GerbilManagerWebAPI/Inbox/MailContracts.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace GerbilManagerWebAPI.Inbox
|
||||
{
|
||||
/// <summary>Connection params for an IMAP fetch (decrypted password — never persisted/logged).</summary>
|
||||
public sealed record MailConnection(string GmailAddress, string AppPassword, string Folder);
|
||||
|
||||
/// <summary>A fetched email reduced to what the Request entity needs.</summary>
|
||||
public sealed record MailSummary(
|
||||
string MessageId,
|
||||
string? ThreadId,
|
||||
string? InReplyTo,
|
||||
string? References,
|
||||
string FromAddress,
|
||||
string? FromName,
|
||||
string? Subject,
|
||||
string? BodyText,
|
||||
DateTimeOffset ReceivedAt,
|
||||
uint Uid);
|
||||
|
||||
/// <summary>Reads mail from a folder. Abstracted so tests feed canned summaries
|
||||
/// (the live MailKit/Gmail path is gated on Julian's App Password).</summary>
|
||||
public interface IGmailMailReader
|
||||
{
|
||||
/// <summary>Fetch messages with UID > <paramref name="sinceUid"/> from the configured folder.</summary>
|
||||
Task<IReadOnlyList<MailSummary>> FetchAsync(MailConnection connection, uint sinceUid, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a sync run.</summary>
|
||||
public sealed record SyncResult(int Imported, string? Error);
|
||||
}
|
||||
27
GerbilManagerWebAPI/Inbox/MailSendContracts.cs
Normal file
27
GerbilManagerWebAPI/Inbox/MailSendContracts.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace GerbilManagerWebAPI.Inbox
|
||||
{
|
||||
/// <summary>A reply to send, with RFC threading headers already computed.</summary>
|
||||
public sealed record OutgoingMail(
|
||||
string To,
|
||||
string Subject,
|
||||
string Body,
|
||||
string? InReplyTo,
|
||||
string? References);
|
||||
|
||||
/// <summary>Outcome of a send attempt. Error is null on success; otherwise a stable code
|
||||
/// ("MailNotConfigured", "MailAuthFailed") the UI maps to a German hint.</summary>
|
||||
public sealed record SendOutcome(bool Sent, string? Error)
|
||||
{
|
||||
public static readonly SendOutcome Ok = new(true, null);
|
||||
public static SendOutcome Fail(string code) => new(false, code);
|
||||
}
|
||||
|
||||
/// <summary>Sends mail. Abstracted so tests can assert the message/capture it without a live
|
||||
/// SMTP server (the live MailKit/Gmail path is gated on Julian's App Password).</summary>
|
||||
public interface IGmailMailSender
|
||||
{
|
||||
/// <summary>Sends the reply. Throws <see cref="MailKit.Security.AuthenticationException"/>
|
||||
/// on bad/revoked credentials (surfaced as "MailAuthFailed").</summary>
|
||||
Task SendAsync(MailConnection connection, OutgoingMail mail, CancellationToken ct = default);
|
||||
}
|
||||
}
|
||||
43
GerbilManagerWebAPI/Inbox/MailSettingsService.cs
Normal file
43
GerbilManagerWebAPI/Inbox/MailSettingsService.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Inbox
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads/updates the singleton MailSettings. The Gmail App Password is encrypted at rest
|
||||
/// with ASP.NET Data Protection and is NEVER returned over the wire (only used server-side
|
||||
/// to open the IMAP/SMTP connection).
|
||||
/// </summary>
|
||||
public sealed class MailSettingsService
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
private readonly IDataProtector _protector;
|
||||
|
||||
public MailSettingsService(ApplicationContext db, IDataProtectionProvider dp)
|
||||
{
|
||||
_db = db;
|
||||
_protector = dp.CreateProtector("GerbilManager.MailSettings.AppPassword.v1");
|
||||
}
|
||||
|
||||
public async Task<MailSettings> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
var s = await _db.MailSettings.FirstOrDefaultAsync(ct);
|
||||
if (s is null)
|
||||
{
|
||||
s = new MailSettings { Id = MailSettings.SingletonId };
|
||||
_db.MailSettings.Add(s);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
public bool HasAppPassword(MailSettings s) => !string.IsNullOrEmpty(s.AppPasswordProtected);
|
||||
|
||||
public string? DecryptPassword(MailSettings s) =>
|
||||
string.IsNullOrEmpty(s.AppPasswordProtected) ? null : _protector.Unprotect(s.AppPasswordProtected);
|
||||
|
||||
public void SetPassword(MailSettings s, string? plain) =>
|
||||
s.AppPasswordProtected = string.IsNullOrEmpty(plain) ? null : _protector.Protect(plain);
|
||||
}
|
||||
}
|
||||
69
GerbilManagerWebAPI/Inbox/RequestSyncService.cs
Normal file
69
GerbilManagerWebAPI/Inbox/RequestSyncService.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Inbox
|
||||
{
|
||||
/// <summary>
|
||||
/// Imports Gmail messages into Request rows: dedup on Message-Id, track the highest IMAP
|
||||
/// UID to avoid rescans. The actual fetch is behind <see cref="IGmailMailReader"/> (mockable).
|
||||
/// </summary>
|
||||
public sealed class RequestSyncService
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
private readonly IGmailMailReader _reader;
|
||||
private readonly MailSettingsService _settings;
|
||||
|
||||
public RequestSyncService(ApplicationContext db, IGmailMailReader reader, MailSettingsService settings)
|
||||
{
|
||||
_db = db;
|
||||
_reader = reader;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public async Task<SyncResult> SyncAsync(CancellationToken ct = default)
|
||||
{
|
||||
var settings = await _settings.GetAsync(ct);
|
||||
var password = _settings.DecryptPassword(settings);
|
||||
if (string.IsNullOrWhiteSpace(settings.GmailAddress) || string.IsNullOrWhiteSpace(password))
|
||||
return new SyncResult(0, "MailNotConfigured");
|
||||
|
||||
var conn = new MailConnection(settings.GmailAddress!, password!, settings.Folder);
|
||||
var summaries = await _reader.FetchAsync(conn, settings.LastUid, ct);
|
||||
|
||||
uint maxUid = settings.LastUid;
|
||||
int imported = 0;
|
||||
if (summaries.Count > 0)
|
||||
{
|
||||
var fetchedIds = summaries.Select(s => s.MessageId).Where(id => !string.IsNullOrEmpty(id)).ToList();
|
||||
var existing = (await _db.Requests
|
||||
.Where(r => fetchedIds.Contains(r.GmailMessageId))
|
||||
.Select(r => r.GmailMessageId).ToListAsync(ct)).ToHashSet();
|
||||
|
||||
foreach (var m in summaries)
|
||||
{
|
||||
if (m.Uid > maxUid) maxUid = m.Uid;
|
||||
if (string.IsNullOrEmpty(m.MessageId) || !existing.Add(m.MessageId)) continue;
|
||||
_db.Requests.Add(new Request
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GmailMessageId = m.MessageId,
|
||||
ThreadId = m.ThreadId,
|
||||
InReplyToMessageId = m.InReplyTo,
|
||||
ReferencesHeader = m.References,
|
||||
FromAddress = m.FromAddress,
|
||||
FromName = m.FromName,
|
||||
Subject = m.Subject,
|
||||
BodyText = m.BodyText,
|
||||
ReceivedAt = m.ReceivedAt,
|
||||
Status = RequestStatus.New,
|
||||
});
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
|
||||
settings.LastUid = maxUid;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new SyncResult(imported, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
GerbilManagerWebAPI/Inbox/SendReplyService.cs
Normal file
73
GerbilManagerWebAPI/Inbox/SendReplyService.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using MailKit.Security;
|
||||
|
||||
namespace GerbilManagerWebAPI.Inbox
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a (human-edited) reply to a Request via SMTP, threaded into the original Gmail
|
||||
/// conversation, and marks the request Answered. Only ever called on the explicit
|
||||
/// POST /api/requests/{id}/send — never auto-sends.
|
||||
/// </summary>
|
||||
public sealed class SendReplyService
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
private readonly IGmailMailSender _sender;
|
||||
private readonly MailSettingsService _settings;
|
||||
|
||||
public SendReplyService(ApplicationContext db, IGmailMailSender sender, MailSettingsService settings)
|
||||
{
|
||||
_db = db;
|
||||
_sender = sender;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public async Task<SendOutcome> SendAsync(Request request, string body, CancellationToken ct = default)
|
||||
{
|
||||
var settings = await _settings.GetAsync(ct);
|
||||
var password = _settings.DecryptPassword(settings);
|
||||
if (string.IsNullOrWhiteSpace(settings.GmailAddress) || string.IsNullOrWhiteSpace(password))
|
||||
return SendOutcome.Fail("MailNotConfigured");
|
||||
|
||||
var mail = new OutgoingMail(
|
||||
To: request.FromAddress,
|
||||
Subject: ReplySubject(request.Subject),
|
||||
Body: body,
|
||||
InReplyTo: request.GmailMessageId,
|
||||
References: BuildReferences(request.ReferencesHeader, request.GmailMessageId));
|
||||
|
||||
try
|
||||
{
|
||||
await _sender.SendAsync(new MailConnection(settings.GmailAddress!, password!, settings.Folder), mail, ct);
|
||||
}
|
||||
catch (AuthenticationException)
|
||||
{
|
||||
return SendOutcome.Fail("MailAuthFailed");
|
||||
}
|
||||
|
||||
request.Status = RequestStatus.Answered;
|
||||
request.AnsweredAt = DateTimeOffset.UtcNow;
|
||||
request.DraftReply = body; // record what was actually sent
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return SendOutcome.Ok;
|
||||
}
|
||||
|
||||
/// <summary>"Re: " prefix without double-prefixing an existing reply subject.</summary>
|
||||
internal static string ReplySubject(string? original)
|
||||
{
|
||||
var s = (original ?? "").Trim();
|
||||
if (s.Length == 0) return "Re:";
|
||||
return s.StartsWith("Re:", StringComparison.OrdinalIgnoreCase) ? s : $"Re: {s}";
|
||||
}
|
||||
|
||||
/// <summary>References = existing chain + the original Message-Id (deduped, in order).</summary>
|
||||
internal static string BuildReferences(string? existingChain, string originalMessageId)
|
||||
{
|
||||
var ids = (existingChain ?? "")
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
if (!string.IsNullOrWhiteSpace(originalMessageId) && !ids.Contains(originalMessageId))
|
||||
ids.Add(originalMessageId);
|
||||
return string.Join(' ', ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
1036
GerbilManagerWebAPI/Migrations/20260606070015_AddSearchFields.Designer.cs
generated
Normal file
1036
GerbilManagerWebAPI/Migrations/20260606070015_AddSearchFields.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSearchFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "NameSearch",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "OriginBreeder",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
// Backfill NameSearch for existing rows (lowercase, strip whitespace/.-_),
|
||||
// matching GerbilSearch.Normalize. New/updated rows stay in sync via
|
||||
// ApplicationContext.SaveChanges.
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE \"Gerbils\" SET \"NameSearch\" = lower(regexp_replace(\"Name\", '[[:space:]._-]', '', 'g'));");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NameSearch",
|
||||
table: "Gerbils");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OriginBreeder",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1043
GerbilManagerWebAPI/Migrations/20260606070929_AddCharacterFields.Designer.cs
generated
Normal file
1043
GerbilManagerWebAPI/Migrations/20260606070929_AddCharacterFields.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCharacterFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CharacterNote",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CharacterTraits",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CharacterNote",
|
||||
table: "Gerbils");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CharacterTraits",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1263
GerbilManagerWebAPI/Migrations/20260606071918_AddCmsEntities.Designer.cs
generated
Normal file
1263
GerbilManagerWebAPI/Migrations/20260606071918_AddCmsEntities.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
141
GerbilManagerWebAPI/Migrations/20260606071918_AddCmsEntities.cs
Normal file
141
GerbilManagerWebAPI/Migrations/20260606071918_AddCmsEntities.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCmsEntities : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Media",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FileName = table.Column<string>(type: "text", nullable: false),
|
||||
Url = table.Column<string>(type: "text", nullable: false),
|
||||
Alt = table.Column<string>(type: "text", nullable: true),
|
||||
Width = table.Column<int>(type: "integer", nullable: true),
|
||||
Height = table.Column<int>(type: "integer", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Media", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Pages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Slug = table.Column<string>(type: "text", nullable: false),
|
||||
Title = table.Column<string>(type: "text", nullable: false),
|
||||
SeoDescription = table.Column<string>(type: "text", nullable: true),
|
||||
Status = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Pages", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Sites",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
DefaultLocale = table.Column<string>(type: "text", nullable: false),
|
||||
NavOrder = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Sites", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Blocks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
PageId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Order = table.Column<int>(type: "integer", nullable: false),
|
||||
Type = table.Column<string>(type: "text", nullable: false),
|
||||
Data = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Blocks", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Blocks_Pages_PageId",
|
||||
column: x => x.PageId,
|
||||
principalTable: "Pages",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Pages",
|
||||
columns: new[] { "Id", "SeoDescription", "Slug", "Status", "Title" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("51720001-0000-0000-0000-000000000001"), null, "start", "Published", "Startseite" },
|
||||
{ new Guid("51720001-0000-0000-0000-000000000002"), null, "ueber-die-zucht", "Published", "Über die Zucht" },
|
||||
{ new Guid("51720001-0000-0000-0000-000000000003"), null, "abgabetiere", "Published", "Abgabetiere" },
|
||||
{ new Guid("51720001-0000-0000-0000-000000000004"), null, "abgabebedingungen", "Published", "Abgabebedingungen" },
|
||||
{ new Guid("51720001-0000-0000-0000-000000000005"), null, "farben-genetik", "Published", "Farben & Genetik" },
|
||||
{ new Guid("51720001-0000-0000-0000-000000000006"), null, "kontakt", "Published", "Kontakt" }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Sites",
|
||||
columns: new[] { "Id", "DefaultLocale", "NavOrder" },
|
||||
values: new object[] { new Guid("5172e000-0000-0000-0000-000000000001"), "de", "[\"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\"]" });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Blocks",
|
||||
columns: new[] { "Id", "Data", "Order", "PageId", "Type" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("51720002-0000-0000-0000-000000000001"), "{\"text\":\"Startseite\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000001"), "Heading" },
|
||||
{ new Guid("51720002-0000-0000-0000-000000000002"), "{\"text\":\"Über die Zucht\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000002"), "Heading" },
|
||||
{ new Guid("51720002-0000-0000-0000-000000000003"), "{\"text\":\"Abgabetiere\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000003"), "Heading" },
|
||||
{ new Guid("51720002-0000-0000-0000-000000000004"), "{\"text\":\"Abgabebedingungen\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000004"), "Heading" },
|
||||
{ new Guid("51720002-0000-0000-0000-000000000005"), "{\"text\":\"Farben & Genetik\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000005"), "Heading" },
|
||||
{ new Guid("51720002-0000-0000-0000-000000000006"), "{\"text\":\"Kontakt\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000006"), "Heading" },
|
||||
{ new Guid("51720002-0000-0000-0000-000000000010"), "{\"mode\":\"auto\",\"intro\":\"\"}", 1, new Guid("51720001-0000-0000-0000-000000000003"), "AbgabetiereList" }
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Blocks_PageId",
|
||||
table: "Blocks",
|
||||
column: "PageId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Pages_Slug",
|
||||
table: "Pages",
|
||||
column: "Slug",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Blocks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Media");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Sites");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Pages");
|
||||
}
|
||||
}
|
||||
}
|
||||
1378
GerbilManagerWebAPI/Migrations/20260606074229_AddRequestsAndMailSettings.Designer.cs
generated
Normal file
1378
GerbilManagerWebAPI/Migrations/20260606074229_AddRequestsAndMailSettings.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRequestsAndMailSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MailSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
GmailAddress = table.Column<string>(type: "text", nullable: true),
|
||||
AppPasswordProtected = table.Column<string>(type: "text", nullable: true),
|
||||
PollIntervalMinutes = table.Column<int>(type: "integer", nullable: false),
|
||||
Folder = table.Column<string>(type: "text", nullable: false),
|
||||
BackgroundPollEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
LastUid = table.Column<long>(type: "bigint", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MailSettings", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Requests",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
GmailMessageId = table.Column<string>(type: "text", nullable: false),
|
||||
ThreadId = table.Column<string>(type: "text", nullable: true),
|
||||
InReplyToMessageId = table.Column<string>(type: "text", nullable: true),
|
||||
ReferencesHeader = table.Column<string>(type: "text", nullable: true),
|
||||
FromAddress = table.Column<string>(type: "text", nullable: false),
|
||||
FromName = table.Column<string>(type: "text", nullable: true),
|
||||
Subject = table.Column<string>(type: "text", nullable: true),
|
||||
BodyText = table.Column<string>(type: "text", nullable: true),
|
||||
ReceivedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
Status = table.Column<string>(type: "text", nullable: false),
|
||||
AssignedContactId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
DraftReply = table.Column<string>(type: "text", nullable: true),
|
||||
AnsweredAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Requests", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Requests_Contacts_AssignedContactId",
|
||||
column: x => x.AssignedContactId,
|
||||
principalTable: "Contacts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "MailSettings",
|
||||
columns: new[] { "Id", "AppPasswordProtected", "BackgroundPollEnabled", "Folder", "GmailAddress", "LastUid", "PollIntervalMinutes" },
|
||||
values: new object[] { new Guid("ab0c0000-0000-0000-0000-000000000001"), null, false, "INBOX", null, 0L, 15 });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Requests_AssignedContactId",
|
||||
table: "Requests",
|
||||
column: "AssignedContactId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Requests_GmailMessageId",
|
||||
table: "Requests",
|
||||
column: "GmailMessageId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "MailSettings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Requests");
|
||||
}
|
||||
}
|
||||
}
|
||||
1381
GerbilManagerWebAPI/Migrations/20260606082045_AddGerbilDeafFlag.Designer.cs
generated
Normal file
1381
GerbilManagerWebAPI/Migrations/20260606082045_AddGerbilDeafFlag.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGerbilDeafFlag : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsDeaf",
|
||||
table: "Gerbils",
|
||||
type: "boolean",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsDeaf",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1386
GerbilManagerWebAPI/Migrations/20260606085655_AddGerbilResidency.Designer.cs
generated
Normal file
1386
GerbilManagerWebAPI/Migrations/20260606085655_AddGerbilResidency.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGerbilResidency : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsResident",
|
||||
table: "Gerbils",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsResident",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1386
GerbilManagerWebAPI/Migrations/20260606090655_SyncColorVarietySeed.Designer.cs
generated
Normal file
1386
GerbilManagerWebAPI/Migrations/20260606090655_SyncColorVarietySeed.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,971 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SyncColorVarietySeed : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000005"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG pp spsp rere", "Rotaugenschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG PP spsp rere", "Agouti" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000007"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG PP spsp rere", "Schwarz" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000008"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE gg PP spsp rere", "Silberagouti" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000009"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE gg PP spsp rere", "Anthrazit" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000010"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000011"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE GG PP spsp rere", "Blau" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000012"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000013"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000014"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000015"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG pp spsp rere", "dd Gold" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE GG pp spsp rere", "dd Platin" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000018"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE gg pp spsp rere", "Altweiss (REW)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000019"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg pp spsp rere", "Apricot (Blassfuchs)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000020"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000021"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg pp spsp rere", "C-Separator" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000022"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE gg pp spsp rere", "Elfenbein" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000023"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Marder" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
column: "Name",
|
||||
value: "Siam (Marder-Hell)");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Orangeschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee gg PP spsp rere", "Blaufuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs-Hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Siam (Marder-Hell) dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||
column: "Name",
|
||||
value: "Marder dd");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE gg PP spsp rere", "Zobel-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||
column: "Name",
|
||||
value: "Silberagouti-Hell dd CP");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||
column: "Name",
|
||||
value: "Agouti-Hell dd CP");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE gg PP spsp rere", "Silberagouti CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000065"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs-Hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs,hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000067"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000068"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000069"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG pp spsp rere", "Topas dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000070"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel-Hell dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000071"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD efef GG PP spsp rere", "Kohlfuchsschimmel CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000072"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee gg pp spsp rere", "Blaufuchs dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000073"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD efef GG PP spsp rere", "CP-Orangeschimmel" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000005"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Schwarzschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG pp spsp rere", "Rotaugenschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000007"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG PP spsp rere", "Agouti" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000008"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG PP spsp rere", "Schwarz" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000009"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE gg PP spsp rere", "Silberagouti" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000010"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE gg PP spsp rere", "Anthrazit" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000011"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000012"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE GG PP spsp rere", "Blau" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000013"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000014"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000015"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG pp spsp rere", "dd Gold" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000018"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE GG pp spsp rere", "dd Platin" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000019"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE gg pp spsp rere", "Altweiss (REW)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000020"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg pp spsp rere", "Apricot (Blassfuchs)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000021"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000022"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg pp spsp rere", "C-Separator" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000023"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE gg pp spsp rere", "Elfenbein" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
column: "Name",
|
||||
value: "Marder");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Siam (Marder-Hell)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Schimmel (Orangeschimmel)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee gg PP spsp rere", "Blaufuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs-Hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||
column: "Name",
|
||||
value: "Siam (Marder-Hell) dd");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Marder dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE gg PP spsp rere", "Zobel-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||
column: "Name",
|
||||
value: "Silberagouti dd CP");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti-Hell dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||
column: "Name",
|
||||
value: "Agouti dd CP");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti-Hell dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE gg PP spsp rere", "Silberagouti CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000065"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs-Hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000067"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs,hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000068"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000069"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000070"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG pp spsp rere", "Topas dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000071"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel-Hell dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000072"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD efef GG PP spsp rere", "Kohlfuchsschimmel CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000073"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee gg PP spsp rere", "Blaufuchs dd" });
|
||||
}
|
||||
}
|
||||
}
|
||||
1302
GerbilManagerWebAPI/Migrations/20260606095755_ReseedColorVarietiesGen3f.Designer.cs
generated
Normal file
1302
GerbilManagerWebAPI/Migrations/20260606095755_ReseedColorVarietiesGen3f.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,632 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ReseedColorVarietiesGen3f : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000062"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000063"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000064"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000065"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000066"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000067"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000068"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000069"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000070"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000071"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000072"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000073"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Orangeschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef gg PP spsp rere", "Polarfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG pp spsp rere", "Topas dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee gg pp spsp rere", "Blaufuchs dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Marder" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmch DD EE GG PP spsp rere", "Siam" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmch DD EE gg PP spsp rere", "Zobel-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "CP-Agouti" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE gg PP spsp rere", "CP-Silberagouti" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "CP-Algierfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "CP-Polarfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd ee GG PP spsp rere", "CP-Fuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmch dd ee GG PP spsp rere", "CP-Fuchs-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd ee gg PP spsp rere", "CP-Blaufuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD efef GG PP spsp rere", "CP-Orangeschimmel" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Marder" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Siam (Marder-Hell)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Orangeschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee gg PP spsp rere", "Blaufuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef gg PP spsp rere", "Polarfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs-Hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Siam (Marder-Hell) dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Marder dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE gg PP spsp rere", "Zobel-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti-Hell dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti-Hell dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs CP" });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "ColorVarieties",
|
||||
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("00000000-0000-0000-0000-000000000062"), "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs CP", 61 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000063"), "AA cchmcchm DD EE gg PP spsp rere", "Silberagouti CP", 62 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000064"), "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP", 63 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000065"), "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs-Hell CP", 64 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000066"), "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs,hell CP", 65 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000067"), "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs CP", 66 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000068"), "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell", 67 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000069"), "AA CC dd EE GG pp spsp rere", "Topas dd", 68 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000070"), "aa cchmcchm dd EE gg PP spsp rere", "Zobel-Hell dd", 69 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000071"), "aa cchmcchm DD efef GG PP spsp rere", "Kohlfuchsschimmel CP", 70 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000072"), "aa CC dd ee gg pp spsp rere", "Blaufuchs dd", 71 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000073"), "AA cchmcchm DD efef GG PP spsp rere", "CP-Orangeschimmel", 72 }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
80
GerbilManagerWebAPI/Models/CmsModels.cs
Normal file
80
GerbilManagerWebAPI/Models/CmsModels.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>Publication state of a CMS page (serialised as string name).</summary>
|
||||
public enum PageStatus
|
||||
{
|
||||
Draft = 0,
|
||||
Published = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CMS block kinds. RichText holds MARKDOWN (sanitised on render). AbgabetiereList is
|
||||
/// dynamic — resolved from live ForSale animals at snapshot time. Serialised as string.
|
||||
/// </summary>
|
||||
public enum BlockType
|
||||
{
|
||||
Heading = 0,
|
||||
RichText = 1,
|
||||
Image = 2,
|
||||
Gallery = 3,
|
||||
ContactInfo = 4,
|
||||
AbgabetiereList = 5,
|
||||
}
|
||||
|
||||
/// <summary>Singleton site settings (WEB epic CMS).</summary>
|
||||
public class Site
|
||||
{
|
||||
/// <summary>Fixed id — there is exactly one Site row.</summary>
|
||||
public static readonly Guid SingletonId = new("5172e000-0000-0000-0000-000000000001");
|
||||
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
public string DefaultLocale { get; set; } = "de";
|
||||
/// <summary>Ordered page ids for the public nav (JSON list).</summary>
|
||||
public List<Guid> NavOrder { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>A CMS page (one of the public website's pages).</summary>
|
||||
public class Page
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
/// <summary>Stable URL slug (e.g. "abgabetiere") — the human key.</summary>
|
||||
public required string Slug { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public string? SeoDescription { get; set; }
|
||||
public PageStatus Status { get; set; } = PageStatus.Draft;
|
||||
|
||||
public ICollection<Block> Blocks { get; } = new List<Block>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A content block on a page (single table, discriminated by <see cref="Type"/>).
|
||||
/// Type-specific payload lives in <see cref="Data"/> as JSON (RichText = Markdown).
|
||||
/// </summary>
|
||||
public class Block
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
public Guid PageId { get; set; }
|
||||
/// <summary>Explicit ordering within the page (AI tools reorder by this).</summary>
|
||||
public int Order { get; set; }
|
||||
public BlockType Type { get; set; }
|
||||
/// <summary>Type-specific fields as a JSON object string.</summary>
|
||||
public string Data { get; set; } = "{}";
|
||||
}
|
||||
|
||||
/// <summary>A media asset referenced by Image/Gallery blocks.</summary>
|
||||
public class Media
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
public required string FileName { get; set; }
|
||||
public required string Url { get; set; }
|
||||
public string? Alt { get; set; }
|
||||
public int? Width { get; set; }
|
||||
public int? Height { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -50,5 +50,47 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// <summary>Raw import payload preserved verbatim (rawGenotype + unmappedTokens like
|
||||
/// the Uw locus / WFNZ markers) so nothing from the spreadsheets is lost. JSON text.</summary>
|
||||
public string? RawImportData { get; set; }
|
||||
|
||||
/// <summary>Breeder/Herkunft (cattery/Zucht) as free text — set by the FEAT-8 import from
|
||||
/// the source Zucht (imported animals have no OriginContact). Gridify-filterable; the
|
||||
/// distinct values back the Tiere "Herkunft" dropdown (GET /gerbils/breeders).</summary>
|
||||
public string? OriginBreeder { get; set; }
|
||||
|
||||
/// <summary>Separator-insensitive search key: Name lowercased with whitespace/.-_ stripped.
|
||||
/// Kept in sync automatically on save (see ApplicationContext.SaveChanges). Gridify-filterable
|
||||
/// so "clan kleine chaoten" matches "Clan-Kleine-Chaoten" (client strips separators too).</summary>
|
||||
public string? NameSearch { get; set; }
|
||||
|
||||
/// <summary>FEAT-14: Charakterbogen trait labels (opaque to the backend — the
|
||||
/// {key,label} vocabulary lives frontend-side). Stored as a JSON text column.</summary>
|
||||
public List<string> CharacterTraits { get; set; } = new();
|
||||
|
||||
/// <summary>FEAT-14: free-text character note; feeds the AI Verkaufstext.</summary>
|
||||
public string? CharacterNote { get; set; }
|
||||
|
||||
/// <summary>GEN-3b: hearing/deaf phenotype flag (NOT a genotype locus — it's the
|
||||
/// downstream effect of high white load / Sp×Sls). null = not stated, true = deaf
|
||||
/// (dea/taub), false = hearing (Dea/hörend). Set by the FEAT-8 import from the
|
||||
/// after-spsp deafness annotation; see hive/agents/god/GENETIK-notation.md.</summary>
|
||||
public bool? IsDeaf { get; set; }
|
||||
|
||||
/// <summary>Residency/ownership (ORIGIN, distinct from Abgabe location): true = part of
|
||||
/// the Clan-kleine-Chaoten Bestand, false = external pedigree ancestor (bred elsewhere).
|
||||
/// Rule (a) Zuchtname matches the Clan kennel, OR (b) it's a parent of a Clan offspring.
|
||||
/// Defaults true (manually-added animals are own stock); the FEAT-8 import classifies
|
||||
/// imported animals. See hive/agents/god/OWNERSHIP-residency.md. Gridify-filterable.</summary>
|
||||
public bool IsResident { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>
|
||||
public static class GerbilSearch
|
||||
{
|
||||
public static string Normalize(string? name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name)) return "";
|
||||
var chars = name.ToLowerInvariant()
|
||||
.Where(c => !char.IsWhiteSpace(c) && c != '-' && c != '.' && c != '_');
|
||||
return new string(chars.ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
66
GerbilManagerWebAPI/Models/InboxModels.cs
Normal file
66
GerbilManagerWebAPI/Models/InboxModels.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>Triage state of an incoming Gmail request (serialised as string name).</summary>
|
||||
public enum RequestStatus
|
||||
{
|
||||
New = 0,
|
||||
InProgress = 1,
|
||||
Assigned = 2,
|
||||
Answered = 3,
|
||||
Abandoned = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An incoming inquiry imported from Gmail (INBOX epic). One row per email,
|
||||
/// deduped on the RFC Message-Id.
|
||||
/// </summary>
|
||||
public class Request
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>RFC 5322 Message-Id — unique; the dedup key.</summary>
|
||||
public required string GmailMessageId { get; set; }
|
||||
/// <summary>Gmail conversation id (X-GM-THRID).</summary>
|
||||
public string? ThreadId { get; set; }
|
||||
public string? InReplyToMessageId { get; set; }
|
||||
public string? ReferencesHeader { get; set; }
|
||||
|
||||
public required string FromAddress { get; set; }
|
||||
public string? FromName { get; set; }
|
||||
public string? Subject { get; set; }
|
||||
public string? BodyText { get; set; }
|
||||
public DateTimeOffset ReceivedAt { get; set; }
|
||||
|
||||
public RequestStatus Status { get; set; } = RequestStatus.New;
|
||||
|
||||
public Guid? AssignedContactId { get; set; }
|
||||
public Contact? AssignedContact { get; set; }
|
||||
|
||||
/// <summary>AI/edited draft reply (INBOX-2); send is INBOX-3. Null until drafted.</summary>
|
||||
public string? DraftReply { get; set; }
|
||||
public DateTimeOffset? AnsweredAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Singleton mail configuration (INBOX epic). The Gmail App Password is stored
|
||||
/// ENCRYPTED at rest (ASP.NET Data Protection) and never returned over the wire.
|
||||
/// </summary>
|
||||
public class MailSettings
|
||||
{
|
||||
public static readonly Guid SingletonId = new("ab0c0000-0000-0000-0000-000000000001");
|
||||
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
public string? GmailAddress { get; set; }
|
||||
/// <summary>Data-Protection-encrypted Gmail App Password (never plaintext, never on the wire).</summary>
|
||||
public string? AppPasswordProtected { get; set; }
|
||||
public int PollIntervalMinutes { get; set; } = 15;
|
||||
public string Folder { get; set; } = "INBOX";
|
||||
public bool BackgroundPollEnabled { get; set; }
|
||||
/// <summary>Highest IMAP UID seen, to avoid rescanning the whole folder.</summary>
|
||||
public uint LastUid { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,17 @@ builder.Services.AddOptions<GerbilManagerWebAPI.SaleAd.AiOptions>()
|
||||
.BindConfiguration(GerbilManagerWebAPI.SaleAd.AiOptions.SectionName);
|
||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
|
||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
|
||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||
|
||||
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
||||
builder.Services.AddDataProtection();
|
||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.MailSettingsService>();
|
||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilManagerWebAPI.Inbox.GmailMailReader>();
|
||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();
|
||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailSender, GerbilManagerWebAPI.Inbox.GmailMailSender>();
|
||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.SendReplyService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -76,6 +87,8 @@ app.MapImportEndpoints();
|
||||
app.MapContractEndpoints();
|
||||
app.MapSettingsEndpoints();
|
||||
app.MapExportEndpoints();
|
||||
app.MapCmsEndpoints();
|
||||
app.MapRequestEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
@@ -9,7 +9,11 @@ namespace GerbilManagerWebAPI.SaleAd
|
||||
string? Farbschlag,
|
||||
/// <summary>ISO "YYYY-MM-DD" (frontend sends the DTO string verbatim).</summary>
|
||||
string? DateOfBirth,
|
||||
string? Notes);
|
||||
string? Notes,
|
||||
/// <summary>FEAT-14: Charakterbogen trait LABELS (German, human-readable) for the prompt.</summary>
|
||||
List<string>? Traits = null,
|
||||
/// <summary>FEAT-14: free-text character note for the prompt.</summary>
|
||||
string? CharacterNote = null);
|
||||
|
||||
public sealed record SaleAdRequest(
|
||||
List<SaleAdAnimal> Animals,
|
||||
|
||||
@@ -58,7 +58,8 @@ namespace GerbilManagerWebAPI.SaleAd
|
||||
|
||||
Harte Regeln:
|
||||
- NIEMALS Preise oder Schutzgebühren nennen.
|
||||
- KEINE Fakten erfinden: Verwende ausschließlich die mitgelieferten Daten (Namen, Farbschläge, Geburtsdaten, Notizen). Fehlt eine Angabe, lässt du sie weg.
|
||||
- KEINE Fakten erfinden: Verwende ausschließlich die mitgelieferten Daten (Namen, Farbschläge, Geburtsdaten, Charakter-Angaben, Notizen). Fehlt eine Angabe, lässt du sie weg.
|
||||
- Charakter-Eigenschaften (Stichworte wie „zutraulich“, „buddelt gern“) und die Charakter-Notiz sind die Grundlage der Persönlichkeits-Prosa: Verwebe sie zu FLIESSENDEM Text — niemals als Aufzählung oder Stichwortliste ausgeben.
|
||||
- Sprache: Deutsch, warm und liebevoll, aber nicht kitschig-übertrieben.
|
||||
- Gib NUR den Inserat-Text aus — keine Erklärungen, keine Markdown-Code-Blöcke.
|
||||
|
||||
@@ -85,7 +86,17 @@ namespace GerbilManagerWebAPI.SaleAd
|
||||
sb.Append($" | Farbschlag: {animal.Farbschlag}");
|
||||
if (!string.IsNullOrWhiteSpace(animal.DateOfBirth))
|
||||
sb.Append($" | geboren am {FormatGermanDate(animal.DateOfBirth)}");
|
||||
if (!string.IsNullOrWhiteSpace(animal.Notes))
|
||||
// FEAT-14c: Charakterbogen (Traits + Notiz) ist die BEVORZUGTE
|
||||
// Charakterquelle; die generischen Notizen dienen nur als
|
||||
// Fallback, wenn kein Charakterbogen gepflegt ist (sie enthalten
|
||||
// oft Verwaltungs-Infos, die nicht ins Inserat gehören).
|
||||
var hasCharacter = animal.Traits is { Count: > 0 }
|
||||
|| !string.IsNullOrWhiteSpace(animal.CharacterNote);
|
||||
if (animal.Traits is { Count: > 0 })
|
||||
sb.Append($" | Charakter: {string.Join(", ", animal.Traits)}");
|
||||
if (!string.IsNullOrWhiteSpace(animal.CharacterNote))
|
||||
sb.Append($" | Charakter-Notiz: {animal.CharacterNote}");
|
||||
if (!hasCharacter && !string.IsNullOrWhiteSpace(animal.Notes))
|
||||
sb.Append($" | Notizen: {animal.Notes}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
@@ -1,87 +1,35 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GerbilManagerWebAPI.Ai;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManagerWebAPI.SaleAd
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-12a: provider-agnostic AI client for sale-ad generation.
|
||||
///
|
||||
/// Speaks the OpenAI-compatible chat-completions wire shape — deliberately
|
||||
/// WITHOUT any vendor SDK: a plain JSON POST to {AI:BaseUrl}/chat/completions
|
||||
/// with a Bearer key covers Google Gemini (compat endpoint), Groq, Mistral,
|
||||
/// local Ollama and any future provider. The wire shape IS the abstraction.
|
||||
/// FEAT-12a: sale-ad generation. The provider-agnostic wire handling lives in
|
||||
/// <see cref="OpenAiChatClient"/> (extracted in INBOX-2 so the reply-draft and
|
||||
/// future AI features reuse the SAME implementation); this service contributes
|
||||
/// the sale-ad prompts and the SaleAd-shaped result. Public surface unchanged.
|
||||
/// </summary>
|
||||
public sealed class SaleAdService(HttpClient http, IOptions<AiOptions> options)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
private readonly OpenAiChatClient _client = new(http, options);
|
||||
|
||||
public async Task<SaleAdResult> GenerateAsync(SaleAdRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var ai = options.Value;
|
||||
if (!ai.IsConfigured)
|
||||
var result = await _client.CompleteAsync(
|
||||
SaleAdPromptBuilder.BuildSystemPrompt(),
|
||||
SaleAdPromptBuilder.BuildUserPrompt(request),
|
||||
ct);
|
||||
var status = result.Status switch
|
||||
{
|
||||
return new SaleAdResult(SaleAdStatus.NotConfigured, null,
|
||||
"KI-Anbieter ist nicht konfiguriert (AI__BaseUrl / AI__ApiKey / AI__Model).");
|
||||
}
|
||||
|
||||
var payload = new ChatRequest(
|
||||
Model: ai.Model!,
|
||||
Messages:
|
||||
[
|
||||
new ChatMessage("system", SaleAdPromptBuilder.BuildSystemPrompt()),
|
||||
new ChatMessage("user", SaleAdPromptBuilder.BuildUserPrompt(request)),
|
||||
],
|
||||
Temperature: 0.7);
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, BuildCompletionsUri(ai.BaseUrl!))
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions),
|
||||
Encoding.UTF8, "application/json"),
|
||||
AiCallStatus.Ok => SaleAdStatus.Ok,
|
||||
AiCallStatus.NotConfigured => SaleAdStatus.NotConfigured,
|
||||
_ => SaleAdStatus.UpstreamError,
|
||||
};
|
||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ai.ApiKey);
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await http.SendAsync(httpRequest, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new SaleAdResult(SaleAdStatus.UpstreamError, null,
|
||||
$"KI-Anbieter antwortete mit HTTP {(int)response.StatusCode}.");
|
||||
}
|
||||
|
||||
var completion = JsonSerializer.Deserialize<ChatResponse>(body, JsonOptions);
|
||||
var text = completion?.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
|
||||
return string.IsNullOrWhiteSpace(text)
|
||||
? new SaleAdResult(SaleAdStatus.UpstreamError, null,
|
||||
"KI-Antwort enthielt keinen Text.")
|
||||
: new SaleAdResult(SaleAdStatus.Ok, text);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
|
||||
{
|
||||
return new SaleAdResult(SaleAdStatus.UpstreamError, null,
|
||||
$"KI-Anbieter nicht erreichbar: {ex.Message}");
|
||||
}
|
||||
return new SaleAdResult(status, result.Text, result.Error);
|
||||
}
|
||||
|
||||
/// <summary>{BaseUrl}/chat/completions — tolerant of a trailing slash on BaseUrl.</summary>
|
||||
/// <summary>Forwarder kept for the existing config-matrix tests.</summary>
|
||||
internal static Uri BuildCompletionsUri(string baseUrl) =>
|
||||
new($"{baseUrl.TrimEnd('/')}/chat/completions");
|
||||
|
||||
// ── OpenAI-compatible wire records (request + the slice of the response we read) ──
|
||||
internal sealed record ChatRequest(string Model, List<ChatMessage> Messages, double? Temperature);
|
||||
|
||||
internal sealed record ChatMessage(string Role, string Content);
|
||||
|
||||
internal sealed record ChatResponse(List<ChatChoice>? Choices);
|
||||
|
||||
internal sealed record ChatChoice(ChatMessage? Message);
|
||||
OpenAiChatClient.BuildCompletionsUri(baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
168
HUMANQUESTION.md
Normal file
168
HUMANQUESTION.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# HUMANQUESTION.md — offene Fragen & benötigte Daten
|
||||
|
||||
> ## ⛔ BITTE DIESE DATEI NICHT SELBST BEARBEITEN
|
||||
> **Antwortet einfach Michael (dem Assistenten) im Chat** — Stichworte genügen
|
||||
> („C1: ja", „A1: hier der Schlüssel …", „Ella: der erste Genotyp stimmt").
|
||||
> **Michael pflegt diese Datei** und streicht jede Frage, sobald sie beantwortet
|
||||
> ist. So steht hier immer nur das, was **noch offen** ist. Nichts geht verloren —
|
||||
> alles wartet geduldig, bis ihr antwortet.
|
||||
|
||||
_Stand: 2026-06-06._
|
||||
|
||||
Der Manager ist fertig und läuft. Die alten Daten sind importiert
|
||||
(**723 Würfe + 247 Tiere + 99 Fotos** sind drin). Mehrere KI-Funktionen sind
|
||||
**fertig gebaut, schlafen aber**, bis ein Gemini-Schlüssel hinterlegt ist.
|
||||
Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
|
||||
|
||||
---
|
||||
|
||||
## A. Für Julian — Konten, Schlüssel, Technik
|
||||
|
||||
| # | Was gebraucht wird | Wofür / was es freischaltet | Blockiert gerade |
|
||||
|---|---|---|---|
|
||||
| A1 | **Gemini API-Schlüssel** (aistudio.google.com → „API-Schlüssel erstellen"). | Schaltet **vier Funktionen auf einmal** frei | KI-Verkaufstexte, Charakterbogen-KI (fertig, schläft), Webseiten-Chat-Editor, KI-Antwortentwürfe im Posteingang |
|
||||
| A2 | **Abrechnung aktivieren? (EU/Deutschland)** Die Gemini-AGB verlangen für EU-Nutzer die **bezahlte Stufe** — bei eurem Volumen **praktisch 0 €**, aber eure Daten werden dann **nicht** zum Training genutzt. Empfehlung: **Abrechnung einschalten** (bleibt fast gratis). Alternative für volle Privatsphäre: lokales Modell (Ollama). | Datenschutz (v. a. E-Mail-Inhalte) + AGB-konform | dieselben KI-Funktionen wie A1 |
|
||||
| A3 | **Gmail App-Passwort.** Einmalig: Google-Konto → 2-Faktor-Bestätigung aktivieren → „App-Passwörter" → eines für „GerbilManager" erstellen → 16-stelligen Code an Michael. | Posteingang verbinden (Anfragen abrufen + Antworten senden) | E-Mail-Posteingang (live) |
|
||||
| A4 | **Domain-Name** (ist registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite auf Cloudflare veröffentlichen | Veröffentlichung der neuen Webseite (Ersatz für Jimdo) |
|
||||
| A5 | **TrueNAS / Gitea — 4 Antworten:** (a) TrueNAS SCALE-Version? (Electric Eel 24.10+?) (b) Gitea Actions aktiviert/aktivierbar? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Welcher Dataset-Pfad für Daten/Backups, und ist Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose + CI sind fertig vorbereitet) |
|
||||
|
||||
## B. Für Julian — kleine Aktionen (jederzeit)
|
||||
|
||||
| # | Aktion | Warum |
|
||||
|---|---|---|
|
||||
| B1 | **`git pull` + App neu starten** auf der laufenden Instanz | Holt den **Such-Fix** + neue Felder (Herkunft-Filter, unscharfe Suche, Charakterbogen). Die laufende Instanz ist sonst auf altem Stand. |
|
||||
| B2 | **Firewall-Regel** (PowerShell **als Administrator**): `New-NetFirewallRule -DisplayName "GerbilManager dev" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5173,5179 -Profile Any` | Damit das Handy deiner Frau im WLAN auf die App kommt. |
|
||||
| B3 | **2 Test-Einträge löschen:** „Testmaus E2E…" und „Wurf E2E…" | Übrig gebliebene Test-Einträge aus der Qualitätssicherung. |
|
||||
|
||||
---
|
||||
|
||||
## C. Für die Züchterin — Genetik-Schreibweise & Import-Prüfung
|
||||
|
||||
Diese Antworten laden die **restlichen importierten Daten** nach (die unten
|
||||
gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
|
||||
**nichts ist verloren**; sobald geklärt, werden sie automatisch nachgeladen).
|
||||
|
||||
| # | Frage | Blockiert |
|
||||
|---|---|---|
|
||||
| ~~C1~~ | ✅ **BEANTWORTET** (2026-06-06): `Uw`=`G` (internationale Schreibweise, groß=groß/klein=klein) · `Sls`=`WP` (eine Scheckungsart) · `DP`=Darkpatch (Scheckungsart) · `Dea`=hörend / `dea`=`taub` (Hörfähigkeit, hängt von der Scheckung ab, steht nach `spsp`) · `WFNZ`=Wildfangnachzucht (keine rezessiven Gene) · `RV`=Rückverpaarung · `GV`=Geschwisterverpaarung (RV/GV = Zuchtmethode, kein Gencode). → Michael baut das ein; **damit lösen sich die 5 Konflikte in Abschnitt D2 von selbst** (Gg ≡ Uwuw). | — erledigt |
|
||||
| ~~C2~~ | ✅ **BEANTWORTET** (2026-06-06): Ja — **`-` = „Allel unbekannt"** (z. B. `D-` = ein D-Allel unbekannt). Wird beim Import als `?`-Platzhalter übernommen. | — erledigt |
|
||||
| ~~C3~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, automatisch zusammenführen — **aber nur wenn auch der Zuchtname gleich ist** (Name + Geburtsdatum + Zuchtname = dasselbe Tier). Wird in die Dedup-Regel eingebaut. (Hinweis: der Extraktor fand bisher 0 Fälle mit gleichem Name+Datum aber verschiedenem Zuchtnamen, also ändert sich an den bestehenden Zusammenführungen nichts — die Regel ist die Absicherung.) | — erledigt |
|
||||
| ~~C4~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, **Wurfchronik Teil 2 existiert** — wird gerade überarbeitet, kommt später. Der Importer ist pro Datei wiederholbar (idempotent), also einfach die Datei schicken, sobald fertig → Michael importiert sie nach (keine Doppelungen). | ⏳ Datei folgt, wenn überarbeitet |
|
||||
| ~~C5~~ | ✅ **BEANTWORTET** (2026-06-06): **Es gibt KEIN „Schwarzschimmel"** — das war ein Fehler in unserem Katalog. Die korrekten Schimmelarten: `efef` → **Orangeschimmel** · `efef pp` → **Rotaugenschimmel** · `efef gg` → **Silberschimmel** · Kombis z. B. `c[chm]c[chm] efef` → **CP-Orangeschimmel**. Michael korrigiert den Katalog (Schwarzschimmel raus, efef = Orangeschimmel). | — erledigt |
|
||||
| C6 | **FAST ERLEDIGT** (Stand 06.06. nachmittags): von den ursprünglich 32 Konflikt-Tieren sind **27 geklärt + geladen** (deine D1–D5-Antworten + Beibehalten-Regel + „genauer gewinnt"-Regel). **Offen sind nur noch die 5 Tiere in D6**: Hanami (Sterbedatum), Big Ben (PP↔Pp), Vance Jr. (Spsp↔spsp), Kazu (3 Loci), Skarlett (Sterbedatum). | nur diese 5 warten noch auf den Import |
|
||||
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
|
||||
| ~~C8~~ | ✅ **BEANTWORTET** (2026-06-06): **Himalaya gibt es** — Himalaya = **`A- c[h]c[h]`** (agouti), Hermelin = **`aa c[h]c[h]`** (nicht-agouti). Beide bleiben im Katalog; die Engine unterscheidet bereits korrekt nach A-/aa. — erledigt |
|
||||
| ~~C9~~ | ✅ **BEANTWORTET** (2026-06-06): **„CP-Fuchs" ist ein Sammelbegriff** — bei diesen Tieren ist unklar, ob es CP-Polarfuchs, CP-Algierfuchs, CP-Kohlfuchs oder CP-Blaufuchs ist (Tiere sind schneeweiß mit schwarzen Augen; Verpaarungen haben die Gene nicht verraten). Bekannt ist nur: **„CP-Fuchs" = `c[chm]c[chm]`**, **„CP-Fuchs hell" = `c[chm]c[h]`**. **Generelle Regel: das Wort „hell" im Farbschlag-Namen bedeutet immer, dass ein `c[h]` im Gencode steckt** (also `c[chm]c[h]`); ohne „hell" = `c[chm]c[chm]`. Die „-Hell"-Vermutung war richtig ✓; Engine-Update beauftragt (GEN-3g): bei unbekannten Unterscheidungs-Loci bleibt der Sammelbegriff „CP-Fuchs" korrekt. — erledigt |
|
||||
|
||||
### Hinweis zu C5 — woher kam das falsche „Schwarzschimmel"? (wie gewünscht notiert)
|
||||
„Schwarzschimmel" stammt aus **unserem ursprünglichen Farbkatalog** `gerbil-manager-web/src/genetics/catalog.ts` (Genotyp `efef`), den wir ganz am Anfang aus den deutschen Genetik-Quellen (de.wikibooks „Schwarze Augen", rennmauswelten, clan-of-topolino) aufgebaut hatten. Von dort kam es in die DB-Seed-Liste + Stammbaum-Farbchips. → Wird in GEN-3 korrigiert: Schwarzschimmel entfernt, `efef` = Orangeschimmel. *(Falls du der Quelle Bescheid geben willst: es ist die de.wikibooks-Farbgenetik-Seite.)*
|
||||
|
||||
---
|
||||
|
||||
## D. Die 32 Konflikt-Tiere (gleicher Name + Datum, aber widersprüchliche Angaben in mehreren Dateien)
|
||||
|
||||
> ✅ **STAND nach Re-Import #2 (06.06.2026):** Alle bisher beantworteten Konflikte sind **live geladen** (+13 Tiere, +31 Würfe, +9 Fotos — u. a. Victoria Welby: **„C" hat jetzt beide Eltern** ✔). Von 32 sind noch **8 in Quarantäne**; 3 davon (Enya, Ella, Zac) löst der Importer demnächst automatisch („genauer gewinnt": `CC` schlägt `C-` — gleiche Logik wie die Beibehalten-Regel). **Wirklich offen: nur die 5 Tiere in D6 unten.**
|
||||
|
||||
Bitte je Tier kurz sagen, **welche Angabe stimmt**. Gruppiert nach Konflikt-Art.
|
||||
Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output/review-report.md`.
|
||||
|
||||
### D1 · Im Farbschlag-Feld steht versehentlich ein **Tiername** (Tippfehler) — welcher Farbschlag stimmt wirklich?
|
||||
> ✅ **GEKLÄRT (Julian, 2026-06-06):** Ursache gefunden — **ab Spalte K** im Stammbaum stehen pro Tier nur Name / Datum / **Gencode** (KEINE Farbangabe). Der Importer hatte dort fälschlich die Nachbarzelle (z. B. den Namen des nächsten Tiers wie „Tennessee", oder eine Notiz wie „DD-Tumor") als Farbschlag gelesen. **Fix ist beauftragt:** in den tiefen Spalten wird kein Farbschlag mehr ausgelesen, die **Farbe wird aus dem Gencode berechnet**; in den frühen Spalten (Proband/Eltern) bleibt der echte Farbschlag erhalten (z. B. Chesnut = „Kohlfuchsschimmel"). **Chesnut und Tennessee sind getrennte Tiere** (bestätigt). → Nach dem Fix verschwindet D1 von selbst; **keine Aktion nötig.**
|
||||
|
||||
| Tier | im Farbschlag steht fälschlich | 📂 Stammbaum-Datei zum Nachschauen |
|
||||
|---|---|---|
|
||||
| ZoneFire (*07.12.2020) | „Kalea von den Kleinen Chaoten" | *Stammbaum von Akio Kids* |
|
||||
| Louis v.d. Kleinen Chaoten (*15.07.2017) | „Roswitha…" (+ Genotyp G/Uw, siehe D2) | *(Quelle siehe `review-report.md`)* |
|
||||
| Bruno of Black Forest (*01.06.2022) | „Mystique of Black Forest" (evtl. Blau) | *Stammbaum von Alberto Kids / Fire Kids / Stella Kids* |
|
||||
| Little Runner's Big Ben (*03.02.2020) | „Daja of Little Rose" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids* |
|
||||
| Vance Jr. v.d. Kleinen Chaoten (*10.04.2022) | „Velvet…" (evtl. Kohlfuchs, hell) | *Stammbaum von Fire Kids / Stella Kids* |
|
||||
| Trogir v.d. Kleinen Chaoten (*21.03.2022) | „Mahima…" (evtl. Gold Ansatzschecke) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
||||
| Chayton v.d. Kleinen Chaoten (*04.02.2022) | „Victoria Welby…" (evtl. Orangeschimmel, hell) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
||||
| Zac gen. Action v.d. Kleinen Chaoten (*25.12.2020) | „Belica gen. Emi…" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
||||
| Chesnut (*13.11.2019) | „Tennessee…" (evtl. Kohlfuchsschimmel) | *Stammbaum von Kentucky* |
|
||||
| Ethan v.d. Kleinen Chaoten (*09.07.2020) | „Ichika…" (evtl. Orangeschimmel hell Kragenschecke) | *(Quelle siehe `review-report.md`)* |
|
||||
| Quied Soldier of Black Forest (*07.06.2018) | „Hoshi…" | *Stammbaum von Kentucky* |
|
||||
|
||||
### ~~D2~~ · ✅ GELÖST durch C1: `Uw`=`G` — diese 5 sind KEINE echten Konflikte, werden automatisch geladen, sobald Michael die Uw=G-Regel eingebaut hat
|
||||
~~Ella · Roswitha · Silenos gen. Adonis · Brandon Stark · Enya~~ (erledigt)
|
||||
|
||||
### D3 · Genotyp: **kleine Abweichung** (eine Quelle genauer als die andere — `DD`↔`D-`, `PP`↔`P-`, `Ee`↔`E`, mit/ohne `spsp`) — welche stimmt?
|
||||
Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich beim genannten Locus). Alle Varianten: `review-report.md`.
|
||||
|
||||
| Tier | Konkreter Konflikt — was stimmt? | Status |
|
||||
|---|---|---|
|
||||
| Firefly v.d. K.C. (*18.12.2019) | D-Locus: **D-** ↔ **DD** | ✅ **D-** (DD war Tippfehler) — Julian |
|
||||
| WildFire v.d. K.C. (*05.10.2017) | P-Locus: **P-** ↔ **PP** | ✅ **PP** — Julian |
|
||||
| Zuleika v.d. K.C. (*24.10.2015) | D-Locus: **D-** ↔ **DD** | ✅ **DD, Ee, Gg, PP** (`aa c[chm]c[h] DD Ee Gg PP spsp`) — Julian |
|
||||
| Milka of LennyLengo (*09.12.2018) | C-Locus: **C-** ↔ **Cc[h]** · E-Locus: **E-** ↔ **EE** | ✅ **Cc[h], EE** (`aa Cc[h] dd EE Gg P- Spsp`) — Julian |
|
||||
| Silvain v.d. K.C. (*27.03.2022) | E-Locus: **Ee** ↔ **ee** · P-Locus: **P-** ↔ **Pp** | ✅ **ee, Pp** (`aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp`) — Julian |
|
||||
| Ichika v.d. K.C. (*19.04.2020) | E-Locus: **ee** ↔ **ee[f]** | ✅ **ee[f]** (Beibehalten-Regel: `[f]` war vorhanden) — Julian |
|
||||
| Daja of Little Rose (*16.05.2021) | Scheckung: **mit `spsp`** ↔ **ohne** | ✅ **mit `spsp`** (Beibehalten-Regel) — Julian |
|
||||
| Chelsea v.d. K.C. | ⚠️ **Kein Genotyp-Konflikt** — zwei „Chelsea" mit verschiedenem Datum (\*02.04.2021 / \*15.10.2021) | ✅ **ein Tier, Geburtsdatum 02.04.2021** (15.10.2021 war falsch → zusammengeführt) — Julian |
|
||||
|
||||
### D4 · **Marker** unterschiedlich (`WP` / `DP` / `WFNZ` / „hörend" mal vorhanden, mal nicht) — welcher gilt?
|
||||
> ✅ **REGEL (Julian 2026-06-06):** „Wenn irgendwo etwas vorhanden war, das anderswo fehlte → **immer beibehalten**." Gilt generell für Marker/Flags und Angaben wie `spsp` oder `[f]` (Vorhandensein gewinnt über Fehlen). Wird zur Standard-Regel im Importer → löst alle „mit/ohne"-Fälle automatisch (z. B. Daja `spsp`, Ichika `[f]`). Greift NICHT bei echten Wert-Widersprüchen (z. B. `DD`↔`D-`, `Ee`↔`ee`) — die brauchen weiter deine Entscheidung.
|
||||
>
|
||||
> ℹ️ Bei D4 waren die Marker `WP`/`DP`/`WFNZ`/`hörend` in BEIDEN Quellen gleich — also **gar nicht** der Konflikt (und jetzt sowieso Flags). Der echte Konflikt ist beim Genotyp. **3 von 5 dadurch automatisch gelöst:**
|
||||
|
||||
| Tier | Konkreter Konflikt — was stimmt? | Status |
|
||||
|---|---|---|
|
||||
| Vestra von den Schlossmäusen (*08.02.2019) | D-Locus: **D-** ↔ **DD** (WP gleich in beiden) | ✅ **DD** — Julian |
|
||||
| Victoria Welby gen. Welby v.d. K.C. (*16.01.2023) | E-Locus: **Ee[f]** ↔ **ee[f]** — **Mutter von „C"!** | ✅ **ee[f]** — Julian → **geladen, C hat jetzt beide Eltern** (Re-Import #2) |
|
||||
| Hedwig of BGB (*30.10.2019) | (WP/DP/hörend) | ✅ auto-gelöst — sind jetzt Flags, kein Konflikt mehr |
|
||||
| Pitari gen. Piti v.d. K.C. (*16.05.2021) | (DP) | ✅ auto-gelöst — DP ist jetzt ein Flag |
|
||||
| Little Hero of Black Forest (*22.02.2018) | (WFNZ ± spsp) | ✅ kein Genotyp-Konflikt mehr (WFNZ = Flag) |
|
||||
|
||||
### D5 · **Sterbedatum** widersprüchlich
|
||||
| Tier | Problem |
|
||||
|---|---|
|
||||
| ~~Flint v.d. Kleinen Chaoten (*23.12.2017)~~ | ✅ **Tod 10.05.2021** (2022 war Tippfehler) — Julian 2026-06-06 |
|
||||
| Hanami v.d. Kleinen Chaoten (*10.09.2015) | Tod 12.12.2019 vs. 14.01.2020 |
|
||||
| ~~Molly of Black Forest (*13.09.2021)~~ | ✅ **Tod 03.05.2022** (03.05.2021 war Jahr-Tippfehler → lag vor der Geburt) — Julian 2026-06-06 |
|
||||
|
||||
*(Das sind 32 Tiere: 11 + 5 + 8 + 5 + 3.)*
|
||||
|
||||
### D6 · **Die letzten 5 offenen Konflikte** (Stand Re-Import #2) — bitte entscheiden
|
||||
| Tier | Konkreter Konflikt — was stimmt? |
|
||||
|---|---|
|
||||
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019** ↔ **14.01.2020** (= D5) |
|
||||
| Little Runner's Big Ben (*03.02.2020) | P-Locus: **PP** ↔ **Pp** |
|
||||
| Vance Jr. v.d. K.C. (*10.04.2022) | Scheckung: **Spsp** ↔ **spsp** (Schecke ja/nein) |
|
||||
| Kazu v.d. K.C. (*23.04.2013) | E-Locus: **e[f]e[f]** ↔ **ee[f]** · G-Locus: **Gg** ↔ **GG** · P-Locus: **P?** ↔ **PP** |
|
||||
| Skarlett v.d. K.C. (*14.07.2013) | Sterbedatum: **17.04.2016** ↔ **2018** |
|
||||
|
||||
*(Enya, Ella und Zac fehlen hier bewusst: deren Abweichung ist nur „unbekannt ↔ genau angegeben" — löst der Importer automatisch mit der „genauer gewinnt"-Regel.)*
|
||||
|
||||
---
|
||||
|
||||
## E. Charakterbogen — Eigenschaften-Liste (für die KI-Verkaufstexte)
|
||||
|
||||
Aktuell eingebaute Häkchen-Eigenschaften (jede ist leicht änderbar). **Bitte
|
||||
sagen, was ergänzt oder gestrichen werden soll:**
|
||||
|
||||
> zutraulich · handzahm · neugierig · aufgeschlossen · ruhig/ausgeglichen ·
|
||||
> lebhaft/aktiv · verschmust · eigenständig · anfängergeeignet · futterfreudig ·
|
||||
> buddelt gern · klettert gern · läuft gern im Laufrad · gut verträglich · schreckhaft
|
||||
|
||||
- **E1** — Eigenschaften hinzufügen/entfernen? (z. B. *dominant, rangniedrig, für erfahrene Halter, einzelgängerisch* …?)
|
||||
- **E2** — Auch **ehrliche „negative" Eigenschaften** aufnehmen (z. B. *schreckhaft, beißt manchmal*) für ehrliche Inserate, oder nur Positives?
|
||||
|
||||
---
|
||||
|
||||
## F. Was hängt woran (Kurzübersicht)
|
||||
|
||||
| Funktion | Status | Wartet auf |
|
||||
|---|---|---|
|
||||
| KI-Verkaufstexte (Abgeben) | gebaut, schläft | **A1** (+A2) |
|
||||
| Charakterbogen → KI-Text | gebaut, schläft | **A1** |
|
||||
| Webseiten-Chat-Editor (Gemini) | in Arbeit | **A1** + Webseiten-Phasen |
|
||||
| Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) |
|
||||
| E-Mail-Posteingang (Anfragen) | in Arbeit | **A3** (App-Passwort) + **A1/A2** für Entwürfe |
|
||||
| NAS-Deployment / Produktiv | fertig vorbereitet | **A5** |
|
||||
| Restliche importierte Tiere (Konflikte) | nur noch 8 in Quarantäne (Re-Import #2 ✅) | **D6** (5 Entscheidungen; Enya/Ella/Zac lädt Michael automatisch nach) |
|
||||
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) |
|
||||
|
||||
---
|
||||
|
||||
*Einfach abschnittsweise antworten — Michael arbeitet die Antworten ein, streicht
|
||||
sie hier und meldet, was dadurch live geht.*
|
||||
@@ -1,18 +0,0 @@
|
||||
# Please refer https://aka.ms/HTTPSinContainer on how to setup an https developer certificate for your ASP.NET Core service.
|
||||
|
||||
version: '3.4'
|
||||
|
||||
services:
|
||||
gerbilmanagerwebapi:
|
||||
image: gerbilmanagerwebapi
|
||||
build:
|
||||
context: .
|
||||
dockerfile: GerbilManagerWebAPI/Dockerfile
|
||||
args:
|
||||
- configuration=Debug
|
||||
ports:
|
||||
- 80:80
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
volumes:
|
||||
- ~/.vsdbg:/remote_debugger:rw
|
||||
@@ -1,57 +0,0 @@
|
||||
# Please refer https://aka.ms/HTTPSinContainer on how to setup an https developer certificate for your ASP.NET Core service.
|
||||
|
||||
version: '3.4'
|
||||
|
||||
services:
|
||||
|
||||
frontend:
|
||||
image: gerbilmanagerweb
|
||||
build:
|
||||
context: .
|
||||
dockerfile: gerbil-manager-web/Dockerfile
|
||||
args:
|
||||
# Der Browser erreicht die API über den am Host veröffentlichten Port
|
||||
- VITE_API_BASE_URL=http://localhost:80
|
||||
ports:
|
||||
- 3000:3000
|
||||
networks:
|
||||
- net2
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
backend:
|
||||
image: gerbilmanagerwebapi
|
||||
build:
|
||||
context: .
|
||||
dockerfile: GerbilManagerWebAPI/Dockerfile
|
||||
environment:
|
||||
- ConnectionStrings:sqlConnection=server=database; database=GerbilManager; User Id=sa;Password=StrongerThenYYou1;Encrypt=False;TrustServerCertificate=True
|
||||
ports:
|
||||
- 80:80
|
||||
networks:
|
||||
- net1
|
||||
- net2
|
||||
depends_on:
|
||||
- database
|
||||
|
||||
database:
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
environment:
|
||||
- ACCEPT_EULA=Y
|
||||
- MSSQL_SA_PASSWORD=StrongerThenYYou1
|
||||
- MSSQL_PID=Evaluation
|
||||
ports:
|
||||
- "1433:1433"
|
||||
volumes:
|
||||
- db-data:/var/opt/mssql
|
||||
networks:
|
||||
- net1
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
|
||||
networks:
|
||||
net1:
|
||||
name: network1
|
||||
net2:
|
||||
name: network2
|
||||
3
gerbil-manager-web/.gitattributes
vendored
Normal file
3
gerbil-manager-web/.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# de.ts is large and edited by many agents — pin to LF so EOL flips don't
|
||||
# create noisy whole-file diffs (GEN-3 follow-up, god-approved).
|
||||
src/strings/de.ts text eol=lf
|
||||
89
gerbil-manager-web/e2e/anfragen.spec.ts
Normal file
89
gerbil-manager-web/e2e/anfragen.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* INBOX-1: Anfragen-Posteingang — Liste/Filter, Triage, KI-Entwurf (inkl.
|
||||
* AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis).
|
||||
* Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock.
|
||||
*/
|
||||
import { acceptNextDialog, de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const ta = de.pages.anfragen
|
||||
const td = ta.detail
|
||||
|
||||
test.describe('Anfragen', () => {
|
||||
test('Liste zeigt Anfragen neueste zuerst + Status-Filter', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await gotoSection(page, de.nav.requests)
|
||||
await expect(page.getByRole('heading', { name: ta.title })).toBeVisible()
|
||||
|
||||
// Neueste zuerst: Anna (05.06.) vor Ben (04.06.) vor Clara (01.06.)
|
||||
const cards = page.locator('.anfrage-card')
|
||||
await expect(cards).toHaveCount(3)
|
||||
await expect(cards.nth(0)).toContainText('Anna Albrecht')
|
||||
await expect(cards.nth(2)).toContainText('clara@example.com') // ohne Anzeigename → Adresse
|
||||
|
||||
// Status-Badge auf der Karte
|
||||
await expect(cards.nth(0)).toContainText(ta.statusLabels.New)
|
||||
|
||||
// UX-MOBILE-1: Status-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
|
||||
await openFilterPanel(page)
|
||||
// Filter: nur Beantwortet
|
||||
await page.getByLabel(td.statusLabel).selectOption('Answered')
|
||||
await expect(cards).toHaveCount(1)
|
||||
await expect(cards.first()).toContainText('Danke!')
|
||||
})
|
||||
|
||||
test('Sync ohne Gmail-Konfiguration zeigt deutschen Hinweis', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
mockDb!.mailConfigured = false
|
||||
await page.goto('/anfragen')
|
||||
await page.getByRole('button', { name: ta.sync }).click()
|
||||
await expect(page.getByText(ta.mailNotConfigured)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Detail: Triage — Abnehmer zuordnen setzt Status auf Zugeordnet', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/anfragen/req-anna')
|
||||
await expect(page.getByRole('heading', { name: 'Anfrage: Pärchen zur Abgabe?' })).toBeVisible()
|
||||
await expect(page.getByText('anna@example.de', { exact: false })).toBeVisible()
|
||||
|
||||
await page.getByLabel(td.assignContact).selectOption({ label: 'Zoohandlung Meier' })
|
||||
await expect(page.locator('.anfrage-badge--assigned')).toBeVisible()
|
||||
expect(mockDb!.requests.find((r) => r.id === 'req-anna')!.assignedContactId).toBe('con-meier')
|
||||
|
||||
// Verwerfen (mit Bestätigung) → Status Verworfen, Zuordnung bleibt
|
||||
acceptNextDialog(page)
|
||||
await page.getByRole('button', { name: td.abandon }).click()
|
||||
await expect(page.locator('.anfrage-badge--abandoned')).toBeVisible()
|
||||
expect(mockDb!.requests.find((r) => r.id === 'req-anna')!.assignedContactId).toBe('con-meier')
|
||||
})
|
||||
|
||||
test('KI-Entwurf füllt das Antwortfeld; Senden markiert als Beantwortet', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/anfragen/req-anna')
|
||||
|
||||
await page.getByRole('button', { name: td.draftButton }).click()
|
||||
const textarea = page.locator('.anfrage-reply__text')
|
||||
await expect(textarea).toHaveValue(/vielen Dank für deine Anfrage/)
|
||||
|
||||
// Entwurf bearbeiten, dann senden (bestätigt) → Beantwortet + Hinweis
|
||||
await textarea.fill('Hallo Anna, ja — die beiden suchen noch ein Zuhause!')
|
||||
acceptNextDialog(page)
|
||||
await page.getByRole('button', { name: td.send }).click()
|
||||
await expect(page.getByText(td.sent)).toBeVisible()
|
||||
await expect(page.locator('.anfrage-badge--answered')).toBeVisible()
|
||||
})
|
||||
|
||||
test('KI ohne Schlüssel: 503 wird zum freundlichen Hinweis', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
mockDb!.aiConfigured = false
|
||||
await page.goto('/anfragen/req-anna')
|
||||
await page.getByRole('button', { name: td.draftButton }).click()
|
||||
await expect(page.getByText(td.aiKeyMissing)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Senden ohne Text zeigt Hinweis statt Versand', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/anfragen/req-anna')
|
||||
await page.getByRole('button', { name: td.send }).click()
|
||||
await expect(page.getByText(td.sendEmptyBody)).toBeVisible()
|
||||
})
|
||||
})
|
||||
45
gerbil-manager-web/e2e/bestand.spec.ts
Normal file
45
gerbil-manager-web/e2e/bestand.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
|
||||
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
|
||||
test('Tiere-Liste blendet externe Ahnen standardmäßig aus', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||
|
||||
// Bestand ist sichtbar …
|
||||
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
||||
// … aber die externe Ahne „Max“ (isResident=false) nicht.
|
||||
await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Toggle „Externe Ahnen einblenden” zeigt externe Tiere mit Extern-Markierung', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
||||
|
||||
// UX-MOBILE-1: Checkbox liegt im Filter-Drawer — auf Mobil erst öffnen.
|
||||
await openFilterPanel(page)
|
||||
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
|
||||
|
||||
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
|
||||
await expect(maxRow).toBeVisible()
|
||||
await expect(maxRow.getByText(t.externalBadge, { exact: true })).toBeVisible()
|
||||
// Der Bestand bleibt weiterhin sichtbar.
|
||||
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Bearbeiten-Formular kann ein Tier als extern markieren (Bestand-Häkchen)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Krümel gehört zum Bestand -> Häkchen entfernen und speichern.
|
||||
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||
const check = page.getByRole('checkbox', { name: t.form.isResidentLabel })
|
||||
await expect(check).toBeChecked()
|
||||
await check.uncheck()
|
||||
await page.getByRole('button', { name: t.form.save }).click()
|
||||
|
||||
// Auf der Detailseite ist Krümel jetzt als „Extern“ markiert.
|
||||
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||
await expect(page.getByText(t.externalBadge, { exact: true })).toBeVisible()
|
||||
})
|
||||
21
gerbil-manager-web/e2e/charakter.spec.ts
Normal file
21
gerbil-manager-web/e2e/charakter.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/** FEAT-14b: Charakterbogen — Eigenschaft umschalten, persistieren, im Abgabe-Komposer wiederfinden. */
|
||||
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
test('Charakterbogen: Eigenschaft umschalten, speichern und im Abgabe-Komposer wiederfinden', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/kruemel')
|
||||
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||
await expect(page.getByText(de.character.sectionTitle)).toBeVisible()
|
||||
|
||||
// Eigenschaft 'neugierig' aktivieren und speichern.
|
||||
await page.getByRole('checkbox', { name: 'neugierig' }).check()
|
||||
await page.getByRole('button', { name: de.character.save }).click()
|
||||
await expect(page.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
|
||||
|
||||
// Tier zur Abgabe stellen -> erscheint im /abgabe-Komposer mit gesetzter Eigenschaft.
|
||||
await page.getByRole('button', { name: de.pages.abgabe.markAction }).click()
|
||||
await page.goto('/abgabe')
|
||||
await expect(page.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
|
||||
})
|
||||
85
gerbil-manager-web/e2e/filter-panel.spec.ts
Normal file
85
gerbil-manager-web/e2e/filter-panel.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* UX-MOBILE-1: FilterPanel — einklappbare Filter auf Smartphone, inline auf Desktop.
|
||||
*
|
||||
* Telefon (390px): Filter-Button sichtbar, Drawer eingeklappt; Tippen öffnet/schliesst.
|
||||
* Desktop (1280px): Alle Controls direkt sichtbar, kein Toggle-Button.
|
||||
*/
|
||||
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
|
||||
test.describe('FilterPanel – Rennmäuse-Liste', () => {
|
||||
test('Phone: Filter-Drawer standardmäßig eingeklappt, Toggle-Button sichtbar', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
skipUnlessMock()
|
||||
if (testInfo.project.name !== 'phone') return
|
||||
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||
|
||||
// Toggle-Button sichtbar.
|
||||
const toggle = page.locator('.filter-panel__toggle')
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
// Status-Beschriftung im Drawer ist noch verborgen.
|
||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Phone: Toggle öffnet und schließt den Filter-Drawer', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
skipUnlessMock()
|
||||
if (testInfo.project.name !== 'phone') return
|
||||
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
const toggle = page.locator('.filter-panel__toggle')
|
||||
|
||||
// Öffnen → Status-Feld wird sichtbar.
|
||||
await toggle.click()
|
||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
|
||||
|
||||
// Schließen → wieder verborgen.
|
||||
await toggle.click()
|
||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Phone: Badge zählt aktive Filter korrekt', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
skipUnlessMock()
|
||||
if (testInfo.project.name !== 'phone') return
|
||||
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
const toggle = page.locator('.filter-panel__toggle')
|
||||
|
||||
// Initial: Status='Active' ist Default → Badge zeigt kein „(N)".
|
||||
await expect(toggle).toHaveText('Filter')
|
||||
|
||||
// Filter-Drawer öffnen und Geschlecht setzen → 1 aktiver Filter.
|
||||
await toggle.click()
|
||||
await page.locator('.filter-panel__drawer select').nth(1).selectOption('male')
|
||||
await expect(toggle).toHaveText('Filter (1)')
|
||||
|
||||
// Zurücksetzen → Badge weg.
|
||||
await page.getByRole('button', { name: de.filterPanel.resetButton }).click()
|
||||
await expect(toggle).toHaveText('Filter')
|
||||
})
|
||||
|
||||
test('Desktop: Toggle-Button nicht sichtbar, alle Controls inline', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
skipUnlessMock()
|
||||
if (testInfo.project.name !== 'desktop') return
|
||||
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||
|
||||
// Kein Toggle-Button auf Desktop (display:none via Media Query).
|
||||
const toggle = page.locator('.filter-panel__toggle')
|
||||
await expect(toggle).toBeHidden()
|
||||
|
||||
// Status-Beschriftung direkt sichtbar.
|
||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -63,6 +63,18 @@ export function acceptNextDialog(page: Page) {
|
||||
page.once('dialog', (d) => void d.accept())
|
||||
}
|
||||
|
||||
/**
|
||||
* UX-MOBILE-1: Filter-Drawer öffnen, falls der Toggle-Button sichtbar ist
|
||||
* (= Smartphone-Ansicht). Auf Desktop-Ansicht ist er per CSS versteckt, dann
|
||||
* kein Klick nötig — Controls sind direkt sichtbar.
|
||||
*/
|
||||
export async function openFilterPanel(page: Page) {
|
||||
const toggle = page.locator('.filter-panel__toggle')
|
||||
if (await toggle.isVisible()) {
|
||||
await toggle.click()
|
||||
}
|
||||
}
|
||||
|
||||
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
|
||||
export const uniqueName = (prefix: string) =>
|
||||
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
|
||||
@@ -135,6 +135,89 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
if (method === 'PUT') return json(route, 204)
|
||||
}
|
||||
|
||||
// ── WEB-3: lokale Vorschau (Renderer-Dateien; nur veröffentlichte Seiten) ──
|
||||
if (path === '/render-site' && method === 'GET') {
|
||||
const files = db.pages
|
||||
.filter((p) => p.status === 'Published')
|
||||
.map((p) => ({ path: p.slug === 'start' ? 'index.html' : `${p.slug}/index.html`, size: 1000 }))
|
||||
return json(route, 200, [{ path: 'assets/site.css', size: 500 }, ...files])
|
||||
}
|
||||
const pv = path.match(/^\/preview(?:\/(.*))?$/)
|
||||
if (pv && method === 'GET') {
|
||||
const key = pv[1] ? pv[1].replace(/\/$/, '') : 'index.html'
|
||||
if (key === 'assets/site.css') {
|
||||
return route.fulfill({ status: 200, contentType: 'text/css', body: 'body{font-family:sans-serif}' })
|
||||
}
|
||||
const slug = key === 'index.html' ? 'start' : key.replace(/\/index\.html$/, '')
|
||||
const p = db.pages.find((x) => x.slug === slug && x.status === 'Published')
|
||||
if (!p) return json(route, 404, { title: 'Not Found' })
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html; charset=utf-8',
|
||||
body: `<!doctype html><html lang="de"><head><meta charset="utf-8"><title>${p.title}</title></head><body><h1>${p.title}</h1><p>Vorschau-Mock</p></body></html>`,
|
||||
})
|
||||
}
|
||||
|
||||
// ── INBOX-1: Anfragen-Posteingang (vor den generischen Kollektionen) ──
|
||||
if (path === '/requests/sync' && method === 'POST') {
|
||||
return json(route, 200, db.mailConfigured ? { imported: 0, error: null } : { imported: 0, error: 'MailNotConfigured' })
|
||||
}
|
||||
let rm = path.match(/^\/requests\/([^/]+)\/draft$/)
|
||||
if (rm && method === 'POST') {
|
||||
if (!db.aiConfigured) {
|
||||
return json(route, 503, { code: 'AiKeyMissing', message: 'AI nicht konfiguriert' })
|
||||
}
|
||||
const r = db.requests.find((x) => x.id === rm![1])
|
||||
if (!r) return json(route, 404, { title: 'Not Found' })
|
||||
r.draftReply = `Hallo ${r.fromName ?? ''},\n\nvielen Dank für deine Anfrage!\n\nViele Grüße`
|
||||
return json(route, 200, r)
|
||||
}
|
||||
rm = path.match(/^\/requests\/([^/]+)\/send$/)
|
||||
if (rm && method === 'POST') {
|
||||
if (!db.mailConfigured) {
|
||||
return json(route, 503, { title: 'MailNotConfigured', detail: 'Gmail ist noch nicht konfiguriert.' })
|
||||
}
|
||||
const r = db.requests.find((x) => x.id === rm![1])
|
||||
if (!r) return json(route, 404, { title: 'Not Found' })
|
||||
r.status = 'Answered'
|
||||
r.answeredAt = '2026-06-06T10:00:00Z'
|
||||
return json(route, 200, r)
|
||||
}
|
||||
rm = path.match(/^\/requests(?:\/([^/]+))?$/)
|
||||
if (rm) {
|
||||
const reqId = rm[1] ? decodeURIComponent(rm[1]) : null
|
||||
if (!reqId && method === 'GET') {
|
||||
return json(route, 200, pagedResponse(db.requests as unknown as Row[], url))
|
||||
}
|
||||
const r = db.requests.find((x) => x.id === reqId)
|
||||
if (!r) return json(route, 404, { title: 'Not Found' })
|
||||
if (method === 'GET') return json(route, 200, r)
|
||||
if (method === 'PUT') {
|
||||
// Triage-Vertrag des Backends: assignedContactId wird IMMER übernommen.
|
||||
const body = request.postDataJSON() as { status?: string | null; assignedContactId?: string | null }
|
||||
r.assignedContactId = body.assignedContactId ?? null
|
||||
if (body.status) {
|
||||
r.status = body.status as typeof r.status
|
||||
if (body.status === 'Answered') r.answeredAt ??= '2026-06-06T10:00:00Z'
|
||||
}
|
||||
return json(route, 204)
|
||||
}
|
||||
return json(route, 405)
|
||||
}
|
||||
|
||||
// SEARCH-2b: distinct Herkunft (originBreeder) values, sorted — vor der
|
||||
// generischen /gerbils/:id-Route abfangen.
|
||||
if (path === '/gerbils/breeders' && method === 'GET') {
|
||||
const values = [
|
||||
...new Set(
|
||||
db.gerbils
|
||||
.map((g) => g.originBreeder)
|
||||
.filter((v): v is string => typeof v === 'string' && v.length > 0),
|
||||
),
|
||||
].sort()
|
||||
return json(route, 200, values)
|
||||
}
|
||||
|
||||
// Sonderrouten zuerst (FEAT-1b/FEAT-4-Verträge)
|
||||
let m = path.match(/^\/gerbils\/([^/]+)\/photos$/)
|
||||
if (m) {
|
||||
@@ -155,6 +238,81 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
})
|
||||
}
|
||||
|
||||
// WEB-0b: CMS-Endpunkte (Seiten + Blöcke). Vor den generischen Kollektionen,
|
||||
// weil 'pages'/'blocks' verschachtelte Routen haben und kein Standard-CRUD sind.
|
||||
const toSummary = (p: (typeof db.pages)[number]) => ({
|
||||
id: p.id, slug: p.slug, title: p.title, seoDescription: p.seoDescription, status: p.status,
|
||||
})
|
||||
const toBlock = (b: { id: string; order: number; type: string; data: unknown }) => ({
|
||||
id: b.id, order: b.order, type: b.type, data: b.data,
|
||||
})
|
||||
const toPage = (p: (typeof db.pages)[number]) => ({
|
||||
...toSummary(p),
|
||||
blocks: [...p.blocks].sort((a, b) => a.order - b.order).map(toBlock),
|
||||
})
|
||||
|
||||
if (path === '/pages' && method === 'GET') {
|
||||
return json(route, 200, [...db.pages].sort((a, b) => a.slug.localeCompare(b.slug)).map(toSummary))
|
||||
}
|
||||
// GET /pages/{slug} · PUT /pages/{id}
|
||||
m = path.match(/^\/pages\/([^/]+)$/)
|
||||
if (m && method === 'GET') {
|
||||
const p = db.pages.find((x) => x.slug === decodeURIComponent(m![1]))
|
||||
return p ? json(route, 200, toPage(p)) : json(route, 404, { title: 'Not Found' })
|
||||
}
|
||||
// PUT /pages/{id}
|
||||
if (m && method === 'PUT') {
|
||||
const p = db.pages.find((x) => x.id === m![1])
|
||||
if (!p) return json(route, 404, { title: 'Not Found' })
|
||||
const body = request.postDataJSON() as Partial<typeof p>
|
||||
if (typeof body.slug === 'string') p.slug = body.slug
|
||||
if (typeof body.title === 'string') p.title = body.title
|
||||
p.seoDescription = (body.seoDescription as string | null) ?? null
|
||||
if (body.status === 'Draft' || body.status === 'Published') p.status = body.status
|
||||
return json(route, 204)
|
||||
}
|
||||
// POST /pages/{pageId}/blocks
|
||||
m = path.match(/^\/pages\/([^/]+)\/blocks$/)
|
||||
if (m && method === 'POST') {
|
||||
const p = db.pages.find((x) => x.id === m![1])
|
||||
if (!p) return json(route, 404, { title: 'Not Found' })
|
||||
const body = request.postDataJSON() as { type: string; data?: Record<string, unknown>; order?: number }
|
||||
const order = body.order ?? (p.blocks.reduce((mx, b) => Math.max(mx, b.order), -1) + 1)
|
||||
const block = { id: newId('blk'), order, type: body.type, data: body.data ?? {} }
|
||||
p.blocks.push(block)
|
||||
return json(route, 201, toBlock(block))
|
||||
}
|
||||
// PUT /pages/{pageId}/blocks/order (Body: { blockIds })
|
||||
m = path.match(/^\/pages\/([^/]+)\/blocks\/order$/)
|
||||
if (m && method === 'PUT') {
|
||||
const p = db.pages.find((x) => x.id === m![1])
|
||||
if (!p) return json(route, 404, { title: 'Not Found' })
|
||||
const { blockIds } = request.postDataJSON() as { blockIds: string[] }
|
||||
const byId = new Map(p.blocks.map((b) => [b.id, b]))
|
||||
if (blockIds.length !== p.blocks.length || blockIds.some((id) => !byId.has(id)))
|
||||
return json(route, 400, 'blockIds must list exactly the page block ids.')
|
||||
blockIds.forEach((id, i) => { byId.get(id)!.order = i })
|
||||
return json(route, 204)
|
||||
}
|
||||
// PUT/DELETE /blocks/{id}
|
||||
m = path.match(/^\/blocks\/([^/]+)$/)
|
||||
if (m) {
|
||||
const pageOf = db.pages.find((p) => p.blocks.some((b) => b.id === m![1]))
|
||||
const block = pageOf?.blocks.find((b) => b.id === m![1])
|
||||
if (!pageOf || !block) return json(route, 404, { title: 'Not Found' })
|
||||
if (method === 'PUT') {
|
||||
const body = request.postDataJSON() as { type?: string; data?: Record<string, unknown>; order?: number }
|
||||
if (typeof body.type === 'string') block.type = body.type
|
||||
if (body.data) block.data = body.data
|
||||
if (typeof body.order === 'number') block.order = body.order
|
||||
return json(route, 204)
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
pageOf.blocks = pageOf.blocks.filter((b) => b.id !== m![1])
|
||||
return json(route, 204)
|
||||
}
|
||||
}
|
||||
|
||||
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||
const col = m ? collections[m[1]] : undefined
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* FEAT-2/FEAT-6-Flüsse.
|
||||
*/
|
||||
import type { Contact, Enclosure, Gerbil, Litter } from '../src/api/types'
|
||||
import type { InboxRequest } from '../src/api/requests'
|
||||
|
||||
export interface HealthRecordRow {
|
||||
id: string
|
||||
@@ -25,6 +26,24 @@ export interface WeightRecordRow {
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
/** WEB-0b: CMS-Block (data ist das typ-spezifische JSON-Objekt). */
|
||||
export interface MockBlock {
|
||||
id: string
|
||||
order: number
|
||||
type: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** WEB-0b: CMS-Seite der öffentlichen Webseite. */
|
||||
export interface MockPage {
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
seoDescription: string | null
|
||||
status: 'Draft' | 'Published'
|
||||
blocks: MockBlock[]
|
||||
}
|
||||
|
||||
export interface MockDb {
|
||||
gerbils: Gerbil[]
|
||||
litters: Litter[]
|
||||
@@ -33,6 +52,12 @@ export interface MockDb {
|
||||
colorVarieties: { id: string; name: string; canonicalGenotype: string | null; sortOrder: number }[]
|
||||
healthRecords: HealthRecordRow[]
|
||||
weightRecords: WeightRecordRow[]
|
||||
pages: MockPage[]
|
||||
// INBOX-1: Anfragen-Posteingang. Die Flags steuern die Fehlerpfade des Mocks
|
||||
// (Specs können sie pro Test umlegen): KI-Entwurf 503 / Sync+Send Mail-Fehler.
|
||||
requests: InboxRequest[]
|
||||
aiConfigured: boolean
|
||||
mailConfigured: boolean
|
||||
}
|
||||
|
||||
function gerbil(
|
||||
@@ -58,8 +83,13 @@ function gerbil(
|
||||
colorVarietyId,
|
||||
originContactId: null,
|
||||
receiverContactId: null,
|
||||
originBreeder: null,
|
||||
// SEARCH-2b: backend stores a normalized name (lowercase, [\s._-] stripped).
|
||||
nameSearch: name.toLowerCase().replace(/[\s._-]/g, ''),
|
||||
genotype,
|
||||
notes: null,
|
||||
// BESTAND-FILTER: default = eigener Bestand; einzelne externe Ahnen unten gesetzt.
|
||||
isResident: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +100,9 @@ export function seedDb(): MockDb {
|
||||
...gerbil('kruemel', 'Krümel', 'female', '2025-03-12', 'w-kruemel', 'cv-agouti', 'Aa CC DD EE GG Pp spsp rere'),
|
||||
enclosureId: 'enc-gross',
|
||||
originContactId: 'con-meier',
|
||||
originBreeder: 'Clan-Kleine-Chaoten',
|
||||
},
|
||||
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross' },
|
||||
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier' },
|
||||
gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'),
|
||||
gerbil('balu', 'Balu', 'male', '2021-04-20', 'w-balu', 'cv-agouti'),
|
||||
gerbil('maja', 'Maja', 'female', '2021-06-11', null, 'cv-schwarz-schecke', 'aa CC DD EE GG PP Spsp rere'),
|
||||
@@ -81,8 +112,9 @@ export function seedDb(): MockDb {
|
||||
gerbil('greta', 'Greta', 'female', '2019-05-23', null, 'cv-schwarz'),
|
||||
gerbil('frieda', 'Frieda', 'female', '2020-01-18', null, 'cv-gold'),
|
||||
gerbil('emil', 'Emil', 'male', '2017-03-03', 'w-emil', 'cv-agouti'),
|
||||
gerbil('hilde', 'Hilde', 'female', '2017-11-11', null, 'cv-schwarz'),
|
||||
gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'),
|
||||
// BESTAND-FILTER: externe Ahnen (Gründertiere fremder Zuchten, nur für den Stammbaum).
|
||||
{ ...gerbil('hilde', 'Hilde', 'female', '2017-11-11', null, 'cv-schwarz'), isResident: false, originBreeder: 'Zoohandlung Meier' },
|
||||
{ ...gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'), isResident: false, originBreeder: 'Zoohandlung Meier' },
|
||||
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
|
||||
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
|
||||
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
|
||||
@@ -138,5 +170,93 @@ export function seedDb(): MockDb {
|
||||
{ id: 'wr-2', gerbilId: 'kruemel', date: '2026-05-20', weightGrams: 82, notes: null },
|
||||
]
|
||||
|
||||
return { gerbils, litters, enclosures, contacts, colorVarieties, healthRecords, weightRecords }
|
||||
// WEB-0b: die 6 Webseiten-Seiten (wie vom Backend geseedet), mit ein paar Blöcken.
|
||||
const pages: MockPage[] = [
|
||||
{
|
||||
id: 'page-start',
|
||||
slug: 'start',
|
||||
title: 'Startseite',
|
||||
seoDescription: 'Willkommen bei unserer Rennmauszucht.',
|
||||
status: 'Published',
|
||||
blocks: [
|
||||
{ id: 'blk-start-1', order: 0, type: 'Heading', data: { text: 'Willkommen', level: 2 } },
|
||||
{ id: 'blk-start-2', order: 1, type: 'RichText', data: { markdown: 'Schön, dass du da bist.' } },
|
||||
],
|
||||
},
|
||||
{ id: 'page-zucht', slug: 'ueber-die-zucht', title: 'Über die Zucht', seoDescription: null, status: 'Draft', blocks: [] },
|
||||
{
|
||||
id: 'page-abgabe',
|
||||
slug: 'abgabetiere',
|
||||
title: 'Abgabetiere',
|
||||
seoDescription: null,
|
||||
status: 'Published',
|
||||
blocks: [
|
||||
{ id: 'blk-abgabe-1', order: 0, type: 'AbgabetiereList', data: { mode: 'auto', intro: 'Diese Tiere suchen ein Zuhause.' } },
|
||||
],
|
||||
},
|
||||
{ id: 'page-bedingungen', slug: 'abgabebedingungen', title: 'Abgabebedingungen', seoDescription: null, status: 'Draft', blocks: [] },
|
||||
{ id: 'page-farben', slug: 'farben-genetik', title: 'Farben & Genetik', seoDescription: null, status: 'Draft', blocks: [] },
|
||||
{ id: 'page-kontakt', slug: 'kontakt', title: 'Kontakt', seoDescription: null, status: 'Draft', blocks: [] },
|
||||
]
|
||||
|
||||
// INBOX-1: Anfragen (neueste zuerst nach receivedAt sortierbar)
|
||||
const requests: InboxRequest[] = [
|
||||
{
|
||||
id: 'req-anna',
|
||||
gmailMessageId: '<anna-1@mail.example>',
|
||||
threadId: 'thr-1',
|
||||
fromAddress: 'anna@example.de',
|
||||
fromName: 'Anna Albrecht',
|
||||
subject: 'Anfrage: Pärchen zur Abgabe?',
|
||||
bodyText:
|
||||
'Hallo,\n\nich habe euer Inserat gesehen — sind die beiden Agouti-Jungs noch zu haben?\n\nViele Grüße\nAnna',
|
||||
receivedAt: '2026-06-05T18:30:00Z',
|
||||
status: 'New',
|
||||
assignedContactId: null,
|
||||
draftReply: null,
|
||||
answeredAt: null,
|
||||
},
|
||||
{
|
||||
id: 'req-ben',
|
||||
gmailMessageId: '<ben-1@mail.example>',
|
||||
threadId: 'thr-2',
|
||||
fromAddress: 'ben@example.org',
|
||||
fromName: 'Ben Berger',
|
||||
subject: 'Frage zur Haltung',
|
||||
bodyText: 'Guten Tag, was für ein Becken empfehlt ihr für zwei Rennmäuse?',
|
||||
receivedAt: '2026-06-04T09:00:00Z',
|
||||
status: 'InProgress',
|
||||
assignedContactId: null,
|
||||
draftReply: 'Hallo Ben, wir empfehlen mindestens 100×50 cm …',
|
||||
answeredAt: null,
|
||||
},
|
||||
{
|
||||
id: 'req-clara',
|
||||
gmailMessageId: '<clara-1@mail.example>',
|
||||
threadId: null,
|
||||
fromAddress: 'clara@example.com',
|
||||
fromName: null,
|
||||
subject: 'Danke!',
|
||||
bodyText: 'Die beiden sind super angekommen — danke nochmal!',
|
||||
receivedAt: '2026-06-01T12:00:00Z',
|
||||
status: 'Answered',
|
||||
assignedContactId: 'con-meier',
|
||||
draftReply: null,
|
||||
answeredAt: '2026-06-02T08:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
gerbils,
|
||||
litters,
|
||||
enclosures,
|
||||
contacts,
|
||||
colorVarieties,
|
||||
healthRecords,
|
||||
weightRecords,
|
||||
pages,
|
||||
requests,
|
||||
aiConfigured: true,
|
||||
mailConfigured: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
|
||||
import { de, expect, gotoSection, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
const tabs = de.pages.tierTabs
|
||||
@@ -15,22 +15,35 @@ test('Liste lädt und die Namenssuche filtert', async ({ page }) => {
|
||||
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden()
|
||||
})
|
||||
|
||||
test('Herkunft-Filter zeigt nur Tiere des gewählten Kontakts (SEARCH-2)', async ({ page }) => {
|
||||
test('Herkunft-Filter (originBreeder) zeigt nur Tiere der gewählten Zucht (SEARCH-2b)', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse')
|
||||
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible()
|
||||
|
||||
// Herkunft (originContactId) auf den Seed-Kontakt 'Zoohandlung Meier' (nur Krümel).
|
||||
// UX-MOBILE-1: Herkunft-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
|
||||
await openFilterPanel(page)
|
||||
// Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel).
|
||||
await page
|
||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })
|
||||
.locator('select')
|
||||
.selectOption({ label: 'Zoohandlung Meier' })
|
||||
.selectOption({ label: 'Clan-Kleine-Chaoten' })
|
||||
|
||||
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden()
|
||||
})
|
||||
|
||||
test('Namenssuche ist trennzeichen-unempfindlich (SEARCH-2b)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse')
|
||||
// 'Krü mel' (mit Leerzeichen) wird normalisiert zu 'krümel' -> matcht Krümel.
|
||||
await page.getByPlaceholder(t.searchPlaceholder).first().fill('Krü mel')
|
||||
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden()
|
||||
})
|
||||
|
||||
test('Neue Rennmaus anlegen — Validierung + Erfolg', async ({ page }) => {
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await page.getByRole('link', { name: t.newButton }).click()
|
||||
|
||||
66
gerbil-manager-web/e2e/webseite-vorschau.spec.ts
Normal file
66
gerbil-manager-web/e2e/webseite-vorschau.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* WEB-3: Lokale Vorschau der öffentlichen Webseite — iframe lädt die
|
||||
* gerenderte Seite, Seiten-Wechsler (nur Veröffentlichte), Smartphone/
|
||||
* Desktop-Umschalter, Einstiege von der Übersicht.
|
||||
* Mock-gebunden (Seed-Seiten) → skipUnlessMock.
|
||||
*/
|
||||
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const tw = de.pages.webseite
|
||||
const tv = tw.vorschau
|
||||
|
||||
test.describe('Webseiten-Vorschau', () => {
|
||||
test('Vorschau öffnet die gerenderte Startseite im iframe', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/webseite')
|
||||
await page.getByRole('link', { name: tv.button }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: tv.title })).toBeVisible()
|
||||
await expect(page.getByText(tv.intro)).toBeVisible()
|
||||
// Inhalt der gerenderten Seite (Mock-HTML) ist im iframe sichtbar
|
||||
const frame = page.frameLocator('.vorschau-iframe')
|
||||
await expect(frame.getByRole('heading', { name: 'Startseite' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Seiten-Wechsler listet nur Veröffentlichte und wechselt die Vorschau', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/webseite/vorschau')
|
||||
|
||||
const select = page.getByLabel(tv.pageSelect)
|
||||
// 2 veröffentlichte Seed-Seiten; Entwürfe (z. B. „Über die Zucht“) fehlen
|
||||
await expect(select.locator('option')).toHaveCount(2)
|
||||
await expect(select.locator('option', { hasText: 'Über die Zucht' })).toHaveCount(0)
|
||||
|
||||
await select.selectOption({ label: 'Abgabetiere' })
|
||||
const frame = page.frameLocator('.vorschau-iframe')
|
||||
await expect(frame.getByRole('heading', { name: 'Abgabetiere' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Smartphone/Desktop-Umschalter ändert die Rahmenbreite', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/webseite/vorschau')
|
||||
|
||||
// Standard: Smartphone-Rahmen
|
||||
await expect(page.locator('.vorschau-frame--phone')).toBeVisible()
|
||||
await page.getByRole('button', { name: tv.viewDesktop }).click()
|
||||
await expect(page.locator('.vorschau-frame--phone')).toHaveCount(0)
|
||||
await page.getByRole('button', { name: tv.viewPhone }).click()
|
||||
await expect(page.locator('.vorschau-frame--phone')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Übersicht: „Ansehen“ nur bei Veröffentlichten, öffnet die richtige Seite', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/webseite')
|
||||
|
||||
// Veröffentlichte Karte hat den Ansehen-Link, Entwurf nicht
|
||||
const abgabeCard = page.locator('.webseite-card', { hasText: 'Abgabetiere' })
|
||||
const draftCard = page.locator('.webseite-card', { hasText: 'Über die Zucht' })
|
||||
await expect(abgabeCard.getByRole('link', { name: tv.openPage })).toBeVisible()
|
||||
await expect(draftCard.getByRole('link', { name: tv.openPage })).toHaveCount(0)
|
||||
|
||||
await abgabeCard.getByRole('link', { name: tv.openPage }).click()
|
||||
await expect(page).toHaveURL(/\/webseite\/vorschau\?seite=abgabetiere/)
|
||||
const frame = page.frameLocator('.vorschau-iframe')
|
||||
await expect(frame.getByRole('heading', { name: 'Abgabetiere' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
57
gerbil-manager-web/e2e/webseite.spec.ts
Normal file
57
gerbil-manager-web/e2e/webseite.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/** WEB-0b: CMS-Verwaltung der öffentlichen Webseite — Smoke-Test. */
|
||||
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.pages.webseite
|
||||
|
||||
test('Webseiten-Übersicht zeigt die Seiten mit Status', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await gotoSection(page, de.nav.website)
|
||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||
|
||||
const cards = page.locator('.webseite-card')
|
||||
await expect(cards).toHaveCount(6)
|
||||
|
||||
const start = page.locator('.webseite-card', { hasText: 'Startseite' })
|
||||
await expect(start.getByText(t.statusPublished, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Block hinzufügen, bearbeiten und verschieben bleibt erhalten', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await gotoSection(page, de.nav.website)
|
||||
|
||||
// Startseite öffnen (hat bereits zwei Blöcke).
|
||||
await page
|
||||
.locator('.webseite-card', { hasText: 'Startseite' })
|
||||
.getByRole('link', { name: t.edit })
|
||||
.click()
|
||||
await expect(page.getByRole('heading', { name: 'Startseite' })).toBeVisible()
|
||||
|
||||
const blocks = page.locator('.block-card')
|
||||
await expect(blocks).toHaveCount(2)
|
||||
|
||||
// Eine Überschrift hinzufügen -> jetzt drei Blöcke.
|
||||
await page.getByRole('button', { name: `+ ${t.blocks.types.Heading}` }).click()
|
||||
await expect(blocks).toHaveCount(3)
|
||||
|
||||
// Den neuen (letzten) Block beschriften und speichern.
|
||||
const last = blocks.last()
|
||||
await last.getByLabel(t.blocks.fields.headingText).fill('Neue Überschrift')
|
||||
await last.getByRole('button', { name: t.blocks.save }).click()
|
||||
await expect(last.getByText(t.blocks.saved, { exact: true })).toBeVisible()
|
||||
|
||||
// Den neuen Block nach oben verschieben (von Position 3 auf 2).
|
||||
await last.getByRole('button', { name: t.blocks.moveUp }).click()
|
||||
|
||||
// Neu laden über die Übersicht — der Mock ist zustandsbehaftet.
|
||||
await page.getByRole('link', { name: t.back }).click()
|
||||
await page
|
||||
.locator('.webseite-card', { hasText: 'Startseite' })
|
||||
.getByRole('link', { name: t.edit })
|
||||
.click()
|
||||
|
||||
// Drei Blöcke, und die neue Überschrift steht jetzt an zweiter Stelle.
|
||||
await expect(page.locator('.block-card')).toHaveCount(3)
|
||||
await expect(page.locator('.block-card').nth(1).getByLabel(t.blocks.fields.headingText)).toHaveValue(
|
||||
'Neue Überschrift',
|
||||
)
|
||||
})
|
||||
@@ -22,6 +22,11 @@ import HilfePage from './pages/HilfePage'
|
||||
import VertraegeListPage from './pages/VertraegeListPage'
|
||||
import VertragWizardPage from './pages/VertragWizardPage'
|
||||
import EinstellungenPage from './pages/EinstellungenPage'
|
||||
import WebseitePage from './pages/WebseitePage'
|
||||
import WebseiteEditorPage from './pages/WebseiteEditorPage'
|
||||
import WebseiteVorschauPage from './pages/WebseiteVorschauPage'
|
||||
import AnfragenPage from './pages/AnfragenPage'
|
||||
import AnfrageDetailPage from './pages/AnfrageDetailPage'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -65,6 +70,18 @@ export default function App() {
|
||||
<Route path="neu" element={<VertragWizardPage />} />
|
||||
</Route>
|
||||
<Route path="einstellungen" element={<EinstellungenPage />} />
|
||||
{/* WEB-0b: CMS-Verwaltung der öffentlichen Webseite */}
|
||||
<Route path="webseite">
|
||||
<Route index element={<WebseitePage />} />
|
||||
{/* WEB-3: lokale Vorschau (statischer Pfad gewinnt vor :slug) */}
|
||||
<Route path="vorschau" element={<WebseiteVorschauPage />} />
|
||||
<Route path=":slug" element={<WebseiteEditorPage />} />
|
||||
</Route>
|
||||
{/* INBOX-1: Anfragen-Posteingang */}
|
||||
<Route path="anfragen">
|
||||
<Route index element={<AnfragenPage />} />
|
||||
<Route path=":id" element={<AnfrageDetailPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
24
gerbil-manager-web/src/api/__tests__/pages.test.ts
Normal file
24
gerbil-manager-web/src/api/__tests__/pages.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ADDABLE_BLOCK_TYPES, defaultBlockData, type BlockType } from '../pages'
|
||||
|
||||
describe('defaultBlockData', () => {
|
||||
it('liefert sinnvolle Startwerte je Block-Typ', () => {
|
||||
expect(defaultBlockData('Heading')).toEqual({ text: '', level: 2 })
|
||||
expect(defaultBlockData('RichText')).toEqual({ markdown: '' })
|
||||
expect(defaultBlockData('Image')).toEqual({ url: '', alt: '' })
|
||||
expect(defaultBlockData('Gallery')).toEqual({ images: [] })
|
||||
expect(defaultBlockData('ContactInfo')).toEqual({ name: '', email: '', phone: '', address: '' })
|
||||
expect(defaultBlockData('AbgabetiereList')).toEqual({ mode: 'auto', intro: '' })
|
||||
})
|
||||
|
||||
it('deckt jeden addbaren Typ ab', () => {
|
||||
for (const type of ADDABLE_BLOCK_TYPES) {
|
||||
expect(defaultBlockData(type)).toBeTypeOf('object')
|
||||
}
|
||||
})
|
||||
|
||||
it('bietet die dynamische AbgabetiereList NICHT zum manuellen Anlegen an', () => {
|
||||
const addable: readonly BlockType[] = ADDABLE_BLOCK_TYPES
|
||||
expect(addable).not.toContain('AbgabetiereList')
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,11 @@ export function getGerbil(id: string): Promise<Gerbil> {
|
||||
return api.get<Gerbil>(`${resources.gerbils}/${id}`)
|
||||
}
|
||||
|
||||
/** SEARCH-2b: distinct non-empty originBreeder (Herkunft) values, sorted. */
|
||||
export function listOriginBreeders(): Promise<string[]> {
|
||||
return api.get<string[]>(`${resources.gerbils}/breeders`)
|
||||
}
|
||||
|
||||
export function createGerbil(body: CreateGerbil): Promise<Gerbil> {
|
||||
return api.post<Gerbil>(resources.gerbils, body)
|
||||
}
|
||||
|
||||
167
gerbil-manager-web/src/api/pages.ts
Normal file
167
gerbil-manager-web/src/api/pages.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* WEB-0b: CMS-Inhalte der öffentlichen Webseite (Seiten + Blöcke).
|
||||
*
|
||||
* Backend-Vertrag (GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs):
|
||||
* - GET /pages -> PageSummary[] (kein Paging!)
|
||||
* - GET /pages/{slug} -> Page (inkl. Blöcke)
|
||||
* - PUT /pages/{id} -> 204
|
||||
* - POST /pages/{pageId}/blocks -> 201 Block
|
||||
* - PUT /blocks/{id} -> 204
|
||||
* - DELETE /blocks/{id} -> 204
|
||||
* - PUT /pages/{pageId}/blocks/order -> 204 (Body: { blockIds })
|
||||
*
|
||||
* Enums kommen dank globalem JsonStringEnumConverter als STRINGS
|
||||
* ("Draft"/"Published", "Heading"/"RichText"/…). Block.data ist ein
|
||||
* typ-spezifisches JSON-Objekt (siehe BlockData-Typen unten).
|
||||
*/
|
||||
import { API_BASE_URL, api } from './client'
|
||||
|
||||
/**
|
||||
* WEB-3-Fix: Die CMS-Endpunkte liegen unter der /api-Gruppe
|
||||
* (CmsEndpoints: MapGroup("/api")) — die Aufrufe hier liefen vorher gegen
|
||||
* /pages und wären gegen die ECHTE API 404 gelaufen (der e2e-Mock hat den
|
||||
* Unterschied kaschiert, weil er das /api-Präfix normalisiert).
|
||||
*/
|
||||
const CMS = '/api'
|
||||
|
||||
export type PageStatus = 'Draft' | 'Published'
|
||||
|
||||
export type BlockType =
|
||||
| 'Heading'
|
||||
| 'RichText'
|
||||
| 'Image'
|
||||
| 'Gallery'
|
||||
| 'ContactInfo'
|
||||
| 'AbgabetiereList'
|
||||
|
||||
/** Block-Typen, die die Hand-Editier-Oberfläche neu anlegen kann (in Anzeige-Reihenfolge). */
|
||||
export const ADDABLE_BLOCK_TYPES: readonly BlockType[] = [
|
||||
'Heading',
|
||||
'RichText',
|
||||
'Image',
|
||||
'Gallery',
|
||||
'ContactInfo',
|
||||
] as const
|
||||
|
||||
// ── Typ-spezifische Block-Daten ────────────────────────────────────────────
|
||||
export interface HeadingData {
|
||||
text: string
|
||||
level: 2 | 3
|
||||
}
|
||||
export interface RichTextData {
|
||||
/** Markdown-Quelltext (wird erst beim Veröffentlichen gerendert + bereinigt). */
|
||||
markdown: string
|
||||
}
|
||||
export interface ImageData {
|
||||
url: string
|
||||
alt: string
|
||||
}
|
||||
export interface GalleryImage {
|
||||
url: string
|
||||
alt: string
|
||||
}
|
||||
export interface GalleryData {
|
||||
images: GalleryImage[]
|
||||
}
|
||||
export interface ContactInfoData {
|
||||
name: string
|
||||
email: string
|
||||
phone: string
|
||||
address: string
|
||||
}
|
||||
export interface AbgabetiereListData {
|
||||
/** auto = aus den aktuellen Abgabetieren aufgelöst; manual = von Hand gepflegt. */
|
||||
mode: 'auto' | 'manual'
|
||||
intro: string
|
||||
}
|
||||
|
||||
export type BlockData = Record<string, unknown>
|
||||
|
||||
export interface Block {
|
||||
id: string
|
||||
order: number
|
||||
type: BlockType
|
||||
data: BlockData
|
||||
}
|
||||
|
||||
export interface PageSummary {
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
seoDescription: string | null
|
||||
status: PageStatus
|
||||
}
|
||||
|
||||
export interface Page extends PageSummary {
|
||||
blocks: Block[]
|
||||
}
|
||||
|
||||
export interface PageInput {
|
||||
slug: string
|
||||
title: string
|
||||
seoDescription: string | null
|
||||
status: PageStatus
|
||||
}
|
||||
|
||||
export interface BlockInput {
|
||||
type: BlockType
|
||||
data: BlockData
|
||||
order?: number
|
||||
}
|
||||
|
||||
/** Sinnvolle Startwerte für einen neu angelegten Block. */
|
||||
export function defaultBlockData(type: BlockType): BlockData {
|
||||
switch (type) {
|
||||
case 'Heading':
|
||||
return { text: '', level: 2 }
|
||||
case 'RichText':
|
||||
return { markdown: '' }
|
||||
case 'Image':
|
||||
return { url: '', alt: '' }
|
||||
case 'Gallery':
|
||||
return { images: [] }
|
||||
case 'ContactInfo':
|
||||
return { name: '', email: '', phone: '', address: '' }
|
||||
case 'AbgabetiereList':
|
||||
return { mode: 'auto', intro: '' }
|
||||
}
|
||||
}
|
||||
|
||||
// ── API-Aufrufe ──────────────────────────────────────────────────────────────
|
||||
export function listPages(): Promise<PageSummary[]> {
|
||||
return api.get<PageSummary[]>(`${CMS}/pages`)
|
||||
}
|
||||
|
||||
export function getPage(slug: string): Promise<Page> {
|
||||
return api.get<Page>(`${CMS}/pages/${encodeURIComponent(slug)}`)
|
||||
}
|
||||
|
||||
export function updatePage(id: string, input: PageInput): Promise<void> {
|
||||
return api.put<void>(`${CMS}/pages/${id}`, input)
|
||||
}
|
||||
|
||||
export function addBlock(pageId: string, input: BlockInput): Promise<Block> {
|
||||
return api.post<Block>(`${CMS}/pages/${pageId}/blocks`, input)
|
||||
}
|
||||
|
||||
export function updateBlock(id: string, input: BlockInput): Promise<void> {
|
||||
return api.put<void>(`${CMS}/blocks/${id}`, input)
|
||||
}
|
||||
|
||||
export function deleteBlock(id: string): Promise<void> {
|
||||
return api.delete(`${CMS}/blocks/${id}`)
|
||||
}
|
||||
|
||||
export function reorderBlocks(pageId: string, blockIds: string[]): Promise<void> {
|
||||
return api.put<void>(`${CMS}/pages/${pageId}/blocks/order`, { blockIds })
|
||||
}
|
||||
|
||||
/**
|
||||
* WEB-3: Absolute URL der lokalen Vorschau (GET /api/preview/… rendert live).
|
||||
* "start" liegt auf index.html, alle anderen Seiten auf {slug}/index.html —
|
||||
* dieselbe Abbildung wie im SiteRenderer.
|
||||
*/
|
||||
export function previewUrl(slug?: string | null): string {
|
||||
const path = !slug || slug === 'start' ? 'index.html' : `${encodeURIComponent(slug)}/index.html`
|
||||
return `${API_BASE_URL}/api/preview/${path}`
|
||||
}
|
||||
87
gerbil-manager-web/src/api/requests.ts
Normal file
87
gerbil-manager-web/src/api/requests.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* INBOX-1: Anfragen-Posteingang (/api/requests, Backend INBOX-0/2/3).
|
||||
*
|
||||
* Triage-Hinweis: PUT setzt assignedContactId UNBEDINGT aus dem Input
|
||||
* (Backend überschreibt mit null, wenn das Feld fehlt) — beim reinen
|
||||
* Status-Wechsel also IMMER die aktuelle Zuordnung mitsenden.
|
||||
*/
|
||||
import { ApiError, api } from './client'
|
||||
import { toQueryString, type GridifyQuery } from './gridify'
|
||||
import type { Paged } from './types'
|
||||
|
||||
/** C#-Enum RequestStatus — Stringnamen auf dem Draht. */
|
||||
export type RequestStatus = 'New' | 'InProgress' | 'Assigned' | 'Answered' | 'Abandoned'
|
||||
export const REQUEST_STATUSES: RequestStatus[] = [
|
||||
'New',
|
||||
'InProgress',
|
||||
'Assigned',
|
||||
'Answered',
|
||||
'Abandoned',
|
||||
]
|
||||
|
||||
export interface InboxRequest {
|
||||
id: string
|
||||
gmailMessageId: string
|
||||
threadId: string | null
|
||||
fromAddress: string
|
||||
fromName: string | null
|
||||
subject: string | null
|
||||
bodyText: string | null
|
||||
receivedAt: string
|
||||
status: RequestStatus
|
||||
assignedContactId: string | null
|
||||
draftReply: string | null
|
||||
answeredAt: string | null
|
||||
}
|
||||
|
||||
export interface RequestTriage {
|
||||
status?: RequestStatus | null
|
||||
assignedContactId?: string | null
|
||||
}
|
||||
|
||||
/** Ergebnis von POST /api/requests/sync. */
|
||||
export interface SyncResult {
|
||||
imported: number
|
||||
/** null = ok; "MailNotConfigured" | "MailAuthFailed" | … */
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const BASE = '/api/requests'
|
||||
|
||||
export function listRequests(query: GridifyQuery): Promise<Paged<InboxRequest>> {
|
||||
return api.get<Paged<InboxRequest>>(`${BASE}${toQueryString(query)}`)
|
||||
}
|
||||
|
||||
export function getRequest(id: string): Promise<InboxRequest> {
|
||||
return api.get<InboxRequest>(`${BASE}/${id}`)
|
||||
}
|
||||
|
||||
export function triageRequest(id: string, triage: RequestTriage): Promise<void> {
|
||||
return api.put<void>(`${BASE}/${id}`, triage)
|
||||
}
|
||||
|
||||
export function syncRequests(): Promise<SyncResult> {
|
||||
return api.post<SyncResult>(`${BASE}/sync`, {})
|
||||
}
|
||||
|
||||
/** KI-Entwurf erzeugen (INBOX-2); 503 {code:'AiKeyMissing'} solange unkonfiguriert. */
|
||||
export function draftReply(id: string): Promise<InboxRequest> {
|
||||
return api.post<InboxRequest>(`${BASE}/${id}/draft`, {})
|
||||
}
|
||||
|
||||
/** (Bearbeitete) Antwort senden (INBOX-3); setzt serverseitig Answered. */
|
||||
export function sendReply(id: string, body: string): Promise<InboxRequest> {
|
||||
return api.post<InboxRequest>(`${BASE}/${id}/send`, { body })
|
||||
}
|
||||
|
||||
/**
|
||||
* ProblemDetails-Titel aus einem ApiError lesen (Send-Endpunkt meldet
|
||||
* MailNotConfigured/MailAuthFailed über `title`, nicht `code`).
|
||||
*/
|
||||
export function problemTitle(err: unknown): string | null {
|
||||
if (err instanceof ApiError && err.body && typeof err.body === 'object' && 'title' in err.body) {
|
||||
const title = (err.body as { title: unknown }).title
|
||||
return typeof title === 'string' ? title : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -20,6 +20,9 @@ export interface SaleAdAnimal {
|
||||
farbschlag: string | null
|
||||
dateOfBirth: string | null
|
||||
notes: string | null
|
||||
/** FEAT-14: German trait LABELS (human-readable for the prompt), not keys. */
|
||||
traits?: string[]
|
||||
characterNote?: string | null
|
||||
}
|
||||
|
||||
export interface SaleAdRequest {
|
||||
|
||||
@@ -31,9 +31,29 @@ export interface Gerbil {
|
||||
colorVarietyId: string | null
|
||||
originContactId: string | null
|
||||
receiverContactId: string | null
|
||||
/** SEARCH-1/2b: free-text Herkunft (Züchter) from the import; Gridify-filterable. */
|
||||
originBreeder?: string | null
|
||||
/**
|
||||
* SEARCH-1/2b: normalized name (lowercase, [\s._-] stripped) — filter-only,
|
||||
* not part of the returned payload; present here so it can be a Gridify field.
|
||||
*/
|
||||
nameSearch?: string | null
|
||||
/** Compact GEN-1 genotype string, e.g. "Aa CC Dd EE GG Pp Spsp rere" (or "?"-wildcards). */
|
||||
genotype: string | null
|
||||
notes: string | null
|
||||
/**
|
||||
* FEAT-14: Charakterbogen. Selected trait KEYS (see de.character.traits).
|
||||
* Optional until FEAT-14a backend exposes the fields; treat absent as [].
|
||||
*/
|
||||
characterTraits?: string[] | null
|
||||
characterNote?: string | null
|
||||
/**
|
||||
* Residency (Bestand): true = part of the own clan (Kleine Chaoten), false =
|
||||
* external/pedigree-only ancestor imported for the Stammbaum. Gridify-filterable
|
||||
* (`isResident==true`); the Tiere list defaults to resident-only. Absent = treat
|
||||
* as resident (backend default true).
|
||||
*/
|
||||
isResident?: boolean
|
||||
}
|
||||
|
||||
/** Payload for POST /gerbils. */
|
||||
@@ -50,8 +70,12 @@ export interface CreateGerbil {
|
||||
colorVarietyId?: string | null
|
||||
originContactId?: string | null
|
||||
receiverContactId?: string | null
|
||||
originBreeder?: string | null
|
||||
genotype?: string | null
|
||||
notes?: string | null
|
||||
characterTraits?: string[] | null
|
||||
characterNote?: string | null
|
||||
isResident?: boolean
|
||||
}
|
||||
|
||||
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
||||
|
||||
@@ -22,10 +22,14 @@ const SECONDARY: NavItem[] = [
|
||||
{ to: '/becken', label: de.nav.enclosures, icon: '🛁' },
|
||||
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' },
|
||||
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
|
||||
// INBOX-1: Anfragen-Posteingang
|
||||
{ to: '/anfragen', label: de.nav.requests, icon: '📨' },
|
||||
{ to: '/statistik', label: de.nav.statistics, icon: '📊' },
|
||||
// FEAT-13: Abgabeverträge + Zuchtprofil
|
||||
{ to: '/vertraege', label: de.nav.contracts, icon: '📄' },
|
||||
{ to: '/einstellungen', label: de.nav.settings, icon: '⚙️' },
|
||||
// WEB-0b: CMS-Verwaltung der öffentlichen Webseite
|
||||
{ to: '/webseite', label: de.nav.website, icon: '🌐' },
|
||||
{ to: '/hilfe', label: de.nav.help, icon: '❓' },
|
||||
]
|
||||
|
||||
|
||||
62
gerbil-manager-web/src/components/Charakterbogen.tsx
Normal file
62
gerbil-manager-web/src/components/Charakterbogen.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { de } from '../strings/de'
|
||||
import { ALL_TRAITS } from '../format/traits'
|
||||
import './charakterbogen.css'
|
||||
|
||||
export interface CharakterbogenProps {
|
||||
/** Selected trait keys. */
|
||||
traits: string[]
|
||||
note: string
|
||||
onTraitsChange: (traits: string[]) => void
|
||||
onNoteChange: (note: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* FEAT-14: character sheet — a checkbox grid of traits + a free note.
|
||||
* Controlled & reusable: rendered on the animal detail page (persisted) and in
|
||||
* the Abgabe listing composer (feeds the AI sale-text). German labels from
|
||||
* de.character.traits; the stored value is the trait KEY.
|
||||
*/
|
||||
export default function Charakterbogen({
|
||||
traits,
|
||||
note,
|
||||
onTraitsChange,
|
||||
onNoteChange,
|
||||
}: CharakterbogenProps) {
|
||||
const t = de.character
|
||||
const selected = new Set(traits)
|
||||
|
||||
const toggle = (key: string) => {
|
||||
const next = new Set(selected)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
// Preserve the vocabulary order for stable output.
|
||||
onTraitsChange(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="charakterbogen">
|
||||
<ul className="trait-grid">
|
||||
{ALL_TRAITS.map((tr) => (
|
||||
<li key={tr.key}>
|
||||
<label className="trait-chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(tr.key)}
|
||||
onChange={() => toggle(tr.key)}
|
||||
/>
|
||||
<span>{tr.label}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<label className="field">
|
||||
<span>{t.noteLabel}</span>
|
||||
<textarea
|
||||
value={note}
|
||||
placeholder={t.notePlaceholder}
|
||||
onChange={(e) => onNoteChange(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
gerbil-manager-web/src/components/FilterPanel.tsx
Normal file
52
gerbil-manager-web/src/components/FilterPanel.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { de } from '../strings/de'
|
||||
import './filterPanel.css'
|
||||
|
||||
interface FilterPanelProps {
|
||||
/** Always visible on mobile (typically the search text input). Optional. */
|
||||
searchField?: ReactNode
|
||||
/** Collapsible filters (hidden behind toggle on mobile; inline on desktop). */
|
||||
children: ReactNode
|
||||
/** Number of currently active (non-default) filter values. Shown as badge. */
|
||||
activeCount: number
|
||||
/** Called when the reset button is clicked. */
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* UX-MOBILE-1: wraps a set of filter controls so they collapse on mobile.
|
||||
* Render inside the existing `.filters` div (or replace it entirely).
|
||||
*
|
||||
* Desktop (>=768px): renders all children inline, identical to today.
|
||||
* Mobile (<768px): shows searchField + a "Filter (N)" toggle; tapping reveals
|
||||
* the rest of the controls in a column drawer + a reset button.
|
||||
*/
|
||||
export function FilterPanel({ searchField, children, activeCount, onReset }: FilterPanelProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const t = de.filterPanel
|
||||
|
||||
const label = activeCount > 0 ? `${t.toggleButton} (${activeCount})` : t.toggleButton
|
||||
|
||||
return (
|
||||
<div className={`filters filter-panel${open ? ' filter-panel--open' : ''}`}>
|
||||
{searchField}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--ghost filter-panel__toggle"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-label={label}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<div className="filter-panel__drawer">
|
||||
{children}
|
||||
{activeCount > 0 && (
|
||||
<button type="button" className="btn btn--ghost filter-panel__reset-btn" onClick={onReset}>
|
||||
{t.resetButton}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import { listGerbilPhotos, photoSrc, type GerbilPhoto } from '../api/photos'
|
||||
import { generateSaleAd } from '../api/saleAd'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { formatDate, genderLabel } from '../format/labels'
|
||||
import { traitLabels } from '../format/traits'
|
||||
import FarbschlagImage from './FarbschlagImage'
|
||||
import Charakterbogen from './Charakterbogen'
|
||||
|
||||
type SaleStatus = 'free' | 'loose' | 'reserved'
|
||||
|
||||
@@ -34,9 +36,13 @@ export default function GroupComposer({ groupNumber, animals, farbschlagOf }: Gr
|
||||
const [status, setStatus] = useState<SaleStatus>('free')
|
||||
const [reservedName, setReservedName] = useState('')
|
||||
const [tagline, setTagline] = useState('')
|
||||
// Per-animal personality text, seeded from notes on first render.
|
||||
// Per-animal character note, seeded from the persisted characterNote (or notes).
|
||||
const [personality, setPersonality] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(animals.map((a) => [a.id, a.notes ?? ''])),
|
||||
Object.fromEntries(animals.map((a) => [a.id, a.characterNote ?? a.notes ?? ''])),
|
||||
)
|
||||
// Per-animal selected trait keys, seeded from the persisted Charakterbogen.
|
||||
const [traitsByAnimal, setTraitsByAnimal] = useState<Record<string, string[]>>(() =>
|
||||
Object.fromEntries(animals.map((a) => [a.id, a.characterTraits ?? []])),
|
||||
)
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Record<string, boolean>>({})
|
||||
const [hints, setHints] = useState('')
|
||||
@@ -147,7 +153,9 @@ export default function GroupComposer({ groupNumber, animals, farbschlagOf }: Gr
|
||||
name: a.name,
|
||||
farbschlag: farbschlagOf(a),
|
||||
dateOfBirth: a.dateOfBirth,
|
||||
notes: personality[a.id] ?? a.notes ?? null,
|
||||
notes: a.notes ?? null,
|
||||
traits: traitLabels(traitsByAnimal[a.id] ?? []),
|
||||
characterNote: personality[a.id] ?? null,
|
||||
})),
|
||||
statusLine,
|
||||
hints,
|
||||
@@ -201,14 +209,12 @@ export default function GroupComposer({ groupNumber, animals, farbschlagOf }: Gr
|
||||
{a.dateOfBirth ? ` · ${t.listing.bornOn} ${formatDate(a.dateOfBirth)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>{t.listing.personality}</span>
|
||||
<textarea
|
||||
value={personality[a.id] ?? ''}
|
||||
placeholder={t.listing.personalityPlaceholder}
|
||||
onChange={(e) => setPersonality((s) => ({ ...s, [a.id]: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<Charakterbogen
|
||||
traits={traitsByAnimal[a.id] ?? []}
|
||||
note={personality[a.id] ?? ''}
|
||||
onTraitsChange={(tr) => setTraitsByAnimal((s) => ({ ...s, [a.id]: tr }))}
|
||||
onNoteChange={(v) => setPersonality((s) => ({ ...s, [a.id]: v }))}
|
||||
/>
|
||||
<div className="photo-select">
|
||||
{(photosById.get(a.id) ?? []).length === 0 ? (
|
||||
<small className="muted">{t.listing.noPhotos}</small>
|
||||
|
||||
41
gerbil-manager-web/src/components/charakterbogen.css
Normal file
41
gerbil-manager-web/src/components/charakterbogen.css
Normal file
@@ -0,0 +1,41 @@
|
||||
/* FEAT-14 Charakterbogen — trait checkbox grid (mobile-first). */
|
||||
|
||||
.charakterbogen .trait-grid {
|
||||
list-style: none;
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.charakterbogen .trait-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.charakterbogen .trait-grid {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.trait-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.trait-chip input {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
min-height: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
53
gerbil-manager-web/src/components/filterPanel.css
Normal file
53
gerbil-manager-web/src/components/filterPanel.css
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* UX-MOBILE-1: FilterPanel — collapsible filter drawer on mobile.
|
||||
*
|
||||
* Desktop (>=768px): toggle hidden, drawer shows as display:contents so its
|
||||
* children participate directly in the parent .filters flex row.
|
||||
* Mobile (<768px): searchField inline, then toggle button. Tap opens a full-
|
||||
* width drawer (flex column) with the remaining filters + reset button.
|
||||
*/
|
||||
|
||||
/* ── Mobile default ── */
|
||||
.filter-panel__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-panel__drawer {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.filter-panel--open .filter-panel__drawer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ── Desktop ── */
|
||||
@media (min-width: 768px) {
|
||||
.filter-panel__toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filter-panel__drawer {
|
||||
/* Let children participate directly in the parent flex row. */
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Reset button sits in the flex row on desktop when active filters exist. */
|
||||
.filter-panel__reset-btn {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Mobile reset button ── */
|
||||
@media (max-width: 767px) {
|
||||
.filter-panel__reset-btn {
|
||||
align-self: flex-start;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,16 @@ export function formatDate(iso: string | null | undefined): string {
|
||||
if (!y || !m || !d) return iso
|
||||
return `${d}.${m}.${y}`
|
||||
}
|
||||
|
||||
/** INBOX-1: ISO-Zeitstempel → "TT.MM.JJJJ, HH:MM" (lokale Zeit, de-DE). */
|
||||
export function formatDateTime(iso: string): string {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
15
gerbil-manager-web/src/format/traits.ts
Normal file
15
gerbil-manager-web/src/format/traits.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/** FEAT-14: map character trait KEYS (stored) <-> German LABELS (de.character.traits). */
|
||||
import { de } from '../strings/de'
|
||||
|
||||
export const ALL_TRAITS = de.character.traits
|
||||
|
||||
const LABEL_BY_KEY = new Map<string, string>(de.character.traits.map((t) => [t.key, t.label]))
|
||||
|
||||
export function traitLabel(key: string): string {
|
||||
return LABEL_BY_KEY.get(key) ?? key
|
||||
}
|
||||
|
||||
/** Selected keys -> German labels (for display + the AI sale-text prompt). */
|
||||
export function traitLabels(keys: readonly string[] | null | undefined): string[] {
|
||||
return (keys ?? []).map(traitLabel)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
toJSON,
|
||||
fromJSON,
|
||||
wildType,
|
||||
extractGenotypeFlags,
|
||||
} from '../genotype'
|
||||
import { combineLocus } from '../punnett'
|
||||
import { LOCI, type LocusKey } from '../loci'
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
} from '../catalog'
|
||||
|
||||
/** The first 18 entries are the frozen contract names (must round-trip exactly). */
|
||||
const FROZEN_COUNT = 18
|
||||
const FROZEN_COUNT = 17
|
||||
import { breed } from '../breed'
|
||||
import { GeneticsWarningCode } from '../warnings'
|
||||
|
||||
@@ -64,6 +65,7 @@ describe('Genotype serialization', () => {
|
||||
P: ['p', 'P'],
|
||||
Sp: ['sp', 'sp'],
|
||||
Re: ['re', 're'],
|
||||
Sls: ['sl', 'sl'],
|
||||
})
|
||||
expect(g.A).toEqual(['A', 'a'])
|
||||
expect(g.C).toEqual(['C', 'ch'])
|
||||
@@ -171,14 +173,16 @@ describe('Farbschlag catalog', () => {
|
||||
})
|
||||
|
||||
it('falls back to Unbekannter Farbschlag for uncatalogued genotypes', () => {
|
||||
// Colourpoint marked + dilute + grey combo not in the catalog.
|
||||
const match = farbschlagFor(fromDisplayString('aa cchmcchm dd ee gg PP spsp rere'))
|
||||
// E-dominant (no Fuchs/Schimmel family) + an uncatalogued cchm/dd/gg/pp combo.
|
||||
const match = farbschlagFor(fromDisplayString('aa CC dd EE gg pp spsp rere'))
|
||||
expect(match.unknown).toBe(true)
|
||||
expect(match.name).toBe('Unbekannter Farbschlag')
|
||||
})
|
||||
|
||||
it('has the expected catalogue coverage (GEN-2: frozen 18 + 55 portal varieties)', () => {
|
||||
expect(CATALOG_SIZE).toBe(73)
|
||||
it('has the expected catalogue coverage (GEN-3f: 61 after cchm CP reconciliation)', () => {
|
||||
// GEN-3f collapsed the 24 portal cchm colourpoint rows to 12 breeder-named
|
||||
// varieties (Marder/Siam/Zobel/Zobel-Hell + CP-<base>), so 73 -> 61.
|
||||
expect(CATALOG_SIZE).toBe(61)
|
||||
})
|
||||
|
||||
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
||||
@@ -204,7 +208,11 @@ describe('Farbschlag catalog', () => {
|
||||
expect(new Set(names).size).toBe(names.length)
|
||||
for (const entry of BASE_COLORS) {
|
||||
for (const [locus, value] of Object.entries(entry.tokens)) {
|
||||
expect(LOCI[locus as LocusKey].alleles).toContain(value)
|
||||
// GEN-3f: a token may be a heterozygous pair "x/y" (e.g. C: 'cchm/ch'
|
||||
// for the het colourpoints Siam/Zobel-Hell) — validate each allele.
|
||||
for (const allele of value.split('/')) {
|
||||
expect(LOCI[locus as LocusKey].alleles).toContain(allele)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -220,7 +228,7 @@ describe('Farbschlag catalog', () => {
|
||||
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
|
||||
expect(genotypeToFarbschlag(wildType())).toBe('Agouti')
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa CC DD EE GG pp spsp rere'))).toBe('Platin')
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa cchmcchm dd ee gg PP spsp rere'))).toBe(
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa CC dd EE gg pp spsp rere'))).toBe(
|
||||
'Unbekannter Farbschlag',
|
||||
)
|
||||
})
|
||||
@@ -240,6 +248,7 @@ describe('Partially-unknown parents (wildcards)', () => {
|
||||
P: ['P', 'P'],
|
||||
Sp: ['sp', 'sp'],
|
||||
Re: ['re', 're'],
|
||||
Sls: ['sl', 'sl'],
|
||||
})
|
||||
const mother = fromDisplayString('aa CC DD EE GG PP spsp rere')
|
||||
const result = breed(father, mother)
|
||||
@@ -263,3 +272,209 @@ describe('Probabilities are exact fractions summing to 1', () => {
|
||||
expect(toNumber(sum)).toBeCloseTo(1, 10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3a: Uw=G alias', () => {
|
||||
it('Uwuw parses as Gg, UwUw as GG', () => {
|
||||
expect(fromDisplayString('AA CC DD EE Uwuw PP spsp rere').G).toEqual(['G', 'g'])
|
||||
expect(fromDisplayString('AA CC DD EE UwUw PP spsp rere').G).toEqual(['G', 'G'])
|
||||
expect(fromDisplayString('AA CC DD EE uwuw PP spsp rere').G).toEqual(['g', 'g'])
|
||||
})
|
||||
|
||||
it('always RENDERS G, never Uw (breeder preference)', () => {
|
||||
// Uw/uw is an input/import alias only; output must echo G/g.
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD EE Uwuw PP spsp rere'))).toBe(
|
||||
'AA CC DD EE Gg PP spsp rere',
|
||||
)
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3a: second spotting locus Sls (WP)', () => {
|
||||
it('S(l)s(l) and WP both parse to the Sls heterozygote Slsl', () => {
|
||||
expect(fromDisplayString('AA CC DD EE GG PP spsp rere S(l)s(l)').Sls).toEqual(['Sl', 'sl'])
|
||||
expect(fromDisplayString('AA CC DD EE GG PP spsp rere WP').Sls).toEqual(['Sl', 'sl'])
|
||||
})
|
||||
|
||||
it('toDisplayString omits wild-type Sls but shows Slsl', () => {
|
||||
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe(
|
||||
'AA CC DD EE GG PP spsp rere Slsl',
|
||||
)
|
||||
})
|
||||
|
||||
it('WP × WP: S(l)S(l) is lethal — removed, renormalized, SLS_LETHAL warning', () => {
|
||||
const parent = fromDisplayString('AA CC DD EE GG PP spsp rere Slsl')
|
||||
const result = breed(parent, parent)
|
||||
expect(result.offspring.every((o) => !o.genotype.includes('SlSl'))).toBe(true)
|
||||
const sum = result.offspring.reduce((acc, o) => acc + o.probability.value, 0)
|
||||
expect(sum).toBeCloseTo(1, 10)
|
||||
// survivors 2/3 Slsl : 1/3 slsl
|
||||
const wp = result.offspring.find((o) => o.genotype.includes('Slsl'))!
|
||||
expect(wp.probability.text).toBe('2/3')
|
||||
const warn = result.warnings.find((w) => w.code === GeneticsWarningCode.SlsLethal)
|
||||
expect(warn?.detail?.youngLostFraction).toBe('1/4')
|
||||
})
|
||||
|
||||
it('Sp × Sls → Superschecke deafness warning', () => {
|
||||
const father = fromDisplayString('AA CC DD EE GG PP Spsp rere')
|
||||
const mother = fromDisplayString('AA CC DD EE GG PP spsp rere Slsl')
|
||||
const result = breed(father, mother)
|
||||
expect(
|
||||
result.warnings.some((w) => w.code === GeneticsWarningCode.SuperscheckeDeaf),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3a: flag/metadata tokens tolerated', () => {
|
||||
it('dea/taub/Dea/DP/WFNZ/RV/GV do not break parsing (stripped)', () => {
|
||||
const g = fromDisplayString('AA CC DD EE GG PP spsp rere dea WFNZ DP RV GV')
|
||||
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp rere')
|
||||
})
|
||||
|
||||
it('extractGenotypeFlags reads deafness + tags', () => {
|
||||
expect(extractGenotypeFlags('AA CC DD EE GG PP spsp rere dea WFNZ').deaf).toBe(true)
|
||||
expect(extractGenotypeFlags('AA CC DD EE GG PP spsp rere Dea').deaf).toBe(false)
|
||||
expect(extractGenotypeFlags('AA CC DD EE GG PP spsp rere WFNZ RV').tags).toEqual(['WFNZ', 'RV'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3a: Schimmel catalog fix (breeder C5)', () => {
|
||||
it('efef base -> Orangeschimmel (not Schwarzschimmel)', () => {
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
|
||||
'Orangeschimmel',
|
||||
)
|
||||
})
|
||||
it('efef pp -> Rotaugenschimmel, efef gg -> Silberschimmel', () => {
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD efef GG pp spsp rere'))).toBe(
|
||||
'Rotaugenschimmel',
|
||||
)
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD efef gg PP spsp rere'))).toBe(
|
||||
'Silberschimmel',
|
||||
)
|
||||
})
|
||||
it('no Schwarzschimmel anywhere in the catalog', () => {
|
||||
expect(BASE_COLORS.some((e) => e.name === 'Schwarzschimmel')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("GEN-3c: unknown allele displays as '-' (stored as '?')", () => {
|
||||
it("accepts '-' input, stores '?', displays '-'", () => {
|
||||
const g = fromDisplayString('Aa C- DD EE GG Pp spsp rere')
|
||||
expect(g.C).toEqual(['C', '?']) // stored internal contract stays '?'
|
||||
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp rere') // displayed as '-'
|
||||
})
|
||||
it("'?' and '-' inputs are equivalent", () => {
|
||||
expect(toDisplayString(fromDisplayString('Aa C? DD EE GG Pp spsp rere'))).toBe(
|
||||
'Aa C- DD EE GG Pp spsp rere',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => {
|
||||
it('eef with unknown other loci -> Fuchsschimmel (the reported bug case)', () => {
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
||||
'Fuchsschimmel',
|
||||
)
|
||||
})
|
||||
it('ee -> Fuchs family, efef -> a Schimmel (never Unbekannt) even with unknowns', () => {
|
||||
expect(farbschlagFor(fromDisplayString('a- C- D- ee G- P-')).unknown).toBe(false)
|
||||
expect(farbschlagFor(fromDisplayString('A- C- D- efef G- P-')).unknown).toBe(false)
|
||||
})
|
||||
it('unknown allele no longer blocks a match (wildcard, never Unbekannt)', () => {
|
||||
// C unknown must not force Unbekannt — it resolves to SOME named variety.
|
||||
expect(farbschlagFor(fromDisplayString('aa C- DD EE GG PP spsp rere')).unknown).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3d: dominance tiebreak for unknown loci', () => {
|
||||
it('unknown-C reads as full-colour, NOT a c^h/c^chm white', () => {
|
||||
// 'aa C- DD EE GG PP' -> Schwarz (dominant C reading), never PEW/Hermelin/Himalaya.
|
||||
const name = genotypeToFarbschlag(fromDisplayString('aa C- DD EE GG PP spsp rere'))
|
||||
expect(name).toBe('Schwarz')
|
||||
expect(['Pink Eyed White (PEW)', 'Hermelin', 'Himalaya']).not.toContain(name)
|
||||
})
|
||||
|
||||
it('unknown second marker allele defaults UNMARKED, not Schecke (marker-aware)', () => {
|
||||
// 'sp-' (one sp + unknown) -> sp/sp -> no Schecke (naive most-dominant would wrongly add it).
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD EE GG PP sp- rere'))).toBe('Agouti')
|
||||
})
|
||||
|
||||
it('still: eef with unknowns -> Fuchsschimmel (family pin unaffected by tiebreak)', () => {
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
||||
'Fuchsschimmel',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3e: C-locus colourpoint naming', () => {
|
||||
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||||
|
||||
it('A- + cchm/cchm -> CP-<base colour> (CP-Silberagouti example)', () => {
|
||||
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
|
||||
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
||||
})
|
||||
|
||||
it('A- + cchm/ch -> CP-<base colour>', () => {
|
||||
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
||||
})
|
||||
|
||||
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
||||
expect(name('aa cchmcchm DD EE GG PP spsp rere')).toBe('Marder')
|
||||
expect(name('aa cchmch DD EE GG PP spsp rere')).toBe('Siam')
|
||||
expect(name('aa cchmcchm DD EE gg PP spsp rere')).toBe('Zobel')
|
||||
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
|
||||
})
|
||||
|
||||
it('chch stays Hermelin (aa) / Himalaya (A-); full C => no CP', () => {
|
||||
expect(name('aa chch DD EE GG PP spsp rere')).toBe('Hermelin')
|
||||
expect(name('AA chch DD EE GG PP spsp rere')).toBe('Himalaya')
|
||||
expect(name('AA CC DD EE GG PP spsp rere')).toBe('Agouti') // one full C -> no colourpoint
|
||||
})
|
||||
|
||||
it('colourpoint composes with the Schecke modifier', () => {
|
||||
expect(name('aa cchmcchm DD EE GG PP Spsp rere')).toBe('Marder Schecke')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her live data)', () => {
|
||||
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||||
const has = (n: string) => BASE_COLORS.some((e) => e.name === n)
|
||||
|
||||
it('every CP- name her data uses exists as a catalog row (import name-match)', () => {
|
||||
for (const n of [
|
||||
'CP-Agouti', 'CP-Silberagouti', 'CP-Algierfuchs', 'CP-Polarfuchs',
|
||||
'CP-Fuchs', 'CP-Fuchs-Hell', 'CP-Blaufuchs', 'CP-Orangeschimmel',
|
||||
'Marder', 'Siam', 'Zobel', 'Zobel-Hell',
|
||||
]) {
|
||||
expect(has(n)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('the removed portal suffix-names are gone (collapsed)', () => {
|
||||
for (const n of [
|
||||
'Agouti CP', 'Agouti CP-Hell', 'Silberagouti CP', 'Algierfuchs CP',
|
||||
'Polarfuchs CP', 'Blaufuchs CP', 'Kohlfuchs CP', 'Kohlfuchsschimmel CP',
|
||||
'Marder dd', 'Zobel dd', 'Siam (Marder-Hell)', 'Agouti dd CP',
|
||||
]) {
|
||||
expect(has(n)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('the engine computes the agouti CP- names her data uses (E-family fallback covers fox/dilute)', () => {
|
||||
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
||||
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
|
||||
expect(name('AA cchmcchm DD ee GG PP spsp rere')).toBe('CP-Algierfuchs')
|
||||
expect(name('AA cchmcchm DD ee gg PP spsp rere')).toBe('CP-Polarfuchs')
|
||||
// dd agouti fox has no dedicated base -> the eFamily fallback yields 'CP-Fuchs'.
|
||||
expect(name('AA cchmcchm dd ee GG PP spsp rere')).toBe('CP-Fuchs')
|
||||
// A- cchm efef -> CP-Orangeschimmel (Schimmel base under full C).
|
||||
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
||||
})
|
||||
|
||||
it('het cchm/ch colourpoints (Siam, Zobel-Hell) resolve to their own names', () => {
|
||||
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
|
||||
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
|
||||
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
|
||||
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,9 +20,15 @@
|
||||
* meta rows dropped, 17 matched the frozen names). Genotypes normalized from
|
||||
* portal notation (c[chm]->cchm, c[h]->ch, e[f]->ef, '-'/'--' = unknown).
|
||||
*/
|
||||
import { LOCUS_ORDER, type LocusKey } from './loci'
|
||||
import { makeGenotype, toDisplayString, wildType, type AllelePair, type Genotype } from './genotype'
|
||||
import { phenotypeTokens, type PhenotypeTokens } from './phenotype'
|
||||
import { LOCI, LOCUS_ORDER, dominantAllele, type LocusKey } from './loci'
|
||||
import {
|
||||
makeGenotype,
|
||||
toDisplayString,
|
||||
wildType,
|
||||
WILDCARD,
|
||||
type AllelePair,
|
||||
type Genotype,
|
||||
} from './genotype'
|
||||
|
||||
export interface FarbschlagEntry {
|
||||
/** German variety name. FROZEN for the original 18 (DB keys). */
|
||||
@@ -41,12 +47,14 @@ export interface FarbschlagEntry {
|
||||
* portal collision group.
|
||||
*/
|
||||
export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
||||
// ── Frozen 18 (names are DB-key contract; do not rename) ──
|
||||
// ── Frozen names (DB-key contract; do not rename) ──
|
||||
// GEN-3a: 'Schwarzschimmel' REMOVED (breeder C5: no such variety; efef base is
|
||||
// Orangeschimmel — see the GEN-2 block below). This was an authorized exception
|
||||
// to the frozen-name rule; the ColorVariety seed drops it too.
|
||||
{ name: 'Pink Eyed White (PEW)', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' }, image: 'rotaugen-weiss-pew-d-sep-e-sep.jpg' },
|
||||
{ name: 'Hermelin', english: 'Dark Tailed White', tokens: { A: 'a', C: 'ch', D: 'D', P: 'P' }, image: 'hermelin.jpeg' },
|
||||
{ name: 'Himalaya', english: 'Himalayan', tokens: { A: 'A', C: 'ch', D: 'D', P: 'P' }, image: 'himalaya.jpg' },
|
||||
{ name: 'Zobel', english: 'Sable', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel.jpeg' },
|
||||
{ name: 'Schwarzschimmel', english: 'Black Roan', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' } },
|
||||
{ name: 'Rotaugenschimmel', english: 'Red-Eyed Roan', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' }, image: 'rotaugen-schimmel.jpg' },
|
||||
{ name: 'Agouti', english: 'Golden Agouti', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-mit-erklaerung-der-genloci.JPG' },
|
||||
{ name: 'Schwarz', english: 'Black', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'schwarz.jpg' },
|
||||
@@ -68,55 +76,58 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
||||
{ name: 'C-Separator', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'g', P: 'p' }, image: 'c-separator.jpg' },
|
||||
{ name: 'Elfenbein', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'g', P: 'p' }, image: 'elfenbein.jpg' },
|
||||
{ name: 'Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs.jpg' },
|
||||
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' },
|
||||
{ name: 'Siam (Marder-Hell)', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' },
|
||||
{ name: 'Polarfuchs', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs.jpg' },
|
||||
{ name: 'Saphir', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' },
|
||||
{ name: 'Schimmel (Orangeschimmel)', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
|
||||
// GEN-3a: efef base (otherwise wild C/D/G/P) = Orangeschimmel (breeder C5).
|
||||
{ name: 'Orangeschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
|
||||
{ name: 'Topas', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'topas.jpg' },
|
||||
{ name: 'Platin-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin-hell.jpg' },
|
||||
{ name: 'Agouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' },
|
||||
{ name: 'Silberagouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
|
||||
{ name: 'Kohlfuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
|
||||
{ name: 'Anthrazit dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' },
|
||||
{ name: 'Agouti CP-Hell', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp-hell.JPG' },
|
||||
{ name: 'Blaufuchs CP', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'blaufuchs-cp.jpg' },
|
||||
{ name: 'Polarfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'polarfuchsschimmel.jpg' },
|
||||
// GEN-3a: efef gg base = Silberschimmel (breeder C5) — listed before the
|
||||
// A-specific Polarfuchsschimmel so the canonical efef-gg reverse-matches here.
|
||||
{ name: 'Silberschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'silberschimmel.jpg' },
|
||||
{ name: 'Polarfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'polarfuchsschimmel.jpg' },
|
||||
{ name: 'Algierfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'algierfuchsschimmel.jpg' },
|
||||
{ name: 'Polarfuchs-Hell CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-hell-cp.jpg' },
|
||||
{ name: 'Kohlfuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'kohlfuchsschimmel.jpg' },
|
||||
{ name: 'Blaufuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'blaufuchsschimmel.jpg' },
|
||||
{ name: 'Kohlfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell.jpg' },
|
||||
{ name: 'Goldfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'goldfuchs-hell.jpg' },
|
||||
{ name: 'Goldfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' }, image: 'goldfuchsschimmel.jpg' },
|
||||
{ name: 'Gold-Hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'gold-hell.jpg' },
|
||||
{ name: 'Siam (Marder-Hell) dd', tokens: { A: 'a', C: 'cchm', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell-dd.jpg' },
|
||||
{ name: 'Marder dd', tokens: { A: 'a', C: 'cchm', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'marder-dd.jpg' },
|
||||
{ name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' },
|
||||
{ name: 'Silberagouti dd CP', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd-cp.jpg' },
|
||||
{ name: 'Silberagouti-Hell dd CP', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-hell-dd-cp.jpg' },
|
||||
{ name: 'Agouti dd CP', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd-cp.jpg' },
|
||||
{ name: 'Agouti-Hell dd CP', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-hell-dd-cp.jpg' },
|
||||
{ name: 'Blaufuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'blaufuchs-hell.jpeg' },
|
||||
{ name: 'Rotfuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' }, image: 'rotfuchsschimmel.jpg' },
|
||||
{ name: 'Polarfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-hell.jpeg' },
|
||||
{ name: 'Kohlfuchsschimmel, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'kohlfuchsschimmel-hell.jpg' },
|
||||
{ name: 'Rotfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs-hell.jpg' },
|
||||
{ name: 'Zobel dd', tokens: { A: 'a', C: 'cchm', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'zobel-dd.jpg' },
|
||||
{ name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' },
|
||||
{ name: 'Kohlfuchs CP', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-cp.jpg' },
|
||||
{ name: 'Algierfuchs CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
|
||||
{ name: 'Silberagouti CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
|
||||
{ name: 'Agouti CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' },
|
||||
{ name: 'Algierfuchs-Hell CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell-cp.jpg' },
|
||||
{ name: 'Kohlfuchs,hell CP', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-cp.jpg' },
|
||||
{ name: 'Polarfuchs CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
|
||||
{ name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' },
|
||||
{ name: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
|
||||
{ name: 'Zobel-Hell dd', tokens: { A: 'a', C: 'cchm', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell-dd.jpg' },
|
||||
{ name: 'Kohlfuchsschimmel CP', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'kohlfuchsschimmel-cp.JPG' },
|
||||
{ name: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
|
||||
|
||||
// ── GEN-3f: c^chm colourpoint varieties, reconciled to the breeder's CP- naming ──
|
||||
// The merged GEN-3e colourpointName() rule is authoritative: aa points are the
|
||||
// marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell — E and D irrelevant);
|
||||
// A- points take the 'CP-<base colour>' prefix and the '-Hell' shade variants
|
||||
// collapse (other loci irrelevant for the CP prefix). These names == the strings
|
||||
// in her live data (god: extract animals.json) so the re-import name-matches and
|
||||
// the Farbschlag mismatch hint stops. The het cchm/ch points (Siam, Zobel-Hell,
|
||||
// CP-Fuchs-Hell) use the 'cchm/ch' pair token. The agouti fox/dilute points
|
||||
// (CP-Fuchs/CP-Blaufuchs) resolve through the engine's E-family fallback to
|
||||
// 'CP-Fuchs'; their distinct dropdown names remain for hand-pick + import match.
|
||||
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' },
|
||||
{ name: 'Siam', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' },
|
||||
{ name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' },
|
||||
{ name: 'CP-Agouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' },
|
||||
{ name: 'CP-Silberagouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
|
||||
{ name: 'CP-Algierfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
|
||||
{ name: 'CP-Polarfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
|
||||
{ name: 'CP-Fuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
||||
{ name: 'CP-Fuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
||||
{ name: 'CP-Blaufuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'g', P: 'P' } },
|
||||
{ name: 'CP-Orangeschimmel', tokens: { C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' } },
|
||||
]
|
||||
|
||||
export const UNKNOWN_FARBSCHLAG = 'Unbekannter Farbschlag'
|
||||
@@ -130,26 +141,108 @@ export interface FarbschlagMatch {
|
||||
readonly unknown: boolean
|
||||
}
|
||||
|
||||
function matches(tokens: PhenotypeTokens, entry: FarbschlagEntry): boolean {
|
||||
/**
|
||||
* Expressed token at a locus. GEN-3d: an UNKNOWN allele ('?') is resolved to the
|
||||
* MOST-DOMINANT allele of the locus (the safer default) rather than acting as a
|
||||
* match-anything wildcard — so an unknown-C animal reads as full-colour 'C', not
|
||||
* a c^h/c^chm colourpoint white. The E locus stays PAIR-aware so the Fuchs/
|
||||
* Schimmel family is distinguishable: ee->'e', e/ef->'eef', ef/ef->'ef'.
|
||||
* (The Fuchs/Schimmel FAMILY for unknown-E is still handled by eFamily on the
|
||||
* raw pair, which runs before this.)
|
||||
*/
|
||||
function locusToken(g: Genotype, locus: LocusKey): string {
|
||||
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the
|
||||
// colour loci (unknown-C => full-colour 'C', not a white), but the recessive
|
||||
// UNMARKED allele for the spotting/rex markers (unknown-Sp must NOT imply Schecke).
|
||||
const alleles = LOCI[locus].alleles
|
||||
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
|
||||
const fallback = isMarker ? alleles[alleles.length - 1] : alleles[0]
|
||||
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
|
||||
if (locus === 'E') {
|
||||
if (x === y) return x // ee->'e', efef->'ef', EE->'E'
|
||||
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'eef'
|
||||
return dominantAllele('E', x, y) // E/ef, E/e -> 'E'
|
||||
}
|
||||
return dominantAllele(locus, x, y)
|
||||
}
|
||||
|
||||
function matches(g: Genotype, entry: FarbschlagEntry): boolean {
|
||||
return (Object.keys(entry.tokens) as LocusKey[]).every(
|
||||
(locus) => tokens[locus] === entry.tokens[locus],
|
||||
(locus) => locusToken(g, locus) === entry.tokens[locus],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* GEN-3c family fallback: the E locus alone names the Fuchs/Schimmel family even
|
||||
* when other loci are unknown (so genotypes never fall through to "Unbekannt").
|
||||
* ee -> Fuchs | e/ef -> Fuchsschimmel | ef/ef -> Schimmel | e/? -> Fuchs (for now)
|
||||
* Returns null when E is dominant (full colour) or fully unknown.
|
||||
*/
|
||||
function eFamily(g: Genotype): string | null {
|
||||
const [x, y] = g.E
|
||||
if (x === 'e' && y === 'e') return 'Fuchs'
|
||||
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'Fuchsschimmel'
|
||||
if (x === 'ef' && y === 'ef') return 'Schimmel'
|
||||
if ((x === 'e' || y === 'e') && (x === WILDCARD || y === WILDCARD)) return 'Fuchs'
|
||||
return null
|
||||
}
|
||||
|
||||
/** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */
|
||||
/** Resolve an allele pair to concrete alleles, defaulting unknown to wild-type. */
|
||||
function resolvedPair(g: Genotype, locus: LocusKey): [string, string] {
|
||||
const alleles = LOCI[locus].alleles
|
||||
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
|
||||
const fallback = isMarker ? alleles[alleles.length - 1] : alleles[0]
|
||||
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
|
||||
return [x, y]
|
||||
}
|
||||
|
||||
/** Base colour name (no modifiers, no colourpoint prefix), via E-family + matches. */
|
||||
function baseColourFor(g: Genotype): string | null {
|
||||
const family = eFamily(g)
|
||||
const base = family
|
||||
? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null)
|
||||
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
|
||||
return base?.name ?? family
|
||||
}
|
||||
|
||||
/**
|
||||
* GEN-3e: the C-locus colourpoint NAMING transform (breeder-authoritative).
|
||||
* Returns the colourpoint name, or null when it doesn't apply (full C present,
|
||||
* or chch — which the base matcher names Hermelin/Himalaya, preserving both).
|
||||
* aa cchm/cchm -> Marder | aa cchm/ch -> Siam
|
||||
* aa cchm/cchm gg -> Zobel | aa cchm/ch gg -> Zobel-Hell
|
||||
* A- cchm/cchm | cchm/ch -> CP-<base colour> (base computed as if C were full)
|
||||
*/
|
||||
function colourpointName(g: Genotype): string | null {
|
||||
const c = resolvedPair(g, 'C')
|
||||
if (c.includes('C')) return null // a full C allele => full colour, no CP
|
||||
if (c[0] === 'ch' && c[1] === 'ch') return null // chch -> base matcher (Hermelin/Himalaya)
|
||||
// Remaining: cchm/cchm or cchm/ch (colourpoint, no full C, not chch).
|
||||
const bothCchm = c[0] === 'cchm' && c[1] === 'cchm'
|
||||
const agouti = resolvedPair(g, 'A').includes('A')
|
||||
if (!agouti) {
|
||||
const [g1, g2] = resolvedPair(g, 'G')
|
||||
const grey = g1 === 'g' && g2 === 'g'
|
||||
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
||||
return bothCchm ? 'Marder' : 'Siam'
|
||||
}
|
||||
// A- colourpoint -> CP-<base colour>, base as if C were full.
|
||||
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
||||
return base ? `CP-${base}` : null
|
||||
}
|
||||
|
||||
export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
||||
const tokens = phenotypeTokens(g)
|
||||
const base = BASE_COLORS.find((e) => matches(tokens, e)) ?? null
|
||||
|
||||
const modifiers: string[] = []
|
||||
if (tokens.Sp === 'Sp') modifiers.push('Schecke')
|
||||
if (tokens.Re === 'Re') modifiers.push('Rex')
|
||||
if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke')
|
||||
if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex')
|
||||
|
||||
if (!base) {
|
||||
const baseName = colourpointName(g) ?? baseColourFor(g)
|
||||
if (!baseName) {
|
||||
return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }
|
||||
}
|
||||
const name = [base.name, ...modifiers].join(' ')
|
||||
return { name, base, unknown: false }
|
||||
const name = [baseName, ...modifiers].join(' ')
|
||||
return { name, base: null, unknown: false }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,7 +267,14 @@ export function representativeGenotype(entry: FarbschlagEntry): Genotype {
|
||||
const out = {} as Record<LocusKey, AllelePair>
|
||||
for (const locus of LOCUS_ORDER) {
|
||||
const token = entry.tokens[locus]
|
||||
out[locus] = token ? [token, token] : base[locus]
|
||||
if (!token) {
|
||||
out[locus] = base[locus]
|
||||
continue
|
||||
}
|
||||
// GEN-3f: a token may encode a HETEROZYGOUS pair as "x/y" (e.g. the het
|
||||
// colourpoints Siam/Zobel-Hell use C: 'cchm/ch'); otherwise it's homozygous.
|
||||
const [a, b] = token.includes('/') ? (token.split('/') as [string, string]) : [token, token]
|
||||
out[locus] = [a, b]
|
||||
}
|
||||
return makeGenotype(out)
|
||||
}
|
||||
|
||||
@@ -1,440 +1,381 @@
|
||||
[
|
||||
{
|
||||
"name": "Pink Eyed White (PEW)",
|
||||
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
|
||||
"sortOrder": 0,
|
||||
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Hermelin",
|
||||
"canonicalGenotype": "aa chch DD EE GG PP spsp rere",
|
||||
"sortOrder": 1,
|
||||
"image": "hermelin.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Himalaya",
|
||||
"canonicalGenotype": "AA chch DD EE GG PP spsp rere",
|
||||
"sortOrder": 2,
|
||||
"image": "himalaya.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Zobel",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 3,
|
||||
"image": "zobel.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Schwarzschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 4,
|
||||
"image": null
|
||||
},
|
||||
{
|
||||
"name": "Rotaugenschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 5,
|
||||
"image": "rotaugen-schimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti",
|
||||
"canonicalGenotype": "AA CC DD EE GG PP spsp rere",
|
||||
"sortOrder": 6,
|
||||
"image": "agouti-mit-erklaerung-der-genloci.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Schwarz",
|
||||
"canonicalGenotype": "aa CC DD EE GG PP spsp rere",
|
||||
"sortOrder": 7,
|
||||
"image": "schwarz.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti",
|
||||
"canonicalGenotype": "AA CC DD EE gg PP spsp rere",
|
||||
"sortOrder": 8,
|
||||
"image": "silberagouti.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Anthrazit",
|
||||
"canonicalGenotype": "aa CC DD EE gg PP spsp rere",
|
||||
"sortOrder": 9,
|
||||
"image": "anthrazit.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 10,
|
||||
"image": "algierfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blau",
|
||||
"canonicalGenotype": "aa CC dd EE GG PP spsp rere",
|
||||
"sortOrder": 11,
|
||||
"image": "blau-schwarz-dd.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Gold",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 12,
|
||||
"image": "gold.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Platin",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 13,
|
||||
"image": "platin.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 14,
|
||||
"image": "goldfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 15,
|
||||
"image": "rotfuchs.JPG"
|
||||
},
|
||||
{
|
||||
"name": "dd Gold",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 16,
|
||||
"image": "gold-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "dd Platin",
|
||||
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 17,
|
||||
"image": "platin-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Altweiss (REW)",
|
||||
"canonicalGenotype": "aa CC DD EE gg pp spsp rere",
|
||||
"sortOrder": 18,
|
||||
"image": "altweiss-rew.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Apricot (Blassfuchs)",
|
||||
"canonicalGenotype": "AA CC DD ee gg pp spsp rere",
|
||||
"sortOrder": 19,
|
||||
"image": "apricot-blassfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 20,
|
||||
"image": "blaufuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "C-Separator",
|
||||
"canonicalGenotype": "aa CC DD ee gg pp spsp rere",
|
||||
"sortOrder": 21,
|
||||
"image": "c-separator.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Elfenbein",
|
||||
"canonicalGenotype": "AA CC DD EE gg pp spsp rere",
|
||||
"sortOrder": 22,
|
||||
"image": "elfenbein.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 23,
|
||||
"image": "kohlfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Marder",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 24,
|
||||
"image": "marder.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Siam (Marder-Hell)",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 25,
|
||||
"image": "siam-marder-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 26,
|
||||
"image": "polarfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Saphir",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 27,
|
||||
"image": "saphir.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Schimmel (Orangeschimmel)",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 28,
|
||||
"image": "schimmel-orangeschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Topas",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 29,
|
||||
"image": "topas.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Platin-Hell",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 30,
|
||||
"image": "platin-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti dd",
|
||||
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
||||
"sortOrder": 31,
|
||||
"image": "agouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti dd",
|
||||
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
||||
"sortOrder": 32,
|
||||
"image": "silberagouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs dd",
|
||||
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
||||
"sortOrder": 33,
|
||||
"image": "kohlfuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Anthrazit dd",
|
||||
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
||||
"sortOrder": 34,
|
||||
"image": "anthrazit-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti CP-Hell",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 35,
|
||||
"image": "agouti-cp-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs CP",
|
||||
"canonicalGenotype": "aa cchmcchm DD ee gg PP spsp rere",
|
||||
"sortOrder": 36,
|
||||
"image": "blaufuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 37,
|
||||
"image": "polarfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 38,
|
||||
"image": "silberschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 39,
|
||||
"image": "algierfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs-Hell CP",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||
"sortOrder": 40,
|
||||
"image": "polarfuchs-hell-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 41,
|
||||
"image": "kohlfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 42,
|
||||
"image": "blaufuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 43,
|
||||
"image": "kohlfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 44,
|
||||
"image": "goldfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 45,
|
||||
"image": "goldfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Gold-Hell",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 46,
|
||||
"image": "gold-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Siam (Marder-Hell) dd",
|
||||
"canonicalGenotype": "aa cchmcchm dd EE GG PP spsp rere",
|
||||
"sortOrder": 47,
|
||||
"image": "siam-marder-hell-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Marder dd",
|
||||
"canonicalGenotype": "aa cchmcchm dd EE GG PP spsp rere",
|
||||
"sortOrder": 48,
|
||||
"image": "marder-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Zobel-Hell",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 49,
|
||||
"image": "zobel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti dd CP",
|
||||
"canonicalGenotype": "AA cchmcchm dd EE gg PP spsp rere",
|
||||
"sortOrder": 50,
|
||||
"image": "silberagouti-dd-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti-Hell dd CP",
|
||||
"canonicalGenotype": "AA cchmcchm dd EE gg PP spsp rere",
|
||||
"sortOrder": 51,
|
||||
"image": "silberagouti-hell-dd-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti dd CP",
|
||||
"canonicalGenotype": "AA cchmcchm dd EE GG PP spsp rere",
|
||||
"sortOrder": 52,
|
||||
"image": "agouti-dd-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti-Hell dd CP",
|
||||
"canonicalGenotype": "AA cchmcchm dd EE GG PP spsp rere",
|
||||
"sortOrder": 53,
|
||||
"image": "agouti-hell-dd-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 54,
|
||||
"image": "blaufuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 55,
|
||||
"image": "rotfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 56,
|
||||
"image": "polarfuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel, hell",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 57,
|
||||
"image": "kohlfuchsschimmel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 58,
|
||||
"image": "rotfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Zobel dd",
|
||||
"canonicalGenotype": "aa cchmcchm dd EE gg PP spsp rere",
|
||||
"sortOrder": 59,
|
||||
"image": "zobel-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs-Hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 60,
|
||||
"image": "kohlfuchs-hell-2.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs CP",
|
||||
"canonicalGenotype": "aa cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 61,
|
||||
"image": "kohlfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs CP",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 62,
|
||||
"image": "algierfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti CP",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 63,
|
||||
"image": "silberagouti-cp.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Agouti CP",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 64,
|
||||
"image": "agouti-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs-Hell CP",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 65,
|
||||
"image": "algierfuchs-hell-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs,hell CP",
|
||||
"canonicalGenotype": "aa cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 66,
|
||||
"image": "kohlfuchs-hell-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs CP",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||
"sortOrder": 67,
|
||||
"image": "polarfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 68,
|
||||
"image": "algierfuchs-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Topas dd",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 69,
|
||||
"image": "topas-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Zobel-Hell dd",
|
||||
"canonicalGenotype": "aa cchmcchm dd EE gg PP spsp rere",
|
||||
"sortOrder": 70,
|
||||
"image": "zobel-hell-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel CP",
|
||||
"canonicalGenotype": "aa cchmcchm DD efef GG PP spsp rere",
|
||||
"sortOrder": 71,
|
||||
"image": "kohlfuchsschimmel-cp.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs dd",
|
||||
"canonicalGenotype": "aa CC dd ee gg PP spsp rere",
|
||||
"sortOrder": 72,
|
||||
"image": "blaufuchs-dd.jpg"
|
||||
}
|
||||
]
|
||||
[
|
||||
{
|
||||
"name": "Pink Eyed White (PEW)",
|
||||
"english": "Pink Eyed White",
|
||||
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
|
||||
"sortOrder": 0,
|
||||
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Hermelin",
|
||||
"english": "Dark Tailed White",
|
||||
"canonicalGenotype": "aa chch DD EE GG PP spsp rere",
|
||||
"sortOrder": 1,
|
||||
"image": "hermelin.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Himalaya",
|
||||
"english": "Himalayan",
|
||||
"canonicalGenotype": "AA chch DD EE GG PP spsp rere",
|
||||
"sortOrder": 2,
|
||||
"image": "himalaya.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Zobel",
|
||||
"english": "Sable",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 3,
|
||||
"image": "zobel.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Rotaugenschimmel",
|
||||
"english": "Red-Eyed Roan",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 4,
|
||||
"image": "rotaugen-schimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti",
|
||||
"english": "Golden Agouti",
|
||||
"canonicalGenotype": "AA CC DD EE GG PP spsp rere",
|
||||
"sortOrder": 5,
|
||||
"image": "agouti-mit-erklaerung-der-genloci.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Schwarz",
|
||||
"english": "Black",
|
||||
"canonicalGenotype": "aa CC DD EE GG PP spsp rere",
|
||||
"sortOrder": 6,
|
||||
"image": "schwarz.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti",
|
||||
"english": "Grey Agouti",
|
||||
"canonicalGenotype": "AA CC DD EE gg PP spsp rere",
|
||||
"sortOrder": 7,
|
||||
"image": "silberagouti.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Anthrazit",
|
||||
"english": "Slate",
|
||||
"canonicalGenotype": "aa CC DD EE gg PP spsp rere",
|
||||
"sortOrder": 8,
|
||||
"image": "anthrazit.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs",
|
||||
"english": "Dark-Eyed Honey",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 9,
|
||||
"image": "algierfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blau",
|
||||
"english": "Blue",
|
||||
"canonicalGenotype": "aa CC dd EE GG PP spsp rere",
|
||||
"sortOrder": 10,
|
||||
"image": "blau-schwarz-dd.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Gold",
|
||||
"english": "Argente Golden",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 11,
|
||||
"image": "gold.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Platin",
|
||||
"english": "Lilac",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 12,
|
||||
"image": "platin.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs",
|
||||
"english": "Yellow Fox",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 13,
|
||||
"image": "goldfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs",
|
||||
"english": "Argente Nutmeg",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 14,
|
||||
"image": "rotfuchs.JPG"
|
||||
},
|
||||
{
|
||||
"name": "dd Gold",
|
||||
"english": "dd Argente Golden",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 15,
|
||||
"image": "gold-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "dd Platin",
|
||||
"english": "dd Lilac",
|
||||
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 16,
|
||||
"image": "platin-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Altweiss (REW)",
|
||||
"canonicalGenotype": "aa CC DD EE gg pp spsp rere",
|
||||
"sortOrder": 17,
|
||||
"image": "altweiss-rew.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Apricot (Blassfuchs)",
|
||||
"canonicalGenotype": "AA CC DD ee gg pp spsp rere",
|
||||
"sortOrder": 18,
|
||||
"image": "apricot-blassfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 19,
|
||||
"image": "blaufuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "C-Separator",
|
||||
"canonicalGenotype": "aa CC DD ee gg pp spsp rere",
|
||||
"sortOrder": 20,
|
||||
"image": "c-separator.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Elfenbein",
|
||||
"canonicalGenotype": "AA CC DD EE gg pp spsp rere",
|
||||
"sortOrder": 21,
|
||||
"image": "elfenbein.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 22,
|
||||
"image": "kohlfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 23,
|
||||
"image": "polarfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Saphir",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 24,
|
||||
"image": "saphir.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Orangeschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 25,
|
||||
"image": "schimmel-orangeschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Topas",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 26,
|
||||
"image": "topas.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Platin-Hell",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 27,
|
||||
"image": "platin-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti dd",
|
||||
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
||||
"sortOrder": 28,
|
||||
"image": "agouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti dd",
|
||||
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
||||
"sortOrder": 29,
|
||||
"image": "silberagouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs dd",
|
||||
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
||||
"sortOrder": 30,
|
||||
"image": "kohlfuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Anthrazit dd",
|
||||
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
||||
"sortOrder": 31,
|
||||
"image": "anthrazit-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 32,
|
||||
"image": "silberschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 33,
|
||||
"image": "polarfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 34,
|
||||
"image": "algierfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 35,
|
||||
"image": "kohlfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 36,
|
||||
"image": "blaufuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 37,
|
||||
"image": "kohlfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 38,
|
||||
"image": "goldfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 39,
|
||||
"image": "goldfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Gold-Hell",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 40,
|
||||
"image": "gold-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 41,
|
||||
"image": "blaufuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 42,
|
||||
"image": "rotfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 43,
|
||||
"image": "polarfuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel, hell",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 44,
|
||||
"image": "kohlfuchsschimmel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 45,
|
||||
"image": "rotfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs-Hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 46,
|
||||
"image": "kohlfuchs-hell-2.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 47,
|
||||
"image": "algierfuchs-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Topas dd",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 48,
|
||||
"image": "topas-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs dd",
|
||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||
"sortOrder": 49,
|
||||
"image": "blaufuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Marder",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 50,
|
||||
"image": "marder.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Siam",
|
||||
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
|
||||
"sortOrder": 51,
|
||||
"image": "siam-marder-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Zobel-Hell",
|
||||
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
|
||||
"sortOrder": 52,
|
||||
"image": "zobel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 53,
|
||||
"image": "agouti-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 54,
|
||||
"image": "silberagouti-cp.JPG"
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 55,
|
||||
"image": "algierfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||
"sortOrder": 56,
|
||||
"image": "polarfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs",
|
||||
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
||||
"sortOrder": 57
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
||||
"sortOrder": 58
|
||||
},
|
||||
{
|
||||
"name": "CP-Blaufuchs",
|
||||
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
|
||||
"sortOrder": 59
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel",
|
||||
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
|
||||
"sortOrder": 60
|
||||
}
|
||||
]
|
||||
|
||||
@@ -64,18 +64,30 @@ export function makeGenotype(input: Record<LocusKey, AllelePair>): Genotype {
|
||||
export function wildType(): Genotype {
|
||||
const out = {} as Record<LocusKey, AllelePair>
|
||||
for (const locus of LOCUS_ORDER) {
|
||||
// Wild-type is homozygous for the most dominant allele, EXCEPT the
|
||||
// marker loci Sp/Re whose wild form is the recessive (unmarked) allele.
|
||||
// Wild-type is homozygous for the most dominant allele, EXCEPT the marker
|
||||
// loci Sp/Re/Sls whose wild form is the recessive (unmarked) allele.
|
||||
const alleles = LOCI[locus].alleles
|
||||
const a = locus === 'Sp' || locus === 'Re' ? alleles[alleles.length - 1] : alleles[0]
|
||||
const marker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
|
||||
const a = marker ? alleles[alleles.length - 1] : alleles[0]
|
||||
out[locus] = [a, a]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere". */
|
||||
/**
|
||||
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere".
|
||||
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
|
||||
* the colour catalog stay byte-identical; it only appears for WP/Sls carriers
|
||||
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
|
||||
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
|
||||
* convention) — e.g. ['C','?'] renders "C-".
|
||||
*/
|
||||
export function toDisplayString(g: Genotype): string {
|
||||
return LOCUS_ORDER.map((locus) => g[locus][0] + g[locus][1]).join(' ')
|
||||
return LOCUS_ORDER.filter(
|
||||
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
||||
)
|
||||
.map((locus) => (g[locus][0] + g[locus][1]).replace(/\?/g, '-'))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/** Stable JSON-storable object (already the in-memory shape; returned as a copy). */
|
||||
@@ -114,18 +126,81 @@ function splitToken(token: string): [string, string] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a display string ("Aa CC Dd EE GG Pp Spsp rere") back into a Genotype.
|
||||
* Tokens may be given in any order; each token must belong to a distinct locus.
|
||||
* GEN-3a: tokens that are NOT genotype loci — health/provenance metadata that may
|
||||
* appear in a herd-book genotype string. Stripped on parse (see extractGenotypeFlags).
|
||||
* - dea/Dea/taub = deafness flag (after spsp); DP/DarkPatch = non-Mendelian patch flag
|
||||
* - WFNZ/RV/GV = provenance/breeding-method annotations
|
||||
*/
|
||||
const FLAG_TOKENS = new Set(['DP', 'DarkPatch', 'dea', 'Dea', 'taub', 'WFNZ', 'RV', 'GV'])
|
||||
|
||||
/**
|
||||
* Normalize one whitespace-token to canonical allele symbols, or null if it is a
|
||||
* non-genotype flag/metadata token (to be stripped):
|
||||
* - Uw/uw -> G/g (international Underwhite == German Grey locus)
|
||||
* - S(l)/s(l) -> Sl/sl (second spotting locus notation)
|
||||
* - WP -> Slsl (WP is the visible S(l)s(l) heterozygote)
|
||||
*/
|
||||
function normalizeToken(tok: string): string | null {
|
||||
if (FLAG_TOKENS.has(tok)) return null
|
||||
let t = tok
|
||||
if (t === 'WP') t = 'Slsl'
|
||||
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
|
||||
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
|
||||
// GEN-3c: '-' is the breeder's UNKNOWN marker on input; store internally as '?'
|
||||
// (the frozen storage contract keeps '?'; only DISPLAY renders '-').
|
||||
t = t.replace(/-/g, '?')
|
||||
return t
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical normalized genotype string (flags/metadata removed, Uw/S(l)/WP
|
||||
* resolved). Exported so the import pipeline (GEN-3b) can mirror this exactly.
|
||||
*/
|
||||
export function normalizeGenotypeString(input: string): string {
|
||||
return input
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map(normalizeToken)
|
||||
.filter((t): t is string => t !== null)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract non-Punkett flags from a raw genotype string: deafness (dea/taub =
|
||||
* deaf, Dea = hearing) and provenance/pattern tags (WFNZ/RV/GV/DP).
|
||||
*/
|
||||
export function extractGenotypeFlags(input: string): { deaf?: boolean; tags: string[] } {
|
||||
const tokens = input.trim().split(/\s+/).filter(Boolean)
|
||||
let deaf: boolean | undefined
|
||||
const tags: string[] = []
|
||||
for (const tok of tokens) {
|
||||
if (tok === 'dea' || tok === 'taub') deaf = true
|
||||
else if (tok === 'Dea') deaf = false
|
||||
else if (tok === 'DP' || tok === 'DarkPatch' || tok === 'WFNZ' || tok === 'RV' || tok === 'GV')
|
||||
tags.push(tok)
|
||||
}
|
||||
return { deaf, tags }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a display string ("Aa CC Dd EE GG Pp Spsp rere [Slsl]") back into a
|
||||
* Genotype. Tokens may be in any order; each must belong to a distinct locus.
|
||||
* Uw/S(l)/WP are normalized and flag/metadata tokens (dea, WFNZ, …) are stripped.
|
||||
* Missing loci default to wild-type.
|
||||
*/
|
||||
export function fromDisplayString(input: string): Genotype {
|
||||
const tokens = input.trim().split(/\s+/).filter(Boolean)
|
||||
const acc = {} as Record<LocusKey, AllelePair>
|
||||
for (const token of tokens) {
|
||||
for (const raw of tokens) {
|
||||
const token = normalizeToken(raw)
|
||||
if (token === null) continue // flag/metadata token — not a locus
|
||||
const [a, b] = splitToken(token)
|
||||
const refAllele = a === WILDCARD ? b : a
|
||||
if (refAllele === WILDCARD) {
|
||||
throw new Error(`Token "${token}" is fully unknown; cannot infer its locus`)
|
||||
// Fully-unknown token ("--"/"??", e.g. a positional placeholder): the locus
|
||||
// can't be inferred — skip it (defaults to wild-type), don't throw.
|
||||
continue
|
||||
}
|
||||
const locus = ALLELE_TO_LOCUS[refAllele]
|
||||
if (!locus) throw new Error(`Unknown allele "${refAllele}" in token "${token}"`)
|
||||
|
||||
@@ -25,6 +25,7 @@ interface LethalRule {
|
||||
|
||||
const LETHAL_RULES: readonly LethalRule[] = [
|
||||
{ locus: 'Sp', allele: 'Sp', kind: 'lethal', warning: GeneticsWarningCode.ScheckeLethal },
|
||||
{ locus: 'Sls', allele: 'Sl', kind: 'lethal', warning: GeneticsWarningCode.SlsLethal },
|
||||
{ locus: 'Re', allele: 'Re', kind: 'semi', warning: GeneticsWarningCode.RexSemiLethal },
|
||||
]
|
||||
|
||||
@@ -67,14 +68,23 @@ export function applyLethality(dist: DistEntry<Genotype>[]): LethalityResult {
|
||||
? survivors
|
||||
: survivors.map((e) => ({ value: e.value, probability: divide(e.probability, survivingMass) }))
|
||||
|
||||
if (lethalMass.num > 0) {
|
||||
warnings.push({
|
||||
code: GeneticsWarningCode.ScheckeLethal,
|
||||
detail: {
|
||||
youngLostFraction: toString(lethalMass),
|
||||
youngLostPercent: Number(((lethalMass.num / lethalMass.den) * 100).toFixed(2)),
|
||||
},
|
||||
})
|
||||
// One lethal warning PER lethal rule that actually removed young (so SpSp ->
|
||||
// ScheckeLethal and S(l)S(l) -> SlsLethal are reported distinctly).
|
||||
for (const rule of LETHAL_RULES) {
|
||||
if (rule.kind !== 'lethal') continue
|
||||
const mass = dist.reduce<Fraction>(
|
||||
(acc, e) => (isHomozygous(e.value, rule.locus, rule.allele) ? add(acc, e.probability) : acc),
|
||||
ZERO,
|
||||
)
|
||||
if (mass.num > 0) {
|
||||
warnings.push({
|
||||
code: rule.warning,
|
||||
detail: {
|
||||
youngLostFraction: toString(mass),
|
||||
youngLostPercent: Number(((mass.num / mass.den) * 100).toFixed(2)),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Semi-lethal: warn if any surviving genotype is homozygous for a semi-lethal allele.
|
||||
@@ -95,5 +105,22 @@ export function applyLethality(dist: DistEntry<Genotype>[]): LethalityResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Superschecke: surviving young carrying BOTH spotting markers (Sp present and
|
||||
// S(l) present) are very-high-white and deafness-prone — info warning.
|
||||
const superMass = distribution.reduce<Fraction>((acc, e) => {
|
||||
const hasSp = e.value.Sp.includes('Sp')
|
||||
const hasSl = e.value.Sls.includes('Sl')
|
||||
return hasSp && hasSl ? add(acc, e.probability) : acc
|
||||
}, ZERO)
|
||||
if (superMass.num > 0) {
|
||||
warnings.push({
|
||||
code: GeneticsWarningCode.SuperscheckeDeaf,
|
||||
detail: {
|
||||
affectedFraction: toString(superMass),
|
||||
affectedPercent: Number(((superMass.num / superMass.den) * 100).toFixed(2)),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return { distribution, warnings }
|
||||
}
|
||||
|
||||
@@ -12,8 +12,12 @@
|
||||
* - de.wikibooks.org/wiki/Die_Rennmaus/_Farbvarianten_und_Farbgenetik
|
||||
*/
|
||||
|
||||
/** Canonical locus keys, in conventional display order. */
|
||||
export const LOCUS_ORDER = ['A', 'C', 'D', 'E', 'G', 'P', 'Sp', 'Re'] as const
|
||||
/**
|
||||
* Canonical locus keys, in conventional display order. Sls (second spotting
|
||||
* locus) is appended LAST so legacy 8-locus genotype strings still parse — a
|
||||
* missing Sls token defaults to wild-type sl/sl.
|
||||
*/
|
||||
export const LOCUS_ORDER = ['A', 'C', 'D', 'E', 'G', 'P', 'Sp', 'Re', 'Sls'] as const
|
||||
export type LocusKey = (typeof LOCUS_ORDER)[number]
|
||||
|
||||
export interface LocusDef {
|
||||
@@ -33,9 +37,12 @@ export interface LocusDef {
|
||||
* E = full extension
|
||||
* ef = Schimmel/roan (progressive whitening)
|
||||
* e = Fox (suppresses eumelanin)
|
||||
* Sp/Re are dominant markers, lethal/semi-lethal when homozygous (see lethality.ts):
|
||||
* Sp = Schecke (checkered); checkered animals are always Spsp, SpSp dies in utero.
|
||||
* Re = Rex (curly coat); rex animals are Re-, ReRe is semi-lethal.
|
||||
* Sp/Re/Sls are dominant markers, lethal/semi-lethal when homozygous (see lethality.ts):
|
||||
* Sp = Schecke (checkered); checkered animals are always Spsp, SpSp dies in utero.
|
||||
* Re = Rex (curly coat); rex animals are Re-, ReRe is semi-lethal.
|
||||
* Sls = second spotting locus (S(l), WP/Minimalschecke). S(l)s(l) het = the WP
|
||||
* phenotype; S(l)S(l) homozygous = lethal (Rumpback/megacolon). Sp + Sls
|
||||
* together => Superschecke (very high white, deafness-prone).
|
||||
*/
|
||||
export const LOCI: Readonly<Record<LocusKey, LocusDef>> = {
|
||||
A: { key: 'A', nameDe: 'Agouti', alleles: ['A', 'a'] },
|
||||
@@ -46,6 +53,7 @@ export const LOCI: Readonly<Record<LocusKey, LocusDef>> = {
|
||||
P: { key: 'P', nameDe: 'Rotaugenaufhellung (Pink-Eye)', alleles: ['P', 'p'] },
|
||||
Sp: { key: 'Sp', nameDe: 'Schecke', alleles: ['Sp', 'sp'] },
|
||||
Re: { key: 'Re', nameDe: 'Rex', alleles: ['Re', 're'] },
|
||||
Sls: { key: 'Sls', nameDe: 'Zweite Scheckung (WP)', alleles: ['Sl', 'sl'] },
|
||||
}
|
||||
|
||||
/** Set of all valid allele symbols, longest-first (for maximal-munch parsing). */
|
||||
|
||||
@@ -10,6 +10,10 @@ export const GeneticsWarningCode = {
|
||||
ScheckeLethal: 'SCHECKE_LETHAL',
|
||||
/** Rex × Rex: ReRe is semi-lethal; reduced viability of homozygous young. */
|
||||
RexSemiLethal: 'REX_SEMI_LETHAL',
|
||||
/** WP × WP: S(l)S(l) is prenatal-lethal (Rumpback/megacolon); fewer live young. */
|
||||
SlsLethal: 'SLS_LETHAL',
|
||||
/** Sp + Sls together -> Superschecke: very high white, deafness-prone (info). */
|
||||
SuperscheckeDeaf: 'SUPERSCHECKE_DEAF',
|
||||
} as const
|
||||
|
||||
export type GeneticsWarningCode =
|
||||
|
||||
@@ -330,6 +330,24 @@ textarea {
|
||||
color: #3a6ea5;
|
||||
}
|
||||
|
||||
/* BESTAND-FILTER: subtle marker for external (non-Bestand) animals. Inline next
|
||||
to the name, so it should not be pushed to the grid edge like the status badge. */
|
||||
.badge--external {
|
||||
justify-self: start;
|
||||
margin-left: 0.4rem;
|
||||
vertical-align: middle;
|
||||
background: #f0f0f0;
|
||||
color: #777;
|
||||
border: 1px dashed #bbb;
|
||||
}
|
||||
|
||||
/* Checkbox-style filter (label text + box on one row), vs. the stacked .field. */
|
||||
.field--check {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.gerbil-card {
|
||||
grid-template-columns: 2fr 1fr 1.5fr 1fr auto;
|
||||
@@ -741,4 +759,4 @@ textarea {
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
245
gerbil-manager-web/src/pages/AnfrageDetailPage.tsx
Normal file
245
gerbil-manager-web/src/pages/AnfrageDetailPage.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* INBOX-1: Anfrage-Detail — Nachricht lesen, Triage (Status / Verwerfen /
|
||||
* Abnehmer zuordnen), KI-Antwortentwurf (INBOX-2) und Versand (INBOX-3).
|
||||
*
|
||||
* - Triage-PUT überschreibt assignedContactId immer → bei jedem Update wird
|
||||
* die aktuelle Zuordnung mitgesendet (Backend-Vertrag, siehe api/requests.ts).
|
||||
* - KI entwirft NUR (human-in-the-loop): Entwurf landet editierbar im
|
||||
* Textfeld; gesendet wird ausschließlich nach Bestätigung.
|
||||
* - 503 AiKeyMissing / MailNotConfigured / MailAuthFailed → deutsche Hinweise
|
||||
* (gleiches Muster wie die Verkaufstext-KI).
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { errorCode } from '../api/client'
|
||||
import {
|
||||
REQUEST_STATUSES,
|
||||
draftReply,
|
||||
getRequest,
|
||||
problemTitle,
|
||||
sendReply,
|
||||
triageRequest,
|
||||
type InboxRequest,
|
||||
type RequestStatus,
|
||||
} from '../api/requests'
|
||||
import { listContactsPaged } from '../api/contacts'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { StatusBadge } from './AnfragenPage'
|
||||
import { formatDateTime } from '../format/labels'
|
||||
import './anfragen.css'
|
||||
|
||||
export default function AnfrageDetailPage() {
|
||||
const t = de.pages.anfragen
|
||||
const td = t.detail
|
||||
const { id = '' } = useParams()
|
||||
|
||||
const request = useApi(() => getRequest(id), [id])
|
||||
const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
|
||||
|
||||
// Antwort-Text: lokaler Entwurf; aus draftReply vorbefüllt, sobald geladen.
|
||||
const [reply, setReply] = useState('')
|
||||
const [replyInitFor, setReplyInitFor] = useState<string | null>(null)
|
||||
if (request.data && replyInitFor !== request.data.id) {
|
||||
setReplyInitFor(request.data.id)
|
||||
setReply(request.data.draftReply ?? '')
|
||||
}
|
||||
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const [hint, setHint] = useState<string | null>(null)
|
||||
|
||||
/** Aktuellen Server-Stand nach einer Mutation übernehmen (ohne Neu-Laden). */
|
||||
const [override, setOverride] = useState<InboxRequest | null>(null)
|
||||
const current = override && override.id === id ? override : request.data
|
||||
|
||||
const triage = useMutation((next: { status?: RequestStatus; assignedContactId: string | null }) =>
|
||||
triageRequest(id, next),
|
||||
)
|
||||
async function applyTriage(next: { status?: RequestStatus; assignedContactId: string | null }) {
|
||||
setNotice(null)
|
||||
const result = await triage.run(next)
|
||||
if (result.ok && current) {
|
||||
setOverride({
|
||||
...current,
|
||||
status: next.status ?? current.status,
|
||||
assignedContactId: next.assignedContactId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const draft = useMutation(() => draftReply(id))
|
||||
async function onDraft() {
|
||||
setHint(null)
|
||||
setNotice(null)
|
||||
const result = await draft.run()
|
||||
if (result.ok) {
|
||||
setOverride(result.value)
|
||||
setReply(result.value.draftReply ?? '')
|
||||
return
|
||||
}
|
||||
const code = errorCode(result.cause)
|
||||
if (code === 'AiKeyMissing') setHint(td.aiKeyMissing)
|
||||
else if (code === 'AiUpstreamError') setHint(td.aiUpstreamError)
|
||||
// sonst: draft.error treibt den Alert
|
||||
}
|
||||
|
||||
const send = useMutation(() => sendReply(id, reply))
|
||||
async function onSend() {
|
||||
setHint(null)
|
||||
setNotice(null)
|
||||
if (reply.trim() === '') {
|
||||
setHint(td.sendEmptyBody)
|
||||
return
|
||||
}
|
||||
if (!window.confirm(td.sendConfirm)) return
|
||||
const result = await send.run()
|
||||
if (result.ok) {
|
||||
setOverride(result.value)
|
||||
setNotice(td.sent)
|
||||
return
|
||||
}
|
||||
const title = problemTitle(result.cause)
|
||||
if (title === 'MailNotConfigured') setHint(t.mailNotConfigured)
|
||||
else if (title === 'MailAuthFailed') setHint(t.mailAuthFailed)
|
||||
}
|
||||
|
||||
if (request.loading) return <p className="muted">{de.common.loading}</p>
|
||||
if (request.error || !current) {
|
||||
return (
|
||||
<section className="page">
|
||||
<p className="muted">{request.error ?? td.notFound}</p>
|
||||
<Link to="/anfragen" className="btn">
|
||||
{td.back}
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const r = current
|
||||
const contactItems = contacts.data?.items ?? []
|
||||
const mutationError = triage.error ?? draft.error ?? send.error
|
||||
|
||||
return (
|
||||
<section className="page anfrage-detail">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>{r.subject ?? td.noSubject}</h2>
|
||||
<StatusBadge status={r.status} />
|
||||
</div>
|
||||
<div className="head-actions">
|
||||
<Link to="/anfragen" className="btn">
|
||||
{td.back}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<dl className="def-list">
|
||||
<div className="def-row">
|
||||
<dt>{td.from}</dt>
|
||||
<dd>{r.fromName ? `${r.fromName} <${r.fromAddress}>` : r.fromAddress}</dd>
|
||||
</div>
|
||||
<div className="def-row">
|
||||
<dt>{td.receivedAt}</dt>
|
||||
<dd>{formatDateTime(r.receivedAt)}</dd>
|
||||
</div>
|
||||
{r.answeredAt && (
|
||||
<div className="def-row">
|
||||
<dt>{td.answeredAt}</dt>
|
||||
<dd>{formatDateTime(r.answeredAt)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<h3>{td.message}</h3>
|
||||
<div className="anfrage-body">{r.bodyText?.trim() ? r.bodyText : td.noBody}</div>
|
||||
|
||||
<h3>{td.triage}</h3>
|
||||
{mutationError && <div className="alert alert--error">{mutationError}</div>}
|
||||
<div className="anfrage-triage">
|
||||
<label className="field">
|
||||
<span>{td.statusLabel}</span>
|
||||
<select
|
||||
value={r.status}
|
||||
disabled={triage.pending}
|
||||
onChange={(e) =>
|
||||
applyTriage({
|
||||
status: e.target.value as RequestStatus,
|
||||
assignedContactId: r.assignedContactId,
|
||||
})
|
||||
}
|
||||
>
|
||||
{REQUEST_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t.statusLabels[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>{td.assignContact}</span>
|
||||
<select
|
||||
value={r.assignedContactId ?? ''}
|
||||
disabled={triage.pending || contacts.loading}
|
||||
onChange={(e) => {
|
||||
const contactId = e.target.value || null
|
||||
// Zuordnung setzt den Status auf „Zugeordnet“ (Beantwortet bleibt).
|
||||
applyTriage({
|
||||
status:
|
||||
contactId && r.status !== 'Answered' ? 'Assigned' : undefined,
|
||||
assignedContactId: contactId,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option value="">{td.assignNone}</option>
|
||||
{contactItems.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{r.status !== 'Abandoned' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger anfrage-abandon"
|
||||
disabled={triage.pending}
|
||||
onClick={() => {
|
||||
if (!window.confirm(td.abandonConfirm)) return
|
||||
applyTriage({ status: 'Abandoned', assignedContactId: r.assignedContactId })
|
||||
}}
|
||||
>
|
||||
{td.abandon}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3>{td.reply}</h3>
|
||||
{notice && <div className="alert">{notice}</div>}
|
||||
{hint && <div className="alert">{hint}</div>}
|
||||
<div className="form anfrage-reply">
|
||||
<textarea
|
||||
className="anfrage-reply__text"
|
||||
placeholder={td.replyPlaceholder}
|
||||
value={reply}
|
||||
onChange={(e) => setReply(e.target.value)}
|
||||
/>
|
||||
<p className="muted anfrage-reply__hint">{td.draftHint}</p>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn" onClick={onDraft} disabled={draft.pending}>
|
||||
{draft.pending ? td.drafting : td.draftButton}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
onClick={onSend}
|
||||
disabled={send.pending}
|
||||
>
|
||||
{send.pending ? td.sending : td.send}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
154
gerbil-manager-web/src/pages/AnfragenPage.tsx
Normal file
154
gerbil-manager-web/src/pages/AnfragenPage.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* INBOX-1: Anfragen-Posteingang — Liste mit Status-Filter (Gridify) und
|
||||
* manuellem Gmail-Abruf („Anfragen abrufen“, POST /api/requests/sync).
|
||||
* Solange Gmail unkonfiguriert ist, antwortet der Sync mit MailNotConfigured
|
||||
* → freundlicher deutscher Hinweis statt Fehler.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import {
|
||||
REQUEST_STATUSES,
|
||||
listRequests,
|
||||
syncRequests,
|
||||
type RequestStatus,
|
||||
} from '../api/requests'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { formatDateTime } from '../format/labels'
|
||||
import { FilterPanel } from '../components/FilterPanel'
|
||||
import './anfragen.css'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function StatusBadge({ status }: { status: RequestStatus }) {
|
||||
return (
|
||||
<span className={`badge anfrage-badge anfrage-badge--${status.toLowerCase()}`}>
|
||||
{de.pages.anfragen.statusLabels[status]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AnfragenPage() {
|
||||
const t = de.pages.anfragen
|
||||
const [status, setStatus] = useState<RequestStatus | ''>('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
|
||||
const requests = useApi(
|
||||
() =>
|
||||
listRequests({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
orderBy: 'receivedAt desc',
|
||||
filter: status === '' ? undefined : `status==${status}`,
|
||||
}),
|
||||
[page, status],
|
||||
)
|
||||
|
||||
const sync = useMutation(() => syncRequests())
|
||||
async function onSync() {
|
||||
setNotice(null)
|
||||
const result = await sync.run()
|
||||
if (!result.ok) return // sync.error treibt den Alert
|
||||
if (result.value.error === 'MailNotConfigured') setNotice(t.mailNotConfigured)
|
||||
else if (result.value.error === 'MailAuthFailed') setNotice(t.mailAuthFailed)
|
||||
else {
|
||||
setNotice(t.syncImported(result.value.imported))
|
||||
if (result.value.imported > 0) requests.reload()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>{t.title}</h2>
|
||||
{requests.data && <p className="muted">{t.countText(requests.data.totalCount)}</p>}
|
||||
</div>
|
||||
<div className="head-actions">
|
||||
<button type="button" className="btn btn--primary" onClick={onSync} disabled={sync.pending}>
|
||||
{sync.pending ? t.syncing : t.sync}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{notice && <div className="alert">{notice}</div>}
|
||||
{sync.error && <div className="alert alert--error">{sync.error}</div>}
|
||||
|
||||
{/* Status-Filter */}
|
||||
<FilterPanel
|
||||
activeCount={status !== '' ? 1 : 0}
|
||||
onReset={() => { setStatus(''); setPage(1) }}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t.detail.statusLabel}</span>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value as RequestStatus | '')
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<option value="">{t.filterAll}</option>
|
||||
{REQUEST_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t.statusLabels[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</FilterPanel>
|
||||
|
||||
{requests.loading && <p className="muted">{de.common.loading}</p>}
|
||||
{requests.error && (
|
||||
<div className="alert alert--error">
|
||||
<span>{requests.error}</span>
|
||||
<button type="button" className="btn" onClick={requests.reload}>
|
||||
{de.common.retry}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requests.data && requests.data.items.length === 0 && (
|
||||
<p className="muted">{status === '' ? t.empty : t.emptyFiltered}</p>
|
||||
)}
|
||||
|
||||
{requests.data && requests.data.items.length > 0 && (
|
||||
<ul className="card-list">
|
||||
{requests.data.items.map((r) => (
|
||||
<li key={r.id}>
|
||||
<Link to={`/anfragen/${r.id}`} className="gerbil-card anfrage-card">
|
||||
<span className="gerbil-card__name">{r.fromName ?? r.fromAddress}</span>
|
||||
<StatusBadge status={r.status} />
|
||||
<span className="anfrage-card__subject">
|
||||
{r.subject ?? de.pages.anfragen.detail.noSubject}
|
||||
</span>
|
||||
<span className="gerbil-card__meta">{formatDateTime(r.receivedAt)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{requests.data && requests.data.totalCount > PAGE_SIZE && (
|
||||
<nav className="pager" aria-label="Seitennavigation">
|
||||
<button type="button" className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
{de.common.previous}
|
||||
</button>
|
||||
<span>
|
||||
{de.common.page} {page} {de.common.of}{' '}
|
||||
{Math.max(1, Math.ceil(requests.data.totalCount / PAGE_SIZE))}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={page >= Math.ceil(requests.data.totalCount / PAGE_SIZE)}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
{de.common.next}
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||
import { fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
|
||||
import FarbschlagImage from '../components/FarbschlagImage'
|
||||
import Charakterbogen from '../components/Charakterbogen'
|
||||
// FEAT-6 (Oscar): Tab-Inhalte + Profilfoto
|
||||
import GerbilHealthTab from '../components/GerbilHealthTab'
|
||||
import GerbilPhotosTab from '../components/GerbilPhotosTab'
|
||||
@@ -43,6 +44,13 @@ export default function GerbilDetailPage() {
|
||||
|
||||
const gerbil = useApi(() => getGerbil(id), [id])
|
||||
const forSale = useMutation(() => updateGerbil(id, { status: 'ForSale' }))
|
||||
// FEAT-14: Charakterbogen (persisted on the Gerbil).
|
||||
const [charTraits, setCharTraits] = useState<string[]>([])
|
||||
const [charNote, setCharNote] = useState('')
|
||||
const [charInit, setCharInit] = useState<string | null>(null)
|
||||
const saveCharacter = useMutation(() =>
|
||||
updateGerbil(id, { characterTraits: charTraits, characterNote: charNote.trim() || null }),
|
||||
)
|
||||
const colorVarieties = useApi(() => listColorVarieties(), [])
|
||||
const enclosures = useApi(() => listEnclosures(), [])
|
||||
const contacts = useApi(() => listContacts(), [])
|
||||
@@ -82,6 +90,13 @@ export default function GerbilDetailPage() {
|
||||
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
|
||||
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
|
||||
|
||||
// Seed the Charakterbogen once from the loaded gerbil (adjust-state-during-render).
|
||||
if (charInit !== g.id) {
|
||||
setCharInit(g.id)
|
||||
setCharTraits(g.characterTraits ?? [])
|
||||
setCharNote(g.characterNote ?? '')
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
<header className="page-head">
|
||||
@@ -89,6 +104,11 @@ export default function GerbilDetailPage() {
|
||||
<GerbilProfilePhoto gerbilId={g.id} />
|
||||
<h2>{g.name}</h2>
|
||||
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
|
||||
{g.isResident === false && (
|
||||
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
|
||||
{de.pages.gerbils.externalBadge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="head-actions">
|
||||
<Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="btn btn--primary">
|
||||
@@ -181,6 +201,28 @@ export default function GerbilDetailPage() {
|
||||
<p className="muted">{t.detail.genotypeNotSet}</p>
|
||||
)}
|
||||
|
||||
<h3>{de.character.sectionTitle}</h3>
|
||||
<Charakterbogen
|
||||
traits={charTraits}
|
||||
note={charNote}
|
||||
onTraitsChange={setCharTraits}
|
||||
onNoteChange={setCharNote}
|
||||
/>
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
disabled={saveCharacter.pending}
|
||||
onClick={async () => {
|
||||
const r = await saveCharacter.run()
|
||||
if (r.ok) gerbil.reload()
|
||||
}}
|
||||
>
|
||||
{saveCharacter.pending ? t.form.saving : de.character.save}
|
||||
</button>
|
||||
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
|
||||
</div>
|
||||
|
||||
<h3 className="visually-hidden">{t.detail.moreData}</h3>
|
||||
<div className="tabs" role="tablist">
|
||||
{(['photos', 'health', 'weight'] as const).map((key) => (
|
||||
|
||||
@@ -24,6 +24,7 @@ interface FormState {
|
||||
receiverContactId: string
|
||||
genotype: string
|
||||
notes: string
|
||||
isResident: boolean
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
@@ -41,6 +42,7 @@ const EMPTY: FormState = {
|
||||
receiverContactId: '',
|
||||
genotype: '',
|
||||
notes: '',
|
||||
isResident: true,
|
||||
}
|
||||
|
||||
function formFromGerbil(g: {
|
||||
@@ -58,6 +60,7 @@ function formFromGerbil(g: {
|
||||
receiverContactId: string | null
|
||||
genotype: string | null
|
||||
notes: string | null
|
||||
isResident?: boolean | null
|
||||
}): FormState {
|
||||
return {
|
||||
name: g.name,
|
||||
@@ -74,6 +77,7 @@ function formFromGerbil(g: {
|
||||
receiverContactId: g.receiverContactId ?? '',
|
||||
genotype: g.genotype ?? '',
|
||||
notes: g.notes ?? '',
|
||||
isResident: g.isResident ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +170,7 @@ export default function GerbilFormPage() {
|
||||
receiverContactId: nn(form.receiverContactId),
|
||||
genotype: nn(form.genotype),
|
||||
notes: nn(form.notes),
|
||||
isResident: form.isResident,
|
||||
}
|
||||
const result = await mutation.run(body)
|
||||
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
|
||||
@@ -355,6 +360,15 @@ export default function GerbilFormPage() {
|
||||
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
||||
</label>
|
||||
|
||||
<label className="field field--check" title={t.form.isResidentHint}>
|
||||
<span>{t.form.isResidentLabel}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isResident}
|
||||
onChange={(e) => set('isResident', e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{mutation.error && <div className="alert alert--error">{mutation.error}</div>}
|
||||
|
||||
<div className="form-actions">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user