Files
GerbilManager/GerbilManagerWebAPI/ApplicationContext.cs
Gulum 97acaf6b10 WEB-6a: Oeffentliche Webseite — Gestaltung & Grundseiten
- SiteRenderer: neues warmes, mobile-first CSS (Georgia-Headings, Scrollnav,
  site-footer-nav mit Impressum/Datenschutz-Links)
- AbgabetiereList: Gruppen-Karten statt Einzelkarten (Becken-Gruppierung,
  Geschlecht-Label, Verfuegbar-Badge, Foto-Strip, pro-Tier-Detail-Zeilen
  mit Farbschlag + geboren-am + Persoenlichkeit/CharacterNote)
- SiteSnapshotService: ForSale-Snapshot um gender, dateOfBirth, characterNote
  erweitert
- Migration AddImpressumDatenschutzPages: Impressum + Datenschutz als
  Platzhalter-Seiten (footer-only, kein navOrder-Eintrag)
- SeedCms: Impressum/Datenschutz auch in HasData (SQLite-Tests)
- Tests: CmsTests angepasst (8 Seiten), 6 neue SiteRendererTests
  (Gruppen-Karte, Geschlecht, Badge, DateOfBirth, Solo-Tier,
   Impressum/Datenschutz, Footer-Links)
Gate: dotnet 131/131 + vitest 78/78 + e2e 94/94 + build clean
2026-06-06 15:15:54 +02:00

329 lines
16 KiB
C#

