2549e9de0c
Big round covering two different asks: Khairat/emergency infrastructure (tightly coupled to what's already built) and a Muslim-lifestyle companion surface (a genuinely different product, added at the user's explicit request after being offered a smaller scope). Khairat & emergency contacts: - nf_khairat_memberships (per-member scheme membership) + nf_emergency_fund (per-family shared reserve tracker) — records intent/contacts only, never holds or moves real money. - nf_trusted_contacts gains category (mosque/police/ambulance/hospital/ khairat/family/other) and email columns. - New notify-emergency-contacts Edge Function, auto-invoked the moment a mutawalli fires a member's death trigger (the one place in this app where auto-send-on-trigger is actually correct), alongside — not instead of — the existing heir notification. Verified with a live fire end-to-end (e2e-emergency-notify.cjs, 5/5). Lifestyle-companion tabs — all computed/searched live, nothing fabricated: - Qibla: pure great-circle bearing math to the Kaaba from geolocation, no API/key. Verified against Kuala Lumpur (293°, matches expected ~292°). - Prayer Times: client-side astronomical calculation (single-pass solar position, MWL angles), sanity-checked against known KL/London times before shipping. - Locate: mosque/halal/cemetery search via the free, keyless OpenStreetMap Overpass API — real community-sourced results only, honest empty state when nothing's mapped nearby. - Quran: Surah list + Arabic/translation via the free alquran.cloud API, fetched fresh each time, nothing stored in this app's own database. - Neighbourhood: a join-by-code community announcement board — a genuinely separate multi-tenant concept from the estate-planning family structure, scoped to the user account. Found and fixed a real UX gap during testing: the create/join form was only reachable with zero existing neighbourhoods, with no way to join a second one. Also found and fixed a real bug: the heir-notify and emergency-notify status messages shared the same CSS class, breaking any script (including the pre-existing e2e-per-member.cjs) that targeted '.notify-status' without further filtering — gave each its own distinguishing class. Covered by e2e-khairat-lifestyle.cjs (13/13) and e2e-emergency-notify.cjs (5/5). Full regression: 316/316 across all suites (several transient flakes under heavy mail-relay load during the sweep, all confirmed clean on rerun — one led to the real class-collision fix above).
658 lines
35 KiB
JavaScript
658 lines
35 KiB
JavaScript
// 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,
|
|
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({
|
|
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, category, email) {
|
|
const { error } = await supabase.from('nf_trusted_contacts').insert({ family_id: familyId, name, method, category: category || 'family', email: email || null });
|
|
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;
|
|
}
|
|
|
|
// ── Khairat — per-member scheme membership + a per-family emergency fund. ──
|
|
export async function listKhairatMemberships(familyId) {
|
|
const { data, error } = await supabase.from('nf_khairat_memberships').select('*').eq('family_id', familyId).order('created_at');
|
|
if (error) throw error;
|
|
return (data || []).map(k => ({
|
|
id: k.id, memberId: k.member_id, schemeName: k.scheme_name, organization: k.organization,
|
|
membershipNumber: k.membership_number, contactPhone: k.contact_phone, contactEmail: k.contact_email, notes: k.notes
|
|
}));
|
|
}
|
|
export async function addKhairatMembership(familyId, memberId, createdBy, fields) {
|
|
const { error } = await supabase.from('nf_khairat_memberships').insert({
|
|
family_id: familyId, member_id: memberId, created_by: createdBy, scheme_name: fields.schemeName,
|
|
organization: fields.organization, membership_number: fields.membershipNumber,
|
|
contact_phone: fields.contactPhone, contact_email: fields.contactEmail, notes: fields.notes
|
|
});
|
|
if (error) throw error;
|
|
}
|
|
export async function removeKhairatMembership(id) {
|
|
const { error } = await supabase.from('nf_khairat_memberships').delete().eq('id', id);
|
|
if (error) throw error;
|
|
}
|
|
|
|
export async function getEmergencyFund(familyId) {
|
|
const { data, error } = await supabase.from('nf_emergency_fund').select('*').eq('family_id', familyId).maybeSingle();
|
|
if (error) throw error;
|
|
return data ? { targetAmount: data.target_amount, currentBalance: data.current_balance, notes: data.notes } : null;
|
|
}
|
|
export async function upsertEmergencyFund(familyId, fields) {
|
|
const { error } = await supabase.from('nf_emergency_fund').upsert({
|
|
family_id: familyId, target_amount: fields.targetAmount ? Number(fields.targetAmount) : null,
|
|
current_balance: Number(fields.currentBalance) || 0, notes: fields.notes || null, updated_at: new Date().toISOString()
|
|
}, { onConflict: 'family_id' });
|
|
if (error) throw error;
|
|
}
|
|
|
|
/** Sends an emergency notice to trusted contacts in the given categories via the notify-emergency-contacts Edge Function. */
|
|
export async function notifyEmergencyContacts(memberId, familyId, categories) {
|
|
const { data, error } = await supabase.functions.invoke('notify-emergency-contacts', { body: { memberId, familyId, categories } });
|
|
if (error) throw error;
|
|
return data;
|
|
}
|
|
|
|
// ── Neighbourhood announcement board — join-by-code community, independent
|
|
// of the family estate structure. ──
|
|
function genJoinCode() {
|
|
return Array.from({ length: 6 }, () => 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'[Math.floor(Math.random() * 32)]).join('');
|
|
}
|
|
export async function createNeighbourhood(name, userId) {
|
|
const joinCode = genJoinCode();
|
|
const { data, error } = await supabase.from('nf_neighbourhoods').insert({ name, join_code: joinCode, created_by: userId }).select().single();
|
|
if (error) throw error;
|
|
await supabase.from('nf_neighbourhood_members').insert({ neighbourhood_id: data.id, user_id: userId });
|
|
return data;
|
|
}
|
|
export async function joinNeighbourhoodByCode(joinCode, userId, displayName) {
|
|
const { data: n, error: findErr } = await supabase.from('nf_neighbourhoods').select('*').eq('join_code', joinCode.toUpperCase().trim()).maybeSingle();
|
|
if (findErr) throw findErr;
|
|
if (!n) throw new Error('No neighbourhood found with that code.');
|
|
const { error } = await supabase.from('nf_neighbourhood_members').insert({ neighbourhood_id: n.id, user_id: userId, display_name: displayName || null });
|
|
if (error) throw error;
|
|
return n;
|
|
}
|
|
export async function listMyNeighbourhoods(userId) {
|
|
const { data, error } = await supabase.from('nf_neighbourhood_members').select('neighbourhood_id, nf_neighbourhoods(id, name, join_code)').eq('user_id', userId);
|
|
if (error) throw error;
|
|
return (data || []).map(r => ({ id: r.neighbourhood_id, name: r.nf_neighbourhoods?.name, joinCode: r.nf_neighbourhoods?.join_code }));
|
|
}
|
|
export async function leaveNeighbourhood(neighbourhoodId, userId) {
|
|
const { error } = await supabase.from('nf_neighbourhood_members').delete().eq('neighbourhood_id', neighbourhoodId).eq('user_id', userId);
|
|
if (error) throw error;
|
|
}
|
|
export async function listNeighbourhoodPosts(neighbourhoodId) {
|
|
const { data, error } = await supabase.from('nf_neighbourhood_posts').select('*').eq('neighbourhood_id', neighbourhoodId).order('created_at', { ascending: false });
|
|
if (error) throw error;
|
|
return data || [];
|
|
}
|
|
export async function addNeighbourhoodPost(neighbourhoodId, authorId, authorName, title, body) {
|
|
const { error } = await supabase.from('nf_neighbourhood_posts').insert({ neighbourhood_id: neighbourhoodId, author_id: authorId, author_name: authorName, title, body });
|
|
if (error) throw error;
|
|
}
|
|
export async function removeNeighbourhoodPost(id) {
|
|
const { error } = await supabase.from('nf_neighbourhood_posts').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, 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, email: b.beneficiary_email }))
|
|
};
|
|
}
|
|
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,
|
|
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, 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, 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;
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
// ── 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, recipientEmail: b.recipient_email }));
|
|
}
|
|
export async function addWassiyahBequest(familyId, authorId, b) {
|
|
const { error } = await supabase.from('nf_wassiyah_bequests').insert({
|
|
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;
|
|
}
|
|
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, 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, authorId, fields) {
|
|
const { error } = await supabase.from('nf_wassiyah_settings').upsert({
|
|
family_id: familyId, author_id: authorId, jurisdiction: fields.jurisdiction, witness1: fields.witness1, witness2: fields.witness2,
|
|
testator_name: fields.testatorName, updated_at: new Date().toISOString()
|
|
}, { 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 || [];
|
|
}
|
|
|
|
// ── Professional review workflow — a structured status, not a marketplace.
|
|
// Any family member can request review of their own Wassiyah; anyone (in
|
|
// practice the mutawalli/agent, coordinating with an actual lawyer outside
|
|
// the app) can mark it reviewed once done.
|
|
export async function requestWassiyahReview(familyId, authorId) {
|
|
const { error } = await supabase.from('nf_wassiyah_settings').upsert({
|
|
family_id: familyId, author_id: authorId, review_status: 'requested', requested_at: new Date().toISOString(), updated_at: new Date().toISOString()
|
|
}, { onConflict: 'family_id,author_id' });
|
|
if (error) throw error;
|
|
}
|
|
export async function markWassiyahReviewed(familyId, authorId, reviewerName) {
|
|
const { error } = await supabase.from('nf_wassiyah_settings').upsert({
|
|
family_id: familyId, author_id: authorId, review_status: 'reviewed', reviewer_name: reviewerName, reviewed_at: new Date().toISOString(), updated_at: new Date().toISOString()
|
|
}, { onConflict: 'family_id,author_id' });
|
|
if (error) throw error;
|
|
}
|
|
/** For the mutawalli dashboard: every family member's Wassiyah settings (jurisdiction, review status). */
|
|
export async function listAllWassiyahSettingsForFamily(familyId) {
|
|
const { data, error } = await supabase.from('nf_wassiyah_settings').select('*').eq('family_id', familyId);
|
|
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, familyId) {
|
|
const { data, error } = await supabase.functions.invoke('notify-heirs', { body: { memberId, familyId } });
|
|
if (error) throw error;
|
|
return data;
|
|
}
|
|
|
|
// ── Family Genealogy ──
|
|
// nf_people are lightweight — most real tree nodes (grandparents, deceased
|
|
// relatives, young children) never have a login. linked_user_id is optional.
|
|
const PHOTO_BUCKET = 'nf-people-photos';
|
|
|
|
export async function listPeople(familyId) {
|
|
const { data, error } = await supabase.from('nf_people').select('*').eq('family_id', familyId).order('created_at');
|
|
if (error) throw error;
|
|
return (data || []).map(p => ({
|
|
id: p.id, familyId: p.family_id, linkedUserId: p.linked_user_id, fullName: p.full_name,
|
|
gender: p.gender, birthDate: p.birth_date, deathDate: p.death_date, notes: p.notes, photoPath: p.photo_path
|
|
}));
|
|
}
|
|
|
|
export async function addPerson(familyId, createdBy, fields) {
|
|
const { data, error } = await supabase.from('nf_people').insert({
|
|
family_id: familyId, created_by: createdBy, full_name: fields.fullName, gender: fields.gender || null,
|
|
birth_date: fields.birthDate || null, death_date: fields.deathDate || null, notes: fields.notes || null
|
|
}).select().single();
|
|
if (error) throw error;
|
|
return data;
|
|
}
|
|
|
|
export async function updatePerson(id, fields) {
|
|
const { error } = await supabase.from('nf_people').update({
|
|
full_name: fields.fullName, gender: fields.gender || null, birth_date: fields.birthDate || null,
|
|
death_date: fields.deathDate || null, notes: fields.notes || null, updated_at: new Date().toISOString()
|
|
}).eq('id', id);
|
|
if (error) throw error;
|
|
}
|
|
|
|
export async function removePerson(id) {
|
|
const { error } = await supabase.from('nf_people').delete().eq('id', id);
|
|
if (error) throw error;
|
|
}
|
|
|
|
/** Uploads a photo for a person and stores its storage path on the record. Path: {familyId}/{personId}/{filename} so RLS can check family membership from the path alone. */
|
|
export async function uploadPersonPhoto(familyId, personId, file) {
|
|
const ext = (file.name.split('.').pop() || 'jpg').toLowerCase();
|
|
const path = `${familyId}/${personId}/${Date.now()}.${ext}`;
|
|
const { error: upErr } = await supabase.storage.from(PHOTO_BUCKET).upload(path, file, { upsert: true, contentType: file.type });
|
|
if (upErr) throw upErr;
|
|
const { error: dbErr } = await supabase.from('nf_people').update({ photo_path: path, updated_at: new Date().toISOString() }).eq('id', personId);
|
|
if (dbErr) throw dbErr;
|
|
return path;
|
|
}
|
|
|
|
export async function removePersonPhoto(personId, photoPath) {
|
|
if (photoPath) await supabase.storage.from(PHOTO_BUCKET).remove([photoPath]);
|
|
const { error } = await supabase.from('nf_people').update({ photo_path: null, updated_at: new Date().toISOString() }).eq('id', personId);
|
|
if (error) throw error;
|
|
}
|
|
|
|
/** Signed URL (private bucket) valid for 1 hour — call fresh each time the photo is displayed. */
|
|
export async function getPersonPhotoUrl(photoPath) {
|
|
if (!photoPath) return null;
|
|
const { data, error } = await supabase.storage.from(PHOTO_BUCKET).createSignedUrl(photoPath, 3600);
|
|
if (error) return null;
|
|
return data.signedUrl;
|
|
}
|
|
|
|
export async function listRelationships(familyId) {
|
|
const { data, error } = await supabase.from('nf_relationships').select('*').eq('family_id', familyId);
|
|
if (error) throw error;
|
|
return (data || []).map(r => ({ id: r.id, personAId: r.person_a_id, personBId: r.person_b_id, type: r.relationship_type }));
|
|
}
|
|
|
|
export async function addRelationship(familyId, personAId, personBId, type) {
|
|
const { error } = await supabase.from('nf_relationships').insert({ family_id: familyId, person_a_id: personAId, person_b_id: personBId, relationship_type: type });
|
|
if (error) throw error;
|
|
}
|
|
|
|
export async function removeRelationship(id) {
|
|
const { error } = await supabase.from('nf_relationships').delete().eq('id', id);
|
|
if (error) throw error;
|
|
}
|
|
|
|
// ── Sadaqah — private daily journal. Family visibility is limited to
|
|
// streak counts via nf_family_sadaqah_streaks (never amounts/causes/notes).
|
|
export async function listSadaqahLog(familyId, memberId) {
|
|
const { data, error } = await supabase.from('nf_sadaqah_log').select('*').eq('family_id', familyId).eq('member_id', memberId).order('log_date', { ascending: false });
|
|
if (error) throw error;
|
|
return (data || []).map(s => ({
|
|
id: s.id, logDate: s.log_date, cause: s.cause, amount: s.amount,
|
|
inMemoryOfPersonId: s.in_memory_of_person_id, notes: s.notes
|
|
}));
|
|
}
|
|
export async function addSadaqahEntry(familyId, memberId, fields) {
|
|
const { error } = await supabase.from('nf_sadaqah_log').insert({
|
|
family_id: familyId, member_id: memberId, log_date: fields.logDate || new Date().toISOString().slice(0, 10),
|
|
cause: fields.cause || null, amount: fields.amount ? Number(fields.amount) : null,
|
|
in_memory_of_person_id: fields.inMemoryOfPersonId || null, notes: fields.notes || null
|
|
});
|
|
if (error) throw error;
|
|
}
|
|
export async function removeSadaqahEntry(id) {
|
|
const { error } = await supabase.from('nf_sadaqah_log').delete().eq('id', id);
|
|
if (error) throw error;
|
|
}
|
|
/** Family-visible streak counts only — no amounts, causes, or notes. */
|
|
export async function getFamilySadaqahStreaks(familyId) {
|
|
const { data, error } = await supabase.rpc('nf_family_sadaqah_streaks', { p_family_id: familyId });
|
|
if (error) throw error;
|
|
return (data || []).map(r => ({ memberId: r.member_id, currentStreak: r.current_streak, gaveToday: r.gave_today }));
|
|
}
|
|
/** Count of sadaqah given in memory of a person — count only, shown on their Tree card. */
|
|
export async function getMemorialSadaqahCount(personId) {
|
|
const { data, error } = await supabase.rpc('nf_memorial_sadaqah_count', { p_person_id: personId });
|
|
if (error) throw error;
|
|
return data || 0;
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
// ── Zakat — per-member, one live record per family/member pair. ──
|
|
export async function getZakatRecord(familyId, memberId) {
|
|
const { data, error } = await supabase.from('nf_zakat_records').select('*').eq('family_id', familyId).eq('member_id', memberId).maybeSingle();
|
|
if (error) throw error;
|
|
if (!data) return null;
|
|
return {
|
|
cash: data.cash, gold: data.gold, silver: data.silver, businessAssets: data.business_assets,
|
|
investments: data.investments, otherZakatable: data.other_zakatable,
|
|
deductibleLiabilities: data.deductible_liabilities, nisabThreshold: data.nisab_threshold
|
|
};
|
|
}
|
|
export async function upsertZakatRecord(familyId, memberId, fields) {
|
|
const { error } = await supabase.from('nf_zakat_records').upsert({
|
|
family_id: familyId, member_id: memberId,
|
|
cash: Number(fields.cash) || 0, gold: Number(fields.gold) || 0, silver: Number(fields.silver) || 0,
|
|
business_assets: Number(fields.businessAssets) || 0, investments: Number(fields.investments) || 0,
|
|
other_zakatable: Number(fields.otherZakatable) || 0, deductible_liabilities: Number(fields.deductibleLiabilities) || 0,
|
|
nisab_threshold: Number(fields.nisabThreshold) || 0, updated_at: new Date().toISOString()
|
|
}, { onConflict: 'family_id,member_id' });
|
|
if (error) throw error;
|
|
}
|