Docs.

Leads, tags, and account-only reservations

architecture/leads-tags-reservations-refactor.md

Status: implemented (migration 0020_leads.sql). This is the design and execution record for splitting inquiries out of the reservation lifecycle into a first-class leads surface, adding an interest-tag registry, making reservations account-only, and giving the office agent tag + session tools. See office-assistant.md for the agent itself.

Why

An inquiry used to be a reservation in the inquired status — conflating "someone asked about us" with "a priced booking in the pipeline." The reservation create form also owned a heavy contact flow: as staff typed an email/phone it matched existing accounts, find-or-created one, and escalated to a merge when the email and phone resolved to different accounts.

The split:

  • Inquiries are leads (public.leads) — a contact (account) plus notes, a soft budget, a referrer, and interest tags. Lead creation owns the contact create / link / merge flow.
  • Reservations are account-only — the create page links an existing client by autocomplete; the detail page shows their info (it already joins the account). A reservation is born quoted and emails the quote.
  • The inquired lifecycle stage is gone.

Schema (supabase/migrations/0020_leads.sql)

create type public.currency as enum ('USD', 'COP'); -- uppercase, matches OfficeCurrency

create table public.tags (
  id uuid primary key default gen_random_uuid(),
  name text not null, description text,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  deleted_at timestamptz
);
create unique index tags_name_lower_uniq on public.tags (lower(name)) where deleted_at is null;

create table public.leads (
  id uuid primary key default gen_random_uuid(),
  account_id uuid not null references public.accounts (id) on delete cascade,
  notes text, referrer text,
  budget_cents bigint, budget_currency public.currency, -- bigint: COP minor-unit headroom
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  deleted_at timestamptz,
  constraint leads_budget_complete check ((budget_cents is null) = (budget_currency is null))
);

create table public.lead_tags (         -- many-to-many (precedent: account_roles)
  lead_id uuid not null references public.leads (id) on delete cascade,
  tag_id  uuid not null references public.tags  (id) on delete cascade,
  created_at timestamptz not null default now(),
  primary key (lead_id, tag_id)
);

All three tables are RLS-on with no policies (service-role only), carry the fn_set_updated_at trigger where they have updated_at, and the migration is idempotent (guarded enum create + recreate, if not exists tables/indexes, create or replace functions) so Branching replays are no-ops.

Tags use a lead_tags join, not a uuid[] column — real FK integrity on both sides, and tag-merge becomes a simple repoint instead of array surgery.

Budget is budget_cents (minor units) + budget_currency, both-or-neither (the check constraint). bigint because COP minor units overflow int4 (the rest of the app uses integer USD cents).

Functions

  • find_or_create_tag(p_name, p_description?) → uuid — reuse a live name (case-insensitive), else insert, else (lost a race) re-select. Used on lead submit for free-typed tags. Mirrors find_or_create_account.
  • merge_tags(p_winner, p_loser) → jsonb — the older tag wins (enforced): every lead carrying the loser repoints to the winner (skipping leads that already have it), leftover join rows are dropped, the winner inherits a missing description, and the loser is soft-deleted (freeing its name). Mirrors merge_accounts.

Dropping inquired

reservation_status is recreated without inquired (the 0010 pattern — guard on the value existing, drop the column default, rename → __old, create the new enum, alter column … using (case when 'inquired' then 'quoted' …), set default quoted, drop __old). Safe because only reservations.status is typed reservation_status and create_reservation casts text→enum at call time. The remap is defensive — pre-launch had no real inquired rows.

Domain layer

  • src/lib/office/tags.tslistTags({q}) (with lead_tags usage counts), resolveTagInput(entries) (mixed {id}/{name} → ids via find_or_create_tag, de-duped), mergeTags(a, b, actor) (orders older-wins, calls the RPC, audits). Shared by the lead actions and the agent.
  • src/lib/office/leads.tscreateLead (resolve account: pre-resolved accountId else find_or_create_account; insert lead; resolve + insert lead_tags; audit), listLeads (search across the lead's text and its account's name/email), getLead (lead + account + the account's reservations + tags), updateLead, setLeadTags (diff the join), archiveLead (soft-delete).
  • src/lib/office/roles.ts — new manager-writable "leads" section.

UI

  • /office/leads — list (search + paginate), new lead action.
  • /office/leads/createLeadForm: the shared ClientContactFields (the moved create/link/merge flow) + TagInput + budget (amount + currency)
    • referrer + notes.
  • /office/leads/[id] — detail: client info, the account's reservations, a "create reservation" action → /office/reservations/create?account=<id> (the lead→booking handoff), and LeadEditPanel (in-place edit of details/tags, archive).
  • src/components/office/TagInput.tsx — multi-select chips + autocomplete; Enter adds a tag even if it doesn't exist yet (shown "· new"), find-or-created on submit. Sibling to CatalogSearch (single-shot pick).
  • client-actions.ts moved reservations/leads/ (lookup by email/phone, compareAccounts, mergeAccountsAction); the merge gate is now requireWrite("leads").

Reservations become account-only

ReservationForm swaps ClientContactFields for a CatalogSearch client picker (link an existing account; empty-state links to "create a lead"); the status <select> is gone. createReservation drops the contact fields + status, requires accountId, keeps only the validate-existing-account branch, and is born quoted. RESERVATION_TRANSITIONS loses the inquired key (which also removes the list filter pill). The inquired stage is removed from the timeline, status tones, the finance pipeline, the calendar legend, and the chat-tool copy.

Agent tools (agent-policy.ts + agent-tools.ts)

Three new permission groups (rendered automatically by the permissions matrix):

GroupDefaultTools
tags_readstaffsearchTags (name/description + usage count)
tag_merge_writesadministratormergeTags (approval-carded; older wins)
sessions_readadministratorsearchSessions, readSession (transcripts)

sessions_read defaults to administrator because transcripts can hold other staffers' chats (tighten to superadmin in the matrix if desired). createReservationDraft was reworked to require an existing accountId (no inline contact creation, matching the account-only rule).

Verification

  • supabase db reset --local (clean 0001 → 0020 replay) + re-apply 0020 (idempotent); pnpm db:synthetic.
  • pnpm tsc --noEmit && pnpm lint && pnpm test && pnpm test:e2e — including tests/e2e/leads.e2e.test.ts (createLead mint + link-existing, find_or_create_tag reuse, merge_tags older-wins + repoint).
  • In the office: create a lead (new email → account; matching → link; conflicting email+phone → merge), add an existing + a brand-new tag; the lead's "create reservation" prefills the client; /office/reservations/create shows only the autocomplete and lands quoted; the reservations list has no inquired pill; the agent's new groups appear at /office/agent/permissions.

Seed

supabase/seed-synthetic.sql replaces its 3 inquired reservations with 3 leads (prefix cccccccc-…) over a small tags set (eeeeeeee-…) linked via lead_tags.