Files
VANDER GUSTAVO ALVESandClaude Opus 5 16d4784288 feat(canais): painel "Para integrar" com endpoint e IDs da conexão (recorte do #1130)
Trabalho de @vgamkt, recortado do PR #1130 (`feat/fluxos-de-atendimento`, 94
commits, 40 arquivos em conflito contra a main de hoje). Esta é a fatia do
PAINEL, escolhida por ser a mais barata de provar: só somada, sem tocar banco,
sem número de migration — os onze do PR estão todos tomados na main.

Recria o commit b2d9e4c40 dele sobre a main de hoje.

─── O que entrou

- `components/connections/ParaIntegrar.tsx` — o painel. Depois de conectar um
  número, mostra endpoint/base da API e os identificadores, com um botão que
  copia tudo de uma vez. O cabeçalho do arquivo explica por que o TOKEN não
  aparece: nada de credencial volta do servidor depois de gravada (o GET
  devolve só `hasToken`), e em vez de abrir exceção para exibi-la, o ícone de
  ajuda diz ONDE obtê-la no painel do provedor. O componente cumpre isso —
  `grep -iE "token|secret|apiKey"` nele devolve texto de ajuda e um
  `aria-label`, zero valor de credencial.
- Ligado em três telas que existem na main hoje: canal oficial (endpoint,
  `phone_number_id`, `waba_id`), canal parceiro (endpoint, conta) e a lista de
  conexões por QR — esta sem campo nenhum, só com a explicação de que a sessão
  é interna desta instalação e o outro CRM precisa de um QR próprio, mais o
  alerta de resposta duplicada se os dois tiverem atendimento automático.
- `metaGraphBase()` e `partnerEndpoint()` — a base pública da Graph API e a do
  parceiro. Endereço público, não segredo; o segredo continua sem sair daqui.
- Teste co-localizado com 4 casos e 15 chaves novas em espanhol.

─── O que NÃO entrou, e por quê

O commit dele também ligava o painel ao canal Datafy
(`app/api/v1/channels/datafy/route.ts`, `CanalGraphParceiroClient.tsx`,
`useGraphPartnerChannel.ts`) e trazia 10 chaves de espanhol desse canal. Esses
três arquivos **não existem na main** — a abstração de canal mudou embaixo do
PR, e o Datafy é uma fatia dele ainda por recortar. Ficaram para lá, com as
chaves que só eles usam. `grep` por qualquer um dos três nesta árvore devolve
zero, com controle positivo verde na mesma sonda.

─── Uma chave a menos que o original, de propósito

`"Número": { es: "Número" }` está no commit dele e NÃO entrou: a main já tem
essa chave. Duas entradas iguais no mesmo objeto é `TS1117`, e esse erro não
nasce em nenhum dos dois lados — só na prévia do merge, que foi exatamente o
que derrubou `build-and-size` (e, sem build, as cinco partes do e2e) no recorte
anterior deste mesmo PR, o #1372. A régua que usei compara cada chave do bloco
contra o resto do dicionário antes de escrever, e a autoridade sobre duplicata
em objeto TS é o compilador, nunca regex — uma sonda que exija aspas não vê
`Host:` escrito nu, e o dicionário tem as duas formas.

Refs: #1130
Co-authored-by: VANDER GUSTAVO ALVES <62725454+vgamkt@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Teqp51CrEKk6FCwb76iZgs
2026-09-20 11:06:41 -03:00

235 lines
8.5 KiB
TypeScript

