From 9eb2ce7ce319f5bf32eebd5cb69c6d14add5ae65 Mon Sep 17 00:00:00 2001 From: wmj Date: Fri, 14 Aug 2026 05:51:46 +0800 Subject: [PATCH] Per-member estate model: each family member authors their own Wassiyah/Waqf, mutawalli executes on any member's trigger, heir email notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- e2e-family-agent.cjs | 3 +- e2e-per-member.cjs | 150 +++++++++++++++++++ e2e-uat.cjs | 3 +- src/App.svelte | 12 +- src/lib/CoverageDashboard.svelte | 9 +- src/lib/DeathTrigger.svelte | 9 +- src/lib/FamilyManagement.svelte | 26 +++- src/lib/FamilyWaqfDesignator.svelte | 17 ++- src/lib/MutawalliDashboard.svelte | 225 ++++++++++++++++++++++++++++ src/lib/WassiyahGenerator.svelte | 24 ++- src/lib/db.js | 97 +++++++++--- src/lib/family.js | 13 +- src/lib/nonprobate.js | 9 +- 13 files changed, 539 insertions(+), 58 deletions(-) create mode 100644 e2e-per-member.cjs create mode 100644 src/lib/MutawalliDashboard.svelte diff --git a/e2e-family-agent.cjs b/e2e-family-agent.cjs index 6ade3ed..0acd573 100644 --- a/e2e-family-agent.cjs +++ b/e2e-family-agent.cjs @@ -65,7 +65,8 @@ async function main() { await ownerPage.locator('nav button[aria-label="Family"]').click(); await ownerPage.waitForTimeout(300); - await ownerPage.locator('.field:has-text("Invite an estate agent") input').fill(agentEmail); + await ownerPage.locator('.field:has-text("Invite by email") input').fill(agentEmail); + await ownerPage.locator('.field:has-text("Role") select').selectOption('agent'); await ownerPage.locator('button.btn-primary', { hasText: 'Send invite' }).click(); await ownerPage.waitForTimeout(800); const memberRowVisible = await ownerPage.locator('.member-row', { hasText: agentEmail }).isVisible(); diff --git a/e2e-per-member.cjs b/e2e-per-member.cjs new file mode 100644 index 0000000..a1f3642 --- /dev/null +++ b/e2e-per-member.cjs @@ -0,0 +1,150 @@ +// Verifies the per-member estate model end-to-end against the live backend: +// owner invites a plain "member" and an agent (mutawalli); the member authors +// their own Wassiyah, private to them; the mutawalli sees it on their +// dashboard and can fire the member's trigger; the member cannot fire their +// own trigger; the owner's own separate Wassiyah is not visible to the +// member as "theirs" (proves per-author isolation, not just per-family). +const { chromium } = require('playwright'); +const BASE = 'https://moslem04.falahos.my/'; +const results = []; +function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); } + +const OWNER = 'nurfalah.e2etest.owner@gmail.com'; +const AGENT = 'nurfalah.e2etest.agent@gmail.com'; +const MEMBER = 'nurfalah.e2etest.member@gmail.com'; +const PASSWORD = 'TestPassword123!'; +const familyName = `PerMember ${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + +async function signIn(page, email) { + await page.goto(BASE, { waitUntil: 'networkidle' }); + await page.locator('.field:has-text("Email") input').fill(email); + await page.locator('.field:has-text("Password") input').fill(PASSWORD); + await page.locator('button.btn-primary', { hasText: 'Sign in' }).click(); + await page.waitForTimeout(1500); +} + +async function main() { + const browser = await chromium.launch(); + + // ── Owner: create family, invite member + agent ── + const ownerCtx = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const ownerPage = await ownerCtx.newPage(); + await signIn(ownerPage, OWNER); + await ownerPage.locator('.field:has-text("Family name") input').fill(familyName); + await ownerPage.locator('button.btn-primary', { hasText: 'Create family' }).click(); + await ownerPage.waitForTimeout(1200); + + await ownerPage.locator('nav button[aria-label="Family"]').click(); + await ownerPage.waitForTimeout(500); + await ownerPage.locator('.field:has-text("Invite by email") input').fill(MEMBER); + await ownerPage.locator('.field:has-text("Role") select').selectOption('member'); + await ownerPage.locator('button.btn-primary', { hasText: 'Send invite' }).click(); + await ownerPage.waitForTimeout(600); + await ownerPage.locator('.field:has-text("Invite by email") input').fill(AGENT); + await ownerPage.locator('.field:has-text("Role") select').selectOption('agent'); + await ownerPage.locator('button.btn-primary', { hasText: 'Send invite' }).click(); + const bothInvited = await ownerPage.locator('.member-row', { hasText: MEMBER }).waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false); + record('Owner: invites both a member and an agent', bothInvited); + + // Owner writes their OWN wassiyah bequest (should stay private to owner) + await ownerPage.locator('nav button[aria-label="Wassiyah"]').click(); + await ownerPage.waitForTimeout(600); + await ownerPage.locator('.form-card .field:has-text("Recipient name") input').fill('Owner Charity'); + await ownerPage.locator('.form-card .field:has-text("Relation to you") input').fill('charity'); + await ownerPage.locator('.form-card .field:has-text("Description") input').fill('Owner personal bequest'); + await ownerPage.locator('.form-card .field:has-text("Value") input').fill('5000'); + await ownerPage.locator('.form-card button.btn-primary', { hasText: 'Add bequest' }).click(); + await ownerPage.waitForTimeout(600); + + // ── Member: accept, author own Wassiyah (should NOT see owner's bequest) ── + const memberCtx = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const memberPage = await memberCtx.newPage(); + await signIn(memberPage, MEMBER); + const inviteRow = memberPage.locator('.invite-row', { hasText: familyName }); + const inviteVisible = await inviteRow.waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false); + record('Member: sees pending invite', inviteVisible); + await inviteRow.locator('.btn-small', { hasText: 'Accept' }).click(); + await memberPage.locator('nav button[aria-label="Coverage"]').waitFor({ state: 'visible', timeout: 10000 }); + + await memberPage.locator('nav button[aria-label="Wassiyah"]').click(); + await memberPage.waitForTimeout(600); + const ownerBequestVisibleToMember = await memberPage.locator('.bequest-row', { hasText: 'Owner Charity' }).isVisible().catch(() => false); + record('Member: does NOT see owner\'s private bequest (per-author isolation)', !ownerBequestVisibleToMember); + + await memberPage.locator('.form-card .field:has-text("Recipient name") input').fill('My Nephew'); + await memberPage.locator('.form-card .field:has-text("Relation to you") input').fill('nephew'); + await memberPage.locator('.form-card .field:has-text("Description") input').fill('Member personal bequest'); + await memberPage.locator('.form-card .field:has-text("Value") input').fill('3000'); + await memberPage.locator('.form-card .field:has-text("Recipient email") input').fill('nephew@example.com'); + await memberPage.locator('.form-card button.btn-primary', { hasText: 'Add bequest' }).click(); + const memberBequestSaved = await memberPage.locator('.bequest-row', { hasText: 'My Nephew' }).waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false); + record('Member: authors their own Wassiyah bequest with heir email', memberBequestSaved); + + // ── Agent (mutawalli): accept, see member's doc on dashboard, cannot fire own trigger ── + const agentCtx = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const agentPage = await agentCtx.newPage(); + await signIn(agentPage, AGENT); + const agentInviteRow = agentPage.locator('.invite-row', { hasText: familyName }); + const agentInviteVisible = await agentInviteRow.waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false); + if (agentInviteVisible) { + await agentInviteRow.locator('.btn-small', { hasText: 'Accept' }).click(); + await agentPage.locator('nav button[aria-label="Coverage"]').waitFor({ state: 'visible', timeout: 10000 }); + } else { + // Agent may already belong to many families from prior test runs — switch to this one via Family tab + await agentPage.locator('nav button[aria-label="Family"]').click().catch(() => {}); + } + record('Agent: accepts mutawalli invite', agentInviteVisible); + + await agentPage.locator('nav button[aria-label="Mutawalli"]').click(); + await agentPage.waitForTimeout(800); + const memberChipVisible = await agentPage.locator('.member-chip', { hasText: MEMBER }).isVisible().catch(() => false); + record('Mutawalli dashboard: shows the member in the chip list', memberChipVisible); + + if (memberChipVisible) { + await agentPage.locator('.member-chip', { hasText: MEMBER }).click(); + await agentPage.waitForTimeout(600); + const memberDocVisible = await agentPage.locator('.doc-row', { hasText: 'My Nephew' }).isVisible().catch(() => false); + record('Mutawalli dashboard: sees the member\'s Wassiyah bequest (read access)', memberDocVisible); + + // Set up and fire the member's trigger + await agentPage.locator('.attestor-row input').nth(0).fill('Attestor One'); + await agentPage.locator('.attestor-row .confirm-btn').nth(0).click(); + await agentPage.waitForTimeout(400); + await agentPage.locator('.attestor-row input').nth(1).fill('Attestor Two'); + await agentPage.locator('.attestor-row .confirm-btn').nth(1).click(); + await agentPage.waitForTimeout(400); + await agentPage.locator('.field:has-text("Date of death") input').fill('2026-08-14'); + await agentPage.locator('.field:has-text("Death certificate reference") input').fill('DC-MEMBER-001'); + await agentPage.waitForTimeout(600); + const fireEnabled = await agentPage.locator('button.btn-danger-solid').isEnabled(); + record('Mutawalli: fire button enabled for the member (not self)', fireEnabled); + + if (fireEnabled) { + await agentPage.locator('button.btn-danger-solid').click(); + const triggeredVisible = await agentPage.locator('.triggered-banner').waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false); + record('Mutawalli: successfully fires the member\'s trigger', triggeredVisible); + + const notifyBtn = agentPage.locator('button.btn-secondary', { hasText: 'Notify heirs' }); + if (await notifyBtn.isVisible().catch(() => false)) { + await notifyBtn.click(); + await agentPage.locator('.notify-status', { hasText: /Sent|Failed/ }).waitFor({ state: 'visible', timeout: 15000 }).catch(() => {}); + const statusText = await agentPage.locator('.notify-status').textContent().catch(() => ''); + record('Mutawalli: heir notification call completes (Sent, or a clear "not configured" message)', statusText.includes('Sent') || statusText.includes('Failed'), statusText); + } + } + + // Now check the agent CANNOT fire their own trigger + await agentPage.locator('.member-chip', { hasText: AGENT }).click().catch(() => {}); + await agentPage.waitForTimeout(600); + const selfNoteVisible = await agentPage.locator('.self-note').isVisible().catch(() => false); + record('Mutawalli: sees "cannot fire own trigger" note when selecting self', selfNoteVisible); + } + + const passCount = results.filter(r => r.pass).length; + const failCount = results.length - passCount; + console.log(`\n${passCount} passed, ${failCount} failed, ${results.length} total`); + if (failCount > 0) results.filter(r => !r.pass).forEach(r => console.log(` - ${r.name}: ${r.detail}`)); + await browser.close(); + process.exit(failCount > 0 ? 1 : 0); +} +main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); }); diff --git a/e2e-uat.cjs b/e2e-uat.cjs index 82c211f..55bf7d2 100644 --- a/e2e-uat.cjs +++ b/e2e-uat.cjs @@ -89,8 +89,7 @@ async function main() { await page.locator('.form-card .field:has-text("Relation to you") input').fill('nephew'); await page.waitForTimeout(500); await page.locator('.form-card button.btn-primary', { hasText: 'Add bequest' }).click(); - await page.waitForTimeout(500); - const bequestRowVisible = await page.locator('.bequest-row', { hasText: 'My Son' }).isVisible(); + const bequestRowVisible = await page.locator('.bequest-row', { hasText: 'My Son' }).waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false); record('Wassiyah: valid non-heir bequest is added', bequestRowVisible); // Push over the 1/3 cap and check override gating diff --git a/src/App.svelte b/src/App.svelte index 0aab5fa..5e3070c 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -16,6 +16,7 @@ import AuthScreen from './lib/AuthScreen.svelte'; import FamilySwitcher from './lib/FamilySwitcher.svelte'; import FamilyManagement from './lib/FamilyManagement.svelte'; + import MutawalliDashboard from './lib/MutawalliDashboard.svelte'; let currentLang = $state('en'); lang.subscribe(v => currentLang = v); @@ -39,8 +40,8 @@ } }); - const tabs = ['Coverage', 'Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Claims (H2)', 'Family', 'Settings']; - const icons = ['🎯', '📊', '📁', '📜', '🎁', '⛲', '📇', '⚡', '🔗', '👥', '⚙️']; + const tabs = ['Coverage', 'Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Claims (H2)', 'Family', 'Settings']; + const icons = ['🎯', '📊', '📁', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🔗', '👥', '⚙️']; let activeTab = $state(0); function handleKeydown(e) { @@ -102,9 +103,10 @@ {:else if activeTab === 5} {:else if activeTab === 6} {:else if activeTab === 7} - {:else if activeTab === 8} - {:else if activeTab === 9} - {:else if activeTab === 10} + {:else if activeTab === 8} + {:else if activeTab === 9} + {:else if activeTab === 10} + {:else if activeTab === 11}

