diff --git a/DEMO_DATA.md b/DEMO_DATA.md
index d7cd9cf..a6c776c 100644
--- a/DEMO_DATA.md
+++ b/DEMO_DATA.md
@@ -34,3 +34,25 @@ Ran `verify-demo-families.cjs` against the live app (not just SQL success) —
confirms sign-in, correct asset count, non-zero estate total, coverage
percentage, correct person count, and non-empty tree render for all 5
families. 35/35 passing.
+
+## Insurance/Takaful, Liabilities & Asset Verification (added later)
+
+Three additions, covered by `e2e-insurance-verification.cjs` (12/12):
+
+- **Insurance & Takaful tab** — per-member (`nf_insurance_policies`), same
+ author-owns-writes/family-reads pattern as Wassiyah/Waqf. Life, Takaful,
+ medical, or asset-specific policies with an optional proof document.
+- **Liabilities** (bottom of the Assets tab) — family-shared
+ (`nf_liabilities`), kept distinct from assets because Faraid requires debts
+ settled before distribution. Optionally linked to the asset it's secured
+ against, with a loan-document attachment.
+- **Asset ownership verification** — every asset in the Asset Registry can
+ now take a proof document (car grant, land title, cover note) and a
+ verified/unverified badge. Either the mutawalli/agent OR any other family
+ member can confirm — not gated to a single approver, since several demo
+ families have no agent assigned.
+- The Mutawalli dashboard's per-member document summary now also lists each
+ member's insurance/Takaful policies.
+- All proof documents (policies, liabilities, assets) share one private
+ Storage bucket, `nf-asset-documents`, with the same family-membership RLS
+ pattern used for person photos.
diff --git a/e2e-insurance-verification.cjs b/e2e-insurance-verification.cjs
new file mode 100644
index 0000000..c23f31d
--- /dev/null
+++ b/e2e-insurance-verification.cjs
@@ -0,0 +1,112 @@
+// Verifies the newest feature set end-to-end against the live backend:
+// per-member Insurance/Takaful policies, family-shared Liabilities, and
+// asset-ownership verification (proof document + dual-path confirm).
+const { chromium } = require('playwright');
+const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
+const BASE = 'https://moslem04.falahos.my/';
+const results = [];
+const consoleErrors = [];
+function record(name, pass, detail = '') { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); }
+
+async function main() {
+ const browser = await chromium.launch();
+ const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
+ page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
+ page.on('pageerror', e => consoleErrors.push(e.message));
+
+ await signInFreshFamily(page, BASE, 'e2e-insurance');
+
+ // ── Insurance & Takaful tab ──
+ await page.locator('nav button[aria-label="Insurance"]').click();
+ await page.waitForTimeout(600);
+
+ await page.locator('.form-card .field:has-text("Type") select').selectOption('takaful');
+ await page.locator('.form-card .field:has-text("Provider") input').fill('Etiqa Takaful');
+ await page.locator('.form-card .field:has-text("Policy number") input').fill('TKF-00123');
+ await page.locator('.form-card .field:has-text("Sum assured") input').fill('250000');
+ await page.locator('.form-card .field:has-text("Beneficiary name") input').fill('Aisha binti Rahman');
+ await page.locator('.form-card .field:has-text("Beneficiary relation") input').fill('spouse');
+ await page.locator('.form-card button.btn-primary', { hasText: 'Add policy' }).click();
+ await page.waitForTimeout(1000);
+
+ const policyRowVisible = await page.locator('.policy-row', { hasText: 'Etiqa Takaful' }).isVisible().catch(() => false);
+ record('Insurance: policy added and listed', policyRowVisible);
+
+ const totalCardVisible = await page.locator('.total-card strong', { hasText: '250,000' }).isVisible().catch(() => false);
+ record('Insurance: total sum assured reflects the added policy', totalCardVisible);
+
+ // Edit the policy
+ await page.locator('.policy-row', { hasText: 'Etiqa Takaful' }).locator('button[aria-label="Edit"]').click();
+ await page.waitForTimeout(300);
+ await page.locator('.form-card .field:has-text("Notes") input').fill('Renewed 2026');
+ await page.locator('.form-card button.btn-primary', { hasText: 'Update policy' }).click();
+ await page.waitForTimeout(800);
+ record('Insurance: editing a policy succeeds without error', consoleErrors.length === 0, consoleErrors.join(' || '));
+
+ // ── Asset ownership verification (Assets tab) ──
+ await page.locator('nav button[aria-label="Assets"]').click();
+ await page.waitForTimeout(600);
+ await page.locator('.form-card .field:has-text("Description") input').fill('Family sedan');
+ await page.locator('.form-card .field:has-text("Estimated value") input').fill('45000');
+ await page.locator('.form-card select').first().selectOption('Vehicle');
+ await page.locator('.form-card button.btn-primary', { hasText: 'Add asset' }).click();
+ await page.waitForTimeout(1000);
+
+ const unverifiedBadge = await page.locator('.verify-badge', { hasText: 'Unverified' }).isVisible().catch(() => false);
+ record('Asset Verification: new asset starts unverified', unverifiedBadge);
+
+ await page.locator('button.verify-toggle', { hasText: 'Confirm ownership' }).first().click();
+ await page.waitForTimeout(1000);
+ const verifiedBadge = await page.locator('.verify-badge.verified', { hasText: 'Verified' }).isVisible().catch(() => false);
+ record('Asset Verification: confirming ownership flips the badge to Verified', verifiedBadge);
+
+ await page.locator('button.verify-toggle', { hasText: 'Unverify' }).first().click();
+ await page.waitForTimeout(1000);
+ const revertedToUnverified = await page.locator('.verify-badge', { hasText: 'Unverified' }).isVisible().catch(() => false);
+ record('Asset Verification: unverify reverts the badge', revertedToUnverified);
+
+ // ── Liabilities (Assets tab, bottom section) ──
+ await page.locator('.liabilities-section .field:has-text("Type") select').selectOption('mortgage');
+ await page.locator('.liabilities-section .field:has-text("Lender") input').fill('Maybank Islamic');
+ await page.locator('.liabilities-section .field:has-text("Outstanding balance") input').fill('180000');
+ await page.locator('.liabilities-section .field:has-text("Linked asset") select').selectOption({ label: 'Family sedan' });
+ await page.locator('.liabilities-section button.btn-primary', { hasText: 'Add liability' }).click();
+ await page.waitForTimeout(1000);
+
+ const liabilityRowVisible = await page.locator('.liability-row', { hasText: 'Maybank Islamic' }).isVisible().catch(() => false);
+ record('Liabilities: liability added and listed', liabilityRowVisible);
+
+ const debtTotalVisible = await page.locator('.total-card.debt strong', { hasText: '180,000' }).isVisible().catch(() => false);
+ record('Liabilities: total outstanding debt card shows the correct sum', debtTotalVisible);
+
+ await page.locator('.liability-row', { hasText: 'Maybank Islamic' }).locator('button[aria-label="Remove"]').click();
+ await page.locator('.liability-row', { hasText: 'Maybank Islamic' }).waitFor({ state: 'detached', timeout: 10000 }).catch(() => {});
+ const liabilityRemoved = await page.locator('.liability-row', { hasText: 'Maybank Islamic' }).isVisible().catch(() => false);
+ record('Liabilities: removing a liability removes it from the list', !liabilityRemoved);
+
+ // ── Mutawalli dashboard should surface the insurance policy for this member (owner-only family: owner sees their own row) ──
+ await page.locator('nav button[aria-label="Mutawalli"]').click();
+ await page.waitForTimeout(800);
+ const mutawalliText = await page.locator('.module').innerText().catch(() => '');
+ const mutawalliGated = mutawalliText.includes('Only the mutawalli');
+ record('Mutawalli: owner-only family either sees the dashboard or the correct gate message', mutawalliGated || /INSURANCE/i.test(mutawalliText));
+
+ // ── Isolation: a second fresh family (same account) must not see this policy/liability/verification data ──
+ const isolationPage = await browser.newPage({ viewport: { width: 390, height: 844 } });
+ await signInFreshFamily(isolationPage, BASE, 'e2e-insurance-isolation');
+ await isolationPage.locator('nav button[aria-label="Insurance"]').click();
+ await isolationPage.waitForTimeout(600);
+ const leakedPolicy = await isolationPage.locator('.policy-row', { hasText: 'Etiqa Takaful' }).isVisible().catch(() => false);
+ record('Isolation: a different family sees none of this insurance data', !leakedPolicy);
+ await isolationPage.close();
+
+ record('No uncaught JS console errors during full session', consoleErrors.length === 0, consoleErrors.join(' || '));
+
+ await browser.close();
+ 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}`));
+ process.exit(failCount > 0 ? 1 : 0);
+}
+main().catch(e => { console.error('SCRIPT ERROR:', e); process.exit(2); });
diff --git a/src/App.svelte b/src/App.svelte
index e82ffcc..4659788 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -18,6 +18,7 @@
import FamilyManagement from './lib/FamilyManagement.svelte';
import MutawalliDashboard from './lib/MutawalliDashboard.svelte';
import FamilyTree from './lib/FamilyTree.svelte';
+ import InsurancePolicies from './lib/InsurancePolicies.svelte';
let currentLang = $state('en');
lang.subscribe(v => currentLang = v);
@@ -41,8 +42,8 @@
}
});
- const tabs = ['Coverage', 'Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Claims (H2)', 'Family', 'Settings'];
- const icons = ['🎯', '📊', '📁', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🔗', '👥', '⚙️'];
+ const tabs = ['Coverage', 'Faraid', 'Assets', 'Insurance', 'Wassiyah', 'Hibah', 'Family Waqf', 'Nominate', 'Trigger', 'Mutawalli', 'Tree', 'Claims (H2)', 'Family', 'Settings'];
+ const icons = ['🎯', '📊', '📁', '🛡️', '📜', '🎁', '⛲', '📇', '⚡', '🕋', '🌳', '🔗', '👥', '⚙️'];
let activeTab = $state(0);
function handleKeydown(e) {
@@ -99,16 +100,17 @@
{#if activeTab === 0}
{:else if activeTab === 1}
{:else if activeTab === 2}
- {:else if activeTab === 3}
- {:else if activeTab === 4}
- {: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 === 11}
- {:else if activeTab === 12}
+ {:else if activeTab === 3}
+ {:else if activeTab === 4}
+ {: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 === 11}
+ {:else if activeTab === 12}
+ {:else if activeTab === 13}
+
+
{a.verified ? '✓ Verified' : 'Unverified'}
+ {#if a.proofDocumentPath}
+
View proof
+
+ {:else}
+
+ {/if}
+
+
{:else}
No assets logged yet.
{/each}
@@ -156,6 +237,70 @@
{/each}
+
+
+ {#if totalDebt > 0}
+
Total outstanding debt{totalDebt.toLocaleString()}
+ {/if}
+
+
+
+
+
+
+
+
+ {#each liabilities as l (l.id)}
+
+
+ {l.liabilityType}
+ {l.lender}
+ {l.notes || '—'}
+
+
{Number(l.outstandingBalance || 0).toLocaleString()}
+
+
+
+
+
+
+ {#if l.documentPath}
+
View loan document
+
+ {:else}
+
+ {/if}
+
+ {:else}
+
No liabilities logged.
+ {/each}
+
+
@@ -187,5 +332,19 @@
.contacts-section h3 { font-size: 15px; color: #C9A84C; margin-bottom: 4px; }
.note { font-size: 11.5px; color: #8A8478; margin-bottom: 12px; }
.contact-row { display: flex; justify-content: space-between; padding: 8px 0; font-size: 13px; color: #E8E4DC; border-bottom: 1px solid rgba(255,255,255,0.06); }
+ .verify-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 0 0 12px; margin-top: -6px; border-bottom: 1px solid rgba(255,255,255,0.06); }
+ .verify-badge { font-size: 10.5px; padding: 2px 8px; border-radius: 999px; background: rgba(255,255,255,0.06); color: #8A8478; }
+ .verify-badge.verified { background: rgba(46,204,113,0.15); color: #2ECC71; }
+ .doc-link { font-size: 11.5px; color: #C9A84C; text-decoration: underline; }
+ .doc-remove, .verify-toggle { background: none; border: 1px solid rgba(255,255,255,0.15); color: #B8B2A6; font-size: 11px; padding: 3px 8px; border-radius: 6px; cursor: pointer; }
+ .doc-upload { font-size: 11px; color: #8A8478; cursor: pointer; }
+ .doc-upload input { display: none; }
+ .liabilities-section { margin-top: 24px; border-top: 1px solid rgba(201,168,76,0.15); padding-top: 16px; }
+ .section-header { display: flex; align-items: center; margin-bottom: 4px; }
+ .liabilities-section h3 { font-size: 15px; color: #C9A84C; margin-bottom: 0; font-family: 'DM Serif Display', serif; }
+ .total-card.debt { background: rgba(231,76,60,0.08); border-color: rgba(231,76,60,0.25); }
+ .total-card.debt strong { color: #E74C3C; }
+ .liability-row { display: flex; align-items: center; gap: 10px; padding: 12px 0 6px; border-bottom: none; }
+ .debt-value { color: #E74C3C; font-weight: 600; font-size: 13px; }
.contact-row button { background: none; border: none; color: #8A8478; cursor: pointer; }
diff --git a/src/lib/InsurancePolicies.svelte b/src/lib/InsurancePolicies.svelte
new file mode 100644
index 0000000..3e125b4
--- /dev/null
+++ b/src/lib/InsurancePolicies.svelte
@@ -0,0 +1,185 @@
+
+
+
+
+
Log your own life, Takaful, medical, or asset insurance — visible to your mutawalli/agent for claims.
+
+ {#if totalCover > 0}
+
Total sum assured{totalCover.toLocaleString()}
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#each policies as p (p.id)}
+
+
+ {typeLabel(p.policyType)}
+ {p.provider} {p.policyNumber ? `· ${p.policyNumber}` : ''}
+ Beneficiary: {p.beneficiaryName || '—'} {p.beneficiaryRelation ? `(${p.beneficiaryRelation})` : ''}
+ {#if p.expiryDate}Expires {p.expiryDate}{/if}
+
+ {#if p.sumAssured}
{Number(p.sumAssured).toLocaleString()}
{/if}
+
+
+
+
+
+
+ {#if p.documentPath}
+
View policy document
+
+ {:else}
+
+ {/if}
+
+ {:else}
+
No insurance or Takaful policies logged yet.
+ {/each}
+
+
+
+
+
+
diff --git a/src/lib/MutawalliDashboard.svelte b/src/lib/MutawalliDashboard.svelte
index bcdbfb9..acc6abb 100644
--- a/src/lib/MutawalliDashboard.svelte
+++ b/src/lib/MutawalliDashboard.svelte
@@ -7,7 +7,7 @@
import { activeFamilyId, listFamilyMembers, listMyFamilies } from './family.js';
import { currentUser } from './auth.js';
import {
- listAllWassiyahForFamily, listAllWaqfForFamily,
+ listAllWassiyahForFamily, listAllWaqfForFamily, listAllInsuranceForFamily,
getMemberTrigger, upsertMemberTriggerSetup, fireMemberTrigger,
listMemberAttestors, addMemberAttestor, updateMemberAttestorName, setMemberAttestorConfirmed,
notifyHeirs
@@ -23,6 +23,7 @@
let selectedMemberId = $state(null);
let wassiyahByAuthor = $state({});
let waqfByAuthor = $state({});
+ let insuranceByMember = $state({});
let trigger = $state(null);
let attestors = $state([]);
@@ -34,8 +35,8 @@
async function refresh() {
if (!familyId) return;
- const [allMembers, myFamilies, wassiyah, waqf] = await Promise.all([
- listFamilyMembers(familyId), listMyFamilies(), listAllWassiyahForFamily(familyId), listAllWaqfForFamily(familyId)
+ const [allMembers, myFamilies, wassiyah, waqf, insurance] = await Promise.all([
+ listFamilyMembers(familyId), listMyFamilies(), listAllWassiyahForFamily(familyId), listAllWaqfForFamily(familyId), listAllInsuranceForFamily(familyId)
]);
myRole = myFamilies.find(f => f.id === familyId)?.role;
members = allMembers.filter(m => m.status === 'active');
@@ -50,6 +51,9 @@
const waqfGrouped = {};
for (const w of waqf) (waqfGrouped[w.author_id] ??= []).push(w);
waqfByAuthor = waqfGrouped;
+ const insuranceGrouped = {};
+ for (const p of insurance) (insuranceGrouped[p.member_id] ??= []).push(p);
+ insuranceByMember = insuranceGrouped;
if (!selectedMemberId && members.length) selectedMemberId = members[0].user_id;
if (selectedMemberId) await loadMemberTrigger(selectedMemberId);
@@ -156,6 +160,12 @@
Mutawalli: {w.mutawalli || '—'} · {(w.nf_waqf_beneficiaries || []).length} beneficiaries
{:else}None recorded
{/each}
+
+
Insurance & Takaful
+ {#each insuranceByMember[selectedMemberId] || [] as p}
+
{p.policy_type} — {p.provider || '—'} {p.sum_assured ? `· ${Number(p.sum_assured).toLocaleString()}` : ''} · beneficiary {p.beneficiary_name || '—'}
+ {:else}
None recorded
{/each}
+
diff --git a/src/lib/db.js b/src/lib/db.js
index 4899d31..f2c418d 100644
--- a/src/lib/db.js
+++ b/src/lib/db.js
@@ -10,7 +10,10 @@ import { supabase } from './supabaseClient.js';
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 }));
+ return (data || []).map(a => ({
+ id: a.id, type: a.type, description: a.description, value: a.value, location: a.location, ownershipShare: a.ownership_share,
+ verified: a.verified, verifiedBy: a.verified_by, verifiedAt: a.verified_at, proofDocumentPath: a.proof_document_path
+ }));
}
export async function addAsset(familyId, userId, a) {
const { data, error } = await supabase.from('nf_assets').insert({
@@ -355,3 +358,135 @@ export async function removeRelationship(id) {
const { error } = await supabase.from('nf_relationships').delete().eq('id', id);
if (error) throw error;
}
+
+// ── Insurance / Takaful — per-member: each family member logs their own
+// policies, but any family member (and the mutawalli dashboard) can read them. ──
+export async function listInsurancePolicies(familyId, memberId) {
+ const { data, error } = await supabase.from('nf_insurance_policies').select('*').eq('family_id', familyId).eq('member_id', memberId).order('created_at');
+ if (error) throw error;
+ return (data || []).map(p => ({
+ id: p.id, policyType: p.policy_type, provider: p.provider, policyNumber: p.policy_number,
+ sumAssured: p.sum_assured, beneficiaryName: p.beneficiary_name, beneficiaryRelation: p.beneficiary_relation,
+ expiryDate: p.expiry_date, notes: p.notes, documentPath: p.document_path
+ }));
+}
+export async function addInsurancePolicy(familyId, memberId, createdBy, p) {
+ const { data, error } = await supabase.from('nf_insurance_policies').insert({
+ family_id: familyId, member_id: memberId, created_by: createdBy, policy_type: p.policyType,
+ provider: p.provider, policy_number: p.policyNumber, sum_assured: p.sumAssured ? Number(p.sumAssured) : null,
+ beneficiary_name: p.beneficiaryName, beneficiary_relation: p.beneficiaryRelation, expiry_date: p.expiryDate || null, notes: p.notes
+ }).select().single();
+ if (error) throw error;
+ return data;
+}
+export async function updateInsurancePolicy(id, p) {
+ const { error } = await supabase.from('nf_insurance_policies').update({
+ policy_type: p.policyType, provider: p.provider, policy_number: p.policyNumber,
+ sum_assured: p.sumAssured ? Number(p.sumAssured) : null, beneficiary_name: p.beneficiaryName,
+ beneficiary_relation: p.beneficiaryRelation, expiry_date: p.expiryDate || null, notes: p.notes,
+ updated_at: new Date().toISOString()
+ }).eq('id', id);
+ if (error) throw error;
+}
+export async function removeInsurancePolicy(id) {
+ const { error } = await supabase.from('nf_insurance_policies').delete().eq('id', id);
+ if (error) throw error;
+}
+/** For the mutawalli dashboard: every family member's insurance/Takaful policies. */
+export async function listAllInsuranceForFamily(familyId) {
+ const { data, error } = await supabase.from('nf_insurance_policies').select('*').eq('family_id', familyId).order('created_at');
+ if (error) throw error;
+ return data || [];
+}
+
+const POLICY_DOC_BUCKET = 'nf-asset-documents';
+export async function uploadPolicyDocument(familyId, policyId, file) {
+ const ext = (file.name.split('.').pop() || 'pdf').toLowerCase();
+ const path = `${familyId}/policy-${policyId}/${Date.now()}.${ext}`;
+ const { error: upErr } = await supabase.storage.from(POLICY_DOC_BUCKET).upload(path, file, { upsert: true, contentType: file.type });
+ if (upErr) throw upErr;
+ const { error: dbErr } = await supabase.from('nf_insurance_policies').update({ document_path: path, updated_at: new Date().toISOString() }).eq('id', policyId);
+ if (dbErr) throw dbErr;
+ return path;
+}
+export async function removePolicyDocument(policyId, docPath) {
+ if (docPath) await supabase.storage.from(POLICY_DOC_BUCKET).remove([docPath]);
+ const { error } = await supabase.from('nf_insurance_policies').update({ document_path: null, updated_at: new Date().toISOString() }).eq('id', policyId);
+ if (error) throw error;
+}
+
+// ── Liabilities — family-shared, same ownership model as Assets. Kept
+// distinct because Faraid requires debts settled before distribution. ──
+export async function listLiabilities(familyId) {
+ const { data, error } = await supabase.from('nf_liabilities').select('*').eq('family_id', familyId).order('created_at');
+ if (error) throw error;
+ return (data || []).map(l => ({
+ id: l.id, liabilityType: l.liability_type, lender: l.lender, outstandingBalance: l.outstanding_balance,
+ linkedAssetId: l.linked_asset_id, notes: l.notes, documentPath: l.document_path
+ }));
+}
+export async function addLiability(familyId, createdBy, l) {
+ const { data, error } = await supabase.from('nf_liabilities').insert({
+ family_id: familyId, created_by: createdBy, liability_type: l.liabilityType, lender: l.lender,
+ outstanding_balance: l.outstandingBalance ? Number(l.outstandingBalance) : null,
+ linked_asset_id: l.linkedAssetId || null, notes: l.notes
+ }).select().single();
+ if (error) throw error;
+ return data;
+}
+export async function updateLiability(id, l) {
+ const { error } = await supabase.from('nf_liabilities').update({
+ liability_type: l.liabilityType, lender: l.lender, outstanding_balance: l.outstandingBalance ? Number(l.outstandingBalance) : null,
+ linked_asset_id: l.linkedAssetId || null, notes: l.notes, updated_at: new Date().toISOString()
+ }).eq('id', id);
+ if (error) throw error;
+}
+export async function removeLiability(id) {
+ const { error } = await supabase.from('nf_liabilities').delete().eq('id', id);
+ if (error) throw error;
+}
+export async function uploadLiabilityDocument(familyId, liabilityId, file) {
+ const ext = (file.name.split('.').pop() || 'pdf').toLowerCase();
+ const path = `${familyId}/liability-${liabilityId}/${Date.now()}.${ext}`;
+ const { error: upErr } = await supabase.storage.from(POLICY_DOC_BUCKET).upload(path, file, { upsert: true, contentType: file.type });
+ if (upErr) throw upErr;
+ const { error: dbErr } = await supabase.from('nf_liabilities').update({ document_path: path, updated_at: new Date().toISOString() }).eq('id', liabilityId);
+ if (dbErr) throw dbErr;
+ return path;
+}
+export async function removeLiabilityDocument(liabilityId, docPath) {
+ if (docPath) await supabase.storage.from(POLICY_DOC_BUCKET).remove([docPath]);
+ const { error } = await supabase.from('nf_liabilities').update({ document_path: null, updated_at: new Date().toISOString() }).eq('id', liabilityId);
+ if (error) throw error;
+}
+
+// ── Asset ownership verification — a proof document (car grant, land title,
+// cover note, loan agreement) plus a dual-path confirm: either the
+// mutawalli/agent OR any other family member can mark an asset verified. ──
+export async function uploadAssetDocument(familyId, assetId, file) {
+ const ext = (file.name.split('.').pop() || 'pdf').toLowerCase();
+ const path = `${familyId}/asset-${assetId}/${Date.now()}.${ext}`;
+ const { error: upErr } = await supabase.storage.from(POLICY_DOC_BUCKET).upload(path, file, { upsert: true, contentType: file.type });
+ if (upErr) throw upErr;
+ const { error: dbErr } = await supabase.from('nf_assets').update({ proof_document_path: path }).eq('id', assetId);
+ if (dbErr) throw dbErr;
+ return path;
+}
+export async function removeAssetDocument(assetId, docPath) {
+ if (docPath) await supabase.storage.from(POLICY_DOC_BUCKET).remove([docPath]);
+ const { error } = await supabase.from('nf_assets').update({ proof_document_path: null }).eq('id', assetId);
+ if (error) throw error;
+}
+export async function getDocumentUrl(path) {
+ if (!path) return null;
+ const { data, error } = await supabase.storage.from(POLICY_DOC_BUCKET).createSignedUrl(path, 3600);
+ if (error) return null;
+ return data.signedUrl;
+}
+/** Verifying isn't restricted to the mutawalli — any other family member who has seen the proof can confirm. */
+export async function setAssetVerified(assetId, verifiedBy, verified) {
+ const { error } = await supabase.from('nf_assets').update({
+ verified, verified_by: verified ? verifiedBy : null, verified_at: verified ? new Date().toISOString() : null
+ }).eq('id', assetId);
+ if (error) throw error;
+}