Add mini family genealogy tree with per-person photos

Per explicit request: "mini family genealogy mapping with ability to add
photos on each family member." Scoped via clarifying questions: people are
lightweight records independent of login accounts (most real tree nodes —
grandparents, deceased relatives, young children — never sign up),
relationships support parent/child + spouse (enough to render a real
multi-generation tree), photos go to Supabase Storage (native to the
backend already in use, no new infrastructure).

Schema: nf_people (name, gender, birth/death dates, notes, optional
linked_user_id if the person also happens to have a real account,
photo_path) and nf_relationships (person_a/person_b + type: parent_of
directional, spouse_of symmetric). Both family-member-shared like the
Asset Registry — any owner/agent/member can view and edit, unlike the
per-author Wassiyah/Waqf model. New private Storage bucket
nf-people-photos, path convention {family_id}/{person_id}/{filename} so
RLS can check family membership straight from the path without a join.

New FamilyTree.svelte: add/edit/delete a person, upload/replace/remove
their photo (signed URLs, 1hr TTL, refreshed on every load since the
bucket is private), link/unlink relationships, and a recursive generational
tree render — roots are anyone with no recorded parent, spouses shown
inline next to their partner rather than as separate branches.

e2e-family-tree.cjs: 4-person 3-generation tree built end to end against
the live backend — add people, link parent/child + spouse relationships,
upload a real PNG to Supabase Storage and confirm it renders as the
avatar, remove it and confirm it reverts to the initial-letter fallback,
edit a person's notes, delete a relationship, and confirm a second family
under the same account sees none of this tree (isolation, same pattern
already proven for Wassiyah/Waqf). Found two real test-authoring bugs
along the way (not app bugs): a case-insensitive :has-text substring match
on "Spouse" was also matching the relationship-type select's own "is
spouse of" option text, and a person-summary click meant to inspect an
already-expanded card was instead toggling it closed. Both fixed by
selecting on DOM position/structure instead of loose text matching.
9/9 passing.

Full regression sweep: e2e-uat 32/32, e2e-fastpath 16/16, e2e-trust 12/12,
e2e-business 10/10, e2e-digital-vehicle 10/10 (one transient failure on
first run, passed clean on retry — consistent with earlier-observed
flakiness under heavy parallel test load, not a regression),
e2e-property 9/9, e2e-other 4/4, e2e-info 31/31, e2e-per-member 11/11,
e2e-family-tree 9/9 — 174/174 total.
This commit is contained in:
wmj
2026-08-14 09:16:42 +08:00
parent 5adbad6d53
commit e7a111c387
5 changed files with 548 additions and 5 deletions
+77
View File
@@ -278,3 +278,80 @@ export async function notifyHeirs(memberId, familyId) {
if (error) throw error;
return data;
}
// ── Family Genealogy ──
// nf_people are lightweight — most real tree nodes (grandparents, deceased
// relatives, young children) never have a login. linked_user_id is optional.
const PHOTO_BUCKET = 'nf-people-photos';
export async function listPeople(familyId) {
const { data, error } = await supabase.from('nf_people').select('*').eq('family_id', familyId).order('created_at');
if (error) throw error;
return (data || []).map(p => ({
id: p.id, familyId: p.family_id, linkedUserId: p.linked_user_id, fullName: p.full_name,
gender: p.gender, birthDate: p.birth_date, deathDate: p.death_date, notes: p.notes, photoPath: p.photo_path
}));
}
export async function addPerson(familyId, createdBy, fields) {
const { data, error } = await supabase.from('nf_people').insert({
family_id: familyId, created_by: createdBy, full_name: fields.fullName, gender: fields.gender || null,
birth_date: fields.birthDate || null, death_date: fields.deathDate || null, notes: fields.notes || null
}).select().single();
if (error) throw error;
return data;
}
export async function updatePerson(id, fields) {
const { error } = await supabase.from('nf_people').update({
full_name: fields.fullName, gender: fields.gender || null, birth_date: fields.birthDate || null,
death_date: fields.deathDate || null, notes: fields.notes || null, updated_at: new Date().toISOString()
}).eq('id', id);
if (error) throw error;
}
export async function removePerson(id) {
const { error } = await supabase.from('nf_people').delete().eq('id', id);
if (error) throw error;
}
/** Uploads a photo for a person and stores its storage path on the record. Path: {familyId}/{personId}/{filename} so RLS can check family membership from the path alone. */
export async function uploadPersonPhoto(familyId, personId, file) {
const ext = (file.name.split('.').pop() || 'jpg').toLowerCase();
const path = `${familyId}/${personId}/${Date.now()}.${ext}`;
const { error: upErr } = await supabase.storage.from(PHOTO_BUCKET).upload(path, file, { upsert: true, contentType: file.type });
if (upErr) throw upErr;
const { error: dbErr } = await supabase.from('nf_people').update({ photo_path: path, updated_at: new Date().toISOString() }).eq('id', personId);
if (dbErr) throw dbErr;
return path;
}
export async function removePersonPhoto(personId, photoPath) {
if (photoPath) await supabase.storage.from(PHOTO_BUCKET).remove([photoPath]);
const { error } = await supabase.from('nf_people').update({ photo_path: null, updated_at: new Date().toISOString() }).eq('id', personId);
if (error) throw error;
}
/** Signed URL (private bucket) valid for 1 hour — call fresh each time the photo is displayed. */
export async function getPersonPhotoUrl(photoPath) {
if (!photoPath) return null;
const { data, error } = await supabase.storage.from(PHOTO_BUCKET).createSignedUrl(photoPath, 3600);
if (error) return null;
return data.signedUrl;
}
export async function listRelationships(familyId) {
const { data, error } = await supabase.from('nf_relationships').select('*').eq('family_id', familyId);
if (error) throw error;
return (data || []).map(r => ({ id: r.id, personAId: r.person_a_id, personBId: r.person_b_id, type: r.relationship_type }));
}
export async function addRelationship(familyId, personAId, personBId, type) {
const { error } = await supabase.from('nf_relationships').insert({ family_id: familyId, person_a_id: personAId, person_b_id: personBId, relationship_type: type });
if (error) throw error;
}
export async function removeRelationship(id) {
const { error } = await supabase.from('nf_relationships').delete().eq('id', id);
if (error) throw error;
}