PR 8 of the multi-user accounts series. Flips the WhatsApp webhook,
automations engine, and flows engine from per-user tenancy to
per-account so a shared inbox actually behaves like one.
Why this matters
- Pre-this-PR: an inbound message resolved the recipient by
whatsapp_config.user_id and dispatched only that user's
automations / flows. Teammates joining via an invite would see
the message in the shared inbox but their automations / flows
never fired.
- Also fixes incipient NOT NULL violations from migration 017.
That migration added `account_id NOT NULL` to automation_logs,
automation_pending_executions, flow_runs, and deals. Inserts in
the engines hadn't been updated yet — any production run would
500 with a column violation. This PR adds account_id to every
such insert.
Surface
- `src/app/api/whatsapp/webhook/route.ts`
- Resolves `config.account_id` AND `config.user_id` from the
matched whatsapp_config row. Passes accountId through every
downstream call. user_id is kept as `configOwnerUserId` and
used only for NOT NULL FK audit columns on contact /
conversation inserts.
- `findOrCreateContact` / `findOrCreateConversation` look up by
account_id and insert with both columns.
- `flagBroadcastReplyIfAny` matches broadcasts by account_id so
any teammate's broadcast is updated when a customer replies.
- `dispatchInboundToFlows` and `runAutomationsForTrigger` now
take `accountId` instead of `userId`.
- `src/lib/automations/engine.ts`
- `DispatchInput.userId` → `accountId`.
- `runAutomationsForTrigger` queries automations by account_id.
- `executeAutomation` inserts into automation_logs with both
account_id (tenancy) and user_id (audit, from automation row).
- `wait` step inserts into automation_pending_executions with
account_id.
- `create_deal` step inserts into deals with account_id.
- `assign_conversation` / `close_conversation` /
`resolveConversationId` filter by account_id.
- `resumePendingExecution` row shape now includes account_id;
cron passes it through.
- engineSendText / engineSendTemplate calls pass
`accountId: args.automation.account_id`.
- `src/lib/flows/engine.ts`
- `DispatchInboundInput.userId` is kept (audit) and
`accountId` is added (tenancy).
- `loadActiveRunForContact`, `isDuplicateInbound`,
`findEntryFlow` all query by account_id.
- `startNewRun` inserts into flow_runs with account_id.
- All `engineSend*` calls pass `accountId: run.account_id`.
- `src/lib/automations/meta-send.ts` + `src/lib/flows/meta-send.ts`
- All four engine senders (text, template, media, interactive
buttons/list) accept `accountId` and look up the contact +
whatsapp_config by account_id. user_id stays for inserts.
- `src/lib/flows/types.ts`
- `FlowRow`, `FlowRunRow`, `DispatchInboundInput` gain
`account_id` / `accountId`.
- `src/types/index.ts`
- `Automation` gains `account_id`. Doc note on the user_id
field clarifying it's audit-only post-multi-user.
- `src/app/api/automations/cron/route.ts`
- Pass account_id from the pending row to resumePendingExecution.
- `src/app/api/automations/engine/route.ts`
- Uses `getCurrentAccount()` from #169 to resolve the caller's
account, then dispatches with accountId.
- `CHANGELOG.md` — Fixed entry explaining the inbox / NOT NULL fix.
Backwards compatibility
- All affected tables already have account_id from migration 017.
This PR populates it on insert paths that hadn't been updated.
- The `user_id` columns are kept everywhere — they still identify
the agent / author for audit display. They just no longer
enforce tenancy.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean (had 4 errors mid-refactor; all
resolved by the call-site updates)
- `npm test` → 366/366 pass (engine tests are pure unit
tests over helper functions; not affected)
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets flows attach an image, video, or document (e.g. PDF invoice /
receipt) to a customer message — the most-requested gap in v1 was that
nodes were text-only. Single node with a `media_type` discriminator
rather than three node types: Meta's wire payload differs only in the
top-level key, so three types would triple the surface area (forms,
engine cases, menu entries) for no behavioural difference.
Files are uploaded directly from the builder to a new public
`flow-media` Supabase Storage bucket (per-user RLS, 16 MB cap, MIME
allowlist) and the resulting public URL is what Meta fetches at send
time. Same trade-off as the existing avatars bucket.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small things noticed in review:
1. handleReplyForActiveRun did UPDATE reprompt_count=0 → SELECT *
the whole row → use it. The re-read existed because the
collect_input capture branch wrote `vars` to the DB without
mirroring the change to the local `run` object — so the next
advance step would interpolate stale vars otherwise.
Mirror the UPDATE in-memory instead. Also skip the reset UPDATE
when reprompt_count is already 0 (the common case after a
successful collect_input — that branch already zeroed it).
2. loadActiveRunForContact used .maybeSingle() which throws on >1
row. The partial unique index makes that impossible by design,
but a migration glitch or manual SQL could produce one and would
crash dispatch for that contact's webhook forever. Switch to
.order().limit(1) so the runner picks the newest and lets the
cron sweep clean up duplicates if any ever appear.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The advance loop called loadNode() on every iteration — one DB
round trip per node visited. A flow with 5 chained auto-advance
nodes (send_message → condition → set_tag → send_message → end)
cost 5 sequential round trips synchronous in the webhook hot path.
At ~50ms each that's 250ms before the customer sees anything.
Switch to a single upfront SELECT into a Map<node_key, FlowNodeRow>
keyed on the flow_id, passed through advanceFromNodeKey and
handleReplyForActiveRun. The advance loop is now in-memory and the
hot path costs one round trip regardless of chain length.
node_not_found semantics are preserved: missing keys still end the
run with that reason, same as the old per-node lookup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
startNewRun read flows.execution_count from a cached row and wrote
`cached + 1`. Two concurrent webhooks starting runs on the same flow
for different contacts both read N and both write N+1 — one count
permanently lost.
Mirrors the automations engine's solution (migration 007): a tiny
SECURITY DEFINER function that does the increment server-side, locked
to the service role. The engine's call becomes a single RPC.
The counter is best-effort: if the RPC fails we log + continue rather
than failing the run for a stat error.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `reply_received` event was persisting `text: message.text` verbatim
into flow_run_events.payload. A customer prompted "what's your card
number?" by a collect_input node would leave the PAN sitting in the
events table forever, surfaced via the runs viewer to anyone with
access.
Switch to `text_length` only. The `node_entered` event already records
`captured_length` for the same reason after a successful capture; this
makes the asymmetry symmetric.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final feature PR before GA cleanup. Closes the gap between "flow
runner works" (PR 2) and "users have everything they need to build
real automation" — three new node types, plain-text sends actually
sending, three starter templates, a run-history page for debugging.
What landed:
1. engineSendText helper (src/lib/flows/meta-send.ts) — wraps
sendTextMessage with the phone-variant retry + DB persistence
pattern. Unblocks send_message + collect_input nodes that
previously auto-advanced without sending.
2. Three new node types in the engine:
- collect_input: sends a prompt, suspends, captures the next
text reply into flow_runs.vars[var_key], advances. Supports
{{vars.X}} interpolation in downstream prompts + handoff
notes.
- condition: branches on subject (var / tag / contact_field)
with operators (equals / contains / present / absent).
Auto-advancing, no Meta call. Pure predicate logic exported
as evaluateConditionPredicate with 7 new unit tests.
- set_tag: upsert/delete on contact_tags then advance. Non-
fatal on write failure — logged + advances anyway so a tag
glitch doesn't strand the customer.
3. Real send_message — calls engineSendText, logs the result,
handles errors by ending the run with status='failed'. Same
{{vars.X}} interpolation.
4. Three starter templates (src/lib/flows/templates.ts):
- Welcome menu (the owner's brief example)
- FAQ bot (keyword → list → answers → end)
- Lead capture (first_inbound → collect_input chain → handoff
with vars in the note)
POST /api/flows now accepts {template_slug} to clone a
template into a fresh draft. GET /api/flows/templates exposes
the gallery shape (no nodes payload — fetched on actual clone).
5. Builder UI extensions:
- NODE_META + config sub-forms for collect_input, condition,
set_tag.
- Template gallery on the /flows New-flow dialog (loads from
/api/flows/templates, falls back gracefully if absent).
- "Runs" button in the editor header → /flows/[id]/runs.
6. Run-history viewer at /flows/[id]/runs — newest-first list of
the 50 most recent runs, with expandable per-run event
timeline (started, node_entered, message_sent,
reply_received, fallback_fired, handoff, timeout, error,
completed). Useful for debugging "why didn't my flow advance?"
from the brief's use case.
7. Validator updates: new error rules for each new node type
(prompt_text required, var_key alphanumeric, operator valid,
true_next/false_next exist, etc.). outgoingEdges helper now
covers collect_input/condition/set_tag for reachability.
All gated behind isFlowsEnabled(profile). Non-beta accounts see no
change. Run-history API is also 404 for non-beta.
Validation: 173/173 tests pass (added 7 new for the predicate);
typecheck + lint + build all clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings the Flows feature alive end-to-end for beta-opted-in accounts.
Still no UI — flows are defined via direct SQL inserts in v1; the
no-code builder lands in PR 3. Everything in this PR is gated behind
`isFlowsEnabled(profile)` so non-beta accounts behave identically to
before.
What landed:
1. `src/lib/flows/types.ts` — DB row + discriminated-union node
config types, plus the `ParsedInbound` shape and a
`DEFAULT_FALLBACK_POLICY`.
2. `src/lib/flows/fallback.ts` — pure policy resolver. Decides
whether an unknown reply triggers a reprompt (up to N times),
a handoff, or end. 11 unit tests.
3. `src/lib/flows/engine.ts` — `dispatchInboundToFlows()` entry
point. Loads the active run (if any), checks idempotency via
`flow_run_events`, advances the run through a synchronous
auto-advance loop that suspends only at nodes that need
customer input. Each suspending node calls
`engineSendInteractive{Buttons,List}` and persists the new
current_node_key via an optimistic UPDATE with a precondition
on the OLD key — concurrent inbounds collide cleanly.
11 unit tests for the pure-logic helpers.
4. `src/app/api/whatsapp/webhook/route.ts` — invokes the runner
between the broadcast-reply flag and the automation dispatch.
When the runner consumes the inbound (started a flow, advanced
one, applied a fallback), suppresses `new_message_received`
and `keyword_match` automation triggers — the customer is
navigating the bot menu, not throwing a keyword. Relationship
triggers (new_contact_created, first_inbound_message) still
fire regardless.
5. `src/app/api/whatsapp/send/route.ts` — after a successful
agent message, marks any active flow run for that contact as
`paused_by_agent`. Best-effort: a write failure is logged but
doesn't block the send response.
6. `src/app/api/flows/cron/route.ts` — sweeps active runs whose
`last_advanced_at` is older than the flow's
`fallback_policy.on_timeout_hours` (default 24h). Marks them
`timed_out` and writes a `flow_run_events.timeout` row. Re-uses
`AUTOMATION_CRON_SECRET` so operators don't have to provision
a second secret.
Test surface in this PR:
- 142/142 tests pass (added 22 new unit tests across fallback +
engine pure logic)
- typecheck clean
- 0 new lint errors
- build clean
Manual end-to-end verification arrives via the SQL fixture in the
PR description (a 3-node Welcome → buttons → handoff flow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>