mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
203 lines
6.6 KiB
TypeScript
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 },
|
|
)
|
|
}
|
|
}
|