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

Leads & CRM — Data Model

Tables in libs/db/src/lib/schema/leads.ts.

leads — canonical lead

ColumnTypeNotes
iduuid PK
namevarchar(255)Nullable.
emailvarchar(255)Nullable. Not globally unique — same email can be a lead in multiple orgs. Dedup is per-org (see organization_leads).
phonevarchar(50)Nullable.
localevarchar(5)Optional.
sourcelead_source enum NOT NULL default website`minisite
statuslead_status enum NOT NULL default new`new
notetextFree-text.
status_changed_attimestamptzBumped on every status change.
created_at / updated_attimestamptz

Indexes: leads_status_created_idx on (status, created_at).

No deleted_at — leads are not soft-deletable.

ColumnTypeNotes
iduuid PK
lead_iduuid NOT NULLFK → leads.id.
organization_iduuid NOT NULLFK → organizations.id. Direct org boundary.
assigned_to_user_iduuidFK → users.id. Staff member responsible for follow-up.
converted_membership_iduuidFK → memberships.id. Set once on convert; sentinel for “already converted”.
trial_datetimestamptzOptional scheduled trial appointment.
campaign_iduuidFK → lead_campaigns.id. The hook bucket for ingested leads (the ad/form/campaign). Null for minisite/manual leads.
pipeline_iduuidFK → lead_pipelines.id. The lead’s current pipeline. Null until first placed; resolves to the org’s default pipeline.
stage_iduuidFK → lead_pipeline_stages.id. The lead’s current stage; its category is mirrored onto leads.status.
created_attimestamptz

Indexes: organization_leads_org_created_idx (org, created_at); organization_leads_org_assignee_idx (org, assignee); organization_leads_org_campaign_idx (org, campaign); organization_leads_org_stage_idx (org, stage).

No updated_at and no deleted_at.

platform_leads — waitlist tag

ColumnTypeNotes
iduuid PK
lead_iduuid NOT NULLFK → leads.id.
created_attimestamptz

Pure tagging table; no per-row business state.

lead_status_events — audit history

ColumnTypeNotes
iduuid PK
lead_iduuid NOT NULLFK → leads.id ON DELETE CASCADE.
from_statuslead_status nullableNull for the initial creation event.
to_statuslead_status NOT NULL
from_stage_iduuidFK → lead_pipeline_stages.id. The stage moved out of (null for the first placement).
to_stage_iduuidFK → lead_pipeline_stages.id. The stage moved into.
changed_by_user_iduuidFK → users.id. Null for public-minisite lead creation.
changed_attimestamptz default now()

Indexes: lead_status_events_lead_changed_idx on (lead_id, changed_at).

Append-only — no update or delete paths.

lead_activity_events — unified timeline ledger (FIT-227)

Complements lead_status_events (semantic status flips on the primary placement) with everything else that happens to a contact: secondary pipeline add/remove/stage moves, same-category primary stage moves, course purchases, and per-recipient bulk-email sends. Append-only; written best-effort (history must never fail the operation it records).

ColumnTypeNotes
iduuid PK
organization_lead_iduuid NOT NULLFK → organization_leads.id ON DELETE CASCADE — GDPR erase cascades the history.
typelead_activity_type enum NOT NULLpipeline_added | pipeline_removed | stage_moved | purchase | email_sent.
pipeline_iduuidFK → lead_pipelines.id ON DELETE SET NULL.
from_stage_id / to_stage_iduuidFK → lead_pipeline_stages.id ON DELETE SET NULL.
actor_user_iduuidFK → users.id. Null for system writes (purchase webhook, email processor).
payloadjsonbDisplay-name snapshots taken at write time (pipelineName, fromStageName, toStageName, programName, subject) so the timeline renders without joins and survives renames/deletes.
created_attimestamptz default now()

Indexes: lead_activity_events_lead_created_idx (organization_lead_id, created_at).

The lead detail read (GET …/leads/:id) merges these with status events and the contact’s conversation_messages into a single timeline array (newest first, explicit kind per entry) — see buildTimeline in organization-leads.service.ts.

Lead ingestion tables

These power the lead-ingestion pipeline (Meta Lead Ads, WhatsApp, …). integration_connections lives in libs/db/src/lib/schema/integrations.ts; lead_campaigns + lead_attribution are in leads.ts.

integration_connections — per-org external connection

