Pular para conteúdo

0031 — Convite de garantidora por link

Status CONCLUÍDA
Branch spec/0031-convite-de-garantidora-por-link
Criada em 2026-08-09
Apps afetados api · web · e2e

Status: RASCUNHO → APROVADA → EM IMPLEMENTAÇÃO → CONCLUÍDA

1. Problema

A garantidora é o cliente do SaaS: entra por negociação comercial, não por formulário público. Mas a tela de login exibe "Criar minha conta" em destaque, convidando qualquer visitante a se cadastrar como se o produto fosse auto-serviço. Quem chega pelo link do app vê, como segunda opção mais visível da tela, um caminho que não corresponde ao processo real.

E o caminho que corresponde ao processo não existe. Quando o time fecha com uma garantidora nova, não há nada no SaaS admin para dizer "convidei este contato": manda-se a URL do app por WhatsApp e torce-se para a pessoa achar o botão certo. Depois, na lista de Garantidoras, não dá para distinguir quem foi convidado de quem se cadastrou sozinho, nem saber quais convites estão pendentes.

Para usuário esse convite existe desde sempre (/onboarding/users → e-mail → /accept-invite). Para garantidora — a entidade que de fato passa por negociação — não.

2. Objetivo

O ADMIN_GLOBAL passa a emitir, no SaaS admin, um link de convite para a garantidora nova — entregue pelo canal que quiser (hoje, WhatsApp, fora do sistema). Quem recebe o link cai no cadastro com o e-mail já preenchido, e o convite fica vinculado à garantidora criada, dando ao time a rastreabilidade que hoje não existe. Ao mesmo tempo, o auto-cadastro deixa de ser anunciado na tela de login.

O que esta spec deliberadamente não faz: fechar a porta. Decisão do usuário em 2026-08-09 — POST /auth/register continua aberto e funcionando sem convite (RN10). Quem souber o endereço /register, ou chamar a API direto, continua criando garantidora sem convite. O ganho aqui é de processo e rastreabilidade — um caminho oficial para convidar, e o registro de quem convidou quem —, não de bloqueio. Se em algum momento a intenção passar a ser impedir o auto-cadastro, isso é outra spec, e ela mexe no endpoint.

3. Fora de escopo

  • Envio automático por WhatsApp. A entrega é manual: o admin copia o link e manda. A integração com API de WhatsApp é assunto de outra spec — esta só garante que o link seja fácil de copiar.
  • Envio do convite por e-mail. Decisão do usuário: o canal é o WhatsApp. Não há disparo de e-mail na emissão. (Ver RN9 para a consequência sobre verificação de e-mail.)
  • Convite de usuário da equipe e de imobiliária parceira. Continuam como estão (/onboarding/users, /onboarding/partner-agencies/{id}/users), por e-mail. Esta spec não os toca.
  • Aprovação em duas etapas. O convite emitido já é válido; não há fluxo de "solicitar convite" nem revisão por um segundo admin.
  • Cobrança, plano ou limite de garantidoras. Nada de comercial entra aqui.
  • Migrar tenants existentes. Quem já está na plataforma continua como está.
  • Bloquear o auto-cadastro. POST /auth/register fica aberto (RN10). Fechar essa porta é decisão de produto que esta spec não toma.

4. Regras de negócio

  • RN1. Só ADMIN_GLOBAL emite convite. É o papel do time do SaaS. ADMIN_TENANT e os demais não veem a área de convites — ela vive no admin, que já é restrito.

  • RN2. O convite carrega apenas o e-mail do responsável. Decisão do usuário. O admin informa o e-mail de quem vai receber o link (e, opcionalmente, um nome para identificar o convite na lista). Nenhum dado da garantidora é pedido na emissão — quem preenche é o convidado, na tela de onboarding que já existe.

  • RN3. A garantidora nasce no aceite, não na emissão. Um convite pendente não é um tenant: não aparece na lista de Garantidoras, não conta em métrica, não ocupa CNPJ. Só ao concluir o onboarding existe Tenant.

