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', a board move into a contacted-category stage, or automatically on the first outbound message in the lead’s conversation (markContacted, best-effort). Event row inserted. |
contacted | trial_booked | PATCH/board move with status/category:'trial_booked'. Typically combined with trialDate. Event row. |
trial_booked | converted | Only POST …/leads/:id/convert (owner/admin). A bare PATCH status:'converted' or a board move into a converted stage is rejected (400) — see invariant below. Membership created; event row inserted. |
| any | lost | PATCH/board move with status/category:'lost'. Event row. |
| any open state | customer | Automatically on course-purchase activation (LeadPurchaseService → markCustomer: moves the lead to its pipeline’s customer-category stage when one exists, explicit match only), or manually via PATCH/board/bulk move. The one-time-purchase “won” — paid, not a member. Freely settable, unlike converted (no membership behind it to protect). Never applied to already-customer/converted leads. |
| any | (any other open stage) | Allowed — stages within new/contacted/trial_booked/lost move freely (it’s a manual sales process). The only guarded transition is into converted. |
statusChangedAt (on leads) is bumped on every status change; serves as the “last touched” timestamp distinct from updated_at. Status is derived from the lead’s current pipeline stage category; moving a lead between stages keeps leads.status in sync and appends a lead_status_events row (with from_stage_id/to_stage_id).
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. The samerequireStaffgate guards every conversations + pipelines endpoint. convertedonly via convert —updateLeadrejects (400) a move/status-set into aconverted-category stage unless the lead is already converted (repositioning). The only path that setsconvertedisconvertLead, which creates the membership — so a “converted” lead always has one. Enforced server-side, covering the board, the AIupdatetool, and direct API calls.- Stage ↔ status are one move —
stageIdwins; a barestatusresolves to the matching-category stage in the lead’s current pipeline; both update together so stage and status can’t drift. Default pipeline is seeded lazily (ensureDefaultPipeline, race-safe via the partial-unique default index). - 1:1 sends respect the contact’s gate —
sendMessageon a lead conversation consultsLeadConsentService.isAllowedfor consent-gated channels (email/sms/whatsapp) and 403s with theLEAD_CHANNEL_BLOCKEDmarker when blocked. Member email conversations independently checknotification_prefs.newMessage.emailand 403 withMEMBER_EMAIL_OPTED_OUTon an explicit opt-out; missing preference rows/keys remain enabled. Both checks happen before the outbound message insert. Internalmanualnotes are never gated. The web mirrors each state up front: lead consent in the lead surfaces, member reachability in the Messages compose/thread surfaces. - Conversation assignee must be a member —
assignrejects anassignedToUserIdthat isn’t an active member of the org (integrity + prevents leaking a non-member’s name via the inbox list). - Inbound is idempotent + re-opens —
recordInbounddedups on(channel, external_message_id)(viaonConflictDoNothing),+1sunread_count, setsstatus='open', and emitsconversation.inbound_received. Threads resolve from the per-conversationReply-To(contact+<id>@<domain>). - Bulk email is broadcast-on-send, thread-on-reply (FIT-224) — a bulk send writes no conversation and advances no stage (a blast ≠ 200 conversations). Each send instead carries a lead-keyed
Reply-To(contact+ol-<organizationLeadId>@<domain>). On a reply,recordInboundForOrgLeadresolves org + lead (never cross-org), lazily finds-or-creates the lead’s email thread, appends the reply, and runsmarkContacted(new→contacted) — because a reply is real engagement. No conversation exists until the lead actually replies. - Dedup is per-org — same email + phone in different orgs is allowed; same email or phone in the same org returns 409 with
existingLeadId— unless the match is a closed lead (lost / frozen / archived), which is reopened instead. See Returning leads. - Automated writes match on a person, not a field —
convertLeadOnSelfSignup,demoteReleasedJoinMembershipand course checkout (findLeadIdForUser) match throughisSameLeadPerson(lead-identity.ts), never a bareemail OR phone: equal email is conclusive; equal phone counts only when the name agrees (or the lead has no name) and no second, differently-named person in the org answers to that number. Names agree (namesAgree) when every word of the shorter name is in the longer one (“Dana” / “Dana Cohen”), or, across Hebrew and Latin, when the first names start with a matching letter (“דור וידר” / “Dor Vider”); a nickname (“יוני” / “יהונתן”) does not agree. Candidates are prefiltered onlower(email)and the phone’s last seven digits, so hand-typed or imported rows (“050-1234567”) are still found. Candidates resolve oldest-first. Reopening is change-detecting: a lead already in the target stage records nolead_status_eventsrow and does not repeat a sentence its note already ends with, and note appends happen in SQL so simultaneous releases cannot overwrite each other. Rationale and the production incident: subscriptions-plans/behavior.md. - A released join is a recovered signup, not a cold lead — the lead
demoteReleasedJoinMembershipcreates or reopens carriesrecoveredSignup: trueon itslead.created/lead.status_changedevent.new_leadautomations skip it. The staffnewLeadmail is unaffected. See staff-newsletter behavior. - Convert is one-way + idempotent —
organization_leads.converted_membership_idset once. Re-attempt raises 400 ‘Lead has already been converted’. - Convert onto an existing member links, never duplicates — when the email already belongs to an active member of the org (
users.email→memberships.user_id),convertLeadcreates nothing: it returnsoutcome:'existing_member'with the member’s name andexistingMember.membershipIdand writes nothing. The write isPOST …/leads/:id/link-member { membershipId }(linkLeadToMember, owner/admin): the lead is linked to that membership (converted_membership_id, converted stage, status event with the staff actor,LEAD_STATUS_CHANGED) andoutcome:'attached'comes back. NoMEMBERSHIP_ACTIVATED— the membership’s welcome fan-out already ran. The membership must be in the org, not soft-deleted, andactive; a non-active existing membership (cancelled,pending_invitation, …) keeps today’s 400 ‘User is already a member of this organization’ on convert and 400 ‘Member is not active’ on link. This is the repair path for a lead the self-signup match missed (no email on the lead, a name in another script). - Link candidates are offered before staff type anything —
GET …/leads/:id/link-candidateslists active members who share the lead’s canonical email or phone (matchedBy:'email'|'phone', email first; empty for a converted lead). Matching is deliberately looser thanisSameLeadPerson: this is a list a person reads and chooses from, not a write, so a shared family number shows every member on it and the name is what staff decide by. The convert dialog shows them as “Possible matches” with a Link button each; every link — from a candidate or from a typed email — goes through the same confirmation step naming the member. - Staff can search the roster to link by hand — for the member no rule can see: a lead saved under a nickname (“Dima” for Dmitry), on a second phone, with no email.
GET …/link-candidates?search=<text>(both the leads and Sales workspace routes, same guards as the candidates) returns up to 20 active members matching it instead of the automatic list: every term must hit first name, last name, email or phone; a search with 4+ digits also matches the stored phone by digits, trunk zero dropped (050-111-2233finds+972501112233); multilingual name candidates join the results. Rows carrymatchedBy:'search'; a converted lead still gets[]. The convert dialog shows a “Link to an existing member” search box under the possible matches; a picked result goes through the same confirm-then-link-memberstep. - Sales workspace candidates use the shared shape —
GET …/sales-workspace/leads/:id/link-candidatesanswers{ data: { candidates } }like the leads route. The web convert dialog reads only this route; a bare array would read as no candidates. - 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. - Primary pipeline is swappable —
POST …/leads/:leadId/pipelines/:pipelineId/set-primarypromotes a secondary membership to the primary placement (the oneleads.statusmirrors) and demotes the old primary to a removable membership. Status re-mirrors from the new primary stage’s category (status event + automations on a real change); the convert invariant holds (a swap can’t fabricateconverted). Aprimary_changedtimeline event records it. Bridge until the opportunity-model refactor removes the primary/secondary asymmetry (planned ADR). - Pipeline win coverage is either-flavor — a pipeline must keep a
newstage, aloststage, and at least one WIN stage:convertedORcustomer. A course funnel can drop its Converted stage entirely once it has a Customer stage (and the last win stage can flip between the two flavors).convertLeadlands the new member on the pipeline’sconvertedstage, else itscustomerstage, else leaves the lead’s stage untouched — never an unrelated column. - Bulk stage move honors the convert invariant —
POST …/leads/bulk-move-stagemoves leads already in the pipeline (primary via the fullmoveLeadToStagemachinery, secondary via a batch membership update). Unconverted leads aimed at aconvertedstage and leads not in the pipeline are skipped (reported in the result), never silently converted or added. - Course purchases tag the contact + move it to Customer (FIT-227) — on entitlement activation (free checkout immediately; paid via the payment webhook’s pending→active flip)
LeadPurchaseServiceattaches a per-coursepurchase-category tag (find-or-create, named after the course) to the buyer’s lead in the seller’s org, appends apurchasetimeline event, and callsmarkCustomer(auto-move to the pipeline’scustomerstage, when one exists). Analytics countscustomeras a win alongsideconverted(conversionRate = (converted + customers) / total). Webhook replays don’t duplicate (only the real pending→active transition records). Buyers with no lead in the org (plain members) are no-ops. This is what makes “email everyone about course X except its buyers” reliable via the bulk-email exclude-tags picker — no stage hygiene required. Backfill for pre-existing entitlements:scripts/backfill-purchase-tags.ts. - Unified timeline (FIT-227) —
GET …/leads/:idreturnstimeline: status flips +lead_activity_events(pipeline add/remove, stage moves incl. same-category and secondary-pipeline ones, purchases, per-recipient bulk-email sends) + inbox messages (last 100), merged newest-first with pre-resolved display names. Activity writes are best-effort: they never fail the operation they record. - Bulk email stage scoping — the pipeline target accepts
stageIds[](multi-select; union’d with the legacy singlestageId) filtered on the lead’s effective stage in that pipeline (coalesce(membership.stage_id, primary.stage_id)). - Ingestion routes on an active connection — inbound webhooks resolve the owning org via
integration_connections.external_account_id(page_id/phone_number_id). No active connection → the event is dropped (no feature flag involved). Processors are idempotent on the external lead id and dedup the lead by phone/email (reusingcreateLead). Connect/disconnect requires owner/admin. - The connect surface is flagged, the pipe is not — every card on
/dashboard/settings/integrationsis behind the rollout key of the connector it configures: Meta Lead Ads and WhatsApp onmeta-integrations, Fizikal onfizikal-freefit-sync(PostHog,organizationgroup, both default OFF). One key covers both Meta cards because either handshake needs the org’s Meta app to have cleared Business Verification, so a half-open page would offer a connect button that dead-ends. The route itself is not flagged: it always hosts the AI-assistants (MCP) card (see MCP server), so owners and admins reach it on permission alone, and with every key off it shows only that card. The gate is UI-only — every route underorganizations/:orgId/integrations/{meta,whatsapp}and both inbound webhooks stay live, so an org that already connected keeps ingesting while the flag is off.
Returning leads
A closed lead is lost, frozen, or archived. A won lead (converted / customer) is never reopened: it is a member or a buyer, not a prospect. When a closed lead reappears, it is reopened, not duplicated and not left in History.
What a reopen writes (returnLead in organization-leads/lead-return.ts, one transaction with the org lead row locked):
- A lost or frozen lead moves to the first active
newstage of its own pipeline (the org default when that pipeline is archived), with a status event and astage_movedactivity. An archived lead that was still open (a booked trial, say) keeps its stage and status. - Unarchived, unpaused,
consecutive_no_answer_countreset to 0. - A call due now:
next_action_at = returned_at,next_action_type = call. The previous life’s follow-up is not a promise any more. The follow-up task is synced after commit. returned_at,returned_from_status,returned_via,return_count + 1, and alead_returnedtimeline entry.- An unowned lead gets an owner: the Sales actor for staff paths, else the org’s default assignee. An existing owner stays.
- The entry point’s note (form message, ad answers, WhatsApp text) is appended in SQL; a repeat of the last sentence is skipped. Empty name / email / phone are filled from the new touch; existing values are never overwritten.
- The
campaign_idis set only when the lead has none. LEAD_STATUS_CHANGED(from the closed status tonew) after commit. The automations listener already treatslost/frozen → newas a reopening: it re-enrolsnew_leadautomations with anoccurredAt-keyed occurrence.
Entry points
| Entry point | returned_via | Path |
|---|---|---|
Staff add (Sales “Add lead”, legacy POST …/leads) | staff_add | createLead finds the closed match and reopens it. The response is 201 with intake: 'reopened'; the web shows “This person was already a closed lead…”. An open or won match is still a 409 — and wins over a closed copy of the same person, so a duplicate is never reopened next to an open lead. A closed match is reopened only when it is the same person by isSameLeadPerson (equal email, or an unshared phone with an agreeing name); someone else on a family phone gets the 409 and never writes their details onto that lead. Matching uses lower(email) and the canonical phone, also for rows stored in another format. |
| Sales History → Reopen | staff_reopen | POST …/sales-workspace/leads/:id/reopen { operationId, expectedVersion } — versioned and replayable like the other Sales writes. 409 for a stale version or an already-open lead; 400 for a won lead. Also POST …/leads/:id/reopen and the agent tool leads.reopen. |
| Public minisite / website / event form | web_form | createLead with returnVia. The response stays { received: true } either way (no enumeration oracle). |
| Meta lead ad | meta_lead_ad | createLead; reported deduped: true. |
| WhatsApp click-to-WhatsApp ad | whatsapp | createLead; reported deduped: true. |
| Reply on email / WhatsApp | inbound_message | ConversationsService.recordInbound after the reply is stored. The reply then ranks as needs_reply. A machine-written email (out-of-office, bounce, delivery report — isAutoGeneratedEmail) is still recorded but never reopens a lead. |
| Course checkout start | course_checkout | CoursesService.attachLeadForBuyer: the org’s lead by email, else by the identity rule above (a phone-only lead), else a new one. A paid purchase still moves the lead to customer (markCustomer). |
| Join-link registration released unpaid | join_link | demoteReleasedJoinMembership → reopenLeadAtStage lands on Awaiting payment / Incomplete signup (category contacted). A closed lead also gets the return marker, a call due now, its task and the default owner — also when it already sat in that column and was only archived. An open lead keeps the old rule (its previous follow-up is dropped, no call): a call with no return marker would read as a missed commitment on arrival. |
| Self-signup (membership activated) | — | convertLeadOnSelfSignup already wins any unconverted lead, lost included. No return marker: the lead is won, not reopened. |
AI agent leads.create | agent | createLead; the tool result carries intake. |
Concurrency. returnLead locks the org lead, then the lead row (the order every lead write takes), and re-checks the status under both locks, so two touches at once reopen once. reopenReturningLead skips the owner lookup and the transaction for an open lead. The task side treats the return call like the new-lead call (isAutoSeed): first contact retires it.
Lead sheet. The lead_returned history entry shows the status the lead came back from, the entry point, and what the person said (the form message, ad answers or WhatsApp text, kept in payload.note). After Reopen, the History row stays for a moment marked “Reopened” and disabled, so a second click cannot land on the next row.
Not covered: a reply on a thread that has no lead_id (a member thread) does not look for a lead by the contact’s phone or email.
Not an entry point, on purpose: the CSV import (importLeads, ImportService) still skips or reports duplicates. Re-importing an old export must not reopen every lost lead at once. A staff drag on the legacy board (PATCH status) and the bulk stage move also stay plain stage moves without the return marker.
Sales queue. A returned lead that nobody has tried since returned_at (still new/contacted, no attempt or contact after the return, follow-up none or still the return call) has the returned reason. It sits in Needs action, ranks with the hot tier (0) during its first 24 hours and with the cooling tier (2) up to 7 days, newest return first. Like the new-lead intake call, the return call is never missed_commitment and never counts as overdue (queue, digests and the daily report). The row shows “Came back {when} · was {status} · {via}”. History rows now label the reason “Closed” instead of “Review next action”.
Routes — the leads surface is the Sales workspace
The dashboard leads page is an optional catch-all route (apps/web/src/app/[lang]/(protected)/dashboard/leads/[[...view]]/page.tsx) that renders the Sales workspace. Its tabs are URL state:
| URL | Renders |
|---|---|
/dashboard/leads, /dashboard/leads/work | The Work queue. |
/dashboard/leads/board[/<pipelineId>] | The read-only stage overview, per pipeline. |
/dashboard/leads/reports | Sales reports. |
/dashboard/leads/history | History. |
/dashboard/leads/manage | Pipelines, team access and Sales settings. |
The retired Leads views keep their URLs: legacyLeadsRedirect (apps/web/src/lib/sales-workspace-routes.ts) replaces them with the Sales tab that took their place — list → the Work queue, inbox → the queue’s needs-reply view, pipeline[/<id>], a bare pipeline id or any other word → the Board (on that pipeline when the id is a UUID). A ?leadId= or ?lead= on the old link opens the lead’s sheet as ?lead=. The server page seeds the workspace’s access query (GET …/sales-workspace/access/me) where the workspace renders.
Campaign hooks — granular acquisition attribution
lead_campaigns answers “which exact ad/form/surface brought this lead”, not just the channel:
- Auto-minted by Meta/WhatsApp ingestion on
(org, channel, externalRef)— externalRef is the provider’s form/ad/source id. - Minisite capture passes a
campaignRefper surface (lead-popup,contact-form) through the same-origin proxy;createLeadfind-or-creates the hook with the same conflict-safe upsert (channel = source, externalRef = ref). - Staff paths (add-lead dialog, Spotter
leads.create) attach an existing hook viacampaignId— validated to belong to the org. - Manual buckets via
POST …/leads/campaigns(name + optional channel/color; externalRef null). - Analytics:
getAnalytics.byCampaignreports per-hook totals, wins (converted+customer), and win rate — the “which ad actually produces members” view.
Segments were removed (2026-07-04).
lead_segments/lead_segment_membersnever saw production use; grouping is served by pipelines (process + interest), tags (contact labels), and campaign hooks (lead_campaigns, granular acquisition source). Bulk audiences target lead ids, pipeline/stage, tags, or a select-all-matching filter.
Golden paths
G1 — Lead arrives via minisite
- Public
POST /leads/organization/:orgIdwith{name, email, phone, locale, note, campaignRef?, minisiteEventId?}. - Validation via
createOrganizationLeadSchema(Zod). The controller then applies public-boundary policy:campaignId(staff-path field) is always dropped;campaignRefis limited to the refs the minisite’s own surfaces send (lead-popup,contact-form) so a public caller can’t mint arbitrary dashboard buckets;minisiteEventIdpasses through (the service validates it against the org’s events and resolves the per-event campaign server-side). - Service dedup against existing leads in this org by email or phone.
- INSERT
leadsrow (source forced tominisite). - INSERT
organization_leadsrow (stamped with the resolved campaign, if any). - 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
{data: {received: true}}— a fixed minimal body. The public endpoint never returns the lead row, and a dedup hit returns the same 201 + body as a fresh create: any distinguishable response would be an enumeration oracle (probe a phone/email → learn the person is a lead of this org). The service’s detailedConflictException(withexistingLeadId/existingLeadName) is reserved for authenticated staff paths and the idempotent webhook ingestion.
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).
G6 — Lead arrives via Meta Lead Ads (webhook)
- Coach has an active
meta_lead_adsconnection (a Page subscribed toleadgen). - Meta POSTs
/webhooks/meta/leadgen;verifyMetaSignaturevalidates theX-Hub-Signature-256HMAC. - Route the
leadgenchange bypage_id→ the owning org’s connection → enqueue on thelead-ingestionqueue. MetaLeadProcessorServicefetches the lead from the Graph API byleadgen_id, maps form fields, resolves/creates thelead_campaignshook bucket, creates the lead (source=facebook|instagram) viacreateLead, and writeslead_attribution. Idempotent onleadgen_id.
G7 — Lead arrives via Click-to-WhatsApp (webhook)
- Coach has an active
whatsapp_cloudconnection (number registered + WABA subscribed). - Meta POSTs
/webhooks/whatsapp; HMAC validated (same app secret). - Route the
messageschange byphone_number_id→ connection. Only messages carrying a CTWAreferralqualify (organic inbound is dropped, by design). Enqueue with job namewhatsapp. WhatsAppProcessorServiceresolves the hook bucket fromreferral.source_id(named from the adheadline), creates the lead (source=whatsapp,phone=wa_id,name=profile name), and writeslead_attribution. Idempotent onctwa_clid(falling back towamid).
Full pipeline detail (connect flows, code map, env) in lead-ingestion.md.
G8 — Move a lead between stages
PATCH …/leads/:leadId { stageId }(API clients, the agent’smove_stage); the web moves stages through the Sales workspace’s versioned stage write.- Service
moveLeadToStage: updatesorganization_leads.stage_id+leads.status(= stage category) + appends alead_status_eventsrow; emitsLEAD_STATUS_CHANGEDon a category change (drives automations). - A move into a
convertedstage (lead not yet converted) is rejected; converting goes through the Convert dialog.
G9 — Inbox: email a lead (outbound + threading)
POST …/conversations { leadId, channel:'email', subject?, body }→ find-or-create the thread (idempotent on(org, channel, externalContactId)), then send.sendMessage: insert an outboundconversation_messagesrow (queued), call the email adapter (Resend) withReply-To: contact+<conversationId>@<INBOUND_EMAIL_DOMAIN>and, if the contact has replied before,In-Reply-To/References= their last inboundexternal_message_id; persist the provider id + delivery status.- First outbound flips the lead
new → contacted(markContacted, best-effort, logged on failure). - Subject is stored once and reused with a
Re:prefix on subsequent replies. Blank subject falls back to a localized default in the contact’s locale.
G10 — Inbox: a lead replies (inbound)
- The lead replies to
contact+<id>@…; Cloudflare Email Routing → the zero-dep Email Worker forwards the raw MIME toPOST /webhooks/email/inbound?secret=…(fail-closed on the secret; throttled). - The body is extracted from the MIME (charset-aware) and quoted history/signature stripped;
parseConversationIdrecovers the thread from the address. recordInboundappends an inbound message (idempotent),+1unread, re-opens the thread, emitsconversation.inbound_received. See the runbook: inbox-email-inbound.md.
Follow-ups and lead tasks (TKN-419)
A lead’s follow-up is its next scheduled contact: organization_leads.next_action_at (when), next_action_type (call / whatsapp / email / meeting / custom) and next_action_note (≤ 255 chars). It drives the attention queue (overdue / due_today in lead-attention.util.ts) and the agent’s leads.list sort=attention. Every write is on the lead’s timeline (lead_activity_events: follow_up_set, follow_up_cleared, call_logged).
| Write path | Effect on the follow-up |
|---|---|
createLead and the join-link sweep (demoteReleasedJoinMembership) | The new-lead seed — next_action_at = leads.created_at, type call (“call this new lead now”; the queue shows it as “Call · |
POST …/leads/:id/touches (logTouch) | Stamps last_contacted_at / channel. nextActionAt set → replaces the follow-up (note defaults to null); explicit null → clears it; omitted → keeps it. |
PUT …/leads/:id/follow-up (setFollowUp) / DELETE (clearFollowUp) | Replace / clear. |
Agent leads.set_follow_up (Spotter + MCP) | setFollowUp, or clearFollowUp for nextActionAt: null. An omitted nextActionType / note keeps the follow-up’s current one (“move it to Sunday” stays a WhatsApp with its note). Owner/admin and lead_management tier. |
CSV import, agent leads.import (Spotter only, confirm card) | None: imported leads get no follow-up, no task and no LEAD_CREATED (deliberate — no automation flood). |
One object: the follow-up IS the lead’s task
A lead has at most ONE open (pending / in_progress, not deleted) contact_lead task linked to it (tasks.linked_lead_id; the partial unique index tasks_open_contact_lead_unique_idx enforces it), and that task mirrors the follow-up. LeadFollowUpTaskService (organization-leads/lead-follow-up-task.service.ts) is the only writer of the pair; it never calls back into the leads or tasks service, so a write in one direction cannot bounce back. Every write takes the lead’s organization_leads row before the task (task-side writes lock it FOR NO KEY UPDATE first), so two people acting on the same lead queue instead of deadlocking.
A linked contact task stands for the follow-up (taskMirrorsFollowUp) when the lead has no follow-up, when the follow-up is still the new-lead seed, or when the follow-up falls on the task’s org-local due date (and due time, if the task has one). A linked task from before this sync — e.g. an automation’s “call the new lead” next to a follow-up someone moved to another day — does not, and closing or moving it never touches the follow-up.
| Trigger | Effect |
|---|---|
| Follow-up set or moved (touch with a date, set follow-up, agent) | Upsert the task (ON CONFLICT on the index), in the same transaction as the follow-up: due date + due time = next_action_at in the org timezone; the description takes the note only while it is empty or still equal to the previous note, so a description written on the task (or longer than the note’s 255 chars) is never overwritten or cut. A new row gets the title <type> · <lead> in the org’s locale (tasks/task-copy.ts, e.g. “שיחה · דנה לוי”), description = the note, priority high, source manual, assignee = the lead’s owner → whoever set it → the org’s longest-standing active owner. An existing row keeps its title, assignee, priority and source. |
| New-lead seed | Same upsert with the seed copy “New lead — call auto, trigger new_lead. When someone later schedules a real follow-up, that generated title is re-rendered once as <type> · <lead> and the trigger is dropped; renaming the task in Tasks drops it too, so a typed title is never re-rendered. A failure is logged + reported to Sentry and never fails lead creation. |
Follow-up cleared (clear, touch with null) | The task → cancelled; follow_up_cleared { reason: cleared }. |
| Touch logged without a follow-up while the follow-up is still the new-lead seed | The seed is retired — cleared, its task cancelled, follow_up_cleared { reason: first_contact }. A follow-up someone chose is kept. |
Task created with linkedLeadId (dashboard, agent tasks.create) | Type defaults to contact_lead. Merges into the lead’s open task (title, description, due, assignee, priority, member link ← the new values) or opens it, and moves the follow-up to the task’s due slot: no due time ⇒ 09:00 org-local, type kept (default call), note ← the description (truncated to 255). A task without a due date leaves the follow-up as it is. The lead must belong to the org (400 otherwise). |
| Task completed / cancelled (complete, PATCH status) / deleted | Only when the task stands for the follow-up: the follow-up is cleared (reason: task_completed / task_cancelled). Completing it is also the contact it asked for: last_contacted_at = now and, when empty, last_contact_channel from the follow-up type (call/whatsapp/email; meeting → in person) — so a new lead whose call was ticked off does not fall back into new_unanswered. Cancelling or deleting stamps nothing. The task keeps the status the user gave it either way. |
| Task due date / due time / description changed | Only when the task stands for the follow-up: the follow-up moves with it (follow_up_set, written only when something actually changed). Otherwise the task moves alone. |
Task reopened (PATCH status back to pending / in_progress — e.g. un-ticking it on the Tasks page) | 409 when another open contact task for the lead exists (the index allows one); the Tasks page shows that reason in a toast. Otherwise, if the lead has no follow-up, the task’s due slot (and description as the note) becomes it; an existing follow-up is kept. A cancelled follow-up task is hidden from the Tasks date tabs (see dashboard behavior), so clearing a follow-up no longer leaves it in Today. |
| Role without lead management (coach) | Linking a task to a lead (linkedLeadId on create) is 403. Their complete / update / delete of a lead task is applied, but never moves or clears the lead’s follow-up. |
Automation create_task on a lead | Due date = org-local today + dueInDays (not the UTC day, which is a day early between 00:00 and 03:00 Israel time). The lead already has an open contact task → nothing. It has a follow-up but no task (set before this sync existed) → that follow-up’s task is created, keeping its date, assigned per the automation’s assignTo. Neither → the automation’s task (linked, title as configured) and the follow-up set to its due date, 09:00 org-local. |
Agent texts. Spotter’s static prompt (Leads & CRM) says a lead has ONE follow-up — use leads.set_follow_up, never a separate tasks.create — and the leads.attention_needed playbook names leads.set_follow_up. The leads.set_follow_up / tasks.create summaries, the tasks.create schema and the MCP instructions describe the follow-up tool and the merge the same way.
Lead tasks close automatically (TKN-420)
A lead’s tasks follow the lead’s status. LeadTaskLifecycleListener (organization-leads/lead-task-lifecycle.listener.ts) subscribes to LEAD_STATUS_CHANGED, which every status-category change emits: moveLeadToStage (PATCH status/stage, board drag, bulk move, markCustomer, markContacted), setPrimaryPipeline, reopenLeadAtStage, attachLeadToMembership (self-signup match, linkLeadToMember) and convertLead. Every one of them emits after its writes are committed — none emits inside a transaction — and the handler runs asynchronously off the emit. A failure is logged and reported to Sentry (subsystem: lead-tasks); it never fails the status change.
| Transition | Effect |
|---|---|
→ lost / converted / customer | Open (pending / in_progress) contact_lead tasks linked to the lead → cancelled — not completed (completion stats stay true), not deleted (the row stays auditable). The follow-up (next_action_*) is cleared, with a follow_up_cleared { reason: lead_lost | lead_converted | lead_customer } timeline entry when one existed. Other linked task types (“send the contract”) are real work and stay open. |
→ trial_booked | Same close, reason: lead_trial_booked — the booked trial replaces the follow-up, so a pending “call them” would be stale. Applies to any follow-up, including one staff scheduled themselves. |
→ frozen | Nothing. Frozen parks a lead and is reversible, so its follow-up and task wait for whoever un-parks it. |
new → any other status | Retires the new-lead seed only: when next_action_at still equals the lead’s created_at it is cleared and its linked contact task cancelled (reason: first_contact). A follow-up staff scheduled is never touched. A new lead going straight to trial_booked or a terminal status gets that close instead. |
- Idempotent — a re-delivered transition cancels and logs nothing more.
- Reopen — a cancelled task stays cancelled.
reopenLeadAtStagealready drops the previous life’s follow-up; the next follow-up creates a new task. - Not covered — paths that emit no status change: archive / bulk archive (the status stays), and conversions that never touch the lead (a plan sold to an existing member, member import). Tasks without
linked_lead_id(pre-TKN-419 auto-tasks, manual tasks) cannot be found by lead — see the backfill below.
Erasing a lead deletes its linked tasks
eraseLeadCore (single and bulk erase) hard-deletes every task whose linked_lead_id is the erased lead, in the erase transaction, before the lead row. tasks.linked_lead_id references leads.id with ON DELETE NO ACTION, so an erase of any lead with a linked task used to fail on the FK (bulk erase reported it as a per-lead failure); and those task titles carry the lead’s name — PII the erasure must remove. Unlinked tasks are not touched.
Backfill — scripts/backfill-lead-task-cleanup.ts
One-off cleanup of tasks that predate the listener:
- Open
contact_leadtasks linked to a lost / converted / customer lead → cancelled (any title language, automation tasks included). - Open unlinked
contact_leadtasks titledContact new lead: X(the legacy auto-task) → X matched to exactly one lead of the same org by name, email or phone. Terminal lead → cancelled; a lead with its own follow-up (next_action_atset) → left as is and listed (a linked contact task stands for the follow-up, and this stale one is not it); any other lead without an open linked contact task → linked, so the listener closes it later; a lead that already has one → left as is; ambiguous or unmatched → left and listed for manual review. - Prints per-org counts and the task / lead ids to review — ids only, no names or contact details.
Runbook: run the dry run (the default: plain SELECTs only, safe on production under a read-only role) → the org owner reviews the report → --apply only with the owner’s approval (one transaction per org; every write re-checks the task is still open and unlinked, and a link that the lead still has no follow-up). An agent never runs --apply against production.
TSX_TSCONFIG_PATH=tsconfig.base.json DATABASE_URL=… \
pnpm exec tsx scripts/backfill-lead-task-cleanup.ts [--org <uuid>] [--apply]Logging a touch
POST …/leads/:id/touches stamps the contact and, per the table above, replaces, clears or keeps the follow-up. On the web, contact results are logged on the lead’s Sales sheet as one versioned Sales operation (…/sales-workspace/leads/:id/results), not through this endpoint.
Lead tasks, “Assigned to me” and the urgent prompt (TKN-422)
Decisions taken for TKN-422 (ticket was needs-decision): soft block, threshold 5; “handled” = the item is no longer overdue/due (no extra state); prompt for owner/admin/coach, lead counts owner/admin only; lead work queue = the existing attention-sorted list, no /leads/queue route. The Tasks page has no separate lead/general split: lead tasks are listed with every other task and found with the existing Type filter (“Contact Lead”, type=contact_lead).
- Tasks page: list and summary rows carry
linkedLeadId; a task with one names the lead, marks it with a lead badge (tasks.leadLabel), shows the lead’s phone, and links to its Sales sheet (/dashboard/leads?lead=<id>). The task detail sheet carries the same link.?leadId=<id>lists one lead’s tasks behind a removable chip, and?task=<id>opens that task — both are how the Sales lead sheet deep-links into Tasks. GET …/leads?assignedTo=me: the server resolves the session user →organization_leads.assigned_to_user_id; never a client-supplied id.- Urgent tasks prompt: a dashboard shell dialog (
components/dashboard/urgent-tasks-prompt.tsx), see below.
Morning “urgent tasks” prompt
GET /organizations/:orgId/urgent-prompt → { show, blocking, counts: { overdueTasks, dueTodayTasks, leadsNeedingAttention }, snoozedUntil, reviewTarget } (urgentPromptStateSchema).
- Who: staff (owner/admin/coach); members always get
show: false. - Items: the caller’s own open tasks (assignee = me, not completed/cancelled) due before today / today in the org timezone, plus, for owner/admin only, leads in an urgent attention state counted in SQL with
urgentAttentionCandidateCondition(inbound unanswered, overdue / due-today follow-up, untouched new lead past the SLA; non-archived). A due follow-up counts as a lead only while no open linkedcontact_leadtask stands for it (onlyDueFollowUpsWithoutTask, aNOT EXISTS): one with a task is a task item (for that task’s assignee), and one set before the follow-up ⇄ task sync existed (no task yet) still counts as a lead — never both. - Once a day:
POST …/urgent-prompt/seen(sent when the dialog opens) stores the org-local date instaff_prompt_states.last_shown_on; the same org-local day answersshow: false. 0 items →show: false, nothing stored. - Soft block: more than 5 items (
URGENT_PROMPT_BLOCKING_THRESHOLD) →blocking: true: no close button, Escape and outside clicks are ignored; only Review now or a snooze closes it. ≤ 5 → dismissible normally. - Snooze (“Remind me later”):
POST …/urgent-prompt/snooze { until: 'one_hour' | 'tomorrow' }setssnoozed_until(now + 1h, or tomorrow 08:00 org-local) and clearslast_shown_on, so the dialog returns once the snooze lapses. Stored per user × org instaff_prompt_states(migration0160), so it holds across devices. The web re-asks every 10 minutes and on window focus. - Handled needs no state: completing, cancelling or rescheduling a task, or logging a contact / moving a follow-up, removes the item from the live counts.
- Review now →
reviewTarget:leads(the Sales queue,/dashboard/leads) when leads outnumber the urgent tasks; otherwisetasks(/dashboard/tasks?tab=today).
Edge cases & error states
| Scenario | Behavior |
|---|---|
| Public minisite dup | Silently swallowed: 201 {data: {received: true}}, identical to a fresh create (anti-enumeration; no new row inserted). |
| Manual create dup | 409 {message: 'A lead with this email or phone already exists in this organization', existingLeadId, existingLeadName}. |
| Convert already-converted lead | 400 ‘Lead has already been converted’. |
| Convert when an active membership exists | 200 outcome:'existing_member' naming the member, nothing written; confirm via POST …/link-member → outcome:'attached'. |
| Convert when a non-active membership exists | 400 ‘User is already a member of this organization’. |
| Link to a membership outside the org or soft-deleted | 404 ‘Member not found’. |
| Link to a non-active membership | 400 ‘Member is not active’. |
| Link an already-converted lead | 400 ‘Lead has already been converted’. |
| 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. |
markContacted failure after a send | Non-blocking, but logged (warn) — the send still succeeds. |
Set status:'converted' via PATCH / board drag | 400 ‘A lead can only be marked converted by converting it to a member’. |
| Assign a conversation to a non-member | 400 ‘Assignee is not a member of this org’. |
| Inbound webhook, missing/invalid secret | 403 ‘Invalid webhook secret’ (fail-closed). The secret query param is redacted from logs. |
Inbound webhook, unroutable / duplicate external_message_id | 200 {ok:true} (acked so the provider doesn’t retry); no duplicate message recorded. |
| Outbound send rejected by provider | Message persisted with delivery_status='failed' + error_message; endpoint returns 400 with the provider error. |
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. A pending trial in a class that has not started and was not checked in becomes the member’s booking (converted, seat moved, no waitlist run; TrialSeatsService.moveIntoBooking). Linking to an existing member, by staff or by a matched self-signup, does the same. The seat moves in the conversion’s own transaction, and any other pending trial (class already started, a tour) ends cancelled there too. See Trials. |
| Archive / bulk archive | archived_at set; a pending trial appointment is ended: checked in by the coach → attended (kept on the class roster, seat held), otherwise cancelled and superseded, and when it sat in a class session the freed seat is offered to that session’s waitlist after commit (trigger trial_released). See Trials. |
| 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 | ✓ | ✓ | ✗ | ✗ | |
POST …/leads/bulk-move-stage | ✓ | ✓ | ✓ | ✗ | |
POST …/leads/bulk-tags | ✓ | ✓ | ✓ | ✗ | |
* /organizations/:orgId/lead-pipelines* | ✓ | ✓ | ✓ | ✗ | |
* /organizations/:orgId/conversations* | ✓ | ✓ | ✓ | ✗ | |
POST /webhooks/email/inbound | secret-gated (@Public, fail-closed, throttled) |
All non-public endpoints additionally require the lead_management feature on the org’s platform_tier. Conversations + pipelines use the same requireStaff gate as the CRM; the inbound webhook is the only public conversations route and is authenticated by EMAIL_INBOUND_SECRET, not Clerk.
Ingestion endpoints (separate from the CRM table above): connect/disconnect of
an integration (…/integrations/meta|whatsapp/*) requires owner/admin; the
inbound webhooks (/webhooks/meta/leadgen, /webhooks/whatsapp) are public,
authenticated by the Meta signature/verify-token handshake. See
lead-ingestion.md.
Managed members
Lead matching and contact lookups consider only users.kind = 'account': a child shares a parent’s phone or email and must never be matched as a lead (apps/api/src/organization-leads/organization-leads.service.ts:170-171,3114). Ungated. Spec: managed-members/behavior.md.