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
quotedand emails the quote. - The
inquiredlifecycle 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. Mirrorsfind_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). Mirrorsmerge_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.ts—listTags({q})(withlead_tagsusage counts),resolveTagInput(entries)(mixed{id}/{name}→ ids viafind_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.ts—createLead(resolve account: pre-resolvedaccountIdelsefind_or_create_account; insert lead; resolve + insertlead_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 leadaction./office/leads/create—LeadForm: the sharedClientContactFields(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), andLeadEditPanel(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 toCatalogSearch(single-shot pick).client-actions.tsmovedreservations/→leads/(lookup by email/phone,compareAccounts,mergeAccountsAction); the merge gate is nowrequireWrite("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):
| Group | Default | Tools |
|---|---|---|
tags_read | staff | searchTags (name/description + usage count) |
tag_merge_writes | administrator | mergeTags (approval-carded; older wins) |
sessions_read | administrator | searchSessions, 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(clean0001 → 0020replay) + re-apply0020(idempotent);pnpm db:synthetic.pnpm tsc --noEmit && pnpm lint && pnpm test && pnpm test:e2e— includingtests/e2e/leads.e2e.test.ts(createLead mint + link-existing,find_or_create_tagreuse,merge_tagsolder-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/createshows only the autocomplete and landsquoted; the reservations list has noinquiredpill; 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.