Consequência aceita: o admin não vê "garantidoras em cadastro" na lista de Garantidoras — vê convites pendentes na lista de Convites. São coisas diferentes, e a spec prefere isso a criar tenant fantasma.

  • RN4. O link expira em 72 horas e é de uso único. Mesma validade do convite de usuário (INVITE_TOKEN_EXPIRE_HOURS), para não haver dois prazos no produto. Aceito o convite, o token é queimado — reabrir o link mostra "convite já utilizado".

  • RN5. Estados do convite. PENDING → ACCEPTED, ou PENDING → REVOKED. EXPIRED é derivado de expires_at < agora, não gravado — assim um convite não precisa de rotina de varredura para mudar de estado.

  • RN6. Revogar e reemitir. O admin pode revogar um convite PENDING (o link morre na hora) e emitir outro para o mesmo e-mail. Revogar um convite já ACCEPTED não é possível — a garantidora já existe, e desfazê-la é outro assunto.

  • RN7. Um convite pendente por e-mail. Emitir para um e-mail que já tem convite PENDING substitui o anterior (revoga e cria um novo), em vez de acumular dois links válidos para a mesma pessoa. A resposta diz que houve substituição.

  • RN8. E-mail já em uso barra a emissão. Se o e-mail já pertence a um usuário da plataforma, a emissão falha com EMAIL_TAKEN (409). O convite é para quem ainda não tem conta.

  • RN9. O e-mail continua sendo verificado — o convite não substitui a verificação. Decisão do usuário em 2026-08-09 (opção b). Quem chega pelo convite percorre o mesmo verify-email de sempre: recebe um código no e-mail informado e só então chega ao onboarding. Na prática isto significa não mexer no fluxo — que já verifica.

Por que importa dizer: o link vai por WhatsApp, então o convite prova que a pessoa foi convidada, mas não prova que o e-mail é dela nem que o admin digitou certo. Sem a verificação, um erro de digitação criaria uma garantidora cujo dono não recebe e-mail nenhum — inclusive o de recuperação de senha, deixando a conta sem caminho de volta. Descartado, por isso, marcar o e-mail como verificado no aceite.

Consequência operacional: o e-mail segue no caminho crítico do cadastro. Onde o SMTP não estiver configurado, o fluxo trava no passo 2 — ver o aviso em §5.

  • RN10. POST /auth/register continua aberto, exatamente como está. Decisão do usuário em 2026-08-09, corrigindo a leitura anterior desta spec. O endpoint não passa a exigir convite, não ganha erro INVITE_REQUIRED e não muda de comportamento para quem chama sem token. Quem já usa esse caminho — inclusive a suíte de testes — segue funcionando sem alteração.

O único parâmetro novo é invite_token, opcional. Presente e válido, vincula o cadastro ao convite (RN13). Ausente, o registro corre como sempre correu.

  • RN11. Sai o CTA; a rota /register fica e continua cadastrando. O botão "Criar minha conta" some da tela de login — essa é a única remoção. O endereço /register continua existindo e continua permitindo cadastro sem convite; quem tem o link salvo não cai em 404 nem em porta fechada.

A tela ganha um comportamento a mais, não um bloqueio:

URL O que a tela faz
/register Formulário de cadastro como hoje — nome, e-mail, senha, tudo editável
/register?token=<válido> Mesmo formulário, com o e-mail do convite preenchido e travado, e o convite vinculado ao concluir
/register?token=<inválido> Aviso de que o link não vale mais (motivo específico) e o formulário normal logo abaixo — o cadastro não é impedido
  • RN12. Convite inválido não bloqueia cadastro. Token expirado, revogado, já usado ou inexistente produz aviso, não parede: a pessoa segue pelo cadastro comum. O que se perde é o vínculo com o convite, não o acesso.

Por que: com o auto-cadastro aberto (RN10), barrar quem chegou por um link vencido seria pior que a ausência do link — a mesma pessoa consegue se cadastrar apagando o ?token= da URL. Uma parede que se contorna assim só ensina a contorná-la.

  • RN13. O convite é vinculado, não exigido. Concluído o onboarding com invite_token válido, o convite vira ACCEPTED e recebe tenant_id — é isso que dá ao admin a rastreabilidade de quem convidou quem. Sem token, nasce uma garantidora sem convite associado, como hoje.

  • RN14. Erros previstos. INVITE_NOT_FOUND (404), INVITE_EXPIRED (410), INVITE_ALREADY_USED (410), INVITE_REVOKED (410) — todos apenas no GET /tenant-invites/validate, para a tela saber o que dizer. EMAIL_TAKEN (409) na emissão. Nenhum deles sai do /auth/register, que não ganha erro novo (RN10).

5. Impacto por app

apps/api

