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

Indexes: organization_leads_org_created_idx (org, created_at); organization_leads_org_assignee_idx (org, assignee).

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

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