/**
* Conexão de um canal por CREDENCIAL — do lado de dentro do seam.
*
* A tela e a rota não podem saber qual provider é (invariante 1 da doutrina),
* mas precisam de três coisas concretas: como se chama o canal para o usuário,
* quais campos pedir, e se a credencial que ele colou presta. As três moram
* aqui, onde nomear o provider é permitido.
*
* ─── Validar ANTES de gravar ────────────────────────────────────────────────
*
* Mesma decisão da conexão oficial, pelo mesmo motivo: gravar primeiro e
* descobrir depois é o que faz o operador achar que conectou e só entender que
* não na primeira mensagem que não sai — com o lead do outro lado esperando.
*/
import type { SupabaseClient } from "@supabase/supabase-js";
import { metadataInicialDoCanal } from "@/lib/ai/elegibilidade/pre-go-live";
import { ARCHIVED_AT, queryTolerantToMissingArchived } from "./archived";
import { CHANNEL_PROVIDER_ZERNIO } from "./capabilities";
import { zernioBaseUrl } from "./zernio/credentials";
import type { ChannelProvider } from "./types";
/**
* O canal conectável por credencial nesta instalação.
*
* Exportado como valor para que rota e tela não escrevam a string — é o que o
* `lint:channels` cobra, e o que faz um canal seguinte trocar uma linha aqui
* em vez de dez espalhadas.
*/
export const PARTNER_CHANNEL_PROVIDER: ChannelProvider = CHANNEL_PROVIDER_ZERNIO;
/**
* Como o canal se chama PARA O USUÁRIO.
*
* O nome comercial mora aqui e não na tela por causa do lint — mas a razão é
* anterior a ele: quem instala reconhece a marca do serviço que contratou, e a
* tela que diz "provedor parceiro" obriga a adivinhar. O rótulo é dado, não
* decisão de quem desenha a tela.
*/
export const PARTNER_CHANNEL_LABEL = "Zernio";
/**
* Endpoint base do parceiro — o que a tela mostra para o operador reaproveitar
* em outro sistema. Não é segredo (endereço público do provedor); a credencial
* continua sem voltar nunca.
*/
export function partnerEndpoint(): string {
return zernioBaseUrl();
}
export interface PartnerCredentialsInput {
accountId: string;
apiKey: string;
}
export type PartnerValidation =
| {
ok: true;
/** Número conectado, para a tela confirmar que é o esperado. */
phoneNumber: string | null;
displayName: string | null;
/** Qualidade do número segundo a plataforma (GREEN/YELLOW/RED). */
qualityRating: string | null;
}
| { ok: false; reason: string };
/**
* A credencial presta, e a conta é mesmo de WhatsApp?
*
* Confere as DUAS coisas de propósito. Uma chave válida apontando para uma
* conta de outra rede autentica bem e falha em todo envio — e o operador veria
* "conectado" numa tela de WhatsApp que nunca manda nada.
*/
export async function validatePartnerCredentials(
input: PartnerCredentialsInput,
): Promise<PartnerValidation> {
const accountId = input.accountId.trim();
const apiKey = input.apiKey.trim();
if (!accountId || !apiKey) return { ok: false, reason: "Informe a conta e a chave." };
let res: Response;
try {
// LISTA e não `GET /accounts/{id}`: medido contra a API, o endpoint por id
// aceita só `PUT` e responde 405 ao GET — e um 405 tratado como "credencial
// inválida" mandaria o operador trocar uma chave que estava certa. Pior: a
// primeira versão deste teste "passava" nos casos de recusa porque TODOS
// recebiam 405, verde afirmando uma validação que não acontecia.
res = await fetch(`${zernioBaseUrl()}/v1/accounts`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
} catch {
// Rede caída não é credencial errada, e dizer "chave inválida" mandaria o
// operador trocar uma chave que estava certa.
return { ok: false, reason: "Não foi possível falar com o provedor. Tente de novo." };
}
if (res.status === 401 || res.status === 403) {
return { ok: false, reason: "Chave recusada pelo provedor." };
}
if (!res.ok) {
return { ok: false, reason: `Provedor respondeu ${res.status}.` };
}
const json = (await res.json().catch(() => null)) as {
accounts?: Record<string, unknown>[];
} | null;
const contas = Array.isArray(json?.accounts) ? json.accounts : [];
const conta = contas.find((c) => String(c._id ?? c.id) === accountId) ?? null;
if (!conta) {
// A chave presta, mas não alcança esta conta. É diferente de chave inválida,
// e a mensagem precisa dizer QUAL das duas para o operador saber o que
// corrigir.
return { ok: false, reason: "Conta não encontrada para esta chave." };
}
if (conta.platform !== "whatsapp") {
return {
ok: false,
reason: `Esta conta é de ${String(conta.platform ?? "outra rede")}, não de WhatsApp.`,
};
}
const meta = (conta.metadata ?? {}) as Record<string, unknown>;
return {
ok: true,
phoneNumber: typeof meta.displayPhoneNumber === "string" ? meta.displayPhoneNumber : null,
displayName: typeof conta.displayName === "string" ? conta.displayName : null,
qualityRating: typeof meta.qualityRating === "string" ? meta.qualityRating : null,
};
}
// ---------------------------------------------------------------------------
// Persistência
// ---------------------------------------------------------------------------
/**
* As colunas da sessão carregam o nome do provider — e é assim que a migration
* 0117/0118 as criou, porque o CHECK precisa saber qual exigir. Escrevê-las da
* rota faria a rota nomear o canal, que é o que o `lint:channels` reprovou na
* primeira versão dela.
*
* Então a leitura e a escrita moram aqui, e a rota fala em conceitos: "a conta",
* "a chave", "o token do webhook".
*/
export interface PartnerSession {
id: string;
accountId: string | null;
phoneNumber: string | null;
displayName: string | null;
status: string | null;
webhookPathToken: string | null;
hasApiKey: boolean;
archivedAt: string | null;
}
const COLUNAS =
"id, zernio_account_id, phone_number, display_name, status, webhook_path_token, zernio_token_encrypted";
function toPartnerSession(row: Record<string, unknown> | null): PartnerSession | null {
if (!row) return null;
return {
id: row.id as string,
accountId: (row.zernio_account_id as string) ?? null,
phoneNumber: (row.phone_number as string) ?? null,
displayName: (row.display_name as string) ?? null,
status: (row.status as string) ?? null,
webhookPathToken: (row.webhook_path_token as string) ?? null,
hasApiKey: !!row.zernio_token_encrypted,
archivedAt: (row.archived_at as string) ?? null,
};
}
export async function findPartnerSession(
admin: SupabaseClient,
organizationId: string,
): Promise<PartnerSession | null> {
const buscar = (colunas: string) =>
admin
.from("channel_sessions")
.select(colunas)
.eq("organization_id", organizationId)
.eq("provider", PARTNER_CHANNEL_PROVIDER)
.maybeSingle();
const { data } = await queryTolerantToMissingArchived(
() => buscar(`${COLUNAS}, ${ARCHIVED_AT}`),
() => buscar(COLUNAS),
);
return toPartnerSession(data as Record<string, unknown> | null);
}
/**
* Grava (ou ressuscita) a sessão.
*
* `archived_at: null` sempre: reconectar por cima de um canal excluído precisa
* trazê-lo de volta. Sem isso o update deixaria a coluna no lugar e o canal
* "conectado" ficaria invisível para o webhook, o envio e os seletores — todos
* filtrados por ela.
*/
export async function savePartnerSession(
admin: SupabaseClient,
input: {
organizationId: string;
existingId: string | null;
accountId: string;
apiKeyEncrypted: string;
webhookPathToken: string;
webhookSecretEncrypted: string;
phoneNumber: string | null;
displayName: string;
},
): Promise<{ error: string | null }> {
const linha = {
organization_id: input.organizationId,
provider: PARTNER_CHANNEL_PROVIDER,
zernio_account_id: input.accountId,
zernio_token_encrypted: input.apiKeyEncrypted,
webhook_path_token: input.webhookPathToken,
webhook_secret_encrypted: input.webhookSecretEncrypted,
phone_number: input.phoneNumber,
display_name: input.displayName,
status: "WORKING",
archived_at: null,
};
const { error } = input.existingId
? await admin.from("channel_sessions").update(linha).eq("id", input.existingId)
: await admin
.from("channel_sessions")
.insert({ ...linha, metadata: metadataInicialDoCanal() });
return { error: error?.message ?? null };
}