9eb2ce7ce3
mutawalli executes on any member's trigger, heir email notification 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.
147 lines
8.5 KiB
JavaScript
147 lines
8.5 KiB
JavaScript
// Verifies the owner/agent family delegation flow end-to-end against the live
|
|
// Supabase-backed app: owner signs up, creates a family, invites an agent; agent
|
|
// signs up with that email, accepts the invite, sees the family in their
|
|
// dashboard, can add an asset — but cannot fire the death trigger (owner-only,
|
|
// enforced by RLS, not just hidden in the UI).
|
|
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 stamp = process.argv[2] || String(Math.floor(Math.random() * 1e9));
|
|
const ownerEmail = 'nurfalah.e2etest.owner@gmail.com';
|
|
const agentEmail = 'nurfalah.e2etest.agent@gmail.com';
|
|
const password = 'TestPassword123!';
|
|
const familyName = `Test Family ${stamp}`;
|
|
|
|
async function signUp(page, email, name) {
|
|
await page.goto(BASE, { waitUntil: 'networkidle' });
|
|
await page.locator('.mode-btn', { hasText: 'Create account' }).click();
|
|
await page.waitForTimeout(200);
|
|
await page.locator('.field:has-text("Full name") input').fill(name);
|
|
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: 'Create account' }).click();
|
|
await page.waitForTimeout(1500);
|
|
}
|
|
|
|
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 mode = process.argv[3];
|
|
|
|
if (mode === 'signup') {
|
|
const browser = await chromium.launch();
|
|
const ownerPage = await (await browser.newContext()).newPage();
|
|
await signUp(ownerPage, ownerEmail, 'Owner Test');
|
|
const agentPage = await (await browser.newContext()).newPage();
|
|
await signUp(agentPage, agentEmail, 'Agent Test');
|
|
await browser.close();
|
|
console.log(JSON.stringify({ ownerEmail, agentEmail, password, familyName }));
|
|
return;
|
|
}
|
|
|
|
// mode === 'flow' — accounts already confirmed via SQL
|
|
const browser = await chromium.launch();
|
|
|
|
// ── Owner: sign in, create family, invite agent ──
|
|
const ownerCtx = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
|
const ownerPage = await ownerCtx.newPage();
|
|
await signIn(ownerPage, ownerEmail);
|
|
const onSwitcher = await ownerPage.locator('.switcher-screen').isVisible().catch(() => false);
|
|
record('Owner: signs in and lands on family switcher (no family yet)', onSwitcher);
|
|
|
|
await ownerPage.locator('.field:has-text("Family name") input').fill(familyName);
|
|
await ownerPage.locator('button.btn-primary', { hasText: 'Create family' }).click();
|
|
await ownerPage.waitForTimeout(1000);
|
|
const onMainApp = await ownerPage.locator('nav button.tab', { hasText: 'Coverage' }).isVisible().catch(() => false);
|
|
record('Owner: creating a family lands on the main app', onMainApp);
|
|
|
|
await ownerPage.locator('nav button[aria-label="Family"]').click();
|
|
await ownerPage.waitForTimeout(300);
|
|
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();
|
|
record('Owner: inviting agent creates a pending member row', memberRowVisible);
|
|
|
|
// ── Agent: sign in, accept invite, see family, add an asset ──
|
|
const agentCtx = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
|
const agentPage = await agentCtx.newPage();
|
|
await signIn(agentPage, agentEmail);
|
|
const inviteRow = agentPage.locator('.invite-row', { hasText: familyName });
|
|
const inviteVisible = await inviteRow.waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
|
|
record('Agent: sees pending invite from owner on sign-in', inviteVisible);
|
|
|
|
await inviteRow.locator('.btn-small', { hasText: 'Accept' }).click();
|
|
const agentOnMainApp = await agentPage.locator('nav button[aria-label="Coverage"]').waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
|
|
record('Agent: accepting invite lands on the main app for that family', agentOnMainApp);
|
|
|
|
await agentPage.locator('nav button[aria-label="Assets"]').click();
|
|
await agentPage.waitForTimeout(400);
|
|
await agentPage.locator('.field:has-text("Description") input').fill('Agent-added asset');
|
|
await agentPage.locator('.field:has-text("Estimated value") input').fill('50000');
|
|
await agentPage.locator('button.btn-primary', { hasText: 'Add asset' }).click();
|
|
const assetAddedByAgent = await agentPage.locator('.asset-row', { hasText: 'Agent-added asset' }).waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
|
|
record('Agent: can add an asset to the family estate', assetAddedByAgent);
|
|
|
|
// Owner should see the agent-added asset too (shared, live data — not per-device)
|
|
await ownerPage.locator('nav button[aria-label="Assets"]').click();
|
|
const ownerSeesAgentAsset = await ownerPage.locator('.asset-row', { hasText: 'Agent-added asset' }).waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
|
|
record('Owner: sees the asset the agent just added (shared family data)', ownerSeesAgentAsset);
|
|
|
|
// ── Agent tries the Death Trigger — should be visibly restricted ──
|
|
await agentPage.locator('nav button[aria-label="Trigger"]').click();
|
|
const agentRestrictionVisible = await agentPage.locator('.agent-restriction').waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
|
|
record('Agent: sees explicit "owner-only to fire" restriction notice', agentRestrictionVisible);
|
|
|
|
// Fill everything needed and confirm the fire button itself won't work for the agent
|
|
const attestorInputs = agentPage.locator('.attestor-row input');
|
|
await attestorInputs.nth(0).fill('Attestor A');
|
|
await agentPage.locator('.attestor-row .confirm-btn').nth(0).click();
|
|
await agentPage.waitForTimeout(500);
|
|
await attestorInputs.nth(1).fill('Attestor B');
|
|
await agentPage.locator('.attestor-row .confirm-btn').nth(1).click();
|
|
await agentPage.waitForTimeout(500);
|
|
await agentPage.locator('.field:has-text("Date of death") input').fill('2026-08-13');
|
|
await agentPage.locator('.field:has-text("Death certificate reference") input').fill('DC-TEST-999');
|
|
await agentPage.waitForTimeout(500);
|
|
const fireBtnDisabledForAgent = await agentPage.locator('button.btn-danger-solid').isDisabled();
|
|
record('Agent: fire-trigger button stays disabled even with all fields filled (role check)', fireBtnDisabledForAgent);
|
|
|
|
// ── Owner fires it — should work, enforced by RLS as role=owner ──
|
|
await ownerPage.locator('nav button[aria-label="Trigger"]').click();
|
|
await ownerPage.waitForTimeout(1000);
|
|
const ownerAttestorInputs = ownerPage.locator('.attestor-row input');
|
|
const attestorCount = await ownerAttestorInputs.count();
|
|
record('Owner: sees the same attestor data the agent entered (shared)', attestorCount >= 2 && (await ownerAttestorInputs.nth(0).inputValue()) === 'Attestor A');
|
|
|
|
await ownerPage.waitForFunction(() => {
|
|
const btn = document.querySelector('button.btn-danger-solid');
|
|
return btn && !btn.disabled;
|
|
}, { timeout: 10000 }).catch(() => {});
|
|
const ownerFireBtnEnabled = await ownerPage.locator('button.btn-danger-solid').isEnabled();
|
|
record('Owner: fire-trigger button is enabled for the owner role', ownerFireBtnEnabled);
|
|
|
|
if (ownerFireBtnEnabled) {
|
|
await ownerPage.locator('button.btn-danger-solid').click();
|
|
const triggeredBannerVisible = await ownerPage.locator('.triggered-banner').waitFor({ state: 'visible', timeout: 10000 }).then(() => true).catch(() => false);
|
|
record('Owner: successfully fires the death trigger', triggeredBannerVisible);
|
|
}
|
|
|
|
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); });
|