wacrm/src/app/api/whatsapp/templates/submit/route.ts
Arnas Donauskas 3113738c7e
feat(templates): submit-to-Meta endpoint + component-based form builder
Closes the second half of #147. Users can now create a template in
wacrm and submit it to Meta for approval without leaving the app —
no more dummy local rows that fail with Meta error #132001 on send.

API surface
-----------

POST /api/whatsapp/templates/submit
  Auth → fetch whatsapp_config → validate payload → build Meta
  components → POST to /{waba_id}/message_templates → persist row
  with meta_template_id + status + sample_values +
  last_submitted_at. Per-field validators throw with specific
  messages before the network call, so misconfigured templates
  fail with "button #3 missing url" instead of a 400 from Meta.
  429s are detected and surfaced as "rate limit hit (100/hr)".

WHATSAPP_TEMPLATES_DRY_RUN=true short-circuits the Meta call and
issues a synthetic `dry-run-<uuid>` meta_template_id so CI and
local dev can exercise the full form without a real WABA.

New modules
-----------

src/lib/whatsapp/template-validators.ts
  Pure functions encoding Meta's caps: 1024-char body, 60-char
  footer/header, 25-char button text, 10 buttons max, 2 URL / 1
  PHONE_NUMBER / 1 COPY_CODE, QUICK_REPLY contiguity rule, URL
  example required when {{1}} is present, COPY_CODE example
  required, header media URL must be http(s), variables must be
  contiguous starting at {{1}}. 41 unit tests cover each rule.

src/lib/whatsapp/template-components.ts
  Translates our local row shape into Meta's components array
  (HEADER → BODY → FOOTER → BUTTONS, body_text examples as a 2D
  array per spec, header_handle preferred over header_url when
  both are present). Snapshot tests assert the wire shape.

src/lib/whatsapp/template-status-normalize.ts
  Single source of truth for upstream→local status mapping.
  Replaces the duplicated normalizer in sync/route.ts; the
  upcoming webhook handler will share it too.

src/lib/whatsapp/meta-api.ts
  New submitMessageTemplate() helper following the existing
  named-args + throwMetaError pattern.

Form rewrite
------------

src/components/settings/template-manager.tsx replaces the flat
form with a component-based builder:

  * Header: format select (None / Text / Image / Video / Document)
    + conditional inputs — text + variable-aware sample row for
    TEXT, URL paste for media (file upload slot ready for the
    Resumable Upload follow-up)
  * Body: textarea with live {{N}} extraction → sample-value rows
    auto-appear and shrink as variables are added/removed
  * Footer: 60-char capped input
  * Buttons editor: add up to 10, per-row type picker
    (Quick Reply / URL / Phone / Copy Code), conditional URL +
    example + phone + code fields, X to remove
  * Card display now surfaces rejection_reason / submission_error
    (red banner) and quality_score (GREEN/YELLOW/RED chip)
  * AUTHENTICATION shows a "create in Meta Manager + Sync" banner
    and disables Submit — that category has a different shape
    (fixed body, mandatory OTP button) and lands in a follow-up

Side effects
------------

  * Broadcast Step 1 picker (step1-choose-template.tsx) now
    filters to status='APPROVED' to match the inbox picker —
    selecting a non-approved template would 400 on send.
  * .env.local.example documents META_APP_ID (Resumable Upload
    follow-up) and WHATSAPP_TEMPLATES_DRY_RUN.

Out of scope (subsequent PRs):
  * Edit / resubmit / delete endpoints (PR 4)
  * Webhook handler for real-time status (PR 5)
  * sendTemplateMessage media + button param support (PR 6)
  * Resumable Upload + AUTHENTICATION builder (follow-up)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 15:34:58 +02:00

203 lines
6.6 KiB
TypeScript

