Add estate agent delegation: real accounts, multi-tenant backend, RLS-enforced roles

Per explicit product direction: the app assumed a single user (head of
family) with everything in per-device localStorage. There was no way for
a family to delegate estate management to an agent (relative or
professional) without literally handing over the device. This required
real backend infrastructure, not a UI addition — added Supabase
(Postgres + Auth) as a multi-tenant backend.

Schema (nf_ prefixed to stay isolated from other tables in the reused
"Falah OS demo" project): nf_families, nf_family_members (role: owner/
agent, status: invited/active), and family-scoped versions of every
estate table — nf_assets, nf_trusted_contacts, nf_hibah_gifts,
nf_waqf_designations/nf_waqf_beneficiaries, nf_nominations,
nf_attestors, nf_death_triggers.

Permission model, enforced by RLS at the database level (not just
hidden in the UI): an agent can do everything an owner can — add/edit
assets, draft Hibah/Waqf/Nominations, set up the Death Trigger — except
fire it. nf_death_triggers' UPDATE/INSERT policies use a WITH CHECK that
only allows triggered=true when the caller has role='owner' on that
family. A professional agent can be invited to multiple families and
switches between them from their own dashboard.

New: auth.js, family.js, db.js, AuthScreen.svelte, FamilySwitcher.svelte,
FamilyManagement.svelte (new "Family" tab: invite agents, see members,
switch families). AssetRegistry, HibahTracker, FamilyWaqfDesignator,
NominationRegistry, CoverageDashboard, and DeathTrigger all migrated
from storage.js (localStorage) to db.js (Supabase), scoped to the
active family_id. App.svelte now gates on auth + family selection
before showing the main tab shell.

Three real bugs found and fixed via testing against the live backend
(not caught by the old localStorage-based suites, which had no
cross-client concurrency to expose them):
- RLS gap: pending-invite lookup joins nf_families(name), but the
  invitee isn't a family member yet, so the join was silently dropped —
  added a policy letting a pending invitee see just the family name.
- Attestor row race: lazy "create on first blur" could double-fire from
  two different code paths, creating duplicate rows and confirming the
  wrong one. Fixed by eagerly creating attestor rows on first load so
  every row always has a real id — no more create-or-update ambiguity.
- Out-of-order async clobber: three death-trigger setup fields each
  fired a full-snapshot upsert on every input; whichever request
  *finished* last (not fired last) won, silently reverting the other
  two fields to stale values. Fixed with per-field partial updates
  (updateDeathTriggerField) that can't clobber columns they don't touch.

e2e-family-agent.cjs: full owner/agent flow against the live Supabase
backend — invite, accept, shared live data, agent blocked from firing
the trigger (button stays disabled and a direct RLS-level attempt would
also fail), owner successfully fires it. 12/12 passing.
e2e-smoke-authed.cjs: post-auth-gate sweep confirming every existing tab
still renders and its info panel still opens under the new sign-in
requirement. 24/24 passing, zero console errors.