Modelo novo — TenantInvite (app/models/tenant_invite.py, tabela tenant_invites):

Campo Tipo Nota
id UUID UUIDMixin
email String(255) e-mail do convidado, indexado
contact_name String(255) nullable rótulo para identificar o convite na lista
token String(64) UUID4, unique, indexado — é o que vai na URL
status enum TenantInviteStatus PENDING · ACCEPTED · REVOKED (RN5)
expires_at timestamptz emissão + INVITE_TOKEN_EXPIRE_HOURS
accepted_at timestamptz nullable
revoked_at timestamptz nullable
invited_by_user_id FK users.id quem emitiu, ondelete=RESTRICT
tenant_id FK tenants.id nullable preenchido no aceite — liga convite ↔ garantidora
created_at / updated_at TimestampMixin

Por que não reaproveitar VerificationCode. Ele tem user_id NOT NULL com FK para users, e o convite de garantidora não tem usuário nem tenant no momento da emissão. Reaproveitá-lo exigiria tornar user_id nullable, enfraquecendo o modelo para os três tipos que hoje dependem dele (EMAIL_VERIFICATION, USER_INVITE, PASSWORD_RESET). Tabela própria custa uma migration e mantém as duas invariantes.

Sem TenantMixin: o convite é da plataforma, não de um tenant — ele existe justamente antes de haver tenant. É a segunda entidade nessa condição, ao lado de Tenant.

Endpoints — router novo app/api/v1/routers/tenant_invites.py, prefixo /tenant-invites, tag Convites de Garantidora. Os três primeiros exigem ADMIN_GLOBAL; os dois últimos são públicos (quem os usa ainda não tem conta):

Método Path Permissão O quê
POST /tenant-invites ADMIN_GLOBAL Emite (RN2, RN7, RN8)
GET /tenant-invites ADMIN_GLOBAL Lista, com filtro por status
POST /tenant-invites/{id}/revoke ADMIN_GLOBAL Revoga (RN6)
GET /tenant-invites/validate público Valida ?token= antes de exibir o formulário
POST /auth/register público, inalterado Ganha só invite_token opcional (RN10)

Regras no service (tenant_invite_service.py): emissão com revogação do pendente anterior (RN7), validação de e-mail livre (RN8), e o consumo do token dentro do fluxo de registro. O accept não ganha endpoint próprio: o convidado percorre o caminho que já existe, agora com token e sem atalho na verificação (RN9):

link do WhatsApp → /register?token=…  →  verify-email (código)  →  /onboarding  →  Tenant
     POST /auth/register (invite_token opcional)  POST /auth/verify-email   POST /onboarding/tenant

É o mesmo fluxo de hoje, com um parâmetro opcional a mais no primeiro passo. Sem token, tudo corre como sempre correu. O convite vira ACCEPTED e recebe tenant_id no último passo, quando o tenant é criado.

Ponto de atenção na implementação: o convite só pode ser marcado ACCEPTED no create_tenant, não no register. Se marcasse no registro, alguém que criasse a conta e abandonasse o onboarding queimaria o convite sem garantidora nenhuma — e precisaria de um convite novo para terminar. Entre register e create_tenant o token fica reservado (used_by_user_id preenchido, status ainda PENDING).

Auditoria: emissão, revogação e aceite entram no AuditLog com entity_type="tenant_invite".

Testes — tests/test_tenant_invites.py: emissão feliz; EMAIL_TAKEN; substituição do pendente (RN7); revogação; token expirado, revogado e já usado; /auth/register sem token recusado (RN10/RN11); fluxo completo convite → registro → tenant → convite ACCEPTED com tenant_id; e que ADMIN_TENANT recebe 403 na emissão (RN1).

Documentação: docstrings de módulo/classe/método no modelo, service e repo; summary/description/response_description em todo decorator; entrada nova em TAGS_METADATA (main.py); e o fluxo principal do DESCRIPTION deixa de começar por "Cadastrar Tenant" para começar pelo convite.

apps/web

