Arquitetura

Tibé é um SaaS multi-tenant: uma única aplicação e um único banco de dados atendem várias empresas clientes (tenants), com isolamento de dados por linha desde a primeira tabela do schema: não há schema nem banco separado por cliente.

Stack

AplicaçãoNext.js 14 (App Router) + TypeScript, hospedado na Vercel
UITailwind CSS + kit de componentes próprio (Radix UI + class-variance-authority)
BancoPostgreSQL 17 via Neon.tech (serverless, região São Paulo)
ORMPrisma 7, com Client Extensions para isolamento multi-tenant e adapter-pg como driver
AutenticaçãoNextAuth v5 (beta), provider Credentials (email + senha com bcrypt)
Fila / cacheRedis Cloud + BullMQ (registro auditável de execuções de job)
AgendamentoVercel Cron (substitui um worker BullMQ persistente, que não existe nesta arquitetura)
Agente WhatsAppWhatsApp Business Cloud API (Meta) + N8N (orquestração) + LLM
CobrançaAsaas (PIX, boleto e cartão de crédito recorrentes)
PDFpdf-lib, gerado sob demanda (sem storage: ver seção Relatórios abaixo)

Isolamento multi-tenant

Toda tabela de negócio carrega uma coluna tenant_id. Em vez de filtrar manualmente cada query, o app usa uma Prisma Client Extension (src/lib/prisma.ts) que injeta automaticamente where: { tenant_id } em toda leitura e o próprio tenant_id em toda escrita, para o tenant resolvido da sessão NextAuth ativa. Rotas de negócio nunca recebem um Prisma Client “cru”: sempre o client já escopado, obtido via guard().

Isso significa que esquecer um filtro de tenant em uma query específica não é um risco: o isolamento acontece em uma camada central, não repetida em cada endpoint.

Exceções documentadas ao isolamento automático

Um pequeno número de rotas legitimamente precisa olhar através de tenants ou rodar sem sessão, porque o chamador ainda não tem um tenant conhecido. Todas usam o client Prisma base (não escopado) de forma deliberada:

  • PlatformUser e SubscriptionStatusLog: painel interno da Pleno Digital (Módulo 6), vivem inteiramente fora do conceito de tenant.
  • Login (NextAuth): busca o User por email antes de saber o tenant.
  • POST /api/v1/signup: cria o tenant; não existe tenant antes disso.
  • POST /api/internal/whatsapp/resolve-contact: identifica o tenant a partir do telefone.
  • POST /api/webhooks/asaas: o Asaas não tem sessão de tenant; a assinatura é localizada por asaas_subscription_id.
  • Job diário de alertas (generateAllAlerts): por natureza, itera por todos os tenants ativos.
  • Rotas /api/platform/tenants* (Módulo 6): o painel interno lê explicitamente por tenant_id qualquer tenant, por desenho (é o ponto do módulo).
  • prisma/seed.ts: popula dados iniciais de mais de um tenant.

Autenticação e permissões

A sessão NextAuth carrega tenant_id e role no JWT. Existem 4 roles, em ordem crescente de privilégio: VISUALIZADOR, OPERADOR, ADMIN, OWNER. Cada módulo (rebanho, lavoura, prestador, financeiro, alertas, usuários, assinatura) tem um nível de acesso: nenhum, leitura ou escrita: por role, definido em src/lib/permissions.ts.

Rotas de API chamam guard(module, action) como primeira linha: ele resolve a sessão, checa a permissão do módulo, checa se o perfil de tenant exigido (fazenda ou prestador) está ativo, checa o status de cobrança (ver abaixo), e devolve o client Prisma já escopado. Qualquer falha retorna o erro pronto no contrato padrão.

Painel da plataforma (Módulo 6)

