The Flows feature was previously gated on a per-account
`profiles.beta_features` opt-in: the sidebar entry was invisible
to non-beta accounts, every /api/flows route 404'd, the webhook
dispatcher was a no-op, and the agent-handoff pause was skipped.
This PR opens the surface to every authenticated user. A small
"Beta" chip in the sidebar nav + on the /flows page H1 keeps the
"young feature" signal without holding anyone out.
Removed:
- `src/lib/flows/feature-flag.ts` + its tests (-11 tests).
- The `requireFlowsBeta` / `requireFlowsBetaAndOwnership` helpers
in the API routes — collapsed to plain `requireUser` / RLS
ownership.
- The `isProfileFlowsEnabled` helper + branch in the webhook
dispatcher — the runner takes the cheap "no_match" exit on
accounts with no active flows (single indexed SELECT).
- The agent-handoff pause gate in /api/whatsapp/send — the UPDATE
matches zero rows when no active run exists, also cheap.
- Client-side beta gates in the 3 flows pages (and the redundant
`useAuth` + `profileLoading` plumbing they needed).
- The defensive message-bubble gate that would have rendered
interactive replies as plain text for non-beta accounts.
- The `betaKey` field on the sidebar NavItem schema, replaced by a
simpler boolean `beta` that purely drives the visual chip.
Kept:
- `profiles.beta_features` column. Generic mechanism for future
beta gates; no current feature reads it. Comments in
`src/types/index.ts` + `use-auth.tsx` updated to say so.
- The chip can be removed in a one-liner once the team's satisfied
Flows has bedded in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation for the upcoming WhatsApp chatbot flow builder. No runner,
no UI yet — just the plumbing that lets the codebase SEND interactive
messages (button replies + lists) and RECEIVE the taps as structured
message rows. Plan: ~/.claude/plans/understood-research-such-functionality-binary-lake.md
## What's in this PR
### Migration 010_flows.sql
- New tables: flows, flow_nodes, flow_runs, flow_run_events
- Linchpin partial unique index on flow_runs(user_id, contact_id) WHERE status='active' — at most one active run per contact, makes concurrent webhook deliveries idempotent at the DB layer
- Widens messages.content_type CHECK to allow 'interactive'
- New messages.interactive_reply_id column for the tapped button/row id
- RLS on every table; flow_runs added to supabase_realtime publication
- Idempotent throughout — matches the 009_message_actions.sql template
### Interactive Meta senders (src/lib/whatsapp/meta-api.ts)
- sendInteractiveButtons: type='interactive', interactive.type='button', up to 3 reply buttons
- sendInteractiveList: type='interactive', interactive.type='list', up to 10 rows across sections
- Limits exported as INTERACTIVE_LIMITS so flow validation can share the constants
- All Meta caps validated BEFORE the network call (button title ≤20 chars, list row title ≤24 chars, ≤10 total rows, no duplicate row ids, body ≤1024 chars, etc.) — misconfigured flows fail at save time instead of as a customer-facing 400 mid-conversation
### Webhook (src/app/api/whatsapp/webhook/route.ts)
- Extends WhatsAppMessage interface with `interactive?: { type, button_reply?, list_reply? }`
- parseMessageContent returns a new `interactiveReplyId` field; refactored to use a shared `empty` base so every return site picks up the new field without 9 explicit copies
- New `case 'interactive'`: uses the tapped option's TITLE as content_text (so the inbox bubble reads "Existing customer", not a UUID) and stashes the id separately
- 'interactive' added to ALLOWED_CONTENT_TYPES so the messages insert no longer coerces it to 'text'
### Inbox bubble (src/components/inbox/message-bubble.tsx)
- New `case "interactive"` in MessageContent renders the tap with a small "↩ Button reply" affordance so agents reading the inbox can tell at a glance that this was a tap rather than the customer typing the same words
### Types (src/types/index.ts)
- ContentType union extended with 'interactive'
- Message.interactive_reply_id field
### Engine helpers (src/lib/flows/)
- admin-client.ts: thin service-role client wrapper, mirrors automations/admin-client.ts
- meta-send.ts: engineSendInteractiveButtons / engineSendInteractiveList — adapts the Meta senders for service-role use, persists messages with sender_type='bot' content_type='interactive', uses the same phone-variant retry as automations/meta-send.ts. Unused in PR #1; ready for the runner in PR #2
### Tests
- 13 new vitest cases in meta-api.test.ts covering Meta's limit validation + payload-shape assertions for both interactive senders
## Validation
- npm test → 102/102 (was 89; 13 new)
- npm run lint → 0 errors
- npm run typecheck → clean
- npm run build → clean
## What's NOT in this PR
- The runner (dispatchInboundToFlows) — PR #2
- Builder UI / API surface — PR #3
- collect_input / condition / set_tag nodes + templates — PR #4
- react-flow canvas + http_fetch — v2 (PR #5)
## Risks tracked from the plan
- messages.content_type CHECK widening: grep'd every consumer; message-bubble.tsx, reply-quote.tsx, send/route.ts, automations/meta-send.ts all handle the new variant correctly (or accept it as a defaulted fallthrough)
- engineSendText / engineSendInteractive share ~80% of code; deliberately not pre-factored yet — let both stabilize, extract a shared base in PR #2 once the shape settles
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Scaffolding for the upcoming WhatsApp Flows feature. The whole Flows
stack (interactive button/list send + parse, server-side runner, no-
code builder, templates) will land across the next ~4 PRs *behind*
this gate so the owner can dogfood the feature on their own account
before exposing it to teammates or downstream pullers of the OSS.
What this lands:
1. `profiles.beta_features TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]`
— generic opt-in column, lives on the existing profiles table.
Empty by default for every existing row.
2. `src/lib/flows/feature-flag.ts` — `isFlowsEnabled(profile)`
predicate + `BETA_FEATURE_FLOWS` constant. Single source of
truth for the gate; later PRs import this in every guarded
surface (sidebar nav, /flows page, /api/flows routes, webhook
dispatcher).
3. `Profile.beta_features` added to the shared TS type and the
auth hook's local copy. Auth hook loads the column and
defaults it to `[]` defensively if the DB read returns null.
4. 11 unit tests covering null/undefined/empty/mismatched cases,
including case sensitivity (only lowercase `'flows'` opts in)
and defensive guards against malformed input.
How the owner enables it for their own account, once merged:
UPDATE profiles
SET beta_features = array_append(beta_features, 'flows')
WHERE user_id = '<their-auth-uid>';
How we ship it to everyone, when ready: grep-and-delete the
`isFlowsEnabled()` callsites in one cleanup PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds three WhatsApp-standard affordances to the inbox: per-message
emoji reactions, reply-with-quote, and copy-text. Bubbles stay pure
presenters — actions layer on by composition (MessageActions wraps
MessageBubble). Hover toolbar on desktop, long-press (onContextMenu)
on touch.
Data model (migration 009):
- messages.reply_to_message_id (self-FK, ON DELETE SET NULL) so replies
survive a deleted parent
- message_reactions table — one row per (message, actor) with a unique
constraint, conversation_id denormalised so Supabase Realtime can
filter without a join
- RLS scoped via conversation ownership; service-role bypass for the
webhook
API:
- /api/whatsapp/send accepts reply_to_message_id, resolves the parent's
Meta message_id and forwards it as context.message_id
- /api/whatsapp/react (new) sends Meta reaction payloads and mirrors
the upsert/delete into message_reactions
- Webhook stops storing inbound reactions as fake text messages and
instead upserts/deletes message_reactions; inbound replies (context.id)
resolve to internal UUID and populate reply_to_message_id
UI:
- MessageActions hover toolbar with inline 6-emoji Popover (static set;
no emoji-picker library)
- ReplyQuote shared between the in-bubble quote and the composer chip
- MessageReactions pill row with click-to-toggle
- Thread subscribes to message_reactions filtered by conversation_id;
optimistic upsert with rollback on failure
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
new_contact_created only fires when the webhook auto-creates the
contact row, so it misses the case of manually-added / imported
contacts sending their first-ever message.
first_inbound_message fires whenever the incoming message is the
contact's first customer-sent message (checked by counting prior
customer-sender messages in the conversation before insert), which
covers both auto-created and pre-existing contacts.
- Added first_inbound_message to AutomationTriggerType.
- New pill color + dropdown entry labeled "First Message from Contact".
- Webhook now counts prior customer messages before inserting the
incoming one; dispatches first_inbound_message when the count is 0.
- new_contact_created still fires for brand-new contacts; they're
independent triggers with overlapping coverage, so users pick.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the /automations placeholder with a full feature:
- Migration 006 adds automations, automation_steps, automation_logs,
and automation_pending_executions with RLS matching existing tables.
- List page with active/inactive switch, quick-start templates,
duplicate, delete, and View Logs actions.
- Full-screen vertical flow builder with Trigger + Action + Condition
(Yes/No branches) + Wait cards. Inline edit forms, up/down reorder,
dotted-grid canvas.
- Execution engine (src/lib/automations/engine.ts) dispatched from the
WhatsApp webhook for new_message_received, new_contact_created, and
keyword_match. Wait steps enqueue to automation_pending_executions;
/api/automations/cron drains them behind AUTOMATION_CRON_SECRET.
- Logs viewer with per-step checkmarks and expandable detail.
Existing files touched only in webhook/route.ts (fire-and-forget
trigger dispatch) and types/index.ts (appended automation types).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause (error 23503):
broadcast_recipients.contact_id and deals.contact_id were declared
NOT NULL REFERENCES contacts(id) with no ON DELETE clause, so
Postgres defaulted to NO ACTION. Any contact who'd ever received a
broadcast or been attached to a deal became un-deletable with
"violates foreign key constraint broadcast_recipients_contact_id_fkey".
Fix (migration 004):
Relax both FKs to ON DELETE SET NULL + drop NOT NULL on both
columns. History rows survive with contact_id = NULL. The UI is
already null-safe:
- broadcast detail: contact?.name ?? 'Unknown'
- deal card: contact?.name || contact?.phone || 'No contact'
- CSV export: contact?.name ?? ''
Why not CASCADE:
CASCADE would wipe broadcast recipient rows and trigger the
aggregate recount on broadcasts (migration 003), retroactively
changing sent_count / delivered_count / read_count. It would also
delete deal history. SET NULL preserves both counts and history.
Type/code:
- Deal.contact_id + BroadcastRecipient.contact_id widen to
string | null
- DealForm: setContactId(deal.contact_id ?? "") — the contact
picker uses "" to mean unselected
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three correctness bugs were making the existing broadcast flow wrong,
not just incomplete.
1. Per-recipient variables were broken
use-broadcast-sending.ts computed templateParams per contact but shipped
only templateParams[0] to the API, so every contact in a batch received
contact 0's personalization (name/email/etc).
Fix: extend POST /api/whatsapp/broadcast to accept a per-recipient
shape `{ recipients: [{ phone, params }], ... }`. Legacy
`{ phone_numbers, template_params }` still accepted. Hook sends per-
recipient payloads; API returns whatsapp_message_id per phone.
2. Webhook never updated broadcast_recipients
handleStatusUpdate in webhook/route.ts only mirrored Meta status onto
the `messages` table. broadcast_recipients had no correlation column,
so delivered_count / read_count / replied_count never advanced for
real broadcasts.
Fix (migration 003):
- broadcast_recipients.whatsapp_message_id (+ UNIQUE index) for
Meta-message-id correlation
- composite index (broadcast_id, status) for fast aggregate scans
- AFTER INSERT/UPDATE/DELETE trigger that recomputes parent
broadcasts counts via COUNT(*) FILTER — sender + webhook only
touch the recipient row, aggregates stay consistent
Webhook:
- handleStatusUpdate also updates broadcast_recipients with forward-
only status guard (read cannot regress to sent on a replay)
- processMessage flags the replied recipient row when an inbound
message arrives from a recent broadcast contact
3. Rate limiting did not match spec
Batch 50 / no delay → Batch 10 / 1s between batches, matching the spec
and Meta's per-phone-number rate ceiling.
Also: extract statusConfig from both page components into
src/lib/broadcast-status.ts (single source of truth for PR B). Add
whatsapp_message_id to BroadcastRecipient type.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rebuild the Pipelines page to match spec.
Highlights:
- Drag-and-drop deal cards between stages via @dnd-kit (200ms drop animation,
emerald dashed drop zone, opacity fade while dragging)
- First-visit seed: auto-creates "Sales Pipeline" with spec-defined stages
(New Lead / Qualified / Proposal Sent / Negotiation / Won)
- Top bar: always-visible pipeline dropdown with "Manage Pipelines" option,
plus "Add Pipeline" and emerald "Add Deal" buttons
- Deal form converted from Dialog to right-side Sheet, adds assigned_to
(profile FK), "Link to Conversation" shortcut, "Mark as Won/Lost" status
buttons, and "Delete Deal"
- Pipeline settings modal: drag-to-reorder stages (@dnd-kit sortable),
per-stage color picker, "Create new pipeline" shortcut, archive
confirmation copy
- Analytics bar below board: total deals, pipeline value, avg deal size,
weighted pipeline value (probability interpolated across stages),
won/lost this month
- Contact detail sheet gains a "Deals" tab showing all deals for that
contact (value + stage badge + status)
- Migration 002: adds deals.assigned_to FK→profiles, normalizes status
CHECK to ('open','won','lost')
Note: spec called for light-gray Kanban columns (#f9fafb) + white cards
inside an otherwise-dark app. Followed spec literally; easy to retheme.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>