feat(deploy): TrueNAS Custom-App + Auto-Deploy, plus aufgelaufene Arbeit
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m11s
CI / Frontend Tests (Node/Vite) (push) Failing after 4m59s
CI / Docker Build & Push (push) Has been skipped
CI / Deploy auf TrueNAS (Custom App) (push) Has been skipped

Deployment:
- custom-app.compose.yaml: self-contained Compose fuer TrueNAS "Custom App"
  (absolute Host-Bind-Pfade, postgres:18, pull_policy always, Port 8090)
- scripts/truenas-deploy.sh: Host-Skript create/redeploy via midclt (App
  bleibt unter Apps sichtbar) inkl. Image-Pull + Health-Check
- ci.yml Deploy-Job: laeuft auf ubuntu-latest-Runner, kopiert Deploy-Dateien
  per SSH auf den NAS-Host und triggert truenas-deploy.sh (statt runs-on goldeye)
- compose.yaml/.env.example: postgres:18 (Locale-Match zur Quell-DB), Port 8090
- .gitignore: .agents/, tools/rag/, deploy/truenas/.env (Secrets/Scratch)

Aufgelaufene Feature-Arbeit (verified/Freeze, Migrationen, Import-Triage):
- GerbilOverride/VerifiedGerbil-Endpoints + GerbilSnapshotService + Tests
- EF-Migrationen (ShowInChronicle, Stillborn, BirthOrder, ManualFlag, DSGVO)
- Frontend VerifizierteTierePage + verified-API + e2e-Spec
- diverse Import-/Triage-Skripte und -Tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 09:19:11 +02:00
parent 84365bba7f
commit 45b8533f18
93 changed files with 20477 additions and 432 deletions

View File

