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:
+188
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user