Href Creative Docs
HREF Team Docs

Database schema reference

The schema is defined in supabase/migrations/0001_initial_schema.sql. That file is the source of truth; this page is a navigable summary.

Tables

tenants

The workspace itself. One row per client.

ColumnTypeNotes
iduuid PKgen_random_uuid() default
slugtext UNIQUEsubdomain; matches ^[a-z0-9][a-z0-9-]{0,62}$
nametextdisplay name
logo_urltext NULLSupabase Storage URL once Phase 7 lands
primary_colortext NULLhex like #18181b
taglinetext NULLsmall descriptor on splash
api_key_hashtextSHA-256 of the plaintext key
settingsjsonbopen-ended per-tenant settings
created_at, updated_attimestamptzupdated_at via trigger

Indexes: slug.

tenant_members

Bridge table between auth.users and tenants. A single user can belong to multiple tenants.

ColumnTypeNotes
iduuid PK
tenant_iduuid FK → tenants ON DELETE CASCADE
user_iduuid FK → auth.users ON DELETE CASCADE
roletext DEFAULT 'member'enum-checked: 'member' | 'admin' | 'manager'
invited_byuuid FK → auth.users ON DELETE SET NULL
joined_attimestamptz

Constraints: UNIQUE (tenant_id, user_id). Indexes: tenant_id, user_id.

tenant_invites

Outstanding invitations. Once accepted, the row stays for audit (with accepted_at set).

ColumnTypeNotes
iduuid PK
tenant_iduuid FK
emailtextlowercased on insert
tokentext UNIQUE32 random bytes, base64url
roletext DEFAULT 'member'same enum as tenant_members.role
invited_byuuid FK → auth.users NULL
expires_attimestamptznow + 7 days at provisioning time
accepted_attimestamptz NULLset on acceptance
created_attimestamptz

Indexes: tenant_id, (tenant_id, email), token.

pipeline_stages

Per-tenant kanban columns.

ColumnTypeNotes
iduuid PK
tenant_iduuid FK
nametext"New", "Contacted", etc.
colortext DEFAULT '#71717a'hex
sort_orderinteger1, 2, 3, ...
stage_typetext DEFAULT 'active'enum: 'active' | 'won' | 'lost'
created_attimestamptz

Indexes: (tenant_id, sort_order).

Default stages are seeded by seed_default_pipeline_stages(tenant_id) (see Helper functions below).

leads

The core record. One row per lead, regardless of source.

ColumnTypeNotes
iduuid PK
tenant_iduuid FK
first_name, last_nametext NULL
email, phonetext NULLat least one of the two is required
street, city, state, ziptext NULLoptional address fields
sourcetext NULL"website-contact-form", "phone-walk-in", etc.
utm_source ... utm_contenttext NULLfull UTM set
metadatajsonb DEFAULT '{}'open-ended per-lead extras (loan amount, case type, anything the form sends)
statustext DEFAULT 'new'enum: 'new' | 'open' | 'won' | 'lost'
pipeline_stage_iduuid FK NULLwhich kanban column
assigned_touuid FK → auth.users NULLv2 hook, hidden in v1 UI
created_at, updated_attimestamptztrigger updates updated_at

Constraints: email IS NOT NULL OR phone IS NOT NULL. Indexes: (tenant_id, created_at desc), (tenant_id, email), (tenant_id, phone), (tenant_id, pipeline_stage_id), (tenant_id, assigned_to).

lead_notes

Free-text notes attached to a lead.

ColumnTypeNotes
iduuid PK
tenant_iduuid FK
lead_iduuid FK ON DELETE CASCADE
author_iduuid FK → auth.users NULLwho wrote it
bodytext
created_attimestamptz

Indexes: (lead_id, created_at desc), tenant_id.

lead_activities

Auto-generated audit log for a lead. v1 does not allow manual activity insertion; only system code writes here.

ColumnTypeNotes
iduuid PK
tenant_iduuid FK
lead_iduuid FK ON DELETE CASCADE
typetextenum below
datajsonb DEFAULT '{}'event-specific payload
actor_iduuid FK → auth.users NULLnull for API-created
created_attimestamptz

