mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
212 lines
9.6 KiB
TypeScript
212 lines
9.6 KiB
TypeScript
import type { InterfaceSettings } from "@/lib/navigation/interface";
|
|
import type { Idioma } from "@/lib/i18n/idiomas";
|
|
import type { ModuloOpcional } from "@/lib/instalacao/modulos";
|
|
|
|
/**
|
|
* Papéis dentro do tenant.
|
|
*
|
|
* `ai_operator` é o papel do AGENTE PUBLICADO, e existe SÓ no escopo do token
|
|
* efêmero — nunca em `user_organizations`. Nenhuma pessoa o recebe, e o CHECK
|
|
* daquela tabela segue com os quatro papéis humanos de propósito: é isso que
|
|
* garante que ninguém ganhe autonomia de máquina por acidente de configuração.
|
|
*
|
|
* Ele senta ENTRE `agent` e `manager` porque descreve exatamente a faixa que
|
|
* faltava: capacidades que um atendente humano não tem (configurar a operação,
|
|
* mexer na régua de retorno) mas que o agente precisa para cumprir o invariante
|
|
* 4 da doutrina — nenhuma demanda sem próximo passo. Abrir essas capacidades
|
|
* para `agent` daria a uma PESSOA um poder que o produto não lhe dá pela tela;
|
|
* fechá-las em `manager` tira do agente o que ele existe para fazer.
|
|
*
|
|
* `fn_role_at_least` no banco NÃO conhece este papel, e está certo assim: ela
|
|
* consulta `fn_user_role_in_org`, que lê `user_organizations`. O agente não é
|
|
* usuário. A RLS segue intacta.
|
|
*/
|
|
export type Role = "viewer" | "agent" | "ai_operator" | "manager" | "admin";
|
|
export const ROLE_RANK: Record<Role, number> = {
|
|
viewer: 1,
|
|
agent: 2,
|
|
ai_operator: 3,
|
|
manager: 4,
|
|
admin: 5,
|
|
};
|
|
|
|
/**
|
|
* Compara um role (possivelmente vindo solto de uma consulta, não tipado)
|
|
* contra um mínimo. NÃO é gate de rota — isso é `requireRole()`
|
|
* (`lib/auth/require-role.ts`), o único lugar que decide 403 e aplica o gate
|
|
* de MFA. Este helper existe para os usos legítimos que sobram depois de uma
|
|
* rota já ter passado por `requireRole()`: computar um campo informativo no
|
|
* payload (ex.: `podeEditar`) ou uma regra de escopo adicional sobre o MESMO
|
|
* role já resolvido (ex.: "autor OU manager+"). Em ambos a decisão de ACESSO
|
|
* À ROTA já foi tomada; isto só lê o rank — nunca decide 401/403 sozinho.
|
|
*/
|
|
export function roleAtLeast(role: string | null | undefined, min: Role): boolean {
|
|
const rank = role ? (ROLE_RANK[role as Role] ?? 0) : 0;
|
|
return rank >= ROLE_RANK[min];
|
|
}
|
|
|
|
/** Papéis que uma PESSOA pode ter. Espelha `user_organizations_role_check`. */
|
|
export const PAPEIS_HUMANOS: ReadonlyArray<Role> = ["viewer", "agent", "manager", "admin"];
|
|
|
|
/** Rótulo pt-BR para quem configura. `ai_operator` nunca aparece em seletor de time. */
|
|
export const ROTULO_DO_PAPEL: Record<Role, string> = {
|
|
viewer: "Somente leitura",
|
|
agent: "Atendente",
|
|
ai_operator: "Assistente com autonomia de operação",
|
|
manager: "Gerente",
|
|
admin: "Administrador",
|
|
};
|
|
|
|
/**
|
|
* Escopo de visualização de conversas por atendente (G4-01, spec 13 §3.5).
|
|
* Só restringe o role `agent`; viewer/manager/admin seguem org-wide.
|
|
*/
|
|
export type VisibilityMode = "all" | "own_and_unassigned" | "own";
|
|
export const DEFAULT_VISIBILITY_MODE: VisibilityMode = "own_and_unassigned"; // G1-06a
|
|
|
|
export interface UserOrgMembership {
|
|
interface_settings?: InterfaceSettings;
|
|
organization_id: string;
|
|
organization_name: string;
|
|
role: Role;
|
|
/**
|
|
* Idioma padrão da organização (`organizations.locale`).
|
|
*
|
|
* Vem junto porque quem escolhe a organização ativa é a mesma função que
|
|
* precisa decidir o idioma — buscá-lo depois seria uma segunda ida ao banco
|
|
* para responder algo que a primeira já tinha em mãos.
|
|
*/
|
|
locale?: string | null;
|
|
/**
|
|
* Fuso IANA da organização (`organizations.timezone`).
|
|
*
|
|
* Pela mesma razão do `locale` acima: quem escolhe a organização ativa é
|
|
* quem precisa saber em que fuso a tela desenha o calendário, e buscá-lo
|
|
* depois seria uma segunda ida ao banco para responder o que a primeira já
|
|
* trouxe. Pode vir nulo ou inutilizável — nenhum escritor valida a coluna —,
|
|
* então quem usa passa por `fusoValido` e cai em `FUSO_PADRAO`.
|
|
*/
|
|
timezone?: string | null;
|
|
}
|
|
|
|
export interface AuthUser {
|
|
support?: import("@/lib/impersonate/support").SupportContext | null;
|
|
id: string;
|
|
email: string;
|
|
full_name: string | null;
|
|
avatar_url: string | null;
|
|
is_platform_admin: boolean;
|
|
/**
|
|
* Idioma da interface, de `user_metadata.locale`.
|
|
*
|
|
* Vem no AuthUser e não numa consulta própria porque toda tela precisa dele
|
|
* no primeiro render: buscá-lo depois faria a interface aparecer em português
|
|
* e trocar para espanhol meio segundo depois, em toda navegação.
|
|
*/
|
|
locale?: string | null;
|
|
/**
|
|
* O idioma que a interface REALMENTE usa nesta sessão — já resolvido.
|
|
*
|
|
* ─── Por que não bastava `locale` ──────────────────────────────────────
|
|
*
|
|
* `locale` é a PREFERÊNCIA de quem está logado, e ela costuma estar vazia:
|
|
* ninguém abre o perfil antes de usar o produto. Quando está vazia, a
|
|
* pergunta certa não é "português, então" — é "em que idioma esta empresa
|
|
* trabalha", que é `organizations.locale`.
|
|
*
|
|
* Esse campo da organização existia, tinha seletor na tela de Configurações,
|
|
* era gravado no banco… e NÃO ERA LIDO POR NINGUÉM. Medido por varredura: as
|
|
* únicas referências eram a escrita (`app/actions/settings/updateTenant.ts`)
|
|
* e a releitura para preencher o próprio formulário. Ou seja, exatamente o
|
|
* defeito que originou o i18n — um seletor que não muda uma letra —, repetido
|
|
* um andar acima e sem que ninguém percebesse.
|
|
*
|
|
* A ordem é: preferência da pessoa → idioma da organização → padrão do
|
|
* produto. É ela que faz o idioma escolhido no instalador chegar a quem
|
|
* entra: o `install.sh` grava na organização, e quem nunca abriu o perfil já
|
|
* encontra o sistema no idioma certo.
|
|
*/
|
|
idioma: Idioma;
|
|
/**
|
|
* Fuso de APRESENTAÇÃO, de `user_metadata.timezone`.
|
|
*
|
|
* Vem aqui pelo mesmo motivo do `locale` acima: a grade da Agenda precisa
|
|
* dele no primeiro render, e buscá-lo depois desenharia o dia inteiro no
|
|
* fuso errado para corrigir meio segundo depois — com os compromissos
|
|
* pulando de posição na frente de quem está olhando.
|
|
*
|
|
* NÃO é o fuso da REGRA. Em que fuso as janelas de trabalho valem é
|
|
* `attendant_availability.schedule.timezone`, e são perguntas diferentes:
|
|
* quem está em Manaus vê a grade no horário de Manaus enquanto a jornada
|
|
* continua valendo no fuso em que foi configurada.
|
|
*
|
|
* Até esta linha o campo era escrito pela tela de perfil e lido por NINGUÉM —
|
|
* o anti-pattern "tela oferece o que o código ignora".
|
|
*/
|
|
timezone?: string | null;
|
|
organizations: UserOrgMembership[];
|
|
}
|
|
|
|
export interface ActiveOrg {
|
|
interface_settings?: InterfaceSettings;
|
|
orgId: string;
|
|
/** Fuso IANA da organização — ver `UserOrgMembership.timezone`. */
|
|
timezone?: string | null;
|
|
name: string;
|
|
role: Role;
|
|
/**
|
|
* Escopo de visualização da org (G4-01). Opcional: só é preenchido no client
|
|
* context (AppLayout) para a UI do inbox decidir visões visíveis. Não é fonte
|
|
* de autorização — a RLS (fn_can_view_conversation) é quem garante o escopo.
|
|
*/
|
|
visibility_mode?: VisibilityMode;
|
|
/**
|
|
* A regra "cliente pela agenda" está ligada nesta organização
|
|
* (`organizations.settings.crm.cliente_pela_agenda`, migration 0262)?
|
|
*
|
|
* Opcional pelo mesmo motivo de `visibility_mode`: só o layout de `/app`
|
|
* preenche, e ausente é desligado. NÃO é autorização nem é quem aplica a
|
|
* regra — quem decide é o banco (o trigger lê a chave). Serve para a tela não
|
|
* mostrar selo, data e funil de clientes de uma regra desligada, em que
|
|
* `first_service_at` está congelada.
|
|
*/
|
|
cliente_pela_agenda?: boolean;
|
|
/**
|
|
* Os módulos opcionais LIGADOS na instalação (`lib/instalacao/modulos.ts`).
|
|
* É da instalação, não da organização — mora aqui porque este é o contexto
|
|
* que o layout de `/app` entrega à casca. Ausente vale como nenhum: a porta
|
|
* de módulo desligado não aparece no menu.
|
|
*/
|
|
modulos_ligados?: readonly ModuloOpcional[];
|
|
/**
|
|
* O que ESTA organização definiu para si — CAMPO A CAMPO, e só o que ela
|
|
* mesma definiu.
|
|
*
|
|
* Opcional pelo mesmo motivo de `visibility_mode`: só o layout de `/app`
|
|
* preenche, e só para o campo cuja resolução aponta a camada da organização.
|
|
* Campo ausente significa "vale o de cima" — o da instalação —, que é
|
|
* exatamente o que o menu já mostrava. Por isso `nome` também é opcional: a
|
|
* organização que definir só o logo não pode arrastar junto um `nome` que
|
|
* ninguém escolheu ali.
|
|
*
|
|
* `logoUrl` TEM produtor desde a onda do upload: `camadaDaOrganizacao`
|
|
* (`lib/branding/resolve.ts`) declara o logo a partir de
|
|
* `settings.branding.logo_path`, gravado por `/api/v1/marca/logo`. O consumidor
|
|
* (a barra lateral) entrou uma onda ANTES do produtor, de propósito e
|
|
* declarado — foi o que permitiu que o upload fosse só a camada, sem reabrir a
|
|
* casca inteira. Enquanto durou, não era campo decorativo pelo avesso: já
|
|
* tinha teste de comportamento (`tests/unit/sidebar-nome-da-organizacao.test.tsx`).
|
|
*
|
|
* A rota é a que já existe: layout → `AuthProvider` → `useAuth()`. É como o
|
|
* valor atravessa a fronteira servidor/navegador sem plumbing nova. A marca da
|
|
* INSTALAÇÃO atravessa por outro caminho, e desde esta onda também vem do
|
|
* banco: `app/layout.tsx` resolve a pilha e o `<PublicEnvScript/>` a injeta em
|
|
* `window.__PUBLIC_ENV__`, de onde `branding()` a lê.
|
|
*/
|
|
marca?: {
|
|
readonly nome?: string;
|
|
readonly logoUrl?: string | null;
|
|
readonly logoDarkUrl?: string | null;
|
|
};
|
|
}
|