Pular para conteúdo

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.

  1. 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.
  2. A Parte II diz como se usa o que vai existir. Revisada na seção 8 (jornada × viewports).
  3. 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.
  4. 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_TENANT e ADMIN_GLOBAL por 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_GLOBAL sente 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:

  1. 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.
  2. 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_GLOBAL já 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
E-mail 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 RecordCard põ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/:id sem 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/:id de 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 User com aquele partner_agency_id e deleted_at IS NULL, em ordem alfabética por nome (desempate por id), sem paginação. Cada item traz: id, name, email, is_manager (porta AGENCY_MANAGER), situation, invited_at, invited_by_name, last_login_at.
  • RN1a. situation é derivada, nunca gravada, nesta ordem: SUSPENDED quando a imobiliária está removida (deleted_at dela preenchido), seja qual for o is_active do usuário; senão ACTIVE quando is_active; senão INVITE_PENDING quando há VerificationCode do tipo USER_INVITE não usado e não expirado; senão INVITE_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 o PATCH is_active da Equipe para ele.
  • RN1b. invited_at e invited_by_name vêm do VerificationCode do convite mais recente daquele usuário (created_at e invited_by_user_id). Usuário anterior a esta spec sem código de convite (o seed_e2e cria direto) devolve os dois nulos, e a tela mostra "—".
  • RN1c. last_login_at é campo novo em users, gravado pelo AuthService.login e pelo refresh (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 é do User, 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 aceitar is_manager: bool (padrão false). Com true, o VerificationCode nasce com role_name = "AGENCY_MANAGER"; com false, "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ável partner_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 registra AGENCY_USER_INVITED com quem convidou (RN9); e o poder ganho é o de AGENCY_USER — ou, com is_manager: true, de AGENCY_MANAGER, que convida outros — daquela imobiliária: credit_analyses:withdraw sobre 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 User com deleted_at preenchido que é de imobiliária — tem partner_agency_id ou porta AGENCY_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) o partner_agency_id dele é 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 o tenant_id dele é o da imobiliária do convite, o serviço grava o partner_agency_id do convite, limpa deleted_at, mantém is_active = false, troca o password_hash por um aleatório, apaga todo VerificationCode USER_INVITE não usado que ela ainda tenha (senão um link antigo, com o role_name antigo, voltaria a valer ao limpar deleted_at) e emite um VerificationCode novo com o role_name do 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 como INVITE_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 o accept_invite continua recusando deleted_at preenchido.
  • 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_invite passa a revogar todo perfil que o usuário tenha antes de conceder o role_name do código. Hoje ele só concede, e sem isto um reconvidado como usuário comum manteria o AGENCY_MANAGER da 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 em INVITE_PENDING ou INVITE_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 mesmo role_name, e manda o e-mail de convite de novo. Apagar, e não marcar used_at, é escolha desta spec: used_at fica 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 usa revoked_at num modelo próprio; VerificationCode não tem esse campo.) Usuário ativo responde 409 INVITE_ALREADY_ACCEPTED.
  • RN5. Cancelar convite (DELETE …/users/{user_id} sobre INVITE_PENDING ou INVITE_EXPIRED) é remoção lógica, igual à RN6 — deleted_at, is_active = false e os VerificationCode nã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 é — o seed_e2e e o POST /users criam 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 é um DELETE contra as FKs RESTRICT de credit_analyses.created_by, documents.uploaded_by, decisions.analyst_id e guarantees.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): grava deleted_at e is_active = false, porque as três portas de sessão conferem só is_active, e apaga os VerificationCode USER_INVITE não usados do usuário, para nenhum link sobreviver à remoção. Perfis ficam gravados (a RN3 os revoga se a pessoa voltar). A Auditoria registra AGENCY_USER_REMOVED com o tenant_id do 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_MANAGER quando ele é o único AGENCY_MANAGER ativo e não excluído daquela imobiliária e a imobiliária tem ao menos um outro usuário ACTIVE. 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) concede AGENCY_MANAGER e revoga AGENCY_USER; deixar de ser responsável (DELETE …/users/{user_id}/manager) faz o inverso. Os dois exigem usuário ACTIVE (409 USER_NOT_ACTIVE caso contrário — de convite pendente muda-se cancelando e reconvidando). São idempotentes: tornar responsável quem já é responde 200 sem evento. A Auditoria registra AGENCY_MANAGER_GRANTED / AGENCY_MANAGER_REVOKED.
  • RN7a. AGENCY_MANAGER e AGENCY_USER nã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: o 403 ROLE_NOT_ASSIGNABLE passa a cobrir os dois. A guarda entra em dois lugares, porque hoje ela só existe num: role_granting.py roda no assign_role_to_user, no UserService.create e no invite_team_user, e o revoke_role_from_user (apps/api/app/services/permission_service.py) não passa por ele — um ADMIN_TENANT revoga AGENCY_USER de um parceiro hoje e o deixa com vínculo e sem perfil. O revoke_role_from_user passa a recusar os dois perfis de parceiro com o mesmo 403 ROLE_NOT_ASSIGNABLE, inclusive para o ADMIN_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_id nulo), semeado pelo rbac_seed.py com todas as permissões de AGENCY_USER mais partner_agency_users:manage. O nome exibido é "Responsável da Imobiliária". Como toda permissão nova, entra pelo seed que o /ship roda 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_agency e agency_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 (porta AGENCY_USER ou AGENCY_MANAGER e partner_agency_id nulo). O segundo não alcança nada: within_agency devolve False para todo registro, e agency_scope devolve um sentinela que nenhum registro casa. E não cria: hoje RenterService.create deriva o vínculo de current_user.partner_agency_id e, com ele nulo, grava na carteira própria da garantidora; PropertyService.create exige imobiliária (422 NO_AGENCY) mas aceita o partner_agency_id do corpo, e o resolve_in_scope o aprova para quem tem escopo nulo — o órfão cadastraria imóvel na imobiliária que nomeasse. Os dois passam a responder 403 AGENCY_LINK_REQUIRED para 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_garantidora passa a excluir também quem porta AGENCY_USER ou AGENCY_MANAGER, além de quem tem partner_agency_id. A docstring do listar diz 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; o ADMIN_GLOBAL continua vendo todo mundo (RN17 da 0041).
  • RN8c — a FK deixa de zerar. users.partner_agency_id passa de ondelete=SET NULL para ondelete=RESTRICT. Um DELETE fí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_links nunca contou User. A migration grava is_active = false em todo usuário is_active cuja imobiliária tem deleted_at — sem deleted_at no usuário, que é o que a restauração devolve (RN9) — e aplica a RN5 aos convites não aceitos daquelas imobiliárias, gravando deleted_at neles. As duas metades, porque a RN9 faz as duas: só a primeira deixaria um convidado que nunca aceitou com is_active = false e deleted_at nulo, e a restauração o tornaria Ativo sem senha e sem perfil. Sem este passo eles apareceriam SUSPENDED na lista e continuariam entrando, porque as portas de sessão conferem is_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_at e is_active = false) a todo User que porta AGENCY_USER e tem partner_agency_id nulo. É irreversível de propósito e o downgrade nã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 gravar deleted_at: todo usuário ACTIVE da imobiliária recebe is_active = false (sem deleted_at — é suspensão, não remoção), e todo usuário em INVITE_PENDING/INVITE_EXPIRED é cancelado pela RN5 (remoção lógica). No restore: todo usuário da imobiliária com deleted_at IS NULL volta a is_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 registra AGENCY_USERS_SUSPENDED / AGENCY_USERS_RESTORED na entidade partner_agency, com a contagem no payload, e um AGENCY_INVITE_CANCELLED por 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 o POST …/restore passam a devolver, na resposta de leitura da imobiliária (PartnerAgencyResponse), o campo novo active_user_count — contagem de usuários ACTIVE ou SUSPENDED —, para os diálogos da Parte II dizerem "N usuários perderão o acesso" / "N usuários voltam a ter acesso", e pending_invite_count, para o "e os convites pendentes serão cancelados". Os dois campos são opcionais no PartnerAgencyResponse (a lista e o get compartilham o schema): a lista os devolve nulos (custo de contagem por linha), e o diálogo de remover/restaurar da lista chama GET /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ão BusinessRuleError, que é 422 e é a classe dos erros vizinhos do partner_agency_service.py). Remover acesso continua funcionando sobre um SUSPENDED (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 é o resolve_in_scope, que responde 404 a tudo que tem deleted_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 tem partner_agency_users:manage, com ou sem partner_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 tem partner_agencies:restore (include_deleted condicionado à permissão, como o get de hoje), com situation = SUSPENDED; sem a permissão, 404.

Auditoria e e-mail

  • RN10. Todo evento desta spec vai à Auditoria pelo AuditRepository.log com o tenant_id do 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. O entity_type é user para os seis primeiros e partner_agency para 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} e POST/DELETE /roles/{role_id}/users/{user_id} respondem 404 quando o alvo é usuário de imobiliária — tem partner_agency_id ou 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 o GET /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 com users:* — 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 do DELETE /partner-agencies/{id}/users/{uid} (o agency_id é o partner_agency_id do alvo) e por isso decide como a RN5a — remoção lógica em qualquer situação, com AGENCY_INVITE_CANCELLED sobre INVITE_* e AGENCY_USER_REMOVED sobre ACTIVE/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_delete com USER_DELETED —, e situation não se aplica a ele, porque ele não está em lista de imobiliária nenhuma. PATCH /users/{id} com is_active sobre usuário de imobiliária é recusado com 403 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; PATCH só de name segue; conceder e revogar perfil de parceiro é a RN7a; e conceder qualquer outro perfil a um usuário de imobiliária por POST /roles/{rid}/users/{uid} é recusado com 403 ROLE_NOT_ASSIGNABLE — parceiro não recebe perfil da garantidora, por nenhum caminho. Hoje _ensure_same_tenant dispensa o ADMIN_GLOBAL e 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_id no path. O serviço resolve a imobiliária com PartnerAgencyService._get_authorized(agency_id, current_user, include_deleted=…) (RN9b) — que aplica tenant_scope e within_agency de apps/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_id no path é resolvido dentro da imobiliária já resolvida: User com aquele id e partner_agency_id == agency.id e tenant_id == agency.tenant_id. Qualquer falha → 404. Isto fecha o caminho "agency_id meu, user_id de outro tenant".
  • Nenhuma FK vem do corpo. O convite recebe name, email, is_manager; o vínculo é o agency_id do path (RN12 da 0034, já é assim).
  • O tenant_id do convidado é o da imobiliária resolvida, não o de quem chama — hoje o serviço usa _get_tenant(current_user), que lança 422 TENANT_REQUIRED para o ADMIN_GLOBAL. A troca é parte desta spec (10.5).

