mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
feat(canais): gestão de modelos do canal Datafy (recorte do #1130)
Port dof75ae2077do #1130 (@vgamkt), adaptado à main que já tem o canal pelo #1459: - capability canManageTemplates passa a true e a fonte de modelos do canal aponta para uma rota própria; - o adapter ganha `templates` (list/create/update/remove na Graph do parceiro, endereçada pela WABA da sessão) e `sendTemplate`, que reusa `sendTemplateForSession` com o host e o token do parceiro; - send-template aceita `graphBase`; send-template-for-session aceita um transporte explícito e a conexão dona da definição; - rota de modelos (listar/sincronizar/criar), gravando contract_hash real; - sub-aba Modelos na aba do canal, reusando a tela do outro parceiro. Adaptação à main: a rota mora em channels/graph-partner/templates (nome neutro, como a de conexão do #1459, e o lint:channels proíbe a marca fora de lib/channels); a credencial de ambiente (DATAFY_WABA_ID) ficou de fora, porque o #1459 removeu o fallback de ambiente; a base da Graph vem de graphPartnerGraphBase(); send-template-for-session já resolve a credencial da sessão na main, e o transporte explícito passa por cima dela. Cherry-picked from commitf75ae20773
This commit is contained in:
committed by
melgarafael
parent
a3c4ba4f0a
commit
3ee6a5651f
@@ -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 de parceiro 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 });
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { TemplatesClient } from "./TemplatesClient";
|
||||
import { TemplatesParceiroClient } from "./TemplatesParceiroClient";
|
||||
import { TelefoniaClient } from "./TelefoniaClient";
|
||||
import { useT } from "@/hooks/i18n/useT";
|
||||
import { rotaDeTemplates } from "@/lib/channels/templates-fonte";
|
||||
|
||||
/**
|
||||
* Conexões — TODOS os canais em um lugar só.
|
||||
@@ -123,7 +124,21 @@ export function ConexoesShell({
|
||||
|
||||
{graphParceiro && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
|
||||
import { canalGraphParceiroLigado, graphPartnerGraphBase, resolveGraphPartnerCreds } from "../graph-parceiro/credentials";
|
||||
import { graphPartnerTemplateOps } from "../graph-parceiro/templates";
|
||||
import { sendTemplateForSession } from "../meta/send-template-for-session";
|
||||
import type {
|
||||
ChannelAdapter,
|
||||
ChannelHealth,
|
||||
@@ -95,6 +97,42 @@ 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(`${NAO_CONFIGURADO}: nenhuma credencial gravada para esta sessão.`);
|
||||
|
||||
const externalId = await sendTemplateForSession(admin, {
|
||||
...(input.beforeSend ? { beforeSend: input.beforeSend } : {}),
|
||||
organizationId: input.organizationId,
|
||||
sessionRef: input.sessionRef,
|
||||
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 }> {
|
||||
if (!canalGraphParceiroLigado()) {
|
||||
throw new Error(`${NAO_CONFIGURADO}: o canal está desligado nesta instalação (DATAFY_ENABLED).`);
|
||||
|
||||
@@ -91,15 +91,14 @@ export const CHANNEL_CAPABILITIES: Record<ProviderDeMensagem, ChannelCapabilitie
|
||||
// (host, token), não o que o WhatsApp permite — então o perfil é o do canal
|
||||
// oficial.
|
||||
//
|
||||
// `canManageTemplates: false` é o recorte desta primeira entrega: enviar,
|
||||
// receber e saúde funcionam, a gestão de modelos deste canal vem na fatia
|
||||
// seguinte. Declarar `true` antes mostraria uma tela que não salva.
|
||||
// `requiresTemplates: true` continua porque a regra da Meta é real: fora da
|
||||
// janela, 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 e sincroniza 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",
|
||||
|
||||
@@ -50,6 +50,8 @@ export const GRAPH_PARTNER_LABEL = "Datafy";
|
||||
|
||||
export interface GraphPartnerCredentials {
|
||||
phoneNumberId: string;
|
||||
/** A WABA (conta) do número — é o que endereça o catálogo de modelos. */
|
||||
wabaId: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
@@ -86,7 +88,7 @@ export async function resolveGraphPartnerCreds(
|
||||
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(
|
||||
@@ -105,5 +107,6 @@ export async function resolveGraphPartnerCreds(
|
||||
const token = await decryptWebhookSecret(admin, cifrado as string);
|
||||
if (!token) return null;
|
||||
|
||||
return { phoneNumberId: (data as { datafy_phone_number_id: string }).datafy_phone_number_id, token };
|
||||
const linha = data as { datafy_phone_number_id: string; datafy_waba_id?: string | null };
|
||||
return { phoneNumberId: linha.datafy_phone_number_id, wabaId: linha.datafy_waba_id ?? "", token };
|
||||
}
|
||||
|
||||
@@ -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 { graphPartnerGraphBase, 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 = `${graphPartnerGraphBase()}/${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 = `${graphPartnerGraphBase()}/${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 },
|
||||
body: Record<string, unknown>,
|
||||
): Promise<ChannelTemplate> {
|
||||
const url = `${graphPartnerGraphBase()}/${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 };
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
import { graphVersion } from "@/lib/graph-version";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
|
||||
import { resolveMetaCreds } from "./credentials";
|
||||
@@ -39,6 +40,21 @@ export interface SendTemplateForSessionInput {
|
||||
name: string;
|
||||
language: string;
|
||||
values: Record<string, string>;
|
||||
/**
|
||||
* Transporte explícito. Ausente = credencial da Meta (sessão, com o ambiente
|
||||
* de reserva). 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,23 +87,33 @@ export async function sendTemplateForSession(
|
||||
// com `Bearer` vazio e viraria `failed` com um erro que não nomeia o motivo real —
|
||||
// e a mudança de elegibilidade da #674 transformaria uma fila recuperável em
|
||||
// falha. Com ela, o desfecho é `queued` com `meta_not_configured`.
|
||||
const creds = await resolveMetaCreds(createAdminClient(), {
|
||||
organizationId: input.organizationId,
|
||||
phoneNumberId: input.sessionRef,
|
||||
});
|
||||
const creds = input.transport
|
||||
? {
|
||||
phoneNumberId: input.transport.phoneNumberId,
|
||||
token: input.transport.token,
|
||||
graphVersion: input.transport.graphVersion ?? graphVersion(),
|
||||
}
|
||||
: await resolveMetaCreds(createAdminClient(), {
|
||||
organizationId: input.organizationId,
|
||||
phoneNumberId: input.sessionRef,
|
||||
});
|
||||
if (!creds) {
|
||||
throw new Error(
|
||||
"meta_not_configured: sem credencial para esta sessão (nem na sessão, nem no ambiente).",
|
||||
);
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
@@ -96,6 +122,7 @@ export async function sendTemplateForSession(
|
||||
phoneNumberId: creds.phoneNumberId,
|
||||
token: creds.token,
|
||||
graphVersion: creds.graphVersion,
|
||||
...(input.transport?.graphBase ? { graphBase: input.transport.graphBase } : {}),
|
||||
to: input.to,
|
||||
binding: {
|
||||
name: input.name,
|
||||
@@ -118,6 +145,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`);
|
||||
@@ -128,6 +156,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
*/
|
||||
import type { ProviderDeMensagem } from "./types";
|
||||
|
||||
export type FonteDeTemplates = "oficial" | "parceiro";
|
||||
export type FonteDeTemplates = "oficial" | "parceiro" | "graph";
|
||||
|
||||
/**
|
||||
* Qual rota serve as definições de cada canal.
|
||||
@@ -44,10 +44,9 @@ const FONTE: Record<ProviderDeMensagem, FonteDeTemplates | null> = {
|
||||
meta_cloud: "oficial",
|
||||
zernio: "parceiro",
|
||||
zernio_social: null,
|
||||
// Exige modelo fora da janela, mas a gestão de modelos deste canal é a fatia
|
||||
// seguinte do #1130 (`canManageTemplates: false`). `null` = a tela não
|
||||
// oferece seletor, 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. */
|
||||
@@ -58,7 +57,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/graph-partner/templates";
|
||||
return "/api/v1/channels/templates";
|
||||
}
|
||||
|
||||
@@ -26,10 +26,10 @@ describe("canal datafy — vocabulário e seam", () => {
|
||||
expect(caps.banRisk).toBe(false);
|
||||
expect(caps.costPerMessage).toBe(true);
|
||||
expect(caps.voiceNote).toBe("opus-only");
|
||||
// Gestão de modelos é a fatia seguinte: declarar `true` agora mostraria uma
|
||||
// tela que não salva, e a fonte de modelos tem de concordar com isso.
|
||||
expect(caps.canManageTemplates).toBe(false);
|
||||
expect(fonteDeTemplates(DATAFY)).toBeNull();
|
||||
// Modelos são os da Cloud API; o parceiro expõe o catálogo. A fonte de
|
||||
// modelos tem de concordar com a capability.
|
||||
expect(caps.canManageTemplates).toBe(true);
|
||||
expect(fonteDeTemplates(DATAFY)).toBe("graph");
|
||||
});
|
||||
|
||||
it("é canal de MENSAGEM e aparece como WhatsApp, nunca como a marca do fornecedor", () => {
|
||||
|
||||
@@ -8,16 +8,20 @@ vi.mock("@/lib/channels/graph-parceiro/credentials", async (original) => ({
|
||||
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";
|
||||
|
||||
/**
|
||||
* Adapter do canal Datafy (recorte do #1130, @vgamkt): o adapter oficial com
|
||||
* outro host e outro token — e que NÃO envia com o canal desligado.
|
||||
*/
|
||||
const CREDS = { phoneNumberId: "106540352242922", token: "sk_live_abc" };
|
||||
const CREDS = { phoneNumberId: "106540352242922", wabaId: "366634483210360", token: "sk_live_abc" };
|
||||
|
||||
function envelope(over: Partial<OutboundEnvelope> = {}): OutboundEnvelope {
|
||||
return {
|
||||
@@ -100,6 +104,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 checkHealth = datafyAdapter.checkHealth!;
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("{}", { status: 401 }));
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
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(),
|
||||
graphPartnerGraphBase: () => "https://cloud.example.test/v1",
|
||||
}));
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -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/graph-partner/templates");
|
||||
expect(rotaDeTemplates("parceiro")).not.toBe(rotaDeTemplates("oficial"));
|
||||
expect(rotaDeTemplates("graph")).not.toBe(rotaDeTemplates("parceiro"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user