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

Leads & CRM — Behavior

State machine — lead_status

FromToTrigger
(insert)newAny lead-create path (minisite, manual, platform). Initial lead_status_events row inserted with from_status=null, to_status=new.
newcontactedPATCH …/leads/:id with status:'contacted'. Event row inserted.
contactedtrial_bookedPATCH with status:'trial_booked'. Typically combined with trialDate. Event row.
trial_bookedconvertedPOST …/leads/:id/convert (owner/admin only). Lead row’s status flipped to converted. Event row inserted. Membership created.
anylostPATCH 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 + idempotentorganization_leads.converted_membership_id set once. Re-attempt raises 400 ‘Lead has already been converted’.
  • Convert requires non-existing membership — if a memberships row already exists for the (email, org) pair (active or pending_invitation), convertLead raises 400. (TODO: verify — the code checks via users.emailmemberships.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 OrganizationLeadsController is gated by @RequiresFeature('lead_management'). Orgs without the feature get 403 from a separate guard.

Golden paths

G1 — Lead arrives via minisite

  1. Public POST /leads/organization/:orgId with {name, email, phone, locale, note}.
  2. Validation via createOrganizationLeadSchema (Zod).
  3. Service dedup against existing leads in this org by email or phone.
  4. INSERT leads row (source forced to minisite).
  5. INSERT organization_leads row.
  6. INSERT lead_status_events row (from=null, to=new, changedAt=lead.createdAt, actor null).
  7. Auto-task: find org owner; INSERT tasks row of type contact_lead, priority high, source auto, dueDate=tomorrow, assigneeId=owner.userId. Errors swallowed.
  8. Return formatted lead.

G2 — Staff manually creates a lead

  1. POST /organizations/:orgId/leads with body. Same path as G1 except:
    • Auth required (requireMembership + isStaffRole).
    • actorUserId resolved from caller for the status event.
    • Source defaults to manual (overridable via body).

G3 — Staff updates lead status

  1. PATCH …/leads/:leadId with {status: 'trial_booked', trialDate: '2026-06-01T10:00:00Z'}.
  2. Service fetches current status, then UPDATEs leads (and statusChangedAt).
  3. If status changed: INSERT lead_status_events row with from=prev, to=new, actor.
  4. UPDATEs organization_leads.trial_date if provided.
  5. Returns the lead with full event history.

G4 — Convert to member

  1. POST .../leads/:leadId/convert with {firstName, lastName, email, role?}.
  2. Owner/admin check.
  3. Load organization_leads row; reject if already converted.
  4. Find-or-create users row by email (uses onConflictDoNothing then re-finds; TODO: verify race-safety).
  5. Check no existing non-deleted membership for (user, org). Reject if present.
  6. INSERT memberships with role: input.role ?? 'member', status:'active', source_lead_id: leadId.
  7. UPDATE organization_leads.converted_membership_id = newMembership.id.
  8. UPDATE leads.status='converted', statusChangedAt=now.
  9. INSERT lead_status_events row.
  10. Emit MEMBERSHIP_ACTIVATED { source:'lead_converted', organizationId, userId, membershipId, role }.
  11. Return { membershipId, userId, role, status }.

G5 — Analytics

  1. GET .../leads/analytics.
  2. 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.
  3. Returns aggregated payload with conversionRate (rounded integer percent).

Edge cases & error states

ScenarioBehavior
Public minisite dup409 {message: 'A lead with this email or phone already exists in this organization', existingLeadId}.
Manual create dupSame 409.
Convert already-converted lead400 ‘Lead has already been converted’.
Convert when membership exists400 ‘User is already a member of this organization’.
Convert by coach403 ‘Only owners and admins can convert leads’.
Update by member403 ‘Staff access required’.
Cross-org access (any endpoint)404 ‘Lead not found’ (LEFT JOIN with organization_leads.organization_id=$orgId).
lead_management feature off403 from @RequiresFeature guard before the handler runs.
Public lead body invalid Zod400 with result.error.flatten().fieldErrors.
findOrCreate user raceonConflictDoNothing 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

OperationSide effects
Public lead submitleads + organization_leads + lead_status_events + auto tasks row.
Status updatelead_status_events row appended; statusChangedAt bumped.
ConvertNew memberships row; MEMBERSHIP_ACTIVATED event (downstream: forms fan-out, etc.); lead row updated; event row appended.
AnalyticsNone (read-only).

Permissions

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