10.4 Isolamento por imobiliária

  • O AGENCY_MANAGER e o AGENCY_USER só alcançam a própria imobiliária: o _get_authorized já aplica within_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_MANAGER só consegue convidar para a própria imobiliária porque qualquer outro agency_id dá 404 antes de o corpo ser lido.
  • Minha Imobiliária no web usa o partner_agency_id que o /auth/me já devolve (UserResponse.partner_agency_id) e chama GET /partner-agencies/{id} e GET /partner-agencies/{id}/users com ele. Não há endpoint "minha": é o mesmo GET com 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_name vem "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_GLOBAL nas rotas desta spec: tenant_scope é nulo, então ele alcança qualquer imobiliária pelo agency_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_user chama _get_tenant(current_user), que lança 422 TENANT_REQUIRED com tenant_id nulo. O que muda: o tenant_id do convidado passa a ser o da imobiliária, resolvida pelo _get_authorized (10.3), e o ADMIN_GLOBAL passa 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 mostrar last_login_at se a UserResponse o 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 de SET NULL para RESTRICT — 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_MANAGER e partner_agency_users:manage são seed, não schema (apps/api/app/db/rbac_seed.py).
  • Endpoints (todos com summary, description, response_description; router novo apps/api/app/api/v1/routers/partner_agency_users.py com CommitRoute, mais as alterações no onboarding.py e no partner_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. Resolve agency_id com o _get_authorized do PartnerAgencyService e user_id dentro da imobiliária (10.3).
  • apps/api/app/services/onboarding_service.py — invite_agency_user: RN2, RN3, RN3b; tenant_id do convidado vem da imobiliária (10.3/10.5); registra AGENCY_USER_INVITED. accept_invite: sem mudança de regra (RN3a).
  • apps/api/app/services/role_granting.py: RN7a — o bloco que recusa AGENCY_USER passa a recusar também AGENCY_MANAGER; e apps/api/app/services/permission_service.py — revoke_role_from_user passa 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); update e soft_delete: RN12a no ramo do ADMIN_GLOBAL (o soft_delete delega ao PartnerAgencyUserService, o update recusa is_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 pelo ADMIN_GLOBAL). apps/api/app/api/v1/routers/users.py: só a description das 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_agency e agency_scope.
  • apps/api/app/repositories/user_repo.py — listar: RN8b; e um listar_da_imobiliaria para a RN1, com o VerificationCode mais recente e o nome de quem convidou num selectinload/subconsulta, para não fazer N+1.
  • apps/api/app/services/partner_agency_service.py — delete e restore: RN9; PartnerAgencyResponse ganha active_user_count e pending_invite_count no get (RN9a); _get_authorized(..., include_deleted=True) é a resolução das rotas de escrita (RN9b).
  • apps/api/app/services/renter_service.py e apps/api/app/services/property_service.py — create: RN8a (403 para parceiro sem vínculo).
  • apps/api/app/services/auth_service.py — login e refresh: RN1c.
  • apps/api/app/services/user_service.py e apps/api/app/schemas/user.py: UserResponse.last_login_at.
  • apps/api/app/core/dependencies.py: sem mudança — require_permission já serve.
  • Testes (apps/api/tests/test_partner_agency_users.py, novo; mais casos em test_onboarding.py e nos testes de escopo existentes):
  • caminho feliz de cada rota e de cada situation derivada (RN1a — inclusive INVITE_EXPIRED por relógio, e SUSPENDED por imobiliária removida);
  • negativos de acesso, um por papel afetado: VIEWER recebe 403 ao convidar; AGENCY_USER recebe 403 ao remover colega; AGENCY_MANAGER recebe 404 ao listar, convidar e remover em outra imobiliária; ADMIN_TENANT recebe 404 em imobiliária de outra garantidora; perfil personalizado com partner_agency_users:manage convida com sucesso e um sem ela recebe 403; ADMIN_GLOBAL convida em qualquer garantidora e o convidado nasce com o tenant_id da imobiliária;
  • RN6a (CANNOT_TARGET_SELF nas duas rotas), RN6b (as duas metades: recusa com outro ativo, aceita sem outro ativo), RN7a (ROLE_NOT_ASSIGNABLE para AGENCY_MANAGER nas três rotas de perfil e na revogação, inclusive para ADMIN_GLOBAL);
  • RN12 (ADMIN_TENANT e perfil personalizado com users:* recebem 404 nas quatro rotas da Equipe sobre usuário de imobiliária), RN12a (ADMIN_GLOBAL: DELETE executa a RN6 com AGENCY_USER_REMOVED; PATCH is_active recebe 403; PATCH name passa);
  • 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_GLOBAL continua vendo), RN8c (o DELETE fí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 e 204 ao remover acesso nela), RN9a (contagens);
  • RN1c (login e refresh gravam last_login_at);
  • RN10 (um evento por ação, com o tenant_id do 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 (ConfirmDialog centrado 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 componente AgencyUsersSection (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 tem partner_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; Agency ganha as duas contagens.
  • apps/web/src/pages/users/index.tsx: a linha de ajuda entre o cabeçalho e os filtros, para quem tem partner_agencies:list.
  • apps/web/src/pages/users/form.tsx: o seletor de perfis esconde AGENCY_MANAGER como já esconde AGENCY_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 tem partner_agency_id — o item precisa de uma condição a mais que o requerPermissao, porque ANALYST também tem partner_agencies:read e não tem imobiliária). Rota /imobiliaria em apps/web/src/App.tsx com ExigeAcesso permissao="partner_agencies:read", e a página redireciona para a inicial se o /me não tiver partner_agency_id. O components/custom-sider/index.tsx consome os nav-groups; conferir que o item aparece também na gaveta do mobile (components/mobile-shell/index.tsx).
  • Menu e rota por permissão: /agencies/:id continua sob partner_agencies:list — parceiro que digita a URL cai na tela de sem-permissão. /imobiliaria sob partner_agencies:read; usuário da garantidora que a digita é redirecionado à inicial (não tem imobiliária). Ações na seção aparecem só com partner_agency_users:manage.
  • Responsividade & PWA: Parte II, 7.1 e 7.2. Sem impacto no PWA.
  • Testes unitários (*.test.tsx ao 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 com EMAIL_TAKEN; my-agency/index.test.tsx — redireciona sem partner_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, com scrollWidth == clientWidth. Ajustes em agencies.spec.ts (texto do diálogo de remoção do detalhe) e isolamento-imobiliaria.spec.ts (o órfão não alcança a carteira).
  • Seeds: apps/api/app/db/seed_e2e.py ganha um AGENCY_MANAGER na Imobiliária E2E (imob-gestor@e2e.test) e um AGENCY_USER sem 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

GET /api/v1/partner-agencies/{agency_id}/users
Authorization: Bearer <token>
Permissão partner_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 }
Permissão 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.

POST /api/v1/partner-agencies/{agency_id}/users/{user_id}/resend-invite
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.

DELETE /api/v1/partner-agencies/{agency_id}/users/{user_id}
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
Permissão 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 }
Na lista (GET /partner-agencies) os dois vêm null.

GET/PATCH/DELETE /api/v1/users/{id}
POST/DELETE     /api/v1/roles/{role_id}/users/{user_id}
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 FK users.partner_agency_id para ON 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, mais UPDATE 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 para ON DELETE SET NULL. Não desfaz a RN8d nem a RN8e — está escrito na regra e no description da migration; o make preflight exige downgrade real, 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_TENANT chama GET …/users, então recebe três itens em ordem alfabética com situation ACTIVE, INVITE_PENDING e INVITE_EXPIRED, invited_at e invited_by_name preenchidos 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_at reflete o refresh; um usuário anterior à spec devolve null e a tela mostra "—" (RN1c).
  • [x] CA3. Dado uma imobiliária removida, quando quem tem partner_agencies:restore lista os usuários, então todos vêm SUSPENDED; quem tem partner_agency_users:manage sem restore recebe 404 na lista, 409 AGENCY_REMOVED ao convidar, reenviar e trocar responsável, e 204 ao remover o acesso de um suspenso, que ganha deleted_at; e um AGENCY_MANAGER de outra imobiliária recebe 404 em todas (RN9b, RN5a).

O convite

  • [x] CA4. Dado um perfil personalizado com partner_agency_users:manage, quando convida com is_manager: true, então o VerificationCode nasce com role_name = AGENCY_MANAGER, a Auditoria tem AGENCY_USER_INVITED com o user_id de quem convidou, e o aceite concede AGENCY_MANAGER (RN2, RN2a, RN10).
  • [x] CA5. Dado o mesmo perfil personalizado sem a permissão, então o convite responde 403; dado VIEWER, 403; dado AGENCY_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ão 409 EMAIL_TAKEN; dado um órfão removido pela migration (sem vínculo), quando a Imobiliária B o convida, então ele volta INVITE_PENDING com 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 o 422 INVALID_TOKEN que o accept_invite já responde a código inexistente — só o segundo vale —, e ao aceitar o segundo ele porta AGENCY_USER e não AGENCY_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ão 409 INVITE_ALREADY_ACCEPTED (RN4).
  • [x] CA8. Dado um convite pendente, quando cancelado, então o usuário fica com deleted_at e is_active = false, os códigos não usados dele somem, a Auditoria tem AGENCY_INVITE_CANCELLED com o id em entity_id e o e-mail no payload, 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 o DELETE responde 204 e 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_at e is_active = false estão gravados, ele recebe 401 na próxima requisição, e a Auditoria tem AGENCY_USER_REMOVED com o tenant_id dele (RN5a, RN6, RN10).
  • [x] CA10. Dado um AGENCY_MANAGER, quando tenta remover o próprio acesso ou tirar a própria responsabilidade, então 403 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_TENANT tira a responsabilidade ou remove o responsável, então 403 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_MANAGER e não AGENCY_USER, tem a permissão partner_agency_users:manage no /me, e a segunda chamada responde 200 sem evento novo; dado um convite pendente, então 409 USER_NOT_ACTIVE (RN7).
  • [x] CA13. Dado ADMIN_GLOBAL, quando tenta conceder AGENCY_MANAGER por POST /roles/{id}/users/{id} ou revogar AGENCY_USER por DELETE /roles/{id}/users/{id}, então 403 ROLE_NOT_ASSIGNABLE nas duas; o seletor da Equipe não o lista (RN7a).
  • [x] CA13a. Dado ADMIN_TENANT (e um perfil personalizado com users:update, users:delete), quando chama GET, PATCH, DELETE /users/{id} ou POST/DELETE /roles/{rid}/users/{id} sobre um usuário de imobiliária da própria garantidora, então 404 nas cinco (RN12). Dado ADMIN_GLOBAL, quando DELETE /users/{id} sobre o mesmo usuário ativo, então ele recebe deleted_at, is_active = false e AGENCY_USER_REMOVED; sobre um convidado pendente, então remoção lógica com AGENCY_INVITE_CANCELLED; sobre um SUSPENDED, então remoção lógica com AGENCY_USER_REMOVED; sobre um parceiro sem vínculo, então remoção lógica com USER_DELETED; quando PATCH {is_active: false}, então 403 AGENCY_USER_VIA_AGENCY; quando PATCH {name}, então 200; quando POST /roles/{id-de-ANALYST}/users/{id} sobre usuário de imobiliária, então 403 ROLE_NOT_ASSIGNABLE (RN12a).

Escopo

  • [x] CA14. Dado um AGENCY_MANAGER da Imobiliária A, quando lista, convida ou remove na Imobiliária B da mesma garantidora, então 404 nas três (10.4).
  • [x] CA15. Dado um ADMIN_TENANT da Garantidora X, quando chama qualquer rota desta spec numa imobiliária da Garantidora Y, então 404 (10.3).
  • [x] CA16. Dado ADMIN_GLOBAL, quando convida numa imobiliária da Garantidora Y, então o convidado nasce com o tenant_id de Y (10.5).
  • [x] CA17. Dado um AGENCY_USER sem 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 do ADMIN_TENANT, mas aparece na lista da plataforma do ADMIN_GLOBAL (RN8a, RN8b).
  • [x] CA18a. Dado uma imobiliária removida antes desta spec com um usuário is_active e um convite nunca aceito, quando a migration roda, então o primeiro fica is_active = false sem deleted_at e não entra mais, o segundo fica com deleted_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êm deleted_at e is_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 = false sem deleted_at, o pendente fica com deleted_at e some da lista, a Auditoria tem AGENCY_USERS_SUSPENDED com {"count": 1, "cancelled_invites": 1} e um AGENCY_INVITE_CANCELLED para o pendente; quando restaurada, o ativo volta e a Auditoria tem AGENCY_USERS_RESTORED com {"count": 1} (RN9, RN10).
  • [x] CA20. Dado a mesma imobiliária, quando o ADMIN_TENANT abre 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:restore e partner_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 responde 204 e 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 o AGENCY_USER, então vê a mesma página sem Convidar e sem menus; dado um ANALYST, então não vê o item, e /imobiliaria o 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, FK RESTRICT, RN8d) com downgrade exercitado
  • [x] apps/api — seed: permissão partner_agency_users:manage, perfil AGENCY_MANAGER
  • [x] apps/api — scoping.py (RN8a), user_repo.listar (RN8b), role_granting.py (RN7a)
  • [x] apps/api — partner_agency_user_service.py + router partner_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 (esconder AGENCY_MANAGER), diálogos da lista (RN9a) — com testes unitários ao lado e make 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 em agencies.spec.ts e isolamento-imobiliaria.spec.ts; rodar só esses specs
  • [x] make test e make 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_at nã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" — o seed_e2e e o POST /users criam 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, ganharam test_passos_de_dados_0051.py — e o teste de idempotência pegou um defeito que teria ido ao ar: identificar "convite não aceito" por is_active = 0 fazia a segunda execução excluir quem a primeira apenas suspendera. O sinal passou a ser ter convite em aberto.
  • PAPEIS_DE_PARCEIRO mora em app/models/role.py, e não no scoping: o UserRepository precisa dela para a RN8b, e repositório importando de serviço inverte a ordem model → 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-fetch intercepta 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 ToastProvider entrou no envoltório de teste do apps/web (src/test/render.tsx). O App.tsx o 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.ts nã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_agencies sendo recusado pela FK RESTRICT, 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 o downgrade que 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 como button. A segunda, em isolamento-imobiliaria.spec.ts, é anterior a esta branch: o DataGrid virtualiza, 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 head abortaria); os dois UPDATE da 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 do GET /{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:

  1. 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.
  2. 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.