Compare commits
86 Commits
feature/ge
...
5e14124322
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e14124322 | |||
| aa473b764e | |||
| 2befe53df9 | |||
| cf672c0933 | |||
| a31fea17fa | |||
| 438c816820 | |||
| 9c98c37d2a | |||
| b9e5b031c4 | |||
| 1845d37476 | |||
| 2868aad369 | |||
| 03ad9e6e81 | |||
| 18efab6996 | |||
| 070a896072 | |||
| 3715e32303 | |||
| cb2ee03a14 | |||
| 41ff973f9a | |||
| d2993418b6 | |||
| 4cb39cb180 | |||
| b53b8cf2ff | |||
| e460cd5905 | |||
| b83e96f552 | |||
| bddbaf59d3 | |||
| 3021149ddd | |||
| 46f987422b | |||
| 5d2316d670 | |||
| c6968b9968 | |||
| f3f4534392 | |||
| d189da954f | |||
| 5b32c09076 | |||
| ea760fba06 | |||
| 396d8f05d8 | |||
| 5732398e60 | |||
| 0185446bfe | |||
| 04d189bd2f | |||
| 7d8166c555 | |||
| d5c7b77e7e | |||
| bff25b4683 | |||
| aa1d22cec7 | |||
| f7e97ad7a2 | |||
| 7d552d33cd | |||
| 4d7333b714 | |||
| 62b07597f5 | |||
| 5ce35af813 | |||
| 23b6dad303 | |||
| e9eef5cce6 | |||
| 299c038449 | |||
| ecd7ad2658 | |||
| 6de56ab112 | |||
| ddc343a96b | |||
| a26bc4d75e | |||
| d4dfe6c3ac | |||
| 40f3ce250d | |||
| ffbbc0dfad | |||
| 05cb1af39e | |||
| 78f87f0d44 | |||
| 7f6855be62 | |||
| 29e9c4cd64 | |||
| 8eddb83c99 | |||
| 1a5f3218fb | |||
| 2d54cb901f | |||
| 03f8e4de90 | |||
| 70803efa1f | |||
| 25af10a736 | |||
| ade40eef5e | |||
| c1b215d06f | |||
| 7525970ffc | |||
| bfc90f8581 | |||
| c45bcc99f0 | |||
| c4a661b5b6 | |||
| 30b2fff775 | |||
| c0ecf2022d | |||
| 2198c33898 | |||
| 04971bf3bb | |||
| 08500757d7 | |||
| 05ef3d9567 | |||
| 7c12a65938 | |||
| 5361a292f1 | |||
| cb5acd2005 | |||
| bda5479b93 | |||
| d5c155953b | |||
| 36795bd974 | |||
| ae60b47ad3 | |||
| adb9e93cdb | |||
| 3aa9ac9811 | |||
| c452b69dd6 | |||
| f20a42a94b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -137,3 +137,6 @@ GerbilManagerWebAPI/photo-storage/
|
||||
|
||||
# AR-3: Data Protection key ring (dev-only ephemeral keys) — never commit
|
||||
GerbilManagerWebAPI/.data-protection-keys/
|
||||
|
||||
# node_modules — never commit, installed locally per npm install
|
||||
node_modules/
|
||||
|
||||
103
GerbilManager.Tests/CmsPublishTests.cs
Normal file
103
GerbilManager.Tests/CmsPublishTests.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using GerbilManagerWebAPI.Endpoints;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>WEB-2: POST /api/publish — atomic swap, file layout, staging cleanup.</summary>
|
||||
public class CmsPublishTests
|
||||
{
|
||||
private static string TempRoot() =>
|
||||
Path.Combine(Path.GetTempPath(), "gm-publish-test-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_erstellt_live_Verzeichnis_mit_allen_Dateien()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
var files = new Dictionary<string, string>
|
||||
{
|
||||
["index.html"] = "<html>start</html>",
|
||||
["kontakt/index.html"] = "<html>kontakt</html>",
|
||||
["assets/site.css"] = "body {}",
|
||||
};
|
||||
|
||||
await CmsEndpoints.PublishToDirectoryAsync(files, root);
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(root, "live", "index.html")));
|
||||
Assert.True(File.Exists(Path.Combine(root, "live", "kontakt", "index.html")));
|
||||
Assert.True(File.Exists(Path.Combine(root, "live", "assets", "site.css")));
|
||||
Assert.Equal("<html>start</html>",
|
||||
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_atomarer_Swap_ueberschreibt_alte_live_Version()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = "version-1" }, root);
|
||||
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = "version-2" }, root);
|
||||
|
||||
var content = await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html"));
|
||||
Assert.Equal("version-2", content);
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_kein_staging_oder_old_Verzeichnis_nach_Swap()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = "x" }, root);
|
||||
|
||||
Assert.False(Directory.Exists(Path.Combine(root, "_staging_new")));
|
||||
Assert.False(Directory.Exists(Path.Combine(root, "_old")));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_mehrfach_ohne_Fehler()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
for (int i = 1; i <= 3; i++)
|
||||
{
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = $"v{i}" }, root);
|
||||
}
|
||||
|
||||
Assert.Equal("v3",
|
||||
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_UTF8_Inhalt_korrekt_gespeichert()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
const string german = "<html>Züchter — Rennmäuse & mehr</html>";
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = german }, root);
|
||||
|
||||
var content = await File.ReadAllTextAsync(
|
||||
Path.Combine(root, "live", "index.html"),
|
||||
System.Text.Encoding.UTF8);
|
||||
Assert.Equal(german, content);
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ public class CmsTests : IClassFixture<ApiFactory>
|
||||
var encId = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Verkaufsmaus", gender = "female", status = "ForSale", enclosureId = encId });
|
||||
// a non-ForSale gerbil must NOT appear
|
||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Active" });
|
||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Breeding" });
|
||||
|
||||
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
||||
var animals = doc.RootElement.GetProperty("pages").EnumerateArray()
|
||||
|
||||
@@ -60,7 +60,7 @@ namespace GerbilManager.Tests
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Krümel",
|
||||
Gender = Gender.female,
|
||||
Status = GerbilStatus.Active,
|
||||
Status = GerbilStatus.Breeding,
|
||||
DateOfBirth = new DateOnly(2025, 3, 12),
|
||||
LitterId = litter.Id,
|
||||
EnclosureId = enclosure.Id,
|
||||
@@ -118,7 +118,7 @@ namespace GerbilManager.Tests
|
||||
var row = lines[1];
|
||||
Assert.Contains("Krümel", row);
|
||||
Assert.Contains("Weiblich", row);
|
||||
Assert.Contains("Aktiv", row);
|
||||
Assert.Contains("Zucht", row);
|
||||
Assert.Contains("12.03.2025", row);
|
||||
Assert.Contains("Großbecken", row);
|
||||
Assert.Contains("Wurf K", row);
|
||||
|
||||
181
GerbilManager.Tests/FeedbackEndpointTests.cs
Normal file
181
GerbilManager.Tests/FeedbackEndpointTests.cs
Normal file
@@ -0,0 +1,181 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// FEEDBACK: the "Fehler melden" report sink.
|
||||
/// - POST /feedback persists a row (with captured debug context) and GET /feedback returns it.
|
||||
/// - Validation: an empty message is rejected with 400.
|
||||
/// - CRITICAL: feedback rows survive the import re-ingest wipe (loose, FK-free GerbilId/LitterId).
|
||||
/// </summary>
|
||||
public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public FeedbackEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Post_feedback_persists_row_with_debug_context()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var gerbilId = Guid.NewGuid();
|
||||
var resp = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Der Stammbaum zeigt den falschen Vater.",
|
||||
context = "stammbaum",
|
||||
gerbilId,
|
||||
litterId = (Guid?)null,
|
||||
entityName = "Krümel",
|
||||
url = "http://localhost:5173/rennmaeuse/kruemel/stammbaum",
|
||||
clientTimestamp = "2026-06-22T12:00:00Z",
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.False(string.IsNullOrEmpty(created.GetProperty("id").GetString()));
|
||||
Assert.Equal("stammbaum", created.GetProperty("context").GetString());
|
||||
Assert.Equal("Krümel", created.GetProperty("entityName").GetString());
|
||||
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
|
||||
|
||||
// GET returns it (newest first)
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
Assert.Contains(listed.EnumerateArray(),
|
||||
f => f.GetProperty("entityName").GetString() == "Krümel"
|
||||
&& f.GetProperty("message").GetString() == "Der Stammbaum zeigt den falschen Vater.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_feedback_rejects_empty_message()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var resp = await client.PostAsJsonAsync("/feedback", new { message = " ", context = "gerbil-detail" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Feedback_survives_ingest_wipe()
|
||||
{
|
||||
// Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "feedback-ingest-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var contactId = Guid.NewGuid();
|
||||
var fatherId = Guid.NewGuid();
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
Contacts = new[]
|
||||
{
|
||||
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
|
||||
},
|
||||
Litters = new[]
|
||||
{
|
||||
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
|
||||
},
|
||||
Gerbils = new[]
|
||||
{
|
||||
Animal(fatherId, "Papa", "male", contactId),
|
||||
Animal(motherId, "Mama", "female", contactId),
|
||||
},
|
||||
GerbilPhotos = Array.Empty<object>(),
|
||||
};
|
||||
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("feedback-ingest-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
// A feedback report referencing the gerbil + litter that the wipe will delete.
|
||||
var feedbackId = Guid.NewGuid();
|
||||
db.Feedback.Add(new Feedback
|
||||
{
|
||||
Id = feedbackId,
|
||||
Message = "Bitte prüfen.",
|
||||
Context = "gerbil-detail",
|
||||
GerbilId = fatherId,
|
||||
LitterId = litterId,
|
||||
EntityName = "Papa",
|
||||
Url = "http://localhost/rennmaeuse/papa",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
// A contact-scoped feedback report — the ContactId is a loose (FK-free) id,
|
||||
// so it must survive the contact-table wipe just like gerbil/litter ids.
|
||||
var contactFeedbackId = Guid.NewGuid();
|
||||
db.Feedback.Add(new Feedback
|
||||
{
|
||||
Id = contactFeedbackId,
|
||||
Message = "Adresse stimmt nicht.",
|
||||
Context = "contact-detail",
|
||||
ContactId = contactId,
|
||||
EntityName = "Test Breeder",
|
||||
Url = "http://localhost/kontakte/test-breeder",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
|
||||
.Build();
|
||||
|
||||
// Run the ingest wipe + reload.
|
||||
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
Assert.Contains("Ingestion successful!", result);
|
||||
|
||||
// Gerbils/litters/contacts were wiped & re-created, but feedback is untouched.
|
||||
var survivor = await db.Feedback.SingleAsync(f => f.Id == feedbackId);
|
||||
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
|
||||
Assert.Equal(litterId, survivor.LitterId);
|
||||
Assert.Equal("Papa", survivor.EntityName);
|
||||
|
||||
// The contact-scoped report also survives the contacts wipe (loose ContactId).
|
||||
var contactSurvivor = await db.Feedback.SingleAsync(f => f.Id == contactFeedbackId);
|
||||
Assert.Equal(contactId, contactSurvivor.ContactId);
|
||||
Assert.Equal("contact-detail", contactSurvivor.Context);
|
||||
Assert.Equal("Test Breeder", contactSurvivor.EntityName);
|
||||
Assert.Equal(2, await db.Feedback.CountAsync());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Gender = gender,
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
DateOfBirth = "2025-01-01",
|
||||
DateOfDeath = (string?)null,
|
||||
CauseOfDeath = (string?)null,
|
||||
GoHomeDate = (string?)null,
|
||||
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||
Notes = "",
|
||||
ImportSource = "docx-export",
|
||||
ExternalRef = "ext-" + name,
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = name.ToLowerInvariant(),
|
||||
CharacterTraits = Array.Empty<string>(),
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true,
|
||||
};
|
||||
}
|
||||
83
GerbilManager.Tests/GerbilProfilePhotoTests.cs
Normal file
83
GerbilManager.Tests/GerbilProfilePhotoTests.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>profilePhotoUrl: computed field on GerbilDto — first photo by sortOrder, or null.</summary>
|
||||
public class GerbilProfilePhotoTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly ApiFactory _factory;
|
||||
|
||||
public GerbilProfilePhotoTests(ApiFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
private static Guid ExtractId(string json) =>
|
||||
Guid.Parse(Regex.Match(json, "\"id\":\"([^\"]+)\"").Groups[1].Value);
|
||||
|
||||
private static string? GetStr(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString() : null;
|
||||
|
||||
private async Task<Guid> CreateGerbil(string name)
|
||||
{
|
||||
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name, gender = "female" }));
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
return ExtractId(await resp.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
private void InsertPhotos(Guid gerbilId, params (string fileName, int sortOrder)[] photos)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||
foreach (var (fn, so) in photos)
|
||||
db.GerbilPhotos.Add(new GerbilPhoto
|
||||
{
|
||||
Id = Guid.NewGuid(), GerbilId = gerbilId,
|
||||
FileName = fn, SortOrder = so, CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_without_photos_returns_null_profilePhotoUrl()
|
||||
{
|
||||
var id = await CreateGerbil("NoPhoto");
|
||||
var json = await _client.GetStringAsync($"/gerbils/{id}");
|
||||
var el = JsonDocument.Parse(json).RootElement;
|
||||
Assert.True(el.TryGetProperty("profilePhotoUrl", out var v));
|
||||
Assert.Equal(JsonValueKind.Null, v.ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_with_photos_returns_lowest_sortOrder_url()
|
||||
{
|
||||
var id = await CreateGerbil("PhotoGerbil");
|
||||
InsertPhotos(id, ("second.jpg", 1), ("first.jpg", 0));
|
||||
|
||||
var json = await _client.GetStringAsync($"/gerbils/{id}");
|
||||
var el = JsonDocument.Parse(json).RootElement;
|
||||
Assert.Equal("/photos/files/first.jpg", GetStr(el, "profilePhotoUrl"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetList_returns_profilePhotoUrl_without_n_plus_1()
|
||||
{
|
||||
var id = await CreateGerbil("ListPhoto");
|
||||
InsertPhotos(id, ("list-photo.jpg", 0));
|
||||
|
||||
var json = await _client.GetStringAsync("/gerbils");
|
||||
var root = JsonDocument.Parse(json).RootElement;
|
||||
var items = root.GetProperty("items").EnumerateArray().ToList();
|
||||
var gerbil = items.FirstOrDefault(x => GetStr(x, "id") == id.ToString());
|
||||
Assert.NotEqual(default, gerbil);
|
||||
Assert.Equal("/photos/files/list-photo.jpg", GetStr(gerbil, "profilePhotoUrl"));
|
||||
}
|
||||
}
|
||||
58
GerbilManager.Tests/GerbilSearchTests.cs
Normal file
58
GerbilManager.Tests/GerbilSearchTests.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>SEARCH-1: separator-insensitive name search + OriginBreeder (Herkunft) filter.</summary>
|
||||
public class GerbilSearchTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public GerbilSearchTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
private static HttpContent Gerbil(string name, string? originBreeder = null) =>
|
||||
JsonContent.Create(new
|
||||
{
|
||||
name,
|
||||
gender = "female",
|
||||
originBreeder,
|
||||
});
|
||||
|
||||
[Theory]
|
||||
[InlineData("Clan-Kleine-Chaoten", "clankleinechaoten")]
|
||||
[InlineData("Clan Kleine Chaoten", "clankleinechaoten")]
|
||||
[InlineData("v.d. Kleinen_Chaoten", "vdkleinenchaoten")]
|
||||
public void Normalize_strips_separators_and_lowercases(string input, string expected) =>
|
||||
Assert.Equal(expected, GerbilSearch.Normalize(input));
|
||||
|
||||
[Fact]
|
||||
public async Task NameSearch_filter_matches_across_separators()
|
||||
{
|
||||
var create = await _client.PostAsync("/gerbils", Gerbil("Clan-Kleine-Chaoten"));
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
|
||||
// Gridify contains is "=*value" (no trailing '*', per the main hotfix 0ee2b53).
|
||||
// The client normalizes the user's term the same way the column is normalized.
|
||||
var json = await _client.GetStringAsync("/gerbils?filter=nameSearch=*clankleinechaoten");
|
||||
Assert.Contains("Clan-Kleine-Chaoten", json);
|
||||
|
||||
// a term that doesn't normalize-match must NOT return it
|
||||
var miss = await _client.GetStringAsync("/gerbils?filter=nameSearch=*completelyother");
|
||||
Assert.DoesNotContain("Clan-Kleine-Chaoten", miss);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OriginBreeder_is_filterable_and_listed_in_breeders_endpoint()
|
||||
{
|
||||
await _client.PostAsync("/gerbils", Gerbil("Herkunftstier", originBreeder: "Zucht der kleinen Chaoten"));
|
||||
|
||||
// distinct-values dropdown source
|
||||
var breeders = await _client.GetStringAsync("/gerbils/breeders");
|
||||
Assert.Contains("Zucht der kleinen Chaoten", breeders);
|
||||
|
||||
// Gridify filter on the field
|
||||
var filtered = await _client.GetStringAsync("/gerbils?filter=originBreeder==Zucht der kleinen Chaoten");
|
||||
Assert.Contains("Herkunftstier", filtered);
|
||||
}
|
||||
}
|
||||
122
GerbilManager.Tests/GerbilStatusTests.cs
Normal file
122
GerbilManager.Tests/GerbilStatusTests.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>STATUS-MODEL: GerbilStatusService.Derive() precedence rules.</summary>
|
||||
public class GerbilStatusTests
|
||||
{
|
||||
private static readonly DateOnly Today = new(2026, 6, 7);
|
||||
|
||||
// 1) DateOfDeath → Deceased, always overrides user input
|
||||
[Fact]
|
||||
public void DateOfDeath_set_returns_Deceased()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Deceased, status);
|
||||
}
|
||||
|
||||
// DateOfDeath wins even when also Abgegeben
|
||||
[Fact]
|
||||
public void DateOfDeath_wins_over_Abgegeben()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.GivenAway, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
|
||||
isAbgegeben: true, Today);
|
||||
Assert.Equal(GerbilStatus.Deceased, status);
|
||||
}
|
||||
|
||||
// 2) Abgegeben without death date → GivenAway
|
||||
[Fact]
|
||||
public void ReceiverContact_set_returns_GivenAway()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, new DateOnly(2022, 1, 1), dateOfDeath: null,
|
||||
isAbgegeben: true, Today);
|
||||
Assert.Equal(GerbilStatus.GivenAway, status);
|
||||
}
|
||||
|
||||
// 3) Age > 6y, no death, not abgegeben → Deceased (presumed)
|
||||
[Fact]
|
||||
public void OlderThan6y_without_death_returns_Deceased()
|
||||
{
|
||||
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(-1); // 1 day past threshold
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Deceased, status);
|
||||
}
|
||||
|
||||
// Exactly 6 years old is NOT presumed dead (threshold is strictly >)
|
||||
[Fact]
|
||||
public void ExactlyMaxAge_is_not_Deceased()
|
||||
{
|
||||
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears); // exactly 6y today
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
||||
// today >= dob.AddYears(6) → exactly equal → IS presumed dead
|
||||
// (threshold = "older than" so >= means Deceased)
|
||||
Assert.Equal(GerbilStatus.Deceased, status);
|
||||
}
|
||||
|
||||
// One day before threshold is alive
|
||||
[Fact]
|
||||
public void OneDayBeforeMaxAge_is_alive()
|
||||
{
|
||||
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(1);
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Breeding, status);
|
||||
}
|
||||
|
||||
// 3a) Age > 6y but Abgegeben → stays GivenAway (not Deceased)
|
||||
[Fact]
|
||||
public void OlderThan6y_but_Abgegeben_stays_GivenAway()
|
||||
{
|
||||
var dob = Today.AddYears(-7);
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.GivenAway, dob, dateOfDeath: null, isAbgegeben: true, Today);
|
||||
Assert.Equal(GerbilStatus.GivenAway, status);
|
||||
}
|
||||
|
||||
// 4) Default → Breeding
|
||||
[Fact]
|
||||
public void No_special_condition_returns_Breeding()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, new DateOnly(2024, 1, 1), dateOfDeath: null,
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Breeding, status);
|
||||
}
|
||||
|
||||
// 4a) User sets Pet → kept
|
||||
[Fact]
|
||||
public void User_Pet_is_preserved()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Pet, new DateOnly(2024, 1, 1), dateOfDeath: null,
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Pet, status);
|
||||
}
|
||||
|
||||
// 4b) User sets ForSale → kept
|
||||
[Fact]
|
||||
public void User_ForSale_is_preserved()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.ForSale, new DateOnly(2024, 1, 1), dateOfDeath: null,
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.ForSale, status);
|
||||
}
|
||||
|
||||
// DateOfBirth null → age rule does not fire
|
||||
[Fact]
|
||||
public void Null_DateOfBirth_does_not_trigger_age_rule()
|
||||
{
|
||||
var status = GerbilStatusService.Derive(
|
||||
GerbilStatus.Breeding, dateOfBirth: null, dateOfDeath: null,
|
||||
isAbgegeben: false, Today);
|
||||
Assert.Equal(GerbilStatus.Breeding, status);
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal(GerbilStatus.GivenAway, gerbil.Status);
|
||||
Assert.Equal(litter.Id, gerbil.LitterId); // date-only link worked
|
||||
Assert.Equal(new DateOnly(2023, 7, 1), gerbil.GoHomeDate);
|
||||
Assert.Equal("Zucht der Kleinen Chaoten", gerbil.OriginBreeder);
|
||||
Assert.Equal("Zucht der kleinen Chaoten", gerbil.OriginBreeder);
|
||||
Assert.False(gerbil.IsResident);
|
||||
Assert.Equal("docx", gerbil.ImportSource);
|
||||
Assert.StartsWith("docx-", gerbil.ExternalRef);
|
||||
|
||||
204
GerbilManager.Tests/IngestResolvedServiceTests.cs
Normal file
204
GerbilManager.Tests/IngestResolvedServiceTests.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
public class IngestResolvedServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
private readonly string _resolvedJsonPath;
|
||||
|
||||
public IngestResolvedServiceTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), "ingest-resolved-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_dir);
|
||||
_resolvedJsonPath = Path.Combine(_dir, "resolved_import.json");
|
||||
|
||||
var contactId = Guid.NewGuid();
|
||||
var fatherId = Guid.NewGuid();
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
var photoId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
Contacts = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = contactId,
|
||||
Name = "Test Breeder",
|
||||
ZuchtName = "Test Zucht",
|
||||
City = "Test City",
|
||||
Email = "test@example.com",
|
||||
Homepage = "",
|
||||
Phone = "",
|
||||
Address = "",
|
||||
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum\"],\"mergedRecordCount\":1,\"notes\":[\"als Züchter erkannt\"]}"
|
||||
}
|
||||
},
|
||||
Litters = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = litterId,
|
||||
Name = "Wurf A",
|
||||
Date = "2026-01-01",
|
||||
TotalBorn = 5,
|
||||
DeathsWithin8Weeks = 0,
|
||||
FatherId = fatherId,
|
||||
MotherId = motherId,
|
||||
ExpectedGoHomeDate = (string?)null,
|
||||
Notes = "Test litter notes",
|
||||
PairingCode = "PC01",
|
||||
ExternalRef = "ext-litter-1",
|
||||
LitterLetter = "A",
|
||||
Provenance = (string?)"{\"sourceFiles\":[\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":1,\"fromWurfchronik\":true,\"notes\":[\"aus Wurfchronik\"]}"
|
||||
}
|
||||
},
|
||||
Gerbils = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = fatherId,
|
||||
Name = "Papa",
|
||||
Gender = "male",
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
|
||||
DateOfBirth = "2025-01-01",
|
||||
DateOfDeath = (string?)null,
|
||||
CauseOfDeath = (string?)null,
|
||||
GoHomeDate = (string?)null,
|
||||
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||
Notes = "",
|
||||
ImportSource = "docx-export",
|
||||
ExternalRef = "ext-papa",
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = "papa",
|
||||
CharacterTraits = new string[] {},
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true,
|
||||
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum von Papa.xlsx\",\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":2,\"fromWurfchronik\":true,\"notes\":[\"aus 2 Datensätzen zusammengeführt\"],\"history\":[\"In \\u201eStammbaum von Papa.xlsx\\u201c gefunden.\",\"Genotyp aus \\u201eWurfchronik-Detail.docx\\u201c.\"]}"
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = motherId,
|
||||
Name = "Mama",
|
||||
Gender = "female",
|
||||
Status = "Breeding",
|
||||
LitterId = (Guid?)null,
|
||||
OriginContactId = contactId,
|
||||
ReceiverContactId = (Guid?)null,
|
||||
EnclosureId = (Guid?)null,
|
||||
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
|
||||
DateOfBirth = "2025-01-01",
|
||||
DateOfDeath = (string?)null,
|
||||
CauseOfDeath = (string?)null,
|
||||
GoHomeDate = (string?)null,
|
||||
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||
Notes = "",
|
||||
ImportSource = "docx-export",
|
||||
ExternalRef = "ext-mama",
|
||||
RawImportData = "{}",
|
||||
OriginBreeder = "Test Zucht",
|
||||
NameSearch = "mama",
|
||||
CharacterTraits = new string[] {},
|
||||
CharacterNote = (string?)null,
|
||||
IsDeaf = false,
|
||||
IsResident = true,
|
||||
Provenance = (string?)null
|
||||
}
|
||||
},
|
||||
GerbilPhotos = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = photoId,
|
||||
GerbilId = fatherId,
|
||||
FileName = "photo1.jpg",
|
||||
SortOrder = 0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
File.WriteAllText(_resolvedJsonPath, JsonSerializer.Serialize(data));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private ApplicationContext NewDb()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("ingest-resolved-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
return db;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestResolved_loads_data_and_resolves_all_relations()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
{ "Import:SourcePath", _dir }
|
||||
})
|
||||
.Build();
|
||||
|
||||
var service = new IngestResolvedService(db, config, null!);
|
||||
var result = await service.RunAsync();
|
||||
|
||||
Assert.Contains("Ingestion successful!", result);
|
||||
|
||||
// Verify counts
|
||||
Assert.Equal(1, await db.Contacts.CountAsync());
|
||||
Assert.Equal(1, await db.Litters.CountAsync());
|
||||
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||
Assert.Equal(1, await db.GerbilPhotos.CountAsync());
|
||||
|
||||
// Verify relations
|
||||
var litter = await db.Litters.SingleAsync();
|
||||
var father = await db.Gerbils.SingleAsync(g => g.Name == "Papa");
|
||||
var mother = await db.Gerbils.SingleAsync(g => g.Name == "Mama");
|
||||
var photo = await db.GerbilPhotos.SingleAsync();
|
||||
|
||||
Assert.Equal(father.Id, litter.FatherId);
|
||||
Assert.Equal(mother.Id, litter.MotherId);
|
||||
Assert.Equal(father.Id, photo.GerbilId);
|
||||
Assert.Equal(father.OriginContactId, mother.OriginContactId);
|
||||
|
||||
// Provenance round-trips verbatim through ingest (and stays null when absent).
|
||||
Assert.NotNull(father.Provenance);
|
||||
Assert.Contains("Stammbaum von Papa.xlsx", father.Provenance);
|
||||
Assert.Contains("mergedRecordCount", father.Provenance);
|
||||
// The chronological, file-attributed history survives ingest verbatim
|
||||
// (stored as opaque JSON on the text column — no schema for it).
|
||||
Assert.Contains("history", father.Provenance);
|
||||
Assert.Contains("Genotyp aus", father.Provenance);
|
||||
Assert.Null(mother.Provenance);
|
||||
|
||||
// Contact + litter provenance also round-trips through the ingest.
|
||||
var contact = await db.Contacts.SingleAsync();
|
||||
Assert.NotNull(contact.Provenance);
|
||||
Assert.Contains("als Züchter erkannt", contact.Provenance);
|
||||
|
||||
Assert.NotNull(litter.Provenance);
|
||||
Assert.Contains("aus Wurfchronik", litter.Provenance);
|
||||
Assert.Contains("fromWurfchronik", litter.Provenance);
|
||||
}
|
||||
}
|
||||
}
|
||||
88
GerbilManager.Tests/LitterMortalityTests.cs
Normal file
88
GerbilManager.Tests/LitterMortalityTests.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>LITTER-MORTALITY: deathsWithin8Weeks field — round-trip, validation, null default.</summary>
|
||||
public class LitterMortalityTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
public LitterMortalityTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
private static int? GetInt(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number
|
||||
? v.GetInt32() : null;
|
||||
|
||||
private async Task<JsonElement> CreateAndGet(object body)
|
||||
{
|
||||
var post = await _client.PostAsJsonAsync("/litters", body);
|
||||
post.EnsureSuccessStatusCode();
|
||||
var idStr = JsonDocument.Parse(await post.Content.ReadAsStringAsync()).RootElement
|
||||
.GetProperty("id").GetString()!;
|
||||
return JsonDocument.Parse(await _client.GetStringAsync($"/litters/{idStr}")).RootElement;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeathsWithin8Weeks_round_trips()
|
||||
{
|
||||
var el = await CreateAndGet(new
|
||||
{
|
||||
name = "Wurf Mortalitätstest",
|
||||
date = "2026-05-01",
|
||||
totalBorn = 6,
|
||||
deathsWithin8Weeks = 2,
|
||||
});
|
||||
Assert.Equal(6, GetInt(el, "totalBorn"));
|
||||
Assert.Equal(2, GetInt(el, "deathsWithin8Weeks"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeathsWithin8Weeks_defaults_null()
|
||||
{
|
||||
var el = await CreateAndGet(new { name = "Wurf Kein Mort", date = "2026-05-02" });
|
||||
Assert.True(el.TryGetProperty("deathsWithin8Weeks", out var v));
|
||||
Assert.Equal(JsonValueKind.Null, v.ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeathsWithin8Weeks_exceeding_TotalBorn_returns_400()
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync("/litters", new
|
||||
{
|
||||
name = "Ungültig",
|
||||
date = "2026-05-03",
|
||||
totalBorn = 4,
|
||||
deathsWithin8Weeks = 5,
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.Contains("TotalBorn", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Negative_DeathsWithin8Weeks_returns_400()
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync("/litters", new
|
||||
{
|
||||
name = "Negativ",
|
||||
date = "2026-05-04",
|
||||
deathsWithin8Weeks = -1,
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.Contains("negativ", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeathsWithin8Weeks_allowed_when_TotalBorn_null()
|
||||
{
|
||||
var el = await CreateAndGet(new
|
||||
{
|
||||
name = "Kein TotalBorn",
|
||||
date = "2026-05-05",
|
||||
deathsWithin8Weeks = 3,
|
||||
});
|
||||
Assert.Equal(3, GetInt(el, "deathsWithin8Weeks"));
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ using Microsoft.Extensions.Options;
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown
|
||||
/// fence strip), 503-not-configured path, upstream-error path.
|
||||
/// FEAT-NAMEGEN / NAMEGEN-2-BE: NameSuggestionService — prompt assembly, usage-code mapping,
|
||||
/// JSON parse (incl. Markdown fence strip), 503-not-configured path, upstream-error path.
|
||||
/// </summary>
|
||||
public class NameSuggestionTests
|
||||
{
|
||||
@@ -26,6 +26,14 @@ namespace GerbilManager.Tests
|
||||
Assert.Contains("origin", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemPrompt_erklärt_thematische_Kategorien()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildSystemPrompt();
|
||||
Assert.Contains("thematischen Kategorien", prompt);
|
||||
Assert.Contains("Erfinde KEINE Etymologie", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
|
||||
{
|
||||
@@ -33,7 +41,10 @@ namespace GerbilManager.Tests
|
||||
Assert.Contains("6", prompt);
|
||||
Assert.Contains("\"A\"", prompt);
|
||||
Assert.Contains("weibliche", prompt);
|
||||
Assert.Contains("norn,mythg", prompt);
|
||||
// codes are mapped to German descriptions
|
||||
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
|
||||
Assert.Contains("Griechische Mythologie", prompt);
|
||||
Assert.Contains("Kulturkreisen", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -54,6 +65,67 @@ namespace GerbilManager.Tests
|
||||
Assert.DoesNotContain("männliche", prompt);
|
||||
}
|
||||
|
||||
// ── NAMEGEN-2-BE: neue Themen-Kategorien ─────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("disney", "Disney-Charaktere")]
|
||||
[InlineData("pokemon", "Pokémon-Namen")]
|
||||
[InlineData("encities", "Namen englischer Städte")]
|
||||
[InlineData("hrcities", "Namen kroatischer Städte")]
|
||||
[InlineData("usstates", "Namen von US-Bundesstaaten")]
|
||||
public void UsageMap_enthält_alle_fünf_neuen_Codes(string code, string expectedDescription)
|
||||
{
|
||||
Assert.True(NameSuggestionService.UsageMap.TryGetValue(code, out var entry));
|
||||
Assert.Equal(expectedDescription, entry.Description);
|
||||
Assert.True(entry.Thematic);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("disney", "Disney-Charaktere")]
|
||||
[InlineData("pokemon", "Pokémon-Namen")]
|
||||
[InlineData("encities", "Namen englischer Städte")]
|
||||
[InlineData("hrcities", "Namen kroatischer Städte")]
|
||||
[InlineData("usstates", "Namen von US-Bundesstaaten")]
|
||||
public void UserPrompt_enthält_Themen_Kategorie_Beschreibung(string code, string expectedDescription)
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt(null, null, code, 5);
|
||||
Assert.Contains(expectedDescription, prompt);
|
||||
Assert.Contains("Themen-Kategorien", prompt);
|
||||
Assert.DoesNotContain("Kulturkreisen", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_trennt_etym_und_thematische_Kategorien()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt("D", null, "norn,disney", 4);
|
||||
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
|
||||
Assert.Contains("Kulturkreisen", prompt);
|
||||
Assert.Contains("Disney-Charaktere", prompt);
|
||||
Assert.Contains("Themen-Kategorien", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_thematisch_enthält_Geschlecht_Hinweis()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt(null, "female", "encities", 3);
|
||||
Assert.Contains("Geschlecht-Filter kann ignoriert werden", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_parst_thematische_Kategorie_Antwort()
|
||||
{
|
||||
var payload = """[{"name":"Dumbo","meaning":"Elefant mit großen Ohren aus dem Disney-Film Dumbo (1941)","origin":"Disney"}]""";
|
||||
var handler = new StubHandler(_ => Canned(payload));
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.SuggestAsync("D", null, "disney", 1);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||
Assert.NotNull(result.Suggestions);
|
||||
Assert.Equal("Dumbo", result.Suggestions![0].Name);
|
||||
Assert.Equal("Disney", result.Suggestions[0].Origin);
|
||||
}
|
||||
|
||||
// ── JSON parsing ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -36,7 +36,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||
name,
|
||||
gender = "female",
|
||||
genotype = "aa CC DD ee GG PP spsp rere",
|
||||
originBreeder = "Zucht der Kleinen Chaoten",
|
||||
originBreeder = "Zucht der kleinen Chaoten",
|
||||
isDeaf = false,
|
||||
characterTraits = new[] { "neugierig", "zutraulich" },
|
||||
characterNote = "Liebling der Familie",
|
||||
@@ -67,7 +67,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||
Assert.Equal("ForSale-Tier", GetStr(json, "name"));
|
||||
Assert.Equal("female", GetStr(json, "gender"));
|
||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||
@@ -91,11 +91,11 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||
// Assert: name / genotype / originBreeder / status / externalRef all intact
|
||||
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||
|
||||
Assert.Equal("Active", GetStr(json, "status"));
|
||||
Assert.Equal("Breeding", GetStr(json, "status"));
|
||||
Assert.Equal("Charakter-Tier", GetStr(json, "name"));
|
||||
Assert.Equal("female", GetStr(json, "gender"));
|
||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
|
||||
@@ -123,7 +123,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||
|
||||
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
|
||||
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
|
||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("Zucht der kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||
@@ -138,7 +138,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||
|
||||
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Minimal", GetStr(json, "name"));
|
||||
Assert.Equal("Active", GetStr(json, "status"));
|
||||
Assert.Equal("Breeding", GetStr(json, "status"));
|
||||
// characterTraits defaults to [] on create when not provided
|
||||
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
|
||||
private readonly HttpClient _client;
|
||||
public SearchVariationsTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
private async Task Create(string name, string? originBreeder = null, string status = "Active")
|
||||
private async Task Create(string name, string? originBreeder = null, string status = "Breeding")
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync("/gerbils",
|
||||
new { name, gender = "female", status, originBreeder });
|
||||
@@ -142,7 +142,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
|
||||
public async Task Combined_status_and_name_filter()
|
||||
{
|
||||
await Create("KombiVerkauf", status: "ForSale");
|
||||
await Create("KombiAktiv", status: "Active");
|
||||
await Create("KombiAktiv", status: "Breeding");
|
||||
var hits = await Names("status==ForSale,nameSearch=*kombi");
|
||||
Assert.Contains("KombiVerkauf", hits);
|
||||
Assert.DoesNotContain("KombiAktiv", hits);
|
||||
|
||||
@@ -13,6 +13,7 @@ public class ApplicationContext : DbContext
|
||||
public DbSet<Enclosure> Enclosures => Set<Enclosure>();
|
||||
public DbSet<ColorVariety> ColorVarieties => Set<ColorVariety>();
|
||||
public DbSet<GerbilPhoto> GerbilPhotos => Set<GerbilPhoto>();
|
||||
public DbSet<EnclosurePhoto> EnclosurePhotos => Set<EnclosurePhoto>();
|
||||
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
|
||||
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
|
||||
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
|
||||
@@ -23,6 +24,7 @@ public class ApplicationContext : DbContext
|
||||
public DbSet<Media> Media => Set<Media>();
|
||||
public DbSet<Request> Requests => Set<Request>();
|
||||
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
|
||||
public DbSet<Feedback> Feedback => Set<Feedback>();
|
||||
|
||||
// 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.
|
||||
@@ -133,6 +135,10 @@ public class ApplicationContext : DbContext
|
||||
e.HasOne<Gerbil>().WithMany()
|
||||
.HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade));
|
||||
|
||||
modelBuilder.Entity<EnclosurePhoto>(e =>
|
||||
e.HasOne<Enclosure>().WithMany()
|
||||
.HasForeignKey(p => p.EnclosureId).OnDelete(DeleteBehavior.Cascade));
|
||||
|
||||
// FEAT-13: Abgabeverträge + Zuchtprofil.
|
||||
modelBuilder.Entity<SaleContract>(e =>
|
||||
{
|
||||
@@ -197,6 +203,15 @@ public class ApplicationContext : DbContext
|
||||
modelBuilder.Entity<MailSettings>()
|
||||
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
|
||||
|
||||
// FEEDBACK: deliberately relationship-free. GerbilId/LitterId are plain nullable
|
||||
// Guid columns (no navigation properties → EF creates NO foreign key), so the
|
||||
// import re-ingest wipe of Gerbils/Litters never cascades into — or breaks —
|
||||
// feedback rows. They survive re-ingest, which is the whole point.
|
||||
modelBuilder.Entity<Feedback>(e =>
|
||||
{
|
||||
e.HasIndex(f => f.CreatedAt);
|
||||
});
|
||||
|
||||
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
||||
if (isNpgsql)
|
||||
{
|
||||
@@ -291,7 +306,7 @@ public class ApplicationContext : DbContext
|
||||
(string Name, string Genotype, int SortOrder)[] catalog =
|
||||
{
|
||||
// --- C-locus white / partial albino (IDs 1-3) ---
|
||||
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere", 0),
|
||||
("REW", "AA chch DD EE GG pp spsp rere", 0),
|
||||
("Hermelin", "aa chch DD EE GG PP spsp rere", 1),
|
||||
("Himalaya", "AA chch DD EE GG PP spsp rere", 2),
|
||||
// --- Zobel / colourpoint dark (ID 4) ---
|
||||
|
||||
16
GerbilManagerWebAPI/Cms/PublicSiteOptions.cs
Normal file
16
GerbilManagerWebAPI/Cms/PublicSiteOptions.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace GerbilManagerWebAPI.Cms
|
||||
{
|
||||
/// <summary>
|
||||
/// WEB-2: path where POST /api/publish writes the rendered static site.
|
||||
/// Env var: PublicSite__RootPath (empty = publish disabled, returns 503).
|
||||
/// In production this volume is shared with the publicsite-nginx container.
|
||||
/// </summary>
|
||||
public sealed class PublicSiteOptions
|
||||
{
|
||||
public const string SectionName = "PublicSite";
|
||||
|
||||
public string? RootPath { get; set; }
|
||||
|
||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(RootPath);
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,14 @@ public sealed record ContractBuyer(
|
||||
/// <param name="Geschlecht">Deutscher Anzeigetext („Weiblich“/„Männlich“) — die
|
||||
/// Abbildung vom <c>Gender</c>-Enum passiert im Aufrufer (Phase B), der
|
||||
/// Generator bleibt frei von Modell-Abhängigkeiten.</param>
|
||||
/// <param name="Photo">Optionales Tierfoto (Rohbytes der Bilddatei) fürs Vertragsbild;
|
||||
/// nur der PDF-Generator wertet es aus, der .docx-Generator ignoriert es.</param>
|
||||
public sealed record ContractAnimal(
|
||||
string Name,
|
||||
string Geschlecht,
|
||||
DateOnly? Geburtsdatum = null,
|
||||
string? Farbschlag = null);
|
||||
string? Farbschlag = null,
|
||||
byte[]? Photo = null);
|
||||
|
||||
/// <summary>
|
||||
/// Alle Eingaben des Vertragsgenerators. Reines Daten-Objekt, keine EF-Typen.
|
||||
|
||||
241
GerbilManagerWebAPI/Contracts/ContractPdfGenerator.cs
Normal file
241
GerbilManagerWebAPI/Contracts/ContractPdfGenerator.cs
Normal file
@@ -0,0 +1,241 @@
|
||||
using System.Globalization;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace GerbilManagerWebAPI.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// FEAT-13 (PDF): erzeugt den Abgabevertrag als druckfertiges PDF (A4) aus
|
||||
/// <see cref="ContractData"/> — gleiche Daten wie der .docx-Generator, reiner
|
||||
/// .NET-Code (QuestPDF), keine externen Konverter. Der Fließtext der Abschnitte
|
||||
/// 4–8 entspricht dem Mustervertrag (Vorlage Abgabevertrag.docx).
|
||||
/// </summary>
|
||||
public static class ContractPdfGenerator
|
||||
{
|
||||
static ContractPdfGenerator()
|
||||
{
|
||||
// QuestPDF Community License (kostenlos für Einzelpersonen / kleine Firmen).
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
}
|
||||
|
||||
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
|
||||
private static string Price(decimal p) => p.ToString("N2", German) + " €";
|
||||
private static string D(DateOnly d) => d.ToString("dd.MM.yyyy", German);
|
||||
private static string Or(string? v, string fallback = "—") => string.IsNullOrWhiteSpace(v) ? fallback : v!;
|
||||
|
||||
private const string Accent = "#A85F2E";
|
||||
private const string Ink = "#3D2E23";
|
||||
private const string Line = "#D9CCB4";
|
||||
|
||||
public static byte[] Generate(ContractData data)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
|
||||
return Document.Create(doc =>
|
||||
{
|
||||
doc.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(1.8f, Unit.Centimetre);
|
||||
page.DefaultTextStyle(x => x.FontSize(9.5f).FontColor(Ink).LineHeight(1.25f));
|
||||
|
||||
page.Header().PaddingBottom(8).Column(h =>
|
||||
{
|
||||
h.Item().Text("Schutzvertrag / Kaufvertrag").FontSize(18).Bold().FontColor(Accent);
|
||||
h.Item().Text("Mongolische Rennmäuse").FontSize(10).FontColor("#8C7F6E");
|
||||
});
|
||||
|
||||
page.Content().PaddingTop(6).Column(col =>
|
||||
{
|
||||
col.Spacing(12);
|
||||
Parties(col, data);
|
||||
Animals(col, data);
|
||||
Subject(col, data);
|
||||
Clauses(col);
|
||||
Signatures(col, data);
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(t =>
|
||||
{
|
||||
t.DefaultTextStyle(x => x.FontSize(8).FontColor("#8C7F6E"));
|
||||
t.Span("Seite ");
|
||||
t.CurrentPageNumber();
|
||||
t.Span(" / ");
|
||||
t.TotalPages();
|
||||
});
|
||||
});
|
||||
}).GeneratePdf();
|
||||
}
|
||||
|
||||
private static void Heading(ColumnDescriptor col, string text) =>
|
||||
col.Item().PaddingTop(4).Text(text).FontSize(12).Bold().FontColor(Accent);
|
||||
|
||||
private static void Kv(ColumnDescriptor col, string label, string value) =>
|
||||
col.Item().Row(r =>
|
||||
{
|
||||
r.ConstantItem(150).Text(label).FontColor("#8C7F6E");
|
||||
r.RelativeItem().Text(value).SemiBold();
|
||||
});
|
||||
|
||||
private static void Bullet(ColumnDescriptor col, string text) =>
|
||||
col.Item().Row(r =>
|
||||
{
|
||||
r.ConstantItem(14).Text("•").FontColor(Accent);
|
||||
r.RelativeItem().Text(text);
|
||||
});
|
||||
|
||||
private static void Parties(ColumnDescriptor col, ContractData data)
|
||||
{
|
||||
var s = data.Seller;
|
||||
var b = data.Buyer;
|
||||
Heading(col, "1. Vertragspartner");
|
||||
col.Item().Text("Verkäufer / Züchter").SemiBold();
|
||||
col.Item().Column(c =>
|
||||
{
|
||||
c.Spacing(2);
|
||||
Kv(c, "Zuchtname:", Or(s.ZuchtName));
|
||||
Kv(c, "Vor- und Nachname:", Or(s.Name));
|
||||
Kv(c, "Adresse:", Or(s.Address));
|
||||
Kv(c, "Telefon:", Or(s.Phone, ""));
|
||||
Kv(c, "E-Mail:", Or(s.Email, ""));
|
||||
Kv(c, "Homepage:", Or(s.Homepage, ""));
|
||||
});
|
||||
col.Item().PaddingTop(4).Text("Käufer / Abnehmer").SemiBold();
|
||||
col.Item().Column(c =>
|
||||
{
|
||||
c.Spacing(2);
|
||||
Kv(c, "Vor- und Nachname:", Or(b.Name));
|
||||
Kv(c, "Adresse:", Or(b.Address));
|
||||
Kv(c, "Telefon:", Or(b.Phone, ""));
|
||||
Kv(c, "E-Mail:", Or(b.Email, ""));
|
||||
});
|
||||
}
|
||||
|
||||
private static void Animals(ColumnDescriptor col, ContractData data)
|
||||
{
|
||||
Heading(col, "2. Angaben zu den Tieren");
|
||||
col.Item().Column(list =>
|
||||
{
|
||||
list.Spacing(10);
|
||||
foreach (var a in data.Animals)
|
||||
{
|
||||
// Ein Block je Tier: links das (optionale) Foto, rechts die Stammdaten —
|
||||
// wie im Mustervertrag (das große Rechteck links).
|
||||
list.Item().Border(1).BorderColor(Line).Row(row =>
|
||||
{
|
||||
row.RelativeItem(2f).MinHeight(150).BorderRight(1).BorderColor(Line)
|
||||
.Padding(4).Element(box =>
|
||||
{
|
||||
if (a.Photo is { Length: > 0 } bytes)
|
||||
box.AlignMiddle().Image(bytes).FitArea();
|
||||
else
|
||||
box.AlignMiddle().AlignCenter().Text("Kein Foto")
|
||||
.FontSize(8).FontColor("#B8AC97");
|
||||
});
|
||||
|
||||
row.RelativeItem(3f).Column(kv =>
|
||||
{
|
||||
AnimalRow(kv, "Name:", Or(a.Name, ""));
|
||||
AnimalRow(kv, "Tierart:", "Mongolische Rennmaus");
|
||||
AnimalRow(kv, "Geschlecht:", Or(a.Geschlecht));
|
||||
AnimalRow(kv, "Geburtsdatum:", a.Geburtsdatum is { } born ? D(born) : "—");
|
||||
AnimalRow(kv, "Farbschlag:", Or(a.Farbschlag, ""), last: true);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void AnimalRow(ColumnDescriptor col, string label, string value, bool last = false)
|
||||
{
|
||||
var cell = col.Item();
|
||||
if (!last) cell = cell.BorderBottom(1).BorderColor(Line);
|
||||
cell.PaddingVertical(7).PaddingHorizontal(8).Row(r =>
|
||||
{
|
||||
r.ConstantItem(100).Text(label).FontColor("#8C7F6E");
|
||||
r.RelativeItem().Text(value).SemiBold();
|
||||
});
|
||||
}
|
||||
|
||||
private static void Subject(ColumnDescriptor col, ContractData data)
|
||||
{
|
||||
Heading(col, "3. Gegenstand des Vertrages");
|
||||
col.Item().Text(
|
||||
$"Der Züchter verkauft dem Käufer mit Unterzeichnung des vorliegenden Vertrages die oben " +
|
||||
$"erwähnten Tiere zu einem Kaufpreis von {Price(data.Price)}. Der Kaufpreis ist bei Übergabe " +
|
||||
$"der Tiere vollständig bezahlt worden. Die Tiere wurden am {D(data.HandoverDate)} dem Käufer " +
|
||||
$"von dem Züchter übergeben.");
|
||||
}
|
||||
|
||||
private static void Clauses(ColumnDescriptor col)
|
||||
{
|
||||
Heading(col, "4. Einverständniserklärung zur Erhebung personenbezogener Daten");
|
||||
col.Item().Text(
|
||||
"Der Käufer stimmt der Erhebung und der Verarbeitung seiner oben aufgeführten Daten durch den " +
|
||||
"Verkäufer zu. Diese werden von dem Züchter vertraulich im Rahmen seiner Zucht behandelt. Darüber " +
|
||||
"hinaus benötigt es für jede weitere Datenerhebung die Zustimmung des Käufers. Der Abnehmer hat das " +
|
||||
"Recht, diese Einwilligung jederzeit ohne Angabe einer Begründung zu widerrufen. Weiterhin können " +
|
||||
"erhobene Daten bei Bedarf korrigiert, gelöscht oder deren Erhebung eingeschränkt werden.");
|
||||
|
||||
Heading(col, "5. Pflichten des Käufers");
|
||||
col.Item().Text(
|
||||
"Tiere sind keine Sache, sondern empfindungs- und leidensfähige Lebewesen. Der Abnehmer ist sich " +
|
||||
"seiner hohen Verantwortung bewusst und übernimmt folgende Pflichten:");
|
||||
col.Item().Column(c =>
|
||||
{
|
||||
c.Spacing(3);
|
||||
Bullet(c, "Der Käufer verpflichtet sich, die Tiere artgerecht zu halten, zu füttern und zu pflegen. Er verpflichtet sich, die stets aktuellen Vorschriften des Tierschutzgesetzes einzuhalten. Die schriftlichen Anweisungen des Züchters über Haltung, Pflege und Unterkunft der Tiere sind zu befolgen.");
|
||||
Bullet(c, "Der Abnehmer verpflichtet sich, die Tiere in Krankheitsfällen oder bei Anzeichen auf diese unverzüglich veterinärmedizinisch behandeln zu lassen. Die Tiere dürfen nicht ohne zwingende veterinärmedizinische Gründe euthanasiert werden.");
|
||||
Bullet(c, "Dem Käufer ist eine Weitergabe der Tiere ohne die Zustimmung des Züchters nicht gestattet. Eine Abgabe an ein Tierheim oder eine Auffangstation ist ohne vorherige Absprache untersagt.");
|
||||
Bullet(c, "Der Abnehmer verpflichtet sich, die Tiere niemals zu misshandeln, für Versuchszwecke oder als Zuchttier einzusetzen oder als Futtertier zu verwenden.");
|
||||
Bullet(c, "Die Einzelhaltung der Tiere ist dem Käufer untersagt. Er verpflichtet sich im Falle eines einzelnen Tieres zur artgerechten Vergesellschaftung mit der Trenngitter-Methode.");
|
||||
Bullet(c, "Der Käufer setzt den Verkäufer über das Versterben sowie die Todesursache der Tiere in Kenntnis.");
|
||||
Bullet(c, "Ernsthafte Erkrankungen genetischer Ursache sind nach gestellter Diagnose unverzüglich dem Züchter zu melden.");
|
||||
});
|
||||
|
||||
Heading(col, "6. Rechte des Käufers");
|
||||
col.Item().Column(c =>
|
||||
{
|
||||
c.Spacing(3);
|
||||
Bullet(c, "Der Käufer hat das Recht, den Züchter für die unentgeltliche Weitervermittlung der Tiere zu beauftragen. Dies schließt jedoch nicht die Aufnahme der Rennmäuse bei dem Züchter ein.");
|
||||
Bullet(c, "Der Verkäufer steht dem Käufer für Fragen zur Tierhaltung zur Verfügung. Der Käufer kann diese Dienste in beschränktem Umfang unentgeltlich in Anspruch nehmen.");
|
||||
Bullet(c, "Der Käufer hat das Recht, gegen Vorlage einer aktuellen negativen Kotuntersuchung den kostenpflichtigen Vergesellschaftungsservice für die Rennmaus in Anspruch zu nehmen.");
|
||||
});
|
||||
|
||||
Heading(col, "7. Haftung / Zuwiderhandlung");
|
||||
col.Item().Column(c =>
|
||||
{
|
||||
c.Spacing(3);
|
||||
Bullet(c, "Bei Vertragsverstoß ist der Züchter berechtigt, den Vertrag fristlos zu kündigen.");
|
||||
Bullet(c, "Im Falle des Verstoßes ist der Käufer zur entschädigungslosen Rückgabe der Tiere an den Züchter verpflichtet.");
|
||||
Bullet(c, "Zur Sicherstellung der Käuferpflichten wird eine Vertragsstrafe in Höhe von 200,- € festgelegt.");
|
||||
});
|
||||
|
||||
Heading(col, "8. Besondere Vereinbarungen / Bemerkungen");
|
||||
col.Item().Text(
|
||||
"Mündliche Vereinbarungen haben keine Gültigkeit. Jede Änderung oder Ergänzung des Vertrages bedarf " +
|
||||
"der Schriftform. Die Vertragsparteien verzichten auf jegliche Sachgewährleistungsansprüche. " +
|
||||
"Gerichtsstand für beide Parteien ist der Wohnort des Züchters. Der Vertrag wird zweifach ausgefertigt " +
|
||||
"und unterzeichnet. Käufer und Verkäufer erhalten je ein Exemplar. Mit der Unterschrift wird versichert, " +
|
||||
"den Vertragsinhalt gelesen und akzeptiert zu haben und sich an diesen zu halten.");
|
||||
}
|
||||
|
||||
private static void Signatures(ColumnDescriptor col, ContractData data)
|
||||
{
|
||||
var ort = string.IsNullOrWhiteSpace(data.Seller.City) ? "" : data.Seller.City + ", ";
|
||||
col.Item().PaddingTop(14).Text($"{ort}den {D(data.ContractDate ?? data.HandoverDate)}");
|
||||
col.Item().PaddingTop(28).Row(r =>
|
||||
{
|
||||
r.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().BorderTop(1).BorderColor(Ink).PaddingTop(4).Text("Unterschrift des Verkäufers");
|
||||
});
|
||||
r.ConstantItem(40);
|
||||
r.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().BorderTop(1).BorderColor(Ink).PaddingTop(4).Text("Unterschrift des Käufers");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? CauseOfDeath,
|
||||
DateOnly? GoHomeDate,
|
||||
string? Genotype,
|
||||
string? SpottingType,
|
||||
string? Notes,
|
||||
string? ImportSource,
|
||||
string? ExternalRef,
|
||||
@@ -29,19 +30,24 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
List<string> CharacterTraits,
|
||||
string? CharacterNote,
|
||||
bool? IsDeaf,
|
||||
bool IsResident);
|
||||
bool IsResident,
|
||||
string? ProfilePhotoUrl,
|
||||
bool IsCastrated,
|
||||
string? Provenance);
|
||||
|
||||
public record LitterDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
DateOnly Date,
|
||||
DateOnly? Date,
|
||||
int? TotalBorn,
|
||||
int? DeathsWithin8Weeks,
|
||||
Guid? FatherId,
|
||||
Guid? MotherId,
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
string? Notes);
|
||||
string? Notes,
|
||||
string? Provenance);
|
||||
|
||||
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes);
|
||||
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix, string? Provenance);
|
||||
|
||||
public record EnclosureDto(Guid Id, string Name, string? Notes);
|
||||
|
||||
@@ -72,6 +78,7 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? CauseOfDeath,
|
||||
DateOnly? GoHomeDate,
|
||||
string? Genotype,
|
||||
string? SpottingType,
|
||||
string? Notes,
|
||||
string? ImportSource,
|
||||
string? ExternalRef,
|
||||
@@ -79,18 +86,20 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
List<string>? CharacterTraits,
|
||||
string? CharacterNote,
|
||||
bool? IsDeaf,
|
||||
bool? IsResident);
|
||||
bool? IsResident,
|
||||
bool? IsCastrated);
|
||||
|
||||
public record LitterInput(
|
||||
string Name,
|
||||
DateOnly Date,
|
||||
DateOnly? Date,
|
||||
int? TotalBorn,
|
||||
int? DeathsWithin8Weeks,
|
||||
Guid? FatherId,
|
||||
Guid? MotherId,
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
string? Notes);
|
||||
|
||||
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes);
|
||||
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
|
||||
|
||||
public record EnclosureInput(string Name, string? Notes);
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
List<Guid> GerbilIds,
|
||||
decimal Price,
|
||||
DateOnly HandoverDate,
|
||||
DateOnly? ContractDate);
|
||||
DateOnly? ContractDate,
|
||||
// Optionales Tierfoto je Tier (GerbilId -> GerbilPhoto-Id) fürs Vertragsbild.
|
||||
Dictionary<Guid, Guid>? AnimalPhotos = null);
|
||||
|
||||
/// <summary>Zuchtprofil — Antwort UND Request-Body von /settings/breeder-profile.</summary>
|
||||
public record BreederProfileDto(
|
||||
@@ -29,5 +31,6 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string Phone,
|
||||
string Email,
|
||||
string Homepage,
|
||||
string City);
|
||||
string City,
|
||||
string NameSuffix);
|
||||
}
|
||||
|
||||
27
GerbilManagerWebAPI/Dtos/FeedbackDtos.cs
Normal file
27
GerbilManagerWebAPI/Dtos/FeedbackDtos.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
/// <summary>FEEDBACK: payload for POST /feedback (the "Fehler melden" dialog).</summary>
|
||||
public record FeedbackInput(
|
||||
string Message,
|
||||
string Context,
|
||||
Guid? GerbilId,
|
||||
Guid? LitterId,
|
||||
Guid? ContactId,
|
||||
string? EntityName,
|
||||
string? Url,
|
||||
DateTimeOffset? ClientTimestamp);
|
||||
|
||||
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
|
||||
public record FeedbackDto(
|
||||
Guid Id,
|
||||
string Message,
|
||||
string Context,
|
||||
Guid? GerbilId,
|
||||
Guid? LitterId,
|
||||
Guid? ContactId,
|
||||
string? EntityName,
|
||||
string? Url,
|
||||
DateTimeOffset? ClientTimestamp,
|
||||
string? UserAgent,
|
||||
DateTimeOffset CreatedAt);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Cms;
|
||||
@@ -5,6 +6,7 @@ using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
@@ -32,6 +34,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
|
||||
});
|
||||
|
||||
// ---- WEB-2: publish — rendert Snapshot auf Disk, atomic swap live/ ----
|
||||
api.MapPost("/publish", async (ApplicationContext db, IOptions<PublicSiteOptions> opts) =>
|
||||
{
|
||||
if (!opts.Value.IsConfigured)
|
||||
return Results.Problem(
|
||||
detail: "PublicSite__RootPath ist nicht konfiguriert. Setze die Umgebungsvariable.",
|
||||
statusCode: 503,
|
||||
title: "PublicSite nicht konfiguriert");
|
||||
|
||||
var snapshot = await new SiteSnapshotService(db).BuildAsync();
|
||||
var files = SiteRenderer.Render(snapshot);
|
||||
await PublishToDirectoryAsync(files, opts.Value.RootPath!);
|
||||
return Results.Ok(new { filesPublished = files.Count });
|
||||
});
|
||||
|
||||
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
|
||||
// und liefert die Datei mit passendem Content-Type aus. Relative
|
||||
// Links/CSS der gerenderten Seite funktionieren dadurch im
|
||||
@@ -169,6 +186,36 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WEB-2: Writes rendered files to <paramref name="rootPath"/>/_staging_new, then
|
||||
/// atomically swaps to live/ (rename on the same filesystem = one syscall, never partial).
|
||||
/// </summary>
|
||||
internal static async Task PublishToDirectoryAsync(
|
||||
IReadOnlyDictionary<string, string> files, string rootPath)
|
||||
{
|
||||
var stagingDir = Path.Combine(rootPath, "_staging_new");
|
||||
var liveDir = Path.Combine(rootPath, "live");
|
||||
var oldDir = Path.Combine(rootPath, "_old");
|
||||
|
||||
if (Directory.Exists(stagingDir)) Directory.Delete(stagingDir, recursive: true);
|
||||
Directory.CreateDirectory(stagingDir);
|
||||
|
||||
foreach (var (relativePath, content) in files)
|
||||
{
|
||||
var normalPath = relativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||
var fullPath = Path.Combine(stagingDir, normalPath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||
await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8);
|
||||
}
|
||||
|
||||
// Atomic swap: _staging_new → live
|
||||
if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true);
|
||||
if (Directory.Exists(liveDir)) Directory.Move(liveDir, oldDir);
|
||||
Directory.Move(stagingDir, liveDir);
|
||||
try { if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true); }
|
||||
catch { /* non-fatal — old dir gone on next publish */ }
|
||||
}
|
||||
|
||||
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
|
||||
private static string PreviewContentType(string path) =>
|
||||
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"
|
||||
|
||||
@@ -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 };
|
||||
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 };
|
||||
db.Contacts.Add(c);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
|
||||
@@ -35,6 +35,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
var c = await db.Contacts.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (c is null) return TypedResults.NotFound();
|
||||
c.Name = input.Name; c.Email = input.Email; c.Phone = input.Phone; c.Address = input.Address; c.Notes = input.Notes;
|
||||
c.IsBreeder = input.IsBreeder; c.IsReceiver = input.IsReceiver; c.NameSuffix = input.NameSuffix;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
@@ -54,6 +55,6 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes);
|
||||
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver, c.NameSuffix, c.Provenance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Contracts;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -22,6 +23,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
private const string DocxContentType =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
private const string PdfContentType = "application/pdf";
|
||||
|
||||
public static IEndpointRouteBuilder MapContractEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
@@ -49,6 +51,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
var errors = new Dictionary<string, string[]>();
|
||||
|
||||
var gerbilIds = (input.GerbilIds ?? []).Distinct().ToList();
|
||||
var photoChoice = input.AnimalPhotos ?? [];
|
||||
if (gerbilIds.Count == 0)
|
||||
errors["gerbilIds"] = ["Mindestens ein Tier auswählen."];
|
||||
if (input.Price < 0)
|
||||
@@ -99,16 +102,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
ContractDate = contractDate,
|
||||
FileName = fileName,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Animals = gerbilIds.Select(id => new SaleContractAnimal { GerbilId = id }).ToList(),
|
||||
Animals = gerbilIds.Select(id => new SaleContractAnimal
|
||||
{
|
||||
GerbilId = id,
|
||||
PhotoId = photoChoice.TryGetValue(id, out var pid) ? pid : null,
|
||||
}).ToList(),
|
||||
};
|
||||
db.SaleContracts.Add(entity);
|
||||
|
||||
// Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion.
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
foreach (var g in gerbils)
|
||||
{
|
||||
g.ReceiverContactId = contact.Id;
|
||||
g.GoHomeDate = input.HandoverDate;
|
||||
g.Status = GerbilStatus.GivenAway;
|
||||
GerbilStatusService.Apply(g, today);
|
||||
}
|
||||
|
||||
try
|
||||
@@ -142,6 +150,49 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.PhysicalFile(path, DocxContentType, download);
|
||||
});
|
||||
|
||||
// GET /contracts/{id}/pdf — druckfertiges PDF, aus den (aktuellen) Daten regeneriert.
|
||||
group.MapGet("/{id:guid}/pdf", async Task<Results<FileContentHttpResult, NotFound>> (
|
||||
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var c = await db.SaleContracts.AsNoTracking()
|
||||
.Include(x => x.Contact)
|
||||
.Include(x => x.Animals)
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (c is null || c.Contact is null) return TypedResults.NotFound();
|
||||
|
||||
var gerbilIds = c.Animals.Select(a => a.GerbilId).ToList();
|
||||
var gerbils = await db.Gerbils.AsNoTracking()
|
||||
.Include(g => g.ColorVariety)
|
||||
.Where(g => gerbilIds.Contains(g.Id))
|
||||
.ToListAsync();
|
||||
var settings = await db.BreederSettings.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == BreederSettings.SingletonId)
|
||||
?? new BreederSettings();
|
||||
|
||||
// Gewählte Vertragsfotos nachladen (Foto muss zum Tier gehören und die Datei
|
||||
// existieren — sonst bleibt das Bild leer statt zu scheitern).
|
||||
var photoByGerbil = c.Animals
|
||||
.Where(a => a.PhotoId is not null)
|
||||
.ToDictionary(a => a.GerbilId, a => a.PhotoId!.Value);
|
||||
var photoBytes = await LoadAnimalPhotos(db, photoByGerbil, PhotoRoot(config, env));
|
||||
|
||||
var data = new ContractData(
|
||||
Seller: ToSeller(settings),
|
||||
Buyer: new ContractBuyer(c.Contact.Name, c.Contact.Address ?? "", c.Contact.Phone, c.Contact.Email),
|
||||
Animals: gerbils
|
||||
.Select(g => new ContractAnimal(
|
||||
g.Name, GeschlechtText(g.Gender), g.DateOfBirth, g.ColorVariety?.Name,
|
||||
photoBytes.GetValueOrDefault(g.Id)))
|
||||
.ToList(),
|
||||
Price: c.Price,
|
||||
HandoverDate: c.HandoverDate,
|
||||
ContractDate: c.ContractDate);
|
||||
|
||||
var pdf = ContractPdfGenerator.Generate(data);
|
||||
var download = $"Abgabevertrag_{c.ContractDate:yyyy-MM-dd}_{Sanitize(c.Contact.Name)}.pdf";
|
||||
return TypedResults.File(pdf, PdfContentType, download);
|
||||
});
|
||||
|
||||
// DELETE /contracts/{id} — Zeile + Datei; Tier-Status wird NICHT zurückgedreht
|
||||
// (das wäre Magie — Status korrigiert man am Tier selbst).
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
@@ -164,6 +215,34 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
private static string ContractRoot(IConfiguration config, IWebHostEnvironment env) =>
|
||||
config["Contracts:RootPath"] ?? Path.Combine(env.ContentRootPath, "contract-storage");
|
||||
|
||||
/// <summary>Foto-Dateiroot (geteilt mit den Foto-Endpoints).</summary>
|
||||
private static string PhotoRoot(IConfiguration config, IWebHostEnvironment env) =>
|
||||
config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage");
|
||||
|
||||
/// <summary>Lädt die gewählten Vertragsfotos als Rohbytes (GerbilId -> Bytes).
|
||||
/// Überspringt Fotos, die nicht zum Tier gehören oder deren Datei fehlt.</summary>
|
||||
private static async Task<Dictionary<Guid, byte[]>> LoadAnimalPhotos(
|
||||
ApplicationContext db, Dictionary<Guid, Guid> photoByGerbil, string photoRoot)
|
||||
{
|
||||
var result = new Dictionary<Guid, byte[]>();
|
||||
if (photoByGerbil.Count == 0) return result;
|
||||
|
||||
var photoIds = photoByGerbil.Values.ToList();
|
||||
var photos = await db.GerbilPhotos.AsNoTracking()
|
||||
.Where(p => photoIds.Contains(p.Id))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var (gerbilId, photoId) in photoByGerbil)
|
||||
{
|
||||
var photo = photos.FirstOrDefault(p => p.Id == photoId && p.GerbilId == gerbilId);
|
||||
if (photo is null) continue;
|
||||
var path = Path.Combine(photoRoot, photo.FileName);
|
||||
if (File.Exists(path))
|
||||
result[gerbilId] = await File.ReadAllBytesAsync(path);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static BreederProfile ToSeller(BreederSettings s) => new()
|
||||
{
|
||||
ZuchtName = s.ZuchtName,
|
||||
|
||||
63
GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs
Normal file
63
GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// FEEDBACK: the "Fehler melden" report sink.
|
||||
/// POST /feedback -> persist a user bug report (with captured debug context), returns 201.
|
||||
/// GET /feedback -> list reports, newest first (for later review).
|
||||
/// Feedback is decoupled from gerbils/litters (loose nullable Guid columns, no FK), so
|
||||
/// rows survive the import re-ingest wipe.
|
||||
/// </summary>
|
||||
public static class FeedbackEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/feedback").WithTags("Feedback");
|
||||
|
||||
group.MapPost("/", async Task<Results<Created<FeedbackDto>, BadRequest<string>>> (
|
||||
FeedbackInput input, ApplicationContext db, HttpContext http) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Message))
|
||||
return TypedResults.BadRequest("Message darf nicht leer sein.");
|
||||
|
||||
var entity = new Feedback
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Message = input.Message.Trim(),
|
||||
Context = string.IsNullOrWhiteSpace(input.Context) ? "unknown" : input.Context.Trim(),
|
||||
GerbilId = input.GerbilId,
|
||||
LitterId = input.LitterId,
|
||||
ContactId = input.ContactId,
|
||||
EntityName = input.EntityName,
|
||||
Url = input.Url,
|
||||
ClientTimestamp = input.ClientTimestamp,
|
||||
UserAgent = http.Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.Feedback.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/feedback/{entity.Id}", ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapGet("/", async (ApplicationContext db) =>
|
||||
{
|
||||
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column.
|
||||
var rows = await db.Feedback.AsNoTracking().ToListAsync();
|
||||
return TypedResults.Ok(rows
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
.Select(ToDto)
|
||||
.ToList());
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static FeedbackDto ToDto(Feedback f) =>
|
||||
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url,
|
||||
f.ClientTimestamp, f.UserAgent, f.CreatedAt);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Gridify;
|
||||
using Gridify.EntityFramework;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -16,8 +18,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
// GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name)
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Gerbils.AsNoTracking()
|
||||
.ToPagedResultAsync(query, ToDto)));
|
||||
{
|
||||
var q = query.ToQuery();
|
||||
var paging = await db.Gerbils.AsNoTracking().GridifyAsync(q);
|
||||
var ids = paging.Data.Select(g => g.Id).ToList();
|
||||
var photoMap = ids.Count == 0 ? new Dictionary<Guid, string>() :
|
||||
await db.GerbilPhotos.AsNoTracking()
|
||||
.Where(p => ids.Contains(p.GerbilId))
|
||||
.GroupBy(p => p.GerbilId)
|
||||
.Select(g => new { GerbilId = g.Key, FileName = g.OrderBy(p => p.SortOrder).First().FileName })
|
||||
.ToDictionaryAsync(x => x.GerbilId, x => x.FileName);
|
||||
var items = paging.Data
|
||||
.Select(g => ToDto(g, photoMap.TryGetValue(g.Id, out var fn) ? $"/photos/files/{fn}" : null))
|
||||
.ToList();
|
||||
return TypedResults.Ok(new PagedResult<GerbilDto>(items, paging.Count, q.Page, q.PageSize));
|
||||
});
|
||||
|
||||
// GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown
|
||||
group.MapGet("/breeders", async (ApplicationContext db) =>
|
||||
@@ -30,7 +45,14 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return g is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(g));
|
||||
if (g is null) return TypedResults.NotFound();
|
||||
var photoFileName = await db.GerbilPhotos.AsNoTracking()
|
||||
.Where(p => p.GerbilId == id)
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.Select(p => p.FileName)
|
||||
.FirstOrDefaultAsync();
|
||||
var profilePhotoUrl = photoFileName != null ? $"/photos/files/{photoFileName}" : null;
|
||||
return TypedResults.Ok(ToDto(g, profilePhotoUrl));
|
||||
});
|
||||
|
||||
// POST /gerbils
|
||||
@@ -86,7 +108,8 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
|
||||
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender);
|
||||
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
|
||||
// Status is applied as a user preference and then overridden by GerbilStatusService.
|
||||
g.Status = i.Status ?? (isCreate ? GerbilStatus.Breeding : g.Status);
|
||||
g.LitterId = i.LitterId ?? g.LitterId;
|
||||
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
|
||||
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
|
||||
@@ -97,6 +120,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
|
||||
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
|
||||
g.Genotype = i.Genotype ?? g.Genotype;
|
||||
g.SpottingType = i.SpottingType ?? g.SpottingType;
|
||||
g.Notes = i.Notes ?? g.Notes;
|
||||
g.ImportSource = i.ImportSource ?? g.ImportSource;
|
||||
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
|
||||
@@ -105,12 +129,15 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
|
||||
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
|
||||
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
||||
g.IsCastrated = i.IsCastrated ?? (isCreate ? false : g.IsCastrated);
|
||||
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
}
|
||||
|
||||
internal static GerbilDto ToDto(Gerbil g) => new(
|
||||
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.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
||||
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,13 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.Ok(report);
|
||||
});
|
||||
|
||||
group.MapPost("/ingest-resolved", async Task<Ok<string>> (
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var result = await new IngestResolvedService(db, config, env).RunAsync();
|
||||
return TypedResults.Ok(result);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,12 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return l is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(l));
|
||||
});
|
||||
|
||||
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>>> (LitterInput input, ApplicationContext db) =>
|
||||
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>, BadRequest<string>>> (LitterInput input, ApplicationContext db) =>
|
||||
{
|
||||
var err = await ValidateParents(db, input.FatherId, input.MotherId);
|
||||
if (err is not null) return TypedResults.BadRequest(err);
|
||||
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 };
|
||||
Apply(l, input);
|
||||
@@ -35,12 +37,14 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.Created($"/litters/{l.Id}", ToDto(l));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>>> (Guid id, LitterInput input, ApplicationContext db) =>
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>, BadRequest<string>>> (Guid id, LitterInput input, ApplicationContext db) =>
|
||||
{
|
||||
var l = await db.Litters.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (l is null) return TypedResults.NotFound();
|
||||
var err = await ValidateParents(db, input.FatherId, input.MotherId);
|
||||
if (err is not null) return TypedResults.BadRequest(err);
|
||||
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
|
||||
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
|
||||
l.Name = input.Name;
|
||||
l.Date = input.Date;
|
||||
Apply(l, input);
|
||||
@@ -76,14 +80,25 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
private static void Apply(Litter l, LitterInput i)
|
||||
{
|
||||
l.TotalBorn = i.TotalBorn;
|
||||
l.DeathsWithin8Weeks = i.DeathsWithin8Weeks;
|
||||
l.FatherId = i.FatherId;
|
||||
l.MotherId = i.MotherId;
|
||||
l.ExpectedGoHomeDate = i.ExpectedGoHomeDate;
|
||||
l.Notes = i.Notes;
|
||||
}
|
||||
|
||||
private static string? ValidateMortality(int? deaths, int? totalBorn)
|
||||
{
|
||||
if (deaths is null) return null;
|
||||
if (deaths < 0) return "DeathsWithin8Weeks darf nicht negativ sein.";
|
||||
if (totalBorn is not null && deaths > totalBorn)
|
||||
return "DeathsWithin8Weeks darf nicht größer als TotalBorn sein.";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static LitterDto ToDto(Litter l) => new(
|
||||
l.Id, l.Name, l.Date, l.TotalBorn, l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
|
||||
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks,
|
||||
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes, l.Provenance);
|
||||
}
|
||||
|
||||
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>
|
||||
|
||||
@@ -72,6 +72,62 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.NoContent();
|
||||
}).WithTags("Photos");
|
||||
|
||||
// ── Gehege (enclosure) photos — mirror the gerbil endpoints, shared file store ──
|
||||
app.MapGet("/enclosures/{id:guid}/photos",
|
||||
async Task<Results<Ok<List<PhotoDto>>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
if (!await db.Enclosures.AnyAsync(e => e.Id == id)) return TypedResults.NotFound();
|
||||
var photos = await db.EnclosurePhotos.AsNoTracking()
|
||||
.Where(p => p.EnclosureId == id)
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.ToListAsync();
|
||||
return TypedResults.Ok(photos.Select(ToEnclosureDto).ToList());
|
||||
}).WithTags("Photos");
|
||||
|
||||
app.MapPost("/enclosures/{id:guid}/photos",
|
||||
async Task<Results<Created<PhotoDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, IFormFile file, [Microsoft.AspNetCore.Mvc.FromForm] string? caption,
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
if (!await db.Enclosures.AnyAsync(e => e.Id == id)) return TypedResults.NotFound();
|
||||
if (file is null || file.Length == 0) return TypedResults.BadRequest("No file uploaded.");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName);
|
||||
var fileName = $"{Guid.NewGuid():N}{ext}";
|
||||
var root = PhotoRoot(config, env);
|
||||
Directory.CreateDirectory(root);
|
||||
await using (var stream = File.Create(Path.Combine(root, fileName)))
|
||||
await file.CopyToAsync(stream);
|
||||
|
||||
int nextSort = (await db.EnclosurePhotos.Where(p => p.EnclosureId == id)
|
||||
.Select(p => (int?)p.SortOrder).MaxAsync() ?? -1) + 1;
|
||||
|
||||
var photo = new EnclosurePhoto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
EnclosureId = id,
|
||||
FileName = fileName,
|
||||
Caption = caption,
|
||||
SortOrder = nextSort,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.EnclosurePhotos.Add(photo);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/enclosure-photos/{photo.Id}", ToEnclosureDto(photo));
|
||||
}).WithTags("Photos").DisableAntiforgery();
|
||||
|
||||
app.MapDelete("/enclosure-photos/{id:guid}",
|
||||
async Task<Results<NoContent, NotFound>> (Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var photo = await db.EnclosurePhotos.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (photo is null) return TypedResults.NotFound();
|
||||
var path = Path.Combine(PhotoRoot(config, env), photo.FileName);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
db.EnclosurePhotos.Remove(photo);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
}).WithTags("Photos");
|
||||
|
||||
app.MapGet("/photos/files/{fileName}",
|
||||
Results<PhysicalFileHttpResult, NotFound, BadRequest<string>> (string fileName, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
@@ -100,5 +156,8 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
private static PhotoDto ToDto(GerbilPhoto p) =>
|
||||
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
|
||||
|
||||
private static PhotoDto ToEnclosureDto(EnclosurePhoto p) =>
|
||||
new(p.Id, p.FileName, p.Caption, p.SortOrder, $"/photos/files/{p.FileName}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
s.Email = input.Email ?? "";
|
||||
s.Homepage = input.Homepage ?? "";
|
||||
s.City = input.City ?? "";
|
||||
s.NameSuffix = input.NameSuffix ?? "";
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
@@ -55,6 +56,6 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
}
|
||||
|
||||
internal static BreederProfileDto ToDto(BreederSettings s) =>
|
||||
new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City);
|
||||
new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City, s.NameSuffix);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,9 +48,11 @@ namespace GerbilManagerWebAPI.Export
|
||||
|
||||
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
|
||||
{
|
||||
[GerbilStatus.Active] = "Aktiv",
|
||||
[GerbilStatus.Breeding] = "Zucht",
|
||||
[GerbilStatus.Deceased] = "Verstorben",
|
||||
[GerbilStatus.GivenAway] = "Abgegeben",
|
||||
[GerbilStatus.ForSale] = "Abzugeben",
|
||||
[GerbilStatus.Pet] = "Liebhaber",
|
||||
};
|
||||
|
||||
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="Gridify" Version="2.19.1" />
|
||||
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
||||
<PackageReference Include="QuestPDF" Version="2026.6.0" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import
|
||||
@@ -54,10 +55,21 @@ namespace GerbilManagerWebAPI.Import
|
||||
var docxLitters = Load<List<DocxLitter>>("docx_litters.json") ?? new();
|
||||
var docxAnimals = Load<List<DocxAnimal>>("docx_animals.json") ?? new();
|
||||
|
||||
var pdfLitters = Load<List<DocxLitter>>("pdf_litters.json") ?? new();
|
||||
var pdfAnimals = Load<List<DocxAnimal>>("pdf_animals.json") ?? new();
|
||||
|
||||
int docxLitCount = docxLitters.Count;
|
||||
int docxAnimCount = docxAnimals.Count;
|
||||
int pdfLitCount = pdfLitters.Count;
|
||||
int pdfAnimCount = pdfAnimals.Count;
|
||||
|
||||
docxLitters.AddRange(pdfLitters);
|
||||
docxAnimals.AddRange(pdfAnimals);
|
||||
|
||||
if (docxLitters.Count == 0 && docxAnimals.Count == 0)
|
||||
{
|
||||
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json). " +
|
||||
"extract_docx.py zuerst ausführen.");
|
||||
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json oder pdf_litters.json/pdf_animals.json). " +
|
||||
"extract_docx.py zuerst ausführen oder PDF-VLM-Daten bereitstellen.");
|
||||
return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes);
|
||||
}
|
||||
|
||||
@@ -69,7 +81,8 @@ namespace GerbilManagerWebAPI.Import
|
||||
.Select(l => new { l.Id, l.Date })
|
||||
.ToListAsync();
|
||||
var littersByDayNumber = littersInDb
|
||||
.GroupBy(l => l.Date.DayNumber)
|
||||
.Where(l => l.Date.HasValue)
|
||||
.GroupBy(l => l.Date!.Value.DayNumber)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// normalize(name)+litterDob → Gerbil snapshot (main-import enrich path)
|
||||
@@ -266,14 +279,14 @@ namespace GerbilManagerWebAPI.Import
|
||||
Name = da.Name.Trim(),
|
||||
DateOfBirth = animalDob,
|
||||
Gender = ParseGender(da.Gender),
|
||||
Status = deathDate is not null ? GerbilStatus.Deceased : GerbilStatus.GivenAway,
|
||||
Status = GerbilStatusService.Derive(GerbilStatus.GivenAway, animalDob, deathDate, isAbgegeben: receiverId is not null, DateOnly.FromDateTime(DateTime.UtcNow)),
|
||||
LitterId = litterId,
|
||||
ReceiverContactId = receiverId,
|
||||
GoHomeDate = goHomeDate,
|
||||
DateOfDeath = deathDate,
|
||||
CauseOfDeath = string.IsNullOrWhiteSpace(da.DeathCause) ? null : da.DeathCause.Trim(),
|
||||
ColorVarietyId = cvId == default ? null : cvId,
|
||||
OriginBreeder = "Zucht der Kleinen Chaoten",
|
||||
OriginBreeder = "Zucht der kleinen Chaoten",
|
||||
IsResident = false,
|
||||
ImportSource = "docx",
|
||||
ExternalRef = externalRef,
|
||||
@@ -312,7 +325,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
});
|
||||
}
|
||||
|
||||
notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx.");
|
||||
notes.Add($"Quelle: {docxLitCount} Würfe / {docxAnimCount} Tiere aus DOCX. {pdfLitCount} Würfe / {pdfAnimCount} Tiere aus PDF.");
|
||||
notes.Add($"Neu angelegt: {animalsCreated} Jungtiere (abgegeben, nicht in Stammbäumen).");
|
||||
notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (DOB-Match ±5 Tage, eindeutig).");
|
||||
notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt.");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import
|
||||
@@ -82,7 +83,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
var existingLitterData = await _db.Litters
|
||||
.Select(l => new { l.Name, l.Date, l.ExternalRef }).ToListAsync();
|
||||
var existingLitterKeySet = existingLitterData
|
||||
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
|
||||
.Select(x => $"{x.Name}|{(x.Date.HasValue ? x.Date.Value.ToString("yyyy-MM-dd") : "")}").ToHashSet();
|
||||
var existingLitterExtRefSet = existingLitterData
|
||||
.Where(x => x.ExternalRef != null)
|
||||
.Select(x => x.ExternalRef!).ToHashSet();
|
||||
@@ -306,13 +307,13 @@ namespace GerbilManagerWebAPI.Import
|
||||
var litterByParentsDate = new Dictionary<string, Guid>();
|
||||
foreach (var l in existingLitterRows)
|
||||
{
|
||||
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{l.Date:yyyy-MM-dd}"] = l.Id;
|
||||
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{(l.Date.HasValue ? l.Date.Value.ToString("yyyy-MM-dd") : "")}"] = l.Id;
|
||||
litterParents[l.Id] = (l.FatherId, l.MotherId);
|
||||
}
|
||||
|
||||
foreach (var sl in synthLitters.Values.ToList())
|
||||
{
|
||||
var reuseKey = $"{sl.Father}|{sl.Mother}|{sl.Date:yyyy-MM-dd}";
|
||||
var reuseKey = $"{sl.Father}|{sl.Mother}|{(sl.Date.HasValue ? sl.Date.Value.ToString("yyyy-MM-dd") : "")}";
|
||||
if (litterByParentsDate.TryGetValue(reuseKey, out var existingId))
|
||||
{
|
||||
// remap offspring to the existing litter; don't create a duplicate.
|
||||
@@ -328,8 +329,8 @@ namespace GerbilManagerWebAPI.Import
|
||||
_db.Litters.Add(new Litter
|
||||
{
|
||||
Id = sl.Id,
|
||||
Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(),
|
||||
Date = sl.Date ?? default,
|
||||
Name = $"Wurf (aus Diagramm) {(sl.Date.HasValue ? sl.Date.Value.ToString("yyyy-MM-dd") : "")}".Trim(),
|
||||
Date = sl.Date,
|
||||
FatherId = null, // deferred — applied after gerbils SaveChanges
|
||||
MotherId = null, // deferred — applied after gerbils SaveChanges
|
||||
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
|
||||
@@ -390,12 +391,12 @@ namespace GerbilManagerWebAPI.Import
|
||||
|
||||
if (execute)
|
||||
{
|
||||
_db.Gerbils.Add(new Gerbil
|
||||
var importedGerbil = new Gerbil
|
||||
{
|
||||
Id = p.Gid,
|
||||
Name = p.A.Name,
|
||||
Gender = p.Gender,
|
||||
Status = GerbilStatus.Active,
|
||||
Status = GerbilStatus.Breeding,
|
||||
DateOfBirth = ParseDate(p.A.Dob),
|
||||
DateOfDeath = ParseDate(p.A.Death),
|
||||
LitterId = litterId,
|
||||
@@ -419,7 +420,9 @@ namespace GerbilManagerWebAPI.Import
|
||||
p.A.SourceFiles,
|
||||
FarbschlagRaw = p.A.Farbschlag,
|
||||
}),
|
||||
});
|
||||
};
|
||||
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
_db.Gerbils.Add(importedGerbil);
|
||||
}
|
||||
|
||||
// photos
|
||||
@@ -568,7 +571,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
// HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose
|
||||
// OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'.
|
||||
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
|
||||
const string DefaultClanBreeder = "Zucht der Kleinen Chaoten";
|
||||
const string DefaultClanBreeder = "Zucht der kleinen Chaoten";
|
||||
int herkunftBackfilled = 0;
|
||||
{
|
||||
var needsHerkunft = await _db.Gerbils
|
||||
@@ -612,9 +615,9 @@ namespace GerbilManagerWebAPI.Import
|
||||
notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher.");
|
||||
if (parentFksBackfilled > 0)
|
||||
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
||||
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
||||
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Zucht der kleinen Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
||||
if (herkunftBackfilled > 0)
|
||||
notes.Add($"Herkunft-Backfill: {herkunftBackfilled} Bestand-Tier(e) mit leerem Herkunft-Feld befüllt (Zucht der Kleinen Chaoten oder von Elternteil abgeleitet). Niemals überschrieben.");
|
||||
notes.Add($"Herkunft-Backfill: {herkunftBackfilled} Bestand-Tier(e) mit leerem Herkunft-Feld befüllt (Zucht der kleinen Chaoten oder von Elternteil abgeleitet). Niemals überschrieben.");
|
||||
if (farbschlagWouldRebackfill > 0)
|
||||
notes.Add($"Farbschlag-Hinweis (kein Overwrite): {farbschlagWouldRebackfill} bereits importierte Tier(e) haben einen veralteten Farbschlag, den der aktuelle Extraktor korrigieren würde — Overwrite-Mechanismus ausstehend (god/Julian Freigabe).");
|
||||
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
||||
|
||||
264
GerbilManagerWebAPI/Import/IngestResolvedService.cs
Normal file
264
GerbilManagerWebAPI/Import/IngestResolvedService.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import
|
||||
{
|
||||
public sealed class IngestResolvedService
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
private readonly string _resolvedJsonPath;
|
||||
private readonly string _sourceDir;
|
||||
private readonly string _photoRoot;
|
||||
|
||||
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"));
|
||||
_resolvedJsonPath = Path.Combine(_sourceDir, "resolved_import.json");
|
||||
_photoRoot = config["Photos:RootPath"]
|
||||
?? Path.Combine(contentRoot, "photo-storage");
|
||||
}
|
||||
|
||||
public async Task<string> RunAsync()
|
||||
{
|
||||
if (!File.Exists(_resolvedJsonPath))
|
||||
{
|
||||
return $"Error: Resolved import file not found at {_resolvedJsonPath}. Run resolve_import.py first.";
|
||||
}
|
||||
|
||||
string jsonContent = await File.ReadAllTextAsync(_resolvedJsonPath);
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
jsonOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||
var data = JsonSerializer.Deserialize<ResolvedImportData>(jsonContent, jsonOptions);
|
||||
|
||||
if (data == null)
|
||||
{
|
||||
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();
|
||||
|
||||
_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)
|
||||
var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id);
|
||||
var addedContactIds = new HashSet<Guid>();
|
||||
int contactsAdded = 0;
|
||||
int contactsUpdated = 0;
|
||||
foreach (var c in data.Contacts)
|
||||
{
|
||||
if (existingContacts.TryGetValue(c.Id, out var existingContact))
|
||||
{
|
||||
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;
|
||||
contactsUpdated++;
|
||||
}
|
||||
else if (addedContactIds.Add(c.Id))
|
||||
{
|
||||
_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>();
|
||||
foreach (var l in data.Litters)
|
||||
{
|
||||
littersToInsert.Add(new Litter
|
||||
{
|
||||
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
|
||||
});
|
||||
}
|
||||
_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>();
|
||||
foreach (var g in data.Gerbils)
|
||||
{
|
||||
var importedGerbil = new Gerbil
|
||||
{
|
||||
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);
|
||||
}
|
||||
_db.Gerbils.AddRange(gerbilsToInsert);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 5. Pass 2: Set the actual foreign key relationships
|
||||
// Update Litters with their parent animal IDs
|
||||
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
|
||||
var gerbilsInDb = await _db.Gerbils.ToDictionaryAsync(g => g.Id);
|
||||
foreach (var g in data.Gerbils)
|
||||
{
|
||||
if (gerbilsInDb.TryGetValue(g.Id, out var dbGerbil))
|
||||
{
|
||||
dbGerbil.LitterId = g.LitterId;
|
||||
}
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 6. Insert all Photos & copy physical files
|
||||
Directory.CreateDirectory(_photoRoot);
|
||||
foreach (var p in data.GerbilPhotos)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(p.SourcePath))
|
||||
{
|
||||
var relPath = p.SourcePath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
|
||||
var src = Path.Combine(_sourceDir, relPath);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Warning: Source photo file not found at {src}");
|
||||
}
|
||||
}
|
||||
|
||||
_db.GerbilPhotos.Add(new GerbilPhoto
|
||||
{
|
||||
Id = p.Id,
|
||||
GerbilId = p.GerbilId,
|
||||
FileName = p.FileName,
|
||||
Caption = p.Caption,
|
||||
SortOrder = p.SortOrder,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, and {data.GerbilPhotos.Count} photos.";
|
||||
}
|
||||
}
|
||||
|
||||
public class ResolvedImportData
|
||||
{
|
||||
public List<Contact> Contacts { get; set; } = new();
|
||||
public List<Litter> Litters { get; set; } = new();
|
||||
public List<Gerbil> Gerbils { get; set; } = new();
|
||||
public List<ResolvedPhoto> GerbilPhotos { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ResolvedPhoto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid GerbilId { get; set; }
|
||||
public required string FileName { get; set; }
|
||||
public string? Caption { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
[JsonPropertyName("_source_path")]
|
||||
public string? SourcePath { get; set; }
|
||||
}
|
||||
}
|
||||
1430
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.Designer.cs
generated
Normal file
1430
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.cs
Normal file
33
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RenameREW : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
|
||||
column: "Name",
|
||||
value: "REW");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
|
||||
column: "Name",
|
||||
value: "Pink Eyed White (PEW)");
|
||||
}
|
||||
}
|
||||
}
|
||||
1430
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.Designer.cs
generated
Normal file
1430
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.cs
Normal file
40
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class StatusModel : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 1) Rename string value 'Active' → 'Breeding'.
|
||||
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Breeding' WHERE ""Status"" = 'Active';");
|
||||
|
||||
// 2) DateOfDeath set → Deceased (always takes precedence).
|
||||
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Deceased' WHERE ""DateOfDeath"" IS NOT NULL;");
|
||||
|
||||
// 3) ReceiverContactId OR SaleContractAnimal → GivenAway (if not already Deceased).
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE ""Gerbils"" SET ""Status"" = 'GivenAway'
|
||||
WHERE ""Status"" != 'Deceased'
|
||||
AND (""ReceiverContactId"" IS NOT NULL
|
||||
OR EXISTS (SELECT 1 FROM ""SaleContractAnimal"" WHERE ""GerbilId"" = ""Gerbils"".""Id""));");
|
||||
|
||||
// 4) Age > 7 years (presumed dead) → Deceased, but NOT overriding GivenAway.
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE ""Gerbils"" SET ""Status"" = 'Deceased'
|
||||
WHERE ""Status"" NOT IN ('Deceased', 'GivenAway')
|
||||
AND ""DateOfBirth"" IS NOT NULL
|
||||
AND ""DateOfBirth"" < (CURRENT_DATE - INTERVAL '7 years');");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Active' WHERE ""Status"" = 'Breeding';");
|
||||
}
|
||||
}
|
||||
}
|
||||
1433
GerbilManagerWebAPI/Migrations/20260607013052_AddLitterMortality.Designer.cs
generated
Normal file
1433
GerbilManagerWebAPI/Migrations/20260607013052_AddLitterMortality.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 AddLitterMortality : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DeathsWithin8Weeks",
|
||||
table: "Litters",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DeathsWithin8Weeks",
|
||||
table: "Litters");
|
||||
}
|
||||
}
|
||||
}
|
||||
1436
GerbilManagerWebAPI/Migrations/20260612181803_AddSpottingType.Designer.cs
generated
Normal file
1436
GerbilManagerWebAPI/Migrations/20260612181803_AddSpottingType.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 AddSpottingType : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SpottingType",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SpottingType",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1442
GerbilManagerWebAPI/Migrations/20260612184004_AddContactRoles.Designer.cs
generated
Normal file
1442
GerbilManagerWebAPI/Migrations/20260612184004_AddContactRoles.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddContactRoles : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsBreeder",
|
||||
table: "Contacts",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsReceiver",
|
||||
table: "Contacts",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsBreeder",
|
||||
table: "Contacts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsReceiver",
|
||||
table: "Contacts");
|
||||
}
|
||||
}
|
||||
}
|
||||
1445
GerbilManagerWebAPI/Migrations/20260612184757_AddIsCastrated.Designer.cs
generated
Normal file
1445
GerbilManagerWebAPI/Migrations/20260612184757_AddIsCastrated.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddIsCastrated : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCastrated",
|
||||
table: "Gerbils",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCastrated",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1453
GerbilManagerWebAPI/Migrations/20260613085850_AddBreedingNameSuffix.Designer.cs
generated
Normal file
1453
GerbilManagerWebAPI/Migrations/20260613085850_AddBreedingNameSuffix.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 AddBreedingNameSuffix : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "NameSuffix",
|
||||
table: "Contacts",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "NameSuffix",
|
||||
table: "BreederSettings",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "BreederSettings",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("11111111-1111-1111-1111-000000000001"),
|
||||
column: "NameSuffix",
|
||||
value: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NameSuffix",
|
||||
table: "Contacts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NameSuffix",
|
||||
table: "BreederSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
1491
GerbilManagerWebAPI/Migrations/20260613093044_AddEnclosurePhotos.Designer.cs
generated
Normal file
1491
GerbilManagerWebAPI/Migrations/20260613093044_AddEnclosurePhotos.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddEnclosurePhotos : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EnclosurePhotos",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
EnclosureId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FileName = table.Column<string>(type: "text", nullable: false),
|
||||
Caption = table.Column<string>(type: "text", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_EnclosurePhotos", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_EnclosurePhotos_Enclosures_EnclosureId",
|
||||
column: x => x.EnclosureId,
|
||||
principalTable: "Enclosures",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EnclosurePhotos_EnclosureId",
|
||||
table: "EnclosurePhotos",
|
||||
column: "EnclosureId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "EnclosurePhotos");
|
||||
}
|
||||
}
|
||||
}
|
||||
1491
GerbilManagerWebAPI/Migrations/20260613114528_MakeLitterDateNullable.Designer.cs
generated
Normal file
1491
GerbilManagerWebAPI/Migrations/20260613114528_MakeLitterDateNullable.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MakeLitterDateNullable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<DateOnly>(
|
||||
name: "Date",
|
||||
table: "Litters",
|
||||
type: "date",
|
||||
nullable: true,
|
||||
oldClrType: typeof(DateOnly),
|
||||
oldType: "date");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<DateOnly>(
|
||||
name: "Date",
|
||||
table: "Litters",
|
||||
type: "date",
|
||||
nullable: false,
|
||||
defaultValue: new DateOnly(1, 1, 1),
|
||||
oldClrType: typeof(DateOnly),
|
||||
oldType: "date",
|
||||
oldNullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
1494
GerbilManagerWebAPI/Migrations/20260613120908_AddContractAnimalPhoto.Designer.cs
generated
Normal file
1494
GerbilManagerWebAPI/Migrations/20260613120908_AddContractAnimalPhoto.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddContractAnimalPhoto : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "PhotoId",
|
||||
table: "SaleContractAnimal",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PhotoId",
|
||||
table: "SaleContractAnimal");
|
||||
}
|
||||
}
|
||||
}
|
||||
1536
GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.Designer.cs
generated
Normal file
1536
GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.cs
Normal file
47
GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFeedback : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Feedback",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Message = table.Column<string>(type: "text", nullable: false),
|
||||
Context = table.Column<string>(type: "text", nullable: false),
|
||||
GerbilId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
LitterId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
EntityName = table.Column<string>(type: "text", nullable: true),
|
||||
Url = table.Column<string>(type: "text", nullable: true),
|
||||
ClientTimestamp = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
UserAgent = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Feedback", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Feedback_CreatedAt",
|
||||
table: "Feedback",
|
||||
column: "CreatedAt");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Feedback");
|
||||
}
|
||||
}
|
||||
}
|
||||
1539
GerbilManagerWebAPI/Migrations/20260622133758_AddGerbilProvenance.Designer.cs
generated
Normal file
1539
GerbilManagerWebAPI/Migrations/20260622133758_AddGerbilProvenance.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 AddGerbilProvenance : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Provenance",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Provenance",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1542
GerbilManagerWebAPI/Migrations/20260622135306_AddFeedbackContactId.Designer.cs
generated
Normal file
1542
GerbilManagerWebAPI/Migrations/20260622135306_AddFeedbackContactId.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFeedbackContactId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "ContactId",
|
||||
table: "Feedback",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ContactId",
|
||||
table: "Feedback");
|
||||
}
|
||||
}
|
||||
}
|
||||
1548
GerbilManagerWebAPI/Migrations/20260622135338_AddContactLitterProvenance.Designer.cs
generated
Normal file
1548
GerbilManagerWebAPI/Migrations/20260622135338_AddContactLitterProvenance.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddContactLitterProvenance : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Provenance",
|
||||
table: "Litters",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Provenance",
|
||||
table: "Contacts",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Provenance",
|
||||
table: "Litters");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Provenance",
|
||||
table: "Contacts");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,10 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("NameSuffix")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
@@ -185,6 +189,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
Email = "",
|
||||
Homepage = "",
|
||||
Name = "",
|
||||
NameSuffix = "",
|
||||
Phone = "",
|
||||
ZuchtName = ""
|
||||
});
|
||||
@@ -216,7 +221,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000001"),
|
||||
CanonicalGenotype = "AA chch DD EE GG pp spsp rere",
|
||||
Name = "Pink Eyed White (PEW)",
|
||||
Name = "REW",
|
||||
SortOrder = 0
|
||||
},
|
||||
new
|
||||
@@ -716,17 +721,29 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsBreeder")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsReceiver")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.UseCollation("de-x-icu");
|
||||
|
||||
b.Property<string>("NameSuffix")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Provenance")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Contacts");
|
||||
@@ -750,6 +767,80 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.ToTable("Enclosures");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Caption")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("EnclosureId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EnclosureId");
|
||||
|
||||
b.ToTable("EnclosurePhotos");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Feedback", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("ClientTimestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("ContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Context")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EntityName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("LitterId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("Feedback");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -794,6 +885,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<string>("ImportSource")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsCastrated")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool?>("IsDeaf")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -824,12 +918,18 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<Guid?>("OriginContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Provenance")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RawImportData")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("ReceiverContactId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("SpottingType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
@@ -921,9 +1021,12 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
b.Property<DateOnly?>("Date")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<int?>("DeathsWithin8Weeks")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateOnly?>("ExpectedGoHomeDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
@@ -949,6 +1052,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<string>("PairingCode")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Provenance")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int?>("TotalBorn")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -1219,6 +1325,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<Guid>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("PhotoId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("SaleContractId", "GerbilId");
|
||||
|
||||
b.HasIndex("GerbilId");
|
||||
@@ -1287,6 +1396,15 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.EnclosurePhoto", b =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.Enclosure", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("EnclosureId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||
{
|
||||
b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety")
|
||||
|
||||
@@ -18,6 +18,13 @@ namespace GerbilManagerWebAPI.Models
|
||||
|
||||
public string ZuchtName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Namens-Anhängsel der eigenen Zucht (wie ein „Nachname"), z. B.
|
||||
/// „von den kleinen Chaoten“. Wird an Namen eigener (selbst gezüchteter)
|
||||
/// Tiere angehängt: „Danako von den kleinen Chaoten“.
|
||||
/// </summary>
|
||||
public string NameSuffix { get; set; } = "";
|
||||
|
||||
/// <summary>Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“.</summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
|
||||
@@ -15,5 +15,20 @@ namespace GerbilManagerWebAPI.Models
|
||||
public string? Phone { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public bool IsBreeder { get; set; }
|
||||
public bool IsReceiver { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Namens-Anhängsel dieser Zucht (wie ein „Nachname"), z. B.
|
||||
/// „von den Wüstenwinden“. Für Tiere fremder Züchter pflegbar.
|
||||
/// </summary>
|
||||
public string? NameSuffix { get; set; }
|
||||
|
||||
/// <summary>Data-provenance / traceability for the import: a JSON object describing
|
||||
/// WHICH source information produced this contact (sourceFiles, mergedRecordCount,
|
||||
/// 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; }
|
||||
}
|
||||
}
|
||||
|
||||
17
GerbilManagerWebAPI/Models/EnclosurePhoto.cs
Normal file
17
GerbilManagerWebAPI/Models/EnclosurePhoto.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>A photo of an enclosure (Gehege). Mirrors <see cref="GerbilPhoto"/>:
|
||||
/// the file lives under the shared photo-storage root; only the file name is in the DB.</summary>
|
||||
public class EnclosurePhoto
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
public Guid EnclosureId { get; set; }
|
||||
public required string FileName { get; set; }
|
||||
public string? Caption { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
47
GerbilManagerWebAPI/Models/Feedback.cs
Normal file
47
GerbilManagerWebAPI/Models/Feedback.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// FEEDBACK: a user-submitted "Fehler melden" report. Decoupled from the rest of the
|
||||
/// model on purpose — GerbilId/LitterId are plain nullable Guid columns (NOT enforced
|
||||
/// foreign keys), so the import re-ingest wipe (IngestResolvedService) can delete
|
||||
/// gerbils/litters without deleting or breaking feedback rows. The captured EntityName
|
||||
/// keeps the report human-readable even after the referenced animal is gone.
|
||||
/// </summary>
|
||||
public class Feedback
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>The user's free-text description of the problem.</summary>
|
||||
public required string Message { get; set; }
|
||||
|
||||
/// <summary>Which view the report came from: stammbaum | gerbil-detail | litter-detail.</summary>
|
||||
public required string Context { get; set; }
|
||||
|
||||
/// <summary>Loose reference (no FK) to the gerbil the report is about, if any.</summary>
|
||||
public Guid? GerbilId { get; set; }
|
||||
|
||||
/// <summary>Loose reference (no FK) to the litter the report is about, if any.</summary>
|
||||
public Guid? LitterId { get; set; }
|
||||
|
||||
/// <summary>Loose reference (no FK) to the contact the report is about, if any.</summary>
|
||||
public Guid? ContactId { get; set; }
|
||||
|
||||
/// <summary>Captured name of the referenced animal/litter (survives an ingest wipe).</summary>
|
||||
public string? EntityName { get; set; }
|
||||
|
||||
/// <summary>The client URL/route the report was filed from.</summary>
|
||||
public string? Url { get; set; }
|
||||
|
||||
/// <summary>Client-supplied timestamp (when the user submitted, in their browser).</summary>
|
||||
public DateTimeOffset? ClientTimestamp { get; set; }
|
||||
|
||||
/// <summary>Optional browser user-agent for diagnostics.</summary>
|
||||
public string? UserAgent { get; set; }
|
||||
|
||||
/// <summary>Server-side creation time.</summary>
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ namespace GerbilManagerWebAPI.Models
|
||||
public Guid Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public Gender Gender { get; set; }
|
||||
public GerbilStatus Status { get; set; } = GerbilStatus.Active;
|
||||
public GerbilStatus Status { get; set; } = GerbilStatus.Breeding;
|
||||
|
||||
// Birth litter (the litter this gerbil was born in).
|
||||
public Guid? LitterId { get; set; }
|
||||
@@ -41,12 +41,21 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// <summary>Compact genotype string (frozen GEN-1 contract). Null = not genotyped.</summary>
|
||||
public string? Genotype { get; set; }
|
||||
|
||||
public string? SpottingType { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// Provenance (for the FEAT-8 spreadsheet import).
|
||||
public string? ImportSource { get; set; }
|
||||
public string? ExternalRef { get; set; }
|
||||
|
||||
/// <summary>Data-provenance / traceability for the import: a JSON object describing
|
||||
/// WHICH source information produced this entry (sourceFiles, mergedRecordCount,
|
||||
/// fromWurfchronik, parentMethod/parentConfidence, notes). Written by the Python
|
||||
/// merge_and_resolve step and surfaced read-only in the Rennmausakte
|
||||
/// ("Nachverfolgungsinformationen"). Null = manually-added animal / no import data.</summary>
|
||||
public string? Provenance { get; set; }
|
||||
|
||||
/// <summary>Raw import payload preserved verbatim (rawGenotype + unmappedTokens like
|
||||
/// the Uw locus / WFNZ markers) so nothing from the spreadsheets is lost. JSON text.</summary>
|
||||
public string? RawImportData { get; set; }
|
||||
@@ -80,6 +89,7 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// Defaults true (manually-added animals are own stock); the FEAT-8 import classifies
|
||||
/// imported animals. See hive/agents/god/OWNERSHIP-residency.md. Gridify-filterable.</summary>
|
||||
public bool IsResident { get; set; } = true;
|
||||
public bool IsCastrated { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.</summary>
|
||||
/// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.
|
||||
/// Deceased and GivenAway are DERIVED (set by GerbilStatusService, not free-form input).
|
||||
/// Breeding/Pet/ForSale are the user-selectable live states.</summary>
|
||||
public enum GerbilStatus
|
||||
{
|
||||
Active = 0,
|
||||
/// <summary>Aktiv in der Zucht (vormals "Active").</summary>
|
||||
Breeding = 0,
|
||||
Deceased = 1,
|
||||
GivenAway = 2,
|
||||
/// <summary>Alive, at home, offered for Abgabe ("Abzugeben"). Drives FEAT-12 sale listing.</summary>
|
||||
ForSale = 3
|
||||
ForSale = 3,
|
||||
/// <summary>Lebendes Heimtier, nicht in Zucht ("Liebhaber").</summary>
|
||||
Pet = 4,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,15 @@ namespace GerbilManagerWebAPI.Models
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public DateOnly Date { get; set; }
|
||||
public DateOnly? Date { get; set; }
|
||||
|
||||
/// <summary>Total born count (was "Strength").</summary>
|
||||
public int? TotalBorn { get; set; }
|
||||
|
||||
/// <summary>Frühsterblichkeit: Anzahl der in den ersten 8 Wochen verstorbenen Tiere.
|
||||
/// Null = unbekannt (Altdaten). Must be ≤ TotalBorn if both are set.</summary>
|
||||
public int? DeathsWithin8Weeks { get; set; }
|
||||
|
||||
public Guid? FatherId { get; set; }
|
||||
public Gerbil? Father { get; set; }
|
||||
public Guid? MotherId { get; set; }
|
||||
@@ -34,5 +38,12 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
|
||||
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
|
||||
public string? LitterLetter { get; set; }
|
||||
|
||||
/// <summary>Data-provenance / traceability for the import: a JSON object describing
|
||||
/// WHICH source information produced this litter (sourceFiles, mergedRecordCount,
|
||||
/// fromWurfchronik, notes — e.g. "aus Wurfchronik", "aus Stammbaum-Diagramm rekonstruiert").
|
||||
/// 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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,5 +41,9 @@ namespace GerbilManagerWebAPI.Models
|
||||
public Guid SaleContractId { get; set; }
|
||||
public Guid GerbilId { get; set; }
|
||||
public Gerbil? Gerbil { get; set; }
|
||||
|
||||
/// <summary>Optional gewähltes Tierfoto (<see cref="GerbilPhoto"/>) fürs Vertragsbild.
|
||||
/// Nur die Id wird gespeichert; beim PDF-Rendern wird die Datei nachgeladen.</summary>
|
||||
public Guid? PhotoId { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,23 @@ namespace GerbilManagerWebAPI.Names
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
// Known usage codes → (German description, isThematic).
|
||||
// Thematic categories provide real source names (not etymology); unknown codes fall through as etym.
|
||||
internal static readonly Dictionary<string, (string Description, bool Thematic)> UsageMap =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["norn"] = ("Nordische/Altnordische Etymologie", false),
|
||||
["japa"] = ("Japanische Etymologie", false),
|
||||
["mythg"] = ("Griechische Mythologie", false),
|
||||
["ger"] = ("Germanische/Deutsche Etymologie", false),
|
||||
["arb"] = ("Arabische Etymologie", false),
|
||||
["disney"] = ("Disney-Charaktere", true),
|
||||
["pokemon"] = ("Pokémon-Namen", true),
|
||||
["encities"] = ("Namen englischer Städte", true),
|
||||
["hrcities"] = ("Namen kroatischer Städte", true),
|
||||
["usstates"] = ("Namen von US-Bundesstaaten", true),
|
||||
};
|
||||
|
||||
public async Task<NameSuggestionResult> SuggestAsync(
|
||||
string? letter, string? gender, string? usages, int count,
|
||||
CancellationToken ct = default)
|
||||
@@ -44,7 +61,11 @@ namespace GerbilManagerWebAPI.Names
|
||||
"Du bist ein Helfer für Rennmaus-Züchter. " +
|
||||
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
|
||||
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
|
||||
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch).";
|
||||
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch). " +
|
||||
"Bei thematischen Kategorien (Disney, Pokémon, Städte, Bundesstaaten): " +
|
||||
"origin = Kategoriename (z.B. \"Disney\", \"Pokémon\", \"Englische Stadt\", \"Kroatische Stadt\", \"US-Bundesstaat\"), " +
|
||||
"meaning = kurzer Kontext aus der Quelle (z.B. Film/Figur-Beschreibung, Lage der Stadt, Bundesstaat-Bezug). " +
|
||||
"Erfinde KEINE Etymologie für thematische Kategorien.";
|
||||
|
||||
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
|
||||
{
|
||||
@@ -55,10 +76,44 @@ namespace GerbilManagerWebAPI.Names
|
||||
if (!string.IsNullOrWhiteSpace(gender) &&
|
||||
!gender.Equals("any", StringComparison.OrdinalIgnoreCase))
|
||||
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
|
||||
|
||||
bool hasThematic = false;
|
||||
if (!string.IsNullOrWhiteSpace(usages))
|
||||
sb.Append($", aus den Kulturkreisen: {usages}");
|
||||
sb.Append(". Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
|
||||
sb.Append("(keine erfundenen oder zufälligen Namen). ");
|
||||
{
|
||||
var codes = usages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var etymDescriptions = new List<string>();
|
||||
var thematicDescriptions = new List<string>();
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (UsageMap.TryGetValue(code, out var entry))
|
||||
{
|
||||
if (entry.Thematic) thematicDescriptions.Add(entry.Description);
|
||||
else etymDescriptions.Add(entry.Description);
|
||||
}
|
||||
else
|
||||
{
|
||||
etymDescriptions.Add(code);
|
||||
}
|
||||
}
|
||||
if (etymDescriptions.Count > 0)
|
||||
sb.Append($", aus den Kulturkreisen: {string.Join(", ", etymDescriptions)}");
|
||||
if (thematicDescriptions.Count > 0)
|
||||
sb.Append($", aus den Themen-Kategorien: {string.Join(", ", thematicDescriptions)}");
|
||||
hasThematic = thematicDescriptions.Count > 0;
|
||||
}
|
||||
|
||||
sb.Append(". ");
|
||||
if (hasThematic)
|
||||
{
|
||||
sb.Append("Für Kulturkreis-Namen: echte etymologische Bedeutung und Herkunft. ");
|
||||
sb.Append("Für Themen-Kategorien: echte Namen aus der Quelle, origin = Kategoriename, ");
|
||||
sb.Append("Geschlecht-Filter kann ignoriert werden wenn nicht sinnvoll anwendbar. ");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append("Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
|
||||
sb.Append("(keine erfundenen oder zufälligen Namen). ");
|
||||
}
|
||||
sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
|
||||
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
|
||||
return sb.ToString();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using GerbilManagerWebAPI.Endpoints;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Scalar.AspNetCore;
|
||||
@@ -48,6 +50,9 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
||||
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
|
||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
|
||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||
// WEB-2: public site publish path (env var PublicSite__RootPath; empty = disabled)
|
||||
builder.Services.AddOptions<GerbilManagerWebAPI.Cms.PublicSiteOptions>()
|
||||
.BindConfiguration(GerbilManagerWebAPI.Cms.PublicSiteOptions.SectionName);
|
||||
|
||||
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
||||
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
|
||||
@@ -81,7 +86,23 @@ app.MapScalarApiReference();
|
||||
if (!app.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.Migrate();
|
||||
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.
|
||||
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))
|
||||
.ToListAsync();
|
||||
if (candidates.Count > 0)
|
||||
{
|
||||
foreach (var g in candidates) g.Status = GerbilStatus.Deceased;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
app.UseCors(LanCorsPolicy);
|
||||
@@ -105,6 +126,7 @@ app.MapExportEndpoints();
|
||||
app.MapCmsEndpoints();
|
||||
app.MapRequestEndpoints();
|
||||
app.MapNamesEndpoints();
|
||||
app.MapFeedbackEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
53
GerbilManagerWebAPI/Services/GerbilStatusService.cs
Normal file
53
GerbilManagerWebAPI/Services/GerbilStatusService.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Central status-derivation logic. Every write path (create/update gerbil, SaleContract
|
||||
/// Abgabe, import, startup sweep) calls Apply() after setting the other fields so the
|
||||
/// derived statuses (Deceased, GivenAway) are always consistent.
|
||||
///
|
||||
/// Precedence (highest wins):
|
||||
/// 1) DateOfDeath set → Deceased (explicit, always)
|
||||
/// 2) Abgabe (ReceiverContactId set) → GivenAway
|
||||
/// 3) Age > MaxAgeYears without a death date or Abgabe → Deceased (presumed)
|
||||
/// 4) User-supplied {Breeding, Pet, ForSale}; defaults Breeding if invalid
|
||||
///
|
||||
/// Age-based death is time-dependent. The stored column is kept up-to-date by:
|
||||
/// a) Apply() on every write (catches the animal at write time)
|
||||
/// b) A startup sweep in Program.cs (catches animals that silently crossed the threshold)
|
||||
/// Gridify filters on the stored value, so status==Breeding never surfaces >7y animals.
|
||||
/// </summary>
|
||||
public static class GerbilStatusService
|
||||
{
|
||||
public const int MaxAgeYears = 6;
|
||||
|
||||
/// <summary>Derives and sets g.Status using the gerbil's current field values.
|
||||
/// Must be called AFTER all other fields (DateOfDeath, ReceiverContactId, DateOfBirth)
|
||||
/// have been applied. today = DateOnly.FromDateTime(DateTime.UtcNow).</summary>
|
||||
public static void Apply(Gerbil g, DateOnly today)
|
||||
{
|
||||
g.Status = Derive(g.Status, g.DateOfBirth, g.DateOfDeath,
|
||||
isAbgegeben: g.ReceiverContactId is not null, today);
|
||||
}
|
||||
|
||||
/// <summary>Pure derivation — useful for tests and the migration backfill.</summary>
|
||||
public static GerbilStatus Derive(
|
||||
GerbilStatus requested,
|
||||
DateOnly? dateOfBirth,
|
||||
DateOnly? dateOfDeath,
|
||||
bool isAbgegeben,
|
||||
DateOnly today)
|
||||
{
|
||||
if (dateOfDeath is not null) return GerbilStatus.Deceased;
|
||||
if (isAbgegeben) return GerbilStatus.GivenAway;
|
||||
if (dateOfBirth is not null && IsOlderThan(dateOfBirth.Value, MaxAgeYears, today))
|
||||
return GerbilStatus.Deceased;
|
||||
return requested is GerbilStatus.Breeding or GerbilStatus.Pet or GerbilStatus.ForSale
|
||||
? requested : GerbilStatus.Breeding;
|
||||
}
|
||||
|
||||
private static bool IsOlderThan(DateOnly dob, int years, DateOnly today) =>
|
||||
today >= dob.AddYears(years);
|
||||
}
|
||||
}
|
||||
@@ -50,28 +50,30 @@ Weitere Eigenschaften lassen sich jederzeit **additiv** ergänzen (1-Zeilen-Änd
|
||||
|
||||
Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (**7 erledigt:** Kazumi/Filou/Sokrates/Osamu/Percy/Iwana/Eragon ✅ — **6 offen**) (gleicher Name+Datum, widersprüchliche Angaben in mehreren Diagrammen). Sie warten in Quarantäne — **nichts ist verloren**, sie laden automatisch nach, sobald du je Tier kurz sagst was stimmt. (Uw=G + „Vorhandensein gewinnt" sind schon angewendet; das hier ist der echte Rest.)
|
||||
|
||||
_Quelldatei = die Stammbaum-Datei(en) in `C:\Users\gulum\dev\Sttammbäume\`, in denen das Tier vorkommt. **Mehrere Dateien = Ursache des Konflikts** (widersprüchliche Angaben in verschiedenen Diagrammen)._
|
||||
|
||||
**A) Nur Sterbedatum offen** (Gencode einig — bei Osamu/Filou/Sunny zusätzlich „taub" beibehalten):
|
||||
| Tier | Sterbedatum — welches? |
|
||||
|---|---|
|
||||
| Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 |
|
||||
| Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 |
|
||||
| ~~Osamu v.d. K.C. (*10.12.2015)~~ ✅ | **18.12.2020** (erledigt) |
|
||||
| ~~Filou v.d. K.C. (*24.11.2014)~~ ✅ | **31.08.2019** (erledigt) |
|
||||
| Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 |
|
||||
| Tier | Sterbedatum — welches? | Quelldatei(en) |
|
||||
|---|---|---|
|
||||
| Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 | Stammbaum von **Ella**.xlsx · von **Kalea**.xlsx |
|
||||
| Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 | Stammbaum von **Kalea**.xlsx · von **Rainny**.xlsx · von **Ren**.xlsx |
|
||||
| ~~Osamu v.d. K.C. (*10.12.2015)~~ ✅ | **18.12.2020** (erledigt) | — |
|
||||
| ~~Filou v.d. K.C. (*24.11.2014)~~ ✅ | **31.08.2019** (erledigt) | — |
|
||||
| Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 | Stammbaum von **Vance**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
|
||||
|
||||
**B) Gencode-Konflikt** (+ ggf. Sterbedatum):
|
||||
| Tier | Konflikt — was stimmt? |
|
||||
|---|---|
|
||||
| Milon v.d. K.C. (*27.11.2014) | A-Locus: **Aa** ↔ **aa** |
|
||||
| ~~Percy of little runners (*16.12.2017)~~ ✅ | P-Locus **Pp** (erledigt) |
|
||||
| ~~Iwana of little runners (*02.10.2018)~~ ✅ | P-Locus **Pp** (erledigt) |
|
||||
| ~~Sokrates v.d. K.C. (*14.12.2015)~~ ✅ | **D-** + Sterbedatum **20.05.2019** (erledigt) |
|
||||
| ~~Eragon (Elieus, *18.05.2016)~~ ✅ | C **CC** (vollfarbig) + Name **„Elieus gen. Eragon"**. Korrekter Datensatz lädt bereits (CC); Colourpoint-Variante „Kleiner Warnowrenner …" bleibt als Dublette in Quarantäne — kein weiterer Schritt nötig. |
|
||||
| Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** |
|
||||
| ~~Kazumi v.d. K.C. (*23.04.2013)~~ ✅ | **Aa Cc[chm] DD ee[f] GG PP Spsp** (erledigt) |
|
||||
| Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) |
|
||||
| Tier | Konflikt — was stimmt? | Quelldatei(en) |
|
||||
|---|---|---|
|
||||
| ~~Milon v.d. K.C. (*27.11.2014)~~ ✅ | A-Locus **Aa** (erledigt — Julian 2026-06-07: Aa gewinnt gg. aa) | — |
|
||||
| ~~Percy of little runners (*16.12.2017)~~ ✅ | P-Locus **Pp** (erledigt) | — |
|
||||
| ~~Iwana of little runners (*02.10.2018)~~ ✅ | P-Locus **Pp** (erledigt) | — |
|
||||
| ~~Sokrates v.d. K.C. (*14.12.2015)~~ ✅ | **D-** + Sterbedatum **20.05.2019** (erledigt) | — |
|
||||
| ~~Eragon (Elieus, *18.05.2016)~~ ✅ | C **CC** (vollfarbig) + Name **„Elieus gen. Eragon"**. Korrekter Datensatz lädt bereits (CC); Colourpoint-Variante „Kleiner Warnowrenner …" bleibt als Dublette in Quarantäne — kein weiterer Schritt nötig. | — |
|
||||
| Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** | Stammbaum von **Jiminy of Black Forest**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
|
||||
| ~~Kazumi v.d. K.C. (*23.04.2013)~~ ✅ | **Aa Cc[chm] DD ee[f] GG PP Spsp** (erledigt) | — |
|
||||
| Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) | Stammbaum von **Danako**.xlsx · von **Kalea**.xlsx · von **Vance**.xlsx · von **Wildfire und Vestras Kids**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
|
||||
|
||||
*(Alle Gencode-Varianten + Quelldateien: `tools/import/output/review-report.md`.)*
|
||||
*(Alle Gencode-Varianten + Quelldateien-Details: `tools/import/output/review-report.md`.)*
|
||||
|
||||
---
|
||||
|
||||
126
README.md
126
README.md
@@ -1,31 +1,95 @@
|
||||
# GerbilManager
|
||||
|
||||
- `GerbilManagerWebAPI/` — ASP.NET Core backend (Swagger unter `/swagger`)
|
||||
- `gerbil-manager-web/` — React-Frontend (Vite + TypeScript), siehe `gerbil-manager-web/README.md`
|
||||
|
||||
# Requirements
|
||||
Install ef:
|
||||
|
||||
dotnet tool install --global dotnet-ef
|
||||
|
||||
# Used cmd´s
|
||||
## Start containers
|
||||
docker compose up
|
||||
## Create the initial database
|
||||
dotnet ef database update
|
||||
## Create an migration script
|
||||
dotnet ef migrations add <name>
|
||||
|
||||
## Docker
|
||||
### force compose rebuild
|
||||
docker-compose build --no-cache
|
||||
|
||||
## Frontend (gerbil-manager-web)
|
||||
cd gerbil-manager-web
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
|
||||
TODO:
|
||||
- Backup docker volume database
|
||||
- run "dotnet ef database update" if no database exist.
|
||||
# GerbilManager
|
||||
|
||||
A manager application for gerbil breeding data featuring a .NET Aspire backend and a React-based frontend.
|
||||
|
||||
- `GerbilManagerWebAPI/` — ASP.NET Core backend (Swagger under `/swagger`)
|
||||
- `gerbil-manager-web/` — React Frontend (Vite + TypeScript), see `gerbil-manager-web/README.md`
|
||||
- `tools/import/` — Python import pipeline scripts and database schemas
|
||||
|
||||
## Requirements
|
||||
To manage database migrations, install the EF Core CLI tool globally:
|
||||
```bash
|
||||
dotnet tool install --global dotnet-ef
|
||||
```
|
||||
|
||||
## Running the Application
|
||||
To run the full stack (PostgreSQL database, WebAPI, and Frontend Vite app) in one command:
|
||||
```bash
|
||||
dotnet run --project GerbilManager.AppHost
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📥 Data Import & Normalization Pipeline
|
||||
|
||||
The application features a Python-based import pipeline that compiles gerbil breeding records, lineage, photos, and contact information from source files (markdown logs, Excel spreadsheets, Word documents) into a unified JSON format, and ingests them into the PostgreSQL database.
|
||||
|
||||
### 1. Structure of the Pipeline
|
||||
|
||||
* **Source Data**: Located at `C:\Users\gulum\dev\Wurfchronik_Bilder` (contains markdown files, `.xlsx` pedigree trees, and detail logs).
|
||||
* **Compilation**: [merge_and_resolve.py](file:///C:/Users/gulum/dev/GerbilManager/tools/import/merge_and_resolve.py) processes the source files, extracts breeding details, resolves lineage links, maps colors/genotypes, and normalizes contact names.
|
||||
* **Normalization Mapping**: `merge_and_resolve.py` contains a `norm_map` dictionary of contact name normalizations. This maps various spelling variations and abbreviations to a single canonical name.
|
||||
|
||||
### 2. How to Run the Import
|
||||
|
||||
#### Automatic Way (All-in-One)
|
||||
The PowerShell script in `tools/import` boots the Aspire stack, waits for the API to become ready, and automatically triggers the ingest:
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File tools/import/run_app_and_import.ps1
|
||||
```
|
||||
|
||||
#### Manual Way
|
||||
1. **Recompile the import data**:
|
||||
Run the compiler script from the root directory to parse raw sources and output a fresh `resolved_import.json`:
|
||||
```bash
|
||||
python tools/import/merge_and_resolve.py
|
||||
```
|
||||
2. **Ingest into the running database**:
|
||||
Ensure the application is running, then send an HTTP `POST` request to the API:
|
||||
```bash
|
||||
Invoke-RestMethod -Uri "http://localhost:5179/import/ingest-resolved" -Method Post
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contact Deduplication Workflow
|
||||
|
||||
When the database is wiped and re-imported, you can detect and resolve duplicate contacts (e.g. spelling mistakes, initials vs full names) using the deduplication checklist workflow.
|
||||
|
||||
### 1. Search for Duplicate Contacts
|
||||
You can run duplicate checks using two different similarity parameter models:
|
||||
* **Round 1 (Clever matching on first 4 characters)**:
|
||||
```bash
|
||||
python tools/import/find_duplicates_v5.py
|
||||
```
|
||||
Creates: `tools/import/output/potential_duplicates_decision.md`
|
||||
* **Round 2 (Broader matching on initials, matching last names, phones, or emails)**:
|
||||
```bash
|
||||
python tools/import/find_duplicates_round2.py
|
||||
```
|
||||
Creates: `tools/import/output/potential_duplicates_decision_round2.md`
|
||||
|
||||
### 2. Fill out the Checklist
|
||||
Open the generated markdown checklist file and edit the checkboxes:
|
||||
* **Combine/Merge**: Put an `x` in the checkbox `[x]` of the name you want to choose as the **canonical name**. Leave the checkboxes empty `[ ]` for any names you want to **merge into the canonical name**.
|
||||
* **Standalone**: Put an `o` in the checkbox `[o]` of any name that should remain a separate, standalone contact.
|
||||
* **New Name**: Put an `x` next to `Custom name:` and type your new desired name inside the backticks.
|
||||
* **Keep all separate**: Leave all checkboxes in a Choice empty `[ ]`.
|
||||
|
||||
*Example:*
|
||||
```markdown
|
||||
- [x] Max Mustermann <-- canonical name
|
||||
- [ ] Max M. <-- merges into Max Mustermann
|
||||
- [o] Moritz Mustermann <-- remains a separate standalone contact
|
||||
```
|
||||
|
||||
### 3. Apply the Decisions
|
||||
Once you have saved your decisions in the checklist file, run the parser:
|
||||
```bash
|
||||
python tools/import/parse_decisions.py
|
||||
```
|
||||
This automatically parses your selections, resolves the merges, and updates the `norm_map` inside `merge_and_resolve.py` so they are **permanently saved** in the codebase.
|
||||
Run the compilation pipeline again to produce the final deduplicated import JSON:
|
||||
```bash
|
||||
python tools/import/merge_and_resolve.py
|
||||
```
|
||||
@@ -7,20 +7,25 @@ POSTGRES_PASSWORD=aendere_mich_bitte
|
||||
# Externer Port fuer das Frontend (Standard: 80)
|
||||
PORT=80
|
||||
|
||||
# Gitea Container Registry (Standard: truenas:13000/gulum)
|
||||
REGISTRY=truenas:13000/gulum
|
||||
# Container Registry (git.rismer.de/gulum)
|
||||
REGISTRY=git.rismer.de/gulum
|
||||
TAG=latest
|
||||
|
||||
# NAS-Dataset-Pfade (TrueNAS SCALE: /mnt/<Pool>/<Dataset>)
|
||||
PGDATA_PATH=/mnt/SSD/gerbil/pgdata
|
||||
PHOTOS_PATH=/mnt/SSD/gerbil/photos
|
||||
BACKUPS_PATH=/mnt/SSD/gerbil/backups
|
||||
# NAS-Dataset-Pfade (TrueNAS SCALE Goldeye: /mnt/JailStorage/DockerVolumes/...)
|
||||
PGDATA_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
PHOTOS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/photos
|
||||
BACKUPS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/backups
|
||||
# AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung)
|
||||
KEYS_PATH=/mnt/SSD/gerbil/keys
|
||||
KEYS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/keys
|
||||
|
||||
# Backup-Rotation: Anzahl Tage (Standard: 7)
|
||||
BACKUP_KEEP_DAYS=7
|
||||
|
||||
# WEB-2: Oeffentliche Webseite (Shared Volume: api schreibt, publicsite-nginx liest)
|
||||
PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
|
||||
# Port fuer den publicsite-nginx (Julian's externer nginx leitet darauf weiter)
|
||||
PUBLICSITE_PORT=8081
|
||||
|
||||
# KI-Funktionen (Verkaufstext + Posteingang-Entwurf)
|
||||
# Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md
|
||||
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)
|
||||
|
||||
@@ -29,7 +29,7 @@ services:
|
||||
|
||||
# --- .NET API (GerbilManagerWebAPI) ---
|
||||
api:
|
||||
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-api:${TAG:-latest}"
|
||||
image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-api:${TAG:-latest}"
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: GerbilManagerWebAPI/Dockerfile
|
||||
@@ -48,9 +48,12 @@ services:
|
||||
AI__BaseUrl: "${AI__BaseUrl:-}"
|
||||
AI__ApiKey: "${AI__ApiKey:-}"
|
||||
AI__Model: "${AI__Model:-gemini-flash-latest}"
|
||||
# WEB-2: Pfad wo POST /api/publish die oeffentliche Seite hinschreibt
|
||||
PublicSite__RootPath: /data/publicsite
|
||||
volumes:
|
||||
- photos:/data/photos
|
||||
- keys:/data/keys
|
||||
- publicsite:/data/publicsite
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -63,7 +66,7 @@ services:
|
||||
|
||||
# --- nginx Frontend (React SPA + API-Proxy) ---
|
||||
frontend:
|
||||
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-frontend:${TAG:-latest}"
|
||||
image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-frontend:${TAG:-latest}"
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: gerbil-manager-web/Dockerfile
|
||||
@@ -74,6 +77,21 @@ services:
|
||||
api:
|
||||
condition: service_healthy
|
||||
|
||||
# --- nginx Public Site (WEB-2) ---
|
||||
# Serviert NUR die statische oeffentliche Seite (live/ aus dem publicsite-Volume).
|
||||
# SICHERHEIT: Kein Proxy auf api/frontend — nur statisches HTML nach aussen.
|
||||
# Julian's externer nginx-Proxy leitet <DOMAIN> auf Port 8081 weiter.
|
||||
publicsite:
|
||||
image: nginx:alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PUBLICSITE_PORT:-8081}:80"
|
||||
volumes:
|
||||
- publicsite:/usr/share/nginx/html:ro
|
||||
- ./nginx/publicsite.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
# --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) ---
|
||||
backup:
|
||||
image: postgres:17-alpine
|
||||
@@ -95,19 +113,19 @@ services:
|
||||
|
||||
volumes:
|
||||
# NAS-Datasets als Bind-Mounts (Pfade in .env konfigurieren).
|
||||
# TrueNAS: Dataset-Pfad z.B. /mnt/SSD/gerbil/pgdata
|
||||
# TrueNAS Goldeye: /mnt/JailStorage/DockerVolumes/gerbilmanager/<name>
|
||||
pgdata:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${PGDATA_PATH:-/mnt/gerbil/pgdata}"
|
||||
device: "${PGDATA_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata}"
|
||||
photos:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}"
|
||||
device: "${PHOTOS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/photos}"
|
||||
# AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung.
|
||||
# Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos).
|
||||
keys:
|
||||
@@ -115,10 +133,17 @@ volumes:
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${KEYS_PATH:-/mnt/gerbil/keys}"
|
||||
device: "${KEYS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/keys}"
|
||||
backups:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}"
|
||||
device: "${BACKUPS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/backups}"
|
||||
# WEB-2: gemeinsames Volume fuer api (rw) und publicsite-nginx (ro).
|
||||
publicsite:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${PUBLICSITE_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite}"
|
||||
|
||||
29
deploy/truenas/nginx/publicsite.conf
Normal file
29
deploy/truenas/nginx/publicsite.conf
Normal file
@@ -0,0 +1,29 @@
|
||||
# GerbilManager — publicsite nginx (WEB-2)
|
||||
# Serviert die statische oeffentliche Seite aus dem live/-Verzeichnis des Shared Volumes.
|
||||
# SICHERHEIT: Kein Proxy auf die API, kein Zugriff auf den Manager.
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html/live;
|
||||
index index.html;
|
||||
charset utf-8;
|
||||
|
||||
# Alle Seiten: no-cache (Aenderungen sofort sichtbar nach Veroeffentlichen)
|
||||
location / {
|
||||
try_files $uri $uri/index.html =404;
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
}
|
||||
|
||||
# CSS/Bilder: kurze TTL (1 Tag)
|
||||
location ~* \.(css|png|jpg|jpeg|gif|ico|webp|svg)$ {
|
||||
try_files $uri =404;
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
}
|
||||
|
||||
# Kein Zugriff auf Staging-Verzeichnisse
|
||||
location ~ ^/_(staging_new|old)/ {
|
||||
return 403;
|
||||
}
|
||||
}
|
||||
37
deploy/truenas/vhost-snippet.conf
Normal file
37
deploy/truenas/vhost-snippet.conf
Normal file
@@ -0,0 +1,37 @@
|
||||
# GerbilManager — Externer nginx-Vhost fuer die oeffentliche Webseite (WEB-2)
|
||||
# In Julians bestehenden nginx-Reverse-Proxy einfuegen.
|
||||
# <DOMAIN> ersetzen sobald der Hostname feststeht (Julian liefert ihn).
|
||||
#
|
||||
# SICHERHEIT: Dieser Vhost zeigt NUR auf den publicsite-Container (Port 8081).
|
||||
# Der Manager (API + Frontend, Port 80) ist NICHT erreichbar von aussen —
|
||||
# er hat keine Authentifizierung und muss LAN-only bleiben.
|
||||
server {
|
||||
listen 80;
|
||||
server_name <DOMAIN>;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Kein Buffering fuer kleine statische HTML-Seiten
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
|
||||
# Fuer HTTPS (empfohlen, z.B. per Let's Encrypt via certbot):
|
||||
# server {
|
||||
# listen 443 ssl;
|
||||
# server_name <DOMAIN>;
|
||||
# ssl_certificate /etc/letsencrypt/live/<DOMAIN>/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/<DOMAIN>/privkey.pem;
|
||||
# location / {
|
||||
# proxy_pass http://127.0.0.1:8081;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# }
|
||||
# }
|
||||
327
docs/ops.md
327
docs/ops.md
@@ -1,4 +1,4 @@
|
||||
# GerbilManager — Betriebsanleitung (TrueNAS)
|
||||
# GerbilManager — Betriebsanleitung (TrueNAS SCALE Goldeye)
|
||||
|
||||
> Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung).
|
||||
> Bookmark für die Ehefrau: **http://\<NAS-IP\>/** (z. B. http://truenas/)
|
||||
@@ -9,13 +9,12 @@
|
||||
|
||||
1. [Übersicht & Architektur](#1-übersicht--architektur)
|
||||
2. [Voraussetzungen](#2-voraussetzungen)
|
||||
3. [Erstinstallation auf TrueNAS](#3-erstinstallation-auf-truenas)
|
||||
3. [Erstinstallation auf TrueNAS Goldeye](#3-erstinstallation-auf-truenas-goldeye)
|
||||
4. [App starten / stoppen / aktualisieren](#4-app-starten--stoppen--aktualisieren)
|
||||
5. [Backup & Wiederherstellung](#5-backup--wiederherstellung)
|
||||
6. [ZFS-Snapshot-Schichtung](#6-zfs-snapshot-schichtung)
|
||||
7. [CI/CD via Gitea Actions](#7-cicd-via-gitea-actions)
|
||||
8. [Offene Fragen (bitte beantworten)](#8-offene-fragen)
|
||||
9. [Fehlerbehebung](#9-fehlerbehebung)
|
||||
8. [Fehlerbehebung](#8-fehlerbehebung)
|
||||
|
||||
---
|
||||
|
||||
@@ -23,7 +22,7 @@
|
||||
|
||||
```
|
||||
Browser / Handy
|
||||
| HTTP :80
|
||||
| HTTP :80 (oder PORT aus .env, z.B. 8080)
|
||||
v
|
||||
┌──────────────────┐
|
||||
│ frontend (nginx) │ statisches React-SPA + Reverse-Proxy
|
||||
@@ -40,9 +39,10 @@ Browser / Handy
|
||||
│ db (Postgres 17)│ │ backup (Sidecar) │
|
||||
└──────────────────┘ │ pg_dump + tar + cron │
|
||||
│ └──────────────────────┘
|
||||
└─ pgdata-Volume (NAS-Dataset)
|
||||
photos-Volume (NAS-Dataset)
|
||||
backups-Volume (NAS-Dataset)
|
||||
└─ pgdata-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
photos-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/photos
|
||||
backups-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/backups
|
||||
keys-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/keys
|
||||
```
|
||||
|
||||
**Einziger veröffentlichter Port:** `80` (konfigurierbar via `PORT` in `.env`).
|
||||
@@ -54,68 +54,107 @@ Alles andere läuft intern im Docker-Netz.
|
||||
|
||||
| Was | Details |
|
||||
|-----|---------|
|
||||
| TrueNAS SCALE | Electric Eel 24.10+ (native Docker Custom Apps) |
|
||||
| Gitea | http://truenas:13000 — Repository `Gulum/GerbilManager` |
|
||||
| Docker | bereits auf TrueNAS vorhanden (Custom Apps nutzen es) |
|
||||
| Datasets | Drei ZFS-Datasets anlegen (siehe Schritt 3) |
|
||||
| TrueNAS SCALE | **25.10.2.1 „Goldeye"** (native Docker Custom Apps) |
|
||||
| Container Registry | `git.rismer.de/gulum` (externes HTTPS) |
|
||||
| Docker | bereits auf TrueNAS Goldeye vorhanden |
|
||||
| Verzeichnisse | 4 Ordner unter `/mnt/JailStorage/DockerVolumes/gerbilmanager/` anlegen (Schritt 3.1) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Erstinstallation auf TrueNAS
|
||||
## 3. Erstinstallation auf TrueNAS Goldeye
|
||||
|
||||
### 3.1 ZFS-Datasets anlegen
|
||||
### 3.1 Verzeichnisse anlegen und Berechtigungen setzen
|
||||
|
||||
In TrueNAS → **Datasets** → **Dataset hinzufügen** (je einmal wiederholen):
|
||||
|
||||
| Dataset-Name | Empfohlener Pfad | Verwendung |
|
||||
|---|---|---|
|
||||
| `gerbil/pgdata` | `/mnt/SSD/gerbil/pgdata` | Postgres-Datenbankdateien |
|
||||
| `gerbil/photos` | `/mnt/SSD/gerbil/photos` | Hochgeladene Tierfotos |
|
||||
| `gerbil/backups` | `/mnt/SSD/gerbil/backups` | Tägliche Backups |
|
||||
|
||||
> **Tipp:** Passe die Pool-Bezeichnung (`SSD`) an deinen tatsächlichen Pool an.
|
||||
|
||||
### 3.2 Repository klonen
|
||||
Öffne eine Shell auf der NAS (TrueNAS → System → Shell oder SSH):
|
||||
|
||||
```bash
|
||||
# SSH in TrueNAS oder lokale Shell
|
||||
git clone http://truenas:13000/Gulum/GerbilManager.git /opt/gerbilmanager
|
||||
# Vier Ordner anlegen
|
||||
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/photos
|
||||
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/backups
|
||||
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/keys
|
||||
|
||||
# Postgres-Container läuft als UID 999 (postgres) / GID 999 intern.
|
||||
# pgdata muss von UID 999 beschreibbar sein; postgres erzwingt chmod 0700.
|
||||
chown -R 999:999 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
chmod 700 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
|
||||
# photos, backups und keys werden von der API bzw. dem Sidecar beschrieben
|
||||
# (laufen als root im Container) — keine weiteren ACL-Anpassungen nötig.
|
||||
```
|
||||
|
||||
> **TrueNAS Dataset-ACL-Hinweis:** Falls `JailStorage` ein ZFS-Dataset mit NFSv4-ACLs ist,
|
||||
> und `chown` meldet „Operation not permitted": setze in TrueNAS → Datasets →
|
||||
> `JailStorage` → Berechtigungen → **ACL-Typ: POSIX** (oder nutze das UI-Formular
|
||||
> „Eigentümer: 999, Gruppe: 999" für das `pgdata`-Unterverzeichnis).
|
||||
|
||||
### 3.2 Registry-Login auf der NAS
|
||||
|
||||
```bash
|
||||
docker login git.rismer.de
|
||||
# Benutzername und Token/Passwort eingeben (Gitea-Account oder Access Token mit read:packages)
|
||||
```
|
||||
|
||||
Der Login wird unter `/root/.docker/config.json` gespeichert und bleibt nach Reboots erhalten.
|
||||
|
||||
### 3.3 Repository klonen
|
||||
|
||||
```bash
|
||||
git clone https://git.rismer.de/gulum/GerbilManager.git /opt/gerbilmanager
|
||||
cd /opt/gerbilmanager
|
||||
```
|
||||
|
||||
### 3.3 Konfiguration anlegen
|
||||
### 3.4 Konfiguration anlegen
|
||||
|
||||
```bash
|
||||
cp deploy/truenas/.env.example deploy/truenas/.env
|
||||
# Jetzt .env bearbeiten:
|
||||
nano deploy/truenas/.env
|
||||
```
|
||||
|
||||
Mindestens setzen:
|
||||
- `POSTGRES_PASSWORD` — sicheres Passwort (mind. 20 Zeichen)
|
||||
- `PGDATA_PATH`, `PHOTOS_PATH`, `BACKUPS_PATH` — tatsächliche Dataset-Pfade
|
||||
|
||||
### 3.4 Images bauen und App starten
|
||||
| Variable | Wert |
|
||||
|----------|------|
|
||||
| `POSTGRES_PASSWORD` | Sicheres Passwort (mind. 20 Zeichen, keine `"`) |
|
||||
| `AI__BaseUrl` | Gemini: `https://generativelanguage.googleapis.com/v1beta/openai` |
|
||||
| `AI__ApiKey` | Dein Gemini API-Key |
|
||||
| `AI__Model` | `gemini-2.0-flash` (oder `gemini-flash-latest`) |
|
||||
| `PORT` | `80` — falls Port 80 auf der NAS bereits belegt ist: **auf `8080` ändern** |
|
||||
|
||||
Die Pfad-Variablen (`PGDATA_PATH`, `PHOTOS_PATH`, etc.) sind bereits auf die Goldeye-Standardpfade
|
||||
vorbelegt und müssen nur geändert werden, wenn du einen anderen Pool nutzt.
|
||||
|
||||
### 3.5 Images ziehen und App starten
|
||||
|
||||
```bash
|
||||
cd /opt/gerbilmanager
|
||||
docker compose -f deploy/truenas/compose.yaml build
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
Erster Start dauert ca. 2–3 Minuten (Postgres-Init + EF-Migrationen).
|
||||
|
||||
### 3.5 Prüfen
|
||||
> **TrueNAS Goldeye Custom App (Alternative):**
|
||||
> Statt der Shell kann die App auch über TrueNAS → Apps → „Custom App installieren" →
|
||||
> „Install via YAML" deployt werden: compose-Inhalt einfügen, Volumes als Host-Pfade
|
||||
> konfigurieren. Die Shell-Methode ist einfacher und gibt mehr Kontrolle.
|
||||
|
||||
### 3.6 Verifikation
|
||||
|
||||
```bash
|
||||
# Alle Container laufen?
|
||||
# Alle 4 Container laufen?
|
||||
docker compose -f deploy/truenas/compose.yaml ps
|
||||
|
||||
# API-Healthcheck
|
||||
curl http://localhost/api/health
|
||||
# API-Healthcheck (erwartet: {"status":"Healthy"})
|
||||
curl -s http://localhost/api/health
|
||||
|
||||
# Webapp im Browser
|
||||
http://<NAS-IP>/
|
||||
# Tier-Gesamtanzahl prüfen (erwartet > 0 nach Import)
|
||||
curl -s "http://localhost/api/gerbils?pageSize=1" | grep -o '"totalCount":[0-9]*'
|
||||
|
||||
# API-Doku (Scalar) im Browser
|
||||
http://<NAS-IP>/scalar
|
||||
|
||||
# Foto-Upload: in der Webapp ein Tier öffnen → Foto hochladen → Foto erscheint
|
||||
```
|
||||
|
||||
---
|
||||
@@ -125,6 +164,7 @@ http://<NAS-IP>/
|
||||
### Starten
|
||||
|
||||
```bash
|
||||
cd /opt/gerbilmanager
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
@@ -134,26 +174,17 @@ docker compose -f deploy/truenas/compose.yaml up -d
|
||||
docker compose -f deploy/truenas/compose.yaml down
|
||||
```
|
||||
|
||||
### Aktualisieren (nach `git push` auf main)
|
||||
### Aktualisieren (nach CI-Push auf main)
|
||||
|
||||
```bash
|
||||
cd /opt/gerbilmanager
|
||||
git pull
|
||||
docker compose -f deploy/truenas/compose.yaml build
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
> EF-Migrationen laufen automatisch beim API-Start — kein manueller Schritt nötig.
|
||||
|
||||
### Mit Gitea CI (wenn Actions aktiviert)
|
||||
|
||||
Push auf `main` triggert automatisch Build → Test → Image-Push.
|
||||
Danach auf der NAS:
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Backup & Wiederherstellung
|
||||
@@ -165,12 +196,12 @@ Der `backup`-Sidecar-Container läuft dauerhaft und sichert täglich um **03:00
|
||||
- Komprimiertes Foto-Archiv als `.tar.gz`
|
||||
- Rotation: Backups älter als `BACKUP_KEEP_DAYS` (Standard: 7) werden gelöscht
|
||||
|
||||
Backups liegen unter: `${BACKUPS_PATH}/YYYY-MM-DD_HH-MM/`
|
||||
Backups liegen unter: `/mnt/JailStorage/DockerVolumes/gerbilmanager/backups/YYYY-MM-DD_HH-MM/`
|
||||
|
||||
```
|
||||
/mnt/SSD/gerbil/backups/
|
||||
/mnt/JailStorage/DockerVolumes/gerbilmanager/backups/
|
||||
2026-06-06_03-00/
|
||||
gerbilmanager_2026-06-06_03-00.sql (Datenbank)
|
||||
gerbilmanager_2026-06-06_03-00.sql (Datenbank-Dump, Klartext SQL)
|
||||
photos_2026-06-06_03-00.tar.gz (Fotos)
|
||||
backup.log (Protokoll)
|
||||
```
|
||||
@@ -184,52 +215,68 @@ docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backu
|
||||
### Backup-Log prüfen
|
||||
|
||||
```bash
|
||||
tail -50 /mnt/SSD/gerbil/backups/backup.log
|
||||
tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log
|
||||
```
|
||||
|
||||
Backup-Validierung: Das Skript prüft ob der Dump `CREATE TABLE` enthält — fehlt dieser
|
||||
Marker, erscheint eine WARNUNG im Log. Größe 0 KB bedeutet Fehlschlag.
|
||||
|
||||
### Wiederherstellung — Runbook
|
||||
|
||||
> **WARNUNG:** Alle aktuellen Daten werden überschrieben!
|
||||
> **WARNUNG:** Alle aktuellen Datenbankdaten und Fotos werden überschrieben!
|
||||
|
||||
**Schritt 1:** App stoppen (optional, aber empfohlen)
|
||||
**Schritt 1:** API und Frontend stoppen (DB und backup-Sidecar laufen weiter)
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml stop api frontend
|
||||
```
|
||||
|
||||
**Schritt 2:** Restore ausführen
|
||||
```bash
|
||||
# Neuestes Backup wiederherstellen:
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
||||
/bin/sh /scripts/restore.sh
|
||||
|
||||
# Bestimmtes Backup wiederherstellen:
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
||||
/bin/sh /scripts/restore.sh 2026-06-05_03-00
|
||||
```bash
|
||||
# Neuestes Backup automatisch wählen und bestätigen:
|
||||
docker compose -f deploy/truenas/compose.yaml exec -T backup \
|
||||
/bin/sh /scripts/restore.sh latest -f
|
||||
|
||||
# Bestimmtes Backup (Datum aus Verzeichnisname):
|
||||
docker compose -f deploy/truenas/compose.yaml exec -T backup \
|
||||
/bin/sh /scripts/restore.sh 2026-06-06_03-00 -f
|
||||
```
|
||||
|
||||
Das Skript:
|
||||
1. Trennt alle offenen DB-Verbindungen
|
||||
2. Spielt den SQL-Dump mit `psql -h db -U postgres -d gerbilmanager < dump.sql` ein
|
||||
3. Entpackt das Foto-Archiv nach `/data/photos`
|
||||
|
||||
**Schritt 3:** API neu starten
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml start api frontend
|
||||
```
|
||||
|
||||
**Schritt 4:** Prüfen
|
||||
**Schritt 4 — Verifikation (Pflicht nach erstem Restore-Drill):**
|
||||
```bash
|
||||
curl http://localhost/api/color-varieties | grep -c '"id"'
|
||||
# Erwarteter Wert: 73
|
||||
# Tier-Anzahl prüfen
|
||||
curl -s "http://localhost/api/gerbils?pageSize=1" | grep -o '"totalCount":[0-9]*'
|
||||
|
||||
# ColorVariety-Anzahl (Stammdaten, erwartet: >= 60)
|
||||
curl -s http://localhost/api/color-varieties | python3 -c "import sys,json; print(len(json.load(sys.stdin)))"
|
||||
|
||||
# Foto stichprobenartig prüfen
|
||||
ls /mnt/JailStorage/DockerVolumes/gerbilmanager/photos/ | head -5
|
||||
```
|
||||
|
||||
### Restore-Nachweis (Round-Trip-Test)
|
||||
### Restore-Nachweis (Round-Trip-Test, lokal 2026-06-06)
|
||||
|
||||
Protokoll vom Test auf lokalem Aspire-Postgres (Vorgänger-Instanz, 2026-06-06 07:09):
|
||||
Protokoll vom getesteten Restore auf lokalem Aspire-Postgres:
|
||||
```
|
||||
73 ColorVarieties vorhanden
|
||||
→ DELETE 12 Zeilen → 61 verbleibend
|
||||
→ pg_restore eingespielt
|
||||
→ psql < dump.sql eingespielt
|
||||
→ 73 ColorVarieties bestätigt
|
||||
Exit-Code: 0
|
||||
```
|
||||
Die Container-Restore-Skripte nutzen dieselbe `psql < dump.sql` Logik.
|
||||
**Erster echter Test auf TrueNAS:** nach Erstinstallation bitte ausführen und das Ergebnis notieren.
|
||||
|
||||
**Erster TrueNAS-Restore-Drill:** nach Erstinstallation bitte ausführen und Tier-Anzahl
|
||||
notieren — beweist dass Backup + Restore auf dem NAS korrekt funktionieren.
|
||||
|
||||
---
|
||||
|
||||
@@ -240,15 +287,15 @@ Sie schützen vor versehentlichem Datenverlust auf Dataset-Ebene.
|
||||
|
||||
### Empfohlene Snapshot-Konfiguration
|
||||
|
||||
In TrueNAS → **Datasets** → Dataset auswählen → **Snapshots** → **Regelmäßige Snapshots**:
|
||||
In TrueNAS → **Datasets** → `JailStorage/DockerVolumes/gerbilmanager` → **Snapshots** → **Regelmäßige Snapshots**:
|
||||
|
||||
| Dataset | Häufigkeit | Aufbewahrung |
|
||||
|---------|-----------|--------------|
|
||||
| `gerbil/photos` | Stündlich | 24 Stunden |
|
||||
| `gerbil/photos` | Täglich | 30 Tage |
|
||||
| `gerbil/pgdata` | Stündlich | 24 Stunden |
|
||||
| `gerbil/pgdata` | Täglich | 30 Tage |
|
||||
| `gerbil/backups` | Täglich | 90 Tage |
|
||||
| Unterordner | Häufigkeit | Aufbewahrung |
|
||||
|-------------|-----------|--------------|
|
||||
| `.../photos` | Stündlich | 24 Stunden |
|
||||
| `.../photos` | Täglich | 30 Tage |
|
||||
| `.../pgdata` | Stündlich | 24 Stunden |
|
||||
| `.../pgdata` | Täglich | 30 Tage |
|
||||
| `.../backups` | Täglich | 90 Tage |
|
||||
|
||||
> **Hinweis:** `pgdata` enthält Live-Postgres-Dateien. ZFS-Snapshots davon sind crash-konsistent,
|
||||
> aber **nicht** application-konsistent — für einen sauberen DB-Restore immer den `pg_dump` verwenden,
|
||||
@@ -257,94 +304,93 @@ In TrueNAS → **Datasets** → Dataset auswählen → **Snapshots** → **Regel
|
||||
### Snapshot manuell erstellen (z. B. vor Update)
|
||||
|
||||
```bash
|
||||
# TrueNAS CLI
|
||||
zfs snapshot SSD/gerbil/photos@vor-update-$(date +%Y%m%d)
|
||||
zfs snapshot SSD/gerbil/backups@vor-update-$(date +%Y%m%d)
|
||||
# Pool-/Dataset-Name anpassen falls nötig
|
||||
zfs snapshot JailStorage/DockerVolumes/gerbilmanager/photos@vor-update-$(date +%Y%m%d)
|
||||
zfs snapshot JailStorage/DockerVolumes/gerbilmanager/backups@vor-update-$(date +%Y%m%d)
|
||||
```
|
||||
|
||||
### Aus ZFS-Snapshot wiederherstellen (Fotos)
|
||||
|
||||
```bash
|
||||
# Snapshot auflisten
|
||||
zfs list -t snapshot SSD/gerbil/photos
|
||||
# Snapshots auflisten
|
||||
zfs list -t snapshot JailStorage/DockerVolumes/gerbilmanager/photos
|
||||
|
||||
# Datei aus Snapshot kopieren
|
||||
cp /mnt/SSD/gerbil/photos/.zfs/snapshot/<NAME>/datei.jpg /mnt/SSD/gerbil/photos/
|
||||
# Einzelne Datei aus Snapshot kopieren
|
||||
cp /mnt/JailStorage/DockerVolumes/gerbilmanager/photos/.zfs/snapshot/<NAME>/datei.jpg \
|
||||
/mnt/JailStorage/DockerVolumes/gerbilmanager/photos/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. CI/CD via Gitea Actions
|
||||
|
||||
Der Workflow `.gitea/workflows/ci.yml` ist als **Entwurf vorhanden, aber inaktiv**.
|
||||
CI pusht Images nach Erfolg zu `git.rismer.de/gulum/gerbilmanager-api` und
|
||||
`git.rismer.de/gulum/gerbilmanager-frontend`.
|
||||
|
||||
### Aktivierung
|
||||
### Registry-Secrets in Gitea
|
||||
|
||||
1. **Gitea Actions aktivieren:**
|
||||
Gitea → Repository `GerbilManager` → Einstellungen → Actions → "Actions aktivieren"
|
||||
Gitea → Repository → Einstellungen → Secrets:
|
||||
|
||||
2. **Gitea Actions Runner installieren** (auf TrueNAS oder einem separaten Gerät):
|
||||
```bash
|
||||
# Gitea Runner Container (einfachste Variante für TrueNAS)
|
||||
docker run -d --name gitea-runner \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /opt/gitea-runner:/data \
|
||||
-e GITEA_INSTANCE_URL=http://truenas:13000 \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<TOKEN> \
|
||||
gitea/act_runner:latest
|
||||
```
|
||||
Token: Gitea → Admin → Actions → Runner → "Runner hinzufügen"
|
||||
| Secret | Wert |
|
||||
|--------|------|
|
||||
| `REGISTRY_USER` | Gitea-Benutzername |
|
||||
| `REGISTRY_TOKEN` | Gitea Access Token mit `package:write` |
|
||||
|
||||
3. **Registry-Secrets konfigurieren:**
|
||||
Gitea → Repository → Einstellungen → Secrets:
|
||||
- `REGISTRY_USER` — dein Gitea-Benutzername
|
||||
- `REGISTRY_TOKEN` — Gitea Access Token mit `package:write`-Berechtigung
|
||||
### Update nach CI-Push
|
||||
|
||||
### Workflow nach Aktivierung
|
||||
|
||||
```
|
||||
git push origin main
|
||||
→ Gitea Actions: dotnet test + npm test + npm run build
|
||||
→ Bei Erfolg: docker build + push zu truenas:13000/gulum/
|
||||
→ Auf NAS: docker compose pull + up -d
|
||||
```bash
|
||||
# Auf der NAS nach erfolgreichem CI-Lauf:
|
||||
cd /opt/gerbilmanager
|
||||
git pull
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Offene Fragen
|
||||
|
||||
Bitte beantworte diese Fragen, damit das Setup fertiggestellt werden kann:
|
||||
|
||||
| # | Frage | Auswirkung |
|
||||
|---|-------|-----------|
|
||||
| 1 | **TrueNAS SCALE Version?** Electric Eel 24.10 hat native Docker Custom Apps. Ältere Versionen nutzen Kubernetes. | Bestimmt ob `docker compose` direkt läuft |
|
||||
| 2 | **Gitea Actions verfügbar/aktivierbar?** | CI/CD-Workflow aktiv oder nur manuell deployen |
|
||||
| 3 | **Eigener Postgres-Container (empfohlen) oder vorhandene NAS-Postgres-App?** | Isolation vs. geteilte Instanz |
|
||||
| 4 | **Genaue Dataset-Pfade?** Poolname und Pfad-Präfix | `.env`-Konfiguration |
|
||||
| 5 | **Port-Wahl?** Standard 80 — frei auf der NAS? | `PORT`-Wert in `.env` |
|
||||
|
||||
---
|
||||
|
||||
## 9. Fehlerbehebung
|
||||
## 8. Fehlerbehebung
|
||||
|
||||
### App startet nicht
|
||||
|
||||
```bash
|
||||
# Logs aller Container
|
||||
docker compose -f deploy/truenas/compose.yaml logs
|
||||
|
||||
# Logs eines bestimmten Containers
|
||||
docker compose -f deploy/truenas/compose.yaml logs api
|
||||
docker compose -f deploy/truenas/compose.yaml logs db
|
||||
```
|
||||
|
||||
### Port 80 belegt
|
||||
|
||||
Falls Port 80 vom TrueNAS-System selbst genutzt wird:
|
||||
|
||||
```bash
|
||||
# In deploy/truenas/.env:
|
||||
PORT=8080
|
||||
# Dann neu starten:
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
### Postgres startet nicht (Permission denied auf pgdata)
|
||||
|
||||
```bash
|
||||
# UID 999 muss Eigentümer des pgdata-Verzeichnisses sein:
|
||||
chown -R 999:999 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
chmod 700 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
docker compose -f deploy/truenas/compose.yaml restart db
|
||||
```
|
||||
|
||||
### Registry-Pull schlägt fehl
|
||||
|
||||
```bash
|
||||
# Neu einloggen:
|
||||
docker login git.rismer.de
|
||||
# Dann pull wiederholen:
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
```
|
||||
|
||||
### Datenbank nicht erreichbar
|
||||
|
||||
```bash
|
||||
# DB-Container läuft?
|
||||
docker compose -f deploy/truenas/compose.yaml ps db
|
||||
|
||||
# Verbindung testen
|
||||
docker compose -f deploy/truenas/compose.yaml exec db \
|
||||
psql -U postgres -d gerbilmanager -c "\dt"
|
||||
```
|
||||
@@ -352,27 +398,16 @@ docker compose -f deploy/truenas/compose.yaml exec db \
|
||||
### Backup-Fehler
|
||||
|
||||
```bash
|
||||
# Backup-Log prüfen
|
||||
cat /mnt/SSD/gerbil/backups/backup.log | tail -30
|
||||
|
||||
# Backup manuell starten (mit Fehlerausgabe)
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
||||
/bin/sh /scripts/backup.sh
|
||||
tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backup.sh
|
||||
```
|
||||
|
||||
### Fotos werden nicht angezeigt
|
||||
|
||||
Prüfe ob das `photos`-Volume korrekt gemounted ist:
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml exec api ls /data/photos
|
||||
```
|
||||
|
||||
### Container-Status zurücksetzen (Neustart)
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml restart api
|
||||
```
|
||||
|
||||
### Kompletter Neustart (Daten bleiben erhalten)
|
||||
|
||||
```bash
|
||||
|
||||
136
docs/web-deploy.md
Normal file
136
docs/web-deploy.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# GerbilManager — Oeffentliche Webseite (Self-Hosted, TrueNAS)
|
||||
|
||||
> **Zielgruppe:** Julian.
|
||||
> Die oeffentliche Seite (Jimdo-Ersatz) laeuft self-hosted auf der TrueNAS neben dem Manager.
|
||||
> Strato-Domain → DynDNS → IP → Julians nginx-Proxy → publicsite-Container (Port 8081).
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
Internet
|
||||
| HTTPS/HTTP
|
||||
v
|
||||
Julians nginx-Reverse-Proxy (laeuft schon auf NAS)
|
||||
| proxy_pass http://127.0.0.1:8081
|
||||
v
|
||||
publicsite (nginx:alpine, Port 8081) ← liest nur: /usr/share/nginx/html/live/
|
||||
| (Shared Volume, read-only)
|
||||
| [POST /api/publish im Manager schreibt in dasselbe Volume]
|
||||
v
|
||||
api (.NET, Port 8080 intern) → schreibt: /data/publicsite/live/
|
||||
| (Shared Volume, read-write)
|
||||
v
|
||||
Manager (frontend-nginx, Port 80) ← LAN-only, NIE internet-exponiert
|
||||
```
|
||||
|
||||
**SICHERHEIT — harte Bedingung:**
|
||||
- Nur `publicsite` (Port 8081) wird ins Internet weitergeleitet.
|
||||
- Der Manager (API + Frontend, Port 80) hat KEINE Authentifizierung → LAN-only.
|
||||
- Der `publicsite`-nginx proxied NICHT auf die API — er serviert nur statisches HTML.
|
||||
|
||||
---
|
||||
|
||||
## Erstinstallation
|
||||
|
||||
### 1. Verzeichnis anlegen
|
||||
|
||||
```bash
|
||||
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
|
||||
```
|
||||
|
||||
Das Verzeichnis wird von der API beschrieben (laeuft als root im Container) — keine ACL-Aenderung noetig.
|
||||
Beim ersten `POST /api/publish` legt die API automatisch `live/` und `_staging_new/` darunter an.
|
||||
|
||||
### 2. .env erganzen
|
||||
|
||||
In `deploy/truenas/.env` hinzufuegen (oder aus `.env.example` uebernehmen):
|
||||
|
||||
```env
|
||||
PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
|
||||
PUBLICSITE_PORT=8081
|
||||
```
|
||||
|
||||
### 3. Compose-Stack neu starten
|
||||
|
||||
```bash
|
||||
cd /opt/gerbilmanager
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
Der neue `publicsite`-Container startet und serviert Port 8081.
|
||||
Solange noch nicht veroeffentlicht wurde, zeigt er einen 404 (live/-Verzeichnis leer).
|
||||
|
||||
### 4. Julians externen nginx konfigurieren
|
||||
|
||||
Inhalt von `deploy/truenas/vhost-snippet.conf` in den bestehenden nginx-Proxy einfuegen
|
||||
(als eigenen `server`-Block oder per `include`):
|
||||
|
||||
```bash
|
||||
# Auf der NAS, nginx-Konfigverzeichnis (z.B. /etc/nginx/conf.d/ oder sites-available):
|
||||
nano /etc/nginx/conf.d/gerbilmanager-public.conf
|
||||
# <DOMAIN> durch den tatsaechlichen Hostnamen ersetzen
|
||||
nginx -t && nginx -s reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Seite veroeffentlichen (Publish-Ablauf)
|
||||
|
||||
1. Im Manager einloggen (http://\<NAS-IP\>/)
|
||||
2. Navigiere zu **Webseite** → Inhalte bearbeiten → **Veroeffentlichen**
|
||||
3. Klick auf "Veroeffentlichen" loest `POST /api/publish` aus.
|
||||
|
||||
**Was passiert intern:**
|
||||
```
|
||||
POST /api/publish
|
||||
→ API baut SiteSnapshot aus DB (alle Published-Seiten)
|
||||
→ SiteRenderer rendert Snapshot → HTML-Dateien (path → content Map)
|
||||
→ Schreibt Dateien nach /data/publicsite/_staging_new/
|
||||
→ Atomic Swap: _staging_new/ → live/ (rename = ein Syscall, nie halb-geschrieben)
|
||||
→ publicsite-nginx serviert beim naechsten Request sofort den neuen Stand
|
||||
→ Kein Container-Restart, kein Image-Rebuild, kein CI
|
||||
Response: { "filesPublished": N }
|
||||
```
|
||||
|
||||
**Endergebnis:** publicsite-nginx liest sofort den neuen Stand aus `live/`.
|
||||
|
||||
---
|
||||
|
||||
## Verifikation
|
||||
|
||||
```bash
|
||||
# publicsite-Container laeuft?
|
||||
docker compose -f deploy/truenas/compose.yaml ps publicsite
|
||||
|
||||
# Seite lokal abrufbar?
|
||||
curl -s http://localhost:8081/ | head -5
|
||||
|
||||
# live/-Verzeichnis gefuellt?
|
||||
ls /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite/live/
|
||||
|
||||
# Oeffentlich erreichbar (nach DNS-Propagation)?
|
||||
curl -s http://<DOMAIN>/ | grep "Kleine Chaoten"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sicherheitstrennung (Pflichtcheck)
|
||||
|
||||
| Was | Port | Internet-exponiert? |
|
||||
|-----|------|---------------------|
|
||||
| Manager (api + frontend) | 80 | **NEIN** — LAN-only |
|
||||
| Oeffentliche Seite (publicsite) | 8081 | Ja, via Julians nginx-Proxy |
|
||||
| API-Doku (Scalar) | 80/scalar | **NEIN** — LAN-only |
|
||||
|
||||
Der Manager-nginx (gerbilmanager-frontend, Port 80) und die API (Port 8080 intern)
|
||||
sind NICHT in `vhost-snippet.conf` eingetragen und NICHT in Julians externem Proxy konfiguriert.
|
||||
Sie sind ausschliesslich im Heimnetz erreichbar.
|
||||
|
||||
---
|
||||
|
||||
## Hostname noch ausstehend
|
||||
|
||||
`<DOMAIN>` in `deploy/truenas/vhost-snippet.conf` ist ein Platzhalter.
|
||||
Julian nennt den Hostnamen/Subdomain → ersetzen und nginx neu laden.
|
||||
@@ -1,5 +1,5 @@
|
||||
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
|
||||
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
|
||||
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
|
||||
@@ -19,9 +19,8 @@ test('Toggle „Externe Ahnen einblenden” zeigt externe Tiere mit Extern-Marki
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
||||
|
||||
// UX-MOBILE-1: Checkbox liegt im Filter-Drawer — auf Mobil erst öffnen.
|
||||
await openFilterPanel(page)
|
||||
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
|
||||
// DESIGN (Rennmaus Filter): „Externe Ahnen" ist ein Toggle-Chip (Button), kein Checkbox.
|
||||
await page.getByRole('button', { name: t.filters.showExternalShort }).click()
|
||||
|
||||
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
|
||||
await expect(maxRow).toBeVisible()
|
||||
|
||||
@@ -9,13 +9,18 @@ test('Charakterbogen: Eigenschaft umschalten, speichern und im Abgabe-Komposer w
|
||||
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||
await expect(page.getByText(de.character.sectionTitle)).toBeVisible()
|
||||
|
||||
// DESIGN (Rennmausakte): Charakter sind anklickbare Chips (Buttons) statt Checkboxen.
|
||||
// Eigenschaft 'neugierig' aktivieren und speichern.
|
||||
await page.getByRole('checkbox', { name: 'neugierig' }).check()
|
||||
await page.getByRole('button', { name: 'neugierig', exact: true }).click()
|
||||
await page.getByRole('button', { name: de.character.save }).click()
|
||||
await expect(page.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
|
||||
await expect(page.getByRole('button', { name: 'neugierig', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
)
|
||||
|
||||
// Tier zur Abgabe stellen -> erscheint im /abgabe-Komposer mit gesetzter Eigenschaft.
|
||||
await page.getByRole('button', { name: de.pages.abgabe.markAction }).click()
|
||||
await expect(page.getByRole('button', { name: de.pages.abgabe.markAction })).toBeHidden()
|
||||
await page.goto('/abgabe')
|
||||
// Auf Krümels Tier-Abschnitt eingrenzen — andere ForSale-Tiere haben ebenfalls
|
||||
// Charakterbögen, deshalb darf die Assertion nicht seitenbreit nach 'neugierig' suchen.
|
||||
|
||||
70
gerbil-manager-web/e2e/farbschlag-sync.spec.ts
Normal file
70
gerbil-manager-web/e2e/farbschlag-sync.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/** FARBSCHLAG-GENOTYPE-SYNC: Bidirektionale Sync im Tier-Formular. */
|
||||
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
|
||||
test('Farbschlag-Auswahl füllt Gencode automatisch aus', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
|
||||
const genotypeInput = page
|
||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
||||
.locator('input')
|
||||
|
||||
// Vor der Auswahl ist das Gencode-Feld leer.
|
||||
await expect(genotypeInput).toHaveValue('')
|
||||
|
||||
// 'Agouti' wählen → Gencode wird automatisch befüllt.
|
||||
await page.locator('.color-picker-trigger').click()
|
||||
await page.locator('.color-picker-option', { hasText: 'Agouti' }).click()
|
||||
await expect(genotypeInput).toHaveValue('AA CC DD EE GG PP spsp rere')
|
||||
})
|
||||
|
||||
test('Gencode-Eingabe aktualisiert die Farbschlag-Auswahl', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
|
||||
const genotypeInput = page
|
||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
||||
.locator('input')
|
||||
|
||||
// Gencode für Schwarz eintippen → Farbschlag-Select springt auf 'Schwarz'.
|
||||
await genotypeInput.fill('aa CC DD EE GG PP spsp rere')
|
||||
await expect(page.locator('.color-picker-trigger')).toHaveText(/Schwarz/)
|
||||
})
|
||||
|
||||
test('Gencode ohne passende Farbschlag-Auswahl leert das Dropdown', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
|
||||
const genotypeInput = page
|
||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
||||
.locator('input')
|
||||
|
||||
// Zuerst eine bekannte Farbe wählen, damit das Select belegt ist.
|
||||
await page.locator('.color-picker-trigger').click()
|
||||
await page.locator('.color-picker-option', { hasText: 'Agouti' }).click()
|
||||
await expect(page.locator('.color-picker-trigger')).toHaveText(/Agouti/)
|
||||
|
||||
// Marder-Genotyp: bekannte Farbe im Engine, aber NICHT im Mock-Dropdown
|
||||
// → Select wird auf '' (kein Eintrag) zurückgesetzt.
|
||||
await genotypeInput.fill('aa cchmcchm DD EE GG PP spsp rere')
|
||||
await expect(page.locator('.color-picker-trigger')).toHaveText(/— keine Angabe —/)
|
||||
})
|
||||
|
||||
test('Unbekannter Genotyp leert das Farbschlag-Dropdown (Unbekannt-Fall)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
|
||||
const genotypeInput = page
|
||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
|
||||
.locator('input')
|
||||
|
||||
await page.locator('.color-picker-trigger').click()
|
||||
await page.locator('.color-picker-option', { hasText: 'Blau' }).click()
|
||||
await expect(page.locator('.color-picker-trigger')).toHaveText(/Blau/)
|
||||
|
||||
// aa CC dd EE gg pp → Unbekannter Farbschlag (nicht im Katalog) → Select leert sich.
|
||||
await genotypeInput.fill('aa CC dd EE gg pp spsp rere')
|
||||
await expect(page.locator('.color-picker-trigger')).toHaveText(/— keine Angabe —/)
|
||||
})
|
||||
132
gerbil-manager-web/e2e/feedback.spec.ts
Normal file
132
gerbil-manager-web/e2e/feedback.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/** FEEDBACK: "Fehler melden" Dialog + Stammbaum-Rechtsklick ("ID kopieren" / "Fehler melden"). */
|
||||
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const f = de.feedback
|
||||
const st = de.pages.stammbaum
|
||||
|
||||
test('Tierakte: "Fehler melden"-Button öffnet den Dialog, Senden zeigt Erfolg', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/kruemel')
|
||||
|
||||
// Button in den Aktionen öffnet den Dialog.
|
||||
await page.getByRole('button', { name: f.button }).click()
|
||||
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
// Debug-Kontext ist sichtbar (automatisch erfasst).
|
||||
await expect(dialog).toContainText(f.debugTitle)
|
||||
await expect(dialog).toContainText(f.contexts['gerbil-detail'])
|
||||
|
||||
// Beschreibung eintragen + senden.
|
||||
await dialog.getByLabel(f.label).fill('Der Farbschlag stimmt nicht.')
|
||||
await dialog.getByRole('button', { name: f.submit }).click()
|
||||
|
||||
// Erfolgs-Toast + Dialog schließt.
|
||||
await expect(page.getByText(f.success)).toBeVisible()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
|
||||
// Der Bericht wurde mit Debug-Kontext gespeichert.
|
||||
expect(mockDb).not.toBeNull()
|
||||
const reports = mockDb!.feedback
|
||||
expect(reports.length).toBe(1)
|
||||
expect(reports[0]).toMatchObject({
|
||||
message: 'Der Farbschlag stimmt nicht.',
|
||||
context: 'gerbil-detail',
|
||||
gerbilId: 'kruemel',
|
||||
})
|
||||
expect(reports[0].url).toContain('/rennmaeuse/kruemel')
|
||||
expect(typeof reports[0].clientTimestamp).toBe('string')
|
||||
})
|
||||
|
||||
test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wurf-Kontext', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/wuerfe/w-kruemel')
|
||||
|
||||
await page.getByRole('button', { name: f.button }).click()
|
||||
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog).toContainText(f.contexts['litter-detail'])
|
||||
|
||||
await dialog.getByLabel(f.label).fill('Die Wurfstärke ist falsch.')
|
||||
await dialog.getByRole('button', { name: f.submit }).click()
|
||||
await expect(page.getByText(f.success)).toBeVisible()
|
||||
|
||||
const reports = mockDb!.feedback
|
||||
expect(reports.length).toBe(1)
|
||||
expect(reports[0]).toMatchObject({ context: 'litter-detail', litterId: 'w-kruemel' })
|
||||
})
|
||||
|
||||
test('Kontakt-Detail: "Fehler melden"-Button sendet mit Kontakt-Kontext', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/kontakte/con-meier')
|
||||
|
||||
await page.getByRole('button', { name: f.button }).click()
|
||||
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog).toContainText(f.contexts['contact-detail'])
|
||||
|
||||
await dialog.getByLabel(f.label).fill('Die Adresse stimmt nicht.')
|
||||
await dialog.getByRole('button', { name: f.submit }).click()
|
||||
await expect(page.getByText(f.success)).toBeVisible()
|
||||
|
||||
const reports = mockDb!.feedback
|
||||
expect(reports.length).toBe(1)
|
||||
expect(reports[0]).toMatchObject({ context: 'contact-detail', contactId: 'con-meier' })
|
||||
})
|
||||
|
||||
test('Stammbaum: Rechtsklick auf eine Karte zeigt "ID kopieren" + "Fehler melden"', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||
|
||||
// Rechtsklick auf die Wurzelkarte (Krümel).
|
||||
await page.locator('.pedigree-card--root').click({ button: 'right' })
|
||||
|
||||
const menu = page.locator('.stammbaum-context-menu')
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(menu.getByRole('menuitem', { name: st.contextMenu.copyId })).toBeVisible()
|
||||
await expect(menu.getByRole('menuitem', { name: st.contextMenu.reportError })).toBeVisible()
|
||||
|
||||
// "Fehler melden" öffnet den Dialog mit Stammbaum-Kontext.
|
||||
await menu.getByRole('menuitem', { name: st.contextMenu.reportError }).click()
|
||||
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog).toContainText(f.contexts.stammbaum)
|
||||
|
||||
await dialog.getByLabel(f.label).fill('Stammbaum-Bug.')
|
||||
await dialog.getByRole('button', { name: f.submit }).click()
|
||||
await expect(page.getByText(f.success)).toBeVisible()
|
||||
|
||||
const reports = mockDb!.feedback
|
||||
expect(reports.length).toBe(1)
|
||||
expect(reports[0]).toMatchObject({ context: 'stammbaum', gerbilId: 'kruemel' })
|
||||
})
|
||||
|
||||
test('Stammbaum: langer Druck (Long-Press) öffnet dasselbe Kontextmenü', async ({ page }, testInfo) => {
|
||||
skipUnlessMock()
|
||||
// Der Long-Press-Code-Pfad (onTouchStart → Timer → Menü) ist projekt-unabhängig
|
||||
// identisch; im Touch-emulierten phone-Projekt ist das Auslösen eines
|
||||
// synthetischen TouchEvents unzuverlässig, daher hier auf das desktop-Projekt
|
||||
// beschränkt (validiert dieselbe Logik).
|
||||
testInfo.skip(testInfo.project.name === 'phone', 'synthetischer Long-Press im phone-Projekt instabil')
|
||||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||
const card = page.locator('.pedigree-card--root')
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
// Long-Press simulieren: echtes touchstart (mit Touch-Objekt) auslösen; der
|
||||
// Timer im PedigreeCard öffnet nach ~500 ms das Menü.
|
||||
const dispatched = await card.evaluate((el) => {
|
||||
if (typeof Touch === 'undefined' || typeof TouchEvent === 'undefined') return false
|
||||
const r = el.getBoundingClientRect()
|
||||
const t = new Touch({ identifier: 1, target: el, clientX: r.left + 10, clientY: r.top + 10 })
|
||||
el.dispatchEvent(
|
||||
new TouchEvent('touchstart', { bubbles: true, cancelable: true, touches: [t], targetTouches: [t], changedTouches: [t] }),
|
||||
)
|
||||
return true
|
||||
})
|
||||
test.skip(!dispatched, 'TouchEvent in diesem Projekt nicht verfügbar')
|
||||
await expect(page.locator('.stammbaum-context-menu')).toBeVisible({ timeout: 2000 })
|
||||
await expect(
|
||||
page.locator('.stammbaum-context-menu').getByRole('menuitem', { name: st.contextMenu.copyId }),
|
||||
).toBeVisible()
|
||||
})
|
||||
@@ -1,85 +1,54 @@
|
||||
/**
|
||||
* UX-MOBILE-1: FilterPanel — einklappbare Filter auf Smartphone, inline auf Desktop.
|
||||
* DESIGN (Rennmaus Filter.html): Quick-Chips-Filter der Rennmäuse-Liste.
|
||||
*
|
||||
* Telefon (390px): Filter-Button sichtbar, Drawer eingeklappt; Tippen öffnet/schliesst.
|
||||
* Desktop (1280px): Alle Controls direkt sichtbar, kein Toggle-Button.
|
||||
* Eine Pillen-Reihe (Status / Geschlecht / Farbschlag / Herkunft) öffnet je ein
|
||||
* Bottom-Sheet mit Mehrfachauswahl; gewählte Werte erscheinen als entfernbare
|
||||
* Chips, „Alle löschen" setzt zurück. Mobile-first; identisches Verhalten auf Desktop.
|
||||
*/
|
||||
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||
import { de, expect, gotoSection, selectListFilter, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
|
||||
test.describe('FilterPanel – Rennmäuse-Liste', () => {
|
||||
test('Phone: Filter-Drawer standardmäßig eingeklappt, Toggle-Button sichtbar', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.describe('Quick-Chips-Filter – Rennmäuse-Liste', () => {
|
||||
test('Kategorie-Pillen sind sichtbar', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
if (testInfo.project.name !== 'phone') return
|
||||
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||
|
||||
// Toggle-Button sichtbar.
|
||||
const toggle = page.locator('.filter-panel__toggle')
|
||||
await expect(toggle).toBeVisible()
|
||||
|
||||
// Status-Beschriftung im Drawer ist noch verborgen.
|
||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
|
||||
const quickrow = page.locator('.rmf-quickrow')
|
||||
await expect(quickrow.getByRole('button', { name: t.filters.status })).toBeVisible()
|
||||
await expect(quickrow.getByRole('button', { name: t.filters.gender })).toBeVisible()
|
||||
await expect(quickrow.getByRole('button', { name: t.filters.colorVariety })).toBeVisible()
|
||||
await expect(quickrow.getByRole('button', { name: t.fields.origin })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Phone: Toggle öffnet und schließt den Filter-Drawer', async ({
|
||||
test('Mehrfachauswahl erzeugt entfernbare Chips, „Alle löschen" setzt zurück', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
}) => {
|
||||
skipUnlessMock()
|
||||
if (testInfo.project.name !== 'phone') return
|
||||
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
const toggle = page.locator('.filter-panel__toggle')
|
||||
|
||||
// Öffnen → Status-Feld wird sichtbar.
|
||||
await toggle.click()
|
||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
|
||||
// Default: Zucht-Chip ist aktiv.
|
||||
await expect(page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding })).toBeVisible()
|
||||
|
||||
// Schließen → wieder verborgen.
|
||||
await toggle.click()
|
||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
|
||||
// Geschlecht = männlich über das Sheet wählen → zweiter Chip erscheint.
|
||||
await selectListFilter(page, t.filters.gender, t.genderLabels.male)
|
||||
await expect(page.locator('.rmf-chip-val', { hasText: t.genderLabels.male })).toBeVisible()
|
||||
|
||||
// „Alle löschen" entfernt sämtliche Chips.
|
||||
await page.getByRole('button', { name: de.filterBar.clearAll }).click()
|
||||
await expect(page.locator('.rmf-chip')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Phone: Badge zählt aktive Filter korrekt', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test('Einzelner Chip lässt sich per Klick entfernen', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
if (testInfo.project.name !== 'phone') return
|
||||
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
const toggle = page.locator('.filter-panel__toggle')
|
||||
|
||||
// Initial: Status='Active' ist Default → Badge zeigt kein „(N)".
|
||||
await expect(toggle).toHaveText('Filter')
|
||||
|
||||
// Filter-Drawer öffnen und Geschlecht setzen → 1 aktiver Filter.
|
||||
await toggle.click()
|
||||
await page.locator('.filter-panel__drawer select').nth(1).selectOption('male')
|
||||
await expect(toggle).toHaveText('Filter (1)')
|
||||
|
||||
// Zurücksetzen → Badge weg.
|
||||
await page.getByRole('button', { name: de.filterPanel.resetButton }).click()
|
||||
await expect(toggle).toHaveText('Filter')
|
||||
})
|
||||
|
||||
test('Desktop: Toggle-Button nicht sichtbar, alle Controls inline', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
skipUnlessMock()
|
||||
if (testInfo.project.name !== 'desktop') return
|
||||
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||
|
||||
// Kein Toggle-Button auf Desktop (display:none via Media Query).
|
||||
const toggle = page.locator('.filter-panel__toggle')
|
||||
await expect(toggle).toBeHidden()
|
||||
|
||||
// Status-Beschriftung direkt sichtbar.
|
||||
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
|
||||
const breedingChip = page.locator('.rmf-chip', {
|
||||
has: page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding }),
|
||||
})
|
||||
await expect(breedingChip).toBeVisible()
|
||||
await breedingChip.click()
|
||||
await expect(breedingChip).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -86,6 +86,18 @@ export async function openFilterPanel(page: Page) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DESIGN (Rennmaus Filter): wählt einen Wert im Quick-Chips-Filter der Rennmäuse-Liste.
|
||||
* Öffnet das Bottom-Sheet der Kategorie, hakt die Option an und schließt mit „Fertig".
|
||||
*/
|
||||
export async function selectListFilter(page: Page, categoryLabel: string, optionLabel: string) {
|
||||
// Pille der Kategorie in der Quick-Chips-Reihe öffnen (nicht den aktiven Chip treffen).
|
||||
await page.locator('.rmf-quickrow').getByRole('button', { name: categoryLabel }).click()
|
||||
const sheet = page.getByRole('dialog', { name: categoryLabel })
|
||||
await sheet.getByRole('button', { name: optionLabel, exact: true }).click()
|
||||
await sheet.getByRole('button', { name: de.filterBar.done }).click()
|
||||
}
|
||||
|
||||
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
|
||||
export const uniqueName = (prefix: string) =>
|
||||
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
|
||||
45
gerbil-manager-web/e2e/juvenile-details.spec.ts
Normal file
45
gerbil-manager-web/e2e/juvenile-details.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/** LITTER-JUVENILE-DETAILS + JUVENILE-AVATARS: Jungtier-Felder und Foto-Avatare im Wurf-Detail. */
|
||||
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.pages.litters
|
||||
|
||||
test('Jungtier-Liste zeigt Farbschlag, Abgabedatum, Abnehmer, Todesdatum und Todesursache', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/wuerfe/w-kruemel')
|
||||
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||
|
||||
// Krümel: Active, colorVarietyId cv-agouti → Farbschlag 'Agouti'
|
||||
const kruemelCard = page.getByRole('link', { name: /Krümel/ })
|
||||
await expect(kruemelCard).toBeVisible()
|
||||
await expect(kruemelCard).toContainText(`${t.detail.juvenileFields.colorVariety}: Agouti`)
|
||||
|
||||
// Pippa: GivenAway, Gold, goHomeDate 2025-05-01, Abnehmer Familie Huber
|
||||
const pippaCard = page.getByRole('link', { name: /Pippa/ })
|
||||
await expect(pippaCard).toBeVisible()
|
||||
await expect(pippaCard).toContainText(`${t.detail.juvenileFields.colorVariety}: Gold`)
|
||||
await expect(pippaCard).toContainText(t.detail.juvenileFields.goHomeDate)
|
||||
await expect(pippaCard).toContainText(`${t.detail.juvenileFields.receiver}: Familie Huber`)
|
||||
|
||||
// Benny: Deceased, no colorVarietyId, dateOfDeath + causeOfDeath
|
||||
const bennyCard = page.getByRole('link', { name: /Benny/ })
|
||||
await expect(bennyCard).toBeVisible()
|
||||
await expect(bennyCard).toContainText(t.detail.juvenileFields.dateOfDeath)
|
||||
await expect(bennyCard).toContainText(`${t.detail.juvenileFields.causeOfDeath}: Altersschwäche`)
|
||||
})
|
||||
|
||||
test('JUVENILE-AVATARS: Pippa zeigt Foto-Avatar, Benny zeigt GerbilIcon', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/wuerfe/w-kruemel')
|
||||
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||
|
||||
// Pippa hat profilePhotoUrl → <img.juvenile-photo__avatar> sichtbar
|
||||
const pippaCard = page.getByRole('link', { name: /Pippa/ })
|
||||
await expect(pippaCard.locator('.juvenile-photo__avatar')).toBeVisible()
|
||||
|
||||
// Benny hat kein Foto → kein img, SVG-Icon (GerbilIcon) sichtbar
|
||||
const bennyCard = page.getByRole('link', { name: /Benny/ })
|
||||
await expect(bennyCard.locator('.juvenile-photo__avatar')).not.toBeVisible()
|
||||
await expect(bennyCard.locator('svg')).toBeVisible()
|
||||
})
|
||||
46
gerbil-manager-web/e2e/litter-mortality.spec.ts
Normal file
46
gerbil-manager-web/e2e/litter-mortality.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/** LITTER-MORTALITY: deathsWithin8Weeks im Wurf-Formular und -Detail. */
|
||||
import { de, expect, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||
|
||||
const t = de.pages.litters
|
||||
|
||||
test('Wurf-Detail zeigt Frühverluste wenn gesetzt', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/wuerfe/w-kruemel')
|
||||
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||
// Frühverluste-Zeile im def-list: Label + Wert '1'
|
||||
const mortalityRow = page.locator('.def-row', {
|
||||
has: page.locator('dt', { hasText: t.fields.deathsWithin8Weeks }),
|
||||
})
|
||||
await expect(mortalityRow).toBeVisible()
|
||||
await expect(mortalityRow.locator('dd')).toHaveText('1')
|
||||
})
|
||||
|
||||
test('Wurf-Formular speichert Frühverluste und zeigt sie im Detail', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
const name = uniqueName('WurfM')
|
||||
await page.goto('/wuerfe/neu')
|
||||
await page.getByLabel(`${t.fields.name} *`).fill(name)
|
||||
await page.getByLabel(`${t.fields.date} *`).fill('2026-01-10')
|
||||
|
||||
// Frühverluste eingeben
|
||||
await page.getByLabel(t.fields.deathsWithin8Weeks).fill('2')
|
||||
await expect(page.getByText(t.validation.deathsExceedTotalBorn)).not.toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name })).toBeVisible()
|
||||
// Frühverluste-Zeile im Detail
|
||||
const mortalityRow = page.locator('.def-row', {
|
||||
has: page.locator('dt', { hasText: t.fields.deathsWithin8Weeks }),
|
||||
})
|
||||
await expect(mortalityRow).toBeVisible()
|
||||
await expect(mortalityRow.locator('dd')).toHaveText('2')
|
||||
})
|
||||
|
||||
test('Frühverluste > Wurfstärke zeigt Warnmeldung', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/wuerfe/neu')
|
||||
|
||||
await page.getByLabel(t.fields.totalBorn).fill('3')
|
||||
await page.getByLabel(t.fields.deathsWithin8Weeks).fill('5')
|
||||
await expect(page.getByText(t.validation.deathsExceedTotalBorn)).toBeVisible()
|
||||
})
|
||||
@@ -45,12 +45,20 @@ function matchesFilter(row: Row, filter: string | null): boolean {
|
||||
|
||||
function applyOrderBy(rows: Row[], orderBy: string | null): Row[] {
|
||||
if (!orderBy) return rows
|
||||
const [field, dir] = orderBy.split(/\s+/)
|
||||
const sign = dir?.toLowerCase() === 'desc' ? -1 : 1
|
||||
// Mirror Gridify's comma-separated multi-key ordering (e.g. "name,id"): each key
|
||||
// is "<field> [desc]"; later keys act as tiebreakers.
|
||||
const keys = orderBy.split(',').map((part) => {
|
||||
const [field, dir] = part.trim().split(/\s+/)
|
||||
return { field, sign: dir?.toLowerCase() === 'desc' ? -1 : 1 }
|
||||
})
|
||||
return [...rows].sort((a, b) => {
|
||||
const av = String(a[field] ?? '')
|
||||
const bv = String(b[field] ?? '')
|
||||
return av < bv ? -sign : av > bv ? sign : 0
|
||||
for (const { field, sign } of keys) {
|
||||
const av = String(a[field] ?? '')
|
||||
const bv = String(b[field] ?? '')
|
||||
if (av < bv) return -sign
|
||||
if (av > bv) return sign
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
@@ -131,6 +139,7 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
name: 'Frau Erika Muster',
|
||||
address: 'Musterweg 1, 12345 Musterstadt',
|
||||
phone: '', email: '', homepage: '', city: 'Musterstadt',
|
||||
nameSuffix: '',
|
||||
})
|
||||
}
|
||||
if (method === 'PUT') return json(route, 204)
|
||||
@@ -245,6 +254,13 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
return json(route, 405)
|
||||
}
|
||||
if (path.match(/^\/photos\/[^/]+$/) && method === 'DELETE') return json(route, 204)
|
||||
// Gehege-Bilder (analog zu Tier-Fotos).
|
||||
m = path.match(/^\/enclosures\/([^/]+)\/photos$/)
|
||||
if (m) {
|
||||
if (method === 'GET') return json(route, 200, [])
|
||||
return json(route, 405)
|
||||
}
|
||||
if (path.match(/^\/enclosure-photos\/[^/]+$/) && method === 'DELETE') return json(route, 204)
|
||||
m = path.match(/^\/gerbils\/([^/]+)\/inbreeding-coefficient$/)
|
||||
if (m) {
|
||||
const isKruemel = m[1] === 'kruemel'
|
||||
@@ -381,6 +397,25 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
return json(route, 200, result)
|
||||
}
|
||||
|
||||
// FEEDBACK: "Fehler melden" — POST persistiert, GET listet (neueste zuerst).
|
||||
if (path === '/feedback') {
|
||||
if (method === 'POST') {
|
||||
const body = request.postDataJSON() as Row
|
||||
const created = {
|
||||
id: newId('feedback'),
|
||||
...body,
|
||||
userAgent: request.headers()['user-agent'] ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
db.feedback.push(created)
|
||||
return json(route, 201, created)
|
||||
}
|
||||
if (method === 'GET') {
|
||||
return json(route, 200, [...db.feedback].reverse())
|
||||
}
|
||||
return json(route, 405)
|
||||
}
|
||||
|
||||
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||
const col = m ? collections[m[1]] : undefined
|
||||
|
||||
@@ -76,6 +76,8 @@ export interface MockDb {
|
||||
saleAdConfigured: boolean
|
||||
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
|
||||
namesConfigured: boolean
|
||||
// FEEDBACK: "Fehler melden" — gesammelte Berichte (POST /feedback)
|
||||
feedback: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
function gerbil(
|
||||
@@ -91,7 +93,7 @@ function gerbil(
|
||||
id,
|
||||
name,
|
||||
gender,
|
||||
status: 'Active',
|
||||
status: 'Breeding',
|
||||
dateOfBirth,
|
||||
dateOfDeath: null,
|
||||
causeOfDeath: null,
|
||||
@@ -108,6 +110,7 @@ function gerbil(
|
||||
notes: null,
|
||||
// BESTAND-FILTER: default = eigener Bestand; einzelne externe Ahnen unten gesetzt.
|
||||
isResident: true,
|
||||
profilePhotoUrl: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,8 +122,29 @@ export function seedDb(): MockDb {
|
||||
enclosureId: 'enc-gross',
|
||||
originContactId: 'con-meier',
|
||||
originBreeder: 'Clan-Kleine-Chaoten',
|
||||
// NACHVERFOLGUNG: Datenherkunft (vom Import erzeugter JSON-String).
|
||||
provenance: JSON.stringify({
|
||||
sourceFiles: ['Stammbaum von Krümel.xlsx', 'Wurfchronik-Detail.docx'],
|
||||
mergedRecordCount: 2,
|
||||
fromWurfchronik: true,
|
||||
parentMethod: 'chart-position',
|
||||
parentConfidence: 'medium',
|
||||
notes: ['aus 2 Datensätzen zusammengeführt'],
|
||||
history: [
|
||||
'In „Stammbaum von Krümel.xlsx“ gefunden.',
|
||||
'Geburtsdatum (12.03.2025) aus „Stammbaum von Krümel.xlsx“.',
|
||||
'Genotyp aus „Stammbaum von Krümel.xlsx“.',
|
||||
'Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt.',
|
||||
// VERWORFEN: abweichender Wert aus einer Quelle wurde verworfen (Grund + Ersatz).
|
||||
'⚠ Geburtsdatum 14.06.2015 aus „Wurfchronik-Detail.docx“ verworfen — abweichend; 12.03.2025 aus „Stammbaum von Krümel.xlsx“ verwendet (Mehrheit).',
|
||||
'Eltern über Position im Stammbaum erkannt (Quelle: „Stammbaum von Krümel.xlsx“).',
|
||||
// VERWORFEN: unplausibler Elternteil verworfen, kein Ersatz verwendet.
|
||||
'⚠ Vater „Jayjay“ (*19.06.2013) verworfen — unplausibel (9 Jahre älter als das Kind); kein Ersatz.',
|
||||
'Angaben aus der Wurfchronik übernommen.',
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier' },
|
||||
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier', profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' },
|
||||
gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'),
|
||||
gerbil('balu', 'Balu', 'male', '2021-04-20', 'w-balu', 'cv-agouti'),
|
||||
gerbil('maja', 'Maja', 'female', '2021-06-11', null, 'cv-schwarz-schecke', 'aa CC DD EE GG PP Spsp rere'),
|
||||
@@ -135,8 +159,10 @@ export function seedDb(): MockDb {
|
||||
{ ...gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'), isResident: false, originBreeder: 'Zoohandlung Meier' },
|
||||
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
|
||||
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
|
||||
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
|
||||
{ ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' },
|
||||
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
|
||||
{ ...gerbil('pauli-breeder', 'Pauli Breeder', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-meier' },
|
||||
{ ...gerbil('pauli-neither', 'Pauli Neither', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-neither' },
|
||||
// ABGABE: zwei Tiere zur Abgabe im Quarantänebecken (enc-leer ist leer, kein Konflikt
|
||||
// mit dem Becken-Belegungstest der Großbecken-2-Tiere-Assertion).
|
||||
{
|
||||
@@ -151,12 +177,51 @@ export function seedDb(): MockDb {
|
||||
enclosureId: 'enc-leer',
|
||||
notes: 'neugieriger Entdecker',
|
||||
},
|
||||
// LITTER-JUVENILE-DETAILS: Jungtiere für Wurf K mit Zusatzinfos.
|
||||
// JUVENILE-AVATARS: Pippa hat profilePhotoUrl (1×1-PNG data-URI) → Avatar-Img sichtbar.
|
||||
{
|
||||
...gerbil('pup-abgabe', 'Pippa', 'female', '2025-03-12', 'w-kruemel', 'cv-gold'),
|
||||
status: 'GivenAway' as const,
|
||||
goHomeDate: '2025-05-01',
|
||||
receiverContactId: 'con-huber',
|
||||
profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
},
|
||||
{
|
||||
...gerbil('pup-verstorben', 'Benny', 'male', '2025-03-12', 'w-kruemel', null),
|
||||
status: 'Deceased' as const,
|
||||
dateOfDeath: '2025-04-15',
|
||||
causeOfDeath: 'Altersschwäche',
|
||||
},
|
||||
// UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail.
|
||||
gerbil('nameless-stub', '', 'male', '2023-01-01', null, null),
|
||||
// SIBLING-PAIRING: Vollgeschwister-Verpaarung (in der Zucht häufig). Beide
|
||||
// Eltern von 'inzucht-kind' stammen aus DEMSELBEN Wurf 'w-zwillinge' → das
|
||||
// Diagramm führt ihren Vorfahren-Ast zu einem Verweis-Knoten zusammen.
|
||||
gerbil('zwilling-bock', 'Zwilling Bock', 'male', '2020-05-01', 'w-zwillinge', 'cv-agouti'),
|
||||
gerbil('zwilling-maus', 'Zwilling Maus', 'female', '2020-05-01', 'w-zwillinge', 'cv-schwarz'),
|
||||
{ ...gerbil('opa-w', 'Opa W', 'male', '2018-01-01', null, 'cv-agouti'), isResident: false },
|
||||
{ ...gerbil('oma-u', 'Oma U', 'female', '2018-02-01', null, 'cv-gold'), isResident: false },
|
||||
gerbil('inzucht-kind', 'Inzucht Kind', 'female', '2021-06-01', 'w-inzucht', 'cv-agouti'),
|
||||
]
|
||||
|
||||
const litters: Litter[] = [
|
||||
{ id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna' },
|
||||
{ id: 'w-zwillinge', name: 'Wurf Z', date: '2020-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'opa-w', motherId: 'oma-u' },
|
||||
{ id: 'w-inzucht', name: 'Wurf I', date: '2021-06-01', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'zwilling-bock', motherId: 'zwilling-maus' },
|
||||
{
|
||||
id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1,
|
||||
// NACHVERFOLGUNG: Datenherkunft des Wurfs (vom Import erzeugter JSON-String).
|
||||
provenance: JSON.stringify({
|
||||
sourceFiles: ['Wurfchronik Teil 1_page_0009.md', 'Wurfchronik Teil 1_page_0028.md'],
|
||||
mergedRecordCount: 2,
|
||||
fromWurfchronik: true,
|
||||
notes: ['aus Wurfchronik', 'aus 2 Datensätzen zusammengeführt', 'Geschwister-Würfe zusammengeführt'],
|
||||
history: [
|
||||
'Wurf aus Wurfchronik „Wurfchronik Teil 1_page_0009.md“.',
|
||||
'Auch in „Wurfchronik Teil 1_page_0028.md“ gefunden → Datensätze zusammengeführt.',
|
||||
'Geschwister-Würfe zusammengeführt.',
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
|
||||
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' },
|
||||
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' },
|
||||
@@ -173,9 +238,24 @@ export function seedDb(): MockDb {
|
||||
|
||||
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt.
|
||||
const contacts: Contact[] = [
|
||||
{ id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null },
|
||||
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null },
|
||||
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft' },
|
||||
{
|
||||
id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null, isBreeder: true, isReceiver: true,
|
||||
// NACHVERFOLGUNG: Datenherkunft des Kontakts (vom Import erzeugter JSON-String).
|
||||
provenance: JSON.stringify({
|
||||
sourceFiles: ['Wurfchronik Teil 1_page_0001.md', 'Stammbaum von Krümel.xlsx'],
|
||||
mergedRecordCount: 2,
|
||||
fromWurfchronik: true,
|
||||
notes: ['aus 2 Datensätzen zusammengeführt', 'als Züchter erkannt', 'als Abnehmer erkannt'],
|
||||
history: [
|
||||
'In „Stammbaum von Krümel.xlsx“ als Züchter erkannt.',
|
||||
'Auch in „Wurfchronik Teil 1_page_0001.md“ gefunden → Datensätze zusammengeführt.',
|
||||
'Sowohl als Züchter als auch als Abnehmer geführt.',
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null, isBreeder: false, isReceiver: true },
|
||||
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft', isBreeder: true, isReceiver: false },
|
||||
{ id: 'con-neither', name: 'Weder Noch', email: null, phone: null, address: null, notes: 'weder züchter noch abnehmer', isBreeder: false, isReceiver: false },
|
||||
]
|
||||
|
||||
const colorVarieties = [
|
||||
@@ -295,5 +375,6 @@ export function seedDb(): MockDb {
|
||||
contracts: [],
|
||||
saleAdConfigured: true,
|
||||
namesConfigured: true,
|
||||
feedback: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { de, expect, gotoSection, test } from './fixtures'
|
||||
|
||||
test('alle Bereiche sind über die Navigation erreichbar', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('heading', { name: de.app.title })).toBeVisible()
|
||||
// DESIGN: the top "Rennmaus-Manager" title bar was removed — the page heading remains.
|
||||
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
|
||||
112
gerbil-manager-web/e2e/provenance.spec.ts
Normal file
112
gerbil-manager-web/e2e/provenance.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/** NACHVERFOLGUNG: "Datenherkunft"-Button in der Tierakte öffnet den
|
||||
* Nachverfolgungs-Dialog mit Quelldateien, Datensatzanzahl und Hinweisen. */
|
||||
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const p = de.provenance
|
||||
|
||||
test('Tierakte: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/kruemel')
|
||||
|
||||
// Button in den Aktionen öffnet den Dialog.
|
||||
await page.getByRole('button', { name: p.button }).click()
|
||||
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
// VERLAUF: chronologischer, dateibezogener Verlauf als Primärinhalt.
|
||||
await expect(dialog).toContainText(p.historyTitle)
|
||||
await expect(dialog).toContainText('In „Stammbaum von Krümel.xlsx“ gefunden.')
|
||||
await expect(dialog).toContainText('Geburtsdatum (12.03.2025) aus „Stammbaum von Krümel.xlsx“.')
|
||||
await expect(dialog).toContainText(
|
||||
'Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt.',
|
||||
)
|
||||
|
||||
// VERWORFEN: eine Quelle wurde verworfen — mit Grund und verwendetem Ersatz.
|
||||
await expect(dialog).toContainText(p.discardLegend)
|
||||
await expect(dialog).toContainText(
|
||||
'⚠ Geburtsdatum 14.06.2015 aus „Wurfchronik-Detail.docx“ verworfen — abweichend; 12.03.2025 aus „Stammbaum von Krümel.xlsx“ verwendet (Mehrheit).',
|
||||
)
|
||||
await expect(dialog).toContainText(
|
||||
'⚠ Vater „Jayjay“ (*19.06.2013) verworfen — unplausibel (9 Jahre älter als das Kind); kein Ersatz.',
|
||||
)
|
||||
// Die Verwerfen-Zeile ist visuell als solche markiert (eigene CSS-Klasse).
|
||||
await expect(dialog.locator('.provenance__history-step--discard').first()).toBeVisible()
|
||||
|
||||
// Quelldateien werden angezeigt.
|
||||
await expect(dialog).toContainText(p.sourceFilesTitle)
|
||||
await expect(dialog).toContainText('Stammbaum von Krümel.xlsx')
|
||||
await expect(dialog).toContainText('Wurfchronik-Detail.docx')
|
||||
|
||||
// Zusammenführungs-Info + Wurfchronik-Hinweis.
|
||||
await expect(dialog).toContainText(p.mergedCount(2))
|
||||
await expect(dialog).toContainText(p.fromWurfchronik)
|
||||
|
||||
// Eltern-Herkunft (übersetzte Methode/Konfidenz).
|
||||
await expect(dialog).toContainText(p.methods['chart-position'])
|
||||
await expect(dialog).toContainText(p.confidences.medium)
|
||||
|
||||
// Hinweis aus dem Import.
|
||||
await expect(dialog).toContainText('aus 2 Datensätzen zusammengeführt')
|
||||
|
||||
// Schließen (Footer-Button — Header-Button trägt dasselbe aria-label).
|
||||
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Tierakte: Tier ohne Importdaten zeigt "Keine Herkunftsdaten"', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Luna hat keine provenance im Mock → Leer-Zustand.
|
||||
await page.goto('/rennmaeuse/luna')
|
||||
|
||||
await page.getByRole('button', { name: p.button }).click()
|
||||
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog).toContainText(p.empty)
|
||||
})
|
||||
|
||||
test('Kontakt: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/kontakte/con-meier')
|
||||
|
||||
await page.getByRole('button', { name: p.button }).click()
|
||||
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
// VERLAUF: dateibezogene Schritte des Kontakts.
|
||||
await expect(dialog).toContainText(p.historyTitle)
|
||||
await expect(dialog).toContainText('In „Stammbaum von Krümel.xlsx“ als Züchter erkannt.')
|
||||
|
||||
// Quelldateien + Zusammenführung + Kontakt-Rolle-Hinweise.
|
||||
await expect(dialog).toContainText(p.sourceFilesTitle)
|
||||
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0001.md')
|
||||
await expect(dialog).toContainText(p.mergedCount(2))
|
||||
await expect(dialog).toContainText(p.fromWurfchronik)
|
||||
await expect(dialog).toContainText('als Züchter erkannt')
|
||||
await expect(dialog).toContainText('als Abnehmer erkannt')
|
||||
|
||||
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Wurf: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/wuerfe/w-kruemel')
|
||||
|
||||
await page.getByRole('button', { name: p.button }).click()
|
||||
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
// VERLAUF: dateibezogene Schritte des Wurfs.
|
||||
await expect(dialog).toContainText(p.historyTitle)
|
||||
await expect(dialog).toContainText('Wurf aus Wurfchronik „Wurfchronik Teil 1_page_0009.md“.')
|
||||
|
||||
await expect(dialog).toContainText(p.sourceFilesTitle)
|
||||
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0009.md')
|
||||
await expect(dialog).toContainText(p.mergedCount(2))
|
||||
await expect(dialog).toContainText(p.fromWurfchronik)
|
||||
await expect(dialog).toContainText('aus Wurfchronik')
|
||||
await expect(dialog).toContainText('Geschwister-Würfe zusammengeführt')
|
||||
|
||||
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
})
|
||||
@@ -110,3 +110,91 @@ test('+-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)
|
||||
await expandBtn.click({ force: true })
|
||||
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
|
||||
})
|
||||
|
||||
test('Geschwister-Verpaarung führt den Vorfahren-Ast zu einem Verweis-Knoten zusammen (SIBLING-PAIRING)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// 'Inzucht Kind': Eltern Zwilling Bock (♂) + Zwilling Maus (♀) stammen aus
|
||||
// demselben Wurf 'w-zwillinge' (Opa W × Oma U) → Vollgeschwister.
|
||||
await page.goto('/rennmaeuse/inzucht-kind/stammbaum')
|
||||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||
|
||||
const fit = page.getByRole('button', { name: t.zoomFit })
|
||||
if (await fit.isVisible()) await fit.click()
|
||||
await page.waitForTimeout(600)
|
||||
|
||||
// Vaterlinie zeigt die echten Großeltern (einmal). Der Vater-Knoten trägt den
|
||||
// Namen als Link (die Verweis-Karte nur als Span — daher Link-Rolle eindeutig).
|
||||
await expect(page.getByRole('link', { name: 'Zwilling Bock' })).toBeVisible()
|
||||
await expect(page.locator('.pedigree-card').filter({ hasText: 'Opa W' })).toBeVisible()
|
||||
await expect(page.locator('.pedigree-card').filter({ hasText: 'Oma U' })).toBeVisible()
|
||||
|
||||
// Mutter-Ast ist zu EINEM Verweis-Knoten zusammengeführt (Verweis auf den Vater).
|
||||
const ref = page.locator('.pedigree-card--sibling')
|
||||
await expect(ref).toBeVisible()
|
||||
await expect(ref).toContainText(t.siblingPairing.refLabel('Zwilling Bock'))
|
||||
await expect(ref).toContainText(t.siblingPairing.refHint)
|
||||
|
||||
// Mini-Legende ergänzt den Geschwister-Hinweis.
|
||||
await expect(page.locator('.stammbaum-hints')).toContainText(t.hintSiblings)
|
||||
})
|
||||
|
||||
test('Würfe-Panel zeigt Würfe des Wurzeltiers + Link öffnet Wurf (STAMMBAUM-LITTERS)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Fridolin ist Vater von Wurf K (5 Junge) — Panel muss erscheinen.
|
||||
await page.goto('/rennmaeuse/fridolin/stammbaum')
|
||||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||
|
||||
const panel = page.locator('.stammbaum-litters-panel')
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel).toContainText(t.littersTitle)
|
||||
await expect(panel).toContainText('Wurf K')
|
||||
await expect(panel).toContainText('5')
|
||||
|
||||
// Link-Klick → Wurf-Detailseite
|
||||
const wurfLink = panel.getByRole('link', { name: /Wurf K/ })
|
||||
await expect(wurfLink).toBeVisible()
|
||||
await wurfLink.click()
|
||||
await expect(page).toHaveURL(/\/wuerfe\/w-kruemel/)
|
||||
})
|
||||
|
||||
test('Kein Würfe-Panel wenn Wurzeltier keine Würfe hat (STAMMBAUM-LITTERS)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Krümel hat noch keine Würfe als Elternteil → Panel muss fehlen.
|
||||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||
await expect(page.locator('.stammbaum-litters-panel')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Knoten-Karte zeigt volles Geburtsdatum (STAMMBAUM-FULLDATE)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Krümel: dateOfBirth '2025-03-12' → formatDate → '12.03.2025'
|
||||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||
await expect(page.locator('.pedigree-card__year').first()).toContainText('12.03.2025')
|
||||
})
|
||||
|
||||
test('Gencode-Chip zeigt Genotyp statt Farbname (STAMMBAUM-GENCODE)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Fridolin ist Vater von Krümel — genotype 'aa CC DD EE GG PP spsp rere'
|
||||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||
const fridolinCard = page.locator('.pedigree-card').filter({ hasText: 'Fridolin' })
|
||||
await expect(fridolinCard.locator('.pedigree-chip')).toContainText('aa CC')
|
||||
})
|
||||
|
||||
test('Tier mit Foto zeigt Avatar-Bild, Tier ohne Foto zeigt Rennmaus-Icon (STAMMBAUM-AVATAR)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Fridolin hat profilePhotoUrl (data URI) → <img.pedigree-card__avatar> sichtbar
|
||||
// Krümel hat profilePhotoUrl=null → kein img, SVG-Icon sichtbar
|
||||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||
|
||||
// Fridolin ist Vater von Krümel → seine Karte enthält ein <img>
|
||||
const fridolinCard = page.locator('.pedigree-card').filter({ hasText: 'Fridolin' })
|
||||
await expect(fridolinCard.locator('img.pedigree-card__avatar')).toBeVisible()
|
||||
|
||||
// Krümel (root) hat kein Foto → SVG-Icon im Foto-Slot
|
||||
const rootCard = page.locator('.pedigree-card.pedigree-card--root')
|
||||
await expect(rootCard.locator('img.pedigree-card__avatar')).not.toBeAttached()
|
||||
await expect(rootCard.locator('svg[role="img"]')).toBeVisible()
|
||||
})
|
||||
|
||||
85
gerbil-manager-web/e2e/status-model.spec.ts
Normal file
85
gerbil-manager-web/e2e/status-model.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/** STATUS-MODEL: Zucht/Liebhaber wählbar; Verstorben/Abgegeben abgeleitet (read-only). */
|
||||
import { de, expect, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
|
||||
test('Tier-Formular zeigt nur wählbare Statuswerte im Dropdown', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
const statusField = page.locator('label.field', {
|
||||
has: page.locator(`span:text-is("${t.fields.status}")`),
|
||||
})
|
||||
// Zucht (Breeding) ist vorausgewählt
|
||||
await expect(statusField.locator('select')).toHaveValue('Breeding')
|
||||
// Optionen: nur Zucht, Liebhaber, Abzugeben (NICHT Verstorben/Abgegeben)
|
||||
const opts = statusField.locator('select option')
|
||||
await expect(opts.filter({ hasText: t.statusLabels.Breeding })).toHaveCount(1)
|
||||
await expect(opts.filter({ hasText: t.statusLabels.Pet })).toHaveCount(1)
|
||||
await expect(opts.filter({ hasText: t.statusLabels.ForSale })).toHaveCount(1)
|
||||
await expect(opts.filter({ hasText: t.statusLabels.Deceased })).toHaveCount(0)
|
||||
await expect(opts.filter({ hasText: t.statusLabels.GivenAway })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Tier-Formular zeigt abgeleiteten Status Verstorben als Text + Hinweis', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Willi ist Deceased
|
||||
await page.goto('/rennmaeuse/willi/bearbeiten')
|
||||
// Status-Select ist NICHT vorhanden (abgeleitet)
|
||||
const statusField = page.locator('.field', {
|
||||
has: page.locator(`span:text-is("${t.fields.status}")`),
|
||||
})
|
||||
await expect(statusField).toBeVisible()
|
||||
await expect(statusField.locator('select')).toHaveCount(0)
|
||||
await expect(statusField).toContainText(t.statusLabels.Deceased)
|
||||
await expect(statusField).toContainText(t.form.statusDerivedHint.Deceased)
|
||||
})
|
||||
|
||||
test('Tiere-Liste zeigt Zucht als Standard-Status-Filter', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse')
|
||||
// DESIGN (Rennmaus Filter): der Default-Status erscheint als aktiver, entfernbarer Chip.
|
||||
await expect(page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Neues Tier anlegen mit Status Liebhaber', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
const name = uniqueName('Tier')
|
||||
await page.goto('/rennmaeuse/neu')
|
||||
await page.getByLabel(`${t.fields.name} *`).fill(name)
|
||||
await page.getByRole('button', { name: t.genderLabels.male, exact: false }).click()
|
||||
const statusField = page.locator('label.field', {
|
||||
has: page.locator(`span:text-is("${t.fields.status}")`),
|
||||
})
|
||||
await statusField.locator('select').selectOption({ label: t.statusLabels.Pet })
|
||||
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Gehege-Feld Sichtbarkeit bei verstorbenen/abgegebenen Tieren', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
|
||||
// 1. Krümel ist Breeding, hat Gehege 'Großbecken'
|
||||
await page.goto('/rennmaeuse/kruemel')
|
||||
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeVisible()
|
||||
await expect(page.locator('dd', { hasText: 'Großbecken' })).toBeVisible()
|
||||
|
||||
// 2. Verstorbene UND abgegebene Tiere sind nicht mehr im eigenen Bestand
|
||||
// -> Gehege ausgeblendet (unabhängig von der Abnehmer-Rolle).
|
||||
for (const slug of ['willi', 'pauli', 'pauli-breeder', 'pauli-neither']) {
|
||||
await page.goto(`/rennmaeuse/${slug}`)
|
||||
await expect(page.locator('dt', { hasText: t.fields.enclosure })).toBeHidden()
|
||||
}
|
||||
})
|
||||
|
||||
test('Verstorbene Tiere: Abnehmer und Inserat-Button ausgeblendet', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
|
||||
// Willi ist verstorben -> "Abnehmer"-Zeile ausgeblendet und "Zur Abgabe stellen"-Button ausgeblendet
|
||||
await page.goto('/rennmaeuse/willi')
|
||||
await expect(page.locator('dt', { hasText: t.fields.receiver })).toBeHidden()
|
||||
await expect(page.getByRole('button', { name: de.pages.abgabe.markAction })).toBeHidden()
|
||||
|
||||
// Krümel lebt -> "Zur Abgabe stellen"-Button vorhanden
|
||||
await page.goto('/rennmaeuse/kruemel')
|
||||
await expect(page.getByRole('button', { name: de.pages.abgabe.markAction })).toBeVisible()
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
|
||||
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||
import { de, expect, gotoSection, selectListFilter, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
const tabs = de.pages.tierTabs
|
||||
@@ -23,13 +23,9 @@ test('Herkunft-Filter (originBreeder) zeigt nur Tiere der gewählten Zucht (SEAR
|
||||
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible()
|
||||
|
||||
// UX-MOBILE-1: Herkunft-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
|
||||
await openFilterPanel(page)
|
||||
// Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel).
|
||||
await page
|
||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })
|
||||
.locator('select')
|
||||
.selectOption({ label: 'Clan-Kleine-Chaoten' })
|
||||
// DESIGN (Rennmaus Filter): Herkunft (originBreeder) über das Quick-Chips-Sheet
|
||||
// auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel) filtern.
|
||||
await selectListFilter(page, t.fields.origin, 'Clan-Kleine-Chaoten')
|
||||
|
||||
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden()
|
||||
@@ -56,7 +52,7 @@ test('Neue Rennmaus anlegen — Validierung + Erfolg', async ({ page }) => {
|
||||
|
||||
const name = uniqueName('Testmaus')
|
||||
await page.getByLabel(`${t.fields.name} *`).fill(name)
|
||||
await page.getByLabel(`${t.fields.gender} *`).selectOption({ label: t.genderLabels.female })
|
||||
await page.getByRole('button', { name: t.genderLabels.female, exact: false }).click()
|
||||
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||
|
||||
// Erfolg: Detailseite des neuen Tieres
|
||||
@@ -108,6 +104,20 @@ test('Detailseite: Wurf-Feld ist ein Link zur Wurf-Detailseite (WURF-LINK)', asy
|
||||
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Detailseite zeigt Geschwisterverpaarungs-Hinweis nur wenn die Eltern Vollgeschwister sind (SIBLING-PAIRING)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// 'Inzucht Kind': Eltern Zwilling Bock + Zwilling Maus aus demselben Wurf
|
||||
// 'w-zwillinge' → Vollgeschwister → Hinweis sichtbar.
|
||||
await page.goto('/rennmaeuse/inzucht-kind')
|
||||
await expect(page.getByRole('heading', { name: 'Inzucht Kind' })).toBeVisible()
|
||||
await expect(page.locator('.ak-fact--sibling')).toContainText(t.detail.siblingPairing)
|
||||
|
||||
// 'Krümel': Eltern Fridolin × Luna aus verschiedenen Würfen → kein Hinweis.
|
||||
await page.goto('/rennmaeuse/kruemel')
|
||||
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||
await expect(page.locator('.ak-fact--sibling')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Detailseite: Herkunft zeigt originBreeder als Text wenn kein Kontakt (WURF-LINK-Addendum)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Fridolin hat originContactId=null + originBreeder='Zoohandlung Meier' → plain text, kein Link
|
||||
@@ -178,12 +188,13 @@ test('Detailseite: Würfe als Elternteil zeigt verlinkten Wurf (ANIMAL-LITTERS)'
|
||||
// Fridolin ist fatherId von 'w-kruemel' (Wurf K)
|
||||
await page.goto('/rennmaeuse/fridolin')
|
||||
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
|
||||
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: t.detail.parentLittersTitle })).toBeVisible()
|
||||
// Wurf K als Link sichtbar
|
||||
const litterLink = page.getByRole('link', { name: /Wurf K/ })
|
||||
await expect(litterLink).toBeVisible()
|
||||
// Vater-Badge sichtbar
|
||||
await expect(page.getByText(t.detail.parentLittersRoleVater)).toBeVisible()
|
||||
// Vater-Rollen-Badge im Wurf-Eintrag sichtbar (die Akte zeigt „Vater" jetzt auch
|
||||
// als Stammdaten-Zeile, daher gezielt auf das Badge eingrenzen).
|
||||
await expect(page.locator('.badge', { hasText: t.detail.parentLittersRoleVater })).toBeVisible()
|
||||
// Navigation zum Wurf funktioniert
|
||||
await litterLink.first().click()
|
||||
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||
@@ -193,6 +204,6 @@ test('Detailseite: Keine Würfe als Elternteil zeigt Leer-Zustand (ANIMAL-LITTER
|
||||
skipUnlessMock()
|
||||
// Krümel hat keine Würfe als Elternteil (nur als Kind von w-kruemel)
|
||||
await page.goto('/rennmaeuse/kruemel')
|
||||
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: t.detail.parentLittersTitle })).toBeVisible()
|
||||
await expect(page.getByText(t.detail.parentLittersEmpty)).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ test('Jungtier erfassen — Formular ist mit Wurf + Geburtsdatum vorbelegt', asy
|
||||
|
||||
const name = uniqueName('Jungtier')
|
||||
await page.getByLabel(`${tg.fields.name} *`).fill(name)
|
||||
await page.getByLabel(`${tg.fields.gender} *`).selectOption({ label: tg.genderLabels.male })
|
||||
await page.getByRole('button', { name: tg.genderLabels.male, exact: false }).click()
|
||||
await page.getByRole('button', { name: tg.form.save, exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name })).toBeVisible()
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Verwaltung für Rennmäuse, Würfe und Züchter" />
|
||||
<!-- DESIGN (Rennmaus Filter): Hanken Grotesk als App-Schrift. -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Rennmaus-Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user