feat(triage): Ticket-Fixes (Daten + Code) + prod-fähige Triage
Daten-Fixes (conflict-decisions.json, re-ingest-stabil) für ~30 Tickets:
Merges (Jamie/Hiro/Mino/Jana/Blacky/Sakura/Malou/Socke→Marty), Eltern-Korrekturen
(Jacky/Idefix/Ichika/Roni/Ethan), Kruke→Kuke (+ Todesdatum), Targa-Wurf R14 + Druna,
Stacy/Merle/Domi/Eliza; Joghurt-Phantomwurf entfernt.
Code-Fixes:
- Gaida & alle Verstorbenen: Status wird aus Todesdatum/Abgabe abgeleitet
(Program.cs Startup-Sweep heilt Altfälle; IngestResolved re-derived nach Freeze).
- CoCo: Scheckungsart wird bei jeder Schecke angezeigt (Platzhalter wenn leer).
- M-Wurf/Gale: über-gemergte Fremdtiere via neuem litterChildren-Override entfernt.
- renameTo eltern-verknüpfungssicher (Quell-Name im Index); dateOfDeath als Override.
Prod-fähige Triage (API):
- GET /feedback/{id} + GET /feedback?status= (kein 2-MB-Dump).
- POST /import/ingest-resolved/upload (multipart) → Ingest gegen Prod ohne SSH.
Tests: 280 Backend, 149 Frontend, alle Python, betroffene Playwright grün.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,12 +55,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.Created($"/feedback/{entity.Id}", ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapGet("/", async (ApplicationContext db) =>
|
||||
group.MapGet("/", async (ApplicationContext db, string? status) =>
|
||||
{
|
||||
// In memory verarbeiten: SQLite (Test-Host) kann weder ORDER BY noch WHERE-Vergleiche
|
||||
// auf DateTimeOffset-Spalten übersetzen.
|
||||
var rows = await db.Feedback.ToListAsync();
|
||||
|
||||
// Optionaler Status-Filter (?status=Open,Answered) — kommagetrennt, case-insensitiv.
|
||||
// Spart der Triage den 2-MB-Volldump; soft-gelöschte Tickets werden dann ausgeblendet.
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
var wanted = status.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
rows = rows.Where(f => f.DeletedAt is null && wanted.Contains(f.Status)).ToList();
|
||||
}
|
||||
|
||||
// Aufräumen: soft-gelöschte Tickets werden nach TrashRetentionDays endgültig entfernt
|
||||
// (lazy beim Abruf — genügt für Single-User, kein Hintergrunddienst nötig).
|
||||
var cutoff = DateTimeOffset.UtcNow.AddDays(-TrashRetentionDays);
|
||||
@@ -90,6 +99,22 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
.ToList());
|
||||
});
|
||||
|
||||
// Einzelnes Ticket (inkl. Anhang-Metadaten). Praktisch für die Triage, um ein Ticket
|
||||
// gezielt zu laden, statt die ganze Liste zu ziehen. 404 bei unbekannter Id.
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<FeedbackDto>, NotFound>> (
|
||||
Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
var atts = await db.FeedbackAttachments
|
||||
.Where(a => a.FeedbackId == id)
|
||||
.Select(a => new FeedbackAttachmentDto(a.Id, a.FileName, a.ContentType, a.Size))
|
||||
.ToListAsync();
|
||||
return TypedResults.Ok(ToDto(entity, atts));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<Ok<FeedbackDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, FeedbackUpdate input, ApplicationContext db, Push.PushNotifier push) =>
|
||||
{
|
||||
|
||||
@@ -44,6 +44,39 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.Ok(result);
|
||||
});
|
||||
|
||||
// Upload-Variante: lädt eine resolved_import.json (+ optional Fotos-ZIP) hoch, legt sie in
|
||||
// dem Verzeichnis ab, aus dem der Ingest liest, und ingested direkt. Damit lässt sich der
|
||||
// Import gegen PROD fahren, OHNE die Datei per SSH/docker cp in den Container zu kopieren
|
||||
// (der lokal generierte Payload geht per HTTP an die Prod-API). Abwärtskompatibel — der
|
||||
// bestehende /ingest-resolved-Endpoint bleibt unverändert.
|
||||
group.MapPost("/ingest-resolved/upload", async Task<Results<Ok<string>, BadRequest<string>>> (
|
||||
IFormFile resolved, IFormFile? photos,
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
if (resolved.Length == 0)
|
||||
return TypedResults.BadRequest("resolved_import.json ist leer.");
|
||||
|
||||
var sourceDir = IngestResolvedService.ResolveSourceDir(config, env);
|
||||
Directory.CreateDirectory(sourceDir);
|
||||
|
||||
// resolved_import.json an den erwarteten Ort schreiben.
|
||||
var destPath = Path.Combine(sourceDir, "resolved_import.json");
|
||||
await using (var fs = File.Create(destPath))
|
||||
await resolved.CopyToAsync(fs);
|
||||
|
||||
// Optionale Fotos-ZIP in den Source-Dir entpacken (Einträge relativ), damit der
|
||||
// Foto-Kopierschritt (Path.Combine(sourceDir, relPath)) neue Fotos findet.
|
||||
if (photos is not null)
|
||||
{
|
||||
await using var zs = photos.OpenReadStream();
|
||||
using var seekable = await ToSeekable(zs);
|
||||
ExtractZipInto(seekable, sourceDir);
|
||||
}
|
||||
|
||||
var result = await new IngestResolvedService(db, config, env).RunAsync();
|
||||
return TypedResults.Ok(result);
|
||||
}).WithTags("Import").DisableAntiforgery();
|
||||
|
||||
// ── RPRO3 ──────────────────────────────────────────────────────────
|
||||
group.MapPost("/rpro3/analyze", async Task<Results<Ok<Rpro3AnalyzeResult>, BadRequest<string>>> (
|
||||
IFormFile backup, IFormFile? images,
|
||||
@@ -143,6 +176,25 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return map;
|
||||
}
|
||||
|
||||
// Entpackt ein ZIP relativ in destDir (Einträge behalten ihren relativen Pfad). Schützt vor
|
||||
// Zip-Slip (Einträge, die aus destDir ausbrechen wollen) und überspringt Verzeichnis-Einträge.
|
||||
private static void ExtractZipInto(Stream zip, string destDir)
|
||||
{
|
||||
var root = Path.GetFullPath(destDir);
|
||||
using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true);
|
||||
foreach (var e in archive.Entries)
|
||||
{
|
||||
if (string.IsNullOrEmpty(e.Name)) continue; // Verzeichnis-Eintrag
|
||||
var target = Path.GetFullPath(Path.Combine(root, e.FullName));
|
||||
if (!target.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.Ordinal)
|
||||
&& !string.Equals(target, root, StringComparison.Ordinal))
|
||||
continue; // Zip-Slip abwehren
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
try { e.ExtractToFile(target, overwrite: true); }
|
||||
catch { /* defektes Archiv-Entry überspringen */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeleteDir(string dir)
|
||||
{
|
||||
try { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); }
|
||||
|
||||
@@ -18,12 +18,23 @@ namespace GerbilManagerWebAPI.Import
|
||||
public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
|
||||
{
|
||||
_db = db;
|
||||
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
|
||||
_sourceDir = config["Import:SourcePath"]
|
||||
?? Path.GetFullPath(Path.Combine(contentRoot, "..", "tools", "import", "output"));
|
||||
_sourceDir = ResolveSourceDir(config, env);
|
||||
_resolvedJsonPath = Path.Combine(_sourceDir, "resolved_import.json");
|
||||
_photoRoot = config["Photos:RootPath"]
|
||||
?? Path.Combine(contentRoot, "photo-storage");
|
||||
?? Path.Combine(env?.ContentRootPath ?? Directory.GetCurrentDirectory(), "photo-storage");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verzeichnis, in dem der Ingest <c>resolved_import.json</c> (+ referenzierte Fotos) erwartet.
|
||||
/// Öffentlich, damit der Upload-Endpoint die hochgeladene Datei an genau denselben Ort stagen kann
|
||||
/// (siehe <c>POST /import/ingest-resolved/upload</c>) — so lässt sich der Ingest gegen Prod fahren,
|
||||
/// ohne die Datei per SSH/docker cp in den Container zu kopieren.
|
||||
/// </summary>
|
||||
public static string ResolveSourceDir(IConfiguration config, IWebHostEnvironment? env)
|
||||
{
|
||||
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
|
||||
return config["Import:SourcePath"]
|
||||
?? Path.GetFullPath(Path.Combine(contentRoot, "..", "tools", "import", "output"));
|
||||
}
|
||||
|
||||
public async Task<string> RunAsync()
|
||||
@@ -313,6 +324,10 @@ namespace GerbilManagerWebAPI.Import
|
||||
if (gerbilsById.TryGetValue(ov.GerbilId, out var gg))
|
||||
{
|
||||
GerbilSnapshotService.ApplyOverride(gg, ov.OverrideJson);
|
||||
// Der Override kann Todesdatum/Abgabe/Geburtsdatum setzen; Status neu ableiten,
|
||||
// damit ein verstorbenes/abgegebenes Tier nicht als 'Zucht' hängen bleibt
|
||||
// (Ticket 37ab228a "Gaida"). Status/Gehege sind selbst NICHT eingefroren.
|
||||
GerbilStatusService.Apply(gg, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
overridesApplied++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,20 +90,24 @@ if (!app.Environment.IsEnvironment("Testing"))
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||
db.Database.Migrate();
|
||||
|
||||
// Startup sweep: derive status for animals that silently crossed the 7-year threshold
|
||||
// since the last write. No-op if all statuses are already current.
|
||||
// Startup sweep: heal any stored status that disagrees with the derived status — animals
|
||||
// that silently crossed the 7-year threshold (→ Deceased), or whose death date / Abgabe was
|
||||
// set on a path that didn't re-derive the status (e.g. a frozen GerbilOverride re-applied after
|
||||
// ingest — Ticket 37ab228a "Gaida"). Only currently-active animals (Breeding/Pet/ForSale) are
|
||||
// considered, so nobody gets un-deceased. No-op if all statuses are already current.
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var candidates = await db.Gerbils
|
||||
.Where(g => g.Status != GerbilStatus.Deceased && g.Status != GerbilStatus.GivenAway
|
||||
&& g.DateOfDeath == null && g.ReceiverContactId == null
|
||||
&& g.DateOfBirth != null
|
||||
&& g.DateOfBirth < today.AddYears(-GerbilStatusService.MaxAgeYears))
|
||||
.Where(g => g.Status != GerbilStatus.Deceased && g.Status != GerbilStatus.GivenAway)
|
||||
.ToListAsync();
|
||||
if (candidates.Count > 0)
|
||||
var healed = 0;
|
||||
foreach (var g in candidates)
|
||||
{
|
||||
foreach (var g in candidates) g.Status = GerbilStatus.Deceased;
|
||||
await db.SaveChangesAsync();
|
||||
var derived = GerbilStatusService.Derive(g.Status, g.DateOfBirth, g.DateOfDeath,
|
||||
isAbgegeben: g.ReceiverContactId is not null, today);
|
||||
if (derived != g.Status) { g.Status = derived; healed++; }
|
||||
}
|
||||
if (healed > 0)
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
app.UseCors(LanCorsPolicy);
|
||||
|
||||
Reference in New Issue
Block a user