@@ -0,0 +1,307 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Import
{
// ── Canonical, human-readable "Akte" snapshot ────────────────────────────
// Names (not ids) are stored so the golden state stays human-verifiable and robust to
// deterministic-id reseeds. Used for the drift report, the export fixture and the
// regression test. Lists are sorted canonically by the builder so equality is stable.
public sealed record GerbilSnapshot(
SnapshotSelf Self,
SnapshotParent? Father,
SnapshotParent? Mother,
List<SnapshotLitter> Litters);
public sealed record SnapshotSelf(
string Name, string Gender, string? DateOfBirth, string? DateOfDeath, string? CauseOfDeath,
string? GoHomeDate, string? Genotype, string? SpottingType, string? ColorName,
bool IsResident, bool? IsDeaf, bool IsCastrated, string? OriginBreeder,
string? OriginContactName, string? ReceiverContactName, string? Notes,
List<string> CharacterTraits, string? CharacterNote, string? BirthLitterName);
public sealed record SnapshotParent(string Name, string? DateOfBirth, string? Genotype);
public sealed record SnapshotLitter(
string Name, string? Date, string Role, int? TotalBorn, bool ShowInChronicle,
List<SnapshotChild> Children);
public sealed record SnapshotChild(string Name, string? DateOfBirth, string Gender);
/// <summary>One field-level difference between two snapshots (golden ↔ current/raw import).</summary>
public sealed record SnapshotDiff(string Path, string? GoldenValue, string? OtherValue);
/// <summary>
/// Builds the canonical Akte snapshot for a gerbil, the machine-readable freeze map (own fields
/// that "vollständig korrekt" pins), applies an override onto a gerbil, and diffs two snapshots.
/// SHARED by the app (endpoints + ingest freeze) and the regression test so the comparison logic
/// can never drift between the two.
/// </summary>
public static class GerbilSnapshotService
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
/// <summary>Own-field keys that the "vollständig korrekt" freeze pins. Deliberately excludes
/// Status (lifecycle, derived) and EnclosureId (current location) and LitterId/lineage
/// (abstammung is detect-only). Keys match the JSON serialised in <see cref="BuildFreezeObject"/>.</summary>
public static readonly IReadOnlyList<string> FreezeFields = new[]
{
"name", "gender", "dateOfBirth", "dateOfDeath", "causeOfDeath", "goHomeDate",
"genotype", "spottingType", "colorVarietyId", "isResident", "isDeaf", "isCastrated",
"originBreeder", "originContactId", "receiverContactId", "notes",
"characterTraits", "characterNote",
};
// ── Snapshot (human-readable Akte) ───────────────────────────────────
public static async Task<GerbilSnapshot?> BuildSnapshotAsync(ApplicationContext db, Guid gerbilId)
{
var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == gerbilId);
if (g is null) return null;
var colorName = g.ColorVarietyId is { } cv
? await db.ColorVarieties.AsNoTracking().Where(c => c.Id == cv).Select(c => c.Name).FirstOrDefaultAsync()
: null;
var originName = g.OriginContactId is { } oc
? await db.Contacts.AsNoTracking().Where(c => c.Id == oc).Select(c => c.Name).FirstOrDefaultAsync()
: null;
var receiverName = g.ReceiverContactId is { } rc
? await db.Contacts.AsNoTracking().Where(c => c.Id == rc).Select(c => c.Name).FirstOrDefaultAsync()
: null;
// Birth litter → parents.
Litter? birthLitter = g.LitterId is { } lid
? await db.Litters.AsNoTracking().FirstOrDefaultAsync(l => l.Id == lid)
: null;
var father = await BuildParentAsync(db, birthLitter?.FatherId);
var mother = await BuildParentAsync(db, birthLitter?.MotherId);
// Own litters (as parent) + children.
var ownLitters = await db.Litters.AsNoTracking()
.Where(l => l.FatherId == gerbilId || l.MotherId == gerbilId)
.ToListAsync();
var ownLitterIds = ownLitters.Select(l => l.Id).ToList();
var children = ownLitterIds.Count == 0
? new List<Gerbil>()
: await db.Gerbils.AsNoTracking().Where(c => c.LitterId != null && ownLitterIds.Contains(c.LitterId.Value)).ToListAsync();
var litterSnaps = ownLitters
.Select(l => new SnapshotLitter(
l.Name,
Iso(l.Date),
l.FatherId == gerbilId ? "father" : "mother",
l.TotalBorn,
l.ShowInChronicle,
children.Where(c => c.LitterId == l.Id)
.Select(c => new SnapshotChild(c.Name, Iso(c.DateOfBirth), c.Gender.ToString()))
.OrderBy(c => c.DateOfBirth ?? "", StringComparer.Ordinal)
.ThenBy(c => c.Name, StringComparer.Ordinal)
.ThenBy(c => c.Gender, StringComparer.Ordinal)
.ToList()))
.OrderBy(l => l.Date ?? "", StringComparer.Ordinal)
.ThenBy(l => l.Name, StringComparer.Ordinal)
.ToList();
var self = new SnapshotSelf(
g.Name, g.Gender.ToString(), Iso(g.DateOfBirth), Iso(g.DateOfDeath), g.CauseOfDeath,
Iso(g.GoHomeDate), g.Genotype, g.SpottingType, colorName,
g.IsResident, g.IsDeaf, g.IsCastrated, g.OriginBreeder,
originName, receiverName, g.Notes,
(g.CharacterTraits ?? new List<string>()).OrderBy(t => t, StringComparer.Ordinal).ToList(),
g.CharacterNote, birthLitter?.Name);
return new GerbilSnapshot(self, father, mother, litterSnaps);
}
private static async Task<SnapshotParent?> BuildParentAsync(ApplicationContext db, Guid? parentId)
{
if (parentId is not { } pid) return null;
var p = await db.Gerbils.AsNoTracking().Where(x => x.Id == pid)
.Select(x => new { x.Name, x.DateOfBirth, x.Genotype }).FirstOrDefaultAsync();
return p is null ? null : new SnapshotParent(p.Name, Iso(p.DateOfBirth), p.Genotype);
}
public static string SerializeSnapshot(GerbilSnapshot snap) => JsonSerializer.Serialize(snap, Json);
public static GerbilSnapshot? DeserializeSnapshot(string? json) =>
string.IsNullOrWhiteSpace(json) ? null : JsonSerializer.Deserialize<GerbilSnapshot>(json, Json);
// ── Diff (golden ↔ other) ────────────────────────────────────────────
/// <summary>Flat, human-readable field diff between a golden snapshot and another (current
/// DB or raw import) snapshot. Empty = identical.</summary>
public static List<SnapshotDiff> Diff(GerbilSnapshot? golden, GerbilSnapshot? other)
{
var g = golden is null ? new Dictionary<string, string?>() : Flatten(golden);
var o = other is null ? new Dictionary<string, string?>() : Flatten(other);
var diffs = new List<SnapshotDiff>();
foreach (var key in g.Keys.Union(o.Keys).OrderBy(k => k, StringComparer.Ordinal))
{
var gv = g.GetValueOrDefault(key);
var ov = o.GetValueOrDefault(key);
if (!string.Equals(gv, ov, StringComparison.Ordinal))
diffs.Add(new SnapshotDiff(key, gv, ov));
}
return diffs;
}
private static Dictionary<string, string?> Flatten(GerbilSnapshot s)
{
var d = new Dictionary<string, string?>(StringComparer.Ordinal)
{
["name"] = s.Self.Name,
["gender"] = s.Self.Gender,
["dateOfBirth"] = s.Self.DateOfBirth,
["dateOfDeath"] = s.Self.DateOfDeath,
["causeOfDeath"] = s.Self.CauseOfDeath,
["goHomeDate"] = s.Self.GoHomeDate,
["genotype"] = s.Self.Genotype,
["spottingType"] = s.Self.SpottingType,
["colorName"] = s.Self.ColorName,
["isResident"] = s.Self.IsResident.ToString(),
["isDeaf"] = s.Self.IsDeaf?.ToString(),
["isCastrated"] = s.Self.IsCastrated.ToString(),
["originBreeder"] = s.Self.OriginBreeder,
["originContact"] = s.Self.OriginContactName,
["receiverContact"] = s.Self.ReceiverContactName,
["notes"] = s.Self.Notes,
["characterTraits"] = string.Join(", ", s.Self.CharacterTraits),
["characterNote"] = s.Self.CharacterNote,
["birthLitter"] = s.Self.BirthLitterName,
["father"] = FormatParent(s.Father),
["mother"] = FormatParent(s.Mother),
};
foreach (var l in s.Litters)
{
var lk = $"wurf[{l.Name} {l.Date}]";
d[$"{lk}.rolle"] = l.Role;
d[$"{lk}.geboren"] = l.TotalBorn?.ToString();
d[$"{lk}.inChronik"] = l.ShowInChronicle.ToString();
foreach (var c in l.Children)
d[$"{lk}.kind[{c.Name} {c.DateOfBirth}]"] = c.Gender;
}
return d;
}
private static string? FormatParent(SnapshotParent? p) =>
p is null ? null : $"{p.Name} (*{p.DateOfBirth}) {p.Genotype}".Trim();
// ── Freeze map (machine-readable own-field override) ─────────────────
/// <summary>Builds the full own-field freeze object (all <see cref="FreezeFields"/>) from a
/// gerbil's current values. Used by verify (store as OverrideJson) and by the per-field
/// edit-protection diff.</summary>
public static JsonObject BuildFreezeObject(Gerbil g)
{
var o = new JsonObject
{
["name"] = g.Name,
["gender"] = g.Gender.ToString(),
["dateOfBirth"] = Iso(g.DateOfBirth),
["dateOfDeath"] = Iso(g.DateOfDeath),
["causeOfDeath"] = g.CauseOfDeath,
["goHomeDate"] = Iso(g.GoHomeDate),
["genotype"] = g.Genotype,
["spottingType"] = g.SpottingType,
["colorVarietyId"] = g.ColorVarietyId?.ToString(),
["isResident"] = g.IsResident,
["isDeaf"] = g.IsDeaf,
["isCastrated"] = g.IsCastrated,
["originBreeder"] = g.OriginBreeder,
["originContactId"] = g.OriginContactId?.ToString(),
["receiverContactId"] = g.ReceiverContactId?.ToString(),
["notes"] = g.Notes,
["characterTraits"] = new JsonArray((g.CharacterTraits ?? new List<string>()).Select(t => (JsonNode?)JsonValue.Create(t)).ToArray()),
["characterNote"] = g.CharacterNote,
};
return o;
}
public static string BuildFreezeJson(Gerbil g) => BuildFreezeObject(g).ToJsonString(Json);
/// <summary>Per-field change detection for the PUT edit-protection path. Returns the subset of
/// freeze keys whose value differs between <paramref name="before"/> and <paramref name="after"/>,
/// as a JsonObject carrying the AFTER values (the ones to pin). Empty if nothing relevant changed.</summary>
public static JsonObject ChangedFields(JsonObject before, JsonObject after)
{
var changed = new JsonObject();
foreach (var key in FreezeFields)
{
var b = before[key]?.ToJsonString() ?? "null";
var a = after[key]?.ToJsonString() ?? "null";
if (!string.Equals(b, a, StringComparison.Ordinal))
changed[key] = after[key] is { } node ? node.DeepClone() : null;
}
return changed;
}
/// <summary>Merge <paramref name="incoming"/> field values into an existing override JSON
/// object (incoming wins). Returns the merged JSON string.</summary>
public static string MergeOverrideJson(string? existingJson, JsonObject incoming)
{
var baseObj = ParseObject(existingJson) ?? new JsonObject();
foreach (var kv in incoming)
baseObj[kv.Key] = kv.Value is { } n ? n.DeepClone() : null;
return baseObj.ToJsonString(Json);
}
/// <summary>Applies a stored override JSON object onto a gerbil, writing only the keys present
/// (the "freeze"). Unknown keys are ignored.</summary>
public static void ApplyOverride(Gerbil g, string? overrideJson)
{
var o = ParseObject(overrideJson);
if (o is null) return;
foreach (var kv in o)
{
var v = kv.Value;
switch (kv.Key)
{
case "name": if (Str(v) is { Length: > 0 } nm) g.Name = nm; break;
case "gender": if (Str(v) is { } gd && Enum.TryParse<Gender>(gd, out var gender)) g.Gender = gender; break;
case "dateOfBirth": g.DateOfBirth = Date(v); break;
case "dateOfDeath": g.DateOfDeath = Date(v); break;
case "causeOfDeath": g.CauseOfDeath = Str(v); break;
case "goHomeDate": g.GoHomeDate = Date(v); break;
case "genotype": g.Genotype = Str(v); break;
case "spottingType": g.SpottingType = Str(v); break;
case "colorVarietyId": g.ColorVarietyId = Guid_(v); break;
case "isResident": g.IsResident = Bool(v) ?? g.IsResident; break;
case "isDeaf": g.IsDeaf = Bool(v); break;
case "isCastrated": g.IsCastrated = Bool(v) ?? g.IsCastrated; break;
case "originBreeder": g.OriginBreeder = Str(v); break;
case "originContactId": g.OriginContactId = Guid_(v); break;
case "receiverContactId": g.ReceiverContactId = Guid_(v); break;
case "notes": g.Notes = Str(v); break;
case "characterTraits": g.CharacterTraits = StrList(v); break;
case "characterNote": g.CharacterNote = Str(v); break;
}
}
}
// ── JSON helpers ─────────────────────────────────────────────────────
private static JsonObject? ParseObject(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try { return JsonNode.Parse(json) as JsonObject; }
catch (JsonException) { return null; }
}
private static string? Str(JsonNode? n) => n?.GetValueKind() == JsonValueKind.String ? n.GetValue<string>() : null;
private static bool? Bool(JsonNode? n) => n?.GetValueKind() is JsonValueKind.True or JsonValueKind.False ? n!.GetValue<bool>() : null;
private static Guid? Guid_(JsonNode? n) => Guid.TryParse(Str(n), out var id) ? id : null;
private static DateOnly? Date(JsonNode? n) => DateOnly.TryParse(Str(n), out var d) ? d : null;
private static List<string> StrList(JsonNode? n)
{
if (n is JsonArray arr)
return arr.Where(x => x is not null).Select(x => x!.GetValue<string>()).ToList();
return new List<string>();
}
private static string? Iso(DateOnly? d) => d?.ToString("yyyy-MM-dd");
}
}