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', 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.
contactedtrial_bookedPATCH/board move with status/category:'trial_booked'. Typically combined with trialDate. Event row.
trial_bookedconvertedOnly 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.
anylostPATCH/board move with status/category:'lost'. Event row.
any open statecustomerAutomatically 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 same requireStaff gate guards every conversations + pipelines endpoint.
  • converted only via convert — updateLead rejects (400) a move/status-set into a converted-category stage unless the lead is already converted (repositioning). The only path that sets converted is convertLead, which creates the membership — so a “converted” lead always has one. Enforced server-side, covering the board, the AI update tool, and direct API calls.
  • Stage ↔ status are one move — stageId wins; a bare status resolves 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 — sendMessage on a lead conversation consults LeadConsentService.isAllowed for consent-gated channels (email/sms/whatsapp) and 403s with the LEAD_CHANNEL_BLOCKED marker when blocked. Member email conversations independently check notification_prefs.newMessage.email and 403 with MEMBER_EMAIL_OPTED_OUT on an explicit opt-out; missing preference rows/keys remain enabled. Both checks happen before the outbound message insert. Internal manual notes 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 — assign rejects an assignedToUserId that 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 — recordInbound dedups on (channel, external_message_id) (via onConflictDoNothing), +1s unread_count, sets status='open', and emits conversation.inbound_received. Threads resolve from the per-conversation Reply-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, recordInboundForOrgLead resolves org + lead (never cross-org), lazily finds-or-creates the lead’s email thread, appends the reply, and runs markContacted (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, demoteReleasedJoinMembership and course checkout (findLeadIdForUser) match through isSameLeadPerson (lead-identity.ts), never a bare email 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 on lower(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 no lead_status_events row 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 demoteReleasedJoinMembership creates or reopens carries recoveredSignup: true on its lead.created / lead.status_changed event. new_lead automations skip it. The staff newLead mail is unaffected. See staff-newsletter behavior.
  • Convert is one-way + idempotent — organization_leads.converted_membership_id set 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), convertLead creates nothing: it returns outcome:'existing_member' with the member’s name and existingMember.membershipId and writes nothing. The write is POST …/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) and outcome:'attached' comes back. No MEMBERSHIP_ACTIVATED — the membership’s welcome fan-out already ran. The membership must be in the org, not soft-deleted, and active; 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-candidates lists 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 than isSameLeadPerson: 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-2233 finds +972501112233); multilingual name candidates join the results. Rows carry matchedBy:'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-member step.
  • Sales workspace candidates use the shared shape — GET …/sales-workspace/leads/:id/link-candidates answers { 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 OrganizationLeadsController is 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-primary promotes a secondary membership to the primary placement (the one leads.status mirrors) 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 fabricate converted). A primary_changed timeline 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 new stage, a lost stage, and at least one WIN stage: converted OR customer. 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). convertLead lands the new member on the pipeline’s converted stage, else its customer stage, else leaves the lead’s stage untouched — never an unrelated column.
  • Bulk stage move honors the convert invariant — POST …/leads/bulk-move-stage moves leads already in the pipeline (primary via the full moveLeadToStage machinery, secondary via a batch membership update). Unconverted leads aimed at a converted stage 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) LeadPurchaseService attaches a per-course purchase-category tag (find-or-create, named after the course) to the buyer’s lead in the seller’s org, appends a purchase timeline event, and calls markCustomer (auto-move to the pipeline’s customer stage, when one exists). Analytics counts customer as a win alongside converted (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/:id returns timeline: 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 single stageId) 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 (reusing createLead). Connect/disconnect requires owner/admin.
  • The connect surface is flagged, the pipe is not — every card on /dashboard/settings/integrations is behind the rollout key of the connector it configures: Meta Lead Ads and WhatsApp on meta-integrations, Fizikal on fizikal-freefit-sync (PostHog, organization group, 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 under organizations/: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 new stage of its own pipeline (the org default when that pipeline is archived), with a status event and a stage_moved activity. An archived lead that was still open (a booked trial, say) keeps its stage and status.
  • Unarchived, unpaused, consecutive_no_answer_count reset 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 a lead_returned timeline 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_id is set only when the lead has none.
  • LEAD_STATUS_CHANGED (from the closed status to new) after commit. The automations listener already treats lost/frozen → new as a reopening: it re-enrols new_lead automations with an occurredAt-keyed occurrence.

Entry points

Entry pointreturned_viaPath
Staff add (Sales “Add lead”, legacy POST …/leads)staff_addcreateLead 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 → Reopenstaff_reopenPOST …/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 formweb_formcreateLead with returnVia. The response stays { received: true } either way (no enumeration oracle).
Meta lead admeta_lead_adcreateLead; reported deduped: true.
WhatsApp click-to-WhatsApp adwhatsappcreateLead; reported deduped: true.
Reply on email / WhatsAppinbound_messageConversationsService.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 startcourse_checkoutCoursesService.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 unpaidjoin_linkdemoteReleasedJoinMembership → 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.createagentcreateLead; 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:

URLRenders
/dashboard/leads, /dashboard/leads/workThe Work queue.
/dashboard/leads/board[/<pipelineId>]The read-only stage overview, per pipeline.
/dashboard/leads/reportsSales reports.
/dashboard/leads/historyHistory.
/dashboard/leads/managePipelines, 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 campaignRef per surface (lead-popup, contact-form) through the same-origin proxy; createLead find-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 via campaignId — validated to belong to the org.
  • Manual buckets via POST …/leads/campaigns (name + optional channel/color; externalRef null).
  • Analytics: getAnalytics.byCampaign reports 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_members never 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

  1. Public POST /leads/organization/:orgId with {name, email, phone, locale, note, campaignRef?, minisiteEventId?}.
  2. Validation via createOrganizationLeadSchema (Zod). The controller then applies public-boundary policy: campaignId (staff-path field) is always dropped; campaignRef is limited to the refs the minisite’s own surfaces send (lead-popup, contact-form) so a public caller can’t mint arbitrary dashboard buckets; minisiteEventId passes through (the service validates it against the org’s events and resolves the per-event campaign server-side).
  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 (stamped with the resolved campaign, if any).
  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 {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 detailed ConflictException (with existingLeadId/existingLeadName) is reserved for authenticated staff paths and the idempotent webhook ingestion.

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

G6 — Lead arrives via Meta Lead Ads (webhook)

  1. Coach has an active meta_lead_ads connection (a Page subscribed to leadgen).
  2. Meta POSTs /webhooks/meta/leadgen; verifyMetaSignature validates the X-Hub-Signature-256 HMAC.
  3. Route the leadgen change by page_id → the owning org’s connection → enqueue on the lead-ingestion queue.
  4. MetaLeadProcessorService fetches the lead from the Graph API by leadgen_id, maps form fields, resolves/creates the lead_campaigns hook bucket, creates the lead (source=facebook|instagram) via createLead, and writes lead_attribution. Idempotent on leadgen_id.

G7 — Lead arrives via Click-to-WhatsApp (webhook)

  1. Coach has an active whatsapp_cloud connection (number registered + WABA subscribed).
  2. Meta POSTs /webhooks/whatsapp; HMAC validated (same app secret).
  3. Route the messages change by phone_number_id → connection. Only messages carrying a CTWA referral qualify (organic inbound is dropped, by design). Enqueue with job name whatsapp.
  4. WhatsAppProcessorService resolves the hook bucket from referral.source_id (named from the ad headline), creates the lead (source=whatsapp, phone=wa_id, name=profile name), and writes lead_attribution. Idempotent on ctwa_clid (falling back to wamid).

Full pipeline detail (connect flows, code map, env) in lead-ingestion.md.

G8 — Move a lead between stages

  1. PATCH …/leads/:leadId { stageId } (API clients, the agent’s move_stage); the web moves stages through the Sales workspace’s versioned stage write.
  2. Service moveLeadToStage: updates organization_leads.stage_id + leads.status (= stage category) + appends a lead_status_events row; emits LEAD_STATUS_CHANGED on a category change (drives automations).
  3. A move into a converted stage (lead not yet converted) is rejected; converting goes through the Convert dialog.

G9 — Inbox: email a lead (outbound + threading)

  1. POST …/conversations { leadId, channel:'email', subject?, body } → find-or-create the thread (idempotent on (org, channel, externalContactId)), then send.
  2. sendMessage: insert an outbound conversation_messages row (queued), call the email adapter (Resend) with Reply-To: contact+<conversationId>@<INBOUND_EMAIL_DOMAIN> and, if the contact has replied before, In-Reply-To/References = their last inbound external_message_id; persist the provider id + delivery status.
  3. First outbound flips the lead new → contacted (markContacted, best-effort, logged on failure).
  4. 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)

  1. The lead replies to contact+<id>@…; Cloudflare Email Routing → the zero-dep Email Worker forwards the raw MIME to POST /webhooks/email/inbound?secret=… (fail-closed on the secret; throttled).
  2. The body is extracted from the MIME (charset-aware) and quoted history/signature stripped; parseConversationId recovers the thread from the address.
  3. recordInbound appends an inbound message (idempotent), +1 unread, re-opens the thread, emits conversation.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 pathEffect 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.

TriggerEffect
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 seedSame upsert with the seed copy “New lead — call ” (“ליד חדש — להתקשר · …”), source 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 seedThe 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) / deletedOnly 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 changedOnly 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 leadDue 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.

