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

@@ -13,6 +13,8 @@ namespace GerbilManagerWebAPI.Import
private readonly string _sourceDir;
private readonly string _photoRoot;
private static readonly JsonSerializerOptions DiffJson = new(JsonSerializerDefaults.Web);
public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
{
_db = db;
@@ -32,11 +34,8 @@ namespace GerbilManagerWebAPI.Import
}
string jsonContent = await File.ReadAllTextAsync(_resolvedJsonPath);
var jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
jsonOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
var jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
jsonOptions.Converters.Add(new JsonStringEnumConverter());
var data = JsonSerializer.Deserialize<ResolvedImportData>(jsonContent, jsonOptions);
if (data == null)
@@ -44,163 +43,185 @@ namespace GerbilManagerWebAPI.Import
return "Error: Failed to deserialize resolved_import.json.";
}
// 1. Provider-agnostic clean wipe of dependent tables to prevent duplicate keys
if (_db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory")
{
// Break circular references first
foreach (var l in _db.Litters)
{
l.FatherId = null;
l.MotherId = null;
}
foreach (var g in _db.Gerbils)
{
g.LitterId = null;
}
await _db.SaveChangesAsync();
// ── UPSERT-based ingest (no total wipe) ──────────────────────────────────────
// The ingest NO LONGER wipes everything. Imported rows are matched by their
// (deterministic) id and updated in place; manually-created rows (IsManual=true) and
// user-entered sub-records (weights/health/photos) are never deleted, so the breeder's
// work survives the re-import. Only STALE imported rows (IsManual=false, absent from the
// new payload) are removed — after nulling any manual references to them (with a warning).
// Finally, hand-curated GerbilOverrides are re-applied on top (the "freeze").
var payloadContactIds = data.Contacts.Select(c => c.Id).ToHashSet();
var payloadLitterIds = data.Litters.Select(l => l.Id).ToHashSet();
var payloadGerbilIds = data.Gerbils.Select(g => g.Id).ToHashSet();
var warnings = new List<string>();
_db.GerbilPhotos.RemoveRange(_db.GerbilPhotos);
_db.WeightRecords.RemoveRange(_db.WeightRecords);
_db.HealthRecords.RemoveRange(_db.HealthRecords);
_db.SaleContracts.RemoveRange(_db.SaleContracts);
_db.Gerbils.RemoveRange(_db.Gerbils);
_db.Litters.RemoveRange(_db.Litters);
_db.Contacts.RemoveRange(_db.Contacts);
await _db.SaveChangesAsync();
}
else
{
// Break circular references first using ExecuteUpdateAsync
await _db.Litters.ExecuteUpdateAsync(s => s
.SetProperty(l => l.FatherId, (Guid?)null)
.SetProperty(l => l.MotherId, (Guid?)null));
await _db.Gerbils.ExecuteUpdateAsync(s => s
.SetProperty(g => g.LitterId, (Guid?)null));
await _db.GerbilPhotos.ExecuteDeleteAsync();
await _db.WeightRecords.ExecuteDeleteAsync();
await _db.HealthRecords.ExecuteDeleteAsync();
await _db.SaleContracts.ExecuteDeleteAsync();
await _db.Gerbils.ExecuteDeleteAsync();
await _db.Litters.ExecuteDeleteAsync();
await _db.Contacts.ExecuteDeleteAsync();
}
// 2. Import Contacts (Add new ones, and update roles/details of existing ones)
// 1. Upsert contacts (preserve manual rows + their IsManual flag).
var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id);
var addedContactIds = new HashSet<Guid>();
int contactsAdded = 0;
int contactsUpdated = 0;
int contactsAdded = 0, contactsUpdated = 0;
foreach (var c in data.Contacts)
{
if (existingContacts.TryGetValue(c.Id, out var existingContact))
if (existingContacts.TryGetValue(c.Id, out var ec))
{
existingContact.Name = c.Name;
existingContact.Email = c.Email;
existingContact.Phone = c.Phone;
existingContact.Address = c.Address;
existingContact.Notes = c.Notes;
existingContact.IsBreeder = c.IsBreeder;
existingContact.IsReceiver = c.IsReceiver;
existingContact.NameSuffix = c.NameSuffix;
existingContact.Provenance = c.Provenance;
ec.Name = c.Name; ec.Email = c.Email; ec.Phone = c.Phone; ec.Address = c.Address;
ec.Notes = c.Notes; ec.IsBreeder = c.IsBreeder; ec.IsReceiver = c.IsReceiver;
ec.NameSuffix = c.NameSuffix; ec.Provenance = c.Provenance;
contactsUpdated++;
}
else if (addedContactIds.Add(c.Id))
else
{
c.IsManual = false;
_db.Contacts.Add(c);
contactsAdded++;
}
}
await _db.SaveChangesAsync();
// 3. Pass 1: Insert all Litters with parent references set to null to avoid FK errors
var littersToInsert = new List<Litter>();
// 2. Upsert litters (parent FKs reset to null; set in pass 2 to avoid ordering issues).
var existingLitters = await _db.Litters.ToDictionaryAsync(l => l.Id);
foreach (var l in data.Litters)
{
littersToInsert.Add(new Litter
if (existingLitters.TryGetValue(l.Id, out var el))
{
Id = l.Id,
Name = l.Name,
Date = l.Date,
TotalBorn = l.TotalBorn,
DeathsWithin8Weeks = l.DeathsWithin8Weeks,
FatherId = null, // Set in Pass 2
MotherId = null, // Set in Pass 2
ExpectedGoHomeDate = l.ExpectedGoHomeDate,
Notes = l.Notes,
PairingCode = l.PairingCode,
ExternalRef = l.ExternalRef,
LitterLetter = l.LitterLetter,
Provenance = l.Provenance
});
el.Name = l.Name; el.Date = l.Date; el.TotalBorn = l.TotalBorn;
el.DeathsWithin8Weeks = l.DeathsWithin8Weeks; el.Stillborn = l.Stillborn;
el.ExpectedGoHomeDate = l.ExpectedGoHomeDate; el.Notes = l.Notes;
el.PairingCode = l.PairingCode; el.ExternalRef = l.ExternalRef;
el.LitterLetter = l.LitterLetter; el.Provenance = l.Provenance;
el.ShowInChronicle = l.ShowInChronicle;
el.FatherId = null; el.MotherId = null;
}
else
{
_db.Litters.Add(new Litter
{
Id = l.Id, Name = l.Name, Date = l.Date, TotalBorn = l.TotalBorn,
DeathsWithin8Weeks = l.DeathsWithin8Weeks, Stillborn = l.Stillborn, FatherId = null, MotherId = null,
ExpectedGoHomeDate = l.ExpectedGoHomeDate, Notes = l.Notes, PairingCode = l.PairingCode,
ExternalRef = l.ExternalRef, LitterLetter = l.LitterLetter, Provenance = l.Provenance,
ShowInChronicle = l.ShowInChronicle, IsManual = false,
});
}
}
_db.Litters.AddRange(littersToInsert);
await _db.SaveChangesAsync();
// 4. Pass 1: Insert all Gerbils with LitterId set to null to avoid FK errors
var gerbilsToInsert = new List<Gerbil>();
// 3. Upsert gerbils (LitterId reset to null; set in pass 2). Manual rows keep IsManual=true
// and are never matched here (their random ids never collide with deterministic import ids).
var existingGerbils = await _db.Gerbils.ToDictionaryAsync(g => g.Id);
foreach (var g in data.Gerbils)
{
var importedGerbil = new Gerbil
Gerbil target;
if (existingGerbils.TryGetValue(g.Id, out var eg))
{
Id = g.Id,
Name = g.Name,
Gender = g.Gender,
Status = g.Status,
LitterId = null, // Set in Pass 2
OriginContactId = g.OriginContactId,
ReceiverContactId = g.ReceiverContactId,
EnclosureId = g.EnclosureId,
ColorVarietyId = g.ColorVarietyId,
DateOfBirth = g.DateOfBirth,
DateOfDeath = g.DateOfDeath,
CauseOfDeath = g.CauseOfDeath,
GoHomeDate = g.GoHomeDate,
Genotype = g.Genotype,
Notes = g.Notes,
ImportSource = g.ImportSource,
ExternalRef = g.ExternalRef,
RawImportData = g.RawImportData,
Provenance = g.Provenance,
OriginBreeder = g.OriginBreeder,
NameSearch = g.NameSearch,
CharacterTraits = g.CharacterTraits,
CharacterNote = g.CharacterNote,
IsDeaf = g.IsDeaf,
IsResident = g.IsResident
};
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
gerbilsToInsert.Add(importedGerbil);
eg.Name = g.Name; eg.Gender = g.Gender; eg.Status = g.Status;
eg.OriginContactId = g.OriginContactId; eg.ReceiverContactId = g.ReceiverContactId;
eg.EnclosureId = g.EnclosureId; eg.ColorVarietyId = g.ColorVarietyId;
eg.DateOfBirth = g.DateOfBirth; eg.DateOfDeath = g.DateOfDeath; eg.CauseOfDeath = g.CauseOfDeath;
eg.GoHomeDate = g.GoHomeDate; eg.Genotype = g.Genotype; eg.Notes = g.Notes;
eg.ImportSource = g.ImportSource; eg.ExternalRef = g.ExternalRef; eg.RawImportData = g.RawImportData;
eg.Provenance = g.Provenance; eg.OriginBreeder = g.OriginBreeder;
eg.CharacterTraits = g.CharacterTraits; eg.CharacterNote = g.CharacterNote;
eg.IsDeaf = g.IsDeaf; eg.IsResident = g.IsResident; eg.LitterId = null;
eg.BirthOrder = g.BirthOrder;
target = eg;
}
else
{
var ng = new Gerbil
{
Id = g.Id, Name = g.Name, Gender = g.Gender, Status = g.Status, LitterId = null,
OriginContactId = g.OriginContactId, ReceiverContactId = g.ReceiverContactId,
EnclosureId = g.EnclosureId, ColorVarietyId = g.ColorVarietyId,
DateOfBirth = g.DateOfBirth, DateOfDeath = g.DateOfDeath, CauseOfDeath = g.CauseOfDeath,
GoHomeDate = g.GoHomeDate, Genotype = g.Genotype, Notes = g.Notes,
ImportSource = g.ImportSource, ExternalRef = g.ExternalRef, RawImportData = g.RawImportData,
Provenance = g.Provenance, OriginBreeder = g.OriginBreeder, NameSearch = g.NameSearch,
CharacterTraits = g.CharacterTraits, CharacterNote = g.CharacterNote,
IsDeaf = g.IsDeaf, IsResident = g.IsResident, IsManual = false,
BirthOrder = g.BirthOrder,
};
_db.Gerbils.Add(ng);
target = ng;
}
GerbilStatusService.Apply(target, DateOnly.FromDateTime(DateTime.UtcNow));
}
_db.Gerbils.AddRange(gerbilsToInsert);
await _db.SaveChangesAsync();
// 5. Pass 2: Set the actual foreign key relationships
// Update Litters with their parent animal IDs
// 4. Pass 2: set the payload FK relationships (only for payload rows; manual rows untouched).
var littersInDb = await _db.Litters.ToDictionaryAsync(l => l.Id);
foreach (var l in data.Litters)
{
if (littersInDb.TryGetValue(l.Id, out var dbLitter))
{
dbLitter.FatherId = l.FatherId;
dbLitter.MotherId = l.MotherId;
}
}
// Update Gerbils with their actual LitterId
if (littersInDb.TryGetValue(l.Id, out var dl)) { dl.FatherId = l.FatherId; dl.MotherId = l.MotherId; }
var gerbilsInDb = await _db.Gerbils.ToDictionaryAsync(g => g.Id);
foreach (var g in data.Gerbils)
if (gerbilsInDb.TryGetValue(g.Id, out var dg)) dg.LitterId = g.LitterId;
await _db.SaveChangesAsync();
// 5. Remove STALE imported rows (IsManual=false, no longer in the payload). Null any
// dangling references from surviving rows first; warn when a MANUAL row is affected.
var allGerbils = await _db.Gerbils.ToListAsync();
var allLitters = await _db.Litters.ToListAsync();
var allContacts = await _db.Contacts.ToListAsync();
var gerbilNameById = allGerbils.ToDictionary(x => x.Id, x => x.Name);
var litterNameById = allLitters.ToDictionary(x => x.Id, x => x.Name);
var contactNameById = allContacts.ToDictionary(x => x.Id, x => x.Name);
var staleGerbilIds = allGerbils.Where(g => !g.IsManual && !payloadGerbilIds.Contains(g.Id)).Select(g => g.Id).ToHashSet();
var staleLitterIds = allLitters.Where(l => !l.IsManual && !payloadLitterIds.Contains(l.Id)).Select(l => l.Id).ToHashSet();
var staleContactIds = allContacts.Where(c => !c.IsManual && !payloadContactIds.Contains(c.Id)).Select(c => c.Id).ToHashSet();
if (staleGerbilIds.Count > 0)
{
if (gerbilsInDb.TryGetValue(g.Id, out var dbGerbil))
foreach (var l in allLitters)
{
dbGerbil.LitterId = g.LitterId;
if (l.FatherId is { } f && staleGerbilIds.Contains(f))
{
if (l.IsManual) warnings.Add($"Manueller Wurf „{l.Name}\" verwies auf importiertes Elterntier {gerbilNameById.GetValueOrDefault(f, "?")}\" (Vater), das im Import nicht mehr existiert — Elternteil entfernt.");
l.FatherId = null;
}
if (l.MotherId is { } m && staleGerbilIds.Contains(m))
{
if (l.IsManual) warnings.Add($"Manueller Wurf „{l.Name}\" verwies auf importiertes Elterntier {gerbilNameById.GetValueOrDefault(m, "?")}\" (Mutter), das im Import nicht mehr existiert — Elternteil entfernt.");
l.MotherId = null;
}
}
}
if (staleContactIds.Count > 0)
{
foreach (var g in allGerbils)
{
if (g.OriginContactId is { } o && staleContactIds.Contains(o))
{
if (g.IsManual) warnings.Add($"Manuelles Tier „{g.Name}\" verwies auf Kontakt {contactNameById.GetValueOrDefault(o, "?")}\" (Herkunft), der im Import nicht mehr existiert — Zuordnung entfernt.");
g.OriginContactId = null;
}
if (g.ReceiverContactId is { } r && staleContactIds.Contains(r))
{
if (g.IsManual) warnings.Add($"Manuelles Tier „{g.Name}\" verwies auf Kontakt {contactNameById.GetValueOrDefault(r, "?")}\" (Abnehmer), der im Import nicht mehr existiert — Zuordnung entfernt.");
g.ReceiverContactId = null;
}
}
}
if (staleLitterIds.Count > 0)
{
// Gerbil.LitterId FK is SetNull on delete → nulled automatically when the litter is
// removed; we only surface a warning for affected MANUAL animals.
foreach (var g in allGerbils)
if (g.IsManual && g.LitterId is { } lid && staleLitterIds.Contains(lid))
warnings.Add($"Manuelles Tier „{g.Name}\" war im Wurf {litterNameById.GetValueOrDefault(lid, "?")}\" geboren, der im Import nicht mehr existiert — Wurf-Zuordnung entfernt.");
}
await _db.SaveChangesAsync();
int gerbilsDeleted = staleGerbilIds.Count, littersDeleted = staleLitterIds.Count, contactsDeleted = staleContactIds.Count;
if (staleGerbilIds.Count > 0) _db.Gerbils.RemoveRange(allGerbils.Where(g => staleGerbilIds.Contains(g.Id)));
if (staleLitterIds.Count > 0) _db.Litters.RemoveRange(allLitters.Where(l => staleLitterIds.Contains(l.Id)));
if (staleContactIds.Count > 0) _db.Contacts.RemoveRange(allContacts.Where(c => staleContactIds.Contains(c.Id)));
await _db.SaveChangesAsync();
// 6. Photos: replace photos of IMPORTED gerbils (manual gerbils' photos survive). Copy files.
var manualGerbilIds = allGerbils.Where(g => g.IsManual).Select(g => g.Id).ToHashSet();
var allPhotos = await _db.GerbilPhotos.ToListAsync();
_db.GerbilPhotos.RemoveRange(allPhotos.Where(p => !manualGerbilIds.Contains(p.GerbilId)));
await _db.SaveChangesAsync();
// 6. Insert all Photos & copy physical files
Directory.CreateDirectory(_photoRoot);
foreach (var p in data.GerbilPhotos)
{
@@ -211,14 +232,8 @@ namespace GerbilManagerWebAPI.Import
if (File.Exists(src))
{
var dest = Path.Combine(_photoRoot, p.FileName);
try
{
File.Copy(src, dest, overwrite: true);
}
catch (Exception ex)
{
Console.WriteLine($"Warning: Failed to copy photo {src} to {dest}: {ex.Message}");
}
try { File.Copy(src, dest, overwrite: true); }
catch (Exception ex) { Console.WriteLine($"Warning: Failed to copy photo {src} to {dest}: {ex.Message}"); }
}
else
{
@@ -233,24 +248,18 @@ namespace GerbilManagerWebAPI.Import
FileName = p.FileName,
Caption = p.Caption,
SortOrder = p.SortOrder,
CreatedAt = DateTimeOffset.UtcNow
CreatedAt = DateTimeOffset.UtcNow,
});
}
await _db.SaveChangesAsync();
// 7. Insert imported Abgabeverträge (SaleContract + join rows).
// The table was wiped above, so these are recreated on every ingest
// (they survive only by being part of the payload). FK-safe: contacts
// and gerbils are already inserted. We skip rows whose buyer contact
// is unknown and skip individual animal links to unknown gerbils
// (defensive — the resolver should never emit those).
// No .docx is copied: the source files live on the network share and
// are not staged into Contracts:RootPath, so the Word download 404s
// gracefully (the endpoint already returns NotFound) while the PDF is
// regenerated from the data. The DTO exposes HasFile=false for these
// so the frontend hides the Word button.
int contractsAdded = 0;
int contractAnimalsSkipped = 0;
// 7. Imported Abgabeverträge: recreate from payload (unchanged behaviour — the imported set
// is part of the payload). Manual sale-contract protection is a separate follow-up.
var scToRemove = await _db.SaleContracts.ToListAsync();
_db.SaleContracts.RemoveRange(scToRemove);
await _db.SaveChangesAsync();
int contractsAdded = 0, contractAnimalsSkipped = 0;
if (data.SaleContracts.Count > 0)
{
var contactIds = await _db.Contacts.Select(c => c.Id).ToHashSetAsync();
@@ -258,8 +267,8 @@ namespace GerbilManagerWebAPI.Import
var seenContractIds = new HashSet<Guid>();
foreach (var sc in data.SaleContracts)
{
if (!seenContractIds.Add(sc.Id)) continue; // dedupe within payload
if (!contactIds.Contains(sc.ContactId)) continue; // unknown buyer
if (!seenContractIds.Add(sc.Id)) continue;
if (!contactIds.Contains(sc.ContactId)) continue;
var animals = new List<SaleContractAnimal>();
foreach (var gid in (sc.Animals ?? new List<Guid>()).Distinct())
@@ -284,7 +293,36 @@ namespace GerbilManagerWebAPI.Import
await _db.SaveChangesAsync();
}
return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, {data.GerbilPhotos.Count} photos, and {contractsAdded} sale contracts ({contractAnimalsSkipped} animal links to unknown gerbils skipped).";
// 8. FREEZE: capture the raw-import snapshot for verified overrides (BEFORE applying the
// freeze), then re-apply every override on top of the fresh import so the breeder's
// curated values win. Overrides survive re-ingest by being their own (FK-free) table.
var overrides = await _db.GerbilOverrides.ToListAsync();
foreach (var ov in overrides.Where(o => o.IsVerified))
{
var raw = await GerbilSnapshotService.BuildSnapshotAsync(_db, ov.GerbilId);
if (raw is null) continue; // verified animal vanished from import → surfaced as "missing" in the UI
ov.LastImportSnapshotJson = GerbilSnapshotService.SerializeSnapshot(raw);
var golden = GerbilSnapshotService.DeserializeSnapshot(ov.SnapshotJson);
var diff = GerbilSnapshotService.Diff(golden, raw);
ov.LastImportDiffJson = diff.Count == 0 ? null : JsonSerializer.Serialize(diff, DiffJson);
}
var gerbilsById = await _db.Gerbils.ToDictionaryAsync(g => g.Id);
int overridesApplied = 0;
foreach (var ov in overrides)
{
if (gerbilsById.TryGetValue(ov.GerbilId, out var gg))
{
GerbilSnapshotService.ApplyOverride(gg, ov.OverrideJson);
overridesApplied++;
}
}
await _db.SaveChangesAsync();
var warnPart = warnings.Count > 0 ? $" {warnings.Count} warning(s): {string.Join(" | ", warnings)}" : "";
return $"Ingestion successful! Contacts +{contactsAdded}/~{contactsUpdated} ({contactsDeleted} stale), " +
$"{data.Litters.Count} litters ({littersDeleted} stale), {data.Gerbils.Count} gerbils ({gerbilsDeleted} stale), " +
$"{data.GerbilPhotos.Count} photos, {contractsAdded} sale contracts ({contractAnimalsSkipped} animal links skipped), " +
$"{overridesApplied} override(s) applied (freeze).{warnPart}";
}
}
@@ -319,7 +357,7 @@ namespace GerbilManagerWebAPI.Import
public required string FileName { get; set; }
public string? Caption { get; set; }
public int SortOrder { get; set; }
[JsonPropertyName("_source_path")]
public string? SourcePath { get; set; }
}