wacrm/src/lib/flows/meta-send.ts
Arnas Donauskas ed15493f95
feat(flows): templates + v1.5 nodes (collect_input/condition/set_tag) + run-history viewer (PR 4/4)
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>
2026-05-22 14:27:01 +03:00

309 lines
9.3 KiB
TypeScript

import {
sendInteractiveButtons,
sendInteractiveList,
sendTextMessage,
type InteractiveButton,
type InteractiveListSection,
} from '@/lib/whatsapp/meta-api'
import { decrypt } from '@/lib/whatsapp/encryption'
import {
sanitizePhoneForMeta,
isValidE164,
phoneVariants,
isRecipientNotAllowedError,
} from '@/lib/whatsapp/phone-utils'
import { supabaseAdmin } from './admin-client'
// ------------------------------------------------------------
// Flows-side Meta sender (interactive variants).
//
// Mirrors src/lib/automations/meta-send.ts (engineSendText /
// engineSendTemplate) but emits interactive button + list messages.
// Kept separate from the automations file so the two engines don't
// fight over each other's shape — once both stabilize, the
// phone-variant retry + DB persistence are obvious extraction
// candidates into a shared base.
//
// PR #1 ships this in isolation: callers don't exist yet. PR #2
// brings the flow runner online and wires it up. Shipping it now
// keeps the foundation PR self-contained and unit-testable.
// ------------------------------------------------------------
interface SendTextEngineArgs {
userId: string
conversationId: string
contactId: string
text: string
}
/**
* Send a plain-text WhatsApp message from the Flows engine.
*
* Used by the runner's `send_message` and `collect_input` nodes —
* both prompt the customer with text and either auto-advance (the
* send_message case) or suspend awaiting a text reply (collect_input).
*
* Wraps the same phone-variant retry + DB persistence pattern as the
* interactive senders; the duplication will be DRY'd into a shared
* `engineSendBase` once the v2 features (templates with variables,
* media sends) settle.
*/
export async function engineSendText(
args: SendTextEngineArgs,
): Promise<{ whatsapp_message_id: string }> {
const db = supabaseAdmin()
const { data: contact, error: contactErr } = await db
.from('contacts')
.select('id, phone')
.eq('id', args.contactId)
.eq('user_id', args.userId)
.maybeSingle()
if (contactErr || !contact?.phone) {
throw new Error('contact not found for this user')
}
const sanitized = sanitizePhoneForMeta(contact.phone)
if (!isValidE164(sanitized)) {
throw new Error(`contact phone invalid: ${contact.phone}`)
}
const { data: config, error: configErr } = await db
.from('whatsapp_config')
.select('*')
.eq('user_id', args.userId)
.single()
if (configErr || !config) {
throw new Error('WhatsApp not configured for this account')
}
const accessToken = decrypt(config.access_token)
const attempt = async (phone: string): Promise<string> => {
const r = await sendTextMessage({
phoneNumberId: config.phone_number_id,
accessToken,
to: phone,
text: args.text,
})
return r.messageId
}
const variants = phoneVariants(sanitized)
let workingPhone = sanitized
let waMessageId = ''
let lastError: unknown = null
for (const v of variants) {
try {
waMessageId = await attempt(v)
workingPhone = v
lastError = null
break
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!isRecipientNotAllowedError(msg)) throw err
lastError = err
}
}
if (lastError) throw lastError
if (workingPhone !== sanitized) {
await db.from('contacts').update({ phone: workingPhone }).eq('id', contact.id)
}
const { error: msgErr } = await db.from('messages').insert({
conversation_id: args.conversationId,
sender_type: 'bot',
content_type: 'text',
content_text: args.text,
message_id: waMessageId,
status: 'sent',
})
if (msgErr) {
throw new Error(`sent to Meta but DB insert failed: ${msgErr.message}`)
}
await db
.from('conversations')
.update({
last_message_text: args.text,
last_message_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq('id', args.conversationId)
return { whatsapp_message_id: waMessageId }
}
interface SendInteractiveButtonsEngineArgs {
userId: string
conversationId: string
contactId: string
bodyText: string
buttons: InteractiveButton[]
headerText?: string
footerText?: string
}
interface SendInteractiveListEngineArgs {
userId: string
conversationId: string
contactId: string
bodyText: string
buttonLabel: string
sections: InteractiveListSection[]
headerText?: string
footerText?: string
}
/**
* Send an interactive-button WhatsApp message from the Flows engine.
*
* Persists the outgoing message to `messages` with
* `content_type='interactive'` and `sender_type='bot'` so the inbox
* surfaces it with the "Button reply" affordance and the conversation
* thread reflects the bot's prompt.
*
* Returns the Meta message id so the caller (engine) can stash it on
* the `flow_runs.last_prompt_message_id` field for later reference.
*/
export async function engineSendInteractiveButtons(
args: SendInteractiveButtonsEngineArgs,
): Promise<{ whatsapp_message_id: string }> {
return sendInteractiveViaMeta({ ...args, kind: 'buttons' })
}
/**
* Send an interactive-list WhatsApp message from the Flows engine.
* Used when the flow needs more than 3 options (Meta's button cap).
*/
export async function engineSendInteractiveList(
args: SendInteractiveListEngineArgs,
): Promise<{ whatsapp_message_id: string }> {
return sendInteractiveViaMeta({ ...args, kind: 'list' })
}
type SendInput =
| (SendInteractiveButtonsEngineArgs & { kind: 'buttons' })
| (SendInteractiveListEngineArgs & { kind: 'list' })
async function sendInteractiveViaMeta(
input: SendInput,
): Promise<{ whatsapp_message_id: string }> {
const db = supabaseAdmin()
// Scope the contact lookup by user_id — same defense-in-depth
// rationale as automations/meta-send.ts. Service-role client
// bypasses RLS, so an attacker who could call into the engine
// with a contact_id from another tenant would otherwise send
// through their own WhatsApp config to a stranger's number.
const { data: contact, error: contactErr } = await db
.from('contacts')
.select('id, phone')
.eq('id', input.contactId)
.eq('user_id', input.userId)
.maybeSingle()
if (contactErr || !contact?.phone) {
throw new Error('contact not found for this user')
}
const sanitized = sanitizePhoneForMeta(contact.phone)
if (!isValidE164(sanitized)) {
throw new Error(`contact phone invalid: ${contact.phone}`)
}
const { data: config, error: configErr } = await db
.from('whatsapp_config')
.select('*')
.eq('user_id', input.userId)
.single()
if (configErr || !config) {
throw new Error('WhatsApp not configured for this account')
}
const accessToken = decrypt(config.access_token)
const attempt = async (phone: string): Promise<string> => {
if (input.kind === 'buttons') {
const r = await sendInteractiveButtons({
phoneNumberId: config.phone_number_id,
accessToken,
to: phone,
bodyText: input.bodyText,
buttons: input.buttons,
headerText: input.headerText,
footerText: input.footerText,
})
return r.messageId
}
const r = await sendInteractiveList({
phoneNumberId: config.phone_number_id,
accessToken,
to: phone,
bodyText: input.bodyText,
buttonLabel: input.buttonLabel,
sections: input.sections,
headerText: input.headerText,
footerText: input.footerText,
})
return r.messageId
}
// Same phone-variant retry as automations/meta-send.ts. Numbers
// registered with/without a trunk 0 + Meta's sandbox quirks all
// need this to reliably land a message.
const variants = phoneVariants(sanitized)
let workingPhone = sanitized
let waMessageId = ''
let lastError: unknown = null
for (const v of variants) {
try {
waMessageId = await attempt(v)
workingPhone = v
lastError = null
break
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!isRecipientNotAllowedError(msg)) throw err
lastError = err
}
}
if (lastError) throw lastError
if (workingPhone !== sanitized) {
await db.from('contacts').update({ phone: workingPhone }).eq('id', contact.id)
}
// Persist the bot's prompt to the messages table so it appears in
// the inbox. content_type='interactive' is supported as of
// migration 010; sender_type='bot' distinguishes flow sends from
// manual agent sends (the conversation list preview will pick up
// last_message_text as a sensible summary).
//
// We do NOT set interactive_reply_id here — that column is reserved
// for the customer's tap on this message, populated by the webhook
// when their reply arrives.
const { error: msgErr } = await db.from('messages').insert({
conversation_id: input.conversationId,
sender_type: 'bot',
content_type: 'interactive',
content_text: input.bodyText,
message_id: waMessageId,
status: 'sent',
})
if (msgErr) {
throw new Error(`sent to Meta but DB insert failed: ${msgErr.message}`)
}
await db
.from('conversations')
.update({
last_message_text: input.bodyText,
last_message_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq('id', input.conversationId)
return { whatsapp_message_id: waMessageId }
}