O painel interno da Pleno Digital (/plataforma) usa uma segunda instância NextAuth, genuinamente separada da de tenant: cookie próprio (tibe-platform-session) e secret próprio (PLATFORM_AUTH_SECRET), nunca a mesma sessão com um campo de “tipo”. Isso significa que uma sessão de tenant nunca é aceita em /plataforma/* e vice-versa: a separação é estrutural (cookies diferentes), não uma checagem de código que poderia ser esquecida num endpoint novo.

PlatformUser tem dois papéis: EQUIPE (lê a lista e o detalhe de tenants) e MASTER_ADMIN (tudo que a equipe vê, mais os KPIs financeiros: MRR, churn, LTV, funil: e as duas ações administrativas: forçar mudança manual de status de uma assinatura e gerenciar a própria equipe da plataforma).

Toda transição de status de uma Subscription: automática (webhook do Asaas) ou manual (ação de um MASTER_ADMIN): é registrada em SubscriptionStatusLog. É esse histórico que permite calcular churn (quem estava ativo no início do período, quem cancelou dentro dele) e o tempo médio de conversão trial→pago no funil, além de servir como log de auditoria das mudanças manuais.

Padrão de rotas

Toda rota de API de negócio segue o mesmo formato:

guard(módulo, ação)  →  valida sessão + permissão + billing, devolve { db, user }
readJson + zod        →  valida o corpo da requisição
lib/actions/*.ts       →  função pura de negócio, recebe "db" (client escopado)
serializers            →  converte Decimal/Date do Prisma para number/ISO string
apiOk / apiError        →  resposta no contrato { data, meta } | { error }

A lógica de negócio mora em src/lib/actions/*.ts, como funções puras que recebem o client escopado: nunca dentro da rota HTTP nem duplicada. O endpoint de execução do agente WhatsApp (/api/internal/whatsapp/execute-action) chama exatamente as mesmas funções que as rotas usadas pelo painel web: uma única fonte de verdade para cada regra de negócio, dois canais de entrada.

Contrato de resposta

Sucesso  → { "data": ..., "meta": {} }
Erro     → { "error": { "code": "STRING_CODE", "message": "legível para humanos" } }

Códigos de status HTTP acompanham o tipo de erro: 401 sem sessão, 403 sem permissão (ou conta arquivada pela plataforma, código TENANT_ARCHIVED), 402 conta bloqueada/em atraso, 404 recurso não encontrado, 409 conflito (duplicidade), 422 validação.

Cobrança e bloqueio por inadimplência

src/lib/billing-access.ts calcula um de três níveis de acesso: full, read_only, blocked: a partir de dias em atraso (assinatura) ou dias após o fim do trial (sem assinatura). A mesma régua vale para os dois casos: até 5 dias, acesso total; de 5 a 15 dias, leitura liberada e escrita bloqueada; acima de 15 dias, bloqueio total (exceto a própria página de assinatura). guard() aplica essa checagem em toda rota, e o layout do dashboard aplica a mesma regra a nível de página.

Relatórios em PDF

Não há Cloudflare R2 nem outro storage de arquivo neste ambiente: o PDF é gerado sob demanda, em memória, e transmitido direto na resposta HTTP. O link de download (usado pelo botão “Exportar relatório” e pelo agente WhatsApp) é uma URL assinada com HMAC (src/lib/reports/report-token.ts), válida por 1 hora: a autorização vem inteiramente da assinatura do token, não de sessão, porque quem abre o link pode não estar logado no navegador.

Estrutura de pastas

src/app/(public)/     páginas sem autenticação (home, planos, faq, políticas, docs, login, criar-conta)
src/app/(dashboard)/  painel autenticado, por módulo (rebanho, lavoura, prestador, financeiro, alertas, configuracoes)
src/app/api/v1/       API de negócio, sempre atrás de guard()
src/app/api/internal/ rotas chamadas pelo N8N e pela Vercel Cron, autenticadas por secret (não por sessão)
src/app/api/webhooks/ rotas chamadas por serviços externos (Asaas), autenticadas por token
src/lib/actions/      lógica de negócio pura, testável, compartilhada entre painel e agente WhatsApp
src/lib/serializers.ts conversão Prisma → JSON (Decimal/Date → number/string)
src/components/       componentes React, organizados por módulo