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:
2026-08-14 14:25:26 +08:00
parent 7ce1121b94
commit 2549e9de0c
15 changed files with 1339 additions and 24 deletions
+87 -2
View File
@@ -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');