feat(canais): gestão de modelos do parceiro Graph-compatível (Datafy)

- Habilita canManageTemplates no canal e liga a fonte de templates à rota nova.
- Adapter do Datafy ganha templates (list/create/update/remove) via a Graph do
  parceiro e sendTemplate, reusando sendTemplateForSession parametrizado por
  host/token (fora da janela de 24h).
- send-template e send-template-for-session aceitam base/transporte explícito;
  a busca no espelho respeita channel_session_id quando há sessão.
- Rota /channels/datafy/templates (conteúdo neutro) resolve pelo seam e grava
  contract_hash REAL (hashContract), não string vazia.
- UI: sub-aba Modelos na aba do parceiro Graph, reusando o componente do outro
  parceiro com a rota vinda de templates-fonte.
This commit is contained in:
root
2026-09-14 07:13:40 +02:00
parent 32ef098cf6
commit f75ae20773
16 changed files with 638 additions and 37 deletions
+13
View File
@@ -0,0 +1,13 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Gestão de modelos do parceiro Graph-compatível (Datafy) — criar, listar e apagar pela tela
---
A conexão por parceiro Graph-compatível ganhou a aba **Modelos**: agora dá para **listar, criar, editar e apagar** as definições aprovadas direto pela tela, sem sair do CRM.
É o que faltava para atender **fora da janela de 24 horas**: texto livre só passa dentro dela; fora, a Meta exige um modelo aprovado — e a partir de agora o parceiro tem esse caminho. O formulário é o mesmo do outro parceiro (cabeçalho, corpo, rodapé, botões, exemplos), e o que é criado entra na fila de revisão da plataforma.
- **Os modelos são os da API oficial** (o parceiro só dá outra porta): o que você cria aqui aparece no painel da plataforma e é o mesmo espelho local (`meta_templates`) que o agente usa para escolher o que enviar.
- **Sincroniza sozinho** depois de criar: a definição nasce em revisão e aparece pendente na lista, para você não criar a mesma duas vezes.
- **Envio pela janela certa:** o modelo enviado monta os parâmetros a partir do espelho e sai pelo host e token do parceiro — nunca pelo número da Meta.
+3
View File
@@ -352,6 +352,9 @@ ZERNIO_API_BASE_URL=
# instalação de número único. Deixe vazias se você não usa este canal.
DATAFY_API_KEY=
DATAFY_PHONE_NUMBER_ID=
# WABA (conta) do número — só necessária no fallback de ambiente; pela tela ela
# é descoberta sozinha no /me.
DATAFY_WABA_ID=
# Só para apontar para homologação. Vazio usa https://cloud.datafyapi.com.br.
DATAFY_API_BASE_URL=
@@ -0,0 +1,211 @@
import { requireSupportWrite } from "@/lib/impersonate/support";
/**
* As definições aprovadas do parceiro Graph-compatível — listar, sincronizar,
* criar.
*
* Espelha a rota do outro parceiro, com duas diferenças que importam:
*
* 1. resolve a conexão pelo seam do parceiro Graph (`findGraphPartnerSession`),
* não pela conexão de credencial antiga;
* 2. grava `contract_hash` REAL (`hashContract`), e não string vazia — sem ele
* a trava de obsolescência do `conferirDefinicao`/`sendTemplate` não acusa
* que o modelo mudou na plataforma.
*
* A rota não nomeia o provider: pede o adapter da sessão e chama
* `adapter.templates`. Todo o nome vive em `lib/channels/`.
*/
import { randomUUID } from "node:crypto";
import type { NextRequest } from "next/server";
import { fail, ok } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { loadAuthUser, resolveActiveOrg } from "@/lib/auth/server";
import {
CHANNEL_SESSION_REF_COLUMNS,
DEFAULT_CHANNEL_PROVIDER,
getAdapter,
resolveSessionRef,
type ChannelProvider,
type ChannelSessionRef,
} from "@/lib/channels";
import { findGraphPartnerSession } from "@/lib/channels/graph-parceiro/session";
import { hashContract } from "@/lib/channels/meta/contract-hash";
import { traduzir } from "@/lib/i18n/dicionario";
import type { Idioma } from "@/lib/i18n/idiomas";
import { logger } from "@/lib/logger";
import { createAdminClient } from "@/lib/supabase/admin";
export const dynamic = "force-dynamic";
interface Contexto {
orgId: string;
sessionId: string;
sessionRef: string;
provider: ChannelProvider;
idioma: Idioma;
}
async function contexto(
requestId: string,
): Promise<{ ok: true; ctx: Contexto } | { ok: false; res: Response }> {
const user = await loadAuthUser();
if (!user) return { ok: false, res: fail("unauthenticated", "Faça login.", 401, { requestId }) };
const t = (texto: string) => traduzir(texto, user.idioma);
const org = await resolveActiveOrg(user);
if (!org) return { ok: false, res: fail("forbidden", t("Sem organização ativa."), 403, { requestId }) };
const admin = createAdminClient();
const sessao = await findGraphPartnerSession(admin, org.orgId);
if (!sessao || sessao.archivedAt) {
return { ok: false, res: fail("not_found", t("Nenhuma conexão ativa."), 404, { requestId }) };
}
const { data: linha } = await admin
.from("channel_sessions")
// As COLUNAS do ref vêm do seam — escrevê-las à mão aqui nomeia providers.
.select(`id, ${CHANNEL_SESSION_REF_COLUMNS}`)
.eq("id", sessao.id)
.maybeSingle();
const provider = ((linha?.provider as string) ?? DEFAULT_CHANNEL_PROVIDER) as ChannelProvider;
const sessionRef = linha ? resolveSessionRef(linha as unknown as ChannelSessionRef) : null;
if (!sessionRef) {
return {
ok: false,
res: fail("failed_precondition", t("Conexão sem identificador utilizável."), 409, { requestId }),
};
}
return {
ok: true,
ctx: { orgId: org.orgId, sessionId: sessao.id, sessionRef, provider, idioma: user.idioma },
};
}
export async function GET(): Promise<Response> {
const requestId = randomUUID();
const r = await contexto(requestId);
if (!r.ok) return r.res;
const admin = createAdminClient();
const { data, error } = await admin
.from("meta_templates")
.select("name, language, status, category, rejected_reason, components, synced_at")
.eq("organization_id", r.ctx.orgId)
.eq("channel_session_id", r.ctx.sessionId)
.order("status")
.order("name");
if (error) return fail("internal_error", error.message, 500, { requestId });
return ok(
{
templates: (data ?? []).map((t) => ({
name: t.name as string,
language: t.language as string,
status: t.status as string,
category: (t.category as string | null) ?? null,
rejectedReason: (t.rejected_reason as string | null) ?? null,
syncedAt: t.synced_at as string,
components: (t.components as unknown[]) ?? [],
})),
},
{ requestId },
);
}
export async function POST(req: NextRequest): Promise<Response> {
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const requestId = randomUUID();
const r = await contexto(requestId);
if (!r.ok) return r.res;
const t = (texto: string) => traduzir(texto, r.ctx.idioma);
const adapter = getAdapter(r.ctx.provider);
if (!adapter.templates) {
return fail("not_implemented", t("Este canal não gerencia definições."), 501, { requestId });
}
const corpo = (await req.json().catch(() => ({}))) as {
acao?: string;
name?: string;
language?: string;
category?: string;
components?: unknown[];
};
try {
if (corpo.acao === "criar") {
if (!corpo.name || !corpo.language || !Array.isArray(corpo.components)) {
return fail("invalid_request", t("Faltam nome, idioma ou conteúdo."), 400, { requestId });
}
await adapter.templates.create({
organizationId: r.ctx.orgId,
sessionRef: r.ctx.sessionRef,
draft: {
name: corpo.name,
language: corpo.language,
category: (corpo.category ?? "UTILITY") as "AUTHENTICATION" | "MARKETING" | "UTILITY",
components: corpo.components,
},
});
await audit({
action: "template.created",
organizationId: r.ctx.orgId,
resourceType: "channel_session",
resourceId: r.ctx.sessionId,
requestId,
metadata: { name: corpo.name, language: corpo.language },
});
}
// Sincroniza sempre — inclusive depois de criar: a definição nasce em
// revisão e o operador precisa VER que ela existe e está pendente.
const remotas = await adapter.templates.list({
organizationId: r.ctx.orgId,
sessionRef: r.ctx.sessionRef,
});
const admin = createAdminClient();
const agora = new Date().toISOString();
let gravadas = 0;
for (const tpl of remotas) {
const parameterFormat = tpl.parameterFormat ?? "POSITIONAL";
const { error } = await admin.from("meta_templates").upsert(
{
organization_id: r.ctx.orgId,
channel_session_id: r.ctx.sessionId,
// O nome da coluna é da época em que só havia um canal: aqui ela
// guarda o identificador da CONTA do provider (sessionRef), como no
// outro parceiro — a escopo real é `channel_session_id`.
waba_id: r.ctx.sessionRef,
name: tpl.name,
language: tpl.language,
status: tpl.status,
category: tpl.category,
rejected_reason: tpl.rejectedReason ?? null,
components: tpl.components,
// Hash REAL: é o que faz o pré-voo acusar "mudou na plataforma".
contract_hash: hashContract(tpl.components, parameterFormat),
parameter_format: parameterFormat,
synced_at: agora,
updated_at: agora,
},
{ onConflict: "organization_id,waba_id,name,language" },
);
if (error) {
logger.warn("[graph/templates] upsert falhou", { name: tpl.name, detail: error.message });
continue;
}
gravadas++;
}
return ok({ sincronizadas: gravadas, total: remotas.length }, { requestId });
} catch (err) {
const msg = err instanceof Error ? err.message : "erro";
logger.error("[graph/templates] falhou", { detail: msg, requestId });
return fail("upstream_error", msg, 502, { requestId });
}
}
+16 -1
View File
@@ -11,6 +11,7 @@ import { TemplatesClient } from "./TemplatesClient";
import { TemplatesParceiroClient } from "./TemplatesParceiroClient";
import { useT } from "@/hooks/i18n/useT";
import { ROTULO_PARCEIRO_GRAPH } from "@/lib/channels/rotulos";
import { rotaDeTemplates } from "@/lib/channels/templates-fonte";
/**
* Conexões — TODOS os canais em um lugar só.
@@ -91,7 +92,21 @@ export function ConexoesShell({ wahaConfigured }: { wahaConfigured: boolean }) {
</TabsContent>
<TabsContent value="graph" className="mt-0">
<CanalGraphParceiroClient />
{/* Sub-abas como nas demais: conectar e gerenciar modelos são tarefas
diferentes. O componente de modelos é o MESMO do outro parceiro,
apontado para a rota desta fonte. */}
<Tabs value={sub} onValueChange={(v) => irPara("graph", v)} className="flex flex-col gap-4">
<TabsList>
<TabsTrigger value="conexao">{t("Conexão")}</TabsTrigger>
<TabsTrigger value="templates">{t("Modelos")}</TabsTrigger>
</TabsList>
<TabsContent value="conexao" className="mt-0">
<CanalGraphParceiroClient />
</TabsContent>
<TabsContent value="templates" className="mt-0">
<TemplatesParceiroClient rota={rotaDeTemplates("graph")} />
</TabsContent>
</Tabs>
</TabsContent>
<TabsContent value="parceiro" className="mt-0">
@@ -59,7 +59,14 @@ const COR_DO_ESTADO: Record<string, string> = {
DISABLED: "text-muted-foreground",
};
export function TemplatesParceiroClient() {
/**
* Modelos do parceiro. `rota` permite reusar o MESMO componente para um segundo
* parceiro Graph-compatível sem duplicar a tela — o default é a rota do parceiro
* por credencial, e a aba nova passa a dela.
*/
export function TemplatesParceiroClient({
rota = "/api/v1/channels/partner/templates",
}: { rota?: string } = {}) {
const tagDoIdioma = useTagDeIdioma();
const t = useT();
const qc = useQueryClient();
@@ -82,19 +89,14 @@ export function TemplatesParceiroClient() {
const nVariaveis = contarVariaveis(corpo);
const lista = useQuery({
queryKey: ["partner-templates"],
queryKey: ["partner-templates", rota],
queryFn: async () =>
apiClient.get<{ data: { templates: TemplateParceiro[] } }>(
"/api/v1/channels/partner/templates",
),
apiClient.get<{ data: { templates: TemplateParceiro[] } }>(rota),
});
const acao = useMutation({
mutationFn: async (corpoReq: Record<string, unknown>) =>
apiClient.post<{ data: { sincronizadas: number; total: number } }>(
"/api/v1/channels/partner/templates",
corpoReq,
),
apiClient.post<{ data: { sincronizadas: number; total: number } }>(rota, corpoReq),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ["partner-templates"] });
// Invalida também o seletor do inbox: sem isto o operador sincroniza aqui,
+37
View File
@@ -21,6 +21,8 @@
import { createAdminClient } from "@/lib/supabase/admin";
import { cloudContactPayload, cloudMediaPayload, toE164Digits } from "../cloud/payload";
import { graphPartnerGraphBase, resolveGraphPartnerCreds } from "../graph-parceiro/credentials";
import { graphPartnerTemplateOps } from "../graph-parceiro/templates";
import { sendTemplateForSession } from "../meta/send-template-for-session";
import type {
ChannelAdapter,
ChannelHealth,
@@ -94,6 +96,41 @@ export const datafyAdapter: ChannelAdapter = {
unknownError: "datafy_unknown",
},
/** Gestão das definições aprovadas pela Graph do parceiro. */
templates: graphPartnerTemplateOps,
/**
* Envia uma DEFINIÇÃO aprovada — a saída do gate de janela de 24h.
*
* Reusa o mesmo caminho da Cloud API (`sendTemplateForSession`), parametrizado
* com o host e o token do parceiro: o modelo é montado a partir do espelho
* (`meta_templates`) e postado na Graph do parceiro.
*/
async sendTemplate(input): Promise<{ externalId: string | null }> {
const admin = createAdminClient();
const creds = await resolveGraphPartnerCreds(admin, {
organizationId: input.organizationId,
phoneNumberId: input.sessionRef,
});
if (!creds) throw new Error("datafy_not_configured: sem token para esta conexão.");
const externalId = await sendTemplateForSession(admin, {
...(input.beforeSend ? { beforeSend: input.beforeSend } : {}),
organizationId: input.organizationId,
to: input.to,
name: input.name,
language: input.language,
values: input.values,
transport: {
phoneNumberId: creds.phoneNumberId,
token: creds.token,
graphBase: graphPartnerGraphBase(),
errorPrefix: "datafy",
},
});
return { externalId };
},
async send(envelope: OutboundEnvelope): Promise<{ externalId: string | null }> {
const creds = await resolveGraphPartnerCreds(createAdminClient(), {
organizationId: envelope.organizationId,
+5 -6
View File
@@ -74,15 +74,14 @@ export const CHANNEL_CAPABILITIES: Record<ChannelProvider, ChannelCapabilities>
// TRANSPORTE (base URL, token), não o que o WhatsApp permite — então as
// capabilities são as do canal oficial.
//
// `canManageTemplates: false` é o recorte desta PRIMEIRA versão: o transporte
// funciona (enviar/receber/saúde), mas a gestão de modelos ainda não foi
// exposta para este canal, e declarar `true` mostraria uma tela que não salva.
// `requiresTemplates: true` continua porque a regra da Meta é real: fora da
// janela de 24h, só modelo aprovado passa.
// `canManageTemplates: true`: os modelos são os da Cloud API e o parceiro
// expõe os mesmos endpoints de catálogo; a tela cria/edita/apaga por lá.
// `requiresTemplates: true` porque a regra da Meta é real: fora da janela de
// 24h, só modelo aprovado passa.
datafy: {
freeformOutsideWindow: false,
requiresTemplates: true,
canManageTemplates: false,
canManageTemplates: true,
banRisk: false,
minIntervalMs: 6000,
voiceNote: "opus-only",
+11 -2
View File
@@ -23,6 +23,8 @@ import { ARCHIVED_AT, queryTolerantToMissingArchived } from "../archived";
export interface GraphPartnerCredentials {
phoneNumberId: string;
/** A WABA (conta) do número — é o que endereça o catálogo de modelos. */
wabaId: string;
token: string;
/** Raiz do host (`https://cloud.datafyapi.com.br`). A Graph entra em `/v1`. */
rootUrl: string;
@@ -58,7 +60,13 @@ export function graphPartnerCredsFromEnv(): GraphPartnerCredentials | null {
const phoneNumberId = process.env.DATAFY_PHONE_NUMBER_ID;
const token = process.env.DATAFY_API_KEY;
if (!phoneNumberId || !token) return null;
return { phoneNumberId, token, rootUrl: graphPartnerRootUrl(), source: "env" };
return {
phoneNumberId,
wabaId: process.env.DATAFY_WABA_ID ?? "",
token,
rootUrl: graphPartnerRootUrl(),
source: "env",
};
}
/**
@@ -78,7 +86,7 @@ export async function graphPartnerCredsForPhoneNumberId(
const base = () =>
admin
.from("channel_sessions")
.select("datafy_phone_number_id, datafy_token_encrypted")
.select("datafy_phone_number_id, datafy_waba_id, datafy_token_encrypted")
.eq("organization_id", organizationId)
.eq("datafy_phone_number_id", phoneNumberId);
const { data, error } = await queryTolerantToMissingArchived(
@@ -99,6 +107,7 @@ export async function graphPartnerCredsForPhoneNumberId(
return {
phoneNumberId: data.datafy_phone_number_id as string,
wabaId: (data.datafy_waba_id as string | null) ?? "",
token,
rootUrl: graphPartnerRootUrl(),
source: "session",
+140
View File
@@ -0,0 +1,140 @@
/**
* Gestão das definições aprovadas pelo parceiro Graph-compatível — listar,
* criar, editar e apagar.
*
* O parceiro expõe a MESMA Cloud API da Meta, então os endpoints de modelo são
* os dela (`/{waba_id}/message_templates`), com o host e o token do parceiro.
* Os `components` chegam prontos em MAIÚSCULA (`BODY`, `HEADER`…) — que é como a
* Graph espera — e viajam crus, porque são a entrada de quem deriva o contrato.
*/
import { createAdminClient } from "@/lib/supabase/admin";
import type {
ChannelTemplate,
ChannelTemplateDraft,
ChannelTemplateOps,
ChannelTenantScope,
} from "../types";
import { resolveGraphPartnerCreds } from "./credentials";
/** A forma que a Graph devolve. */
interface RawTemplate {
id?: string;
name?: string;
language?: string;
status?: string;
category?: string | null;
components?: unknown[];
rejected_reason?: string | null;
parameter_format?: string | null;
error?: { message?: string };
}
type Escopo = ChannelTenantScope & { sessionRef: string };
/** Organização + número: as duas pontas que identificam a sessão (issue #236). */
async function creds(escopo: Escopo) {
const c = await resolveGraphPartnerCreds(createAdminClient(), {
organizationId: escopo.organizationId,
phoneNumberId: escopo.sessionRef,
});
if (!c) throw new Error("graph_partner_not_configured: sem token para esta conexão.");
if (!c.wabaId) throw new Error("graph_partner_sem_waba: a conexão não tem conta (WABA).");
return c;
}
function toNeutral(t: RawTemplate | null): ChannelTemplate {
const componentes = t?.components;
return {
name: t?.name ?? "",
language: t?.language ?? "",
// Vocabulário ABERTO: a plataforma cria estado novo sem avisar.
status: t?.status ?? "UNKNOWN",
category: t?.category ?? null,
components: Array.isArray(componentes) ? componentes : [],
rejectedReason: t?.rejected_reason ?? null,
parameterFormat: t?.parameter_format ?? null,
};
}
async function lerJson(res: Response): Promise<RawTemplate | null> {
return (await res.json().catch(() => null)) as RawTemplate | null;
}
function erroDaGraph(res: Response, json: RawTemplate | null, acao: string): Error {
return new Error(
`graph_partner_template_${acao}: ${res.status} ${json?.error?.message ?? ""}`.trim(),
);
}
export const graphPartnerTemplateOps: ChannelTemplateOps = {
async list({ organizationId, sessionRef }): Promise<ChannelTemplate[]> {
const c = await creds({ organizationId, sessionRef });
const fields =
"name,language,status,category,parameter_format,rejected_reason,quality_score,components";
let url: string | null = `${c.rootUrl}/v1/${encodeURIComponent(c.wabaId)}/message_templates?limit=100&fields=${fields}`;
const todos: ChannelTemplate[] = [];
// Paginação por `paging.next` (URL completa). O teto de páginas evita laço
// infinito se a plataforma devolver sempre o mesmo cursor.
for (let pagina = 0; pagina < 50 && url; pagina += 1) {
const res = await fetch(url, { headers: { Authorization: `Bearer ${c.token}` } });
const json = (await res.json().catch(() => null)) as
| (RawTemplate & { data?: RawTemplate[]; paging?: { next?: string } })
| null;
if (!res.ok || json?.error) throw erroDaGraph(res, json, "failed");
for (const t of json?.data ?? []) todos.push(toNeutral(t));
const proxima = json?.paging?.next;
url = proxima && proxima !== url ? proxima : null;
}
return todos;
},
async create({ organizationId, sessionRef, draft }): Promise<ChannelTemplate> {
const c = await creds({ organizationId, sessionRef });
return postMessageTemplate(c, {
name: draft.name,
language: draft.language,
category: draft.category,
components: draft.components,
...(draft.parameterFormat ? { parameter_format: draft.parameterFormat } : {}),
});
},
async update({ organizationId, sessionRef, name, patch }): Promise<ChannelTemplate> {
const c = await creds({ organizationId, sessionRef });
// A Graph edita pela MESMA coleção (POST por nome), não por id.
return postMessageTemplate(c, {
name,
...(patch.category ? { category: patch.category } : {}),
...(patch.components ? { components: patch.components } : {}),
});
},
async remove({ organizationId, sessionRef, name }): Promise<void> {
const c = await creds({ organizationId, sessionRef });
const url = `${c.rootUrl}/v1/${encodeURIComponent(c.wabaId)}/message_templates?name=${encodeURIComponent(name)}`;
const res = await fetch(url, {
method: "DELETE",
headers: { Authorization: `Bearer ${c.token}` },
});
if (!res.ok) throw erroDaGraph(res, await lerJson(res), "failed");
},
};
/** POST na coleção de modelos; devolve o que a Graph devolver (pode vir vazio). */
async function postMessageTemplate(
c: { wabaId: string; token: string; rootUrl: string },
body: Record<string, unknown>,
): Promise<ChannelTemplate> {
const url = `${c.rootUrl}/v1/${encodeURIComponent(c.wabaId)}/message_templates`;
const res = await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${c.token}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await lerJson(res);
if (!res.ok || json?.error) throw erroDaGraph(res, json, "failed");
return toNeutral(json);
}
export type { ChannelTemplate, ChannelTemplateDraft };
+28 -7
View File
@@ -25,6 +25,21 @@ export interface SendTemplateForSessionInput {
name: string;
language: string;
values: Record<string, string>;
/**
* Transporte explícito. Ausente = credencial do ambiente da Meta (instalação
* de número único). Presente = canal Graph-compatível (parceiro), com host e
* token próprios. Sem isto o modelo do parceiro sairia pelo número da Meta.
*/
transport?: {
phoneNumberId: string;
token: string;
graphBase?: string;
graphVersion?: string;
/** Prefixo dos códigos de erro (`meta_`/`datafy_`). Default `meta`. */
errorPrefix?: string;
};
/** Conexão dona da definição — restringe o `meta_templates` a ela. */
channelSessionId?: string | null;
}
/**
@@ -43,21 +58,26 @@ export async function sendTemplateForSession(
throw new Error("template_incompleto: nome e idioma são obrigatórios em type=template");
}
const { data: linha, error } = await db
let consulta = db
.from("meta_templates")
.select("name, language, status, contract_hash, components")
.eq("organization_id", input.organizationId)
.eq("name", input.name)
.eq("language", input.language)
.maybeSingle();
.eq("language", input.language);
// Com sessão, restringe à conexão: dois números têm definições diferentes e
// conferir a do número errado aprovaria um envio que a plataforma recusa.
if (input.channelSessionId) consulta = consulta.eq("channel_session_id", input.channelSessionId);
const { data: linha, error } = await consulta.maybeSingle();
if (error) throw new Error(`template_lookup_failed: ${error.message}`);
await input.beforeSend?.();
const resultado = await sendTemplate({
phoneNumberId: process.env.META_PHONE_NUMBER_ID ?? "",
token: process.env.META_SYSTEM_USER_TOKEN ?? "",
graphVersion: process.env.META_GRAPH_VERSION ?? "v22.0",
phoneNumberId: input.transport?.phoneNumberId ?? process.env.META_PHONE_NUMBER_ID ?? "",
token: input.transport?.token ?? process.env.META_SYSTEM_USER_TOKEN ?? "",
graphVersion: input.transport?.graphVersion ?? process.env.META_GRAPH_VERSION ?? "v22.0",
...(input.transport?.graphBase ? { graphBase: input.transport.graphBase } : {}),
to: input.to,
binding: {
name: input.name,
@@ -80,6 +100,7 @@ export async function sendTemplateForSession(
if (resultado.sent) return resultado.externalId;
const prefixo = input.transport?.errorPrefix ?? "meta";
switch (resultado.reason) {
case "missing":
throw new Error(`template_missing: ${input.name} (${input.language}) não está no espelho`);
@@ -90,6 +111,6 @@ export async function sendTemplateForSession(
case "missing_values":
throw new Error(`template_missing_values: ${resultado.missing.join(", ")}`);
case "api_error":
throw new Error(`meta_${resultado.code ?? "erro"}: ${resultado.message}`);
throw new Error(`${prefixo}_${resultado.code ?? "erro"}: ${resultado.message}`);
}
}
+9 -1
View File
@@ -28,6 +28,13 @@ export interface SendTemplateInput {
phoneNumberId: string;
token: string;
graphVersion: string;
/**
* Base da API, quando o canal NÃO é a Meta direta. Um parceiro
* Graph-compatível usa a MESMA Cloud API com outro host/token; sem este campo
* o envio de modelo sairia pelo `graph.facebook.com` com o token errado.
* `undefined` = Meta direta (`https://graph.facebook.com/{graphVersion}`).
*/
graphBase?: string;
/** Destinatário em dígitos E.164, sem `+` — é o que a Graph API aceita. */
to: string;
binding: TemplateBinding;
@@ -90,7 +97,8 @@ export async function sendTemplate(input: SendTemplateInput): Promise<SendTempla
};
}
const url = `https://graph.facebook.com/${input.graphVersion}/${input.phoneNumberId}/messages`;
const base = input.graphBase ?? `https://graph.facebook.com/${input.graphVersion}`;
const url = `${base}/${input.phoneNumberId}/messages`;
const res = await fetch(url, {
method: "POST",
headers: {
+7 -9
View File
@@ -21,7 +21,7 @@
*/
import type { ChannelProvider } from "./types";
export type FonteDeTemplates = "oficial" | "parceiro";
export type FonteDeTemplates = "oficial" | "parceiro" | "graph";
/**
* Qual rota serve as definições de cada canal.
@@ -42,11 +42,9 @@ const FONTE: Record<ChannelProvider, FonteDeTemplates | null> = {
waha: null,
meta_cloud: "oficial",
zernio: "parceiro",
// O canal exige modelo fora da janela de 24h, mas esta primeira versão ainda
// não expõe a gestão de modelos dele — ver `canManageTemplates` em
// `capabilities.ts`. `null` = a tela não oferece seletor para este canal, em
// vez de mandar o operador para uma lista que não existe.
datafy: null,
// Parceiro Graph-compatível: os modelos são os da Cloud API, servidos por uma
// rota própria (host/token do parceiro).
datafy: "graph",
};
/** `null` quando este canal não trabalha com definições aprovadas. */
@@ -57,7 +55,7 @@ export function fonteDeTemplates(provider: string | null | undefined): FonteDeTe
/** A rota que serve as definições desta fonte. */
export function rotaDeTemplates(fonte: FonteDeTemplates): string {
return fonte === "parceiro"
? "/api/v1/channels/partner/templates"
: "/api/v1/channels/templates";
if (fonte === "parceiro") return "/api/v1/channels/partner/templates";
if (fonte === "graph") return "/api/v1/channels/datafy/templates";
return "/api/v1/channels/templates";
}
+2 -2
View File
@@ -21,8 +21,8 @@ describe("canal datafy — vocabulário e seam", () => {
expect(caps.freeformOutsideWindow).toBe(false);
expect(caps.banRisk).toBe(false);
expect(caps.costPerMessage).toBe(true);
// Gestão de modelos ainda não exposta nesta primeira versão.
expect(caps.canManageTemplates).toBe(false);
// Modelos são os da Cloud API; o parceiro expõe o catálogo.
expect(caps.canManageTemplates).toBe(true);
});
it("o sessionRef vem da coluna própria do provider", () => {
+28
View File
@@ -5,13 +5,18 @@ vi.mock("@/lib/channels/graph-parceiro/credentials", () => ({
resolveGraphPartnerCreds: vi.fn(),
graphPartnerGraphBase: () => "https://cloud.example.test/v1",
}));
vi.mock("@/lib/channels/meta/send-template-for-session", () => ({
sendTemplateForSession: vi.fn(),
}));
import { datafyAdapter } from "@/lib/channels/adapters/datafy";
import { resolveGraphPartnerCreds } from "@/lib/channels/graph-parceiro/credentials";
import { sendTemplateForSession } from "@/lib/channels/meta/send-template-for-session";
import type { OutboundEnvelope } from "@/lib/channels/types";
const CREDS = {
phoneNumberId: "106540352242922",
wabaId: "366634483210360",
token: "sk_live_abc",
rootUrl: "https://cloud.example.test",
source: "session" as const,
@@ -72,6 +77,29 @@ describe("adapter datafy", () => {
await expect(datafyAdapter.send(envelope())).rejects.toThrow(/datafy_131047/);
});
it("sendTemplate usa o transporte do parceiro (host + token), não o da Meta", async () => {
vi.mocked(sendTemplateForSession).mockResolvedValue("wamid.T");
const r = await datafyAdapter.sendTemplate!({
organizationId: "org-1",
sessionRef: "106540352242922",
to: "5531999998888",
name: "pedido_confirmado",
language: "pt_BR",
values: { "1": "João" },
});
expect(r.externalId).toBe("wamid.T");
const chamada = vi.mocked(sendTemplateForSession).mock.calls[0];
expect(chamada?.[1].transport).toEqual({
phoneNumberId: "106540352242922",
token: "sk_live_abc",
graphBase: "https://cloud.example.test/v1",
errorPrefix: "datafy",
});
expect(chamada?.[1].organizationId).toBe("org-1");
});
it("checkHealth mapeia 401 para FAILED e rede para reachable=false", async () => {
const health = datafyAdapter.checkHealth!;
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}", { status: 401 }));
+111
View File
@@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: () => ({}) }));
vi.mock("@/lib/channels/graph-parceiro/credentials", () => ({
resolveGraphPartnerCreds: vi.fn(),
}));
import { resolveGraphPartnerCreds } from "@/lib/channels/graph-parceiro/credentials";
import { graphPartnerTemplateOps } from "@/lib/channels/graph-parceiro/templates";
const CREDS = {
phoneNumberId: "106540352242922",
wabaId: "366634483210360",
token: "sk_live_abc",
rootUrl: "https://cloud.example.test",
source: "session" as const,
};
const ESCOPO = { organizationId: "org-1", sessionRef: "106540352242922" };
beforeEach(() => {
vi.mocked(resolveGraphPartnerCreds).mockReset();
vi.mocked(resolveGraphPartnerCreds).mockResolvedValue(CREDS);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("modelos do parceiro Graph-compatível", () => {
it("lista pela WABA, com o token do parceiro, e traduz o vocabulário", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
data: [
{
name: "pedido_confirmado",
language: "pt_BR",
status: "APPROVED",
category: "UTILITY",
components: [{ type: "BODY", text: "Olá {{1}}" }],
},
],
}),
{ status: 200 },
),
);
const r = await graphPartnerTemplateOps.list(ESCOPO);
expect(r).toEqual([
{
name: "pedido_confirmado",
language: "pt_BR",
status: "APPROVED",
category: "UTILITY",
components: [{ type: "BODY", text: "Olá {{1}}" }],
rejectedReason: null,
parameterFormat: null,
},
]);
const [url, init] = fetchSpy.mock.calls[0]!;
expect(String(url)).toContain("/v1/366634483210360/message_templates");
expect((init?.headers as Record<string, string>).Authorization).toBe("Bearer sk_live_abc");
});
it("cria na coleção da WABA com os componentes em MAIÚSCULA", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ name: "novo", language: "pt_BR", status: "PENDING" }), {
status: 200,
}),
);
const r = await graphPartnerTemplateOps.create({
...ESCOPO,
draft: {
name: "novo",
language: "pt_BR",
category: "UTILITY",
components: [{ type: "BODY", text: "Olá" }],
},
});
expect(r.status).toBe("PENDING");
const [url, init] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe("https://cloud.example.test/v1/366634483210360/message_templates");
expect(init?.method).toBe("POST");
const body = JSON.parse(String(init?.body));
expect(body.components[0].type).toBe("BODY");
expect(body.name).toBe("novo");
});
it("apaga pelo nome", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ success: true }), { status: 200 }),
);
await graphPartnerTemplateOps.remove({ ...ESCOPO, name: "antigo" });
const [url, init] = fetchSpy.mock.calls[0]!;
expect(String(url)).toContain("/message_templates?name=antigo");
expect(init?.method).toBe("DELETE");
});
it("erro da API sobe com o código do status", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ error: { message: "nome inválido" } }), { status: 400 }),
);
await expect(graphPartnerTemplateOps.list(ESCOPO)).rejects.toThrow(/graph_partner_template/);
});
});
+6
View File
@@ -42,6 +42,10 @@ describe("de onde vêm as definições", () => {
expect(fonteDeTemplates("meta_cloud")).toBe("oficial");
});
it("parceiro Graph-compatível busca na rota própria", () => {
expect(fonteDeTemplates("datafy")).toBe("graph");
});
it("número por QR não tem definição a listar", () => {
// Ele manda texto livre a qualquer hora: um seletor ali ofereceria uma
// solução para um problema que aquele canal não tem.
@@ -58,7 +62,9 @@ describe("de onde vêm as definições", () => {
// lista da Meta — que é exatamente o defeito de origem.
expect(rotaDeTemplates("parceiro")).toBe("/api/v1/channels/partner/templates");
expect(rotaDeTemplates("oficial")).toBe("/api/v1/channels/templates");
expect(rotaDeTemplates("graph")).toBe("/api/v1/channels/datafy/templates");
expect(rotaDeTemplates("parceiro")).not.toBe(rotaDeTemplates("oficial"));
expect(rotaDeTemplates("graph")).not.toBe(rotaDeTemplates("parceiro"));
});
});