mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
247 lines
12 KiB
TypeScript
247 lines
12 KiB
TypeScript
/**
|
|
* A marca nas saídas SEM DOM — e-mail, autenticador, remetente, suporte.
|
|
*
|
|
* ── E na fachada de acesso, que tem DOM e mesmo assim vem aqui ────────────────
|
|
*
|
|
* `app/(public)/layout.tsx` (login, cadastro, recuperação, MFA) também chama
|
|
* `marcaDaSaida(null)`, e isso não contradiz o nome deste módulo: o que aquela
|
|
* casca precisa é exatamente o que ele entrega — UM nome e UM logo, da pilha
|
|
* instalação → `.env`, de um resolvedor que nunca lança. Cor ela não usa: quem
|
|
* pinta aquelas telas é o `<style id="marca-instalacao">` do layout raiz. O que
|
|
* NÃO pode acontecer é a fachada montar a própria pilha e anunciar uma
|
|
* precedência que o resto do produto não usa.
|
|
*
|
|
* ── Por que este seam existe ─────────────────────────────────────────────────
|
|
*
|
|
* `app/layout.tsx:51-62` monta a pilha e devolve `MarcaResolvida`, que carrega
|
|
* a rampa de 11 stops nos DOIS temas, os tokens de cada papel e a lista de
|
|
* motivos. Isso é o que o CSS precisa. E-mail e PDF não têm CSS, não têm
|
|
* cascata e não têm tema: precisam de UM hex e de UMA frente legível. Entregar
|
|
* `MarcaResolvida` a um template de e-mail obrigaria cada template a escolher
|
|
* tema e a cavar `cor?.derivada?.claro.accent` — e o dia em que um deles
|
|
* cavasse `escuro` ninguém perceberia, porque nenhum gate lê cor de e-mail.
|
|
*
|
|
* ── TEMA CLARO, SEMPRE ───────────────────────────────────────────────────────
|
|
*
|
|
* E-mail não tem tema. A maioria dos clientes pinta fundo branco atrás do
|
|
* corpo, e `prefers-color-scheme` dentro de e-mail não é confiável (Gmail web
|
|
* descarta media query em boa parte dos casos). Escolher o tema claro é a única
|
|
* decisão que não depende de adivinhar o cliente de quem recebe.
|
|
*
|
|
* ── NUNCA LANÇA — e aqui isso não é zelo ─────────────────────────────────────
|
|
*
|
|
* Mesmo contrato de `lib/branding/instalacao.ts:230-241`, por um motivo mais
|
|
* duro: quem chama isto é o e-mail de LGPD, que responde a um direito legal do
|
|
* titular com SLA de D+7 (`docs/prd/01-prd-platform-base.md`, e o alarme em
|
|
* `lib/lgpd/sla-alarm.ts`). Um envio que falha porque a LEITURA DA COR deu erro
|
|
* troca um problema estético por um descumprimento de prazo legal. Em qualquer
|
|
* falha — banco fora do ar, tabela ausente, jsonb corrompido — esta função
|
|
* degrada para o padrão do produto e segue.
|
|
*
|
|
* Não é engolir erro: a recusa sai no log estruturado, e a resolução da cor
|
|
* continua devolvendo `motivos` por dentro (`resolve.ts`), que é o que a tela
|
|
* `/admin/marca` mostra ao operador.
|
|
*/
|
|
|
|
import { DEFAULT_APP_NAME } from "@/lib/branding";
|
|
import { env } from "@/lib/env";
|
|
import { valorDaInstalacao } from "@/lib/instalacao/config";
|
|
import { logger } from "@/lib/logger";
|
|
import { createAdminClient } from "@/lib/supabase/admin";
|
|
|
|
import { melhorFrenteSobre } from "./contraste";
|
|
import { marcaDaInstalacao } from "./instalacao";
|
|
import { resolverMarcaDaOrganizacao } from "./organizacao";
|
|
import { stop } from "./rampa";
|
|
import { REGUA_DO_PRODUTO } from "./regua-do-produto";
|
|
import { camadaDaInstalacao, camadaDoAmbiente, resolverMarca } from "./resolve";
|
|
|
|
export type MarcaDeSaida = {
|
|
readonly nome: string;
|
|
/**
|
|
* `null` = não há logo configurado, e quem renderiza NÃO desenha nada no
|
|
* lugar. Ficou sem leitor nenhum desde que este seam nasceu — os dois
|
|
* consumidores entraram nesta onda: o topo do convite de time
|
|
* (`lib/email/templates/invite.ts`) e a casca das telas de acesso
|
|
* (`app/(public)/layout.tsx`).
|
|
*/
|
|
readonly logoUrl: string | null;
|
|
readonly logoDarkUrl?: string | null;
|
|
/** `#hex` sempre — o formato que cliente de e-mail e @react-pdf entendem. */
|
|
readonly accent: string;
|
|
/** Preto ou branco, já com o piso de contraste aplicado. */
|
|
readonly accentFg: string;
|
|
/** De qual camada veio cada campo. `padrao` = ninguém configurou. */
|
|
readonly origens: { readonly nome: string; readonly cor: string };
|
|
};
|
|
|
|
/**
|
|
* O accent do tema CLARO do produto — LIDO da régua, nunca redigitado.
|
|
*
|
|
* `REGUA_DO_PRODUTO.claro.indices.accent` é 6 (`regua-do-produto.ts:175`) e o
|
|
* grau 600 da rampa do produto é `#506d48` (`:34`). Escrever `"#506d48"` aqui
|
|
* criaria a QUARTA cópia do mesmo hex no repositório (as outras vivem em
|
|
* `regua-do-produto.ts`, `app/globals.css` e na rampa derivada), e nada as
|
|
* manteria em sincronia — o dia em que o produto mudar de cor, o botão dos
|
|
* e-mails ficaria com a cor velha e nenhum teste reprovaria.
|
|
*
|
|
* `stop()` e não `[6]`: sob `noUncheckedIndexedAccess`, indexar a tupla com um
|
|
* `number` devolveria `string | undefined`, e o `!` para calar isso é
|
|
* exatamente o cast que a doutrina proíbe.
|
|
*/
|
|
const ACCENT_DO_PRODUTO = stop(
|
|
REGUA_DO_PRODUTO.rampaDoProduto,
|
|
REGUA_DO_PRODUTO.claro.indices.accent,
|
|
);
|
|
|
|
/**
|
|
* Os neutros do tema claro, para o texto que NÃO é o botão.
|
|
*
|
|
* Existe para que um template de e-mail nunca precise digitar um hex. Antes
|
|
* disto cada template tinha a própria paleta cinza inventada (`#111827`,
|
|
* `#6b7280`, `#1c1917`, `#57534e`, `#78716c` — cinco tons de dois sistemas
|
|
* diferentes em três arquivos), e o corpo do e-mail não parecia o produto.
|
|
* Os índices são os mesmos que `app/globals.css` usa para texto e texto suave.
|
|
*/
|
|
export const NEUTROS_DE_SAIDA = {
|
|
/** Corpo do texto. */
|
|
texto: stop(REGUA_DO_PRODUTO.claro.neutros, 9),
|
|
/** Rodapé, legenda, aviso — o que não é a mensagem principal. */
|
|
suave: stop(REGUA_DO_PRODUTO.claro.neutros, 5),
|
|
/** Fundo da página do e-mail. */
|
|
fundo: stop(REGUA_DO_PRODUTO.claro.neutros, 0),
|
|
/** Régua e borda. */
|
|
linha: stop(REGUA_DO_PRODUTO.claro.neutros, 2),
|
|
} as const;
|
|
|
|
/** O que sobra quando nada pôde ser lido. Uma instalação funcionando. */
|
|
function padraoDoProduto(): MarcaDeSaida {
|
|
return {
|
|
nome: DEFAULT_APP_NAME,
|
|
logoUrl: null,
|
|
accent: ACCENT_DO_PRODUTO,
|
|
accentFg: melhorFrenteSobre(ACCENT_DO_PRODUTO),
|
|
origens: { nome: "padrao", cor: "padrao" },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Avisos já registrados neste processo — mesmo desenho de `instalacao.ts:223`.
|
|
* Sem isto, um banco fora do ar encheria o log com uma linha por e-mail.
|
|
*/
|
|
const avisosRegistrados = new Set<string>();
|
|
|
|
function avisarUmaVez(chave: string, mensagem: string, contexto: Record<string, unknown>): void {
|
|
if (avisosRegistrados.has(chave)) return;
|
|
avisosRegistrados.add(chave);
|
|
logger.warn(mensagem, contexto);
|
|
}
|
|
|
|
/**
|
|
* O `settings` da organização, cru. `null` = "não deu para ler" E TAMBÉM
|
|
* "a organização não fala de marca" — as duas descem para a camada de baixo,
|
|
* que é o comportamento certo nos dois casos (`camadaDaOrganizacao` trata
|
|
* `null` como camada que não fala de nada, `resolve.ts:466-471`).
|
|
*/
|
|
async function settingsDaOrganizacao(organizationId: string): Promise<unknown> {
|
|
const { data, error } = await createAdminClient()
|
|
.from("organizations")
|
|
.select("settings")
|
|
.eq("id", organizationId)
|
|
.maybeSingle();
|
|
if (error) {
|
|
avisarUmaVez(
|
|
`organizacao|${error.code ?? "?"}`,
|
|
"marca de saída: não deu para ler a marca da organização; vale a da instalação",
|
|
{ codigo: error.code, detalhe: error.message },
|
|
);
|
|
return null;
|
|
}
|
|
return data?.settings ?? null;
|
|
}
|
|
|
|
/**
|
|
* A marca que vai numa saída sem DOM.
|
|
*
|
|
* `organizationId !== null` → pilha completa (organização → instalação → `.env`
|
|
* → padrão). É a CLASSE A: o convite de time e o e-mail de LGPD dizem quem
|
|
* atendeu, e no produto do revendedor quem atende é o cliente dele.
|
|
*
|
|
* `organizationId === null` → instalação → `.env` → padrão. É a CLASSE B: as
|
|
* saídas anteriores a qualquer organização (cadastro de MFA, e-mail de auth).
|
|
* Passar `null` não é omissão — é a declaração de que ainda não há organização.
|
|
*
|
|
* A CLASSE C (não leva marca nenhuma) não chama esta função: é o PDF de LGPD,
|
|
* e o motivo está escrito em `lib/lgpd/pdf-renderer.tsx`.
|
|
*
|
|
* A leitura da instalação é memoizada por 30s (`instalacao.ts:209`) e a
|
|
* invalidação da escrita alcança o MESMO processo — porque quem envia e-mail é
|
|
* o app, via `event-log-drain`, e não o contêiner `worker` (medido:
|
|
* `lib/event-log/register-handlers.ts:12` é importado só por
|
|
* `app/api/v1/cron/event-log-drain/route.ts:21`; `Dockerfile.worker` roda
|
|
* `workers/agent-worker/main.ts`, que não importa e-mail nenhum).
|
|
*/
|
|
export async function marcaDaSaida(organizationId: string | null): Promise<MarcaDeSaida> {
|
|
try {
|
|
const linha = await marcaDaInstalacao();
|
|
const marca =
|
|
organizationId === null
|
|
? resolverMarca([camadaDaInstalacao(linha), camadaDoAmbiente(env)], REGUA_DO_PRODUTO)
|
|
: resolverMarcaDaOrganizacao(await settingsDaOrganizacao(organizationId), linha, env);
|
|
|
|
// `claro`, sempre — ver o cabeçalho. `derivada` é `null` quando a semente
|
|
// não pinta (cor acromática, papel só de identidade, hex recusado): aí o
|
|
// accent do produto é a resposta certa, e não a semente crua, que nesses
|
|
// casos é justamente a cor que o derivador se recusou a usar.
|
|
const derivada = marca.cor?.derivada ?? null;
|
|
const accent = derivada?.claro.accent ?? ACCENT_DO_PRODUTO;
|
|
|
|
return {
|
|
nome: marca.name,
|
|
logoUrl: marca.logoUrl,
|
|
...(marca.logoDarkUrl ? { logoDarkUrl: marca.logoDarkUrl } : {}),
|
|
accent,
|
|
// Nunca `#ffffff` fixo: `melhorFrenteSobre` (`contraste.ts:79`) já
|
|
// decide preto ou branco pelo contraste real. Uma marca amarela colada
|
|
// pelo revendedor produziria texto branco ilegível no botão — e é
|
|
// exatamente a marca que se cola sem avisar ninguém.
|
|
accentFg: derivada?.claro.accentFg ?? melhorFrenteSobre(accent),
|
|
origens: {
|
|
nome: marca.origens.nome,
|
|
// Sem derivação a cor EXIBIDA é a do produto, mesmo que alguma camada
|
|
// tenha declarado uma semente. Reportar a camada aqui faria o
|
|
// diagnóstico dizer "a cor veio do banco" enquanto o botão está verde
|
|
// do produto.
|
|
cor: derivada ? marca.origens.cor : "padrao",
|
|
},
|
|
};
|
|
} catch (erro) {
|
|
avisarUmaVez("resolucao|excecao", "marca de saída: resolução falhou; vale o padrão do produto", {
|
|
detalhe: erro instanceof Error ? erro.message : String(erro),
|
|
});
|
|
return padraoDoProduto();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* O e-mail de suporte que a instalação mostra ao cliente final.
|
|
*
|
|
* String VAZIA significa "ninguém configurou", e quem chama NÃO renderiza
|
|
* endereço nenhum. Cair no nosso endereço seria o defeito de volta: numa tela
|
|
* de conta suspensa, quem suspendeu foi o revendedor — mandar o cliente dele
|
|
* escrever para nós entrega o cliente e não resolve o problema dele.
|
|
*
|
|
* O que a nota anterior aqui pedia — "quando a coluna entrar, esta função ganha
|
|
* a linha do banco ACIMA do ambiente, na mesma ordem que a marca já usa" — é o
|
|
* que esta versão faz. A migration 0341 trouxe `platform_config`, que guarda uma
|
|
* linha por variável em vez de uma coluna por campo, e o resolvedor devolve a
|
|
* ordem certa: banco acima, arquivo de instalação embaixo.
|
|
*
|
|
* Virou `async` porque o banco exige espera. O alcance foi medido antes: são
|
|
* duas páginas de servidor, ambas já assíncronas.
|
|
*/
|
|
export async function emailDeSuporte(): Promise<string> {
|
|
const { valor } = await valorDaInstalacao("SUPPORT_EMAIL");
|
|
return (valor ?? "").trim();
|
|
}
|