using GerbilManagerWebAPI.Models; using Microsoft.EntityFrameworkCore; public class ApplicationContext : DbContext { public ApplicationContext(DbContextOptions options) : base(options) { } public DbSet Gerbils => Set(); public DbSet Litters => Set(); public DbSet Contacts => Set(); public DbSet Enclosures => Set(); public DbSet ColorVarieties => Set(); public DbSet GerbilPhotos => Set(); public DbSet EnclosurePhotos => Set(); public DbSet HealthRecords => Set(); public DbSet WeightRecords => Set(); public DbSet SaleContracts => Set(); public DbSet BreederSettings => Set(); public DbSet Sites => Set(); public DbSet Pages => Set(); public DbSet Blocks => Set(); public DbSet Media => Set(); public DbSet Requests => Set(); public DbSet MailSettings => Set(); public DbSet Feedback => Set(); public DbSet AcquisitionRecords => Set(); public DbSet SaleReservations => Set(); public DbSet WaitingListEntries => Set(); // 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 SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) { SyncNameSearch(); return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); } private void SyncNameSearch() { foreach (var entry in ChangeTracker.Entries()) { if (entry.State is EntityState.Added or EntityState.Modified) entry.Entity.NameSearch = GerbilSearch.Normalize(entry.Entity.Name); } } protected override void OnModelCreating(ModelBuilder modelBuilder) { // DB-4: ICU German collation on user-visible / searched text columns. // "de-x-icu" gives correct German sort order (ä between a and b) and locale-aware // case-folding (lower('Ä')='ä'). Applied only on Npgsql (Postgres); SQLite does not // support custom collation names and would fail EnsureCreated on the test host. const string DeIcu = "de-x-icu"; bool isNpgsql = Database.ProviderName?.Contains("Npgsql", StringComparison.OrdinalIgnoreCase) ?? false; modelBuilder.Entity(e => { // Enums persisted as their string names (readable, Gridify-friendly). e.Property(g => g.Gender).HasConversion(); e.Property(g => g.Status).HasConversion(); // Residency defaults to true (own stock unless explicitly marked external). e.Property(g => g.IsResident).HasDefaultValue(true); // DB-4: German collation on searched/sorted name fields (Npgsql-only). if (isNpgsql) { e.Property(g => g.Name).UseCollation(DeIcu); e.Property(g => g.NameSearch).UseCollation(DeIcu); e.Property(g => g.OriginBreeder).UseCollation(DeIcu); } // 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, string>( v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null), v => string.IsNullOrEmpty(v) ? new List() : System.Text.Json.JsonSerializer.Deserialize>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List()); var traitsComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer>( (a, b) => (a ?? new List()).SequenceEqual(b ?? new List()), 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); // DB-1: ExternalRef is the import idempotency key — enforce uniqueness at the DB level. // Filtered (nulls allowed: manually-entered animals have no ExternalRef). e.HasIndex(g => g.ExternalRef) .IsUnique() .HasFilter("\"ExternalRef\" IS NOT NULL"); }); modelBuilder.Entity(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); // DB-5: ExternalRef = source litter id from extract.py. // Unique (filtered, nulls allowed for manually-entered litters). e.HasIndex(l => l.ExternalRef) .IsUnique() .HasFilter("\"ExternalRef\" IS NOT NULL"); }); modelBuilder.Entity(e => { e.Property(h => h.Type).HasConversion(); e.HasOne().WithMany() .HasForeignKey(h => h.GerbilId).OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity(e => e.HasOne().WithMany() .HasForeignKey(w => w.GerbilId).OnDelete(DeleteBehavior.Cascade)); modelBuilder.Entity(e => e.HasOne().WithMany() .HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade)); modelBuilder.Entity(e => e.HasOne().WithMany() .HasForeignKey(p => p.EnclosureId).OnDelete(DeleteBehavior.Cascade)); // FEAT-13: Abgabeverträge + Zuchtprofil. modelBuilder.Entity(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(e => { e.HasKey(a => new { a.SaleContractId, a.GerbilId }); e.HasOne().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() .HasData(new BreederSettings { Id = GerbilManagerWebAPI.Models.BreederSettings.SingletonId }); // WEB epic: CMS content model. modelBuilder.Entity(e => { var navConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter, string>( v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null), v => string.IsNullOrEmpty(v) ? new List() : System.Text.Json.JsonSerializer.Deserialize>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List()); var navComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer>( (a, b) => (a ?? new List()).SequenceEqual(b ?? new List()), 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(e => { e.HasIndex(p => p.Slug).IsUnique(); e.Property(p => p.Status).HasConversion(); e.HasMany(p => p.Blocks).WithOne() .HasForeignKey(b => b.PageId).OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity(e => e.Property(b => b.Type).HasConversion()); SeedCms(modelBuilder); // INBOX epic: Gmail request inbox. modelBuilder.Entity(e => { e.Property(r => r.Status).HasConversion(); 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() .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(e => { e.HasIndex(f => f.CreatedAt); }); // ERWERB: like Feedback, deliberately relationship-free. GerbilId/SourceContactId // are plain nullable Guid columns (no navigation properties → EF creates NO foreign // key), so the import re-ingest wipe of Gerbils/Contacts never cascades into — or // breaks — acquisition rows. They survive re-ingest, which is the whole point. modelBuilder.Entity(e => { e.Property(a => a.Price).HasPrecision(10, 2); e.HasIndex(a => a.GerbilId); }); // ABGABE-STATUS: deliberately relationship-free (same rationale as Feedback). // GerbilId/ReservedForContactId are plain Guid columns (no navigation properties → // EF creates NO foreign key), so the import re-ingest wipe of Gerbils/Contacts never // cascades into — or breaks — reservation rows. They survive re-ingest by design. modelBuilder.Entity(e => { e.Property(r => r.Price).HasPrecision(10, 2); e.HasIndex(r => r.GerbilId); }); // WAITLIST: same relationship-free pattern as Feedback. ContactId is a plain // nullable Guid column (no navigation property → EF creates NO foreign key), so // the import re-ingest wipe of Contacts never cascades into — or breaks — // waiting-list rows. They survive re-ingest, which is the whole point. modelBuilder.Entity(e => { e.HasIndex(w => w.CreatedAt); // DB-4: German collation on remaining searched/sorted text columns (Npgsql-only). if (isNpgsql) { modelBuilder.Entity() .Property(v => v.Name).UseCollation(DeIcu); modelBuilder.Entity() .Property(c => c.Name).UseCollation(DeIcu); } SeedColorVarieties(modelBuilder); } /// /// 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. /// 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(); var blockRows = new List(); 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().HasData(pageRows); modelBuilder.Entity().HasData(blockRows); modelBuilder.Entity().HasData(new Site { Id = Site.SingletonId, DefaultLocale = "de", NavOrder = pages.Select(p => Pid(p.n)).ToList(), }); } /// /// 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. /// private static void SeedColorVarieties(ModelBuilder modelBuilder) { // CATALOG-RESEED (AR-5 / colorVarietySeed.backend.json): 70 entries, frozen symbols. // ID = array_index + 1 (stable, never changes — live FKs safe). // SortOrder comes from colorVarietySeed.backend.json (decoupled from ID). // New entries appended at end (IDs 67-70: Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs). // GUARDRAIL: DO NOT reseed from colorVarietySeed.generated.json — it uses bracket display // notation (e[f]/c[chm]) which breaks CR-11 GenotypePotentiallyMatches() (frozen vs brackets). (string Name, string Genotype, int SortOrder)[] catalog = { // --- C-locus white / partial albino (IDs 1-3) --- ("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) --- ("Zobel", "aa cchmcchm DD EE gg PP spsp rere", 3), // --- Schimmel family (IDs 5) --- ("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere", 4), // --- Standard colours (IDs 6-15) --- ("Agouti", "AA CC DD EE GG PP spsp rere", 5), ("Schwarz", "aa CC DD EE GG PP spsp rere", 6), ("Silberagouti","AA CC DD EE gg PP spsp rere", 7), ("Anthrazit", "aa CC DD EE gg PP spsp rere", 8), ("Algierfuchs", "AA CC DD ee GG PP spsp rere", 9), ("Blau", "aa CC dd EE GG PP spsp rere", 10), ("Gold", "AA CC DD EE GG pp spsp rere", 11), ("Platin", "aa CC DD EE GG pp spsp rere", 12), ("Goldfuchs", "AA CC DD ee GG pp spsp rere", 13), ("Rotfuchs", "aa CC DD ee GG pp spsp rere", 14), // --- Dilute (dd) standard (IDs 16-17) --- ("Dilute Gold", "AA CC dd EE GG pp spsp rere", 15), ("Dilute Platin","aa CC dd EE GG pp spsp rere", 16), // --- REW / Apricot / misc C-locus (IDs 18-28) --- ("Altweiss (REW)", "aa CC DD EE gg pp spsp rere", 17), ("Apricot (Blassfuchs)","AA CC DD ee gg pp spsp rere", 18), ("Blaufuchs", "aa CC DD ee gg PP spsp rere", 19), ("C-Separator", "aa CC DD ee gg pp spsp rere", 20), ("Elfenbein", "AA CC DD EE gg pp spsp rere", 21), ("Kohlfuchs", "aa CC DD ee GG PP spsp rere", 22), ("Polarfuchs", "AA CC DD ee gg PP spsp rere", 23), ("Saphir", "aa CC DD EE GG pp spsp rere", 24), ("Orangeschimmel","AA CC DD efef GG PP spsp rere", 25), ("Topas", "AA CC DD EE GG pp spsp rere", 26), ("Platin-Hell", "aa CC DD EE GG pp spsp rere", 27), // --- Dilute (dd) varieties (IDs 29-32) --- ("Dilute Agouti", "AA CC dd EE GG PP spsp rere", 28), ("Dilute Silberagouti","AA CC dd EE gg PP spsp rere", 29), ("Dilute Kohlfuchs", "aa CC dd ee GG PP spsp rere", 30), ("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31), // --- Schimmel / Fuchsschimmel (IDs 33-37) --- ("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36), ("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37), ("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38), ("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39), ("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40), // --- Hell variants (IDs 38-48) --- ("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41), ("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42), ("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere", 43), ("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44), ("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45), ("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere", 46), ("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere", 47), ("Kohlfuchsschimmel, hell","aa CC DD efef GG PP spsp rere", 48), ("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49), ("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50), ("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51), // --- Dilute (dd) renamed variants (IDs 49-50) --- ("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52), ("Dilute Blaufuchs","aa CC dd ee gg pp spsp rere", 53), // --- Marder / Siam / CP- series (IDs 51-66) --- ("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54), ("Siam", "aa cchmch DD EE GG PP spsp rere", 55), ("Zobel-Hell","aa cchmch DD EE gg PP spsp rere", 56), ("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere", 57), ("CP-Silberagouti","AA cchmcchm DD EE gg PP spsp rere", 59), ("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere", 61), ("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere", 63), ("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere", 65), ("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere", 66), ("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere", 67), ("CP-Orangeschimmel","AA cchmcchm DD efef GG PP spsp rere", 68), // GEN-3g / CATALOG-RESEED: CP-*-Hell interleaved (IDs 62-66, SortOrders from backend.json) ("CP-Agouti-Hell", "AA cchmch DD EE GG PP spsp rere", 58), ("CP-Silberagouti-Hell", "AA cchmch DD EE gg PP spsp rere", 60), ("CP-Algierfuchs-Hell", "AA cchmch DD ee GG PP spsp rere", 62), ("CP-Polarfuchs-Hell", "AA cchmch DD ee gg PP spsp rere", 64), ("CP-Orangeschimmel-Hell","AA cchmch DD efef GG PP spsp rere", 69), // CATALOG-RESEED: 4 new Dilute (dd) Fuchs varieties appended (IDs 67-70) ("Dilute Algierfuchs","AA CC dd ee GG PP spsp rere", 32), ("Dilute Goldfuchs", "AA CC dd ee GG pp spsp rere", 33), ("Dilute Rotfuchs", "aa CC dd ee GG pp spsp rere", 34), ("Dilute Polarfuchs", "AA CC dd ee gg PP spsp rere", 35), }; var rows = new ColorVariety[catalog.Length]; for (int i = 0; i < catalog.Length; i++) { rows[i] = new ColorVariety { // Stable, deterministic GUIDs — ID = array_index + 1 (never reorder existing entries). Id = new Guid($"00000000-0000-0000-0000-{(i + 1):D12}"), Name = catalog[i].Name, CanonicalGenotype = catalog[i].Genotype, SortOrder = catalog[i].SortOrder, }; } modelBuilder.Entity().HasData(rows); } }