ColumnTypeNotes
iduuid PK
organization_iduuid NOT NULLFK → organizations.id.
providerintegration_provider enum NOT NULL`meta_lead_ads
statusintegration_connection_status enum NOT NULL default pending`pending
external_account_idvarchar(255)page_id (Meta) | phone_number_id (WhatsApp). Inbound webhooks route on this. Nullable while pending (e.g. OAuth done, page not yet picked).
encrypted_credentialstextAccess token, encrypted at rest (same scheme as payment_provider_configs). Nullable for a pre-token pending row.
configjsonbChannel extras (e.g. WhatsApp {wabaId, displayPhoneNumber, verifiedName}).
created_at / updated_attimestamptz

Indexes: integration_connections_org_idx (org); unique integration_connections_provider_account_idx (provider, external_account_id) — one provider account maps to exactly one org connection (and is the webhook-routing lookup).

lead_campaigns — the hook bucket

The named group a lead is attributed to (an ad / form / campaign), shown in the UI.

ColumnTypeNotes
iduuid PK
organization_iduuid NOT NULLFK → organizations.id. Org-scoped.
namevarchar(255) NOT NULLDisplay name (derived from the form/ad headline on auto-create).
channellead_source enum NOT NULL`facebook
colorvarchar(32)Optional UI color.
external_refvarchar(255)The provider key the bucket is resolved on (Meta form/ad id; CTWA source_id).
is_archivedboolean NOT NULL default falseHide without deleting.
created_at / updated_attimestamptz

Indexes: unique lead_campaigns_org_channel_ref_idx (organization_id, channel, external_ref) — the upsert key, so the same ad resolves to one bucket per org.

lead_attribution — 1:1 raw attribution

One row per ingested lead with the full provenance + raw payload.

ColumnTypeNotes
iduuid PK
lead_iduuid NOT NULL UNIQUEFK → leads.id ON DELETE CASCADE. 1:1 with the lead.
external_lead_idvarchar(255)leadgen_id (Meta) | ctwa_clid (WhatsApp). Idempotency key for the processor.
form_id / form_namevarchar(255)Meta lead form.
ad_id / ad_name / adset_namevarchar(255)Meta ad hierarchy.
meta_campaign_id / meta_campaign_namevarchar(255)Meta campaign.
ref_payloadtextRaw ref/tracking string.
source_url / headline / bodytextCTWA creative (the ad the WhatsApp lead tapped).
consentjsonbCaptured consent, when present.
rawjsonbFull inbound payload, for forensics.
created_attimestamptz

Indexes: lead_attribution_external_lead_id_idx (external_lead_id) — the processor’s idempotency lookup.

Configurable pipelines

Tables in libs/db/src/lib/schema/lead-pipelines.ts. An org-scoped, user-defined sales funnel that replaces the fixed five-state board with as many named, ordered stages as the studio wants. Each stage maps to a semantic category (the legacy lead_status enum) — this is the keystone: leads.status is kept in sync with the lead’s current stage category, so conversion, analytics and the lead-funnel automations keep working unchanged. Every org gets one default pipeline seeded on first use (stages New / Contacted / Trial booked / Converted / Lost). See ADR-0012.

lead_pipelines

ColumnTypeNotes
iduuid PK
organization_iduuid NOT NULLFK → organizations.id. Org boundary.
namevarchar(255) NOT NULLDisplay name.
positioninteger NOT NULL default 0Ordering across an org’s pipelines.
is_defaultboolean NOT NULL default falseWhere new leads land when none is specified.
is_archivedboolean NOT NULL default falseHide without deleting.
created_at / updated_attimestamptz

Indexes: lead_pipelines_org_position_idx (org, position); unique partial lead_pipelines_org_default_idx (org) WHERE is_default — at most one default pipeline per org. (The predicate is a bare boolean sql column, not eq(col,true): drizzle-kit serializes a literal-valued partial-index predicate as an invalid = $1 bind param in DDL.)

lead_pipeline_stages

ColumnTypeNotes
iduuid PK
pipeline_iduuid NOT NULLFK → lead_pipelines.id ON DELETE CASCADE.
namevarchar(255) NOT NULLStage label (free-text org data). The web board shows a localized label for un-renamed defaults.
positioninteger NOT NULL default 0Column order.
categorylead_status enum NOT NULLSemantic mapping onto the lifecycle. Drives conversion (converted), analytics, and which automations fire.
colorvarchar(32)Optional UI color.
show_in_reportsboolean NOT NULL default truePer-stage “count toward funnel reports” toggle (mirrors GHL).
is_archivedboolean NOT NULL default false
created_at / updated_attimestamptz

Indexes: lead_pipeline_stages_pipeline_position_idx (pipeline, position).

Unified inbox

Tables in libs/db/src/lib/schema/conversations.ts. A channel-agnostic thread between the studio and a contact (a lead or a member), aggregating messages across channels (email today; manual notes always; WhatsApp/SMS later) into one inbox via a ChannelAdapter registry. See ADR-0012.

conversations

ColumnTypeNotes
iduuid PK
organization_iduuid NOT NULLFK → organizations.id. Org boundary.
lead_iduuidFK → leads.id. The contact, when it’s a lead.
contact_user_iduuidFK → users.id. The contact, when it’s a member. Exactly one of lead/user is set.
channelconversation_channel enum NOT NULL`manual
statusconversation_status enum NOT NULL default open`open
subjecttextThread subject from the first message; reused with a Re: prefix on replies (added in migration 0070).
assigned_to_user_iduuidFK → users.id. Must be an org member (enforced in assign).
external_contact_idvarchar(255)Provider-side contact key (email address, WA wa_id, …). NULL for manual.
last_message_at / last_message_preview / last_directiondenormalizedInbox-list fields, avoiding a per-row join.
last_inbound_attimestamptzDrives WhatsApp’s 24h window later.
unread_countinteger NOT NULL default 0Staff-side; +1 on inbound, cleared on read.
created_at / updated_attimestamptz

Indexes: conversations_org_status_last_msg_idx; conversations_org_assignee_idx; conversations_lead_idx; conversations_contact_user_idx; unique conversations_org_channel_contact_idx (org, channel, external_contact_id) — one thread per contact key per channel per org (idempotent thread resolution).

conversation_messages

ColumnTypeNotes
iduuid PK
conversation_iduuid NOT NULLFK → conversations.id ON DELETE CASCADE.
organization_iduuid NOT NULLDenormalized for direct scoping.
directionmessage_direction enum NOT NULL`inbound
channelconversation_channel enum NOT NULL
bodytextMessage text.
sender_user_iduuidFK → users.id. Staff sender of an outbound; NULL for inbound.
external_message_idvarchar(255)Provider id (email Message-Id, WA wamid). Used for threading + inbound idempotency.
delivery_statusmessage_delivery_status enum NOT NULL default sent`queued
error_messagetextSet when a send fails (surfaced from Resend).
sent_at / read_attimestamptz
metadatajsonbChannel-specific payload.
created_attimestamptz

Indexes: conversation_messages_conversation_created_idx; unique partial conversation_messages_channel_external_id_idx (channel, external_message_id) WHERE external_message_id IS NOT NULL — delivery-receipt match + inbound dedup. Writes use onConflictDoNothing so concurrent deliveries can’t 500.

Relations

  • leads.platformLead — one-to-one optional (platform_leads.lead_id).
  • leads.organizationLead — one-to-one optional (organization_leads.lead_id).
  • leads.statusEvents — many (lead_status_events.lead_id).
  • organization_leads.organization — one.
  • organization_leads.assignedTo — optional one (user).
  • organization_leads.convertedMembership — optional one (membership).

The schema models leads.platformLead and leads.organizationLead as 1:1 — a single leads row is conceptually either a platform lead or an org lead, not both. (There is no UNIQUE constraint enforcing this at the DB layer — TODO: verify whether the service enforces.)

Lifecycle

New lead (org)

  1. INSERT leads (status='new').
  2. INSERT organization_leads referencing it.
  3. INSERT lead_status_events (from=null, to=new).
  4. INSERT tasks of type contact_lead (best-effort).

Status update

  1. UPDATE leads.status + status_changed_at.
  2. INSERT lead_status_events (from=prev, to=new).

Convert

  1. Find-or-INSERT users shell by email.
  2. INSERT memberships (role='member'|input.role, status='active', source_lead_id=leadId).
  3. UPDATE organization_leads.converted_membership_id = newMembership.id.
  4. UPDATE leads.status='converted' + event row.
  5. Emit MEMBERSHIP_ACTIVATED.

Multi-org isolation pattern

  • organization_leads.organization_id is the direct boundary on the staff CRM side.
  • leads itself is shared across orgs (because leads.id can be referenced by 0 or 1 platform_lead and 0+ organization_leads). In practice the dedup check is at the (org, email/phone) level, so an email can have multiple leads rows — one per org — each with its own organization_leads link.
  • Every read joins through organization_leads.organization_id. The leads row is never accessed without the join.

Soft-delete vs hard-delete

  • None for any leads table. There’s no deletion path exposed; lost leads use status='lost' rather than deletion.
  • lead_status_events is the only table with a CASCADE on parent delete (ON DELETE CASCADE if a leads row is hard-deleted — which doesn’t happen in the service).