Admin — página nova Convites (admin/pages/invites/):

  • Item no AdminSider.tsx, seção PLATAFORMA, entre Garantidoras e Usuários (ordem do processo: convido → vira garantidora). Ícone envelope-simple (Phosphor, já disponível pelo icon/map.ts).
  • Rota /admin/invites em App.tsx.
  • Lista com e-mail, nome de contato, status, emitido em, expira em, e quem emitiu. Filtro por status. Segue o padrão de lista do design system (FilterBar + DataGrid), como a de Garantidoras.
  • Botão "Convidar garantidora" → diálogo com e-mail e nome de contato.
  • Ao emitir, o diálogo mostra o link pronto com botão "Copiar link" — é o artefato que vai para o WhatsApp. Copiar também disponível na linha da lista, enquanto PENDING.
  • "Revogar" por linha, com confirmação (ação destrutiva).

Login — remover o bloco "Ainda não tem a Garantora?" com o CTA login-register-cta (pages/login/index.tsx). O painel lateral de marketing continua.

Rota /register — permanece em App.tsx, e a pasta pages/register/ não é reescrita: o formulário atual continua sendo o formulário. O que entra é a leitura do ?token= (RN11).

Com token válido: chama GET /tenant-invites/validate, preenche o e-mail e o trava, e mostra uma faixa discreta identificando o convite ("Convite para carlos@…"). Ao enviar, repassa invite_token; daí em diante é o fluxo de sempre — verificação de e-mail (RN9) e onboarding.

Com token inválido: faixa de aviso com o motivo (expirado, revogado, já usado, inexistente) e o formulário normal logo abaixo, utilizável (RN12). Sem token: a tela é exatamente a de hoje.

Como o formulário é o mesmo nos três casos, o diff do pages/register/ é pequeno — o grosso do trabalho do web está na página de Convites do admin.

O e-mail está no caminho crítico (RN9), e a produção ainda tem SMTP placeholder — a própria spec 0028 registra isso em §9. Enquanto o SMTP real não for configurado em srv01, o convidado conclui o passo 1 e trava no passo 2, sem receber o código. Não é defeito desta spec, mas é bloqueio para usá-la em produção: vale conferir o SMTP antes de emitir o primeiro convite real. No local e no dev-remote, o Mailpit resolve.

data-testid: invites-new-button, invite-create-dialog, invite-email-input, invite-contact-name-input, invite-create-confirm, invite-create-cancel, invite-copy-link-{id}, invite-revoke-{id}, invite-revoke-confirm, invites-status-filter, invites-row-{id}, invites-error, register-invite-form, register-invite-name-input, register-invite-password-input, register-invite-submit, register-invite-banner, register-invite-invalid.

Prefixo register-invite-, e não accept-invite-: /accept-invite já é a tela do convite de usuário da equipe, que continua existindo (§3). Dois conjuntos de testids parecidos para telas diferentes é armadilha para quem escrever o próximo spec.

Responsividade & PWA: a lista de Convites vira cartões no mobile (RecordCard), como manda o padrão — não <Table>, para não repetir a dívida da tela de Garantias. O diálogo de emissão é coluna única em 375px. O botão "Copiar link" precisa de atenção no mobile: navigator.clipboard exige contexto seguro (há HTTPS nos três ambientes) e pode ser negado — havendo falha, o link aparece em campo selecionável, para não deixar o admin sem saída. O /register é público e precisa funcionar bem no celular, que é onde o link do WhatsApp abre — inclusive a faixa do convite, que não pode empurrar o formulário para fora da primeira dobra em 375px. PWA: sem asset novo e nenhuma rota removida, então atalho salvo e cache do service worker seguem válidos.

apps/e2e

Specs — tenant-invite.spec.ts:

  1. ADMIN_GLOBAL emite convite, o link aparece e é copiável;
  2. o link leva à tela de aceite com o e-mail preenchido e travado;
  3. o cadastro conclui e a garantidora aparece em /admin/tenants;
  4. reabrir o mesmo link mostra "já utilizado" (RN4);
  5. convite revogado não deixa cadastrar (RN6);
  6. a tela de login não tem login-register-cta, e /app/register sem token abre o formulário normal e permite cadastrar (RN10, RN11);
  7. sem verificar o e-mail, o onboarding recusa criar a garantidora (RN9).

Seeds — sem dado novo; o convite é criado pelo próprio teste via API. Atenção ao tenant de e2e: o fluxo cria garantidora nova a cada execução, então o CNPJ e o e-mail precisam ser gerados únicos por execução — a mesma armadilha que o fix 0006 registrou (CNPJ fixo passou na primeira execução e deu 409 nas quatro seguintes).

