wacrm/src/app/api/whatsapp/templates/sync/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

316 lines
9.3 KiB
TypeScript

import { NextResponse } from 'next/server'
import {
ForbiddenError,
UnauthorizedError,
requireRole,
toErrorResponse,
} from '@/lib/auth/account'
import { decrypt } from '@/lib/whatsapp/encryption'
import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize'
import type { TemplateButton, TemplateSampleValues } from '@/types'
/**
* Sync message templates from Meta → local message_templates table.
*
* The local catalog stores Meta's status enum verbatim (APPROVED /
* PENDING / REJECTED / PAUSED / DISABLED / IN_APPEAL / PENDING_DELETION)
* so the edit / resubmit / delete flows can distinguish recoverable
* states (PAUSED) from terminal ones (DISABLED) and so webhook events
* land 1:1 without a translation table.
*
* Locally-created templates (no Meta counterpart) are NOT deleted —
* they remain visible so the user can notice drift and clean up.
*/
const META_API_VERSION = 'v21.0'
const META_API_BASE = `https://graph.facebook.com/${META_API_VERSION}`
interface MetaButton {
type: string
text: string
url?: string
phone_number?: string
example?: string[] | string
}
interface MetaTemplateComponent {
type: string
text?: string
format?: string
buttons?: MetaButton[]
example?: {
header_text?: string[]
header_handle?: string[]
body_text?: string[][]
}
}
interface MetaTemplate {
id: string
name: string
language: string
status: string
category: string
components?: MetaTemplateComponent[]
quality_score?: { score?: string } | string
}
function normalizeCategory(
meta: string,
): 'Marketing' | 'Utility' | 'Authentication' {
const upper = meta.toUpperCase()
if (upper === 'UTILITY') return 'Utility'
if (upper === 'AUTHENTICATION') return 'Authentication'
return 'Marketing'
}
function normalizeQualityScore(
raw: MetaTemplate['quality_score'],
): 'GREEN' | 'YELLOW' | 'RED' | null {
const score =
typeof raw === 'string' ? raw : raw?.score ? String(raw.score) : null
if (!score) return null
const upper = score.toUpperCase()
return upper === 'GREEN' || upper === 'YELLOW' || upper === 'RED'
? (upper as 'GREEN' | 'YELLOW' | 'RED')
: null
}
function parseButtons(metaButtons: MetaButton[] | undefined): TemplateButton[] {
if (!metaButtons?.length) return []
const out: TemplateButton[] = []
for (const b of metaButtons) {
switch (b.type?.toUpperCase()) {
case 'QUICK_REPLY':
out.push({ type: 'QUICK_REPLY', text: b.text })
break
case 'URL':
out.push({
type: 'URL',
text: b.text,
url: b.url ?? '',
example: Array.isArray(b.example) ? b.example[0] : b.example,
})
break
case 'PHONE_NUMBER':
out.push({
type: 'PHONE_NUMBER',
text: b.text,
phone_number: b.phone_number ?? '',
})
break
case 'COPY_CODE':
out.push({
type: 'COPY_CODE',
text: b.text,
example: Array.isArray(b.example) ? b.example[0] ?? '' : b.example ?? '',
})
break
// OTP, FLOW, etc — out of scope for v1; drop silently.
}
}
return out
}
function extractSampleValues(
body: MetaTemplateComponent | undefined,
header: MetaTemplateComponent | undefined,
): TemplateSampleValues | null {
// Meta returns body_text as a 2D array — one row per example set.
// We take the first row (most templates have exactly one).
const bodySample = body?.example?.body_text?.[0]
const headerSample = header?.example?.header_text
if (!bodySample?.length && !headerSample?.length) return null
const sv: TemplateSampleValues = {}
if (bodySample?.length) sv.body = bodySample
if (headerSample?.length) sv.header = headerSample
return sv
}
export async function POST() {
try {
// Syncing rewrites the account-wide template catalog, which is
// 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.
const { supabase, accountId, userId } = await requireRole('admin')
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)
const metaTemplates: MetaTemplate[] = []
let nextUrl:
| string
| null = `${META_API_BASE}/${config.waba_id}/message_templates?limit=100&fields=id,name,language,status,category,components,quality_score`
const PAGE_CAP = 20
let pageCount = 0
while (nextUrl && pageCount < PAGE_CAP) {
pageCount++
const metaRes: Response = await fetch(nextUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!metaRes.ok) {
let metaErr = `Meta API error: ${metaRes.status}`
try {
const body = await metaRes.json()
if (body?.error?.message) metaErr = body.error.message
} catch {
// response wasn't JSON — keep the fallback
}
return NextResponse.json({ error: metaErr }, { status: 502 })
}
const metaBody: {
data?: MetaTemplate[]
paging?: { next?: string }
} = await metaRes.json()
if (metaBody.data) metaTemplates.push(...metaBody.data)
nextUrl = metaBody.paging?.next ?? null
}
let inserted = 0
let updated = 0
const errors: { name: string; language: string; message: string }[] = []
for (const t of metaTemplates) {
const body = (t.components ?? []).find((c) => c.type === 'BODY')
const header = (t.components ?? []).find((c) => c.type === 'HEADER')
const footer = (t.components ?? []).find((c) => c.type === 'FOOTER')
const buttons = (t.components ?? []).find((c) => c.type === 'BUTTONS')
const parsedButtons = parseButtons(buttons?.buttons)
const sampleValues = extractSampleValues(body, header)
const headerFormat = header?.format?.toUpperCase()
const headerType =
headerFormat === 'TEXT' ||
headerFormat === 'IMAGE' ||
headerFormat === 'VIDEO' ||
headerFormat === 'DOCUMENT'
? headerFormat.toLowerCase()
: null
const row = {
// Account tenancy + user audit, same split as the submit
// route. account_id is NOT NULL on message_templates
// post-017, so an INSERT without it errors.
account_id: accountId,
user_id: userId,
name: t.name,
category: normalizeCategory(t.category),
language: t.language,
header_type: headerType,
header_content: header?.text ?? null,
header_handle: header?.example?.header_handle?.[0] ?? null,
body_text: body?.text ?? '',
footer_text: footer?.text ?? null,
buttons: parsedButtons.length ? parsedButtons : null,
sample_values: sampleValues,
status: normalizeStatus(t.status),
meta_template_id: t.id,
quality_score: normalizeQualityScore(t.quality_score),
updated_at: new Date().toISOString(),
}
const { data: existing, error: lookupErr } = await supabase
.from('message_templates')
.select('id')
.eq('account_id', accountId)
.eq('name', t.name)
.eq('language', t.language)
.maybeSingle()
if (lookupErr) {
errors.push({
name: t.name,
language: t.language,
message: lookupErr.message,
})
continue
}
if (existing?.id) {
const { error: updErr } = await supabase
.from('message_templates')
.update(row)
.eq('id', existing.id)
if (updErr) {
errors.push({
name: t.name,
language: t.language,
message: updErr.message,
})
} else {
updated++
}
} else {
const { error: insErr } = await supabase
.from('message_templates')
.insert(row)
if (insErr) {
errors.push({
name: t.name,
language: t.language,
message: insErr.message,
})
} else {
inserted++
}
}
}
return NextResponse.json({
success: errors.length === 0,
total: metaTemplates.length,
inserted,
updated,
errors,
truncated: pageCount >= PAGE_CAP && nextUrl !== null,
})
} catch (error) {
// Auth failures map to 401/403 rather than being folded into the
// generic 500 below, which surfaces `error.message` as a sync failure.
if (
error instanceof UnauthorizedError ||
error instanceof ForbiddenError
) {
return toErrorResponse(error)
}
console.error('Error syncing WhatsApp templates:', error)
return NextResponse.json(
{
error:
error instanceof Error ? error.message : 'Failed to sync templates',
},
{ status: 500 },
)
}
}