Leads & CRM — Behavior
State machine — lead_status
| From | To | Trigger |
|---|---|---|
| (insert) | new | Any lead-create path (minisite, manual, platform). Initial lead_status_events row inserted with from_status=null, to_status=new. |
new | contacted | PATCH …/leads/:id with status:'contacted'. Event row inserted. |
contacted | trial_booked | PATCH with status:'trial_booked'. Typically combined with trialDate. Event row. |
trial_booked | converted | POST …/leads/:id/convert (owner/admin only). Lead row’s status flipped to converted. Event row inserted. Membership created. |
| any | lost | PATCH with status:'lost'. Event row. |
| any | (any other) | Allowed — there are no service-level transition guards beyond the convert endpoint requiring owner/admin. |
statusChangedAt (on leads) is bumped on every status change; serves as the “last touched” timestamp distinct from updated_at.
Invariants
- Org isolation on org-leads — every read/write requires
requireMembership(orgId, clerkId)+ staff role check (isStaffRole(role)returns true for owner/admin/coach). Convert requires owner/admin. - Dedup is per-org — same email + phone in different orgs is allowed; same email or phone in the same org returns 409 with
existingLeadId. - Convert is one-way + idempotent —
organization_leads.converted_membership_idset once. Re-attempt raises 400 ‘Lead has already been converted’. - Convert requires non-existing membership — if a
membershipsrow already exists for the (email, org) pair (active or pending_invitation),convertLeadraises 400. (TODO: verify — the code checks viausers.email→memberships.user_id.) - Auto-task never blocks lead create — wrapped in try/catch. If no owner-role membership is found in the org, the task is silently skipped.
- Platform feature gate — every method on
OrganizationLeadsControlleris gated by@RequiresFeature('lead_management'). Orgs without the feature get 403 from a separate guard.
Golden paths
G1 — Lead arrives via minisite
- Public
POST /leads/organization/:orgIdwith{name, email, phone, locale, note}. - Validation via
createOrganizationLeadSchema(Zod). - Service dedup against existing leads in this org by email or phone.
- INSERT
leadsrow (source forced tominisite). - INSERT
organization_leadsrow. - INSERT
lead_status_eventsrow (from=null, to=new, changedAt=lead.createdAt, actor null). - Auto-task: find org owner; INSERT
tasksrow of typecontact_lead, priorityhigh, sourceauto,dueDate=tomorrow,assigneeId=owner.userId. Errors swallowed. - Return formatted lead.
G2 — Staff manually creates a lead
POST /organizations/:orgId/leadswith body. Same path as G1 except:- Auth required (
requireMembership+isStaffRole). actorUserIdresolved from caller for the status event.- Source defaults to
manual(overridable via body).
- Auth required (
G3 — Staff updates lead status
PATCH …/leads/:leadIdwith{status: 'trial_booked', trialDate: '2026-06-01T10:00:00Z'}.- Service fetches current status, then UPDATEs
leads(andstatusChangedAt). - If status changed: INSERT
lead_status_eventsrow withfrom=prev, to=new, actor. - UPDATEs
organization_leads.trial_dateif provided. - Returns the lead with full event history.
G4 — Convert to member
POST .../leads/:leadId/convertwith{firstName, lastName, email, role?}.- Owner/admin check.
- Load
organization_leadsrow; reject if already converted. - Find-or-create
usersrow by email (usesonConflictDoNothingthen re-finds; TODO: verify race-safety). - Check no existing non-deleted membership for (user, org). Reject if present.
- INSERT
membershipswithrole: input.role ?? 'member', status:'active', source_lead_id: leadId. - UPDATE
organization_leads.converted_membership_id = newMembership.id. - UPDATE
leads.status='converted', statusChangedAt=now. - INSERT
lead_status_eventsrow. - Emit
MEMBERSHIP_ACTIVATED { source:'lead_converted', organizationId, userId, membershipId, role }. - Return
{ membershipId, userId, role, status }.
G5 — Analytics
GET .../leads/analytics.- Service runs 3 queries scoped to this org’s leads:
- Totals with
count(*)+count(*) filter (where status='converted')+count(*) filter (where createdAt >= startOfMonth). - Group-by source.
- Group-by status.
- Totals with
- Returns aggregated payload with
conversionRate(rounded integer percent).
Edge cases & error states
| Scenario | Behavior |
|---|---|
| Public minisite dup | 409 {message: 'A lead with this email or phone already exists in this organization', existingLeadId}. |
| Manual create dup | Same 409. |
| Convert already-converted lead | 400 ‘Lead has already been converted’. |
| Convert when membership exists | 400 ‘User is already a member of this organization’. |
| Convert by coach | 403 ‘Only owners and admins can convert leads’. |
| Update by member | 403 ‘Staff access required’. |
| Cross-org access (any endpoint) | 404 ‘Lead not found’ (LEFT JOIN with organization_leads.organization_id=$orgId). |
lead_management feature off | 403 from @RequiresFeature guard before the handler runs. |
| Public lead body invalid Zod | 400 with result.error.flatten().fieldErrors. |
findOrCreate user race | onConflictDoNothing may return undefined; service re-fetches via email; if still missing, 400 ‘Could not create user’. |
| Auto-task failure (no owner / DB error) | Silent — wrapped in try/catch. Lead create still succeeds. |
Side effects
| Operation | Side effects |
|---|---|
| Public lead submit | leads + organization_leads + lead_status_events + auto tasks row. |
| Status update | lead_status_events row appended; statusChangedAt bumped. |
| Convert | New memberships row; MEMBERSHIP_ACTIVATED event (downstream: forms fan-out, etc.); lead row updated; event row appended. |
| Analytics | None (read-only). |
Permissions
| Endpoint | Public | owner | admin | coach | member |
|---|---|---|---|---|---|
POST /leads (platform) | ✓ | ||||
POST /leads/organization/:orgId (minisite) | ✓ | ||||
GET /organizations/:orgId/leads/analytics | ✓ | ✓ | ✓ | ✗ | |
GET /organizations/:orgId/leads | ✓ | ✓ | ✓ | ✗ | |
GET …/leads/:id | ✓ | ✓ | ✓ | ✗ | |
POST …/leads (manual) | ✓ | ✓ | ✓ | ✗ | |
PATCH …/leads/:id | ✓ | ✓ | ✓ | ✗ | |
POST …/leads/:id/convert | ✓ | ✓ | ✗ | ✗ |
All non-public endpoints additionally require the lead_management feature on the org’s platform_tier.