TransitionEffect
→ lost / converted / customerOpen (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_bookedSame 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.
→ frozenNothing. Frozen parks a lead and is reversible, so its follow-up and task wait for whoever un-parks it.
new → any other statusRetires 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. reopenLeadAtStage already 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:

  1. Open contact_lead tasks linked to a lost / converted / customer lead → cancelled (any title language, automation tasks included).
  2. Open unlinked contact_lead tasks titled Contact 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_at set) → 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.
  3. 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 linked contact_lead task stands for it (onlyDueFollowUpsWithoutTask, a NOT 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 in staff_prompt_states.last_shown_on; the same org-local day answers show: 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' } sets snoozed_until (now + 1h, or tomorrow 08:00 org-local) and clears last_shown_on, so the dialog returns once the snooze lapses. Stored per user × org in staff_prompt_states (migration 0160), 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; otherwise tasks (/dashboard/tasks?tab=today).

Edge cases & error states

ScenarioBehavior
Public minisite dupSilently swallowed: 201 {data: {received: true}}, identical to a fresh create (anti-enumeration; no new row inserted).
Manual create dup409 {message: 'A lead with this email or phone already exists in this organization', existingLeadId, existingLeadName}.
Convert already-converted lead400 ‘Lead has already been converted’.
Convert when an active membership exists200 outcome:'existing_member' naming the member, nothing written; confirm via POST …/link-member → outcome:'attached'.
Convert when a non-active membership exists400 ‘User is already a member of this organization’.
Link to a membership outside the org or soft-deleted404 ‘Member not found’.
Link to a non-active membership400 ‘Member is not active’.
Link an already-converted lead400 ‘Lead has already been converted’.
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.
markContacted failure after a sendNon-blocking, but logged (warn) — the send still succeeds.
Set status:'converted' via PATCH / board drag400 ‘A lead can only be marked converted by converting it to a member’.
Assign a conversation to a non-member400 ‘Assignee is not a member of this org’.
Inbound webhook, missing/invalid secret403 ‘Invalid webhook secret’ (fail-closed). The secret query param is redacted from logs.
Inbound webhook, unroutable / duplicate external_message_id200 {ok:true} (acked so the provider doesn’t retry); no duplicate message recorded.
Outbound send rejected by providerMessage persisted with delivery_status='failed' + error_message; endpoint returns 400 with the provider error.

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. 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 archivearchived_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.
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✓✓✗✗
POST …/leads/bulk-move-stage✓✓✓✗
POST …/leads/bulk-tags✓✓✓✗
* /organizations/:orgId/lead-pipelines*✓✓✓✗
* /organizations/:orgId/conversations*✓✓✓✗
POST /webhooks/email/inboundsecret-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.