- 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/
331 lines
10 KiB
TypeScript
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 },
|
|
)
|
|
}
|
|
}
|