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