import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { decrypt } from '@/lib/whatsapp/encryption'
import { submitMessageTemplate } from '@/lib/whatsapp/meta-api'
import {
validateTemplatePayload,
type TemplatePayload,
} from '@/lib/whatsapp/template-validators'
import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize'
/**
* Submit a template to Meta for approval AND persist it locally.
*
* Auth → fetch whatsapp_config → validate → (DRY_RUN short-circuit) →
* POST to Meta → upsert local row by (user_id, name, language) with
* status, meta_template_id, sample_values, last_submitted_at.
*
* When WHATSAPP_TEMPLATES_DRY_RUN=true, we skip the network call and
* insert a row with a synthetic `dry-run-<uuid>` meta_template_id so
* CI / local dev can exercise the full UI without a real Meta App.
*
* On the Meta side this is a one-way trip — a row can only be
* submitted; editing or deleting requires hsm_id and lives in PR 4.
*/
export async function POST(request: Request) {
try {
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let payload: TemplatePayload
try {
payload = (await request.json()) as TemplatePayload
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 })
}
if (payload.category === 'Authentication') {
return NextResponse.json(
{
error:
'AUTHENTICATION templates are not yet supported here — create them in Meta WhatsApp Manager and use "Sync from Meta".',
},
{ status: 400 },
)
}
try {
validateTemplatePayload(payload)
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'Validation failed.' },
{ status: 400 },
)
}
const metaPayload = buildMetaTemplatePayload(payload)
const dryRun =
process.env.WHATSAPP_TEMPLATES_DRY_RUN === 'true' ||
process.env.WHATSAPP_TEMPLATES_DRY_RUN === '1'
let metaTemplateId: string
let metaStatus: string
if (dryRun) {
metaTemplateId = `dry-run-${crypto.randomUUID()}`
metaStatus = 'PENDING'
} else {
const { data: config, error: configError } = await supabase
.from('whatsapp_config')
.select('*')
.eq('user_id', user.id)
.single()
if (configError || !config) {
return NextResponse.json(
{
error:
'WhatsApp not configured. Connect your WhatsApp Business account in Settings first.',
},
{ status: 400 },
)
}
if (!config.waba_id) {
return NextResponse.json(
{
error:
'WABA (WhatsApp Business Account) ID missing. Re-connect your account in Settings.',
},
{ status: 400 },
)
}
const accessToken = decrypt(config.access_token)
try {
const meta = await submitMessageTemplate({
wabaId: config.waba_id,
accessToken,
payload: metaPayload,
})
metaTemplateId = meta.id
metaStatus = meta.status
} catch (e) {
const message = e instanceof Error ? e.message : 'Meta submit failed.'
// Persist the failure so the user can retry; tag with a 429
// hint if Meta's rate-limit response leaked through.
await supabase
.from('message_templates')
.upsert(
{
user_id: user.id,
name: payload.name,
category: payload.category,
language: payload.language,
header_type: payload.header_type ?? null,
header_content: payload.header_content ?? null,
header_media_url: payload.header_media_url ?? null,
header_handle: payload.header_handle ?? null,
body_text: payload.body_text,
footer_text: payload.footer_text ?? null,
buttons: payload.buttons ?? null,
sample_values: payload.sample_values ?? null,
status: 'DRAFT',
submission_error: message,
last_submitted_at: new Date().toISOString(),
},
{ onConflict: 'user_id,name,language' },
)
const isRateLimit = /\b429\b/.test(message)
return NextResponse.json(
{
error: isRateLimit
? 'Meta rate limit hit (100 template creates per hour). Try again later.'
: message,
},
{ status: isRateLimit ? 429 : 502 },
)
}
}
const { data: row, error: upsertErr } = await supabase
.from('message_templates')
.upsert(
{
user_id: user.id,
name: payload.name,
category: payload.category,
language: payload.language,
header_type: payload.header_type ?? null,
header_content: payload.header_content ?? null,
header_media_url: payload.header_media_url ?? null,
header_handle: payload.header_handle ?? null,
body_text: payload.body_text,
footer_text: payload.footer_text ?? null,
buttons: payload.buttons ?? null,
sample_values: payload.sample_values ?? null,
status: normalizeStatus(metaStatus),
meta_template_id: metaTemplateId,
submission_error: null,
rejection_reason: null,
last_submitted_at: new Date().toISOString(),
},
{ onConflict: 'user_id,name,language' },
)
.select()
.single()
if (upsertErr) {
// The submit succeeded on Meta's side but we failed to persist
// locally. That's a data-drift state — surface the meta_template_id
// so the user can recover via "Sync from Meta".
return NextResponse.json(
{
error: `Submitted to Meta but failed to save locally: ${upsertErr.message}. Run "Sync from Meta" to recover.`,
meta_template_id: metaTemplateId,
},
{ status: 500 },
)
}
return NextResponse.json({
success: true,
template: row,
dry_run: dryRun,
})
} catch (error) {
console.error('Error submitting template:', error)
return NextResponse.json(
{
error:
error instanceof Error ? error.message : 'Failed to submit template.',
},
{ status: 500 },
)
}
}