Verificação de e-mail no teste: reaproveitar fixtures/mailpit (clearMailbox, waitForEmail, extractLink), que o password-reset.spec.ts já usa — é como o código de verificação chega ao teste sem mock.

6. Contrato de API

Emitir (ADMIN_GLOBAL)

POST /api/v1/tenant-invites
Authorization: Bearer <token>

{ "email": "carlos@novagarantidora.com.br", "contact_name": "Carlos Silva" }
{
  "id": "uuid",
  "email": "carlos@novagarantidora.com.br",
  "contact_name": "Carlos Silva",
  "status": "PENDING",
  "invite_url": "https://app.garantora.com.br/register?token=8f3a…",
  "expires_at": "2026-08-12T14:03:00Z",
  "replaced_previous": false
}

invite_url é montada com APP_BASE_URL, que difere por ambiente — /app no local e no dev-remote, raiz na produção (VITE_BASE_PATH, spec 0028). Montar no servidor evita que o front tenha de adivinhar a base.

Validar (público)

GET /api/v1/tenant-invites/validate?token=8f3a…
{ "valid": true, "email": "carlos@novagarantidora.com.br", "contact_name": "Carlos Silva" }

Inválido → 404 INVITE_NOT_FOUND ou 410 com INVITE_EXPIRED / INVITE_REVOKED / INVITE_ALREADY_USED.

Registrar (público, inalterado — invite_token é opcional)

POST /api/v1/auth/register

{ "name": "Carlos Silva", "email": "carlos@…", "password": "…", "invite_token": "8f3a…" }

Sem invite_token a chamada é idêntica à de hoje e o comportamento não muda (RN10): nenhum erro novo, nenhum campo obrigatório novo. Com invite_token válido, o cadastro fica vinculado ao convite; o email precisa bater com o do convite (divergindo → 422 EMAIL_MISMATCH). Com token inválido, o registro prossegue e o vínculo é ignorado (RN12).

Revogar e listar seguem o padrão do resto do admin.

7. Critérios de aceite

  • [x] CA1. Dado ADMIN_GLOBAL em /admin/invites, quando emite um convite para um e-mail livre, então o convite aparece como PENDING e o diálogo mostra um link copiável.
  • [x] CA2. Dado esse link aberto em outro navegador (sem sessão), então /register mostra o e-mail do convite preenchido e não editável, e pede nome e senha.
  • [x] CA3. Dado o cadastro concluído — incluindo a verificação do e-mail com o código recebido (RN9) —, então existe uma garantidora nova em /admin/tenants, e o convite passa a ACCEPTED com tenant_id preenchido.
  • [ ] CA3b. Dado o convidado que definiu a senha mas não verificou o e-mail, então ele não consegue criar a garantidora: /onboarding/tenant recusa com EMAIL_NOT_VERIFIED. O convite segue PENDING.
  • [x] CA4. Dado um link já utilizado, quando reaberto, então a tela diz "convite já utilizado" — não um erro genérico — e o formulário de cadastro continua utilizável logo abaixo (RN12).
  • [x] CA5. Dado um convite PENDING revogado pelo admin, quando o link é aberto, então diz "convite revogado", e o cadastro sem vínculo segue possível.
  • [x] CA6. Dado um convite com expires_at no passado, então a tela diz "convite expirado", e o convite consta como expirado na lista sem que nenhuma rotina o tenha alterado (RN5).
  • [x] CA7. Dada a tela de login, então não existe o CTA "Criar minha conta"; e /app/register sem token abre o formulário de cadastro igual ao de hoje (RN11).
  • [x] CA8. Dado POST /api/v1/auth/register sem invite_token, então o comportamento é idêntico ao de antes desta spec — cadastro criado, nenhum erro novo (RN10). É o critério que protege a suíte existente.
  • [x] CA9. Dado um e-mail que já é de um usuário da plataforma, quando o admin tenta convidar, então recebe 409 EMAIL_TAKEN e nenhum convite é criado.
  • [x] CA10. Dado um e-mail com convite PENDING, quando o admin emite outro, então o anterior fica REVOKED, existe um único convite válido, e a resposta traz replaced_previous: true (RN7).
  • [x] CA11. Dado um ADMIN_TENANT autenticado, quando chama POST /tenant-invites, então recebe 403; e o item Convites não aparece no menu dele.
  • [x] CA12. Dado que o convidado cria a conta mas abandona antes de concluir o onboarding, quando volta pelo mesmo link, então ainda consegue terminar — o convite não foi queimado no registro.
  • [ ] CA12b. Dado um cadastro feito sem convite, então a garantidora nasce normalmente e nenhum convite é vinculado a ela (RN13).
  • [x] CA13. Em 375px e em desktop: a lista de Convites não rola na horizontal (scrollWidth - clientWidth == 0), vira cartões no mobile, e a tela de aceite é utilizável no celular.
  • [x] CA14. make test passa com todos os testes anteriores mais os novos; tsc limpo; ruff sem apontamento novo nos arquivos tocados.