Activity types (type enum):

  • created — lead row was created
  • stage_changed — pipeline stage updated; data: { from_stage_id, to_stage_id }
  • status_changed — status updated; data: { from, to }
  • note_added — new lead_notes row; data: { note_id }
  • task_added — new tasks row linked to this lead; data: { task_id }
  • task_completed — task linked to this lead was completed; data: { task_id }
  • lead_assignedassigned_to updated (v2); data: { from_user_id, to_user_id }

Indexes: (lead_id, created_at desc), tenant_id.

tasks

Follow-ups, optionally linked to a lead.

ColumnTypeNotes
iduuid PK
tenant_iduuid FK
lead_iduuid FK ON DELETE CASCADE NULLoptional
assigned_touuid FK → auth.users NULLv2 hook, ignored in v1 UI
titletext
due_attimestamptz NULL
completed_attimestamptz NULLnon-null = task is done
created_byuuid FK → auth.users NULL
created_at, updated_attimestamptz

Indexes: (tenant_id, due_at) WHERE completed_at IS NULL (open-tasks index), lead_id, (tenant_id, assigned_to).

Row-Level Security

Every table above (except auth.users, which Supabase manages) has RLS enabled. The pattern is consistent: gate by is_tenant_member(tenant_id).

Helper function

CREATE OR REPLACE FUNCTION is_tenant_member(target UUID)
RETURNS BOOLEAN
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = public
AS $$
  SELECT EXISTS (
    SELECT 1 FROM tenant_members
    WHERE tenant_id = target AND user_id = auth.uid()
  );
$$;

SECURITY DEFINER and the explicit search_path are intentional: the function runs with the schema owner's privileges and a locked-down search path so it cannot be hijacked by callers manipulating their session.

Policy summary

TableSELECTINSERTUPDATEDELETE
tenantsmembers of self(service-role only)(service-role only)(service-role only)
tenant_membersmembers of same tenant(service-role only)(service-role only)(service-role only)
tenant_invitesmembers of same tenant(service-role only)(service-role only)(service-role only)
pipeline_stagesmembersmembersmembersmembers
leadsmembersmembersmembersmembers
lead_notesmembersmembers & author=selfmembers & author=selfmembers & author=self
lead_activitiesmembers(service-role only)(service-role only)(service-role only)
tasksmembersmembersmembersmembers

"Service-role only" means the policy returns false for any normal authenticated request, and we only mutate via the admin Supabase client (which bypasses RLS entirely). This applies to provisioning, intake API, invite acceptance, etc.

Triggers

set_updated_at()       -- BEFORE UPDATE on tenants, leads, tasks
                       -- bumps updated_at to now()

Helper functions

seed_default_pipeline_stages(target_tenant UUID) RETURNS VOID

Inserts the five default stages (New / Contacted / Qualified / Won / Lost) for a freshly created tenant. Called by the provisioning script.

Realtime publication

ALTER PUBLICATION supabase_realtime ADD TABLE
  leads,
  lead_activities,
  lead_notes,
  tasks;

Authenticated browser clients subscribe to tenant:<id> channels. RLS still applies to the publication, so a client only sees broadcasts for tenants they're a member of.

Migrations

All schema changes go through new SQL files in supabase/migrations/000N_*.sql. Apply locally:

docker exec -i supabase-db psql -U postgres -d postgres < supabase/migrations/000N_*.sql

We do not (yet) use the Supabase CLI's migration runner because the self-hosted stack at localhost:8000 is not registered as a "linked" Supabase project. Direct psql is fine for our scale and gives us full control.

Sample queries

A tenant's open leads (as the tenant's own user, RLS-gated):

SELECT id, first_name, last_name, email, source, status, pipeline_stage_id, created_at
FROM leads
WHERE status IN ('new', 'open')
ORDER BY created_at DESC
LIMIT 50;

Activity timeline for a single lead:

SELECT type, data, actor_id, created_at
FROM lead_activities
WHERE lead_id = $1
ORDER BY created_at DESC;

Cross-tenant query (run as the service-role to verify RLS isolation):

-- as a normal authed user this returns ZERO rows for any tenant the user
-- is not a member of. as service-role, it returns everything.
SELECT tenant_id, COUNT(*) FROM leads GROUP BY tenant_id ORDER BY 2 DESC;

On this page