using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
public class ApplicationContext : DbContext
{
public ApplicationContext(DbContextOptions options) : base(options)
{
}
public DbSet<Gerbil> Gerbils => Set<Gerbil>();
public DbSet<Litter> Litters => Set<Litter>();
public DbSet<Contact> Contacts => Set<Contact>();
public DbSet<Enclosure> Enclosures => Set<Enclosure>();
public DbSet<ColorVariety> ColorVarieties => Set<ColorVariety>();
public DbSet<GerbilPhoto> GerbilPhotos => Set<GerbilPhoto>();
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
public DbSet<BreederSettings> BreederSettings => Set<BreederSettings>();
public DbSet<Site> Sites => Set<Site>();
public DbSet<Page> Pages => Set<Page>();
public DbSet<Block> Blocks => Set<Block>();
public DbSet<Media> Media => Set<Media>();
public DbSet<Request> Requests => Set<Request>();
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
// 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.
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
SyncNameSearch();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
SyncNameSearch();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
private void SyncNameSearch()
{
foreach (var entry in ChangeTracker.Entries<Gerbil>())
{
if (entry.State is EntityState.Added or EntityState.Modified)
entry.Entity.NameSearch = GerbilSearch.Normalize(entry.Entity.Name);
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Gerbil>(e =>
{
// Enums persisted as their string names (readable, Gridify-friendly).
e.Property(g => g.Gender).HasConversion<string>();
e.Property(g => g.Status).HasConversion<string>();
// Residency defaults to true (own stock unless explicitly marked external).
e.Property(g => g.IsResident).HasDefaultValue(true);
// FEAT-14: character traits stored as a JSON text column (works on both
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
v => string.IsNullOrEmpty(v)
? new List<string>()
: System.Text.Json.JsonSerializer.Deserialize<List<string>>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List<string>());
var traitsComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<List<string>>(
(a, b) => (a ?? new List<string>()).SequenceEqual(b ?? new List<string>()),
v => v == null ? 0 : v.Aggregate(0, (h, s) => HashCode.Combine(h, s.GetHashCode())),
v => v.ToList());
e.Property(g => g.CharacterTraits).HasConversion(traitsConverter, traitsComparer);
e.HasOne(g => g.Litter).WithMany()
.HasForeignKey(g => g.LitterId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(g => g.OriginContact).WithMany()
.HasForeignKey(g => g.OriginContactId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(g => g.ReceiverContact).WithMany()
.HasForeignKey(g => g.ReceiverContactId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(g => g.Enclosure).WithMany(en => en.Gerbils)
.HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(g => g.ColorVariety).WithMany()
.HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<Litter>(e =>
{
e.HasOne(l => l.Father).WithMany()
.HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(l => l.Mother).WithMany()
.HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<HealthRecord>(e =>
{
e.Property(h => h.Type).HasConversion<string>();
e.HasOne<Gerbil>().WithMany()
.HasForeignKey(h => h.GerbilId).OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<WeightRecord>(e =>
e.HasOne<Gerbil>().WithMany()
.HasForeignKey(w => w.GerbilId).OnDelete(DeleteBehavior.Cascade));
modelBuilder.Entity<GerbilPhoto>(e =>
e.HasOne<Gerbil>().WithMany()
.HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade));
// FEAT-13: Abgabeverträge + Zuchtprofil.
modelBuilder.Entity<SaleContract>(e =>
{
e.Property(c => c.Price).HasPrecision(10, 2);
// Restrict: ein Kontakt mit Verträgen ist ein Dokumentenbestand,
// kein versehentlich löschbarer Datensatz.
e.HasOne(c => c.Contact).WithMany()
.HasForeignKey(c => c.ContactId).OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<SaleContractAnimal>(e =>
{
e.HasKey(a => new { a.SaleContractId, a.GerbilId });
e.HasOne<SaleContract>().WithMany(c => c.Animals)
.HasForeignKey(a => a.SaleContractId).OnDelete(DeleteBehavior.Cascade);
// Cascade: wird ein Tier gelöscht, verschwindet nur die Verknüpfung —
// der Vertrag (und seine .docx als Beleg) bleibt bestehen.
e.HasOne(a => a.Gerbil).WithMany()
.HasForeignKey(a => a.GerbilId).OnDelete(DeleteBehavior.Cascade);
});
// Zuchtprofil: genau eine (leere) Zeile mit fixer Id.
modelBuilder.Entity<BreederSettings>()
.HasData(new BreederSettings { Id = GerbilManagerWebAPI.Models.BreederSettings.SingletonId });
// WEB epic: CMS content model.
modelBuilder.Entity<Site>(e =>
{
var navConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<Guid>, string>(
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
v => string.IsNullOrEmpty(v)
? new List<Guid>()
: System.Text.Json.JsonSerializer.Deserialize<List<Guid>>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List<Guid>());
var navComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<List<Guid>>(
(a, b) => (a ?? new List<Guid>()).SequenceEqual(b ?? new List<Guid>()),
v => v == null ? 0 : v.Aggregate(0, (h, x) => HashCode.Combine(h, x.GetHashCode())),
v => v.ToList());
e.Property(s => s.NavOrder).HasConversion(navConverter, navComparer);
});
modelBuilder.Entity<Page>(e =>
{
e.HasIndex(p => p.Slug).IsUnique();
e.Property(p => p.Status).HasConversion<string>();
e.HasMany(p => p.Blocks).WithOne()
.HasForeignKey(b => b.PageId).OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<Block>(e => e.Property(b => b.Type).HasConversion<string>());
SeedCms(modelBuilder);
// INBOX epic: Gmail request inbox.
modelBuilder.Entity<Request>(e =>
{
e.Property(r => r.Status).HasConversion<string>();
e.HasIndex(r => r.GmailMessageId).IsUnique();
e.HasOne(r => r.AssignedContact).WithMany()
.HasForeignKey(r => r.AssignedContactId).OnDelete(DeleteBehavior.Restrict);
});
// exactly one MailSettings row, fixed id.
modelBuilder.Entity<MailSettings>()
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
SeedColorVarieties(modelBuilder);
}
/// <summary>
/// Seed the public website's CMS: one Site + the 6 Jimdo-mirror pages, each with a
/// placeholder Heading; the abgabetiere page also carries an auto AbgabetiereList block
/// (resolved from live ForSale animals at snapshot time). Hand-editable afterwards.
/// </summary>
private static void SeedCms(ModelBuilder modelBuilder)
{
Guid Pid(int n) => new($"51720001-0000-0000-0000-{n:D12}");
Guid Bid(int n) => new($"51720002-0000-0000-0000-{n:D12}");
(int n, string Slug, string Title)[] pages =
{
(1, "start", "Startseite"),
(2, "ueber-die-zucht", "Über die Zucht"),
(3, "abgabetiere", "Abgabetiere"),
(4, "abgabebedingungen", "Abgabebedingungen"),
(5, "farben-genetik", "Farben & Genetik"),
(6, "kontakt", "Kontakt"),
};
// WEB-6a: footer-only legal pages (not in main nav)
(int n, string Slug, string Title, string BodyMd)[] legalPages =
{
(7, "impressum", "Impressum",
"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
(8, "datenschutz", "Datenschutz",
"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
};
var pageRows = new List<Page>();
var blockRows = new List<Block>();
foreach (var p in pages)
{
pageRows.Add(new Page { Id = Pid(p.n), Slug = p.Slug, Title = p.Title, Status = PageStatus.Published });
// placeholder Heading per page
blockRows.Add(new Block
{
Id = Bid(p.n), PageId = Pid(p.n), Order = 0, Type = BlockType.Heading,
Data = $"{{\"text\":\"{p.Title}\",\"level\":1}}",
});
}
// abgabetiere (n=3): auto AbgabetiereList as the second block
blockRows.Add(new Block
{
Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList,
Data = "{\"mode\":\"auto\",\"intro\":\"\"}",
});
foreach (var lp in legalPages)
{
pageRows.Add(new Page { Id = Pid(lp.n), Slug = lp.Slug, Title = lp.Title, Status = PageStatus.Published });
blockRows.Add(new Block { Id = Bid(lp.n), PageId = Pid(lp.n), Order = 0, Type = BlockType.Heading, Data = $"{{\"text\":\"{lp.Title}\",\"level\":1}}" });
blockRows.Add(new Block { Id = Bid(lp.n * 10), PageId = Pid(lp.n), Order = 1, Type = BlockType.RichText, Data = $"{{\"markdown\":\"{lp.BodyMd}\"}}" });
}
modelBuilder.Entity<Page>().HasData(pageRows);
modelBuilder.Entity<Block>().HasData(blockRows);
modelBuilder.Entity<Site>().HasData(new Site
{
Id = Site.SingletonId,
DefaultLocale = "de",
NavOrder = pages.Select(p => Pid(p.n)).ToList(),
});
}
/// <summary>
/// Seed the colour-variety catalog. Source of truth = Kevin's GEN-2 generated list
/// (gerbil-manager-web/src/genetics/colorVarietySeed.generated.json): 73 varieties
/// (the 18 frozen-contract names first, then the baseportal.de extension), in stable
/// SortOrder. Names are UI filter keys — spelling changes route through god.
/// </summary>
private static void SeedColorVarieties(ModelBuilder modelBuilder)
{
(string Name, string Genotype)[] catalog =
{
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere"),
("Hermelin", "aa chch DD EE GG PP spsp rere"),
("Himalaya", "AA chch DD EE GG PP spsp rere"),
("Zobel", "aa cchmcchm DD EE gg PP spsp rere"),
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere"),
("Agouti", "AA CC DD EE GG PP spsp rere"),
("Schwarz", "aa CC DD EE GG PP spsp rere"),
("Silberagouti", "AA CC DD EE gg PP spsp rere"),
("Anthrazit", "aa CC DD EE gg PP spsp rere"),
("Algierfuchs", "AA CC DD ee GG PP spsp rere"),
("Blau", "aa CC dd EE GG PP spsp rere"),
("Gold", "AA CC DD EE GG pp spsp rere"),
("Platin", "aa CC DD EE GG pp spsp rere"),
("Goldfuchs", "AA CC DD ee GG pp spsp rere"),
("Rotfuchs", "aa CC DD ee GG pp spsp rere"),
("dd Gold", "AA CC dd EE GG pp spsp rere"),
("dd Platin", "aa CC dd EE GG pp spsp rere"),
("Altweiss (REW)", "aa CC DD EE gg pp spsp rere"),
("Apricot (Blassfuchs)", "AA CC DD ee gg pp spsp rere"),
("Blaufuchs", "aa CC DD ee gg PP spsp rere"),
("C-Separator", "aa CC DD ee gg pp spsp rere"),
("Elfenbein", "AA CC DD EE gg pp spsp rere"),
("Kohlfuchs", "aa CC DD ee GG PP spsp rere"),
("Polarfuchs", "AA CC DD ee gg PP spsp rere"),
("Saphir", "aa CC DD EE GG pp spsp rere"),
("Orangeschimmel", "AA CC DD efef GG PP spsp rere"),
("Topas", "AA CC DD EE GG pp spsp rere"),
("Platin-Hell", "aa CC DD EE GG pp spsp rere"),
("Agouti dd", "AA CC dd EE GG PP spsp rere"),
("Silberagouti dd", "AA CC dd EE gg PP spsp rere"),
("Kohlfuchs dd", "aa CC dd ee GG PP spsp rere"),
("Anthrazit dd", "aa CC dd EE gg PP spsp rere"),
("Silberschimmel", "AA CC DD efef gg PP spsp rere"),
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"),
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere"),
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere"),
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere"),
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere"),
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere"),
("Gold-Hell", "AA CC DD EE GG pp spsp rere"),
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere"),
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"),
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"),
("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"),
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere"),
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"),
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere"),
("Topas dd", "AA CC dd EE GG pp spsp rere"),
("Blaufuchs dd", "aa CC dd ee gg pp spsp rere"),
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
("Siam", "aa cchmch DD EE GG PP spsp rere"),
("Zobel-Hell", "aa cchmch DD EE gg PP spsp rere"),
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere"),
("CP-Silberagouti", "AA cchmcchm DD EE gg PP spsp rere"),
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere"),
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere"),
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere"),
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"),
("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
};
var rows = new ColorVariety[catalog.Length];
for (int i = 0; i < catalog.Length; i++)
{
rows[i] = new ColorVariety
{
// Stable, deterministic GUIDs so the HasData seed is migration-stable.
Id = new Guid($"00000000-0000-0000-0000-{(i + 1):D12}"),
Name = catalog[i].Name,
CanonicalGenotype = catalog[i].Genotype,
SortOrder = i,
};
}
modelBuilder.Entity<ColorVariety>().HasData(rows);
}
}