Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica
- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind - Frontend Homenest (versión alternativa) - Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp) - WACRM fork (Next.js 16 + Supabase) - Hermes + Bridge + Skills (Qwen3.6 via Nan Builders) - Scripts de migración y operación - Documentación extensiva en docs/
This commit is contained in:
322
wacrm/src/app/api/whatsapp/templates/sync/route.ts
Normal file
322
wacrm/src/app/api/whatsapp/templates/sync/route.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
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 {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Resolve the caller's account_id — both whatsapp_config and
|
||||
// the message_templates we sync into are account-scoped.
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('account_id')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
const accountId = profile?.account_id as string | undefined
|
||||
if (!accountId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Your profile is not linked to an account.' },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
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: user.id,
|
||||
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) {
|
||||
console.error('Error syncing WhatsApp templates:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error ? error.message : 'Failed to sync templates',
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user