8. Plano de implementação

Ordem obrigatória: api → web → e2e.

  • [x] apps/api — modelo TenantInvite + enum TenantInviteStatus
  • [x] apps/api — migration Alembic (tabela + índices + unique em token), com downgrade
  • [x] apps/api — schemas, repository, tenant_invite_service
  • [x] apps/api — router /tenant-invites (3 privados + validate público)
  • [x] apps/api — auth/register ganha invite_token opcional (RN10) — sem quebrar quem chama sem ele
  • [x] apps/api — create_tenant marca o convite ACCEPTED e grava tenant_id
  • [x] apps/api — auditoria dos três eventos
  • [x] apps/api — docstrings + Swagger + TAGS_METADATA
  • [x] apps/api — tests/test_tenant_invites.py
  • [x] apps/api — make test verde antes de tocar o web
  • [x] apps/web — página /admin/invites (lista, emitir, copiar, revogar) + AdminSider
  • [x] apps/web — /register lê ?token=: e-mail travado quando válido, faixa de aviso quando inválido, tela de hoje quando ausente
  • [x] apps/web — remover apenas o CTA do login (rota e formulário permanecem)
  • [x] apps/web — data-testid em tudo; cartões no mobile
  • [x] apps/e2e — tenant-invite.spec.ts (8 cenários, com Mailpit) — executado, 8/8
  • [x] make test e make lint (comparar antes/depois, não o total)
  • [x] /devremote-rebuild + screenshot nos dois viewports (CA13)

9. Decisões em aberto

Nenhuma. As duas que havia foram decididas pelo usuário em 2026-08-09:

  • [x] Verificação de e-mail: opção (b). O passo de verify-email fica depois do aceite (RN9). Descartado marcar como verificado no aceite — o link vai por WhatsApp e não prova o endereço, e um erro de digitação do admin produziria uma conta sem caminho de volta. Descartado também verificar só antes da primeira ação sensível: adiaria o problema para o momento em que ele custa mais caro.
  • [x] Rota /register: fica, só o CTA sai (RN11). Descartado removê-la — quem tiver o link salvo cairia num 404 —, e descartado criar /convite em paralelo, que daria dois endereços para a mesma tela.
  • [x] POST /auth/register continua aberto e sem convite (RN10). Uma versão anterior desta spec o fazia exigir invite_token, apoiada numa suposição declarada sobre o que "manter a rota" queria dizer. A suposição estava errada, e o usuário a corrigiu em 2026-08-09: o endpoint fica como está, e invite_token entra apenas como parâmetro opcional.

    O que a correção muda no alcance da spec: ela deixa de ser um portão e passa a ser processo + rastreabilidade. Quem souber o endereço continua se cadastrando sozinho — está dito na §2 e na §3, para ninguém ler o título e supor bloqueio. Efeito colateral bem-vindo: a suíte existente, que cria conta por /auth/register, não precisa de ajuste (CA8).

10. Registro

Implementada em 2026-08-09.

O que a spec não previa

Achado O que aconteceu
O sider do admin quebrava o mobile — em todas as telas O AdminSider usava Drawer variant="permanent", reservando 232px de uma tela de 375px. Sobravam ~55px úteis e as quatro telas do admin rolavam na horizontal (medido: overflow=21 em Painel, Garantidoras, Usuários e Convites). É dívida anterior a esta spec, mas a página nova a tornou intolerável: uma lista de convites em 55px não é usável. Corrigido: gaveta temporary no mobile, fechada ao abrir a tela, com botão de menu no AppBar (admin-menu-toggle). Escopo adjacente assumido de propósito — entregar a página nova quebrada seria pior que tocar o layout.
datetime naive × aware is_expired comparava datetime.now(timezone.utc) com expires_at, e o SQLite dos testes não guarda timezone: a coluna é DateTime(timezone=True), o PostgreSQL devolve aware e o SQLite devolve naive — TypeError em 8 testes. O VerificationCode nunca esbarrou nisso porque compara datas no SQL, não em Python. A property passou a normalizar para UTC antes de comparar.
Faltava o ícone copy O registry de ícones é curado (76 entradas) e não tinha o conceito de copiar — que é a ação central da tela. Adicionado copy (Phosphor), como o /journeys fez com os que precisou.
E-mail longo estourava a faixa Como palavra única sem quebra, o e-mail do convite empurrava a página em 5px no mobile. wordBreak: break-word na faixa.

