feat(deploy): TrueNAS Custom-App + Auto-Deploy, plus aufgelaufene Arbeit
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:
@@ -32,6 +32,7 @@ public class ApplicationContext : DbContext
|
||||
public DbSet<WaitingListEntry> WaitingListEntries => Set<WaitingListEntry>();
|
||||
public DbSet<ReturnRecord> ReturnRecords => Set<ReturnRecord>();
|
||||
public DbSet<ExhibitionResult> ExhibitionResults => Set<ExhibitionResult>();
|
||||
public DbSet<GerbilOverride> GerbilOverrides => Set<GerbilOverride>();
|
||||
|
||||
// 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.
|
||||
@@ -74,6 +75,10 @@ public class ApplicationContext : DbContext
|
||||
// Residency defaults to true (own stock unless explicitly marked external).
|
||||
e.Property(g => g.IsResident).HasDefaultValue(true);
|
||||
|
||||
// Manual-flag defaults false: every pre-existing / imported row is import-driven.
|
||||
// Only POST /gerbils sets it true, so the ingest wipe never touches manual animals.
|
||||
e.Property(g => g.IsManual).HasDefaultValue(false);
|
||||
|
||||
// DB-4: German collation on searched/sorted name fields (Npgsql-only).
|
||||
if (isNpgsql)
|
||||
{
|
||||
@@ -125,6 +130,8 @@ public class ApplicationContext : DbContext
|
||||
e.HasIndex(l => l.ExternalRef)
|
||||
.IsUnique()
|
||||
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||
|
||||
e.Property(l => l.IsManual).HasDefaultValue(false);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<HealthRecord>(e =>
|
||||
@@ -268,6 +275,19 @@ public class ApplicationContext : DbContext
|
||||
e.HasIndex(x => x.GerbilId);
|
||||
});
|
||||
|
||||
// VERIFIED/PROTECTED OVERRIDE: deliberately relationship-free (same pattern as Feedback).
|
||||
// GerbilId is a plain Guid column (no navigation property → EF creates NO foreign key), so
|
||||
// the import re-ingest wipe of Gerbils never cascades into — or breaks — override rows. They
|
||||
// survive re-ingest and are re-applied on top of the fresh import (the freeze), which is the
|
||||
// whole point.
|
||||
modelBuilder.Entity<GerbilOverride>(e =>
|
||||
{
|
||||
e.HasIndex(o => o.GerbilId).IsUnique();
|
||||
});
|
||||
|
||||
// Manual-flag default false for Contact (imported/pre-existing rows are import-driven).
|
||||
modelBuilder.Entity<Contact>().Property(c => c.IsManual).HasDefaultValue(false);
|
||||
|
||||
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
||||
if (isNpgsql)
|
||||
{
|
||||
@@ -304,9 +324,9 @@ public class ApplicationContext : DbContext
|
||||
(int n, string Slug, string Title, string BodyMd)[] legalPages =
|
||||
{
|
||||
(7, "impressum", "Impressum",
|
||||
"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
|
||||
"## Angaben gemäß § 18 Abs. 1 MStV\\n\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\n\\n## Kontakt\\n\\nE-Mail: [E-Mail-Adresse]\\nTelefon: [Telefonnummer]\\n\\n---\\n\\nDiese Seite stellt eine private Hobbyzucht mongolischer Rennmäuse ohne Gewinnerzielungsabsicht vor.\\n\\n*Hinweis für den Seitenbetreiber: Name und ladungsfähige Anschrift (kein Postfach) sind Pflicht (§ 18 Abs. 1 MStV). Sollte die Zucht jemals gewerblich bzw. geschäftsmäßig betrieben werden, gelten zusätzlich § 5 DDG (vollständiges Anbieter-Impressum) und ggf. die Erlaubnispflicht nach § 11 TierSchG — dann zuständige Behörde und Aktenzeichen hier ergänzen. Kein Hinweis auf die EU-Streitbeilegungsplattform: diese wurde zum 20.07.2025 abgeschaltet (ein fortbestehender Link wäre irreführend).*"),
|
||||
(8, "datenschutz", "Datenschutz",
|
||||
"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
|
||||
"## Datenschutzerklärung\\n\\n## 1. Verantwortlicher\\n\\nVerantwortlich für die Datenverarbeitung auf dieser Webseite ist:\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\nE-Mail: [E-Mail-Adresse]\\n\\n## 2. Hosting und Server-Logfiles\\n\\nDiese Webseite wird auf einem eigenen Server betrieben. Beim Aufruf der Seite verarbeitet der Server automatisch Informationen, die dein Browser übermittelt, in sogenannten Server-Logfiles: IP-Adresse, Datum und Uhrzeit des Zugriffs, die aufgerufene Seite bzw. Datei, die übertragene Datenmenge sowie Browser und Betriebssystem.\\nDiese Verarbeitung ist technisch erforderlich, um die Webseite auszuliefern und ihren sicheren und stabilen Betrieb zu gewährleisten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am störungsfreien Betrieb und an der Sicherheit). Die Logfiles werden nach spätestens [z. B. 7] Tagen gelöscht und nicht mit anderen Daten zusammengeführt.\\n\\n## 3. Cookies, Tracking und externe Dienste\\n\\nDiese Webseite verwendet keine Cookies, kein Tracking und keine Analyse- oder Marketing-Dienste. Es werden keine externen Inhalte Dritter (z. B. Schriftarten, Karten, Videos, CDNs) nachgeladen.\\n\\n## 4. Kontaktaufnahme\\n\\nWenn du uns per E-Mail kontaktierst, verarbeiten wir deine Angaben (z. B. Name, E-Mail-Adresse, Inhalt der Nachricht) ausschließlich zur Bearbeitung deiner Anfrage. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO bzw. Art. 6 Abs. 1 lit. f DSGVO. Die Daten werden gelöscht, sobald sie nicht mehr benötigt werden und keine gesetzlichen Aufbewahrungspflichten entgegenstehen.\\n\\n## 5. Empfänger der Daten\\n\\nEine Weitergabe deiner Daten an Dritte findet nicht statt. Eine Datenübermittlung in Länder außerhalb der EU bzw. des EWR erfolgt nicht.\\n\\n## 6. Deine Rechte\\n\\nDu hast jederzeit das Recht auf:\\n- Auskunft über die zu deiner Person gespeicherten Daten (Art. 15 DSGVO)\\n- Berichtigung unrichtiger Daten (Art. 16 DSGVO)\\n- Löschung (Art. 17 DSGVO)\\n- Einschränkung der Verarbeitung (Art. 18 DSGVO)\\n- Datenübertragbarkeit (Art. 20 DSGVO)\\n- Widerspruch gegen die Verarbeitung (Art. 21 DSGVO)\\n\\nZur Ausübung genügt eine formlose Nachricht an die oben genannte E-Mail-Adresse.\\n\\n## 7. Beschwerderecht bei der Aufsichtsbehörde\\n\\nDu hast das Recht, dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO). Zuständig ist die Aufsichtsbehörde deines Bundeslandes bzw. des Seitenbetreibers: [zuständige Landesdatenschutzbehörde eintragen].\\n\\n## 8. Keine automatisierte Entscheidungsfindung\\n\\nEine automatisierte Entscheidungsfindung einschließlich Profiling nach Art. 22 DSGVO findet nicht statt.\\n\\n---\\n\\n*Hinweis für den Seitenbetreiber: Platzhalter in eckigen Klammern ausfüllen. Aufbewahrungsdauer der Logfiles an deinen Server bzw. Reverse-Proxy anpassen. Sobald externe Dienste oder ein Kontaktformular eingebunden werden, diesen Text ergänzen. Stand: [Datum eintragen].*"),
|
||||
};
|
||||
|
||||
var pageRows = new List<Page>();
|
||||
|
||||
@@ -17,6 +17,11 @@ RUN dotnet publish GerbilManagerWebAPI.csproj -c Release -o /app/publish /p:UseA
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
# curl für den Container-Healthcheck (das Basis-Image bringt weder curl noch wget mit;
|
||||
# ohne dieses würde der compose-Healthcheck dauerhaft fehlschlagen und api nie "healthy").
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# .NET 10 containers default to ASPNETCORE_HTTP_PORTS=8080 bound on all interfaces.
|
||||
EXPOSE 8080
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
@@ -33,7 +33,8 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
bool IsResident,
|
||||
string? ProfilePhotoUrl,
|
||||
bool IsCastrated,
|
||||
string? Provenance);
|
||||
string? Provenance,
|
||||
int? BirthOrder = null);
|
||||
|
||||
public record LitterDto(
|
||||
Guid Id,
|
||||
@@ -41,11 +42,13 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
DateOnly? Date,
|
||||
int? TotalBorn,
|
||||
int? DeathsWithin8Weeks,
|
||||
int? Stillborn,
|
||||
Guid? FatherId,
|
||||
Guid? MotherId,
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
string? Notes,
|
||||
string? Provenance);
|
||||
string? Provenance,
|
||||
bool ShowInChronicle);
|
||||
|
||||
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix, string? Provenance);
|
||||
|
||||
@@ -65,8 +68,33 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
|
||||
public record PhotoDto(Guid Id, string FileName, string? Caption, int SortOrder, string Url);
|
||||
|
||||
// VERIFIED/PROTECTED OVERRIDE -------------------------------------------
|
||||
// Status: "unchanged" = raw import agrees with the golden state; "drifted" = the raw import
|
||||
// differs from the golden state (see ImportDiff — a signal for what the importer might still
|
||||
// get wrong); "missing" = the verified animal no longer exists in the imported data.
|
||||
public record VerifiedGerbilDto(
|
||||
Guid GerbilId,
|
||||
string? EntityName,
|
||||
bool IsVerified,
|
||||
string Status,
|
||||
DateTimeOffset? VerifiedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? Note,
|
||||
IReadOnlyList<string> ProtectedFields,
|
||||
IReadOnlyList<SnapshotDiffDto> ImportDiff);
|
||||
|
||||
/// <summary>One field difference between the golden state and the raw import.</summary>
|
||||
public record SnapshotDiffDto(string Path, string? GoldenValue, string? ImportValue);
|
||||
|
||||
/// <summary>Export entry for the committed regression fixture (verified-golden.json).</summary>
|
||||
public record VerifiedGoldenEntry(
|
||||
Guid GerbilId, string? Name, DateTimeOffset? VerifiedAt, Import.GerbilSnapshot? Snapshot);
|
||||
|
||||
// Request DTOs -----------------------------------------------------------
|
||||
|
||||
/// <summary>Body for POST /verified-gerbils/{id} — optional free-text note.</summary>
|
||||
public record VerifyGerbilInput(string? Note);
|
||||
|
||||
public record GerbilInput(
|
||||
string? Name,
|
||||
Gender? Gender,
|
||||
@@ -97,10 +125,12 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
DateOnly? Date,
|
||||
int? TotalBorn,
|
||||
int? DeathsWithin8Weeks,
|
||||
int? Stillborn,
|
||||
Guid? FatherId,
|
||||
Guid? MotherId,
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
string? Notes);
|
||||
string? Notes,
|
||||
bool? ShowInChronicle = null);
|
||||
|
||||
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
group.MapPost("/", async (ContactInput input, ApplicationContext db) =>
|
||||
{
|
||||
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes, IsBreeder = input.IsBreeder, IsReceiver = input.IsReceiver, NameSuffix = input.NameSuffix };
|
||||
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes, IsBreeder = input.IsBreeder, IsReceiver = input.IsReceiver, NameSuffix = input.NameSuffix, IsManual = true };
|
||||
db.Contacts.Add(c);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Gridify;
|
||||
@@ -61,7 +62,9 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
if (string.IsNullOrWhiteSpace(input.Name))
|
||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
||||
|
||||
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name! };
|
||||
// Manually created in the UI → IsManual=true, so the import re-ingest never wipes or
|
||||
// overwrites this animal (and its sub-records).
|
||||
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name!, IsManual = true };
|
||||
Apply(g, input, isCreate: true);
|
||||
db.Gerbils.Add(g);
|
||||
await db.SaveChangesAsync();
|
||||
@@ -73,8 +76,17 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (g is null) return TypedResults.NotFound();
|
||||
|
||||
// Capture the own-field values BEFORE the edit so manual changes to an IMPORTED animal
|
||||
// can be pinned per-field (survive the next ingest). Manual animals need no override —
|
||||
// they survive the ingest wholesale.
|
||||
var before = GerbilSnapshotService.BuildFreezeObject(g);
|
||||
Apply(g, input, isCreate: false);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (!g.IsManual)
|
||||
await RecordEditOverrideAsync(db, g, before);
|
||||
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
@@ -133,11 +145,47 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
}
|
||||
|
||||
// EDIT PROTECTION: after a manual edit to an IMPORTED animal, pin the change so the next
|
||||
// ingest can't overwrite it. A VERIFIED animal keeps its certification and its whole golden
|
||||
// is refreshed to the edited state (Frage 5). An unverified animal gets/updates a "geschützt"
|
||||
// override carrying ONLY the changed fields (per-field) — untouched fields keep receiving
|
||||
// import improvements. Called after the edit is already persisted.
|
||||
private static async Task RecordEditOverrideAsync(ApplicationContext db, Gerbil g, System.Text.Json.Nodes.JsonObject before)
|
||||
{
|
||||
var ov = await db.GerbilOverrides.FirstOrDefaultAsync(o => o.GerbilId == g.Id);
|
||||
if (ov is { IsVerified: true })
|
||||
{
|
||||
ov.EntityName = g.Name;
|
||||
ov.OverrideJson = GerbilSnapshotService.BuildFreezeJson(g);
|
||||
var snap = await GerbilSnapshotService.BuildSnapshotAsync(db, g.Id);
|
||||
if (snap is not null) ov.SnapshotJson = GerbilSnapshotService.SerializeSnapshot(snap);
|
||||
ov.LastImportSnapshotJson = null;
|
||||
ov.LastImportDiffJson = null;
|
||||
ov.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
var after = GerbilSnapshotService.BuildFreezeObject(g);
|
||||
var changed = GerbilSnapshotService.ChangedFields(before, after);
|
||||
if (changed.Count == 0) return;
|
||||
|
||||
if (ov is null)
|
||||
{
|
||||
ov = new GerbilOverride { Id = Guid.NewGuid(), GerbilId = g.Id, IsVerified = false, OverrideJson = "{}" };
|
||||
db.GerbilOverrides.Add(ov);
|
||||
}
|
||||
ov.EntityName = g.Name;
|
||||
ov.OverrideJson = GerbilSnapshotService.MergeOverrideJson(ov.OverrideJson, changed);
|
||||
ov.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => 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.SpottingType, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
||||
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated,
|
||||
g.Provenance);
|
||||
g.Provenance, g.BirthOrder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
|
||||
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
|
||||
|
||||
var l = new Litter { Id = Guid.NewGuid(), Name = input.Name, Date = input.Date };
|
||||
var l = new Litter { Id = Guid.NewGuid(), Name = input.Name, Date = input.Date, IsManual = true };
|
||||
Apply(l, input);
|
||||
db.Litters.Add(l);
|
||||
await db.SaveChangesAsync();
|
||||
@@ -81,10 +81,15 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
l.TotalBorn = i.TotalBorn;
|
||||
l.DeathsWithin8Weeks = i.DeathsWithin8Weeks;
|
||||
l.Stillborn = i.Stillborn;
|
||||
l.FatherId = i.FatherId;
|
||||
l.MotherId = i.MotherId;
|
||||
l.ExpectedGoHomeDate = i.ExpectedGoHomeDate;
|
||||
l.Notes = i.Notes;
|
||||
// Nur überschreiben, wenn explizit gesetzt — sonst bestehenden Wert / Modell-Default
|
||||
// (true) behalten, damit ein Edit ohne dieses Feld einen versteckten Wurf nicht
|
||||
// versehentlich wieder in die Wurfchronik holt.
|
||||
if (i.ShowInChronicle is bool show) l.ShowInChronicle = show;
|
||||
}
|
||||
|
||||
private static string? ValidateMortality(int? deaths, int? totalBorn)
|
||||
@@ -97,8 +102,8 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
}
|
||||
|
||||
private static LitterDto ToDto(Litter l) => new(
|
||||
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks,
|
||||
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes, l.Provenance);
|
||||
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks, l.Stillborn,
|
||||
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes, l.Provenance, l.ShowInChronicle);
|
||||
}
|
||||
|
||||
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>
|
||||
|
||||
136
GerbilManagerWebAPI/Endpoints/VerifiedGerbilEndpoints.cs
Normal file
136
GerbilManagerWebAPI/Endpoints/VerifiedGerbilEndpoints.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// VERIFIED / PROTECTED OVERRIDES — the breeder's hand-curated "this animal is correct" layer.
|
||||
/// POST /verified-gerbils/{gerbilId} -> mark "vollständig korrekt": snapshot the full Akte,
|
||||
/// freeze all own fields, apply the freeze immediately. Toggle-on. Optional {note}.
|
||||
/// DELETE /verified-gerbils/{gerbilId} -> remove the override (un-flag / release protection).
|
||||
/// Non-destructive: current values stay; the next ingest makes the animal import-driven again.
|
||||
/// GET /verified-gerbils -> all overrides (verified + protected) with drift status.
|
||||
/// GET /verified-gerbils/{gerbilId} -> single status (badge on the Rennmausakte).
|
||||
/// GET /verified-gerbils/export -> canonical golden dump for the committed regression fixture.
|
||||
/// Overrides are decoupled from gerbils (loose GerbilId, no FK), so they survive the import
|
||||
/// re-ingest wipe and are re-applied on top of the fresh import (the "freeze").
|
||||
/// </summary>
|
||||
public static class VerifiedGerbilEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public static IEndpointRouteBuilder MapVerifiedGerbilEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/verified-gerbils").WithTags("VerifiedGerbils");
|
||||
|
||||
group.MapPost("/{gerbilId:guid}", async Task<Results<Ok<VerifiedGerbilDto>, NotFound>> (
|
||||
Guid gerbilId, VerifyGerbilInput? input, ApplicationContext db) =>
|
||||
{
|
||||
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == gerbilId);
|
||||
if (g is null) return TypedResults.NotFound();
|
||||
|
||||
var snapshot = await GerbilSnapshotService.BuildSnapshotAsync(db, gerbilId);
|
||||
|
||||
var ov = await db.GerbilOverrides.FirstOrDefaultAsync(o => o.GerbilId == gerbilId);
|
||||
if (ov is null)
|
||||
{
|
||||
ov = new GerbilOverride { Id = Guid.NewGuid(), GerbilId = gerbilId, OverrideJson = "{}" };
|
||||
db.GerbilOverrides.Add(ov);
|
||||
}
|
||||
ov.EntityName = g.Name;
|
||||
ov.IsVerified = true;
|
||||
ov.OverrideJson = GerbilSnapshotService.BuildFreezeJson(g);
|
||||
ov.SnapshotJson = snapshot is null ? null : GerbilSnapshotService.SerializeSnapshot(snapshot);
|
||||
// Reset the drift cache — recomputed on the next ingest against the fresh import.
|
||||
ov.LastImportSnapshotJson = null;
|
||||
ov.LastImportDiffJson = null;
|
||||
ov.Note = string.IsNullOrWhiteSpace(input?.Note) ? ov.Note : input!.Note!.Trim();
|
||||
ov.VerifiedAt ??= DateTimeOffset.UtcNow;
|
||||
ov.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Ok(await ToDtoAsync(db, ov));
|
||||
});
|
||||
|
||||
group.MapDelete("/{gerbilId:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
Guid gerbilId, ApplicationContext db) =>
|
||||
{
|
||||
var ov = await db.GerbilOverrides.FirstOrDefaultAsync(o => o.GerbilId == gerbilId);
|
||||
if (ov is null) return TypedResults.NotFound();
|
||||
db.GerbilOverrides.Remove(ov);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
group.MapGet("/", async (ApplicationContext db) =>
|
||||
{
|
||||
var rows = await db.GerbilOverrides.ToListAsync();
|
||||
var existingIds = await db.Gerbils.Select(g => g.Id).ToHashSetAsync();
|
||||
var list = new List<VerifiedGerbilDto>();
|
||||
foreach (var ov in rows.OrderByDescending(o => o.UpdatedAt))
|
||||
list.Add(await ToDtoAsync(db, ov, existingIds));
|
||||
return TypedResults.Ok(list);
|
||||
});
|
||||
|
||||
group.MapGet("/export", async (ApplicationContext db) =>
|
||||
{
|
||||
var rows = await db.GerbilOverrides.Where(o => o.IsVerified).ToListAsync();
|
||||
var export = rows
|
||||
.OrderBy(o => o.EntityName, StringComparer.Ordinal)
|
||||
.ThenBy(o => o.GerbilId)
|
||||
.Select(o => new VerifiedGoldenEntry(
|
||||
o.GerbilId, o.EntityName, o.VerifiedAt,
|
||||
GerbilSnapshotService.DeserializeSnapshot(o.SnapshotJson)))
|
||||
.ToList();
|
||||
return TypedResults.Ok(export);
|
||||
});
|
||||
|
||||
group.MapGet("/{gerbilId:guid}", async Task<Results<Ok<VerifiedGerbilDto>, NotFound>> (
|
||||
Guid gerbilId, ApplicationContext db) =>
|
||||
{
|
||||
var ov = await db.GerbilOverrides.FirstOrDefaultAsync(o => o.GerbilId == gerbilId);
|
||||
if (ov is null) return TypedResults.NotFound();
|
||||
return TypedResults.Ok(await ToDtoAsync(db, ov));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<VerifiedGerbilDto> ToDtoAsync(
|
||||
ApplicationContext db, GerbilOverride ov, HashSet<Guid>? existingIds = null)
|
||||
{
|
||||
var exists = existingIds?.Contains(ov.GerbilId) ?? await db.Gerbils.AnyAsync(g => g.Id == ov.GerbilId);
|
||||
var diffs = DeserializeDiff(ov.LastImportDiffJson);
|
||||
var status = !exists ? "missing" : (diffs.Count > 0 ? "drifted" : "unchanged");
|
||||
return new VerifiedGerbilDto(
|
||||
ov.GerbilId, ov.EntityName, ov.IsVerified, status, ov.VerifiedAt, ov.UpdatedAt, ov.Note,
|
||||
ExtractFieldKeys(ov.OverrideJson), diffs);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SnapshotDiffDto> DeserializeDiff(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<SnapshotDiffDto>();
|
||||
try
|
||||
{
|
||||
var raw = JsonSerializer.Deserialize<List<SnapshotDiff>>(json, Json) ?? new();
|
||||
return raw.Select(d => new SnapshotDiffDto(d.Path, d.GoldenValue, d.OtherValue)).ToList();
|
||||
}
|
||||
catch (JsonException) { return Array.Empty<SnapshotDiffDto>(); }
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractFieldKeys(string? overrideJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(overrideJson)) return Array.Empty<string>();
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(overrideJson);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return Array.Empty<string>();
|
||||
return doc.RootElement.EnumerateObject().Select(p => p.Name).ToList();
|
||||
}
|
||||
catch (JsonException) { return Array.Empty<string>(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
307
GerbilManagerWebAPI/Import/GerbilSnapshotService.cs
Normal file
307
GerbilManagerWebAPI/Import/GerbilSnapshotService.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -105,6 +105,11 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
public string? Color { get; set; }
|
||||
public string? Dob { get; set; } // ISO yyyy-MM-dd
|
||||
public string? Origin { get; set; }
|
||||
/// <summary>Roher Gencode (Fcode, z. B. „aa Cc[h] DD ee Gg P- spsp"). Überschreibt den
|
||||
/// automatisch aus den Cluster-Mitgliedern gewählten Gencode — nötig, wenn sich die
|
||||
/// Varianten im Gencode unterscheiden (z. B. C-Locus c[h] vs. c[chm]) und die Züchterin
|
||||
/// den korrekten Genotyp vorgibt.</summary>
|
||||
public string? Genotype { get; set; }
|
||||
public bool? Resident { get; set; }
|
||||
/// <summary>Zusatz, der an die Notizen des Tiers angehängt wird (z. B. abweichendes DOB).</summary>
|
||||
public string? Note { get; set; }
|
||||
|
||||
@@ -427,6 +427,7 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ov.Color)) farbe = ov.Color;
|
||||
if (!string.IsNullOrWhiteSpace(ov.Origin)) origin = ov.Origin;
|
||||
if (!string.IsNullOrWhiteSpace(ov.Genotype)) fcode = ov.Genotype;
|
||||
if (!string.IsNullOrWhiteSpace(ov.Dob) && DateOnly.TryParse(ov.Dob, out var od)) dob = od;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1861
GerbilManagerWebAPI/Migrations/20260624071249_AddLitterShowInChronicle.Designer.cs
generated
Normal file
1861
GerbilManagerWebAPI/Migrations/20260624071249_AddLitterShowInChronicle.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLitterShowInChronicle : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ShowInChronicle",
|
||||
table: "Litters",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
// Bestehende Würfe bleiben in der Wurfchronik sichtbar; nur explizit als
|
||||
// „nicht in Chronik" importierte Würfe (z. B. Akanes extern entstandener Wurf)
|
||||
// werden beim Ingest auf false gesetzt.
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShowInChronicle",
|
||||
table: "Litters");
|
||||
}
|
||||
}
|
||||
}
|
||||
1861
GerbilManagerWebAPI/Migrations/20260624082334_UpdateLegalPagesDsgvo.Designer.cs
generated
Normal file
1861
GerbilManagerWebAPI/Migrations/20260624082334_UpdateLegalPagesDsgvo.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UpdateLegalPagesDsgvo : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Blocks",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("51720002-0000-0000-0000-000000000070"),
|
||||
column: "Data",
|
||||
value: "{\"markdown\":\"## Angaben gemäß § 5 DDG (Digitale-Dienste-Gesetz)\\n\\nSeitenbetreiber:\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\n\\n## Kontakt\\n\\nTelefon: [Telefonnummer]\\nE-Mail: [E-Mail-Adresse]\\n\\n## Verantwortlich für den Inhalt gemäß § 18 Abs. 2 MStV\\n\\n[Vor- und Nachname], Anschrift wie oben\\n\\n## Verbraucherstreitbeilegung\\n\\nWir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.\\n\\n---\\n\\n*Hinweis für den Seitenbetreiber: Bitte alle Platzhalter in eckigen Klammern durch deine echten Angaben ersetzen. Pflicht ist eine ladungsfähige Anschrift (kein Postfach). Wird die Zucht gewerblich betrieben oder besteht eine Erlaubnis nach § 11 TierSchG, ergänze hier die zuständige Behörde und das Aktenzeichen.*\"}");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Blocks",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("51720002-0000-0000-0000-000000000080"),
|
||||
column: "Data",
|
||||
value: "{\"markdown\":\"## Datenschutzerklärung\\n\\n## 1. Verantwortlicher\\n\\nVerantwortlich für die Datenverarbeitung auf dieser Webseite ist:\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\nE-Mail: [E-Mail-Adresse]\\n\\n## 2. Hosting und Server-Logfiles\\n\\nDiese Webseite wird auf einem eigenen Server betrieben. Beim Aufruf der Seite verarbeitet der Server automatisch Informationen, die dein Browser übermittelt, in sogenannten Server-Logfiles: IP-Adresse, Datum und Uhrzeit des Zugriffs, die aufgerufene Seite bzw. Datei, die übertragene Datenmenge sowie Browser und Betriebssystem.\\nDiese Verarbeitung ist technisch erforderlich, um die Webseite auszuliefern und ihren sicheren und stabilen Betrieb zu gewährleisten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am störungsfreien Betrieb und an der Sicherheit). Die Logfiles werden nach spätestens [z. B. 7] Tagen gelöscht und nicht mit anderen Daten zusammengeführt.\\n\\n## 3. Cookies, Tracking und externe Dienste\\n\\nDiese Webseite verwendet keine Cookies, kein Tracking und keine Analyse- oder Marketing-Dienste. Es werden keine externen Inhalte Dritter (z. B. Schriftarten, Karten, Videos, CDNs) nachgeladen.\\n\\n## 4. Kontaktaufnahme\\n\\nWenn du uns per E-Mail kontaktierst, verarbeiten wir deine Angaben (z. B. Name, E-Mail-Adresse, Inhalt der Nachricht) ausschließlich zur Bearbeitung deiner Anfrage. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO bzw. Art. 6 Abs. 1 lit. f DSGVO. Die Daten werden gelöscht, sobald sie nicht mehr benötigt werden und keine gesetzlichen Aufbewahrungspflichten entgegenstehen.\\n\\n## 5. Empfänger der Daten\\n\\nEine Weitergabe deiner Daten an Dritte findet nicht statt. Eine Datenübermittlung in Länder außerhalb der EU bzw. des EWR erfolgt nicht.\\n\\n## 6. Deine Rechte\\n\\nDu hast jederzeit das Recht auf:\\n- Auskunft über die zu deiner Person gespeicherten Daten (Art. 15 DSGVO)\\n- Berichtigung unrichtiger Daten (Art. 16 DSGVO)\\n- Löschung (Art. 17 DSGVO)\\n- Einschränkung der Verarbeitung (Art. 18 DSGVO)\\n- Datenübertragbarkeit (Art. 20 DSGVO)\\n- Widerspruch gegen die Verarbeitung (Art. 21 DSGVO)\\n\\nZur Ausübung genügt eine formlose Nachricht an die oben genannte E-Mail-Adresse.\\n\\n## 7. Beschwerderecht bei der Aufsichtsbehörde\\n\\nDu hast das Recht, dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO). Zuständig ist die Aufsichtsbehörde deines Bundeslandes bzw. des Seitenbetreibers: [zuständige Landesdatenschutzbehörde eintragen].\\n\\n## 8. Keine automatisierte Entscheidungsfindung\\n\\nEine automatisierte Entscheidungsfindung einschließlich Profiling nach Art. 22 DSGVO findet nicht statt.\\n\\n---\\n\\n*Hinweis für den Seitenbetreiber: Platzhalter in eckigen Klammern ausfüllen. Aufbewahrungsdauer der Logfiles an deinen Server bzw. Reverse-Proxy anpassen. Sobald externe Dienste oder ein Kontaktformular eingebunden werden, diesen Text ergänzen. Stand: [Datum eintragen].*\"}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Blocks",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("51720002-0000-0000-0000-000000000070"),
|
||||
column: "Data",
|
||||
value: "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Blocks",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("51720002-0000-0000-0000-000000000080"),
|
||||
column: "Data",
|
||||
value: "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}");
|
||||
}
|
||||
}
|
||||
}
|
||||
1861
GerbilManagerWebAPI/Migrations/20260624084747_UpdateImpressumHobbyMStV.Designer.cs
generated
Normal file
1861
GerbilManagerWebAPI/Migrations/20260624084747_UpdateImpressumHobbyMStV.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UpdateImpressumHobbyMStV : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Blocks",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("51720002-0000-0000-0000-000000000070"),
|
||||
column: "Data",
|
||||
value: "{\"markdown\":\"## Angaben gemäß § 18 Abs. 1 MStV\\n\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\n\\n## Kontakt\\n\\nE-Mail: [E-Mail-Adresse]\\nTelefon: [Telefonnummer]\\n\\n---\\n\\nDiese Seite stellt eine private Hobbyzucht mongolischer Rennmäuse ohne Gewinnerzielungsabsicht vor.\\n\\n*Hinweis für den Seitenbetreiber: Name und ladungsfähige Anschrift (kein Postfach) sind Pflicht (§ 18 Abs. 1 MStV). Sollte die Zucht jemals gewerblich bzw. geschäftsmäßig betrieben werden, gelten zusätzlich § 5 DDG (vollständiges Anbieter-Impressum) und ggf. die Erlaubnispflicht nach § 11 TierSchG — dann zuständige Behörde und Aktenzeichen hier ergänzen. Kein Hinweis auf die EU-Streitbeilegungsplattform: diese wurde zum 20.07.2025 abgeschaltet (ein fortbestehender Link wäre irreführend).*\"}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Blocks",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("51720002-0000-0000-0000-000000000070"),
|
||||
column: "Data",
|
||||
value: "{\"markdown\":\"## Angaben gemäß § 5 DDG (Digitale-Dienste-Gesetz)\\n\\nSeitenbetreiber:\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\n\\n## Kontakt\\n\\nTelefon: [Telefonnummer]\\nE-Mail: [E-Mail-Adresse]\\n\\n## Verantwortlich für den Inhalt gemäß § 18 Abs. 2 MStV\\n\\n[Vor- und Nachname], Anschrift wie oben\\n\\n## Verbraucherstreitbeilegung\\n\\nWir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.\\n\\n---\\n\\n*Hinweis für den Seitenbetreiber: Bitte alle Platzhalter in eckigen Klammern durch deine echten Angaben ersetzen. Pflicht ist eine ladungsfähige Anschrift (kein Postfach). Wird die Zucht gewerblich betrieben oder besteht eine Erlaubnis nach § 11 TierSchG, ergänze hier die zuständige Behörde und das Aktenzeichen.*\"}");
|
||||
}
|
||||
}
|
||||
}
|
||||
1921
GerbilManagerWebAPI/Migrations/20260712142631_AddVerifiedGerbilAndManualFlag.Designer.cs
generated
Normal file
1921
GerbilManagerWebAPI/Migrations/20260712142631_AddVerifiedGerbilAndManualFlag.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVerifiedGerbilAndManualFlag : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsManual",
|
||||
table: "Litters",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsManual",
|
||||
table: "Gerbils",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsManual",
|
||||
table: "Contacts",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "GerbilOverrides",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
GerbilId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
EntityName = table.Column<string>(type: "text", nullable: true),
|
||||
IsVerified = table.Column<bool>(type: "boolean", nullable: false),
|
||||
OverrideJson = table.Column<string>(type: "text", nullable: false),
|
||||
SnapshotJson = table.Column<string>(type: "text", nullable: true),
|
||||
LastImportSnapshotJson = table.Column<string>(type: "text", nullable: true),
|
||||
LastImportDiffJson = table.Column<string>(type: "text", nullable: true),
|
||||
Note = table.Column<string>(type: "text", nullable: true),
|
||||
VerifiedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_GerbilOverrides", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GerbilOverrides_GerbilId",
|
||||
table: "GerbilOverrides",
|
||||
column: "GerbilId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "GerbilOverrides");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsManual",
|
||||
table: "Litters");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsManual",
|
||||
table: "Gerbils");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsManual",
|
||||
table: "Contacts");
|
||||
}
|
||||
}
|
||||
}
|
||||
1924
GerbilManagerWebAPI/Migrations/20260713173625_AddLitterStillborn.Designer.cs
generated
Normal file
1924
GerbilManagerWebAPI/Migrations/20260713173625_AddLitterStillborn.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 AddLitterStillborn : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Stillborn",
|
||||
table: "Litters",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Stillborn",
|
||||
table: "Litters");
|
||||
}
|
||||
}
|
||||
}
|
||||
1927
GerbilManagerWebAPI/Migrations/20260714055648_AddGerbilBirthOrder.Designer.cs
generated
Normal file
1927
GerbilManagerWebAPI/Migrations/20260714055648_AddGerbilBirthOrder.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 AddGerbilBirthOrder : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "BirthOrder",
|
||||
table: "Gerbils",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BirthOrder",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("51720002-0000-0000-0000-000000000070"),
|
||||
Data = "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
|
||||
Data = "{\"markdown\":\"## Angaben gemäß § 18 Abs. 1 MStV\\n\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\n\\n## Kontakt\\n\\nE-Mail: [E-Mail-Adresse]\\nTelefon: [Telefonnummer]\\n\\n---\\n\\nDiese Seite stellt eine private Hobbyzucht mongolischer Rennmäuse ohne Gewinnerzielungsabsicht vor.\\n\\n*Hinweis für den Seitenbetreiber: Name und ladungsfähige Anschrift (kein Postfach) sind Pflicht (§ 18 Abs. 1 MStV). Sollte die Zucht jemals gewerblich bzw. geschäftsmäßig betrieben werden, gelten zusätzlich § 5 DDG (vollständiges Anbieter-Impressum) und ggf. die Erlaubnispflicht nach § 11 TierSchG — dann zuständige Behörde und Aktenzeichen hier ergänzen. Kein Hinweis auf die EU-Streitbeilegungsplattform: diese wurde zum 20.07.2025 abgeschaltet (ein fortbestehender Link wäre irreführend).*\"}",
|
||||
Order = 1,
|
||||
PageId = new Guid("51720001-0000-0000-0000-000000000007"),
|
||||
Type = "RichText"
|
||||
@@ -163,7 +163,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("51720002-0000-0000-0000-000000000080"),
|
||||
Data = "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
|
||||
Data = "{\"markdown\":\"## Datenschutzerklärung\\n\\n## 1. Verantwortlicher\\n\\nVerantwortlich für die Datenverarbeitung auf dieser Webseite ist:\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\nE-Mail: [E-Mail-Adresse]\\n\\n## 2. Hosting und Server-Logfiles\\n\\nDiese Webseite wird auf einem eigenen Server betrieben. Beim Aufruf der Seite verarbeitet der Server automatisch Informationen, die dein Browser übermittelt, in sogenannten Server-Logfiles: IP-Adresse, Datum und Uhrzeit des Zugriffs, die aufgerufene Seite bzw. Datei, die übertragene Datenmenge sowie Browser und Betriebssystem.\\nDiese Verarbeitung ist technisch erforderlich, um die Webseite auszuliefern und ihren sicheren und stabilen Betrieb zu gewährleisten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am störungsfreien Betrieb und an der Sicherheit). Die Logfiles werden nach spätestens [z. B. 7] Tagen gelöscht und nicht mit anderen Daten zusammengeführt.\\n\\n## 3. Cookies, Tracking und externe Dienste\\n\\nDiese Webseite verwendet keine Cookies, kein Tracking und keine Analyse- oder Marketing-Dienste. Es werden keine externen Inhalte Dritter (z. B. Schriftarten, Karten, Videos, CDNs) nachgeladen.\\n\\n## 4. Kontaktaufnahme\\n\\nWenn du uns per E-Mail kontaktierst, verarbeiten wir deine Angaben (z. B. Name, E-Mail-Adresse, Inhalt der Nachricht) ausschließlich zur Bearbeitung deiner Anfrage. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO bzw. Art. 6 Abs. 1 lit. f DSGVO. Die Daten werden gelöscht, sobald sie nicht mehr benötigt werden und keine gesetzlichen Aufbewahrungspflichten entgegenstehen.\\n\\n## 5. Empfänger der Daten\\n\\nEine Weitergabe deiner Daten an Dritte findet nicht statt. Eine Datenübermittlung in Länder außerhalb der EU bzw. des EWR erfolgt nicht.\\n\\n## 6. Deine Rechte\\n\\nDu hast jederzeit das Recht auf:\\n- Auskunft über die zu deiner Person gespeicherten Daten (Art. 15 DSGVO)\\n- Berichtigung unrichtiger Daten (Art. 16 DSGVO)\\n- Löschung (Art. 17 DSGVO)\\n- Einschränkung der Verarbeitung (Art. 18 DSGVO)\\n- Datenübertragbarkeit (Art. 20 DSGVO)\\n- Widerspruch gegen die Verarbeitung (Art. 21 DSGVO)\\n\\nZur Ausübung genügt eine formlose Nachricht an die oben genannte E-Mail-Adresse.\\n\\n## 7. Beschwerderecht bei der Aufsichtsbehörde\\n\\nDu hast das Recht, dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO). Zuständig ist die Aufsichtsbehörde deines Bundeslandes bzw. des Seitenbetreibers: [zuständige Landesdatenschutzbehörde eintragen].\\n\\n## 8. Keine automatisierte Entscheidungsfindung\\n\\nEine automatisierte Entscheidungsfindung einschließlich Profiling nach Art. 22 DSGVO findet nicht statt.\\n\\n---\\n\\n*Hinweis für den Seitenbetreiber: Platzhalter in eckigen Klammern ausfüllen. Aufbewahrungsdauer der Logfiles an deinen Server bzw. Reverse-Proxy anpassen. Sobald externe Dienste oder ein Kontaktformular eingebunden werden, diesen Text ergänzen. Stand: [Datum eintragen].*\"}",
|
||||
Order = 1,
|
||||
PageId = new Guid("51720001-0000-0000-0000-000000000008"),
|
||||
Type = "RichText"
|
||||
@@ -756,6 +756,11 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<bool>("IsBreeder")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsReceiver")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -998,6 +1003,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int?>("BirthOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CauseOfDeath")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -1042,6 +1050,11 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<bool?>("IsDeaf")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsResident")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
@@ -1104,6 +1117,51 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.ToTable("Gerbils");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilOverride", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("EntityName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsVerified")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LastImportDiffJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LastImportSnapshotJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("OverrideJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SnapshotJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("VerifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GerbilId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("GerbilOverrides");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1187,6 +1245,11 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<Guid?>("FatherId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("LitterLetter")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -1206,6 +1269,12 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<string>("Provenance")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("ShowInChronicle")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int?>("Stillborn")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("TotalBorn")
|
||||
.HasColumnType("integer");
|
||||
|
||||
|
||||
@@ -30,5 +30,10 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// notes). Written by the Python merge_and_resolve step and surfaced read-only
|
||||
/// ("Datenherkunft"). Null = manually-added contact / no import data.</summary>
|
||||
public string? Provenance { get; set; }
|
||||
|
||||
/// <summary>true = manually created in the UI; false = produced by an import path. The ingest
|
||||
/// re-import wipe deletes ONLY IsManual=false contacts, so a manually-added contact (e.g. the
|
||||
/// Herkunft/Abnehmer of a manual animal) is never wiped. All import paths leave this false.</summary>
|
||||
public bool IsManual { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,16 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// imported animals. See hive/agents/god/OWNERSHIP-residency.md. Gridify-filterable.</summary>
|
||||
public bool IsResident { get; set; } = true;
|
||||
public bool IsCastrated { get; set; }
|
||||
|
||||
/// <summary>true = manually created in the UI (POST /gerbils); false = produced by an import
|
||||
/// path. The ingest re-import wipe deletes ONLY IsManual=false rows, so manually-added animals
|
||||
/// (and their sub-records) are never wiped or overwritten. All import paths leave this false.</summary>
|
||||
public bool IsManual { get; set; }
|
||||
|
||||
/// <summary>Reihenfolge der Jungtiere innerhalb ihres Wurfs (aus der Wurfchronik, sortiert nach
|
||||
/// Geburtsgewicht). null = unbekannt → alphabetische Sortierung als Fallback. Wird im Import aus
|
||||
/// conflict-decisions.json (`litterOrder`) gesetzt; von der Wurf- und Akten-Anzeige genutzt.</summary>
|
||||
public int? BirthOrder { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>
|
||||
|
||||
62
GerbilManagerWebAPI/Models/GerbilOverride.cs
Normal file
62
GerbilManagerWebAPI/Models/GerbilOverride.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// VERIFIED/PROTECTED OVERRIDE: authoritative, hand-curated per-gerbil field values. Decoupled
|
||||
/// from the rest of the model on purpose — <see cref="GerbilId"/> is a plain Guid column (NOT an
|
||||
/// enforced foreign key, no navigation property), so the import re-ingest wipe
|
||||
/// (<c>IngestResolvedService</c>) can delete and reload gerbils without deleting or breaking
|
||||
/// override rows. After every ingest the override is re-applied on top of the fresh import (the
|
||||
/// "freeze"), so the breeder's curated values are never lost. Matched to the (deterministic)
|
||||
/// gerbil id. Same ingest-surviving pattern as <see cref="Feedback"/>.
|
||||
///
|
||||
/// Two flavours, both stored here:
|
||||
/// - <see cref="IsVerified"/> = true → "vollständig korrekt": the whole own-field freeze set is
|
||||
/// pinned, and a full Akte snapshot (<see cref="SnapshotJson"/>) is kept for the drift report,
|
||||
/// export and regression test.
|
||||
/// - <see cref="IsVerified"/> = false → "geschützt": created by a manual edit; only the changed
|
||||
/// fields are pinned (per-field) so untouched fields keep receiving import improvements.
|
||||
/// </summary>
|
||||
public class GerbilOverride
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Loose reference (no FK) to the gerbil this override curates.</summary>
|
||||
public Guid GerbilId { get; set; }
|
||||
|
||||
/// <summary>Captured gerbil name — keeps the row human-readable and survives an ingest wipe.</summary>
|
||||
public string? EntityName { get; set; }
|
||||
|
||||
/// <summary>true = "vollständig korrekt" (full freeze + certified); false = "geschützt" (per-field edit).</summary>
|
||||
public bool IsVerified { get; set; }
|
||||
|
||||
/// <summary>Frozen own-field values as a JSON object (field name → value). Verified rows carry
|
||||
/// the full freeze set; protected rows carry only the manually-changed fields. Re-applied on
|
||||
/// top of the fresh import after every ingest.</summary>
|
||||
public required string OverrideJson { get; set; }
|
||||
|
||||
/// <summary>Full human-readable Akte snapshot (self + parents + own litters/children) captured
|
||||
/// at verify time — feeds the drift report, export and regression test. null for protected-only rows.</summary>
|
||||
public string? SnapshotJson { get; set; }
|
||||
|
||||
/// <summary>Raw-import Akte snapshot captured during the LAST ingest, BEFORE the freeze was
|
||||
/// applied — lets the UI show where the raw import disagrees with the golden state. null until
|
||||
/// the first ingest after verifying.</summary>
|
||||
public string? LastImportSnapshotJson { get; set; }
|
||||
|
||||
/// <summary>Cached diff (golden ↔ last raw import) as a JSON array, computed at ingest time.
|
||||
/// null/empty = no drift / not yet computed.</summary>
|
||||
public string? LastImportDiffJson { get; set; }
|
||||
|
||||
/// <summary>Optional free-text note the breeder attached when verifying.</summary>
|
||||
public string? Note { get; set; }
|
||||
|
||||
/// <summary>When the row was marked "vollständig korrekt"; null for protected-only rows.</summary>
|
||||
public DateTimeOffset? VerifiedAt { get; set; }
|
||||
|
||||
/// <summary>When the override was last created/updated.</summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,11 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// Null = unbekannt (Altdaten). Must be ≤ TotalBorn if both are set.</summary>
|
||||
public int? DeathsWithin8Weeks { get; set; }
|
||||
|
||||
/// <summary>Totgeburten (TG): tot geborene Jungtiere dieses Wurfs. Getrennt von
|
||||
/// DeathsWithin8Weeks (in den ersten 8 Wochen verstorben). Aus der xlsx-Wurfchronik
|
||||
/// (litters.json breakdown) importiert. Null = unbekannt/nicht erfasst.</summary>
|
||||
public int? Stillborn { get; set; }
|
||||
|
||||
public Guid? FatherId { get; set; }
|
||||
public Gerbil? Father { get; set; }
|
||||
public Guid? MotherId { get; set; }
|
||||
@@ -45,5 +50,17 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// Written by the Python merge_and_resolve step and surfaced read-only ("Datenherkunft").
|
||||
/// Null = manually-added litter / no import data.</summary>
|
||||
public string? Provenance { get; set; }
|
||||
|
||||
/// <summary>Steuert, ob dieser Wurf in der Wurfchronik (Würfe-Liste) erscheint.
|
||||
/// true = normal sichtbar. false = aus der Wurfchronik ausgeblendet, aber weiterhin
|
||||
/// auf der Tier-Detailseite des Elterntiers sichtbar — z. B. ein extern (bei einer
|
||||
/// anderen Zucht) entstandener Wurf eines nicht-residenten Tieres, der nur zur
|
||||
/// Dokumentation der Abstammung gehört. Default true (alle bestehenden Würfe).</summary>
|
||||
public bool ShowInChronicle { get; set; } = true;
|
||||
|
||||
/// <summary>true = manually created in the UI; false = produced by an import path. The ingest
|
||||
/// re-import wipe deletes ONLY IsManual=false litters, so a manually-added litter (e.g. the
|
||||
/// birth litter of a manual animal) is never wiped. All import paths leave this false.</summary>
|
||||
public bool IsManual { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ app.MapCmsEndpoints();
|
||||
app.MapRequestEndpoints();
|
||||
app.MapNamesEndpoints();
|
||||
app.MapFeedbackEndpoints();
|
||||
app.MapVerifiedGerbilEndpoints();
|
||||
app.MapPushEndpoints();
|
||||
app.MapAcquisitionEndpoints();
|
||||
app.MapSaleReservationEndpoints();
|
||||
|
||||
Reference in New Issue
Block a user