Empresas e multi-tenant
Estrutura multi-tenant do template — empresas, membros, convites, perfis e planos.
O template é multi-tenant: um usuário pode estar vinculado a várias empresas (Empresa), cada uma com seus próprios membros (MembroEmpresa), perfis de acesso (Role) e um plano de assinatura (Plano) que funciona como teto de features. Todo recurso de domínio real (hoje, Evento da agenda) pertence a uma empresa, não a um usuário.
Para o motor de RBAC que consome esse modelo (AccessContext, canUseFeature, guards), ver Controle de acesso.
Modelo de dados
Empresa(id, nome, ativo, planoId?): o tenant.planoIdé opcional —nullequivale a "sem teto de plano" (tudo liberado), pra não quebrar empresas criadas antes de existir a feature de planos.MembroEmpresa(id, empresaId, usuarioId, roleId, ativo,@@unique([empresaId, usuarioId])): o vínculo de um usuário com uma empresa e o perfil que ele tem nela. Um usuário pode ter, no máximo, um vínculo por empresa — mas pode ter vínculos com várias empresas diferentes (é assim que um mesmo login acessa múltiplas empresas, trocando qual está "ativa" na sessão).Role(id, empresaId, nome, permissoes, padraoSistema,@@unique([empresaId, nome])): perfil de acesso, sempre escopado a uma empresa (não é mais global).padraoSistema: truemarca os 4 perfis padrão semeados na criação da empresa (policy.ts.papeisPadraoDoSistema) — Proprietário, Administrador, Secretário e Profissional — que não podem ser renomeados nem excluídos. Proprietário e Administrador nascem com todas as permissões; Secretário e Profissional nascem com tudo exceto operações de exclusão (o template ainda não tem um recurso "financeiro" pra restringir só do Profissional — quando existir, remova essas chaves do conjunto dele empapeisPadraoDoSistema). O Proprietário, além disso, sempre tem todas as permissões da empresa (bypass emcanUseFeature, não editável em/dashboard/empresa/papeis); os outros 3 têm as permissões editáveis normalmente.Plano/PlanoPermissao: matriz de features liberadas por plano, editada em/admin/planos(área global, só admin da plataforma). Hoje só existe o planoPADRAO, com todas as chaves depermission-registry.tsliberadas — pensado para crescer para planos reais (com tiers e limites) sem mudar a estrutura.
Empresa ativa da sessão
A sessão guarda qual empresa está "ativa" num cookie httpOnly (EMPRESA_ATIVA_COOKIE, lib/access-control/context.ts). Esse cookie nunca é confiado diretamente: toda leitura de AccessContext revalida o valor contra os vínculos MembroEmpresa ativos do usuário no banco — se o cookie apontar pra uma empresa sem vínculo ativo, getAccessContext() cai para o primeiro vínculo do usuário (ou null, se não houver nenhum).
A troca de empresa ativa fica no dropdown do cabeçalho do sidebar (components/sidebar/logo-header-sidebar.tsx, lista alimentada por components/sidebar/layout.tsx) — não existe mais uma página dedicada de "Minhas empresas". selecionarEmpresaAtivaAction (components/sidebar/actions.ts) sempre confirma o vínculo (empresaService.possuiVinculoAtivo) antes de setar o cookie — nunca aceita um empresaId vindo do client sem essa checagem — e o client força um reload completo da página após trocar, pra garantir que todo estado reflita a empresa nova.
Fluxo de criação de empresa
empresaService.criarEmpresa (lib/services/empresa.service.ts) roda numa transação: cria a Empresa, semeia os 4 perfis padrão do sistema (papeisPadraoDoSistema()) e vincula quem criou como Proprietário. A tela (/dashboard/empresas/novo) fica fora do gate de /dashboard/empresa (o usuário ainda não tem vínculo no momento de criar a primeira empresa) e, ao criar, seta o cookie de empresa ativa e redireciona para /dashboard/empresa.
Convite de membro por e-mail
empresaService.criarConviteEEnviar reaproveita o model ActionToken já existente (tipo CONVITE_MEMBRO_EMPRESA, expira em 24h, revogarAnteriores: true invalida convites pendentes anteriores pro mesmo e-mail/empresa) e o emailService pra enviar o link. A tela /dashboard/empresa/membros lista os membros (ativos e inativos) e também os convites ainda pendentes (empresaService.listarConvitesPendentes, status "Convite enviado" na tabela, com ações de revogar e reenviar). O botão "Adicionar membro" no header abre o formulário de convite em dialog, visível só pra quem tem a permissão empresa:convidar — a mesma permissão também guarda trocar o perfil de um membro (alterarPapelMembroAction) e inativá-lo (inativarMembroAction): gerenciar membros é uma única área de responsabilidade.
O convite exige aceite explícito (empresaService.aceitarConvite, tela /dashboard/empresas/convite/[token]), mesmo se o e-mail convidado já tiver conta na plataforma — a sessão precisa bater com o e-mail convidado, e o aceite é registrado no AuditLog como evento explícito (CONVITE_MEMBRO_EMPRESA_ACEITO).
Uma empresa nunca pode ficar sem Proprietário: inativarMembro bloqueia inativar o único Proprietário ativo, e bloqueia um membro de inativar a si mesmo.
Perfis e permissões (/dashboard/empresa/papeis)
Tela de administração de perfis da empresa — badges pra selecionar o perfil (via searchParams) + a matriz de permissões inline num Card, com "Salvar" no header do card (mesmo padrão visual da tela de planos abaixo, sem modal). Reaproveita PermissionMatrixField (components/permission-matrix-field.tsx): checkbox mestre por recurso + "+ opções" colapsável pras operações individuais, com base no catálogo de permission-registry.ts.
- O botão "Novo perfil" no header da página abre um dialog/drawer pra dar o nome do perfil e, opcionalmente, copiar as permissões de um perfil já existente (
copiarDeIdemcriarPapelAction) — sem isso, o perfil nasce sem nenhuma permissão. - Perfis
padraoSistema(os 4 perfis padrão) têm as permissões editáveis, mas não podem ser renomeados nem excluídos. - O perfil Proprietário é a única exceção: nem as permissões dele são editáveis — a tela mostra um aviso em vez do formulário de matriz, porque ele sempre tem tudo liberado (ver Controle de acesso).
- Excluir um perfil exige que ele não tenha membros ativos vinculados — a mensagem de erro orienta a mover os membros antes.
- Trava de leitura: nenhuma operação de
create/update/deletepode ficar liberada sem a operaçãoreaddo mesmo recurso também liberada — não faz sentido editar algo que não se pode ver.PermissionMatrixFieldtrava o checkbox de leitura enquanto alguma escrita estiver marcada (desligue a escrita primeiro pra poder desmarcar a leitura), eaplicarDependenciasDeLeitura(permission-registry.ts) normaliza a mesma regra no servidor, emrole.service.tseplano.service.ts, então a trava vale mesmo se alguém contornar a UI.
Tudo isso é guardado, na origem (não só escondido na UI), pela permissão papeis:gerenciar e pelas travas em role.service.ts (atualizarPermissoes/renomear/removerPapel).
Permissões por plano (/admin/planos)
Área global (não por empresa, app/(admin)/admin/planos), só acessível a admin da plataforma (assertAdminAction). Mesmo padrão visual da tela de perfis: badges pra selecionar o plano + matriz inline com "Salvar". Um formulário simples ("Novo plano", só código/nome) permite cadastrar mais planos conforme forem sendo definidos — hoje só existe o PADRAO, com tudo liberado, seguindo o mesmo catálogo de permission-registry.ts usado pela tela de perfis.
planoService.buscarMatriz(planoId) é a fonte única consultada por canUseFeature: para cada chave do catálogo, permitido: true/false (ausência de linha = false), memoizado por request via cache() do React. planoId: null (empresa sem plano vinculado) retorna tudo true, preservando compatibilidade.
Estendendo para o seu domínio
Ao adicionar um recurso novo de domínio (ex.: Produto), siga o padrão de Evento:
estudioId/userIdnão — o model ganhaempresaId String+ relação comEmpresa, nunca comUserdiretamente.- Services escopam por
empresaId, nunca aceitando esse valor do client sem confirmar contractx.membroEmpresa.empresaId. - Novas permissões granulares (
produtos:create, etc.) entram empermission-registry.ts, liberadas no(s) plano(s) relevantes e atribuídas aos perfis que devem ter acesso — ver "Como incluir uma nova permissão" em Controle de acesso.
A página /dashboard/exemplos mostra, ao vivo, o resultado de cada permissão do catálogo pro usuário logado — útil como referência ao estender.