wacrm/src/app/api/whatsapp/templates/sync/route.ts
Arnas Donauskas faee4eea44
chore: rename product to "CRM Template for WhatsApp"
Drops the "WaCRM" branded name across user-facing surfaces in favour
of a descriptive name. Branded names that imply WhatsApp ownership or
sponsorship are a trademark fair-use risk; a relationship-based name
("CRM Template for WhatsApp") sidesteps that.

Scope:
- Brand text in nav, sidebar, footer, docs shell, mock window chrome.
- SITE_NAME / SITE_DESCRIPTION / legalName in src/lib/seo/site-config.ts.
- README, CONTRIBUTING, docs/, .github/ templates and policies.
- package.json description.
- Auth pages, settings copy, FAQ, code comments.

Out of scope (intentionally — these are infra renames the project
owner should drive separately so links and the package lockfile stay
consistent):
- The github.com/ArnasDon/wacrm repo URL.
- The wacrm.tech domain / homepage field.
- The package.json "name" field.
- The Hostinger application name and Meta system-user name in deploy
  docs (existing-deployment instructions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:52:11 +03:00

254 lines
7.4 KiB
TypeScript

import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { decrypt } from '@/lib/whatsapp/encryption'
/**
* Sync message templates from Meta → local message_templates table.
*
* Why this exists:
* The Settings → Message Templates UI only writes to Supabase. It does
* NOT submit templates for approval to Meta. Users would create a
* template locally, try to broadcast with it, and hit Meta's error
* #132001 "Template name does not exist in the translation" — because
* Meta had never seen the template, or had it approved under a
* different language code than what we stored locally.
*
* This route pulls the source of truth (Meta's approved templates)
* and upserts them into the local catalog by (user_id, name, language).
* After a sync, every local template row is guaranteed to match
* something Meta will actually accept on send.
*
* Scope:
* - Read-only against Meta. We never push local → Meta (template
* submission happens in Meta's WhatsApp Manager and requires human
* review).
* - Only approved templates are surfaced by default. We return
* everything Meta returns and let the UI filter — so the user can
* see their Pending / Rejected templates and understand why.
* - Locally-created templates (no Meta counterpart) are NOT deleted —
* they remain visible so the user can notice drift and clean up
* manually.
*/
const META_API_VERSION = 'v21.0'
const META_API_BASE = `https://graph.facebook.com/${META_API_VERSION}`
interface MetaTemplateComponent {
type: string
text?: string
format?: string
}
interface MetaTemplate {
id: string
name: string
language: string
status: 'APPROVED' | 'PENDING' | 'REJECTED' | 'PAUSED'
category: string
components?: MetaTemplateComponent[]
}
/**
* Meta's template categories are upper-snake (MARKETING / UTILITY /
* AUTHENTICATION); our DB CHECK constraint is TitleCase. Normalize.
*/
function normalizeCategory(
meta: string,
): 'Marketing' | 'Utility' | 'Authentication' {
const upper = meta.toUpperCase()
if (upper === 'UTILITY') return 'Utility'
if (upper === 'AUTHENTICATION') return 'Authentication'
return 'Marketing'
}
/**
* Meta's template status is UPPERCASE; our DB uses TitleCase.
*/
function normalizeStatus(
meta: string,
): 'Draft' | 'Pending' | 'Approved' | 'Rejected' {
switch (meta.toUpperCase()) {
case 'APPROVED':
return 'Approved'
case 'PENDING':
case 'IN_APPEAL':
case 'PENDING_DELETION':
return 'Pending'
case 'REJECTED':
case 'DISABLED':
case 'PAUSED':
return 'Rejected'
default:
return 'Draft'
}
}
export async function POST() {
try {
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// whatsapp_config holds waba_id + encrypted access_token.
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)
// Paginate through every template Meta has for this WABA. Meta
// returns at most 100 per page; `paging.next` is a full URL. Cap
// at 20 pages (2k templates) as a safety against infinite loops
// from a misbehaving upstream.
const metaTemplates: MetaTemplate[] = []
let nextUrl:
| string
| null = `${META_API_BASE}/${config.waba_id}/message_templates?limit=100&fields=id,name,language,status,category,components`
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
}
// For each Meta template: upsert by (user_id, name, language).
// No UNIQUE constraint on that triple, so we match manually.
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 row = {
user_id: user.id,
name: t.name,
category: normalizeCategory(t.category),
language: t.language,
header_type: header?.format?.toLowerCase() ?? null,
header_content: header?.text ?? null,
body_text: body?.text ?? '',
footer_text: footer?.text ?? null,
status: normalizeStatus(t.status),
updated_at: new Date().toISOString(),
}
const { data: existing, error: lookupErr } = await supabase
.from('message_templates')
.select('id')
.eq('user_id', user.id)
.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) {
console.error('Error syncing WhatsApp templates:', error)
return NextResponse.json(
{
error:
error instanceof Error ? error.message : 'Failed to sync templates',
},
{ status: 500 },
)
}
}