Per-member estate model: each family member authors their own Wassiyah/Waqf,

mutawalli executes on any member's trigger, heir email notification

Per explicit product direction: "all members of the family can make their
own wassiyah or waqif. The mutawali or the trustee agent can access and
execute those wassiyah and waqif upon any event triggers. Warith or the
heir will be automatically notified via email."

Schema: nf_family_members.role now includes 'member' (authors own documents,
doesn't manage the family). nf_wassiyah_settings/nf_wassiyah_bequests/
nf_waqf_designations gained author_id — each is now per-author, not
per-family. Added recipient_email / beneficiary_email columns for warith
notification targets. New nf_member_triggers (composite PK family_id+
member_id) and nf_member_attestors: a per-member death trigger, separate
from the legacy family-wide nf_death_triggers (kept for backward
compatibility, still exercised by existing suites).

RLS: any family member can READ any other member's Wassiyah/Waqf (the
mutawalli needs full visibility to execute), but only the document's own
author can WRITE to it — not even the owner. Firing a member's trigger
requires the caller to have role agent/owner AND not be the member
themselves (enforced in the policy's WITH CHECK, not just the UI) — matches
"the mutawalli executes, never for themselves."

New UI: FamilyManagement gained a role selector (member vs agent) on
invites. New MutawalliDashboard.svelte — the trustee's execution surface:
pick any family member, see their Wassiyah/Waqf read-only, set up
attestors + death cert ref, fire their trigger (blocked for self both by
disabled UI and by RLS), then trigger heir email notifications.

Edge Function notify-heirs deployed (Deno, uses Resend): reads the
triggered member's Wassiyah recipients and Waqf beneficiaries wherever an
email was recorded, sends each a notice. Returns a clear 501 rather than
failing silently until RESEND_API_KEY is set as a project secret.

Three real bugs found via testing against the live backend, not visible
from code review alone:
- listFamilyMembers() never selected user_id — every member-scoped lookup
  on the new dashboard was silently keying off undefined.
- nf_member_triggers keyed by member_id alone: since a person can belong
  to multiple families, firing a trigger in one family marked them
  "triggered" in every other family they belong to. Fixed to composite
  (family_id, member_id) key.
- Classic Svelte 5 $state pitfall: (proxyObject[key] ??= []).push(item)
  mutates the plain array literal the ??= expression evaluates to, not
  the proxy-wrapped array Svelte actually tracks — so pushed items were
  silently invisible to the UI forever. Fixed by building on a plain
  object and assigning to the $state variable once. Also found and fixed
  the same design smell in the older WassiyahGenerator/FamilyWaqfDesignator
  authorId handling: it was snapshotted once via currentUser()?.id at
  mount instead of read live off the session store, which could silently
  break writes on a remount that happened before session hydration
  finished — now reads live and guards refresh() on it being present.

CoverageDashboard and DeathTrigger updated to check ANY family member's
Waqf corpus for coverage (not just one author's), since coverage is a
family-wide view even though authorship is per-member now.

e2e-per-member.cjs: new suite covering the full flow — owner invites a
member and an agent; member authors a private Wassiyah (invisible to
other members, confirming per-author isolation); mutawalli sees it on
their dashboard and fires the member's trigger; member cannot fire their
own; heir notification call completes with either Sent or a clear
"not configured" failure, never hangs. 11/11 passing.

Full regression sweep after these changes: e2e-uat 32/32 (stable across
3 consecutive runs), e2e-fastpath 16/16, e2e-trust 12/12, e2e-business
10/10, e2e-digital-vehicle 10/10, e2e-property 9/9, e2e-other 4/4,
e2e-info 31/31, e2e-family-agent 12/12 (updated for the new invite-form
role selector), e2e-per-member 11/11 — 178/178 total, no regressions.
This commit is contained in:
wmj
2026-08-14 05:51:46 +08:00
parent b9a98bd97a
commit 9eb2ce7ce3
13 changed files with 539 additions and 58 deletions
+80 -17
View File
@@ -66,18 +66,18 @@ export async function removeHibahGift(id) {
}
// ── Waqf ──
export async function getWaqfDesignation(familyId) {
const { data, error } = await supabase.from('nf_waqf_designations').select('*').eq('family_id', familyId).order('created_at', { ascending: false }).limit(1).maybeSingle();
export async function getWaqfDesignation(familyId, authorId) {
const { data, error } = await supabase.from('nf_waqf_designations').select('*').eq('family_id', familyId).eq('author_id', authorId).order('created_at', { ascending: false }).limit(1).maybeSingle();
if (error) throw error;
if (!data) return null;
const { data: beneficiaries } = await supabase.from('nf_waqf_beneficiaries').select('*').eq('waqf_id', data.id);
return {
id: data.id, corpusAssetId: data.corpus_asset_id, mutawalli: data.mutawalli,
successorMutawalli: data.successor_mutawalli, equalSplit: data.equal_split, jurisdiction: data.jurisdiction,
beneficiaries: (beneficiaries || []).map(b => ({ id: b.id, name: b.name, relation: b.relation, sharePercent: b.share_percent }))
beneficiaries: (beneficiaries || []).map(b => ({ id: b.id, name: b.name, relation: b.relation, sharePercent: b.share_percent, email: b.beneficiary_email }))
};
}
export async function upsertWaqfDesignation(familyId, existingId, fields) {
export async function upsertWaqfDesignation(familyId, authorId, existingId, fields) {
if (existingId) {
const { error } = await supabase.from('nf_waqf_designations').update({
corpus_asset_id: fields.corpusAssetId || null, mutawalli: fields.mutawalli, successor_mutawalli: fields.successorMutawalli,
@@ -87,16 +87,23 @@ export async function upsertWaqfDesignation(familyId, existingId, fields) {
return existingId;
}
const { data, error } = await supabase.from('nf_waqf_designations').insert({
family_id: familyId, corpus_asset_id: fields.corpusAssetId || null, mutawalli: fields.mutawalli,
family_id: familyId, author_id: authorId, corpus_asset_id: fields.corpusAssetId || null, mutawalli: fields.mutawalli,
successor_mutawalli: fields.successorMutawalli, equal_split: fields.equalSplit, jurisdiction: fields.jurisdiction
}).select().single();
if (error) throw error;
return data.id;
}
export async function addWaqfBeneficiary(waqfId, b) {
const { error } = await supabase.from('nf_waqf_beneficiaries').insert({ waqf_id: waqfId, name: b.name, relation: b.relation, share_percent: b.sharePercent || null });
const { error } = await supabase.from('nf_waqf_beneficiaries').insert({ waqf_id: waqfId, name: b.name, relation: b.relation, share_percent: b.sharePercent || null, beneficiary_email: b.email || null });
if (error) throw error;
}
/** For the mutawalli dashboard: every family member's Waqf designations. */
export async function listAllWaqfForFamily(familyId) {
const { data, error } = await supabase.from('nf_waqf_designations').select('*, nf_waqf_beneficiaries(*)').eq('family_id', familyId).order('created_at');
if (error) throw error;
return data || [];
}
export async function removeWaqfBeneficiary(id) {
const { error } = await supabase.from('nf_waqf_beneficiaries').delete().eq('id', id);
if (error) throw error;
@@ -187,15 +194,16 @@ export function estateTotal(assets) {
return (assets || []).reduce((sum, a) => sum + (Number(a.value) || 0) * (Number(a.ownershipShare ?? 100) / 100), 0);
}
// ── Wassiyah ──
export async function listWassiyahBequests(familyId) {
const { data, error } = await supabase.from('nf_wassiyah_bequests').select('*').eq('family_id', familyId).order('created_at');
// ── Wassiyah — per-author: each signed-in family member has their own ──
export async function listWassiyahBequests(familyId, authorId) {
const { data, error } = await supabase.from('nf_wassiyah_bequests').select('*').eq('family_id', familyId).eq('author_id', authorId).order('created_at');
if (error) throw error;
return (data || []).map(b => ({ id: b.id, recipient: b.recipient, relation: b.relation, description: b.description, value: b.value }));
return (data || []).map(b => ({ id: b.id, recipient: b.recipient, relation: b.relation, description: b.description, value: b.value, recipientEmail: b.recipient_email }));
}
export async function addWassiyahBequest(familyId, b) {
export async function addWassiyahBequest(familyId, authorId, b) {
const { error } = await supabase.from('nf_wassiyah_bequests').insert({
family_id: familyId, recipient: b.recipient, relation: b.relation, description: b.description, value: Number(b.value)
family_id: familyId, author_id: authorId, recipient: b.recipient, relation: b.relation,
description: b.description, value: Number(b.value), recipient_email: b.recipientEmail || null
});
if (error) throw error;
}
@@ -203,15 +211,70 @@ export async function removeWassiyahBequest(id) {
const { error } = await supabase.from('nf_wassiyah_bequests').delete().eq('id', id);
if (error) throw error;
}
export async function getWassiyahSettings(familyId) {
const { data, error } = await supabase.from('nf_wassiyah_settings').select('*').eq('family_id', familyId).maybeSingle();
export async function getWassiyahSettings(familyId, authorId) {
const { data, error } = await supabase.from('nf_wassiyah_settings').select('*').eq('family_id', familyId).eq('author_id', authorId).maybeSingle();
if (error) throw error;
return data;
}
export async function upsertWassiyahSettings(familyId, fields) {
export async function upsertWassiyahSettings(familyId, authorId, fields) {
const { error } = await supabase.from('nf_wassiyah_settings').upsert({
family_id: familyId, jurisdiction: fields.jurisdiction, witness1: fields.witness1, witness2: fields.witness2,
family_id: familyId, author_id: authorId, jurisdiction: fields.jurisdiction, witness1: fields.witness1, witness2: fields.witness2,
updated_at: new Date().toISOString()
}, { onConflict: 'family_id' });
}, { onConflict: 'family_id,author_id' });
if (error) throw error;
}
/** For the mutawalli dashboard: every family member's Wassiyah bequests, grouped by author. */
export async function listAllWassiyahForFamily(familyId) {
const { data, error } = await supabase.from('nf_wassiyah_bequests').select('*').eq('family_id', familyId).order('created_at');
if (error) throw error;
return data || [];
}
// ── Per-member death trigger — the mutawalli/owner fires it for a specific
// member, never the member themselves (enforced by RLS, not just the UI). ──
export async function getMemberTrigger(memberId, familyId) {
const { data, error } = await supabase.from('nf_member_triggers').select('*').eq('member_id', memberId).eq('family_id', familyId).maybeSingle();
if (error) throw error;
return data;
}
export async function upsertMemberTriggerSetup(memberId, familyId, fields) {
const { error } = await supabase.from('nf_member_triggers').upsert({
member_id: memberId, family_id: familyId, executor_name: fields.executorName,
date_of_death: fields.dateOfDeath || null, death_cert_ref: fields.deathCertRef, updated_at: new Date().toISOString()
}, { onConflict: 'family_id,member_id' });
if (error) throw error;
}
/** Only succeeds (per RLS) if the caller is an agent/owner on this family and is NOT the member themselves. */
export async function fireMemberTrigger(memberId, familyId, firedBy) {
const { error } = await supabase.from('nf_member_triggers').upsert({
member_id: memberId, family_id: familyId, triggered: true, triggered_at: new Date().toISOString(),
fired_by: firedBy, updated_at: new Date().toISOString()
}, { onConflict: 'family_id,member_id' });
if (error) throw error;
}
export async function listMemberAttestors(memberId, familyId) {
const { data, error } = await supabase.from('nf_member_attestors').select('*').eq('member_id', memberId).eq('family_id', familyId).order('created_at');
if (error) throw error;
return data || [];
}
export async function addMemberAttestor(memberId, familyId, name) {
const { data, error } = await supabase.from('nf_member_attestors').insert({ member_id: memberId, family_id: familyId, name }).select().single();
if (error) throw error;
return data;
}
export async function updateMemberAttestorName(id, name) {
const { error } = await supabase.from('nf_member_attestors').update({ name }).eq('id', id);
if (error) throw error;
}
export async function setMemberAttestorConfirmed(id, confirmed) {
const { error } = await supabase.from('nf_member_attestors').update({ confirmed, confirmed_at: confirmed ? new Date().toISOString() : null }).eq('id', id);
if (error) throw error;
}
/** Sends the heir notification email via the notify-heirs Edge Function. */
export async function notifyHeirs(memberId) {
const { data, error } = await supabase.functions.invoke('notify-heirs', { body: { memberId } });
if (error) throw error;
return data;
}