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.

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).

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.