wacrm/src/app/api/whatsapp/templates/submit/route.ts
Arnas Donauskas 242ecdf2c5
fix(whatsapp): require a write role on the WhatsApp write routes
The five write handlers under /api/whatsapp resolved the caller's
account_id straight off their profile row, which only proves account
membership — never a role. Every other write surface in the app
(account/, ai/, automations/, flows/, quick-replies/) already goes
through requireRole(); this family was missed.

RLS did not backstop it, because each of these routes calls Meta
*before* it persists anything:

  - broadcast: makes no database write at all, so there was no policy
    involved on any path
  - send / react: the Meta call precedes the insert, so the outbound
    message or reaction reached the customer and only the local mirror
    was refused
  - templates/submit, templates/sync: push to Meta before upserting the
    local catalog

Gate each handler at the role its data class already implies, matching
lib/auth/roles and the migration 017 policies:

  - send, react, broadcast  -> 'agent'  (canSendMessages)
  - templates/submit, sync  -> 'admin'  (canEditSettings, and the
                                        message_templates_insert/update
                                        policies)

Also switches these handlers to toErrorResponse so an authorization
failure returns 401/403 instead of being folded into a generic 500. The
two template routes keep their existing message-surfacing 500 for
non-auth errors, so a Meta failure still reports its own reason.

Adds role-enforcement coverage to the send route's test: a viewer is
refused with 403 and sendTemplateMessage is never called, while an agent
passes through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 23:02:03 +03:00

261 lines
8.7 KiB
TypeScript

import { NextResponse } from 'next/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
ForbiddenError,
UnauthorizedError,
requireRole,
toErrorResponse,
} from '@/lib/auth/account'
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 { ensureImageHeaderHandle } from '@/lib/whatsapp/template-header-handle'
import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize'
/**
* Shared upsert payload builder — both the Meta-failure path and the
* Meta-success path write nearly identical rows; dropping the shared
* fields here means adding a column later only touches one spot.
*/
function buildUpsertRow(
accountId: string,
userId: string,
payload: TemplatePayload,
extras: {
status: 'DRAFT' | string
metaTemplateId: string | null
submissionError: string | null
},
) {
return {
// Account tenancy — required NOT NULL on message_templates as
// of migration 017. Without this an INSERT throws on the
// not-null constraint.
account_id: accountId,
// Original author — kept as audit only. The unique index is
// still on (user_id, name, language) — see the upsert helper
// for the cross-teammate dedup follow-up.
user_id: userId,
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: extras.status,
meta_template_id: extras.metaTemplateId,
submission_error: extras.submissionError,
// Clear stale rejection_reason whenever we re-submit; the
// webhook will set it again if Meta still rejects.
rejection_reason: extras.submissionError ? null : null,
last_submitted_at: new Date().toISOString(),
}
}
async function upsertTemplateRow(
supabase: SupabaseClient,
row: ReturnType<typeof buildUpsertRow>,
) {
// TODO(account-sharing): conflict target is still scoped to
// user_id. Once a follow-up migration drops the legacy unique
// index on (user_id, name, language) and adds (account_id,
// name, language), switch `onConflict` here so two teammates
// can't shadow each other's same-named template.
return supabase
.from('message_templates')
.upsert(row, { onConflict: 'user_id,name,language' })
.select()
.single()
}
/**
* 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 {
// Message templates are settings-class data: `canEditSettings` and the
// message_templates_insert/update RLS policies (migration 017) both
// require 'admin'. Resolving account_id off the profile only proved
// membership, so a viewer or agent could push a template to Meta for
// approval — an external side effect RLS can't roll back — before the
// local upsert was refused.
const { supabase, accountId, userId } = await requireRole('admin')
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 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('account_id', accountId)
.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)
// Image headers need a Resumable-Upload handle (Meta rejects a
// plain URL at creation). Derive it from header_media_url before
// building the payload. Surfaces a 400 with an actionable message
// (missing META_APP_ID, unreachable URL, wrong type/size).
try {
await ensureImageHeaderHandle(payload, accessToken)
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'Header image upload failed.' },
{ status: 400 },
)
}
const metaPayload = buildMetaTemplatePayload(payload)
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; row stays DRAFT
// until they fix and re-submit.
await upsertTemplateRow(
supabase,
buildUpsertRow(accountId, userId, payload, {
status: 'DRAFT',
metaTemplateId: null,
submissionError: message,
}),
)
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 upsertTemplateRow(
supabase,
buildUpsertRow(accountId, userId, payload, {
status: normalizeStatus(metaStatus),
metaTemplateId,
submissionError: null,
}),
)
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) {
// Auth failures map to 401/403. Handled before the generic branch
// below, which surfaces `error.message` as a 500 — reporting "you
// aren't an admin" as a template submission failure would send the
// user chasing the wrong problem.
if (
error instanceof UnauthorizedError ||
error instanceof ForbiddenError
) {
return toErrorResponse(error)
}
console.error('Error submitting template:', error)
return NextResponse.json(
{
error:
error instanceof Error ? error.message : 'Failed to submit template.',
},
{ status: 500 },
)
}
}