wacrm/src/app/api/quick-replies/route.ts
Arnas Donauskas 78b42dfba9 feat: WhatsApp interactive messages (reply buttons + lists)
Add full support for WhatsApp Cloud API interactive messages across the
inbox, automations, and a new quick-replies feature. Extends the
interactive plumbing that previously existed only for the Flows engine.

Sending
- send-message core handles `interactive` (reply buttons ≤3, list ≤10
  rows) via the existing Meta senders; validated server-side before send.
- Inbox composer gains a "+" menu → builder dialog with live WhatsApp-style
  preview + Meta char limits (button 20, list row 24, body 1024).
- Automation "Send Buttons" / "Send List" steps in the builder.
- Public v1 /messages accepts type=interactive.

Storage / round-trip
- migration 035: messages.interactive_payload JSONB + quick_replies table.
- Outbound interactive persists the structured payload (composer,
  automations, and Flows) so the thread re-renders the buttons/rows.

Receiving
- Bubble renders outbound interactive via the shared preview; inbound taps
  (button_reply/list_reply) keep the "chosen option" affordance.

Automations
- New `interactive_reply` trigger matches on tapped button/list IDs
  (exact), enabling chained multi-step menus.

Quick replies
- New account-scoped quick_replies (text or interactive snippets), managed
  in Settings and insertable from the composer; "Save as quick reply" in
  the builder.

Shared core in src/lib/whatsapp/interactive.ts + src/components/interactive/.
Includes unit tests for the validator, send-param validation, the
interactive_reply trigger match, and the new automation step/trigger
validation. typecheck + lint clean; full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:03:15 +03:00

81 lines
2.5 KiB
TypeScript

import { NextResponse } from 'next/server'
import { getCurrentAccount, requireRole, toErrorResponse } from '@/lib/auth/account'
import { supabaseAdmin } from '@/lib/automations/admin-client'
import { validateInteractivePayload } from '@/lib/whatsapp/interactive'
// Quick replies — reusable snippets (plain text or a saved interactive
// message) shared across the account. GET lists; POST creates. Mirrors
// the automations route: RLS-scoped read via the user client, service-
// role write after an explicit role check.
export async function GET() {
try {
const { supabase } = await getCurrentAccount()
// RLS (quick_replies_select) scopes to the caller's account.
const { data, error } = await supabase
.from('quick_replies')
.select('*')
.order('created_at', { ascending: false })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ quick_replies: data ?? [] })
} catch (err) {
return toErrorResponse(err)
}
}
export async function POST(request: Request) {
let ctx
try {
ctx = await requireRole('agent')
} catch (err) {
return toErrorResponse(err)
}
const body = await request.json().catch(() => null)
if (!body) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
const title = typeof body.title === 'string' ? body.title.trim() : ''
const kind = body.kind === 'interactive' ? 'interactive' : 'text'
if (!title) {
return NextResponse.json({ error: 'title is required' }, { status: 400 })
}
let content_text: string | null = null
let interactive_payload: unknown = null
if (kind === 'interactive') {
const result = validateInteractivePayload(body.interactive_payload)
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
interactive_payload = body.interactive_payload
} else {
const text = typeof body.content_text === 'string' ? body.content_text : ''
if (!text.trim()) {
return NextResponse.json(
{ error: 'content_text is required for text quick replies' },
{ status: 400 },
)
}
content_text = text
}
const { data, error } = await supabaseAdmin()
.from('quick_replies')
.insert({
account_id: ctx.accountId,
user_id: ctx.userId,
title,
kind,
content_text,
interactive_payload,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ quick_reply: data }, { status: 201 })
}