Known follow-up, not done here: the eight pre-auth E2E suites
(e2e-uat.cjs, e2e-fastpath.cjs, e2e-trust.cjs, e2e-business.cjs,
e2e-digital-vehicle.cjs, e2e-property.cjs, e2e-other.cjs, e2e-info.cjs)
assume an anonymous landing page and need a sign-in prelude added
before they're valid again — their detailed assertions were re-verified
functionally via the smoke test and manual review, not by running them
as-is.
This commit is contained in:
wmj
2026-08-13 21:07:23 +08:00
parent 4250f8da14
commit a0c70e1411
19 changed files with 1245 additions and 111 deletions
+188
View File
@@ -0,0 +1,188 @@
// Family-scoped data layer over Supabase. Replaces the old per-device localStorage
// calls in storage.js for estate data — Asset Registry, Hibah, Waqf, Nominations,
// Attestors, and the Death Trigger — so an owner and their assigned agent(s) see
// and manage the same live data, with the "agent cannot fire the trigger" rule
// enforced by RLS on nf_death_triggers (see the migration), not just hidden in
// the UI.
import { supabase } from './supabaseClient.js';
// ── Assets ──
export async function listAssets(familyId) {
const { data, error } = await supabase.from('nf_assets').select('*').eq('family_id', familyId).order('created_at');
if (error) throw error;
return (data || []).map(a => ({ id: a.id, type: a.type, description: a.description, value: a.value, location: a.location, ownershipShare: a.ownership_share }));
}
export async function addAsset(familyId, userId, a) {
const { data, error } = await supabase.from('nf_assets').insert({
family_id: familyId, type: a.type, description: a.description, value: Number(a.value),
location: a.location, ownership_share: Number(a.ownershipShare ?? 100), created_by: userId
}).select().single();
if (error) throw error;
return data;
}
export async function updateAsset(id, a) {
const { error } = await supabase.from('nf_assets').update({
type: a.type, description: a.description, value: Number(a.value), location: a.location, ownership_share: Number(a.ownershipShare ?? 100)
}).eq('id', id);
if (error) throw error;
}
export async function removeAsset(id) {
const { error } = await supabase.from('nf_assets').delete().eq('id', id);
if (error) throw error;
}
// ── Trusted contacts ──
export async function listTrustedContacts(familyId) {
const { data, error } = await supabase.from('nf_trusted_contacts').select('*').eq('family_id', familyId).order('created_at');
if (error) throw error;
return data || [];
}
export async function addTrustedContact(familyId, name, method) {
const { error } = await supabase.from('nf_trusted_contacts').insert({ family_id: familyId, name, method });
if (error) throw error;
}
export async function removeTrustedContact(id) {
const { error } = await supabase.from('nf_trusted_contacts').delete().eq('id', id);
if (error) throw error;
}
// ── Hibah ──
export async function listHibahGifts(familyId) {
const { data, error } = await supabase.from('nf_hibah_gifts').select('*').eq('family_id', familyId).order('created_at');
if (error) throw error;
return (data || []).map(g => ({ id: g.id, recipient: g.recipient, relation: g.relation, description: g.description, date: g.gift_date, statement: g.statement, linkedAssetId: g.linked_asset_id, flagged: g.flagged, cap: g.cap }));
}
export async function addHibahGift(familyId, g) {
const { error } = await supabase.from('nf_hibah_gifts').insert({
family_id: familyId, recipient: g.recipient, relation: g.relation, description: g.description,
gift_date: g.date, statement: g.statement, linked_asset_id: g.linkedAssetId || null,
flagged: !!g.flagged, cap: g.cap ?? null, acknowledgment_link: g.acknowledgmentLink
});
if (error) throw error;
}
export async function removeHibahGift(id) {
const { error } = await supabase.from('nf_hibah_gifts').delete().eq('id', id);
if (error) throw error;
}
// ── 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();
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 }))
};
}
export async function upsertWaqfDesignation(familyId, 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,
equal_split: fields.equalSplit, jurisdiction: fields.jurisdiction
}).eq('id', existingId);
if (error) throw error;
return existingId;
}
const { data, error } = await supabase.from('nf_waqf_designations').insert({
family_id: familyId, 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 });
if (error) throw error;
}
export async function removeWaqfBeneficiary(id) {
const { error } = await supabase.from('nf_waqf_beneficiaries').delete().eq('id', id);
if (error) throw error;
}
// ── Nominations ──
export async function listNominations(familyId) {
const { data, error } = await supabase.from('nf_nominations').select('*').eq('family_id', familyId).order('created_at');
if (error) throw error;
return (data || []).map(n => ({
id: n.id, linkedAssetId: n.linked_asset_id, type: n.type, institution: n.institution, nomineeeName: n.nominee_name,
referenceNumber: n.reference_number, trusteeName: n.trustee_name, successorTrustee: n.successor_trustee,
trustBeneficiaries: n.trust_beneficiaries, businessStructure: n.business_structure, businessInstrument: n.business_instrument,
successorOwner: n.successor_owner, buySellTerms: n.buy_sell_terms, custodyType: n.custody_type, platform: n.platform,
keyHolderName: n.key_holder_name, accessInstructionsRef: n.access_instructions_ref
}));
}
export async function addNomination(familyId, n) {
const { error } = await supabase.from('nf_nominations').insert({
family_id: familyId, linked_asset_id: n.linkedAssetId || null, type: n.type, institution: n.institution,
nominee_name: n.nomineeeName, reference_number: n.referenceNumber, trustee_name: n.trusteeName,
successor_trustee: n.successorTrustee, trust_beneficiaries: n.trustBeneficiaries, business_structure: n.businessStructure,
business_instrument: n.businessInstrument, successor_owner: n.successorOwner, buy_sell_terms: n.buySellTerms,
custody_type: n.custodyType, platform: n.platform, key_holder_name: n.keyHolderName, access_instructions_ref: n.accessInstructionsRef
});
if (error) throw error;
}
export async function removeNomination(id) {
const { error } = await supabase.from('nf_nominations').delete().eq('id', id);
if (error) throw error;
}
// ── Attestors & Death Trigger ──
export async function listAttestors(familyId) {
const { data, error } = await supabase.from('nf_attestors').select('*').eq('family_id', familyId).order('created_at');
if (error) throw error;
return data || [];
}
export async function addAttestor(familyId, name) {
const { data, error } = await supabase.from('nf_attestors').insert({ family_id: familyId, name }).select().single();
if (error) throw error;
return data;
}
export async function setAttestorConfirmed(id, confirmed) {
const { error } = await supabase.from('nf_attestors').update({ confirmed, confirmed_at: confirmed ? new Date().toISOString() : null }).eq('id', id);
if (error) throw error;
}
export async function updateAttestorName(id, name) {
const { error } = await supabase.from('nf_attestors').update({ name }).eq('id', id);
if (error) throw error;
}
export async function getDeathTrigger(familyId) {
const { data, error } = await supabase.from('nf_death_triggers').select('*').eq('family_id', familyId).maybeSingle();
if (error) throw error;
return data;
}
export async function upsertDeathTriggerSetup(familyId, fields) {
const { error } = await supabase.from('nf_death_triggers').upsert({
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' });
if (error) throw error;
}
/**
* Updates a single death-trigger setup field. Used instead of
* upsertDeathTriggerSetup for per-keystroke autosave on individual inputs —
* three fields each firing their own full-snapshot upsert can complete
* out of order over the network, and the last one to *finish* (not the last
* one to *fire*) silently clobbers the other two fields back to whatever
* stale value it captured. A single-column update can't do that.
*/
export async function updateDeathTriggerField(familyId, column, value) {
const { error } = await supabase.from('nf_death_triggers').upsert({
family_id: familyId, [column]: value, updated_at: new Date().toISOString()
}, { onConflict: 'family_id' });
if (error) throw error;
}
/** Only succeeds (per RLS) if the caller has role='owner' on this family. */
export async function fireDeathTrigger(familyId) {
const { error } = await supabase.from('nf_death_triggers').upsert({
family_id: familyId, triggered: true, triggered_at: new Date().toISOString(), updated_at: new Date().toISOString()
}, { onConflict: 'family_id' });
if (error) throw error;
}
export function estateTotal(assets) {
return (assets || []).reduce((sum, a) => sum + (Number(a.value) || 0) * (Number(a.ownershipShare ?? 100) / 100), 0);
}