0051 — Usuários da imobiliária: quem tem acesso, e como tirá-lo¶
| Status | CONCLUÍDA |
| Branch | spec/0051-usuarios-da-imobiliaria |
| Criada em | 2026-09-05 |
| Apps afetados | api · web · e2e |
| Revisão do problema | aprovada na rodada 1 (nota 9) |
| Revisão da experiência | aprovada na rodada 3 (nota 8) |
| Revisão do detalhamento | aprovada na rodada 5 (nota 8) |
| Revisão da implementação | aprovada na rodada 5 da revisão de branch (nota 9) |
Status:
RASCUNHO→APROVADA→EM IMPLEMENTAÇÃO→CONCLUÍDA
A spec tem três partes, escritas em ordem, e quatro portões — cada um revisado por um subagente de contexto limpo, em até 3 rodadas. A máquina de revisão é do plugin superpowers; a régua é a rubrica correspondente em .claude/rubrics/. Achados vêm classificados: qualquer Critical reprova, Important em aberto reprova salvo decisão registrada do usuário, e Minor vira pendência aceita.
- A Parte I diz qual é o problema e como se convive com ele hoje. Revisada na seção 6 (necessidade × jornada de hoje). É o portão mais barato de todos, e o único que pergunta contra que problema isto está sendo construído.
- A Parte II diz como se usa o que vai existir. Revisada na seção 8 (jornada × viewports).
- A Parte III diz como se constrói. Revisada na seção 16 (segurança × clareza técnica) — autorização, isolamento, e se sobrou decisão para quem vai codar.
- A implementação é medida contra as três, na seção 17 (aderência × qualidade × testes) — uma revisão por fase, e uma da branch inteira antes de fechar.
Cada parte só se escreve depois que a anterior passou. Descrever tela sobre problema mal enquadrado, ou detalhar endpoint sobre experiência vaga, é o jeito mais caro de descobrir que era outra coisa.
Parte I — O problema¶
Aqui não entra solução: nem tela, nem endpoint, nem botão. Só o que dói, para quem, e o que se faz hoje. Se você já sabe qual é a tela, guarde para a Parte II.
1. Dor¶
A garantidora dá acesso a pessoas da imobiliária e depois não consegue mais vê-las nem tirá-las. O convite de parceria cria o usuário e manda o e-mail; a partir daí ele some da vista de quem convidou. Não há lugar no produto onde o gestor da garantidora responda a três perguntas elementares sobre uma parceira: quem da imobiliária tem acesso, quem ainda não aceitou o convite, e como tirar o acesso de quem saiu de lá.
O custo de conviver com isso é de dois tipos, e o segundo é o que pesa:
- Acesso que sobrevive à pessoa. A imobiliária avisa que um funcionário saiu; a garantidora não tem o que fazer. A conta continua entrando, vendo inquilinos, imóveis e análises daquela imobiliária, e criando análise em nome dela. O dado que a garantidora guarda por conta de um contrato de parceria fica exposto a alguém que não está mais na parceria — e a garantidora é quem responde por ele.
- Convite que ninguém confere. Errou o e-mail no convite, ou a pessoa não recebeu: não há como saber se está pendente, reenviar ou cancelar. O que a garantidora enxerga é o mesmo, convidado ou não.
Há ainda um efeito colateral que já apareceu no ambiente de desenvolvimento: um usuário de imobiliária que perde o vínculo com a imobiliária aparece na Equipe — a lista de usuários internos da garantidora — misturado ao time. Quem administra a garantidora vê ali um nome que não é da casa, com um perfil ("Usuário de Imobiliária") que a Equipe não concede nem sabe explicar. E esse mesmo usuário, sem vínculo, é tratado pelo escopo de visibilidade como se fosse da garantidora: alcança a carteira inteira — inquilinos, imóveis e análises de todas as imobiliárias do tenant —, exatamente o que um usuário de imobiliária nunca deveria alcançar. Não é hipótese: são sete contas assim no dev-remote hoje (seis criadas por jornadas encenadas em 2026-08-11, em três imobiliárias apagadas depois, e uma criada à mão em 2026-08-15), todas ativas.
Do lado da imobiliária, a dor é simétrica. O responsável pela imobiliária não enxerga quem da equipe dele tem acesso ao sistema da garantidora, não convida um colega novo e não tira o acesso de quem saiu — tudo isso depende de pedir à garantidora, que por sua vez não tem como atender. O canal, que é quem conhece a própria equipe, é o único que não pode cuidar dela.
2. Quem sente¶
- O gestor da garantidora (
ADMIN_TENANT) — sente primeiro e mais. É ele quem fecha a parceria, convida o primeiro usuário da imobiliária e, meses depois, recebe o aviso de que "o Fulano saiu". Também é ele quem abre a Equipe e encontra um usuário de imobiliária no meio do time. É caminho obrigatório: toda parceria começa com um convite, e toda equipe de imobiliária troca de gente. - Quem a garantidora delegar a administração de acessos — hoje a garantidora pode criar perfis personalizados com as permissões de Equipe, mas o convite de imobiliária está fora de qualquer permissão delegável: é exclusivo de
ADMIN_TENANTeADMIN_GLOBALpor decisão da spec 0041 (RN2b). O gestor não consegue passar essa tarefa a ninguém. - O responsável pela imobiliária (
AGENCY_USER) — quer que a própria equipe consiga operar, e hoje só consegue isso pedindo à garantidora. Sente a cada contratação e a cada saída na imobiliária. - O
ADMIN_GLOBALsente pouco: pela lista da plataforma ele vê e desativa qualquer usuário, inclusive os de imobiliária. É o único que consegue, e é o caminho errado — o SaaS admin administra a plataforma, não a parceria de um cliente.
Não há volume a medir: o produto está em produção sem clientes (ver o estágio do produto no CLAUDE.md da raiz). O alcance se sustenta pelo desenho — o convite é o único caminho de entrada de um usuário de imobiliária, e não há caminho de saída.
3. A jornada de hoje¶
Dar acesso. O gestor da garantidora abre a página da imobiliária parceira, clica em Convidar, informa nome e e-mail. O sistema cria o usuário já vinculado à imobiliária, inativo, e manda o e-mail com o link; ao aceitar, a pessoa define a senha, ganha o perfil de imobiliária e passa a operar. Até aqui funciona.
Conferir quem tem acesso. Não há passo: a página da imobiliária não mostra os usuários dela, e a Equipe os esconde de propósito (RN5 da spec 0041, para não misturar canal e time). O gestor sabe quem convidou se anotou em algum lugar fora do sistema. Se o e-mail foi digitado errado, ele descobre quando a imobiliária reclama que nada chegou — e o único jeito de "reenviar" é convidar de novo, que falha com e-mail já em uso se o endereço era o certo e a pessoa só não viu o e-mail.
Tirar acesso. É onde quebra. A imobiliária avisa, por WhatsApp ou telefone, que alguém saiu. O gestor da garantidora não tem onde clicar: o usuário não aparece na Equipe e a página da imobiliária não lista ninguém. As saídas que existem hoje:
- Pedir ao time da plataforma (
ADMIN_GLOBAL), que enxerga todo mundo na lista global e desativa. Depende de alguém de fora da garantidora, para uma decisão que é da garantidora — e, com clientes de verdade, é o SaaS admin mexendo em acesso de negócio de um cliente. - Não fazer nada, que é o que acontece quando ninguém lembra de pedir. A conta segue ativa.
Do lado da imobiliária. O responsável não tem página nenhuma da própria imobiliária: o perfil dele tem permissão de ler imobiliária, mas não de listar, e a rota da lista exige listar — então o menu não mostra Imobiliárias e a URL direta é barrada. Quem entrou e quem saiu da equipe dele, ele resolve por WhatsApp com a garantidora, que cai no parágrafo anterior.
O órfão. O vínculo do usuário com a imobiliária é uma chave estrangeira com ondelete=SET NULL; nada amarra o perfil de imobiliária a esse vínculo. Apagar a imobiliária de verdade — como uma limpeza de dados de teste faz, fora da API — zera o vínculo e deixa o perfil. O resultado é o usuário descrito na seção 1: aparece na Equipe (o filtro dela é "sem vínculo", não "sem perfil de imobiliária") e enxerga a carteira inteira (o escopo trata "sem vínculo" como "é da garantidora"). Não há trilha de auditoria dessa remoção, porque ela não passou pelo produto. Pelo produto, a remoção de imobiliária é lógica (spec 0049) e não faz nada com os usuários dela: eles continuam entrando, apontando para uma imobiliária removida.
O que já se tentou. A spec 0041 tirou os usuários de imobiliária da Equipe (RN5) e fechou o convite para ADMIN_TENANT/ADMIN_GLOBAL (RN2b), porque um perfil personalizado com users:create podia convidar um e-mail que controlasse e ganhar o poder do parceiro. Resolveu a confusão da lista e a escalada — e deixou os usuários de imobiliária sem lugar nenhum para aparecer. A spec 0049 deu remoção lógica à imobiliária e não tocou nos usuários dela.
4. Resultado esperado¶
- A garantidora vê, para cada imobiliária parceira, quem tem acesso e em que situação (aceitou, ainda não aceitou, teve o acesso tirado), com quando e por quem foi convidado e quando entrou pela última vez.
- A garantidora tira o acesso de quem saiu, e o efeito é imediato: a pessoa não entra mais, e a saída fica registrada na Auditoria com quem fez.
- Convite pendente deixa de ser cego: dá para reenviar e cancelar, sem esbarrar em e-mail já em uso.
- O responsável pela imobiliária cuida da própria equipe — vê quem tem acesso, convida colega, tira acesso de quem saiu — sem passar pela garantidora e sem enxergar nada de outra imobiliária.
- A garantidora delega essa administração a quem quiser do time, por perfil, em vez de ela ficar presa ao gestor.
- Usuário de imobiliária sem imobiliária deixa de existir como estado alcançável: não aparece na Equipe, não enxerga a carteira inteira, e a remoção da imobiliária leva o acesso dos usuários dela junto (e a restauração o devolve). As sete contas do dev-remote são removidas e, se ainda forem necessárias, reconvidadas pelo caminho certo.
Como se sabe que resolveu: o aviso "o Fulano saiu" passa a ter um destino dentro do produto, em minutos, sem ADMIN_GLOBAL; a Equipe não mostra mais nenhum "Usuário de Imobiliária"; e um usuário de imobiliária nunca enxerga registro de outra, com ou sem vínculo.
5. Fora de escopo¶
- Vincular um usuário já existente a uma imobiliária, ou trocá-lo de imobiliária. O único caminho de entrada continua sendo o convite. O órfão de hoje se resolve removendo e reconvidando, não religando — religar exigiria decidir o que fazer com o que ele fez enquanto estava solto.
- Editar dados do usuário de imobiliária (nome, e-mail) e redefinir senha por ele. A pessoa cuida do próprio cadastro; a garantidora e o responsável cuidam do acesso.
- Perfis personalizados para a imobiliária. A garantidora cria perfis para o próprio time; a imobiliária não ganha isso aqui. O que se distingue dentro dela é só quem administra a equipe e quem não administra — e esse desenho é da Parte III.
- Lista de usuários de imobiliária no SaaS admin. O
ADMIN_GLOBALjá vê todo mundo na lista da plataforma; não ganha tela nova. - Repensar a Equipe. Ela só deixa de mostrar quem não é da casa e de deixar o gestor sem saber onde esses usuários ficam; layout, filtros e ações continuam os da spec 0041.
- Notificar a imobiliária quando um acesso é tirado. Quem tira, avisa.
6. Revisão do problema¶
Preenchido pelo laço do /spec new, rubrica .claude/rubrics/problema.md. Registrar todas as rodadas, inclusive as reprovadas.
| Rodada | Critical | Important | Minor | Nota | Veredicto |
|---|---|---|---|---|---|
| 1 | 0 | 0 | 3 | 9 | aprovada |
Resultado: aprovada na rodada 1.
Pendências aceitas: dos três Minor, dois foram corrigidos de passagem (o singular "cuja imobiliária" onde eram três imobiliárias; o desenho de tela que escapava para o fora de escopo). O terceiro fica como aviso à Parte III: "quando entrou pela última vez" (seção 4) é dado novo — o User de hoje não registra último acesso —, não dado exibido.
Parte II — A experiência¶
Não preencher antes da revisão da seção 6 estar aprovada.
7. Jornada pretendida¶
A feature é uma seção — "Usuários" — dentro da página da imobiliária, vista por dois lados: pela garantidora, na página de detalhe de cada parceira que já existe hoje; e pela própria imobiliária, numa página nova, Minha Imobiliária, que é a mesma página vista de dentro. Não há tela de usuário de imobiliária fora dessa seção, e a Equipe continua sendo só do time da garantidora.
Ponto de entrada.
- Garantidora: menu Imobiliárias → a parceira → a seção Usuários, no fim da página de detalhe, depois de Responsável de contato. O botão Convidar operador que já existe no topo continua lá e passa a alimentar a seção.
- Imobiliária: item novo no menu, Minha Imobiliária, no grupo Administração — o espelho de Minha Garantidora. Abre a página de detalhe da própria imobiliária inteira e só leitura — a faixa de situação com "Cadastrado em", e as quatro seções: identificação, contato, endereço e responsável de contato — com a mesma seção Usuários embaixo. Todo usuário de imobiliária vê o item; o que muda entre o responsável e o usuário comum é se as ações aparecem.
- Equipe: ganha uma linha de ajuda entre o cabeçalho e os filtros — "Usuários de imobiliária são administrados na página de cada imobiliária", com imobiliária levando à lista de parceiras. Não vai no subtítulo do
PageHeader: aquele slot é texto de uma linha, sem quebra e sem link, e já diz "Gerencie usuários internos", que fica. Aparece só para quem alcança a lista de imobiliárias; para os outros, nada muda. - E-mail de convite: o convidado continua chegando pelo link do e-mail, define a senha e cai na tela inicial dele. Esse fluxo não muda.
Jornada, caso feliz — tirar o acesso de quem saiu (garantidora). A imobiliária avisa que a Sandra saiu. O gestor abre Imobiliárias, entra na parceira, rola até Usuários, encontra a Sandra, abre o menu da linha e escolhe Remover acesso. Um diálogo diz o que vai acontecer — ela deixa de entrar agora; o e-mail dela continua reservado; as análises que ela abriu continuam na imobiliária — e pede confirmação. Confirmado, a linha some e uma mensagem confirma. Três cliques depois de achar o nome; nada a digitar.
Jornada, caso feliz — a imobiliária cuida da própria equipe. O responsável pela imobiliária abre Minha Imobiliária, vê a equipe em Usuários, clica Convidar operador, informa nome e e-mail e marca se a pessoa também será responsável. O convidado aparece na hora com a situação Convite pendente, e some dela quando aceita. Se o e-mail não chegou, o menu da linha oferece Reenviar convite; se foi engano, Cancelar convite — e o e-mail volta a ficar livre. Quando alguém sai, o mesmo Remover acesso da garantidora está ali.
Jornada — trocar quem é responsável. Na linha de um usuário comum, Tornar responsável; na de um responsável, Deixar de ser responsável, com confirmação. O sistema não deixa a imobiliária ficar sem responsável — quem tenta tirar o último lê o motivo no diálogo — e as ações que ninguém pode fazer consigo mesmo (tirar o próprio acesso, deixar de ser o próprio responsável) simplesmente não aparecem na própria linha (ver 7.1).
Jornada — remover a imobiliária. A remoção tem dois diálogos hoje — o da lista e o da página de detalhe —, e os dois passam a avisar "N usuários desta imobiliária perderão o acesso" quando houver algum. O da página de detalhe ainda diz "remover permanentemente… não pode ser desfeita", contra uma remoção que é lógica desde a spec 0049: ele ganha o mesmo texto do diálogo da lista — "O registro deixa de aparecer nas listas, mas pode ser restaurado em até 60 dias." —, porque é por ele que a jornada desta spec passa. Confirmada a remoção, quem era ativo passa a Acesso suspenso e não entra mais, e todo convite não aceito — pendente ou expirado — é cancelado. O N do aviso conta só os ativos; quando há convite em aberto, o aviso acrescenta "e os convites pendentes serão cancelados". A restauração — que só existe na lista — devolve o acesso de quem o tinha, e o diálogo de restaurar diz isso: "N usuários voltam a ter acesso".
Custo de uso. O que a pessoa digita é só o convite — nome, e-mail e a marca de responsável. O sistema já sabe a imobiliária (é a página em que se está, ou a do próprio perfil), quem está convidando, a data, e de quem é cada usuário; nada disso é pedido. Não há busca nem filtro na seção: uma imobiliária tem uma dúzia de pessoas, não uma centena, e a lista vem inteira, em ordem alfabética.
7.1 Desktop¶
A seção Usuários fica dentro do mesmo painel de detalhe, depois de um divisor, e é uma tabela com estas colunas:
| Coluna | O que mostra |
|---|---|
| Nome | avatar com iniciais e o nome; quem é responsável leva um selo Responsável ao lado |
| o e-mail de acesso | |
| Situação | Ativo, Convite pendente, Convite expirado (72 h passaram sem aceite) ou Acesso suspenso (a imobiliária está removida) |
| Convidado | a data do convite e por quem — "12/08/2026 por Marcus" |
| Último acesso | a data da última entrada; "—" quando não há registro; para convite pendente ou expirado, vazio |
| Ações | um menu de excedente na linha (três pontos), só para quem pode administrar |
Ordem alfabética por nome, sem paginação — a lista inteira cabe na tela. Quem só lê vê a tabela sem a coluna Ações. A partir de uma linha, pelo menu:
- Ativo: Tornar responsável / Deixar de ser responsável, Remover acesso.
- Convite pendente ou expirado: Reenviar convite, Cancelar convite.
- Acesso suspenso (imobiliária removida): só Remover acesso. É a única ação que a imobiliária removida aceita, e existe porque passados os 60 dias ela não volta mais: sem ela o acesso ficaria suspenso para sempre e o e-mail reservado, sem caminho para a pessoa seguir noutra parceira (decisão do usuário, 2026-09-05).
O menu de excedente é padrão novo nas listas do app, e é decisão desta spec: as tabelas de hoje põem um IconButton com Tooltip por ação, o que funciona com uma ou duas ações fixas. Aqui são até três por linha, o conjunto muda com a situação, e Tornar responsável não tem ícone que se explique sozinho — três ícones mudando de linha para linha é o que se quer evitar. Fora desta seção nada muda.
A própria linha de quem está logado nunca tem Remover acesso nem Deixar de ser responsável — é uma regra só, e vale também para a API, que recusa essas duas ações sobre o próprio id como defesa, não como caminho da tela. Como a própria linha de um responsável fica então sem ação nenhuma, o botão de três pontos não é desenhado nela: menu vazio não existe. Tornar responsável em si mesmo também não existe — quem chega ao menu já é responsável. No mobile isso faz o próprio cartão do responsável sair sem rodapé, um pouco mais curto que os vizinhos — aceito: é o cartão dele, e ele sabe por quê. O botão Convidar operador segue no topo da página, e o formulário do convite ganha a caixa "Esta pessoa também administra os usuários da imobiliária" (desmarcada por padrão). Convite enviado, cancelado, reenviado, acesso removido e responsabilidade alterada confirmam com o toast de sucesso que o app prescreve para ação concluída; o aviso em faixa que o convite mostra hoje no topo da página sai, e o prazo de 72 h vai no texto do toast.
Na Equipe, a linha de ajuda entra onde o ponto de entrada disse — entre o cabeçalho e os filtros — e nada mais muda. Na Minha Imobiliária, a página é a de detalhe da imobiliária sem Editar e sem Remover no topo — a imobiliária não edita o próprio cadastro aqui —, e com Convidar operador só para o responsável.
7.2 Mobile (375px)¶
A seção vira uma lista de RecordCard, um por usuário, na mesma ordem:
- título: avatar com iniciais e o nome, e só — o título é uma linha sem quebra, e um selo ali comeria o nome;
- status: o mesmo selo de situação da tabela (Ativo / Convite pendente / Convite expirado / Acesso suspenso);
- meta: três linhas — o e-mail; "Responsável · convidado em 12/08 por Marcus" (sem o prefixo quando não é responsável), em duas alturas (
duasLinhas), porque um nome de duas palavras não cabe numa; e "Último acesso há 3 dias" (ou "—" quando não há registro; para convite pendente ou expirado a linha não aparece). Os formatos são deliberadamente diferentes dos do desktop — data curta sem ano, e "há 3 dias" em vez da data — porque o cartão tem 317 px úteis e a tabela tem a largura que quiser; - ações: o mesmo menu de três pontos, com 44 px de alvo, no rodapé do cartão, que é onde o
RecordCardpõe as ações — só para quem pode administrar. O cartão em si não navega: não há tela de detalhe de usuário.
Sem paginação nem carregar mais — a lista vem inteira por ser curta. Sem MobileFilterBar — não há filtro no desktop tampouco. O botão Convidar operador fica onde o PageHeader já põe a ação primária no mobile (ícone de 44 px no topo). O formulário do convite tem três campos (nome, e-mail e a caixa de responsável) e, em 375px, abre em tela cheia, com Enviar fixo no rodapé — é a lição registrada do app para diálogo com campo de texto: com o teclado aberto sobram ~180px, e um botão que rola junto é um botão que não se acha (specs 0037 e 0038). Hoje esse diálogo é centrado; muda aqui. As confirmações (Remover acesso, Cancelar convite, Deixar de ser responsável) são ConfirmDialog centrados no mobile, como a spec 0049 decidiu para confirmação sem campo — cabem inteiras no centro da tela.
Na Equipe, a linha de ajuda é a mesma do desktop, quebrando em duas linhas quando preciso, entre o cabeçalho e a barra de filtros do mobile.
Na Minha Imobiliária, o item de menu entra na gaveta de navegação do mobile, no grupo Administração, e a página é a mesma do desktop empilhada: identificação, contato e endereço e responsável de contato em coluna, e os cartões de usuários embaixo. Nada some no celular: o que a garantidora faz da mesa o responsável pela imobiliária faz do telefone, com os mesmos diálogos. Nada muda no PWA — não há tela offline nem asset novo; é rota e componente dentro do bundle.
7.3 Estados¶
- Vazio: a seção mostra "Nenhum usuário com acesso a esta imobiliária" e, para quem pode administrar, "Convide o primeiro" apontando para o botão do topo. Uma imobiliária recém-cadastrada começa assim.
- Carregando: o painel de detalhe já abre com os dados da imobiliária; no desktop a seção Usuários mostra um esqueleto de três linhas enquanto a lista chega, para não empurrar o conteúdo de cima; no mobile fica o "Carregando…" que a lista de cartões já mostra.
- Erro ao carregar a lista: um aviso dentro da seção — "Não foi possível carregar os usuários" com Tentar novamente — sem derrubar o resto da página, que já está desenhada.
- Erro numa ação: a mensagem da API dentro do próprio diálogo (remover, cancelar, responsabilidade) ou num toast de erro para o Reenviar, que não tem diálogo — o padrão de feedback prescreve modal para falha de operação, e aqui a spec prefere o toast porque a mensagem é curta e não há decisão a tomar —, e a lista não muda. Os casos com texto próprio: "A imobiliária ficaria sem responsável — indique outro antes" (ao tirar a responsabilidade do único responsável, ou remover o acesso dele); "Este convite já foi aceito" (a pessoa aceitou entre o carregamento e o clique — a lista recarrega e mostra o estado atual).
- Erro no convite: o formulário mostra o erro dentro do próprio diálogo, que fica aberto com o que foi digitado, para corrigir e enviar de novo. O caso comum tem texto próprio: o e-mail já pertence a alguém com acesso — da equipe da garantidora, desta ou de outra imobiliária — e a pessoa lê "Este e-mail já tem acesso ao sistema" e troca o e-mail. Readmitir quem teve o acesso removido é a exceção, e é o caminho feliz: o e-mail de quem foi removido desta mesma imobiliária não dá erro — o convite reaproveita o cadastro, a pessoa recebe o link, define senha nova e volta à lista como Convite pendente. A remoção reserva o e-mail contra terceiros, não contra a própria pessoa.
- Sem permissão: quem só lê vê a lista sem menu de ações e sem Convidar — a tela esconde o que a API negaria, como faz nas outras páginas. Quem digita a URL de
/agencies/:idsem alcançar imobiliárias continua caindo na tela de sem-permissão que já existe. Um usuário de imobiliária não vê Imobiliárias no menu e não abre/agencies/:idde outra imobiliária — nem da própria por essa URL; a dele é Minha Imobiliária. - Imobiliária removida, vista pela garantidora: quem pode restaurar ainda abre a página de detalhe de uma removida. A seção Usuários aparece, só leitura: quem era ativo mostra a situação Acesso suspenso, e o menu da linha oferece só Remover acesso, para quem tem permissão de administrar os usuários; os convites não aceitos — pendentes ou expirados — foram cancelados na remoção e não aparecem. Convidar operador não aparece. Convidar operador não aparece numa removida. Ao restaurar, as linhas voltam a Ativo. Do lado da imobiliária esse estado não existe: ninguém dela entra.
- Conta que perdeu o acesso: quem foi removido, ou cuja imobiliária foi removida, ao tentar entrar lê o aviso de conta inativa que o login já mostra hoje. Quem estava com a sessão aberta é derrubado na próxima requisição, como acontece com a exclusão na Equipe.
- Último acesso sem registro: o sistema passa a registrar a entrada a partir desta spec, e não há como saber quando alguém entrou antes disso. Por isso a coluna mostra "—", nunca nunca entrou: quem já usava o sistema não vira "nunca entrou" no dia do deploy. Em produção não há usuário de imobiliária anterior a isto (o produto não tem clientes); no dev-remote há, e é onde o "—" vai aparecer.
- Convite expirado: a situação muda sozinha na lista quando as 72 h passam, sem varredura; Reenviar emite um link novo e a situação volta a pendente.
8. Revisão da experiência¶
Preenchido pelo laço do /spec new, rubrica .claude/rubrics/experiencia.md. Registrar todas as rodadas, inclusive as reprovadas.
| Rodada | Critical | Important | Minor | Nota | Veredicto |
|---|---|---|---|---|---|
| 1 | 0 | 4 | 4 | 6 | reprovada |
| 2 | 0 | 3 | 4 | 7 | reprovada |
| 3 | 0 | 2 | 5 | 8 | aprovada |
Resultado: aprovada na rodada 3.
Ajuste posterior: o detalhamento (rodada 5) mostrou que a linha Acesso suspenso precisa de uma ação — Remover acesso —, senão a decisão do usuário de 2026-09-05 (remover acesso continua permitido numa imobiliária removida) não teria tela por onde acontecer. As seções 7.1 e 7.3 foram corrigidas depois de aprovadas, e o CA20a cobre o estado.
Pendências aceitas: nenhuma — os dois Important da rodada 3 (erro do convite, com a readmissão de quem foi removido; convite expirado na remoção da imobiliária) e os cinco Minor (página inteira em Minha Imobiliária, formatos do cartão, toast de erro no reenviar, texto do diálogo letra por letra, cartão sem rodapé) foram corrigidos antes da Parte III. As rodadas 1 e 2 reprovaram pela mesma classe de defeito: a correção de uma seção deixando a frase antiga em outra, e conteúdo que não cabe no slot de uma linha do componente.
Parte III — Detalhamento¶
Não preencher antes das revisões das seções 6 e 8 estarem aprovadas.
9. Regras de negócio¶
O vocabulário desta parte. Usuário de imobiliária é quem tem partner_agency_id; responsável é o usuário de imobiliária que porta o perfil de sistema novo AGENCY_MANAGER; usuário comum é o que porta só AGENCY_USER. Administrar usuários de uma imobiliária é o conjunto convidar, reenviar, cancelar, remover acesso e trocar responsável.
A lista¶
- RN1. A lista de usuários de uma imobiliária traz todo
Usercom aquelepartner_agency_idedeleted_at IS NULL, em ordem alfabética por nome (desempate porid), sem paginação. Cada item traz:id,name,email,is_manager(portaAGENCY_MANAGER),situation,invited_at,invited_by_name,last_login_at. - RN1a.
situationé derivada, nunca gravada, nesta ordem:SUSPENDEDquando a imobiliária está removida (deleted_atdela preenchido), seja qual for ois_activedo usuário; senãoACTIVEquandois_active; senãoINVITE_PENDINGquando háVerificationCodedo tipoUSER_INVITEnão usado e não expirado; senãoINVITE_EXPIRED— inclusive quando não há código nenhum, que é o único estado herdado possível (usuário inativo sem convite, que hoje não existe no dev-remote, e que Reenviar conserta). Não existe quarto caso daqui em diante: usuário de imobiliária inativo ou tem convite em aberto, ou a imobiliária está removida — e é a RN12 que garante isso, fechando oPATCH is_activeda Equipe para ele. - RN1b.
invited_ateinvited_by_namevêm doVerificationCodedo convite mais recente daquele usuário (created_ateinvited_by_user_id). Usuário anterior a esta spec sem código de convite (oseed_e2ecria direto) devolve os dois nulos, e a tela mostra "—". - RN1c.
last_login_até campo novo emusers, gravado peloAuthService.logine pelorefresh(o app renova o token sozinho por dias; sem o refresh a coluna diria que quem trabalha todo dia entrou há duas semanas). Nasce nulo, sem backfill, e a tela mostra "—" (Parte II, 7.3). Vale para todo usuário, não só de imobiliária — a coluna é doUser, e a Equipe pode exibi-la em spec futura.
O convite¶
- RN2. O convite de imobiliária (
POST /onboarding/partner-agencies/{id}/invite) passa a aceitaris_manager: bool(padrãofalse). Comtrue, oVerificationCodenasce comrole_name = "AGENCY_MANAGER"; comfalse,"AGENCY_USER", como hoje. O aceite concede o perfil que o código carrega e nada mais muda ali. - RN2a. O porteiro do convite muda de
require_system_role("ADMIN_GLOBAL", "ADMIN_TENANT")para a permissão delegávelpartner_agency_users:manage(seção 10). Isto reverte a RN2b da spec 0041 por decisão do usuário (2026-09-05): a garantidora precisa delegar a administração de acessos de parceiras a perfis personalizados. O risco que a RN2b fechava fica registrado como aceito na seção 16, com a contenção da RN2b-bis. - RN2b-bis. A escalada que a RN2b da 0041 descreve — quem tem a permissão convida um e-mail que controla e passa a operar como parceiro — é contida, não eliminada, por três coisas: o convidado nasce escopado à imobiliária do convite (RN12 da 0034, o vínculo não vem do corpo); o convite grava
invited_by_user_id, e a Auditoria registraAGENCY_USER_INVITEDcom quem convidou (RN9); e o poder ganho é o deAGENCY_USER— ou, comis_manager: true, deAGENCY_MANAGER, que convida outros — daquela imobiliária:credit_analyses:withdrawsobre as análises dela, e nada de outra. Não há como, por este caminho, alcançar outra imobiliária nem a carteira própria. - RN3. Reconvidar quem foi removido reaproveita o cadastro. Se o e-mail do convite pertence a um
Usercomdeleted_atpreenchido que é de imobiliária — tempartner_agency_idou portaAGENCY_USER/AGENCY_MANAGER(as duas metades, porque a RN8d deixou órfãos sem vínculo e com perfil) —, e vale uma das três — (a) opartner_agency_iddele é o da imobiliária do convite; (b) ele não tem vínculo (é um órfão removido pela RN8d — decisão do usuário, 2026-09-05: reconvidável por qualquer imobiliária da mesma garantidora); (c) a imobiliária dele está removida e passou da janela de restauração (não volta mais, e a pessoa pode seguir noutra parceira) — e otenant_iddele é o da imobiliária do convite, o serviço grava opartner_agency_iddo convite, limpadeleted_at, mantémis_active = false, troca opassword_hashpor um aleatório, apaga todoVerificationCodeUSER_INVITEnão usado que ela ainda tenha (senão um link antigo, com orole_nameantigo, voltaria a valer ao limpardeleted_at) e emite umVerificationCodenovo com orole_namedo convite. Os perfis antigos não são revogados aqui, e sim no aceite (RN3c): revogar no reconvite apagaria o próprio sinal que identifica a pessoa como de imobiliária, e um cancelamento seguinte a deixaria irreconhecível — foi o beco que a rodada 4 encontrou. A pessoa volta à lista comoINVITE_PENDING. Isto é a única exceção à RN14 da 0041: o e-mail continua reservado contra terceiros, contra a Equipe, e contra outra imobiliária enquanto a dele existe ou pode voltar — não contra a própria pessoa no lugar certo. - RN3a. A RN11a da 0041 ("aceitar convite não ressuscita quem foi excluído") continua valendo: quem ressuscita é o convite novo, emitido por quem tem a permissão, e não o aceite de um código antigo. O código antigo de um usuário removido continua inválido porque já está
used_at(ele aceitou quando entrou), e oaccept_invitecontinua recusandodeleted_atpreenchido. - RN3b. Qualquer outro e-mail em uso — usuário ativo ou inativo de qualquer lugar, excluído de outra imobiliária, excluído da Equipe — responde
409 EMAIL_TAKEN, com a mensagem "Este e-mail já tem acesso ao sistema". A verificação é a consulta que enxerga excluídos (email_em_uso), como a RN14 da 0041 exige. - RN3c. O aceite substitui os perfis.
accept_invitepassa a revogar todo perfil que o usuário tenha antes de conceder orole_namedo código. Hoje ele só concede, e sem isto um reconvidado como usuário comum manteria oAGENCY_MANAGERda vida anterior. Vale só para quem tem perfil de parceiro — o convite de membro da Equipe não é afetado. - RN4. Reenviar convite (
POST …/users/{user_id}/resend-invite) exige que o usuário esteja emINVITE_PENDINGouINVITE_EXPIRED. Apaga o código anterior não usado (para não haver dois links válidos) e emite um código novo de 72 h com o mesmorole_name, e manda o e-mail de convite de novo. Apagar, e não marcarused_at, é escolha desta spec:used_atfica significando só aceitou, e é esse significado que a RN5 usa para saber se a pessoa já teve história. (O convite de garantidora da 0031 usarevoked_atnum modelo próprio;VerificationCodenão tem esse campo.) Usuário ativo responde409 INVITE_ALREADY_ACCEPTED. - RN5. Cancelar convite (
DELETE …/users/{user_id}sobreINVITE_PENDINGouINVITE_EXPIRED) é remoção lógica, igual à RN6 —deleted_at,is_active = falsee osVerificationCodenão usados apagados. Nunca exclusão física, e essa é a escolha que esta spec faz depois de tentar o contrário: "quem nunca aceitou nunca fez nada" parece verdade e não é — oseed_e2ee oPOST /userscriam usuário ativo sem convite nenhum, e a RN3 devolve à situação de convite pendente quem já trabalhou. Qualquer critério de "nunca teve acesso" erra em algum desses casos, e o preço do erro é umDELETEcontra as FKsRESTRICTdecredit_analyses.created_by,documents.uploaded_by,decisions.analyst_ideguarantees.signed_by— 500 — ou a conta de uma pessoa real apagada. O e-mail não fica "livre" no sentido literal da Parte II: fica reconvidável (RN3), que é o que a tela promete na prática — quem cancelou por engano convida de novo, e o convite passa.
A Auditoria registra AGENCY_INVITE_CANCELLED com o id do usuário em entity_id e o e-mail no payload. - RN5a. Convite cancelado e remoção de acesso são a mesma rota (DELETE …/users/{user_id}), e o serviço decide pela situação: em todas as situações é remoção lógica — a RN5 (convite) e a RN6 (acesso) diferem só no evento de auditoria e na mensagem da tela, e remover acesso de um SUSPENDED é permitido de propósito (RN9b). A tela mostra o nome certo em cada linha (Cancelar convite / Remover acesso), mas o contrato tem uma porta só — a distinção é de estado, não de intenção.
Remover acesso e responsabilidade¶
- RN6. Remover acesso de um usuário
ACTIVEé remoção lógica igual à da Equipe (RN11 da 0041): gravadeleted_ateis_active = false, porque as três portas de sessão conferem sóis_active, e apaga osVerificationCodeUSER_INVITEnão usados do usuário, para nenhum link sobreviver à remoção. Perfis ficam gravados (a RN3 os revoga se a pessoa voltar). A Auditoria registraAGENCY_USER_REMOVEDcom otenant_iddo alvo. - RN6a. Ninguém tira o próprio acesso nem a própria responsabilidade —
403 CANNOT_TARGET_SELF, o mesmo código da RN13 da 0041, nas rotas de remover e de tirar responsável. A tela já não oferece as ações na própria linha (Parte II, 7.1); a API recusa como defesa. - RN6b. A imobiliária não fica sem responsável. Remover o acesso de um responsável, ou tirar-lhe a responsabilidade, é recusado com
403 LAST_AGENCY_MANAGERquando ele é o únicoAGENCY_MANAGERativo e não excluído daquela imobiliária e a imobiliária tem ao menos um outro usuárioACTIVE. Sem outro usuário ativo a recusa não faz sentido — o responsável que sai sozinho deixa uma imobiliária vazia, que a garantidora repovoa pelo convite —, e é por isso que a regra é sobre o efeito, não sobre o alvo (mesmo raciocínio da RN12 da 0041). Uma imobiliária sem responsável é estado alcançável (convidou só usuários comuns) e a garantidora resolve tornando alguém responsável. - RN7. Tornar responsável (
POST …/users/{user_id}/manager) concedeAGENCY_MANAGERe revogaAGENCY_USER; deixar de ser responsável (DELETE …/users/{user_id}/manager) faz o inverso. Os dois exigem usuárioACTIVE(409 USER_NOT_ACTIVEcaso contrário — de convite pendente muda-se cancelando e reconvidando). São idempotentes: tornar responsável quem já é responde200sem evento. A Auditoria registraAGENCY_MANAGER_GRANTED/AGENCY_MANAGER_REVOKED. - RN7a.
AGENCY_MANAGEReAGENCY_USERnão se concedem nem se revogam pelas rotas de perfil (POST/DELETE /roles/{role_id}/users/{user_id},POST /users,POST /onboarding/users), estendendo a RN3 da 0041 ao perfil novo: o403 ROLE_NOT_ASSIGNABLEpassa a cobrir os dois. A guarda entra em dois lugares, porque hoje ela só existe num:role_granting.pyroda noassign_role_to_user, noUserService.createe noinvite_team_user, e orevoke_role_from_user(apps/api/app/services/permission_service.py) não passa por ele — umADMIN_TENANTrevogaAGENCY_USERde um parceiro hoje e o deixa com vínculo e sem perfil. Orevoke_role_from_userpassa a recusar os dois perfis de parceiro com o mesmo403 ROLE_NOT_ASSIGNABLE, inclusive para oADMIN_GLOBAL. Quem os concede é o convite (RN2) e a troca de responsável (RN7), que são os únicos caminhos que sabem de qual imobiliária se trata. O seletor de perfis da Equipe continua escondendo os dois. - RN7b.
AGENCY_MANAGERé perfil de sistema (is_system = true,tenant_idnulo), semeado pelorbac_seed.pycom todas as permissões deAGENCY_USERmaispartner_agency_users:manage. O nome exibido é "Responsável da Imobiliária". Como toda permissão nova, entra pelo seed que o/shiproda em todo deploy (spec 0038).
O órfão e a remoção da imobiliária¶
- RN8. Usuário de imobiliária sem imobiliária deixa de existir. Três frentes, que se cobrem mutuamente:
- RN8a — o escopo fecha em vez de abrir.
within_agencyeagency_scope(apps/api/app/services/scoping.py) passam a distinguir usuário da garantidora (sem vínculo e sem perfil de parceiro) de parceiro sem vínculo (portaAGENCY_USERouAGENCY_MANAGERepartner_agency_idnulo). O segundo não alcança nada:within_agencydevolveFalsepara todo registro, eagency_scopedevolve um sentinela que nenhum registro casa. E não cria: hojeRenterService.createderiva o vínculo decurrent_user.partner_agency_ide, com ele nulo, grava na carteira própria da garantidora;PropertyService.createexige imobiliária (422 NO_AGENCY) mas aceita opartner_agency_iddo corpo, e oresolve_in_scopeo aprova para quem tem escopo nulo — o órfão cadastraria imóvel na imobiliária que nomeasse. Os dois passam a responder403 AGENCY_LINK_REQUIREDpara parceiro sem vínculo, antes de qualquer resolução. Hoje ele alcança a carteira inteira (Parte I). - RN8b — a Equipe filtra por vínculo ou perfil.
so_da_garantidorapassa a excluir também quem portaAGENCY_USERouAGENCY_MANAGER, além de quem tempartner_agency_id. A docstring dolistardiz que o critério é o vínculo e não o papel "porque um parceiro a quem alguém concedeu outro perfil é o caso mais confuso" — os dois critérios juntos cobrem os dois casos: parceiro com perfil extra continua fora pelo vínculo, órfão continua fora pelo perfil. Vale só no ramo da garantidora; oADMIN_GLOBALcontinua vendo todo mundo (RN17 da 0041). - RN8c — a FK deixa de zerar.
users.partner_agency_idpassa deondelete=SET NULLparaondelete=RESTRICT. UmDELETEfísico de imobiliária com usuários — que é o que produziu os sete órfãos, fora da API — passa a falhar no banco, e quem limpa dado de teste apaga os usuários antes. Pelo produto a remoção é lógica e não é afetada. - RN8e — quem está numa imobiliária já removida é suspenso pela migration. A RN9 vale do deploy em diante; imobiliária removida antes dele deixou usuários ativos, porque
count_active_linksnunca contouUser. A migration gravais_active = falseem todo usuáriois_activecuja imobiliária temdeleted_at— semdeleted_atno usuário, que é o que a restauração devolve (RN9) — e aplica a RN5 aos convites não aceitos daquelas imobiliárias, gravandodeleted_atneles. As duas metades, porque a RN9 faz as duas: só a primeira deixaria um convidado que nunca aceitou comis_active = falseedeleted_atnulo, e a restauração o tornaria Ativo sem senha e sem perfil. Sem este passo eles apareceriamSUSPENDEDna lista e continuariam entrando, porque as portas de sessão conferemis_active. Hoje não há nenhuma imobiliária removida no dev-remote, e produção não tem cliente: o passo é para não deixar o estado alcançável em aberto. - RN8d — os órfãos de hoje são removidos. A migration desta spec, na parte de dados, aplica a RN6 (
deleted_ateis_active = false) a todoUserque portaAGENCY_USERe tempartner_agency_idnulo. É irreversível de propósito e odowngradenão os ressuscita — a decisão do usuário foi remover e reconvidar (Parte I, seção 5). - RN9. Remover a imobiliária suspende os usuários dela; restaurar os devolve. No
PartnerAgencyService.delete, depois de gravardeleted_at: todo usuárioACTIVEda imobiliária recebeis_active = false(semdeleted_at— é suspensão, não remoção), e todo usuário emINVITE_PENDING/INVITE_EXPIREDé cancelado pela RN5 (remoção lógica). Norestore: todo usuário da imobiliária comdeleted_at IS NULLvolta ais_active = true. O conjunto é exato porque, depois da remoção, os únicos usuários não excluídos da imobiliária são os que estavam ativos — os pendentes foram excluídos —, e porque a RN12 fecha o único outro caminho que desativava um usuário de imobiliária sem excluí-lo. A Auditoria registraAGENCY_USERS_SUSPENDED/AGENCY_USERS_RESTOREDna entidadepartner_agency, com a contagem nopayload, e umAGENCY_INVITE_CANCELLEDpor convite cancelado (RN5) — o agregado diz quantos perderam o acesso; o individual diz de quem era cada convite cancelado. - RN9a. O
DELETE /partner-agencies/{id}e oPOST …/restorepassam a devolver, na resposta de leitura da imobiliária (PartnerAgencyResponse), o campo novoactive_user_count— contagem de usuáriosACTIVEouSUSPENDED—, para os diálogos da Parte II dizerem "N usuários perderão o acesso" / "N usuários voltam a ter acesso", epending_invite_count, para o "e os convites pendentes serão cancelados". Os dois campos são opcionais noPartnerAgencyResponse(a lista e ogetcompartilham o schema): a lista os devolve nulos (custo de contagem por linha), e o diálogo de remover/restaurar da lista chamaGET /partner-agencies/{id}ao abrir para ter as contagens. - RN9b. Enquanto a imobiliária está removida, convidar, reenviar e trocar responsável respondem
409 AGENCY_REMOVED(ConflictError, como todos os 409 desta spec — nãoBusinessRuleError, que é 422 e é a classe dos erros vizinhos dopartner_agency_service.py). Remover acesso continua funcionando sobre umSUSPENDED(RN5a → RN6) — decisão do usuário, 2026-09-05: sem isso, passada a janela de 60 dias da 0049 a imobiliária não volta, os suspensos ficariam assim para sempre e o e-mail deles reservado sem rota que o liberasse; removidos, a RN3(c) os deixa reconvidar noutra parceira. A resolução da imobiliária nas rotas de escrita não é oresolve_in_scope, que responde 404 a tudo que temdeleted_at: éPartnerAgencyService._get_authorized(agency_id, current_user, include_deleted=True), que já existe, já resolve por tenant e por imobiliária (within_agency) e já responde 404 fora do escopo — fora do escopo (outra garantidora, outra imobiliária, parceiro sem vínculo) → 404; no escopo e removida → 409 nas três rotas acima, segue na remoção; no escopo e viva → segue. O 409 é para quem tempartner_agency_users:manage, com ou sempartner_agencies:restore: quem administra os usuários de uma imobiliária sabe que ela existe, e "removida" é a resposta útil. Na lista (RN1) vale a RN13 da 0049: a removida só é alcançável por quem tempartner_agencies:restore(include_deletedcondicionado à permissão, como ogetde hoje), comsituation = SUSPENDED; sem a permissão, 404.
Auditoria e e-mail¶
- RN10. Todo evento desta spec vai à Auditoria pelo
AuditRepository.logcom otenant_iddo alvo:AGENCY_USER_INVITED(novo — hoje o convite não registra nada),AGENCY_INVITE_RESENT,AGENCY_INVITE_CANCELLED,AGENCY_USER_REMOVED,AGENCY_MANAGER_GRANTED,AGENCY_MANAGER_REVOKED,AGENCY_USERS_SUSPENDED,AGENCY_USERS_RESTORED. Oentity_typeéuserpara os seis primeiros epartner_agencypara os dois últimos. - RN11. O e-mail de convite (
email_service.send_user_invite) não muda de template. O reenvio manda o mesmo e-mail com o link novo.
As rotas da Equipe¶
- RN12. Usuário de imobiliária só se administra pela imobiliária. No ramo da garantidora (quem chama não é
ADMIN_GLOBAL),GET/PATCH/DELETE /users/{id}ePOST/DELETE /roles/{role_id}/users/{user_id}respondem 404 quando o alvo é usuário de imobiliária — tempartner_agency_idou porta perfil de parceiro —, exatamente como respondem a um id de outra garantidora, e pelo mesmo motivo: o erro não confirma que o id existe. Hoje_get_authorized(apps/api/app/services/user_service.py) filtra só por tenant, e a RN5 da 0041 deixou oGET /users/{id}aberto de propósito; esta spec fecha os quatro no ramo da garantidora, porque agora existe o lugar certo para essa administração (decisão do usuário, 2026-09-05). Vale também para perfil personalizado comusers:*—partner_agency_users:manageé a permissão que abre o caminho certo, não uma exceção neste. - RN12a. No ramo do
ADMIN_GLOBAL, as rotas continuam alcançando (a lista da plataforma mostra usuários de imobiliária, RN17 da 0041), com as regras desta spec no lugar das da Equipe:DELETE /users/{id}sobre usuário de imobiliária com vínculo delega à mesma porta de serviço doDELETE /partner-agencies/{id}/users/{uid}(oagency_idé opartner_agency_iddo alvo) e por isso decide como a RN5a — remoção lógica em qualquer situação, comAGENCY_INVITE_CANCELLEDsobreINVITE_*eAGENCY_USER_REMOVEDsobreACTIVE/SUSPENDED, com RN6a e RN6b. Sobre um parceiro sem vínculo (o órfão, que só a lista da plataforma mostra) não há imobiliária a resolver: segue o caminho da Equipe de hoje —soft_deletecomUSER_DELETED—, esituationnão se aplica a ele, porque ele não está em lista de imobiliária nenhuma.PATCH /users/{id}comis_activesobre usuário de imobiliária é recusado com403 AGENCY_USER_VIA_AGENCY("Usuário de imobiliária se administra pela imobiliária") — suspensão só existe pela remoção da imobiliária (RN9), e é isso que mantém a RN1a sem quarto caso;PATCHsó denamesegue; conceder e revogar perfil de parceiro é a RN7a; e conceder qualquer outro perfil a um usuário de imobiliária porPOST /roles/{rid}/users/{uid}é recusado com403 ROLE_NOT_ASSIGNABLE— parceiro não recebe perfil da garantidora, por nenhum caminho. Hoje_ensure_same_tenantdispensa oADMIN_GLOBALe deixaria passar.
10. Autorização e isolamento¶
10.1 Matriz de papéis¶
| Papel | Pode | Não pode |
|---|---|---|
ADMIN_GLOBAL | listar usuários de qualquer imobiliária de qualquer garantidora; convidar, reenviar, cancelar, remover, trocar responsável em qualquer uma (hoje o convite dele falha com 422 TENANT_REQUIRED — passa a funcionar, 10.5); excluir usuário de imobiliária pela lista da plataforma com as regras desta spec (RN12a) | conceder ou revogar AGENCY_USER/AGENCY_MANAGER pelas rotas de perfil (RN7a — nem ele); desativar usuário de imobiliária por PATCH (403, RN12a); tirar o próprio acesso |
ADMIN_TENANT | tudo isso nas imobiliárias da própria garantidora; delegar partner_agency_users:manage a perfil personalizado (a permissão está contida nele) | alcançar imobiliária de outra garantidora (404); alcançar usuário de imobiliária pelas rotas da Equipe (404, RN12); conceder ou revogar os dois perfis de parceiro por rota de perfil |
ANALYST, OPERATOR, VIEWER | listar usuários de uma imobiliária da própria garantidora (têm partner_agencies:read) | qualquer escrita (403 — não têm partner_agency_users:manage); e, mesmo com users:* por perfil personalizado, alcançar usuário de imobiliária pelas rotas da Equipe (404, RN12) |
perfil personalizado com partner_agency_users:manage | o mesmo que ADMIN_TENANT nas imobiliárias da própria garantidora | o mesmo que ADMIN_TENANT não pode |
AGENCY_MANAGER | listar, convidar, reenviar, cancelar, remover e trocar responsável na própria imobiliária; abrir Minha Imobiliária | qualquer coisa em outra imobiliária (404, inclusive a lista); tirar o próprio acesso ou a própria responsabilidade (403); sair como último responsável com outro ativo (403); abrir /agencies (sem partner_agencies:list) |
AGENCY_USER | listar os usuários da própria imobiliária; abrir Minha Imobiliária | qualquer escrita (403); outra imobiliária (404) |
| parceiro sem vínculo (órfão, RN8a) | nada | tudo — 404 em qualquer imobiliária, lista vazia em toda entidade escopada, 403 ao criar inquilino ou imóvel |
10.2 Permissões¶
Uma permissão nova: partner_agency_users:manage — "Administrar usuários de imobiliária parceira". Entra no rbac_seed.py em ADMIN_GLOBAL, ADMIN_TENANT e no perfil novo AGENCY_MANAGER. Não entra em ANALYST, OPERATOR, VIEWER nem AGENCY_USER. É delegável por desenho (decisão do usuário, RN2a).
Um perfil de sistema novo: AGENCY_MANAGER (RN7b).
| Endpoint | Permissão | Escopo adicional |
|---|---|---|
GET /partner-agencies/{id}/users | partner_agencies:read | a imobiliária tem de estar no escopo de quem chama (tenant + imobiliária) |
POST /onboarding/partner-agencies/{id}/invite | partner_agency_users:manage | idem; imobiliária removida → 409 |
POST /partner-agencies/{id}/users/{uid}/resend-invite | partner_agency_users:manage | idem |
DELETE /partner-agencies/{id}/users/{uid} | partner_agency_users:manage | idem; uid tem de pertencer a {id} (404 senão); não a si mesmo (403); funciona numa removida (RN9b) |
POST /partner-agencies/{id}/users/{uid}/manager | partner_agency_users:manage | idem |
DELETE /partner-agencies/{id}/users/{uid}/manager | partner_agency_users:manage | idem; não a si mesmo (403); não o último com outro ativo (403) |
DELETE /partner-agencies/{id} e POST …/restore | as de hoje (partner_agencies:delete / restore) | ganham o efeito da RN9; sem porteiro novo |
GET/PATCH/DELETE /users/{id}, POST/DELETE /roles/{rid}/users/{uid} | as de hoje | usuário de imobiliária → 404 no ramo da garantidora (RN12); no do ADMIN_GLOBAL, RN12a e RN7a |
O porteiro é require_permission("partner_agency_users", "manage") em todas as rotas de escrita — e o serviço confere o escopo da imobiliária em cada uma (10.3, 10.4). Permissão sem escopo é o que a 0034 corrigiu; não se repete.
10.3 Isolamento por garantidora (tenant)¶
- Toda rota recebe
agency_idno path. O serviço resolve a imobiliária comPartnerAgencyService._get_authorized(agency_id, current_user, include_deleted=…)(RN9b) — que aplicatenant_scopeewithin_agencydeapps/api/app/services/scoping.py— antes de qualquer leitura de usuário: imobiliária de outra garantidora → 404, e a resposta não distingue "não existe" de "não é sua". user_idno path é resolvido dentro da imobiliária já resolvida:Usercom aqueleidepartner_agency_id == agency.idetenant_id == agency.tenant_id. Qualquer falha → 404. Isto fecha o caminho "agency_idmeu,user_idde outro tenant".- Nenhuma FK vem do corpo. O convite recebe
name,email,is_manager; o vínculo é oagency_iddo path (RN12 da 0034, já é assim). - O
tenant_iddo convidado é o da imobiliária resolvida, não o de quem chama — hoje o serviço usa_get_tenant(current_user), que lança422 TENANT_REQUIREDpara oADMIN_GLOBAL. A troca é parte desta spec (10.5).
10.4 Isolamento por imobiliária¶
- O
AGENCY_MANAGERe oAGENCY_USERsó alcançam a própria imobiliária: o_get_authorizedjá aplicawithin_agency(RN8 da 0034), e com a RN8a o parceiro sem vínculo não alcança nenhuma. Imobiliária alheia → 404 em todas as rotas, inclusive a lista. - O vínculo do convidado é derivado do path e nunca aceito do corpo. O
AGENCY_MANAGERsó consegue convidar para a própria imobiliária porque qualquer outroagency_iddá 404 antes de o corpo ser lido. - Minha Imobiliária no web usa o
partner_agency_idque o/auth/mejá devolve (UserResponse.partner_agency_id) e chamaGET /partner-agencies/{id}eGET /partner-agencies/{id}/userscom ele. Não há endpoint "minha": é o mesmoGETcom o escopo fazendo o trabalho. - Lista de usuários expõe e-mail e nome de pessoas — o mesmo que a página da imobiliária já expõe do
contact_email. Uma exceção: quando quem convidou éADMIN_GLOBAL,invited_by_namevem"a plataforma"em vez do nome — o parceiro não precisa saber quem do time do SaaS operou dentro da garantidora dele. Não há documento, análise nem contagem de negócio na resposta.
10.5 Plataforma × cliente (SaaS admin)¶
ADMIN_GLOBALnas rotas desta spec:tenant_scopeé nulo, então ele alcança qualquer imobiliária peloagency_id. Isto é aceitável: usuário e convite são dado de acesso, que o SaaS admin administra, e não dado de negócio (inquilino, análise, garantia). Hoje o convite dele não funciona:invite_agency_userchama_get_tenant(current_user), que lança422 TENANT_REQUIREDcomtenant_idnulo. O que muda: otenant_iddo convidado passa a ser o da imobiliária, resolvida pelo_get_authorized(10.3), e oADMIN_GLOBALpassa a conseguir convidar.- Nada novo no
/app/admin/*. O SaaS admin não ganha tela; a lista da plataforma já mostra os usuários de imobiliária (RN17 da 0041) e passa a mostrarlast_login_atse aUserResponseo expuser — e expõe (RN1c), sem coluna nova na tela dele nesta spec. O que ele faz a partir dela sobre um usuário de imobiliária segue a RN12a. - Nenhuma tela da garantidora aceita
tenant_id. As rotas desta spec não têm o campo.
11. Impacto por app¶
apps/api¶
- Modelos/migrations:
users.last_login_at(DateTime(timezone=True), nulo) — RN1c.users.partner_agency_id: FK deSET NULLparaRESTRICT— RN8c.- Passos de dados: suspensão de quem está em imobiliária já removida (RN8e) e remoção lógica dos órfãos (RN8d).
- Sem tabela nova.
AGENCY_MANAGERepartner_agency_users:managesão seed, não schema (apps/api/app/db/rbac_seed.py). - Endpoints (todos com
summary,description,response_description; router novoapps/api/app/api/v1/routers/partner_agency_users.pycomCommitRoute, mais as alterações noonboarding.pye nopartner_agencies.py) — o contrato está na seção 12. - Regras no service:
apps/api/app/services/partner_agency_user_service.py(novo): RN1, RN1a, RN1b, RN4, RN5, RN5a, RN6, RN6a, RN6b, RN7, RN9b, RN10. Resolveagency_idcom o_get_authorizeddoPartnerAgencyServiceeuser_iddentro da imobiliária (10.3).apps/api/app/services/onboarding_service.py—invite_agency_user: RN2, RN3, RN3b;tenant_iddo convidado vem da imobiliária (10.3/10.5); registraAGENCY_USER_INVITED.accept_invite: sem mudança de regra (RN3a).apps/api/app/services/role_granting.py: RN7a — o bloco que recusaAGENCY_USERpassa a recusar tambémAGENCY_MANAGER; eapps/api/app/services/permission_service.py—revoke_role_from_userpassa a chamar a mesma guarda (RN7a).apps/api/app/services/user_service.py—_get_authorized: RN12 (404 no ramo da garantidora para usuário de imobiliária);updateesoft_delete: RN12a no ramo doADMIN_GLOBAL(osoft_deletedelega aoPartnerAgencyUserService, oupdaterecusais_active).apps/api/app/services/permission_service.py—_ensure_same_tenant: RN12 nas rotas de perfil (hoje ela deixa passar usuário de imobiliária do mesmo tenant) e RN12a (perfil da garantidora a usuário de imobiliária, mesmo peloADMIN_GLOBAL).apps/api/app/api/v1/routers/users.py: só adescriptiondas rotas.apps/api/app/services/onboarding_service.py—accept_invite: revoga os perfis antigos antes de conceder o do código (RN3c).apps/api/app/services/scoping.py: RN8a —within_agencyeagency_scope.apps/api/app/repositories/user_repo.py—listar: RN8b; e umlistar_da_imobiliariapara a RN1, com oVerificationCodemais recente e o nome de quem convidou numselectinload/subconsulta, para não fazer N+1.apps/api/app/services/partner_agency_service.py—deleteerestore: RN9;PartnerAgencyResponseganhaactive_user_countepending_invite_countnoget(RN9a);_get_authorized(..., include_deleted=True)é a resolução das rotas de escrita (RN9b).apps/api/app/services/renter_service.pyeapps/api/app/services/property_service.py—create: RN8a (403 para parceiro sem vínculo).apps/api/app/services/auth_service.py—loginerefresh: RN1c.apps/api/app/services/user_service.pyeapps/api/app/schemas/user.py:UserResponse.last_login_at.apps/api/app/core/dependencies.py: sem mudança —require_permissionjá serve.- Testes (
apps/api/tests/test_partner_agency_users.py, novo; mais casos emtest_onboarding.pye nos testes de escopo existentes): - caminho feliz de cada rota e de cada
situationderivada (RN1a — inclusiveINVITE_EXPIREDpor relógio, eSUSPENDEDpor imobiliária removida); - negativos de acesso, um por papel afetado:
VIEWERrecebe 403 ao convidar;AGENCY_USERrecebe 403 ao remover colega;AGENCY_MANAGERrecebe 404 ao listar, convidar e remover em outra imobiliária;ADMIN_TENANTrecebe 404 em imobiliária de outra garantidora; perfil personalizado compartner_agency_users:manageconvida com sucesso e um sem ela recebe 403;ADMIN_GLOBALconvida em qualquer garantidora e o convidado nasce com otenant_idda imobiliária; - RN6a (
CANNOT_TARGET_SELFnas duas rotas), RN6b (as duas metades: recusa com outro ativo, aceita sem outro ativo), RN7a (ROLE_NOT_ASSIGNABLEparaAGENCY_MANAGERnas três rotas de perfil e na revogação, inclusive paraADMIN_GLOBAL); - RN12 (
ADMIN_TENANTe perfil personalizado comusers:*recebem 404 nas quatro rotas da Equipe sobre usuário de imobiliária), RN12a (ADMIN_GLOBAL:DELETEexecuta a RN6 comAGENCY_USER_REMOVED;PATCH is_activerecebe 403;PATCH namepassa); - RN3 (reconvidar removido da mesma imobiliária →
INVITE_PENDING, perfis revogados, senha trocada), RN3b (removido de outra imobiliária → 409), RN3a (código antigo de removido continua inválido); - RN5 (cancelar é remoção lógica; o usuário fica com
deleted_at, os códigos não usados somem e nenhuma FK é tocada), RN5a (a mesma rota remove um ativo e um suspenso), RN4 (o código anterior é apagado, não marcado), RN3c (o aceite revoga o perfil antigo); - RN8a (parceiro sem vínculo: lista de análises vazia, 404 em imobiliária), RN8b (órfão fora da Equipe;
ADMIN_GLOBALcontinua vendo), RN8c (oDELETEfísico falha com usuários — teste de integração no PostgreSQL, marcado para pular no SQLite), RN8d (migration de dados sobre fixture com órfão), RN8e (migration sobre fixture com imobiliária removida e usuário ativo); - RN9 (remover suspende ativos e cancela os convites não aceitos, que ficam com
deleted_at; restaurar reativa só os suspensos; RN9b: 409 ao convidar numa removida e204ao remover acesso nela), RN9a (contagens); - RN1c (
loginerefreshgravamlast_login_at); - RN10 (um evento por ação, com o
tenant_iddo alvo).
O make preflight confere Swagger, CommitRoute e downgrade no que a branch acrescentar.
apps/web¶
- Páginas/componentes:
apps/web/src/pages/agencies/show.tsx: seção Usuários (tabela desktop / cartões mobile), menu de excedente por linha (padrão novo, Parte II 7.1), diálogos de confirmar (ConfirmDialogcentrado no mobile), formulário de convite com a caixa de responsável e tela cheia em 375px, toast de sucesso no lugar da faixa, texto do diálogo de remoção igual ao da lista mais o aviso de usuários (RN9a), e o estado da imobiliária removida — só leitura, exceto Remover acesso, que continua na linha suspensa (RN9b). Extraído num componenteAgencyUsersSection(apps/web/src/pages/agencies/users-section.tsx) para ser usado pelas duas páginas.apps/web/src/pages/my-agency/index.tsx(novo): Minha Imobiliária — a página de detalhe inteira, só leitura, sem Editar/Remover, com a seção de usuários; Convidar operador só para quem tempartner_agency_users:manage.apps/web/src/pages/agencies/index.tsx: o diálogo de remover e o de restaurar ganham as contagens (RN9a).apps/web/src/pages/agencies/api.ts:listUsers,resendInvite,removeUser,grantManager,revokeManager;InviteUserRequest.is_manager;Agencyganha as duas contagens.apps/web/src/pages/users/index.tsx: a linha de ajuda entre o cabeçalho e os filtros, para quem tempartner_agencies:list.apps/web/src/pages/users/form.tsx: o seletor de perfis escondeAGENCY_MANAGERcomo já escondeAGENCY_USER(RN7a).data-testid:agency-users-section,agency-users-table,agency-users-mobile-list,agency-user-row-{id},agency-user-card-{id},agency-user-menu-{id},agency-user-action-resend,agency-user-action-cancel,agency-user-action-remove,agency-user-action-grant-manager,agency-user-action-revoke-manager,agency-user-remove-dialog,agency-user-cancel-dialog,agency-user-manager-dialog,agency-invite-manager-checkbox,agency-users-empty,agency-users-error,agency-users-retry,agency-delete-users-warning,agency-restore-users-notice,users-agency-hint,users-agency-hint-link,my-agency-page.- Sider / menu: item Minha Imobiliária em
apps/web/src/components/navigation/nav-groups.tsx(grupo Administração,requerPermissao: "partner_agencies:read"e só para quem tempartner_agency_id— o item precisa de uma condição a mais que orequerPermissao, porqueANALYSTtambém tempartner_agencies:reade não tem imobiliária). Rota/imobiliariaemapps/web/src/App.tsxcomExigeAcesso permissao="partner_agencies:read", e a página redireciona para a inicial se o/menão tiverpartner_agency_id. Ocomponents/custom-sider/index.tsxconsome osnav-groups; conferir que o item aparece também na gaveta do mobile (components/mobile-shell/index.tsx). - Menu e rota por permissão:
/agencies/:idcontinua sobpartner_agencies:list— parceiro que digita a URL cai na tela de sem-permissão./imobiliariasobpartner_agencies:read; usuário da garantidora que a digita é redirecionado à inicial (não tem imobiliária). Ações na seção aparecem só compartner_agency_users:manage. - Responsividade & PWA: Parte II, 7.1 e 7.2. Sem impacto no PWA.
- Testes unitários (
*.test.tsxao lado,make test-web):users-section.test.tsx— situações e ações por linha (inclusive a própria linha sem menu e a removida só-leitura), os três diálogos, erro do convite comEMAIL_TAKEN;my-agency/index.test.tsx— redireciona sempartner_agency_id, esconde Convidar sem a permissão;users/index.test.tsx— a linha de ajuda por permissão.
apps/e2e¶
- Specs:
usuarios-da-imobiliaria.spec.ts(novo) — a garantidora convida, vê Convite pendente, reenvia, cancela, remove um ativo, torna e tira responsável, e vê o aviso ao remover a imobiliária; o responsável abre Minha Imobiliária, convida colega, e não vê outra imobiliária; o usuário comum vê a lista sem ações; a Equipe não mostra usuário de imobiliária e mostra a linha de ajuda; nos dois viewports, comscrollWidth == clientWidth. Ajustes emagencies.spec.ts(texto do diálogo de remoção do detalhe) eisolamento-imobiliaria.spec.ts(o órfão não alcança a carteira). - Seeds:
apps/api/app/db/seed_e2e.pyganha umAGENCY_MANAGERna Imobiliária E2E (imob-gestor@e2e.test) e umAGENCY_USERsem vínculo (imob-orfao@e2e.test) para a RN8a — este último criado direto, porque pelo produto o estado não é mais alcançável.
12. Contrato de API¶
Permissãopartner_agencies:read. Sem paginação. {
"items": [
{
"id": "uuid", "name": "Sandra Yamamoto", "email": "sandra@vitoria.com.br",
"is_manager": true, "situation": "ACTIVE",
"invited_at": "2026-08-12T12:00:00Z", "invited_by_name": "Marcus Floriano",
"last_login_at": "2026-09-02T09:15:00Z"
}
],
"total": 1
}
situation ∈ {ACTIVE, SUSPENDED, INVITE_PENDING, INVITE_EXPIRED}. Erros: 404 imobiliária fora do escopo (outra garantidora, outra imobiliária, ou parceiro sem vínculo). Classes de erro, para os testes do web não divergirem: todo 409 desta spec é ConflictError (EMAIL_TAKEN, AGENCY_REMOVED, INVITE_ALREADY_ACCEPTED, USER_NOT_ACTIVE); todo 403 é ForbiddenError (CANNOT_TARGET_SELF, LAST_AGENCY_MANAGER, ROLE_NOT_ASSIGNABLE, AGENCY_USER_VIA_AGENCY); 404 é NotFoundError. Nenhum usa BusinessRuleError, que é 422.
POST /api/v1/onboarding/partner-agencies/{agency_id}/invite
{ "name": "Roberto Lima", "email": "roberto@imobiliaria.com.br", "is_manager": false }
partner_agency_users:manage (era papel de sistema). 201 com { "user_id", "message" } como hoje. Erros: 404 imobiliária fora do escopo; 409 EMAIL_TAKEN ("Este e-mail já tem acesso ao sistema") salvo a exceção da RN3; 409 AGENCY_REMOVED. Permissão partner_agency_users:manage. 200 com o item da lista atualizado. Erros: 404; 409 INVITE_ALREADY_ACCEPTED (usuário ativo); 409 AGENCY_REMOVED. Permissão partner_agency_users:manage. 204. Remoção lógica em qualquer situação — cancela o convite se INVITE_*, remove o acesso se ACTIVE ou SUSPENDED (esta rota funciona numa imobiliária removida, RN9b). Erros: 404; 403 CANNOT_TARGET_SELF; 403 LAST_AGENCY_MANAGER. POST /api/v1/partner-agencies/{agency_id}/users/{user_id}/manager
DELETE /api/v1/partner-agencies/{agency_id}/users/{user_id}/manager
partner_agency_users:manage. 200 com o item da lista. Idempotentes. Erros: 404; 409 USER_NOT_ACTIVE; no DELETE, 403 CANNOT_TARGET_SELF e 403 LAST_AGENCY_MANAGER; 409 AGENCY_REMOVED. GET /api/v1/partner-agencies/{agency_id} (resposta de hoje + dois campos opcionais)
{ ..., "active_user_count": 3, "pending_invite_count": 1 }
GET /partner-agencies) os dois vêm null. Sem mudança de forma. Sobre usuário de imobiliária: 404 no ramo da garantidora (RN12); no ramo do ADMIN_GLOBAL, PATCH com is_active → 403 AGENCY_USER_VIA_AGENCY, DELETE → remoção lógica pela porta da imobiliária quando há vínculo e pelo soft_delete da Equipe quando não há, qualquer perfil → 403 ROLE_NOT_ASSIGNABLE (RN12a, RN7a). Migration aa1b2c3d4e5f_usuarios_da_imobiliaria (nome ilustrativo; o id é o que o Alembic gerar):
upgrade:ALTER TABLE users ADD COLUMN last_login_at TIMESTAMPTZ NULL; troca da FKusers.partner_agency_idparaON DELETE RESTRICT;UPDATE users SET deleted_at = now(), is_active = false WHERE partner_agency_id IS NULL AND id IN (usuários com role AGENCY_USER) AND deleted_at IS NULL(RN8d);UPDATE users SET is_active = false WHERE partner_agency_id IN (SELECT id FROM partner_agencies WHERE deleted_at IS NOT NULL) AND is_active, maisUPDATE users SET deleted_at = now(), is_active = false WHERE partner_agency_id IN (…removidas) AND NOT is_active AND deleted_at IS NULL(RN8e).downgrade: remove a coluna; volta a FK paraON DELETE SET NULL. Não desfaz a RN8d nem a RN8e — está escrito na regra e nodescriptionda migration; omake preflightexigedowngradereal, e este é real no schema.
Permissão e perfil novos entram pelo seed, que o /ship roda sempre; a migration não os cria.
13. Critérios de aceite¶
A lista e as situações
- [x] CA1. Dado uma imobiliária com um usuário ativo, um convidado há 1 h e um convidado há 80 h, quando o
ADMIN_TENANTchamaGET …/users, então recebe três itens em ordem alfabética comsituationACTIVE,INVITE_PENDINGeINVITE_EXPIRED,invited_ateinvited_by_namepreenchidos nos dois convidados (RN1, RN1a, RN1b). - [x] CA2. Dado um usuário que entrou pelo login e depois renovou o token, então
last_login_atreflete o refresh; um usuário anterior à spec devolvenulle a tela mostra "—" (RN1c). - [x] CA3. Dado uma imobiliária removida, quando quem tem
partner_agencies:restorelista os usuários, então todos vêmSUSPENDED; quem tempartner_agency_users:managesemrestorerecebe404na lista,409 AGENCY_REMOVEDao convidar, reenviar e trocar responsável, e204ao remover o acesso de um suspenso, que ganhadeleted_at; e umAGENCY_MANAGERde outra imobiliária recebe404em todas (RN9b, RN5a).
O convite
- [x] CA4. Dado um perfil personalizado com
partner_agency_users:manage, quando convida comis_manager: true, então oVerificationCodenasce comrole_name = AGENCY_MANAGER, a Auditoria temAGENCY_USER_INVITEDcom ouser_idde quem convidou, e o aceite concedeAGENCY_MANAGER(RN2, RN2a, RN10). - [x] CA5. Dado o mesmo perfil personalizado sem a permissão, então o convite responde
403; dadoVIEWER,403; dadoAGENCY_USER,403(negativos por papel). - [x] CA6. Dado um usuário removido da Imobiliária A, quando A o reconvida, então ele volta
INVITE_PENDING, sem perfis, com senha nova, e o código antigo dele continua inválido; quando a Imobiliária B convida o mesmo e-mail, então409 EMAIL_TAKEN; dado um órfão removido pela migration (sem vínculo), quando a Imobiliária B o convida, então ele voltaINVITE_PENDINGcom o vínculo de B; dado um removido de uma imobiliária cuja janela de restauração passou, quando B o convida, então o mesmo; e dado um reconvidado como responsável, cancelado e reconvidado como comum antes de 72 h, quando abre o link do primeiro convite, então o aceite é recusado com o422 INVALID_TOKENque oaccept_invitejá responde a código inexistente — só o segundo vale —, e ao aceitar o segundo ele portaAGENCY_USERe nãoAGENCY_MANAGER(RN3, RN3a, RN3b, RN3c, RN6). - [x] CA7. Dado um convite pendente, quando reenviado, então o código anterior não existe mais e há um novo de 72 h com o mesmo
role_name, e o e-mail foi enviado; dado um usuário ativo, então409 INVITE_ALREADY_ACCEPTED(RN4). - [x] CA8. Dado um convite pendente, quando cancelado, então o usuário fica com
deleted_ateis_active = false, os códigos não usados dele somem, a Auditoria temAGENCY_INVITE_CANCELLEDcom o id ementity_ide o e-mail nopayload, e um convite novo para o mesmo e-mail na mesma imobiliária passa (RN5, RN3); dado um reconvidado com uma análise criada por ele, quando cancelado, então oDELETEresponde204e a análise continua existindo (RN5).
Remover e responsável
- [x] CA9. Dado um usuário ativo, quando removido pela mesma rota do CA8, então
deleted_ateis_active = falseestão gravados, ele recebe 401 na próxima requisição, e a Auditoria temAGENCY_USER_REMOVEDcom otenant_iddele (RN5a, RN6, RN10). - [x] CA10. Dado um
AGENCY_MANAGER, quando tenta remover o próprio acesso ou tirar a própria responsabilidade, então403 CANNOT_TARGET_SELF— e a tela não mostra o menu na linha dele (RN6a). - [x] CA11. Dado uma imobiliária com um responsável e um usuário comum ativos, quando o
ADMIN_TENANTtira a responsabilidade ou remove o responsável, então403 LAST_AGENCY_MANAGER; dado o responsável sozinho, então a mesma ação é aceita (RN6b, as duas metades). - [x] CA12. Dado um usuário comum ativo, quando tornado responsável, então porta
AGENCY_MANAGERe nãoAGENCY_USER, tem a permissãopartner_agency_users:manageno/me, e a segunda chamada responde200sem evento novo; dado um convite pendente, então409 USER_NOT_ACTIVE(RN7). - [x] CA13. Dado
ADMIN_GLOBAL, quando tenta concederAGENCY_MANAGERporPOST /roles/{id}/users/{id}ou revogarAGENCY_USERporDELETE /roles/{id}/users/{id}, então403 ROLE_NOT_ASSIGNABLEnas duas; o seletor da Equipe não o lista (RN7a). - [x] CA13a. Dado
ADMIN_TENANT(e um perfil personalizado comusers:update,users:delete), quando chamaGET,PATCH,DELETE /users/{id}ouPOST/DELETE /roles/{rid}/users/{id}sobre um usuário de imobiliária da própria garantidora, então404nas cinco (RN12). DadoADMIN_GLOBAL, quandoDELETE /users/{id}sobre o mesmo usuário ativo, então ele recebedeleted_at,is_active = falseeAGENCY_USER_REMOVED; sobre um convidado pendente, então remoção lógica comAGENCY_INVITE_CANCELLED; sobre umSUSPENDED, então remoção lógica comAGENCY_USER_REMOVED; sobre um parceiro sem vínculo, então remoção lógica comUSER_DELETED; quandoPATCH {is_active: false}, então403 AGENCY_USER_VIA_AGENCY; quandoPATCH {name}, então200; quandoPOST /roles/{id-de-ANALYST}/users/{id}sobre usuário de imobiliária, então403 ROLE_NOT_ASSIGNABLE(RN12a).
Escopo
- [x] CA14. Dado um
AGENCY_MANAGERda Imobiliária A, quando lista, convida ou remove na Imobiliária B da mesma garantidora, então404nas três (10.4). - [x] CA15. Dado um
ADMIN_TENANTda Garantidora X, quando chama qualquer rota desta spec numa imobiliária da Garantidora Y, então404(10.3). - [x] CA16. Dado
ADMIN_GLOBAL, quando convida numa imobiliária da Garantidora Y, então o convidado nasce com otenant_idde Y (10.5). - [x] CA17. Dado um
AGENCY_USERsem vínculo, quando lista análises, inquilinos e imóveis, então recebe listas vazias; quando cria inquilino ou imóvel,403 AGENCY_LINK_REQUIRED; quando abre qualquer imobiliária,404; e ele não aparece na Equipe doADMIN_TENANT, mas aparece na lista da plataforma doADMIN_GLOBAL(RN8a, RN8b). - [x] CA18a. Dado uma imobiliária removida antes desta spec com um usuário
is_activee um convite nunca aceito, quando a migration roda, então o primeiro ficais_active = falsesemdeleted_ate não entra mais, o segundo fica comdeleted_at, e a restauração da imobiliária devolve o primeiro e não o segundo (RN8e, RN9). - [ ] CA18. (a primeira metade sem teste automatizado — ver o Registro) Dado o banco PostgreSQL com uma imobiliária que tem usuários, quando se tenta
DELETE FROM partner_agencies, então o banco recusa pela FK (RN8c). Dado o dev-remote com os sete órfãos, quando a migration roda, então os sete têmdeleted_ateis_active = false(RN8d).
Remoção da imobiliária
- [x] CA19. Dado uma imobiliária com um ativo e um convite pendente, quando removida, então o ativo fica
is_active = falsesemdeleted_at, o pendente fica comdeleted_ate some da lista, a Auditoria temAGENCY_USERS_SUSPENDEDcom{"count": 1, "cancelled_invites": 1}e umAGENCY_INVITE_CANCELLEDpara o pendente; quando restaurada, o ativo volta e a Auditoria temAGENCY_USERS_RESTOREDcom{"count": 1}(RN9, RN10). - [x] CA20. Dado a mesma imobiliária, quando o
ADMIN_TENANTabre o diálogo de remover no detalhe, então lê o texto da lista ("…pode ser restaurado em até 60 dias.") mais "2 usuários desta imobiliária perderão o acesso e os convites pendentes serão cancelados"; o de restaurar diz "2 usuários voltam a ter acesso" (RN9a, Parte II).
A tela (desktop e mobile)
- [x] CA20a. Dado a página de detalhe de uma imobiliária removida, aberta por quem tem
partner_agencies:restoreepartner_agency_users:manage, então as linhas vêm Acesso suspenso, o menu de cada uma oferece só Remover acesso, Convidar operador não aparece, e remover uma delas responde204e tira a linha da lista (RN9b, Parte II 7.3). - [x] CA21. Dado a página de detalhe em 1280px, então a seção Usuários vem depois de Responsável de contato com as seis colunas da Parte II; o menu da linha oferece as ações certas por situação; a própria linha de quem está logado não tem o botão de menu.
- [x] CA22. Dado a mesma página em 375px, então os usuários são cartões com título só nome+avatar, o selo de situação, três linhas de
meta(a segunda em duas alturas) e o menu no rodapé; o formulário de convite abre em tela cheia com Enviar no rodapé; as confirmações abrem centradas;scrollWidth == clientWidth. - [x] CA23. Dado o
AGENCY_MANAGER, quando abre o app, então vê Minha Imobiliária no menu (desktop e gaveta mobile), a página mostra as quatro seções só leitura e a seção de usuários com ações; dado oAGENCY_USER, então vê a mesma página sem Convidar e sem menus; dado umANALYST, então não vê o item, e/imobiliariao redireciona à inicial. - [x] CA24. Dado a Equipe aberta por quem tem
partner_agencies:list, então a linha de ajuda aparece entre o cabeçalho e os filtros com o link para Imobiliárias; por quem não tem, não aparece; e nenhum usuário de imobiliária — com ou sem vínculo — está na lista. - [x] CA25. Dado a seção vazia, carregando, com erro de carga e com erro de ação, então cada estado mostra o que a Parte II 7.3 descreve, inclusive "Este e-mail já tem acesso ao sistema" dentro do diálogo de convite, que fica aberto com o que foi digitado.
14. Plano de implementação¶
Ordem obrigatória no monorepo: api → web → e2e.
- [x]
apps/api— modelo (last_login_at), migration (coluna, FKRESTRICT, RN8d) comdowngradeexercitado - [x]
apps/api— seed: permissãopartner_agency_users:manage, perfilAGENCY_MANAGER - [x]
apps/api—scoping.py(RN8a),user_repo.listar(RN8b),role_granting.py(RN7a) - [x]
apps/api—partner_agency_user_service.py+ routerpartner_agency_users.py(RN1–RN7, RN9b, RN10), com testes escritos antes (TDD) - [x]
apps/api—onboarding_service.invite_agency_user(RN2, RN2a, RN3, RN3b, tenant da imobiliária) e porteiro do router - [x]
apps/api—partner_agency_service.delete/restore(RN9) e contagens (RN9a) - [x]
apps/api—auth_service.login/refresh(RN1c),UserResponse.last_login_at - [x]
apps/api— docstrings + Swagger;make preflight; revisão da fase - [x]
apps/web—api.ts,AgencyUsersSection(tabela, cartões, menu, diálogos, convite com caixa e tela cheia),show.tsx,my-agency/, rota e item de menu,users/index.tsx(linha de ajuda),users/form.tsx(esconderAGENCY_MANAGER), diálogos da lista (RN9a) — com testes unitários ao lado emake test-web - [x]
apps/web— validado em desktop e 375px no dev-remote após/devremote-rebuild; revisão da fase - [x]
apps/e2e— seed (imob-gestor,imob-orfao),usuarios-da-imobiliaria.spec.ts, ajustes emagencies.spec.tseisolamento-imobiliaria.spec.ts; rodar só esses specs - [x]
make testemake lint(sem apontamento novo nos arquivos tocados) - [ ] Revisão da branch inteira
15. Decisões em aberto¶
Nenhuma. As decisões desta spec foram tomadas na entrevista de 2026-09-05: remover e reconvidar o órfão; remoção lógica como a Equipe; responsável por imobiliária como perfil próprio, trocável pela lista pela garantidora e pelo responsável; convite pendente com reenviar e cancelar; imobiliária removida suspende e restaurada devolve; colunas de convite, quem convidou e último acesso; permissão delegável para a garantidora (revertendo a RN2b da 0041, risco registrado na seção 16); e, nas rodadas do detalhamento: que as rotas da Equipe respondem 404 a usuário de imobiliária no ramo da garantidora (RN12, rodada 1); que o órfão removido é reconvidável por qualquer imobiliária da garantidora (RN3(b), rodada 3); e que remover acesso continua permitido numa imobiliária removida (RN9b, rodada 3). Três decisões foram tomadas pela spec, sem pergunta, e ficam à vista: parceiro não recebe perfil da garantidora por caminho nenhum, nem pelo ADMIN_GLOBAL (RN12a); cancelar convite é sempre remoção lógica, nunca exclusão física (RN5, na rodada 4 — não há critério confiável de "nunca teve acesso", e o preço de errar é 500 ou uma conta real apagada); e a migration suspende quem está em imobiliária removida antes do deploy (RN8e).
16. Revisão do detalhamento¶
Preenchido pelo laço do /spec new, rubrica .claude/rubrics/detalhamento.md. Registrar todas as rodadas, inclusive as reprovadas.
| Rodada | Critical | Important | Minor | Nota | Veredicto |
|---|---|---|---|---|---|
| 1 | 0 | 3 | 6 | 7 | reprovada |
| 2 | 0 | 4 | 4 | 7 | reprovada |
| 3 | 0 | 5 | 2 | 6 | reprovada |
| 4 | 0 | 6 | 2 | 7 | reprovada |
| 5 | 0 | 2 | 3 | 8 | aprovada |
Resultado: aprovada na rodada 5.
Riscos aceitos: a permissão partner_agency_users:manage é delegável a perfil personalizado, reabrindo a escalada que a RN2b da 0041 fechou — quem a tem convida um e-mail que controla — com is_manager: true, se quiser — e passa a operar como AGENCY_USER ou AGENCY_MANAGER daquela imobiliária, inclusive com credit_analyses:withdraw e, no segundo caso, convidando outros para ela. Decisão do usuário em 2026-09-05 ("precisa ter uma permissão para adicionar para outros perfis"); contenção na RN2b-bis (escopo à imobiliária do convite, invited_by_user_id e AGENCY_USER_INVITED na Auditoria).
17. Revisão da implementação¶
Preenchido pelo /spec implement, rubrica .claude/rubrics/implementacao.md. As revisões de fase (api, web, e2e) vão nas primeiras linhas; a da branch inteira, na última.
| Rodada | Escopo | Critical | Important | Minor | Nota | Veredicto |
|---|---|---|---|---|---|---|
| 1 | apps/api | 3 | 5 | 6 | 5 | reprovada |
| 2 | apps/web | 1 | 4 | 3 | 6 | reprovada |
| 3 | branch | 3 | 0 | 6 | 5 | reprovada |
| 4 | branch | 0 | 3 | 6 | 7 | reprovada |
| 5 | branch | 0 | 0 | 2 | 9 | aprovada |
Resultado: aprovada na rodada 5 da revisão de branch.
Os três primeiros escopos (apps/api, apps/web, e a primeira passada de branch) fecharam numa rodada cada, todos com Critical real — a fase de API reprovou por uma migration que não subiria (duas cabeças no Alembic) e por um passo de dados que apagaria quem devia apenas suspender; a de web, por faltar o Convidar operador em Minha Imobiliária — a metade da feature que é do parceiro. A branch inteira levou três rodadas: a primeira achou os mesmos três Critical de novo (a correção da fase ainda não tinha chegado ao commit revisado), a segunda achou lacunas de rede de segurança (negativo de papel nas rotas novas, red-green do last_login_at que não provava o que dizia, Swagger desatualizado), e a terceira fechou em nota 9.
Defeitos aceitos: nenhum. Os dois Minor da rodada 5 (nome do campo pending_invite_count não distinguindo expirado de pendente; o exemplo do CA19 divergindo do cenário do teste) foram corrigidos antes do fechamento, não deixados como pendência.
18. Registro¶
O que mudou em relação ao planejado, e por quê.
activated_atnão existe. A Parte III o criara (RN1d) para o cancelamento decidir entre apagar e remover logicamente. Durante a rodada 4 do portão o campo caiu junto com a exclusão física: não há critério confiável de "nunca teve acesso" — oseed_e2ee oPOST /userscriam usuário ativo sem convite nenhum, e o reconvite devolve à situação de convite pendente quem já trabalhou. Cancelar passou a ser sempre remoção lógica (RN5), e a coluna deixou de ter função.- Os passos de dados da migration saíram para
app/db/passos_de_dados_0051.py. Migration é o único código que roda uma vez, em produção, sem ninguém olhando. Extraídos, ganharamtest_passos_de_dados_0051.py— e o teste de idempotência pegou um defeito que teria ido ao ar: identificar "convite não aceito" poris_active = 0fazia a segunda execução excluir quem a primeira apenas suspendera. O sinal passou a ser ter convite em aberto. PAPEIS_DE_PARCEIROmora emapp/models/role.py, e não noscoping: oUserRepositoryprecisa dela para a RN8b, e repositório importando de serviço inverte a ordemmodel → schema → repository → service → router.- A recusa de 403 aparece no diálogo global de permissão, não dentro do diálogo de confirmação. O
api-fetchintercepta todo 403 de ação e mostra a mensagem da API num diálogo próprio — é como a Equipe já recusa o último administrador. A tela desta spec faz o que lhe cabe: não fecha a confirmação nem tira a linha da lista. O erro dentro do diálogo continua valendo para os 409, que chegam ao chamador. - O
ToastProviderentrou no envoltório de teste doapps/web(src/test/render.tsx). OApp.tsxo instala na raiz; sem ele nos testes,show()não renderizava nada e um teste de confirmação passaria sem provar que alguém lê alguma coisa. - O ajuste previsto em
agencies.spec.tsnão foi necessário: aquele spec não afirma nada sobre o texto do diálogo de remoção — quem o cobre é a suíte unitária (agencies/index.test.tsx), e é lá que o texto novo e o aviso de usuários passaram a ter teste. - O diálogo de convite virou componente próprio (
agencies/invite-dialog.tsx). A revisão da fase de web encontrou o buraco que a extração fecha: Minha Imobiliária tinha a lista e não tinha o botão de convidar — a metade da feature que é do parceiro ficara de fora, e o próprio e2e escrito na branch já a exigia. - O CA18 fica com metade da prova, e é decisão registrada. A segunda metade — a migration removendo os órfãos — foi verificada no dev-remote (sete antes, zero depois). A primeira, o
DELETE FROM partner_agenciessendo recusado pela FKRESTRICT, exige PostgreSQL, e a suíte roda em SQLite: um teste marcado para pular ali não prova nada e dá aparência de cobertura. O que sustenta a regra é a migration aplicada, que trocou a constraint, e odowngradeque a devolve. - As duas falhas do e2e não eram do produto, e a segunda ensina algo sobre o ambiente. A primeira era do próprio teste novo, que procurava o item de menu como
link— o sider renderiza cada item comobutton. A segunda, emisolamento-imobiliaria.spec.ts, é anterior a esta branch: oDataGridvirtualiza, e no dev-remote — que acumula inquilino de toda execução passada — ele renderizou 15 das 20 linhas, deixando os dois registros do seed (os mais antigos) fora do DOM. A API, medida na hora, devolvia os dois ao gestor. O teste passou a buscar pelo nome: prova a mesma coisa e não depende de o registro caber na janela. - A revisão da fase de API reprovou na primeira rodada, com três
Critical, e os três eram reais: a migration apontava para uma revisão que já tinha filho (duas cabeças no Alembic,alembic upgrade headabortaria); os doisUPDATEda RN8e rodavam na ordem em que o segundo colhia o que o primeiro escrevia; e a lista de usuários de uma imobiliária removida respondia 200 para quem não pode restaurá-la, virando o oráculo que o 404 doGET /{id}existe para não ser.
Afirmações conferidas¶
Fatos sobre o repositório que uma rodada de portão já conferiu abrindo o arquivo. O recortar.py leva esta seção junto em qualquer parte, e o revisor da rodada seguinte pode tomá-los como dados em vez de reconferir tudo do zero.
| Afirmação | Onde na spec | Conferida em |
|---|---|---|
A Equipe filtra por "sem vínculo" (partner_agency_id IS NULL), não por perfil de imobiliária | §3 "O órfão" | rodada 1 do problema — apps/api/app/repositories/user_repo.py:177-178, apps/api/app/services/user_service.py:139-140 |
O escopo trata "sem vínculo" como "é da garantidora": within_agency devolve True com partner_agency_id is None | §1, §3 | rodada 1 do problema — apps/api/app/services/scoping.py:65-67 |
A FK do vínculo é ondelete="SET NULL"; o perfil vem de user_roles e nada o amarra ao vínculo | §3 "O órfão" | rodada 1 do problema — apps/api/app/models/user.py:22-24, apps/api/app/services/onboarding_service.py:335-338 |
AGENCY_USER tem partner_agencies:read e não partner_agencies:list | §3 "Do lado da imobiliária" | rodada 1 do problema — apps/api/app/db/rbac_seed.py:381 |
A rota /agencies e o item de menu exigem partner_agencies:list | §3 | rodada 1 do problema — apps/web/src/App.tsx:428, apps/web/src/components/navigation/nav-groups.tsx:211 |
O convite de imobiliária é exclusivo de ADMIN_GLOBAL/ADMIN_TENANT (RN2b da 0041) | §2, §3 | rodada 1 do problema — apps/api/app/api/v1/routers/onboarding.py:170 |
O convite cria o usuário inativo e já vinculado; reconvidar o mesmo e-mail dá 409 EMAIL_TAKEN | §3 | rodada 1 do problema — apps/api/app/services/onboarding_service.py:397-409 |
O aceite define a senha, ativa e concede AGENCY_USER | §3 | rodada 1 do problema — apps/api/app/services/onboarding_service.py:330-338 |
A remoção lógica da imobiliária não faz nada com os usuários dela; count_active_links conta Renter, não User | §3 | rodada 1 do problema — apps/api/app/services/partner_agency_service.py:84-107, apps/api/app/repositories/partner_agency_repo.py:90-113 |
| A página da imobiliária só convida, não lista usuários | §3 | rodada 1 do problema — apps/web/src/pages/agencies/show.tsx:60,128-131 |
ADMIN_GLOBAL vê todo mundo na lista da plataforma e desativa por PATCH /users/{id} | §2, §3 | rodada 1 do problema — apps/api/app/services/user_service.py:139-140, apps/api/app/api/v1/routers/users.py:126-160 |
As três imobiliárias das jornadas não existem em partner_agencies (nem removidas) e audit_logs não tem partner_agency/REMOVED | §3 "O órfão" | rodada 1 do problema — consulta ao garantora-devremote-db-1 |
O subtítulo do PageHeader é string, uma linha, noWrap; a Equipe põe ali "Gerencie usuários internos" | §7 "Equipe" | rodada 2 da experiência — apps/web/src/components/shared/PageHeader.tsx:48,278-285, apps/web/src/pages/users/index.tsx:346 |
| Responsável de contato é a última seção do detalhe da imobiliária; Convidar operador é a ação primária do topo, só para papel de sistema | §7 | rodada 3 da experiência — apps/web/src/pages/agencies/show.tsx:60,127-137,238-248 |
O diálogo de convite é centrado hoje (fullWidth maxWidth="xs", sem fullScreen), com erro em Alert dentro, e o sucesso é uma faixa no topo com "O link expira em 72 horas" | §7.1, §7.2, §7.3 | rodada 3 da experiência — apps/web/src/pages/agencies/show.tsx:158-161,325,332; apps/api/app/core/config.py:113 |
| O diálogo de remoção do detalhe diz "remover permanentemente… não pode ser desfeita"; o da lista diz "O registro deixa de aparecer nas listas, mas pode ser restaurado em até 60 dias."; restaurar só existe na lista | §7 "remover a imobiliária" | rodada 3 da experiência — apps/web/src/pages/agencies/show.tsx:290-295, apps/web/src/pages/agencies/index.tsx:483-488,518-587 |
Quem tem partner_agencies:restore abre o detalhe de uma removida | §7.3 | rodada 2 da experiência — apps/api/app/services/partner_agency_service.py:66-70,136-149 |
Nenhuma lista do app tem menu de excedente por linha; o padrão é IconButton + Tooltip por ação | §7.1 "padrão novo" | rodada 2 da experiência — varredura de apps/web/src/pages/; docs/ui-patterns.md "Tabelas — Padrão" |
RecordCard: título noWrap numa linha, meta com duasLinhas opcional e teto de três, ações no rodapé (sem actions, sem rodapé); RecordCardList mostra "Carregando…" | §7.2, §7.3 | rodada 3 da experiência — apps/web/src/components/shared/RecordCard.tsx:31-44,70,122-126,153-173,178-211,236-239 |
ConfirmDialog é tela cheia por padrão em 375px, quem chama desliga para confirmação sem campo (0049), e mostra erro da API dentro | §7.2, §7.3 | rodada 3 da experiência — apps/web/src/components/shared/ConfirmDialog.tsx:82-89,93-106,125,177,221-224 |
O PageHeader põe a ação primária como ícone de 44 px no mobile | §7.2 | rodada 2 da experiência — apps/web/src/components/shared/PageHeader.tsx:94-96,159-186 |
| O app tem toast de sucesso e de erro | §7.1, §7.3 | rodada 3 da experiência — apps/web/src/contexts/toast/index.tsx:3-40, apps/web/src/providers/notification.tsx:72 |
/agencies/:id é gateada por partner_agencies:list e cai na tela de sem-permissão | §7.3 | rodada 3 da experiência — apps/web/src/App.tsx:116,428-431, apps/web/src/pages/unauthorized/index.tsx:73 |
O login mostra "Sua conta está desativada…" só para usuário que existe e está inativo — a exclusão da Equipe grava deleted_at e is_active=False por isso | §7.3 | rodada 3 da experiência — apps/api/app/services/auth_service.py:48-49, apps/web/src/pages/login/index.tsx:66,254, apps/api/app/services/user_service.py:188-193 |
Sessão aberta cai na próxima requisição: get_current_user confere is_active, o refresh recusa inativo, o web redireciona no segundo 401 | §7.3 | rodada 2 da experiência — apps/api/app/core/dependencies.py:53-54, apps/api/app/services/auth_service.py:78-79, apps/web/src/providers/api-fetch.ts:173-182 |
O sistema sabe quem convidou (VerificationCode.invited_by_user_id) | §7 "Custo de uso" | rodada 3 da experiência — apps/api/app/models/verification_code.py:43 |
User não tem campo de último acesso (só last_seen_version) | §7.1, §7.3 | rodada 2 da experiência — apps/api/app/models/user.py:64 |
| Minha Garantidora está no grupo Administração e a gaveta do mobile renderiza os grupos | §7, §7.2 | rodada 2 da experiência — apps/web/src/components/navigation/nav-groups.tsx:216-225, apps/web/src/components/mobile-shell/index.tsx:59 |
Duas regras, e a segunda é a que faz a primeira valer:
- Só entra o que um revisor conferiu — não o que você conferiu ao escrever. O ponto é aproveitar trabalho já pago por uma rodada, não abrir atalho para afirmar sem prova.
- Mexeu na frase da spec, a linha sai da tabela. A afirmação volta a ser conferível. Sem isso a tabela vira o oposto do portão: uma afirmação carimbada como verdadeira porque um dia foi.
Feature sem afirmação sobre o repositório não precisa da seção — apague-a.