Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesLeads CrmLeads & CRM — QA Plan

Leads & CRM — QA Plan

Pre-requisites

  • Org on a tier that includes the lead_management feature (TODO: confirm which tier this maps to — pro and above per apps/api/src/platform-tiers/).
  • A second org on lite (no lead_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

StepActionExpected
1Unauthenticated POST /leads/organization/:orgId with {name, email, phone, locale, note}.201/200 with formatted lead.
2DB: one leads row (status=new, source=minisite), one organization_leads row, one lead_status_events row (from=null, to=new).
3DB: one tasks row (type=contact_lead, priority=high, source=auto, dueDate=tomorrow, assigneeId=owner.userId).

G2 — Staff lists + filters

StepActionExpected
1Owner GET /organizations/:orgId/leads?page=1&limit=20.Paginated list, newest first.
2Filter ?status=new.Only new-status leads.
3Filter ?source=minisite.Only minisite-source leads.
4Filter ?search=alic (partial name).Returns matching leads (ILIKE on name/email/phone).

G3 — Detail with history

StepActionExpected
1GET /leads/:leadId.Returns lead with statusEvents[] populated.
2History contains at least one entry: from=null, to=new.

G4 — Update lead

StepActionExpected
1PATCH {status:'contacted'}.leads.status='contacted', status_changed_at=now. New lead_status_events row (from=new, to=contacted, changedByUserId=caller).
2PATCH {status:'trial_booked', trialDate:'2026-06-01T10:00Z'}.Both fields updated. Event row inserted. organization_leads.trial_date set.

G5 — Convert to member

StepActionExpected
1Owner 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'.
2organization_leads.converted_membership_id set. leads.status='converted'. Event row appended.
3MEMBERSHIP_ACTIVATED { source:'lead_converted' } emitted (verify via subscriber side effects — e.g. forms fan-out).

G6 — Analytics

StepActionExpected
1GET /leads/analytics.{total, newThisMonth, converted, conversionRate, bySource[], byStatus[]}.
2Convert one lead. Re-fetch.converted +1, conversionRate recomputed.

G7 — Meta Lead Ads ingestion (webhook)

StepActionExpected
1Owner/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.
2Submit a Meta lead-ads test lead for that Page./webhooks/meta/leadgen POST passes HMAC; a job is enqueued.
3After 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).
4Re-deliver the same webhook.No duplicate lead (idempotent on leadgen_id).

G8 — Click-to-WhatsApp ingestion (webhook)

StepActionExpected
1Owner/admin connects a number (Embedded Signup → finalize).Active integration_connections row (provider=whatsapp_cloud, external_account_id=phone_number_id).
2From a CTWA ad, message the number./webhooks/whatsapp POST passes HMAC; the message carries a referral; a job is enqueued.
3After 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.
4Send 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

StepActionExpected
1Open 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.
2Drag 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.
3Drag 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.
4Drag 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

StepActionExpected
1From 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.
2The 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.
3Staff sends a follow-up.The outbound carries In-Reply-To/References = the lead’s last inbound id (threads in their client).
4Open 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).
5Mark the thread read.unread_count=0; inbound messages get read_at.

Edge cases

E1 — Feature gate

StepActionExpected
1Org on lite (no lead_management). Owner GET /leads/analytics.403 from @RequiresFeature guard.
2Public minisite POST to the same org.200 — public endpoint NOT gated by @RequiresFeature (lives in the leads controller, not organization-leads).
3Upgrade to pro. Retry analytics.200.

E2 — Dedup

StepActionExpected
1POST minisite lead with email a@x.com.200.
2POST again with same email.409 {message:…, existingLeadId}.
3POST with same email to a different org.200 (per-org dedup).
4POST with the email blank but the phone matching an existing lead.409.

E3 — Convert already-converted

StepActionExpected
1Convert lead A.200.
2Convert lead A again.400 ‘Lead has already been converted’.

E4 — Convert with existing membership

StepActionExpected
1User 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

StepActionExpected
1Coach POST convert.403 ‘Only owners and admins can convert leads’.

E6 — Cross-org read

StepActionExpected
1Owner of org A GET /organizations/A/leads/{idFromB}.404 ‘Lead not found’ (LEFT JOIN constraint).

E7 — Auto-task failure (no owner)

StepActionExpected
1Org 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

StepActionExpected
1POST /leads/organization/:orgId with email: 'not-an-email'.400 with field errors object.

E9 — Member tries any CRM endpoint

StepActionExpected
1Member GET /leads.403 ‘Staff access required’ (from isStaffRole check after requireMembership).

E10 — Convert race

StepActionExpected
1Two 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

StepActionExpected
1PATCH {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

StepActionExpected
1POST /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

StepActionExpected
1Valid-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

StepActionExpected
1GET /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

StepActionExpected
1PATCH …/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.
2POST …/leads/:id/convert.Succeeds; membership created; status converted.

E16 — Assign a conversation to a non-member

StepActionExpected
1PATCH …/conversations/:id/assign { assignedToUserId: <user not in org> }.400 ‘Assignee is not a member of this org’; assignment unchanged.

E17 — Inbound webhook auth + idempotency

StepActionExpected
1POST /webhooks/email/inbound with a wrong/absent secret.403; nothing recorded. The secret query param is redacted in the access log.
2Deliver the same inbound twice (same message_id).One inbound message recorded (idempotent); no 500.
3Inbound to an unparseable / unknown contact+<id> address.200 {ok:true} ack; nothing recorded.

E18 — Hebrew inbound charset

StepActionExpected
1Reply 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 @Throttle decorator is applied).

i18n

LangStrings to verify
enleads.subtitle reads “Track and manage potential members”. leads.statuses.*, leads.sources.*.
heSame keys, Hebrew.
ruSame keys, Russian.

Expected vs actual

  • After public submit: one leads + one organization_leads + one lead_status_events row. One tasks row (or zero if no owner). PostHog/event tracker pipeline (if hooked) records the lead submit.
  • After convert: memberships row with source_lead_id=lead.id. MEMBERSHIP_ACTIVATED consumers ran.
  • After PATCH status: matching lead_status_events row with correct actor.
  • Analytics conversionRate formula: Math.round(converted / total * 100). Verify with hand-calc.