a0c70e1411
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.
101 lines
3.1 KiB
JavaScript
101 lines
3.1 KiB
JavaScript
import { writable } from 'svelte/store';
|
|
import { supabase } from './supabaseClient.js';
|
|
import { currentUser } from './auth.js';
|
|
|
|
// The family currently being managed — an owner has exactly one (their own),
|
|
// an agent may have several and switches between them here.
|
|
export const activeFamilyId = writable(localStorage.getItem('nf.activeFamilyId') || null);
|
|
activeFamilyId.subscribe(id => {
|
|
if (id) localStorage.setItem('nf.activeFamilyId', id);
|
|
});
|
|
|
|
export async function createFamily(name) {
|
|
const user = currentUser();
|
|
if (!user) throw new Error('Not signed in');
|
|
const { data: fam, error: famErr } = await supabase
|
|
.from('nf_families')
|
|
.insert({ name, owner_id: user.id })
|
|
.select()
|
|
.single();
|
|
if (famErr) throw famErr;
|
|
|
|
const { error: memErr } = await supabase.from('nf_family_members').insert({
|
|
family_id: fam.id,
|
|
user_id: user.id,
|
|
invited_email: user.email,
|
|
role: 'owner',
|
|
status: 'active'
|
|
});
|
|
if (memErr) throw memErr;
|
|
|
|
activeFamilyId.set(fam.id);
|
|
return fam;
|
|
}
|
|
|
|
/** List every family this user belongs to, owner or agent, with their role. */
|
|
export async function listMyFamilies() {
|
|
const user = currentUser();
|
|
if (!user) return [];
|
|
const { data, error } = await supabase
|
|
.from('nf_family_members')
|
|
.select('role, status, family_id, nf_families(id, name)')
|
|
.eq('user_id', user.id)
|
|
.eq('status', 'active');
|
|
if (error) throw error;
|
|
return (data || []).map(r => ({ id: r.family_id, name: r.nf_families?.name, role: r.role }));
|
|
}
|
|
|
|
/** Pending invites addressed to this user's email, not yet accepted. */
|
|
export async function listPendingInvites() {
|
|
const user = currentUser();
|
|
if (!user) return [];
|
|
const { data, error } = await supabase
|
|
.from('nf_family_members')
|
|
.select('id, role, family_id, nf_families(name)')
|
|
.eq('invited_email', user.email)
|
|
.eq('status', 'invited');
|
|
if (error) throw error;
|
|
return (data || []).map(r => ({ membershipId: r.id, familyId: r.family_id, familyName: r.nf_families?.name, role: r.role }));
|
|
}
|
|
|
|
export async function acceptInvite(membershipId) {
|
|
const user = currentUser();
|
|
if (!user) throw new Error('Not signed in');
|
|
const { error } = await supabase
|
|
.from('nf_family_members')
|
|
.update({ user_id: user.id, status: 'active' })
|
|
.eq('id', membershipId);
|
|
if (error) throw error;
|
|
}
|
|
|
|
/** Owner invites an agent by email — creates a pending membership row. */
|
|
export async function inviteAgent(familyId, email) {
|
|
const { error } = await supabase.from('nf_family_members').insert({
|
|
family_id: familyId,
|
|
invited_email: email,
|
|
role: 'agent',
|
|
status: 'invited'
|
|
});
|
|
if (error) throw error;
|
|
}
|
|
|
|
export async function listFamilyMembers(familyId) {
|
|
const { data, error } = await supabase
|
|
.from('nf_family_members')
|
|
.select('id, invited_email, role, status')
|
|
.eq('family_id', familyId);
|
|
if (error) throw error;
|
|
return data || [];
|
|
}
|
|
|
|
export async function removeMember(membershipId) {
|
|
const { error } = await supabase.from('nf_family_members').delete().eq('id', membershipId);
|
|
if (error) throw error;
|
|
}
|
|
|
|
export function getActiveFamilyId() {
|
|
let id;
|
|
activeFamilyId.subscribe(v => id = v)();
|
|
return id;
|
|
}
|