Files
SKEEN-Proyecto/wacrm/src/app/api/whatsapp/templates/[id]/route.ts
Consultoría Alcaraz Salazar a718592291 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/
2026-07-20 07:44:23 +00:00

331 lines
10 KiB
TypeScript

import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { decrypt } from '@/lib/whatsapp/encryption'
import {
deleteMessageTemplate,
editMessageTemplate,
} 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'
/**
* Per-template lifecycle endpoint.
*
* PATCH — edit an existing Meta-side template (and re-submit). Used
* by the "Edit" action on APPROVED rows and the "Resubmit"
* action on REJECTED / PAUSED rows. Meta replaces components
* wholesale on edit and bumps status back to PENDING.
*
* DELETE — remove the template on Meta (when meta_template_id is set,
* scoped to a single language variant via hsm_id) AND drop
* the local row. Local-only rows skip the Meta call.
*
* Initial submission (DRAFT → PENDING) lives at the sibling
* /submit endpoint — keep this route narrowly about lifecycle of
* already-submitted templates.
*/
const EDITABLE_STATUSES = new Set(['APPROVED', 'REJECTED', 'PAUSED'])
// uuid v4 plus the looser shape Postgres gen_random_uuid emits.
// We don't need exhaustive RFC parsing — just enough to reject
// "../etc/passwd"-style payloads before they hit Supabase.
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
function isDryRun(): boolean {
return (
process.env.WHATSAPP_TEMPLATES_DRY_RUN === 'true' ||
process.env.WHATSAPP_TEMPLATES_DRY_RUN === '1'
)
}
export async function PATCH(
request: Request,
context: { params: Promise<{ id: string }> },
) {
try {
const { id } = await context.params
if (!UUID_RE.test(id)) {
return NextResponse.json(
{ error: 'Invalid template id.' },
{ status: 400 },
)
}
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 so template + whatsapp_config
// lookups work for teammates who didn't author the row.
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 },
)
}
let payload: TemplatePayload
try {
payload = (await request.json()) as TemplatePayload
} catch {
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 })
}
// RLS handles ownership, but we need the existing row to read
// meta_template_id and status — fetch explicitly.
const { data: existing, error: lookupErr } = await supabase
.from('message_templates')
.select('id, name, status, meta_template_id, language')
.eq('id', id)
.eq('account_id', accountId)
.maybeSingle()
if (lookupErr || !existing) {
return NextResponse.json({ error: 'Template not found.' }, { status: 404 })
}
if (!existing.meta_template_id) {
return NextResponse.json(
{
error:
'This template was never submitted to Meta — use New Template to submit it instead.',
},
{ status: 400 },
)
}
if (!EDITABLE_STATUSES.has(existing.status)) {
return NextResponse.json(
{
error: `Templates in status ${existing.status} cannot be edited. Allowed: APPROVED, REJECTED, PAUSED.`,
},
{ status: 400 },
)
}
if (payload.category === 'Authentication') {
return NextResponse.json(
{
error:
'AUTHENTICATION templates are not editable here — manage them in Meta WhatsApp Manager.',
},
{ status: 400 },
)
}
try {
validateTemplatePayload(payload)
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'Validation failed.' },
{ status: 400 },
)
}
if (!isDryRun()) {
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.' },
{ status: 400 },
)
}
const accessToken = decrypt(config.access_token)
// Image headers need a fresh Resumable-Upload handle on every edit
// (Meta replaces components wholesale). Derive from header_media_url.
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 {
await editMessageTemplate({
metaTemplateId: existing.meta_template_id,
accessToken,
components: metaPayload.components,
})
} catch (e) {
const message = e instanceof Error ? e.message : 'Meta edit failed.'
await supabase
.from('message_templates')
.update({
submission_error: message,
last_submitted_at: new Date().toISOString(),
})
.eq('id', id)
return NextResponse.json({ error: message }, { status: 502 })
}
}
// Meta accepted the edit — status flips back to PENDING for review.
const { data: row, error: updErr } = await supabase
.from('message_templates')
.update({
category: payload.category,
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: 'PENDING',
submission_error: null,
rejection_reason: null,
last_submitted_at: new Date().toISOString(),
})
.eq('id', id)
.select()
.single()
if (updErr) {
return NextResponse.json(
{
error: `Edited on Meta but failed to save locally: ${updErr.message}. Run "Sync from Meta" to recover.`,
},
{ status: 500 },
)
}
return NextResponse.json({
success: true,
template: row,
dry_run: isDryRun(),
})
} catch (error) {
console.error('Error editing template:', error)
return NextResponse.json(
{
error:
error instanceof Error ? error.message : 'Failed to edit template.',
},
{ status: 500 },
)
}
}
export async function DELETE(
_request: Request,
context: { params: Promise<{ id: string }> },
) {
try {
const { id } = await context.params
if (!UUID_RE.test(id)) {
return NextResponse.json(
{ error: 'Invalid template id.' },
{ status: 400 },
)
}
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Same account-scoping rationale as the PATCH handler above —
// teammates need to be able to operate on shared templates +
// the shared whatsapp_config.
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: existing, error: lookupErr } = await supabase
.from('message_templates')
.select('id, name, meta_template_id')
.eq('id', id)
.eq('account_id', accountId)
.maybeSingle()
if (lookupErr || !existing) {
return NextResponse.json({ error: 'Template not found.' }, { status: 404 })
}
if (existing.meta_template_id && !isDryRun()) {
const { data: config, error: configError } = await supabase
.from('whatsapp_config')
.select('*')
.eq('account_id', accountId)
.single()
if (configError || !config || !config.waba_id) {
return NextResponse.json(
{ error: 'WhatsApp not configured — cannot delete on Meta.' },
{ status: 400 },
)
}
const accessToken = decrypt(config.access_token)
try {
await deleteMessageTemplate({
wabaId: config.waba_id,
accessToken,
name: existing.name,
metaTemplateId: existing.meta_template_id,
})
} catch (e) {
const message = e instanceof Error ? e.message : 'Meta delete failed.'
return NextResponse.json({ error: message }, { status: 502 })
}
}
const { error: delErr } = await supabase
.from('message_templates')
.delete()
.eq('id', id)
if (delErr) {
return NextResponse.json(
{
error: `Deleted on Meta but failed to delete locally: ${delErr.message}.`,
},
{ status: 500 },
)
}
return NextResponse.json({ success: true, dry_run: isDryRun() })
} catch (error) {
console.error('Error deleting template:', error)
return NextResponse.json(
{
error:
error instanceof Error ? error.message : 'Failed to delete template.',
},
{ status: 500 },
)
}
}