Divergências de implementação

  • ConfirmDialog e PrimaryAction têm contratos diferentes do que supus (description/onClose, e testId em vez de data-testid). Ajustado ao que existe, em vez de estender os componentes compartilhados.
  • StatusTone não tem neutral — o vocabulário é default | info | success | warning | error. "Expirado" usa default.
  • O invite_url é reescrito para a base sob teste no e2e. A API monta com APP_BASE_URL, que pode apontar para outro ambiente; o spec extrai o token e remonta a URL no alvo.

Verificação

  • API: 244 testes (230 do baseline + 14 novos). Execução final: 243 passam, 1 falha — o flake conhecido de test_partner_agency_status_filter.py, em arquivo que esta spec não toca.

    Numa das execuções apareceu um ERROR de setup em test_email_ja_cadastrado_barra_emissao que não reproduziu nas duas seguintes. A execução afetada rodava em paralelo com o script de screenshot, que batia na mesma API — contenção provável. Fica registrado por honestidade; se reaparecer, é sintoma, não ruído.

  • Migration: aplicada, revertida e reaplicada no PostgreSQL do dev-remote. O downgrade derruba tabela e enum — sem isso o upgrade seguinte falharia com "type tenant_invite_status already exists".
  • Ruff: 251 contra 249 do baseline — +2, e os dois são os F821 de forward-ref Mapped["User"]/Mapped["Tenant"] do model novo: o mesmo falso positivo que todos os models do projeto têm, documentado no CLAUDE.md.
  • tsc: limpo.
  • e2e: executada a pedido do usuário — 159 passam, 0 falham (7,9 min), sendo 8 do tenant-invite.spec.ts. Os 2 skipped são os semeadores em massa, já marcados test.skip no repositório.

A suíte e2e foi executada, a pedido do usuário

O tenant-invite.spec.ts rodou contra o dev-remote reconstruído. Duas correções saíram daí, ambas de teste, não de produto:

  • O CA5 vivia no describe do admin, com sessão ativa. goto('/register') autenticado redireciona para o painel, e o teste media o dashboard. É a pegadinha inversa da que o CLAUDE.md do e2e documenta (lá, goto em rota protegida perde a sessão; aqui, goto em rota pública com sessão sai da tela). Movido para o describe sem sessão.
  • O CA3 prometia mais do que entregava. Chamava-se "a garantidora nasce" e parava no verify-email. Foi estendido até o fim: extrai o OTP do Mailpit, verifica o e-mail, cria a garantidora e confere que o convite ficou ACCEPTED com o tenant_id da garantidora criada, e que o link passa a responder 410.

Isso fecha a maior lacuna que esta entrega tinha: o link_tenant é o único ponto que nenhum teste de API alcança sozinho — lá o convite só chega a ser reservado. Agora o consumo é exercido de verdade.

O payload do onboarding exige phone_mobile, o que só apareceu na execução. A mensagem de erro que eu havia posto no expect mostrou o campo exato — foi o que tornou a correção imediata em vez de investigativa.

Erros meus no caminho

  • Rodei a suíte dentro do container e disparei o rebuild em paralelo. O rebuild recriou o container no meio do pytest e a execução morreu sem resultado — 4 minutos perdidos. Suíte e rebuild disputam o mesmo container; não podem ser paralelos.
  • Um teste de papel que não testava papel. test_admin_tenant_nao_emite mandava um token inválido e conferia 401 — o que prova apenas que a rota exige autenticação. Passaria mesmo se qualquer usuário logado pudesse emitir convite. Reescrito para autenticar um usuário real sem ADMIN_GLOBAL e exigir 403.
  • Comparei coluna UUID com string nos testes (TenantInvite.id == invite["id"]), o mesmo erro que cometi na spec 0027 quatro horas antes.