diff --git a/RMuseum/DbContext/RMuseumDbContext.cs b/RMuseum/DbContext/RMuseumDbContext.cs index a16267a9..deb84d38 100644 --- a/RMuseum/DbContext/RMuseumDbContext.cs +++ b/RMuseum/DbContext/RMuseumDbContext.cs @@ -123,6 +123,37 @@ namespace RMuseum.DbContext .HasIndex(m => m.Name) .IsUnique(); + // GanjoorPersonRelation has two required FKs to the same table (GanjoorRelatedPerson) - + // left at their EF Core default (Cascade, since both are required/non-nullable), SQL + // Server refuses to create the second FK with "may cause cycles or multiple cascade + // paths". Restricting one side (Person2) is enough to break the ambiguity; deleting a + // person that's still referenced by a relation should be prevented at the application + // level anyway (via a "still has family tree entries" check), not silently cascaded. + builder.Entity() + .HasOne(r => r.Person1) + .WithMany() + .HasForeignKey(r => r.Person1Id) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasOne(r => r.Person2) + .WithMany() + .HasForeignKey(r => r.Person2Id) + .OnDelete(DeleteBehavior.Restrict); + + // same two-required-FKs-to-the-same-table situation as GanjoorPersonRelation above + builder.Entity() + .HasOne(a => a.Person1) + .WithMany() + .HasForeignKey(a => a.Person1Id) + .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasOne(a => a.Person2) + .WithMany() + .HasForeignKey(a => a.Person2Id) + .OnDelete(DeleteBehavior.Restrict); + builder.Entity() .HasIndex(b => new { b.UserId, b.PoemId, b.CoupletIndex }); @@ -578,6 +609,16 @@ namespace RMuseum.DbContext /// public DbSet GanjoorRelatedPersons { get; set; } + /// + /// approved kinship edges between people (family tree) - see GanjoorPersonRelation + /// + public DbSet GanjoorPersonRelations { get; set; } + + /// + /// approved non-family ties between people (e.g. minister-to-king) - see GanjoorPersonAffiliation + /// + public DbSet GanjoorPersonAffiliations { get; set; } + /// /// Books (PDF Library) /// diff --git a/RMuseum/Models/Ganjoor/GanjoorPersonAffiliation.cs b/RMuseum/Models/Ganjoor/GanjoorPersonAffiliation.cs new file mode 100644 index 00000000..835f94e9 --- /dev/null +++ b/RMuseum/Models/Ganjoor/GanjoorPersonAffiliation.cs @@ -0,0 +1,50 @@ +namespace RMuseum.Models.Ganjoor +{ + /// + /// an approved non-family tie between two people (GanjoorRelatedPerson rows) - e.g. a minister + /// serving a king. Kept separate from GanjoorPersonRelation (kinship) rather than folded into + /// it: this is what lets two unrelated family trees show up as adjacent to each other (e.g. + /// browsing the Barmakid tree surfaces a link out to the Abbasid tree via a shared minister) + /// without treating "family tree" as its own entity to be linked - the tie is between the two + /// people, and the tree-to-tree adjacency is just what falls out of rendering it that way. + /// + public class GanjoorPersonAffiliation + { + /// + /// record id + /// + public int Id { get; set; } + + /// + /// first person in the tie - for a directional type (Minister, Advisor, Courtier, Patron) + /// this is the one in the subordinate/serving role; for a symmetric one (Ally, Rival) order + /// doesn't matter + /// + public int Person1Id { get; set; } + + /// + /// first person (navigation) + /// + public virtual GanjoorRelatedPerson Person1 { get; set; } + + /// + /// second person in the tie - for a directional type this is the one being served + /// + public int Person2Id { get; set; } + + /// + /// second person (navigation) + /// + public virtual GanjoorRelatedPerson Person2 { get; set; } + + /// + /// the kind of tie between Person1 and Person2 + /// + public PersonAffiliationType AffiliationType { get; set; } + + /// + /// free-text note (e.g. sourcing/reasoning, or what the tie actually is when AffiliationType is Other) + /// + public string Note { get; set; } + } +} diff --git a/RMuseum/Models/Ganjoor/GanjoorPersonRelation.cs b/RMuseum/Models/Ganjoor/GanjoorPersonRelation.cs new file mode 100644 index 00000000..c512522d --- /dev/null +++ b/RMuseum/Models/Ganjoor/GanjoorPersonRelation.cs @@ -0,0 +1,55 @@ +namespace RMuseum.Models.Ganjoor +{ + /// + /// an approved kinship edge between two people (GanjoorRelatedPerson rows) - the live/materialized + /// counterpart of a pending suggestion, which travels as JSON on GanjoorPoemGeoDateTagCorrection's + /// SuggestedPersonGraphJson until it's approved and turned into rows here. The graph as a whole + /// (all rows in this table) is a general kinship graph, not a strict tree - see PersonRelationType. + /// + public class GanjoorPersonRelation + { + /// + /// record id + /// + public int Id { get; set; } + + /// + /// first person in the relation - for a directional relation type (Parent, Ancestor) this is + /// the parent/ancestor side; for a symmetric one (Sibling, Spouse) order doesn't matter + /// + public int Person1Id { get; set; } + + /// + /// first person (navigation) + /// + public virtual GanjoorRelatedPerson Person1 { get; set; } + + /// + /// second person in the relation - for a directional relation type (Parent, Ancestor) this is + /// the child/descendant side + /// + public int Person2Id { get; set; } + + /// + /// second person (navigation) + /// + public virtual GanjoorRelatedPerson Person2 { get; set; } + + /// + /// the kind of relation between Person1 and Person2 + /// + public PersonRelationType RelationType { get; set; } + + /// + /// for RelationType == Ancestor, an optional known exact degree (e.g. 2 for "grandparent", + /// 3 for "great-grandparent") - left null when only the relative order is known, not the + /// exact number of generations in between. Not used for other relation types. + /// + public int? DegreeHint { get; set; } + + /// + /// free-text note (e.g. sourcing/reasoning for this relation) + /// + public string Note { get; set; } + } +} diff --git a/RMuseum/Models/Ganjoor/GanjoorPoemGeoDateTagCorrection.cs b/RMuseum/Models/Ganjoor/GanjoorPoemGeoDateTagCorrection.cs index 897bb47f..f1f8f7c3 100644 --- a/RMuseum/Models/Ganjoor/GanjoorPoemGeoDateTagCorrection.cs +++ b/RMuseum/Models/Ganjoor/GanjoorPoemGeoDateTagCorrection.cs @@ -56,7 +56,8 @@ public int? LunarDay { get; set; } /// - /// related person id (existing, approved GanjoorRelatedPerson only - no suggestion path for new people yet) + /// related person id - an existing, already approved GanjoorRelatedPerson. Set this OR + /// SuggestedPersonGraphJson below, not both (same pattern as LocationId/Suggested* above). /// public int? PersonId { get; set; } @@ -65,6 +66,35 @@ /// public virtual GanjoorRelatedPerson Person { get; set; } + /// + /// a brand new, not yet approved person (and optionally that person's relatives/relations, + /// which may themselves be new people) - serialized JSON rather than its own set of + /// correction tables, because a single suggestion can introduce several interlinked new + /// people at once (e.g. "add this person, and their father, and the relation between them") + /// and a new person referencing another not-yet-existing new person has no real id to point + /// at until the whole graph is approved together. Expected shape (local keys are only used + /// to resolve relations within this same submission and never stored beyond approval time): + /// { + /// "person": { "localKey": "p1", "existingPersonId": null, "name": "...", "description": "...", + /// "wikiUrl": "...", "birthYearInLHijri": null, "deathYearInLHijri": null, + /// "validBirthDate": false, "validDeathDate": false, + /// "birthLocationId": null, "deathLocationId": null, + /// "familyTreeCaption": null }, + /// "relatedPeople": [ { "localKey": "p2", "existingPersonId": 42, ... } ], + /// "relations": [ { "kind": "family", "person1": "p1", "person2": "p2", "relationType": "Parent", + /// "degreeHint": null, "note": "..." }, + /// { "kind": "affiliation", "person1": "p2", "person2": "p3", + /// "affiliationType": "Minister", "note": "..." } ] + /// } + /// "person" is the node that ends up assigned to PersonId once approved. Only set when + /// PersonId above is null. Each entry in "relations" carries a "kind" discriminator so one + /// submission can suggest both kinship edges (materialized as GanjoorPersonRelation, + /// "relationType" against PersonRelationType) and non-family ties (materialized as + /// GanjoorPersonAffiliation, "affiliationType" against PersonAffiliationType) at once - e.g. + /// introducing a person along with both their father and the king they served. + /// + public string SuggestedPersonGraphJson { get; set; } + /// /// if true, this tag is excluded from category/poet-level map aggregation (e.g. a place mentioned only /// for comparison, not actually visited/relevant to the poet's own path) - default false, matching diff --git a/RMuseum/Models/Ganjoor/GanjoorRelatedPerson.cs b/RMuseum/Models/Ganjoor/GanjoorRelatedPerson.cs index 1352bdc3..cc1aea2c 100644 --- a/RMuseum/Models/Ganjoor/GanjoorRelatedPerson.cs +++ b/RMuseum/Models/Ganjoor/GanjoorRelatedPerson.cs @@ -69,5 +69,14 @@ /// AI generated /// public bool MachineGenerated { get; set; } + + /// + /// optional caption for the family tree this person is treated as the root of (e.g. + /// "ساسانیان", "آل برمک") - purely a display label for whoever a tree is being browsed + /// from; nothing enforces that this person actually has no recorded ancestors themselves, + /// and most people will leave this null (only whichever person a tree is "named after" + /// needs one set). + /// + public string FamilyTreeCaption { get; set; } } } diff --git a/RMuseum/Models/Ganjoor/PersonAffiliationType.cs b/RMuseum/Models/Ganjoor/PersonAffiliationType.cs new file mode 100644 index 00000000..0cd421b9 --- /dev/null +++ b/RMuseum/Models/Ganjoor/PersonAffiliationType.cs @@ -0,0 +1,48 @@ +namespace RMuseum.Models.Ganjoor +{ + /// + /// the kind of non-family tie a GanjoorPersonAffiliation represents between two people - e.g. + /// a minister serving a king, an advisor, a patron. Unlike PersonRelationType this has nothing + /// to do with kinship; it's what lets two otherwise unrelated family trees show up as adjacent + /// (e.g. "some Barmakids served the Abbasid court") without merging them into one tree. New + /// values can be appended safely later (stored as int) as more cases come up. + /// + public enum PersonAffiliationType + { + /// + /// Person1 served as minister/vizier to Person2 + /// + Minister = 0, + + /// + /// Person1 was an advisor/counselor to Person2, without holding a formal ministerial post + /// + Advisor = 1, + + /// + /// Person1 was a courtier/attendant/servant of Person2 (a catch-all for court-affiliated + /// roles not covered by a more specific type) + /// + Courtier = 2, + + /// + /// Person1 was a patron/sponsor of Person2 (e.g. a king patronizing a poet) + /// + Patron = 3, + + /// + /// Person1 and Person2 were allies (symmetric - order doesn't matter) + /// + Ally = 4, + + /// + /// Person1 and Person2 were rivals/enemies (symmetric - order doesn't matter) + /// + Rival = 5, + + /// + /// doesn't fit any of the above - rely on Note for what the tie actually is + /// + Other = 99, + } +} diff --git a/RMuseum/Models/Ganjoor/PersonRelationType.cs b/RMuseum/Models/Ganjoor/PersonRelationType.cs new file mode 100644 index 00000000..b8f20776 --- /dev/null +++ b/RMuseum/Models/Ganjoor/PersonRelationType.cs @@ -0,0 +1,35 @@ +namespace RMuseum.Models.Ganjoor +{ + /// + /// the kind of kinship edge a GanjoorPersonRelation represents between two GanjoorRelatedPerson + /// rows. The graph is not a strict tree - siblings can be known without their parents, an + /// ancestor can be known without the exact number of generations in between, etc. - so this is + /// a small, deliberately open set rather than a rigid parent/child-only model. + /// + public enum PersonRelationType + { + /// + /// Person1 is a parent of Person2 (directional - the mirror "child" relation is implied, + /// not stored as a second row) + /// + Parent = 0, + + /// + /// Person1 and Person2 are siblings (symmetric - order doesn't matter). Doesn't require + /// either parent to be known/recorded. + /// + Sibling = 1, + + /// + /// Person1 and Person2 are spouses (symmetric - order doesn't matter) + /// + Spouse = 2, + + /// + /// Person1 is a known ancestor of Person2 (directional) but the exact number of generations + /// between them is not known/recorded - see GanjoorPersonRelation.DegreeHint for the case + /// where the exact degree (e.g. "grandparent") IS known + /// + Ancestor = 3, + } +}