Settings

diff --git a/src/lib/CoverageDashboard.svelte b/src/lib/CoverageDashboard.svelte index 0aa021e..097e580 100644 --- a/src/lib/CoverageDashboard.svelte +++ b/src/lib/CoverageDashboard.svelte @@ -5,7 +5,7 @@ import { onMount } from 'svelte'; import { computeCoverage, suggestedChannel } from './nonprobate.js'; import { activeFamilyId } from './family.js'; - import { listAssets, listHibahGifts, listNominations, getWaqfDesignation } from './db.js'; + import { listAssets, listHibahGifts, listNominations, listAllWaqfForFamily } from './db.js'; import Disclaimer from './Disclaimer.svelte'; import InfoPanel from './InfoPanel.svelte'; @@ -16,10 +16,11 @@ async function refresh() { if (!familyId) return; - const [assets, gifts, nominations, waqf] = await Promise.all([ - listAssets(familyId), listHibahGifts(familyId), listNominations(familyId), getWaqfDesignation(familyId) + const [assets, gifts, nominations, waqfDesignations] = await Promise.all([ + listAssets(familyId), listHibahGifts(familyId), listNominations(familyId), listAllWaqfForFamily(familyId) ]); - coverage = computeCoverage({ assets, gifts, nominations, waqfCorpusId: waqf?.corpusAssetId ?? null }); + const waqfCorpusIds = waqfDesignations.map(w => w.corpus_asset_id).filter(Boolean); + coverage = computeCoverage({ assets, gifts, nominations, waqfCorpusIds }); } onMount(refresh); diff --git a/src/lib/DeathTrigger.svelte b/src/lib/DeathTrigger.svelte index 0690b57..2cf0c76 100644 --- a/src/lib/DeathTrigger.svelte +++ b/src/lib/DeathTrigger.svelte @@ -18,7 +18,7 @@ import { onMount } from 'svelte'; import { computeCoverage } from './nonprobate.js'; import { activeFamilyId, listMyFamilies } from './family.js'; - import { listAssets, listHibahGifts, listNominations, getWaqfDesignation, listAttestors, addAttestor as addAttestorDb, updateAttestorName, setAttestorConfirmed, getDeathTrigger, upsertDeathTriggerSetup, updateDeathTriggerField, fireDeathTrigger } from './db.js'; + import { listAssets, listHibahGifts, listNominations, listAllWaqfForFamily, listAttestors, addAttestor as addAttestorDb, updateAttestorName, setAttestorConfirmed, getDeathTrigger, upsertDeathTriggerSetup, updateDeathTriggerField, fireDeathTrigger } from './db.js'; import { supabase } from './supabaseClient.js'; import InfoPanel from './InfoPanel.svelte'; @@ -36,10 +36,11 @@ async function refresh() { if (!familyId) return; - const [assets, gifts, nominations, waqf, families] = await Promise.all([ - listAssets(familyId), listHibahGifts(familyId), listNominations(familyId), getWaqfDesignation(familyId), listMyFamilies() + const [assets, gifts, nominations, waqfDesignations, families] = await Promise.all([ + listAssets(familyId), listHibahGifts(familyId), listNominations(familyId), listAllWaqfForFamily(familyId), listMyFamilies() ]); - coverage = computeCoverage({ assets, gifts, nominations, waqfCorpusId: waqf?.corpusAssetId ?? null }); + const waqfCorpusIds = waqfDesignations.map(w => w.corpus_asset_id).filter(Boolean); + coverage = computeCoverage({ assets, gifts, nominations, waqfCorpusIds }); myRole = families.find(f => f.id === familyId)?.role; attestors = await listAttestors(familyId); diff --git a/src/lib/FamilyManagement.svelte b/src/lib/FamilyManagement.svelte index 7f61741..d788c92 100644 --- a/src/lib/FamilyManagement.svelte +++ b/src/lib/FamilyManagement.svelte @@ -1,6 +1,6 @@ + +
+
+

Mutawalli

+ +
+ + {#if myRole !== 'agent' && myRole !== 'owner'} +

Only the mutawalli (agent) or head of family can access this dashboard.

+ {:else} +
+ {#each members as m} + + {/each} +
+ + {#if selectedMemberId} +
+

{selectedEmail()}'s documents

+
+ Wassiyah bequests + {#each wassiyahByAuthor[selectedMemberId] || [] as b} +
{b.recipient} ({b.relation || '—'}) — {Number(b.value).toLocaleString()}{b.recipient_email ? ` · ${b.recipient_email}` : ''}
+ {:else}
None recorded
{/each} +
+
+ Waqf designations + {#each waqfByAuthor[selectedMemberId] || [] as w} +
Mutawalli: {w.mutawalli || '—'} · {(w.nf_waqf_beneficiaries || []).length} beneficiaries
+ {:else}
None recorded
{/each} +
+
+ +
+

Death trigger — {selectedEmail()}

+ {#if isSelf} +

This is your own trigger. You cannot fire it for yourself — another agent/owner or the head of family must.

+ {/if} + {#if trigger?.triggered} +
Triggered {trigger.triggered_at?.slice(0, 10)}.
+ + {#if notifyStatus}

{notifyStatus}

{/if} + {:else} + +
+ {#each attestors as a} +
+ saveAttestorName(a)} /> + +
+ {/each} +
+ + + {#if error}

{error}

{/if} + + {/if} +
+ {/if} + {/if} + + +
+ + diff --git a/src/lib/WassiyahGenerator.svelte b/src/lib/WassiyahGenerator.svelte index d888b1c..f27cb0c 100644 --- a/src/lib/WassiyahGenerator.svelte +++ b/src/lib/WassiyahGenerator.svelte @@ -2,29 +2,36 @@ import { onMount } from 'svelte'; import { oneThirdCap, isQuranicHeirRelation } from './calc/faraid.js'; import { activeFamilyId } from './family.js'; + import { session } from './auth.js'; import { listAssets, estateTotal, listWassiyahBequests, addWassiyahBequest, removeWassiyahBequest, getWassiyahSettings, upsertWassiyahSettings } from './db.js'; import Disclaimer from './Disclaimer.svelte'; import InfoPanel from './InfoPanel.svelte'; let familyId = $state(null); activeFamilyId.subscribe(v => familyId = v); + // Read live off the session store rather than snapshotting currentUser() once + // at mount — components here get destroyed/recreated on every tab switch, and + // a stale/null snapshot at one particular remount would silently break every + // write until the next remount. + let authorId = $state(null); + session.subscribe(v => authorId = v?.user?.id ?? null); let total = $state(0); let cap = $state(0); let jurisdiction = $state('UK'); let bequests = $state([]); - let form = $state({ recipient: '', relation: '', description: '', value: '' }); + let form = $state({ recipient: '', relation: '', description: '', value: '', recipientEmail: '' }); let witness1 = $state(''); let witness2 = $state(''); let overrideAcknowledged = $state(false); async function refresh() { - if (!familyId) return; + if (!familyId || !authorId) return; const assets = await listAssets(familyId); total = estateTotal(assets); cap = oneThirdCap(total); - bequests = await listWassiyahBequests(familyId); - const settings = await getWassiyahSettings(familyId); + bequests = await listWassiyahBequests(familyId, authorId); + const settings = await getWassiyahSettings(familyId, authorId); if (settings) { jurisdiction = settings.jurisdiction || 'UK'; witness1 = settings.witness1 || ''; @@ -33,10 +40,10 @@ } onMount(refresh); - $effect(() => { familyId; refresh(); }); + $effect(() => { familyId; authorId; refresh(); }); async function saveSettings() { - await upsertWassiyahSettings(familyId, { jurisdiction, witness1, witness2 }); + await upsertWassiyahSettings(familyId, authorId, { jurisdiction, witness1, witness2 }); } const bequestTotal = $derived(bequests.reduce((s, b) => s + Number(b.value), 0)); @@ -45,8 +52,8 @@ async function addBequest() { if (!form.recipient || !form.value) return; - await addWassiyahBequest(familyId, form); - form = { recipient: '', relation: '', description: '', value: '' }; + await addWassiyahBequest(familyId, authorId, form); + form = { recipient: '', relation: '', description: '', value: '', recipientEmail: '' }; await refresh(); } @@ -127,6 +134,7 @@ + {#if blockedRecipient}

This relation ("{form.relation}") matches an existing Quranic heir. You cannot bequeath to an existing heir via wassiyah — the fixed Faraid shares already govern their portion. This entry is blocked.

{:else} diff --git a/src/lib/db.js b/src/lib/db.js index 476ef7d..56f4305 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -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; +} diff --git a/src/lib/family.js b/src/lib/family.js index 34f9bac..bab9301 100644 --- a/src/lib/family.js +++ b/src/lib/family.js @@ -79,10 +79,21 @@ export async function inviteAgent(familyId, email) { if (error) throw error; } +/** Invite an ordinary family member — authors their own Wassiyah/Waqf, doesn't manage the family. */ +export async function inviteMember(familyId, email) { + const { error } = await supabase.from('nf_family_members').insert({ + family_id: familyId, + invited_email: email, + role: 'member', + 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') + .select('id, user_id, invited_email, role, status') .eq('family_id', familyId); if (error) throw error; return data || []; diff --git a/src/lib/nonprobate.js b/src/lib/nonprobate.js index 12a306e..c5431ba 100644 --- a/src/lib/nonprobate.js +++ b/src/lib/nonprobate.js @@ -23,12 +23,17 @@ // Takes already-fetched data rather than loading it itself — the caller (now // Supabase-backed via db.js instead of localStorage) owns the fetch. -export function computeCoverage({ assets = [], gifts = [], nominations = [], waqfCorpusId = null } = {}) { +export function computeCoverage({ assets = [], gifts = [], nominations = [], waqfCorpusId = null, waqfCorpusIds = null } = {}) { + // waqfCorpusIds covers the per-member model: ANY family member's waqf + // designation counts for coverage, not just one author's. waqfCorpusId + // (singular) is kept for backward compatibility with any caller still + // passing a single id. + const corpusIdSet = new Set(waqfCorpusIds || (waqfCorpusId ? [waqfCorpusId] : [])); const rows = assets.map(a => { const ownedValue = (Number(a.value) || 0) * (Number(a.ownershipShare ?? 100) / 100); const hibahMatch = gifts.find(g => g.linkedAssetId === a.id); const nominationMatch = nominations.find(n => n.linkedAssetId === a.id); - const isWaqfCorpus = waqfCorpusId === a.id; + const isWaqfCorpus = corpusIdSet.has(a.id); let channel = 'none'; let fast = false;