feat: add Khairat, emergency-contact auto-notify, and 5 lifestyle-companion tabs
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).
This commit is contained in:
+87
-2
@@ -40,8 +40,8 @@ export async function listTrustedContacts(familyId) {
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
export async function addTrustedContact(familyId, name, method) {
|
||||
const { error } = await supabase.from('nf_trusted_contacts').insert({ family_id: familyId, name, method });
|
||||
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) {
|
||||
@@ -49,6 +49,91 @@ export async function removeTrustedContact(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');
|
||||
|
||||
Reference in New Issue
Block a user