Leads & CRM — QA Plan
Pre-requisites
- Org on a tier that includes the
lead_managementfeature (TODO: confirm which tier this maps to —proand above perapps/api/src/platform-tiers/). - A second org on
lite(nolead_management) — for feature-gate testing. - Personas: owner, admin, coach, member.
- An active owner in the org so auto-task creation finds an assignee.
Golden paths
G1 — Public minisite submit
| Step | Action | Expected |
|---|---|---|
| 1 | Unauthenticated POST /leads/organization/:orgId with {name, email, phone, locale, note}. | 201/200 with formatted lead. |
| 2 | DB: one leads row (status=new, source=minisite), one organization_leads row, one lead_status_events row (from=null, to=new). | |
| 3 | DB: one tasks row (type=contact_lead, priority=high, source=auto, dueDate=tomorrow, assigneeId=owner.userId). |
G2 — Staff lists + filters
| Step | Action | Expected |
|---|---|---|
| 1 | Owner GET /organizations/:orgId/leads?page=1&limit=20. | Paginated list, newest first. |
| 2 | Filter ?status=new. | Only new-status leads. |
| 3 | Filter ?source=minisite. | Only minisite-source leads. |
| 4 | Filter ?search=alic (partial name). | Returns matching leads (ILIKE on name/email/phone). |
G3 — Detail with history
| Step | Action | Expected |
|---|---|---|
| 1 | GET /leads/:leadId. | Returns lead with statusEvents[] populated. |
| 2 | History contains at least one entry: from=null, to=new. |
G4 — Update lead
| Step | Action | Expected |
|---|---|---|
| 1 | PATCH {status:'contacted'}. | leads.status='contacted', status_changed_at=now. New lead_status_events row (from=new, to=contacted, changedByUserId=caller). |
| 2 | PATCH {status:'trial_booked', trialDate:'2026-06-01T10:00Z'}. | Both fields updated. Event row inserted. organization_leads.trial_date set. |
G5 — Convert to member
| Step | Action | Expected |
|---|---|---|
| 1 | Owner POST /leads/:leadId/convert with {firstName, lastName, email, role:'member'}. | New users row (or linked existing) + new memberships row with source_lead_id=leadId, status='active', role='member'. |
| 2 | organization_leads.converted_membership_id set. leads.status='converted'. Event row appended. | |
| 3 | MEMBERSHIP_ACTIVATED { source:'lead_converted' } emitted (verify via subscriber side effects — e.g. forms fan-out). |
G6 — Analytics
| Step | Action | Expected |
|---|---|---|
| 1 | GET /leads/analytics. | {total, newThisMonth, converted, conversionRate, bySource[], byStatus[]}. |
| 2 | Convert one lead. Re-fetch. | converted +1, conversionRate recomputed. |
G7 — Meta Lead Ads ingestion (webhook)
| Step | Action | Expected |
|---|---|---|
| 1 | Owner/admin connects a Page (OAuth → page-picker → finalize). | Active integration_connections row (provider=meta_lead_ads, external_account_id=page_id); Page subscribed to leadgen. |
| 2 | Submit a Meta lead-ads test lead for that Page. | /webhooks/meta/leadgen POST passes HMAC; a job is enqueued. |
| 3 | After processing. | One leads row (source=facebook/instagram), one organization_leads (with campaign_id), one lead_campaigns bucket, one lead_attribution row (external_lead_id=leadgen_id). |
| 4 | Re-deliver the same webhook. | No duplicate lead (idempotent on leadgen_id). |
G8 — Click-to-WhatsApp ingestion (webhook)
| Step | Action | Expected |
|---|---|---|
| 1 | Owner/admin connects a number (Embedded Signup → finalize). | Active integration_connections row (provider=whatsapp_cloud, external_account_id=phone_number_id). |
| 2 | From a CTWA ad, message the number. | /webhooks/whatsapp POST passes HMAC; the message carries a referral; a job is enqueued. |
| 3 | After processing. | One leads row (source=whatsapp, phone=wa_id), organization_leads.campaign_id set, lead_attribution with external_lead_id=ctwa_clid + CTWA headline/body. |
| 4 | Send an organic (non-ad) message from a new number. | No lead created — organic inbound is dropped (CTWA-only v1 rule). |
G9 — Board: drag a lead between stages
| Step | Action | Expected |
|---|---|---|
| 1 | Open the board view; first use seeds the default pipeline (New / Contacted / Trial booked / Converted / Lost), localized headers per viewer. | Five columns; leads grouped by stage. |
| 2 | Drag a lead from New → Contacted. | Card moves; organization_leads.stage_id + leads.status='contacted' updated; lead_status_events row appended; board and list views refresh. |
| 3 | Drag a lead into a Trial booked stage. | A date prompt opens; on confirm → PATCH { stageId, trialDate }, lead lands in the stage with a trial date. |
| 4 | Drag a not-yet-converted lead into a Converted stage. | The Convert dialog opens (no silent move); on confirm a membership is created and the lead lands in Converted. |
G10 — Inbox: email a lead, then receive the reply
| Step | Action | Expected |
|---|---|---|
| 1 | From the lead detail, compose + send an email. | A conversations thread + outbound conversation_messages row; Resend called with Reply-To: contact+<id>@<domain>; lead flips new→contacted. |
| 2 | The lead replies to that address. | The Email Worker POSTs /webhooks/email/inbound?secret=…; an inbound message is appended (quoted history stripped), unread_count +1, thread status='open'; conversation.inbound_received emitted. |
| 3 | Staff sends a follow-up. | The outbound carries In-Reply-To/References = the lead’s last inbound id (threads in their client). |
| 4 | Open the thread on a mobile viewport (below the md breakpoint). | The thread is reachable — the list swaps to the thread with a back button (not hidden). |
| 5 | Mark the thread read. | unread_count=0; inbound messages get read_at. |
Edge cases
E1 — Feature gate
| Step | Action | Expected |
|---|---|---|
| 1 | Org on lite (no lead_management). Owner GET /leads/analytics. | 403 from @RequiresFeature guard. |
| 2 | Public minisite POST to the same org. | 200 — public endpoint NOT gated by @RequiresFeature (lives in the leads controller, not organization-leads). |
| 3 | Upgrade to pro. Retry analytics. | 200. |
E2 — Dedup
| Step | Action | Expected |
|---|---|---|
| 1 | POST minisite lead with email a@x.com. | 200. |
| 2 | POST again with same email. | 409 {message:…, existingLeadId}. |
| 3 | POST with same email to a different org. | 200 (per-org dedup). |
| 4 | POST with the email blank but the phone matching an existing lead. | 409. |
E3 — Convert already-converted
| Step | Action | Expected |
|---|---|---|
| 1 | Convert lead A. | 200. |
| 2 | Convert lead A again. | 400 ‘Lead has already been converted’. |
E4 — Convert with existing membership
| Step | Action | Expected |
|---|---|---|
| 1 | User a@x.com is already a member of the org. Lead exists with same email. POST convert. | 400 ‘User is already a member of this organization’. |
E5 — Convert by coach
| Step | Action | Expected |
|---|---|---|
| 1 | Coach POST convert. | 403 ‘Only owners and admins can convert leads’. |
E6 — Cross-org read
| Step | Action | Expected |
|---|---|---|
| 1 | Owner of org A GET /organizations/A/leads/{idFromB}. | 404 ‘Lead not found’ (LEFT JOIN constraint). |
E7 — Auto-task failure (no owner)
| Step | Action | Expected |
|---|---|---|
| 1 | Org with no active owner (TODO: verify this state is reachable). POST a new lead. | Lead inserted successfully. No tasks row. No error. |
E8 — Invalid Zod payload
| Step | Action | Expected |
|---|---|---|
| 1 | POST /leads/organization/:orgId with email: 'not-an-email'. | 400 with field errors object. |
E9 — Member tries any CRM endpoint
| Step | Action | Expected |
|---|---|---|
| 1 | Member GET /leads. | 403 ‘Staff access required’ (from isStaffRole check after requireMembership). |
E10 — Convert race
| Step | Action | Expected |
|---|---|---|
| 1 | Two staff convert the same lead simultaneously. | One succeeds. The other’s existing membership check or the converted_membership_id UPDATE race races on second attempt and returns 400. (TODO: verify exact race resolution — no SERIALIZABLE wrapper observed.) |
E11 — Status update with no actual change
| Step | Action | Expected |
|---|---|---|
| 1 | PATCH {status:'new'} when status is already new. | UPDATE bumps updated_at but not status_changed_at (the service compares previous vs new and skips event insert if equal). |
E12 — Webhook with a bad signature
| Step | Action | Expected |
|---|---|---|
| 1 | POST /webhooks/meta/leadgen (or /webhooks/whatsapp) with a wrong/absent X-Hub-Signature-256. | Rejected by verifyMetaSignature; no job enqueued, no lead. |
E13 — Webhook for an unconnected account
| Step | Action | Expected |
|---|---|---|
| 1 | Valid-signature webhook whose page_id/phone_number_id has no active integration_connections row. | Dropped — nothing to route to; no lead. |
E14 — Hub-verify handshake
| Step | Action | Expected |
|---|---|---|
| 1 | GET /webhooks/whatsapp?hub.mode=subscribe&hub.verify_token=…&hub.challenge=123. | Echoes hub.challenge when the token matches WHATSAPP_VERIFY_TOKEN (else 403). Same for /webhooks/meta/leadgen with META_VERIFY_TOKEN. |
E15 — converted only via convert
| Step | Action | Expected |
|---|---|---|
| 1 | PATCH …/leads/:id { status:'converted' } (or drag into a Converted stage via the API). | 400 ‘A lead can only be marked converted by converting it to a member’; status unchanged. |
| 2 | POST …/leads/:id/convert. | Succeeds; membership created; status converted. |
E16 — Assign a conversation to a non-member
| Step | Action | Expected |
|---|---|---|
| 1 | PATCH …/conversations/:id/assign { assignedToUserId: <user not in org> }. | 400 ‘Assignee is not a member of this org’; assignment unchanged. |
E17 — Inbound webhook auth + idempotency
| Step | Action | Expected |
|---|---|---|
| 1 | POST /webhooks/email/inbound with a wrong/absent secret. | 403; nothing recorded. The secret query param is redacted in the access log. |
| 2 | Deliver the same inbound twice (same message_id). | One inbound message recorded (idempotent); no 500. |
| 3 | Inbound to an unparseable / unknown contact+<id> address. | 200 {ok:true} ack; nothing recorded. |
E18 — Hebrew inbound charset
| Step | Action | Expected |
|---|---|---|
| 1 | Reply with a base64/quoted-printable body in charset=iso-8859-8 (or windows-1255). | The thread shows correct Hebrew, not mojibake. |
Cross-persona
- Coach can list/get/update leads (including status changes) but cannot convert.
- Members get 403 from all CRM endpoints.
- Public form is reachable to anonymous users; rate-limiting (TODO: verify whether
@Throttledecorator is applied).
i18n
| Lang | Strings to verify |
|---|---|
| en | leads.subtitle reads “Track and manage potential members”. leads.statuses.*, leads.sources.*. |
| he | Same keys, Hebrew. |
| ru | Same keys, Russian. |
Expected vs actual
- After public submit: one
leads+ oneorganization_leads+ onelead_status_eventsrow. Onetasksrow (or zero if no owner). PostHog/event tracker pipeline (if hooked) records the lead submit. - After convert:
membershipsrow withsource_lead_id=lead.id.MEMBERSHIP_ACTIVATEDconsumers ran. - After PATCH status: matching
lead_status_eventsrow with correct actor. - Analytics
conversionRateformula:Math.round(converted / total * 100). Verify with hand-calc.