mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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
235 lines
8.5 KiB
TypeScript
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 };
|
|
}
|