Refactor: Memberships

Status: planejamento — nenhum código escrito ainda Criado em: 2026-04-17 Bloqueado por: decisões pendentes (§4) Relação com RBAC Fase 2: este refactor substitui o escopo antigo da Fase 2 (ver memory/project_rbac_refactor.md). Metade do trabalho de RBAC só faz sentido em cima desse novo modelo. Tamanho estimado: 1-2 sprints bem feito; alto blast radius (cruza backend + frontend + JWT + todo o scoping multi-tenant).

1. Por que estamos fazendo isso

1.1 Como o modelo está hoje

users
┌─────────────────────────────────────────────────────────────┐
│ id │ name │ tenant_id │ unit_id │ is_tenant_admin │ role... │
└─────────────────────────────────────────────────────────────┘
O usuário tem FK única para tenant_id + unit_id direto na tabela users. O scoping multi-tenant é feito pelos traits BelongsToTenant e BelongsToTenantAndUnit, que leem esses campos. Funciona bem para funcionários fixos (recepcionista contratado da Clínica X, Unidade Y).

1.2 Onde quebra — 3 cenários reais do mercado

Cenário A — Radiologista terceirizado multi-clínica Dr. João lauda para Clínica Alfa (3 unidades) E Clínica Beta (2 unidades). → Hoje precisaria de 5 usuários diferentes (5 emails, 5 senhas). Cenário B — Médico em múltiplas unidades da mesma clínica Dra. Maria atende nas unidades Centro e Zona Sul da Clínica Alfa. → Hoje precisa de 2 usuários ou fica presa a uma só. Cenário C — Médico “coringa” da clínica Dr. Pedro pode laudar em qualquer unidade da Clínica Alfa (não só algumas específicas). → Hoje não existe esse “wildcard” — precisa escolher uma unit fixa.

1.3 Por que isso é bloqueante para o produto

Médico terceirizado é o padrão no mercado brasileiro de radiologia. Sem suportar esse caso, o produto não fecha vendas reais.

2. Solução proposta: memberships

2.1 Conceito em uma frase

Membership = um “crachá” que representa o vínculo de um usuário com uma clínica (opcionalmente, uma unidade específica). Um usuário pode ter N crachás. Quando ele faz login, escolhe qual crachá quer usar (ou o sistema escolhe automaticamente se for só um). Esse crachá define o contexto ativo — o que ele vê e pode fazer naquela sessão.

2.2 Comparação antes / depois

Antes:
users
┌────┬──────┬───────────┬─────────┬────────┐
│ id │ name │ tenant_id │ unit_id │ role   │
├────┼──────┼───────────┼─────────┼────────┤
│ 1  │ João │ alfa      │ centro  │ doctor │  ← um único vínculo
└────┴──────┴───────────┴─────────┴────────┘
Depois:
users (só dados pessoais)
┌────┬──────┬───────────┬─────┬─────────────┐
│ id │ name │ email     │ crm │ signature   │
├────┼──────┼───────────┼─────┼─────────────┤
│ 1  │ João │ joao@...  │ ... │ ...         │
└────┴──────┴───────────┴─────┴─────────────┘

memberships (vínculos — múltiplos por user)
┌────┬─────────┬───────────┬──────────┬───────────────┬───────────┐
│ id │ user_id │ tenant_id │ unit_id  │ role          │ is_active │
├────┼─────────┼───────────┼──────────┼───────────────┼───────────┤
│ 1  │ 1       │ alfa      │ centro   │ doctor        │ true      │
│ 2  │ 1       │ alfa      │ zona_sul │ doctor        │ true      │
│ 3  │ 1       │ beta      │ NULL     │ doctor        │ true      │ ← wildcard
│ 4  │ 1       │ gamma     │ vila_ok  │ tenant_admin  │ true      │ ← admin aqui
└────┴─────────┴───────────┴──────────┴───────────────┴───────────┘

2.3 Schema proposto (sujeito a decisões pendentes)

CREATE TABLE memberships (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    unit_id UUID NULL REFERENCES units(id) ON DELETE CASCADE,  -- NULL = wildcard (todas as units)
    role VARCHAR(32) NOT NULL,  -- 'doctor' | 'tenant_admin' | 'receptionist' | ...
    is_active BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ,
    updated_at TIMESTAMPTZ,

    -- Ver decisão §4.4 sobre UNIQUE
    UNIQUE (user_id, tenant_id, unit_id)
);

CREATE INDEX idx_memberships_user ON memberships(user_id) WHERE is_active = true;
CREATE INDEX idx_memberships_tenant_unit ON memberships(tenant_id, unit_id) WHERE is_active = true;

3. O que NÃO muda (fora de escopo)

Importante delimitar o que este refactor não toca:
ItemEstadoPor quê
modality.unit_idContinua FK únicaAparelho DICOM fica fisicamente em uma unidade — não faz sentido multi-unit
study.tenant_id / study.unit_idContinua fixoEstudo nasceu em uma unidade específica; dados históricos imutáveis
patient.tenant_id / patient.unit_idContinua fixoIdem
report_template.tenant_id / .unit_idContinua fixoTemplate pertence à clínica/unidade onde foi criado
is_super_admin em usersProvavelmente fica (ver §4.3)Super admin é transversal à plataforma, não por vínculo
Fluxo de ingestão DICOM (Orthanc → webhook → worker)Não mudaResolve tenant/unit via AETitle da modality, não via user
OHIF viewer CORS proxy e tokenLógica de validação muda (ver §6.4), mas arquitetura não

4. Decisões pendentes — BLOQUEIAM início do código

Cada item abaixo precisa ser decidido antes de escrever qualquer linha.

4.1 Wildcard via unit_id NULL?

A favor: resolve o Cenário C elegantemente. Uma única linha na tabela, sem precisar criar N rows (um por unit) que ficam desatualizadas quando nova unit é criada. Contra: complica o scoping SQL:
WHERE tenant_id = :active_tenant
  AND (unit_id = :active_unit OR active_unit IS NULL)
  AND (membership.unit_id = :active_unit OR membership.unit_id IS NULL)
Fácil de errar. Quando um user tem wildcard membership e escolhe “trabalhar na unidade X”, ele está usando essa unit_id específica no contexto, mas o membership é wildcard. Alternativa: em vez de NULL, ter uma tabela membership_units (many-to-many) listando explicitamente cada unit. Mas aí wildcard vira “N rows” que precisam ser re-sincronizadas. Decisão pendente.

4.2 Role mora no membership ou no user?

Proposta: role mora no membership (não no user). Por quê: João pode ser doctor na Clínica Alfa e tenant_admin na Clínica Gamma (a própria dele). Com flag global no user, não dá pra expressar. Consequência: remover users.is_tenant_admin. Adicionar memberships.role. O middleware tenant_admin passa a checar active_membership.role === 'tenant_admin'. Decisão pendente, mas provável: role no membership.

4.3 Onde fica is_super_admin?

Proposta: continua em users. Super admin não precisa de membership — acessa tudo transversalmente. Decisão pendente, mas provável: continuar em users.

4.4 UNIQUE (user_id, tenant_id, unit_id)?

Se sim: um user não pode ter 2 memberships para a mesma combinação tenant+unit. Simplifica tudo. Se não: permite coisas como “John é doctor nessa unit e tenant_admin nessa unit” (hierarquia de roles). Mais flexível, mais complexo. Proposta: UNIQUE sim. Se precisar de roles múltiplas, juntar em roles separadas via bitmap ou lista. Decisão pendente.

4.5 Como guardar o “contexto ativo”?

Três opções:
OpçãoComo funcionaPrósContras
A — JWTGravar active_membership_id (ou tenant+unit) no JWT no login / trocaStateless, sem round-tripPrecisa reemitir JWT em toda troca de contexto
B — Server-side session (Redis)Guardar contexto em Redis session:{user_id}Troca de contexto barataDeixa a API menos stateless
C — Cookie separadoCookie pacs_active_context no navegador, backend lê via middlewareSimplesVulnerável a manipulação se não assinado
Proposta: A (JWT) — consistente com arquitetura atual (já usamos JWT em cookie httpOnly). Troca de contexto vira um POST /api/context/switch que reemite token. Decisão pendente.

4.6 JWT carrega memberships inteiras ou só contexto ativo?

  • Só contexto ativo: JWT fica pequeno, mas a API /me precisa retornar a lista completa de memberships em todo login
  • Lista completa: JWT fica maior, mas o frontend tem tudo em memória
Proposta: só contexto ativo no JWT; lista de memberships vai em /api/me. Troca de contexto: frontend chama POST /api/context/switch {membership_id} → recebe JWT novo. Decisão pendente.

4.7 “Herança” de permissões — tenant_admin tem acesso a todas as units?

Se João tem membership tenant_admin na Clínica Alfa (sem especificar unit):
  • Ele vê todos os estudos de todas as units da Alfa?
  • Ele pode criar laudos em qualquer unit?
Proposta: tenant_admin de um tenant tem acesso implícito a todas as units daquele tenant, sem precisar de N memberships. Efetivamente, tenant_admin implica wildcard para o scoping de leitura. Mas médico regular: precisa de membership explícito em cada unit (ou wildcard explícito). Decisão pendente.

4.8 Forçar logout na troca de contexto?

  • Sim (seguro): cada troca invalida JWT anterior, força reemissão. Perde estado local do frontend.
  • Não (UX): troca transparente, estado preservado.
Proposta: não forçar logout. Apenas reemitir JWT e recarregar as páginas que dependem de contexto (router.refresh). Mantém estado de formulários não salvos? A decidir. Decisão pendente.

4.9 Super admin tem pseudo-membership?

Quando super admin acessa uma clínica para operar (ex.: criar user para ela), ele precisa de um contexto ativo? Ou opera num modo “god mode” transversal? Proposta: super admin em modo transversal por padrão (vê tudo). Se precisar impersonar uma clínica específica (ex.: para testar), usa um endpoint POST /admin/impersonate/{tenant_id} que seta contexto. Decisão pendente.

4.10 Médico solicitante — CRM único global ou profile compartilhado?

Quando o mesmo médico solicitante aparece em várias clínicas, duas arquiteturas possíveis. Discussão completa em §12.6. Proposta: Solução A (CRM único global) no MVP; considerar migração para Solução B (profile compartilhado) se LGPD exigir consentimento explícito do médico antes de outra clínica vinculá-lo. Decisão pendente.

5. Mapa de impacto — Backend

5.1 Tabelas (schema)

TabelaAção
usersRemover (ou manter read-only como “primário”): tenant_id, unit_id, is_tenant_admin. Manter: is_super_admin. Adicionar UNIQUE em crm se adotada Solução A de §12.6.
membershipsNova. Ver schema em §2.3. Roles suportadas: doctor, tenant_admin, referring_physician, receptionist.
studiesAdicionar colunas: requesting_physician_name, requesting_physician_crm, requesting_physician_user_id (FK NULL) — ver §12.10
Todas as demaisSem mudança — já têm tenant_id/unit_id corretos
Migração de dados:
  • Cada user atual → cria 1 membership com tenant_id + unit_id atuais + role derivada de is_tenant_admin
  • Depois de validar, remove colunas antigas do users

5.2 Models Eloquent

ModelMudanças
User- Remove $fillable de tenant/unit
- Adiciona relacionamento hasMany(Membership)
- Adiciona método activeMembership(), currentTenantId(), currentUnitId() lendo contexto
MembershipNovo. Relacionamentos: belongsTo(User), belongsTo(Tenant), belongsTo(Unit)
Tenant, Unit, demaisSem mudança

5.3 Traits de scoping

TraitMudança
BelongsToTenantHoje lê $user->tenant_id. Passa a ler do contexto ativo (via resolver injetado).
BelongsToTenantAndUnitIdem. Trata wildcard (se contexto tem unit_id NULL, não filtra por unit).
Padrão proposto:
// app/Support/TenantContext.php
class TenantContext {
    public function currentTenantId(): ?string { ... }  // lê do JWT active_tenant_id
    public function currentUnitId(): ?string { ... }     // lê do JWT (pode ser null = wildcard)
    public function currentRole(): ?string { ... }
    public function isSuperAdmin(): bool { ... }
}

// Trait passa a injetar:
protected static function booted(): void {
    static::addGlobalScope('tenant', function ($query) {
        $ctx = app(TenantContext::class);
        if ($ctx->isSuperAdmin()) return;  // super admin ignora scope
        $query->where('tenant_id', $ctx->currentTenantId());
        if ($ctx->currentUnitId() !== null) {
            $query->where('unit_id', $ctx->currentUnitId());
        }
    });
}

5.4 Controllers afetados

Todos que hoje dependem de scope continuam funcionando (scope lê contexto novo). Mudanças específicas:
ControllerMudança
AuthController- login: emite JWT com contexto automático se user tem 1 membership; senão sem contexto (força switch)
- refresh: preserva contexto atual
UserController (admin)- show/store/update expõem memberships no response
- Criar user pode aceitar initial_memberships
AdminController (novos endpoints)- POST /admin/users/{id}/memberships
- DELETE /admin/users/{id}/memberships/{mid}
- PATCH /admin/users/{id}/memberships/{mid} (ativa/desativa)
ReportControllerValidar: user tem membership ativo no study.tenant_id + study.unit_id (ou wildcard) antes de criar laudo.
ViewerTokenControllerIdem: validar membership antes de emitir token.
WebhookController / ProcessStudyMetadataNão muda. Resolve tenant/unit via AETitle, não via user.
Worker Python (worker/main.py)Passa a indexar tag DICOM RequestingPhysician; opcionalmente auto-match por CRM — ver §12.5
Todos os que retornam me, $user->tenant, $user->unitSubstituir por activeContext()

5.5 Middlewares

MiddlewareMudança
auth:apiSem mudança
tenant_adminReescrita — checa activeContext->role === 'tenant_admin' (não mais flag no user)
super_adminSem mudança (lê is_super_admin no user)

5.6 JWT

Claims hoje: sub, tenant_id, unit_id, is_super_admin, is_tenant_admin Claims propostos:
{
  "sub": "user_uuid",
  "active_tenant_id": "uuid | null",
  "active_unit_id": "uuid | null",    // null = wildcard
  "active_role": "doctor | tenant_admin | null",
  "is_super_admin": false,
  "exp": ...
}
Se super admin: active_* podem ser null; middleware trata isso. Se user comum sem contexto escolhido: active_* null → frontend força seleção.

5.7 Endpoints novos

EndpointO que faz
GET /api/me (modifica)Retorna user + lista de memberships ativas + contexto atual
POST /api/context/switchBody: { membership_id }. Valida que membership pertence ao user e está ativo. Reemite JWT com contexto novo. Invalida JWT anterior.
GET /api/memberships (opcional)Lista memberships do próprio usuário (redundante com /me, mas útil para recarregar sem refetch de tudo)
POST /admin/users/{id}/membershipsSuper admin cria membership para user. Body: { tenant_id, unit_id?, role }
PATCH /admin/users/{id}/memberships/{mid}Altera role ou ativa/desativa
DELETE /admin/users/{id}/memberships/{mid}Remove
GET /admin/users/{id}/membershipsLista memberships de um user (super admin)
GET /admin/users/search?crm=X&name=YBusca global por CRM/nome (dados reduzidos) — usado pelo admin ao cadastrar médico solicitante (§12.7)
PATCH /studies/{id}/requesting-physicianAdmin vincula manualmente um user como solicitante de um estudo (§12.11)

6. Mapa de impacto — Frontend

6.1 Auth store (Zustand)

Hoje: store/auth-store.ts guarda user com tenant_id/unit_id/is_tenant_admin. Depois:
type AuthStore = {
  user: User | null;
  memberships: Membership[];       // todas as ativas do usuário
  activeMembership: Membership | null;  // o contexto atual
  // ...
}

6.2 Fluxo pós-login

  1. User faz login com email/senha
  2. Backend responde com JWT + user + lista de memberships
  3. Se 0 memberships e não é super admin → erro (“usuário sem vínculos ativos”)
  4. Se 1 membership → backend já emite JWT com contexto setado; frontend entra direto
  5. Se >1 membership → JWT sem contexto; frontend mostra tela “Escolha sua clínica” antes de qualquer outra rota
  6. User escolhe → POST /api/context/switch → novo JWT → redirect para dashboard

6.3 Seletor de contexto (componente novo)

Combobox no header (ou modal) com:
Trabalhando em:
Clínica Alfa — Unidade Centro    [trocar]
Ao clicar em “trocar”:
  • Modal com lista de memberships
  • Escolher → POST /api/context/switch → refresh da página / invalida cache React Query

6.4 Rotas protegidas

Grupo de rotasChecagem antigaChecagem nova
(super-admin)/*user.is_super_adminMesma
(tenant-admin)/*user.is_tenant_adminactiveMembership.role === 'tenant_admin'
(app)/* (médico)qualquer user autenticadoqualquer user com activeMembership ou super_admin

6.5 Componentes que leem tenant/unit do user

Rodar subagent para mapear todos os usos de user.tenant_id, user.unit_id, user.is_tenant_admin. Substituir por activeMembership.*.

6.6 UI condicional por role (inclui referring_physician)

Componentes que precisam checar activeMembership.role para esconder/mostrar ações:
  • Detalhe do estudo: botões “Criar laudo”, “Aplicar preset”, “Assinar” só aparecem para role doctor (ou tenant_admin com override)
  • Página “Meus exames solicitados” (nova): visível só para role referring_physician
  • Sidebar: links de “Laudos”, “Templates”, “Presets” ocultos para referring_physician
Ver detalhes em §12.12.

6.6 Persistência do contexto

O contexto ativo vive no JWT (no cookie httpOnly). Não precisa duplicar em localStorage — se o cookie for perdido, user refaz login e pode ser forçado a escolher de novo.

7. Validações novas (regras de negócio)

Hoje o scope automaticamente impede acesso a recursos de outros tenants. Mas precisamos de validações novas onde o user faz uma ação cross-entity:

7.1 Criar/editar laudo

  • Antes de salvar: user.memberships contém um ativo em study.tenant_id + (study.unit_id ou wildcard)?
  • Se não: 403

7.2 Gerar token OHIF

  • Antes de emitir: mesma validação acima.
  • Se válido: token carrega user_id e contexto do study (não do user) — o nginx valida se o user pode ver aquele study.

7.3 Trocar de contexto

  • Membership existe, pertence ao user, está ativo?
  • Se não: 422

7.4 Super admin criar user

  • Pode criar user sem membership inicial (super admin só).
  • Tenant admin (depois do refactor, se ganhar permissão de criar users da própria clínica) só pode criar com memberships dentro do seu tenant.

8. Plano de fases

Fase 0 — Decisões e mapeamento (AGORA)

  • Decidir todos os itens de §4 em discussão com stakeholder
  • Rodar subagents Explore (ver §9) para mapa exaustivo de todos os arquivos afetados
  • Atualizar este documento com mapa final

Fase 1 — Schema + migração de dados (não-disruptiva)

  • Migration: cria tabela memberships
  • Seeder/command: para cada user atual, cria 1 membership com dados atuais
  • Não remove colunas antigas ainda (compat durante transição)
  • Testes de integridade (count de users == count de memberships ativos)

Fase 2 — Backend core

  • Model Membership
  • User expõe memberships() relationship
  • TenantContext service lendo JWT
  • Reescreve traits BelongsToTenant / BelongsToTenantAndUnit
  • JWT carrega active_tenant_id/unit_id/role no lugar dos antigos
  • /api/me retorna memberships
  • POST /api/context/switch
  • Middleware tenant_admin reescrito
  • Validações de §7 em controllers afetados
  • Suite de testes atualizada

Fase 3 — Frontend core

  • Auth store atualizado
  • Tela de escolha de contexto pós-login
  • Seletor de contexto no header
  • Substituir todos os usos de user.tenant_id/unit_id/is_tenant_admin
  • Rotas protegidas atualizadas

Fase 4 — Admin CRUD de memberships

  • Endpoints POST/PATCH/DELETE /admin/users/{id}/memberships
  • UI no painel super admin para gerenciar memberships de cada user

Fase 5 — Cleanup

  • Drop users.tenant_id, users.unit_id, users.is_tenant_admin
  • Remover todo código que ainda lê esses campos (deve estar zero após Fase 3)
  • Atualizar docs Mintlify (especialmente admin.md e auth.md)

Fase 6 — Validação em homolog + PR

  • Rodar HOMOLOG_CHECKLIST.md inteiro focado em casos multi-membership
  • Adicionar casos novos ao checklist:
    • Login com user de 1 membership (fluxo automático)
    • Login com user de múltiplos memberships (tela de escolha)
    • Troca de contexto preserva/não preserva estado
    • Médico com wildcard vê todas as units do tenant
    • tenant_admin num tenant, doctor em outro
  • PR develop → main

9. Metodologia de mapeamento (antes de qualquer Edit)

Conforme memory/feedback_refactor_methodology.md e docs/STORAGE_ARCHITECTURE.md §6: Rodar subagents Explore em paralelo, um por área:

9.1 Subagents backend

SubagentMissão
Explore 1 — ScopingListar todos os models que usam BelongsToTenant ou BelongsToTenantAndUnit; todos os where('tenant_id') / where('unit_id') manuais em controllers e services
Explore 2 — User accessListar todos os auth()->user()->tenant_id, $user->unit_id, $user->is_tenant_admin no código backend
Explore 3 — Endpoints com tenant/unitListar todas as rotas que retornam ou aceitam tenant_id / unit_id no body/response
Explore 4 — Middlewares e JWTArquivos de config JWT, guards, middlewares — mapear o que precisa mudar
Explore 5 — TestesTodos os testes que setam tenant_id / unit_id direto no user de teste

9.2 Subagents frontend

SubagentMissão
Explore 1 — Auth/storeTodos os usos do auth-store, user.tenant_id, user.unit_id, user.is_tenant_admin
Explore 2 — Route guardsRotas com grupos (super-admin), (tenant-admin) e checagens de role
Explore 3 — API consumersHooks React Query que recebem/enviam tenant/unit; lib/api/client.ts e derivados
Explore 4 — Componentes UIComponentes que mostram nome da clínica/unidade (sidebar, header, avatar etc.)

9.3 Output esperado

Cada subagent retorna lista exaustiva de arquivos com file:line e snippet. Consolida tudo num apêndice deste documento (§12) antes de começar Edit.

10. Discussões abertas (design decisions ainda não concluídas)

Coisas que não são decisões de implementação, mas merecem conversa antes de fechar:
  1. Nome “membership”? Alternativas: user_access, user_tenant_link, affiliation, vinculo. Membership é padrão em SaaS (Stripe, GitHub, Auth0 usam).
  2. Tenant admin pode gerenciar memberships da própria clínica? Ou isso fica só com super admin?
    • Se sim: precisa endpoint POST /tenant/memberships (criar user convidado + membership na clínica do admin).
    • Se não: toda criação de membership vai via super admin (operação nossa).
    • Ver memory/project_rbac_refactor.md — decisão anterior foi super admin cuida de tudo. Mantém?
  3. Convite por email? Quando tenant admin (ou super admin) adicionar um médico que já existe no sistema (de outra clínica), manda email “você foi convidado para Clínica X”? Ou adiciona silenciosamente?
  4. CRM e assinatura são do user ou do membership?
    • Do user: um médico tem um CRM só (ou lista de CRMs por estado) e uma assinatura. Isso vai junto para qualquer clínica.
    • Do membership: médico pode assinar diferente dependendo da clínica (raro, mas possível).
    • Proposta: do user.
  5. Logo e branding do laudo: hoje o PDF do laudo usa logo do tenant (via activeMembership.tenant). Cross-check.
  6. Notificações: futuras notificações (laudo pendente, etc.) — por user ou por membership? Provavelmente por membership (usuário pode querer desativar notificações de uma clínica específica).
  7. Histórico de membership: se um médico sai da clínica, soft delete do membership? Hard delete? Auditoria?
  8. Transição de nomenclatura no frontend: hoje a sidebar mostra “Tenant: Alfa | Unit: Centro”. Precisa mudar para “Trabalhando em: Alfa / Centro” ou similar, mais orgânico.
  9. Impacto em integrações futuras: se no futuro integrar com sistema externo (ex.: RIS da clínica), a chave é user ou membership?
  10. Multi-membership ativo simultâneo? Hoje propus “um contexto ativo por vez”. Em algum momento faria sentido “ver todos os estudos de todos os meus memberships consolidados”? (Provavelmente não — multi-tenant puro é por isolamento.)

11. Referências

  • CLAUDE.md — arquitetura atual (modelo User, traits, JWT claims)
  • memory/project_rbac_refactor.md — plano antigo da Fase 2 RBAC (este refactor substitui)
  • memory/feedback_refactor_methodology.md — metodologia de blast radius
  • docs/STORAGE_ARCHITECTURE.md §6 — metodologia canônica aplicada a qualquer refactor grande
  • docs/api/admin.md — endpoints que precisarão mudar (users + novos para memberships)
  • docs/api/auth.md — login/refresh/me precisarão mudar
  • docs/HOMOLOG_CHECKLIST.md — casos novos a adicionar na Fase 6

12. Role referring_physician — Caso especial do solicitante externo

Adicionado em 2026-04-17 após discussão sobre médicos solicitantes que precisam ter acesso limitado aos estudos que pediram.

12.1 Quem é esse usuário

Médico externo à clínica que encaminhou um paciente para fazer um exame. Ex.: ortopedista que solicitou ressonância. Ele não trabalha na clínica que executa o exame, mas:
  • Precisa ver o exame dele (imagens + laudo)
  • Não precisa laudar nada
  • Não pode ver exames de outros médicos solicitantes

12.2 Três camadas distintas (importante não confundir)

Conceitos relacionados mas separados — mantenha cada um na cabeça:
CamadaO que éOnde moraSempre existe?
Tag DICOMNome/CRM do solicitante que veio no examestudies.requesting_physician_name, studies.requesting_physician_crm (denormalizado)Sim (se a modalidade preencheu)
Vínculo ao userFK opcional do estudo ao user do solicitantestudies.requesting_physician_user_idSó se houver match por CRM ou vinculação manual
Membership com roleVínculo do user com a clínica (acesso de login)memberships com role = 'referring_physician'Só se o solicitante tiver login no sistema
A Camada 1 sempre acompanha o estudo como dado. As Camadas 2 e 3 só aparecem se o médico solicitante for cadastrado/vinculado.

12.3 O que a role permite

Pode:
  • Listar estudos onde ele é solicitante (scope adicional filtrando por requesting_physician_user_id = user.id)
  • Abrir detalhe do estudo
  • Abrir OHIF viewer dos estudos dele
  • Ver laudos finalizados (não rascunhos) dos estudos dele
Não pode:
  • Criar, editar ou finalizar laudos
  • Ver estudos de outros solicitantes
  • Acessar templates, presets, configurações da clínica
  • Listar pacientes além dos vinculados aos estudos dele
  • Nada de admin

12.4 Scope condicional por role

O trait BelongsToTenantAndUnit precisa de uma camada extra quando a role ativa é referring_physician:
// pseudo-código no trait
$query->where('tenant_id', $ctx->tenantId());
if ($ctx->unitId()) $query->where('unit_id', $ctx->unitId());

// Scope adicional só para referring_physician:
if ($ctx->role() === 'referring_physician') {
    $query->where('requesting_physician_user_id', $ctx->userId());
}
Isso adiciona complexidade à trait, mas encapsula a regra num lugar só — qualquer controller que usa o trait herda o comportamento correto.

12.5 Worker Python — indexação da tag DICOM

O worker hoje indexa StudyInstanceUID, StudyDate, PatientName. Precisa passar a indexar também:
requesting_physician_name = dicom_data.get("RequestingPhysician", "")  # tag (0032,1032)
# Tentar extrair CRM do texto (regex: "CRM 12345/SP" ou "CRM-SP 12345")
requesting_physician_crm = parse_crm(requesting_physician_name)
Opcionalmente (recomendado): auto-match por CRM — se existe user com crm normalizado idêntico, preencher studies.requesting_physician_user_id automaticamente.

12.6 Decisão pendente: CRM único ou profile compartilhado?

Duas soluções para o problema de “mesmo médico solicitante em várias clínicas”:

Solução A — CRM único global (MVP recomendado)

  • users.crm UNIQUE (case-insensitive, normalizado)
  • Admin busca por CRM antes de criar
  • Se existe: “esse médico já está no sistema; criar acesso de solicitante para sua clínica?” → cria apenas membership novo (não duplica user)
  • Se não existe: cria user + membership
Prós: simples, funciona, resolve 90% dos casos reais. Contras: admin de qualquer clínica pode adicionar o médico só sabendo o CRM. Implicação LGPD a avaliar com advogado.

Solução B — Profile compartilhado

Tabela requesting_physician_profiles separada (CRM, nome, UF). Estudos referenciam o profile. Cada profile pode (opcionalmente) ter user_id vinculado para login.
  • Médico pode aparecer em N estudos de N clínicas sem precisar ter login
  • Login/membership só é criado quando o próprio médico (ou admin autorizado) pede acesso
  • Se o user é criado depois, vincula retroativamente ao profile → acesso automático a todos os estudos onde o profile aparece
Prós: separa “informação do solicitante” (dado) de “acesso ao sistema” (identidade); mais alinhado com LGPD. Contras: mais uma tabela; migração mais complexa; lookup em 2 níveis. Recomendação: Solução A no MVP, migrar para B só se LGPD apertar ou se múltiplas clínicas reclamarem da falta de consentimento. Decisão pendente.

12.7 Workflow do admin ao cadastrar solicitante

  1. Admin clica “Adicionar médico solicitante”
  2. Digita CRM (obrigatório) + nome
  3. Backend faz lookup global (GET /admin/users/search?crm=X):
    • Match de CRM: mostra dados do user existente (nome, CRM, clínicas onde já tem acesso — omitindo detalhes sensíveis) → admin confirma → cria apenas membership (role: referring_physician)
    • Sem match: formulário completo (email opcional, CRM, nome, UF) → cria user + membership
  4. (Futuro) Disparar email de boas-vindas se houver email cadastrado

12.8 Caso de borda: médico solicitante também lauda noutra clínica

Dr. João solicita exames na Clínica Alfa e é radiologista na Clínica Gamma. Seus memberships:
memberships de Dr. João:
- Alfa  → role: referring_physician → vê só os estudos que solicitou
- Gamma → role: doctor              → lauda normalmente
Ao trocar de contexto Alfa → Gamma, o scope muda de “só solicitados” para “todos da unit”. Funciona naturalmente no modelo de memberships.

12.9 Caso de borda: tag DICOM mal preenchida ou vazia

Se a modalidade não manda RequestingPhysician, ou manda texto mal formatado sem CRM extraível:
  • studies.requesting_physician_name fica com o texto cru (pode ser vazio)
  • studies.requesting_physician_user_id fica NULL (sem auto-match possível)
  • Admin pode vincular manualmente via UI
Política proposta: estudos com solicitante não vinculado não aparecem para nenhum user com role referring_physician. Precisa vínculo explícito (auto ou manual).

12.10 Implicações no schema

Adições à migration do refactor (complementam §2.3):
-- Tabela studies: denormalização da tag DICOM + FK opcional
ALTER TABLE studies ADD COLUMN requesting_physician_name VARCHAR(255);
ALTER TABLE studies ADD COLUMN requesting_physician_crm VARCHAR(32);
ALTER TABLE studies ADD COLUMN requesting_physician_user_id UUID NULL
    REFERENCES users(id) ON DELETE SET NULL;

CREATE INDEX idx_studies_requesting_physician
    ON studies(requesting_physician_user_id)
    WHERE requesting_physician_user_id IS NOT NULL;

-- Unicidade do CRM (se adotar Solução A)
ALTER TABLE users ADD CONSTRAINT users_crm_unique UNIQUE (crm);

-- Role permitida em memberships (validação de aplicação; não precisa ENUM)
-- Valores aceitos: 'doctor' | 'tenant_admin' | 'referring_physician' | 'receptionist'

12.11 Endpoints adicionais (além dos de §5.7)

EndpointO que faz
GET /admin/users/search?crm=X&name=YBusca global por CRM/nome. Retorna dados reduzidos (sem sensíveis). Usado pelo admin ao cadastrar solicitante.
PATCH /studies/{id}/requesting-physicianAdmin vincula manualmente um user como solicitante do estudo. Body: { user_id }.

12.12 Impacto frontend — páginas novas / restritas

  • Nova página “Meus exames solicitados” — visão do user com role referring_physician; lista só os estudos dele
  • Detalhe do estudo (condicional): esconder botões “Criar laudo”, “Aplicar preset”, “Adicionar assinatura” quando role ativa é referring_physician; mostrar “Ver laudo” (se finalizado) e “Abrir viewer”
  • Admin — “Adicionar solicitante”: form com CRM + busca prévia (§12.7)
  • Admin — “Vincular solicitante ao estudo”: UI para vínculo manual quando auto-match falhou
  • Sidebar: links restritos quando role é referring_physician (sem “Laudos”, sem “Templates”, etc.)

12.13 Impacto na metodologia de mapeamento

Adicionar aos subagents da §9:
SubagentMissão adicional
Explore backend — WorkerMapear indexação do worker hoje; identificar onde adicionar RequestingPhysician
Explore backend — StudiesTodos os controllers/services que retornam dados de Study; onde adicionar os novos campos
Explore frontend — EstudoComponente de detalhe do estudo; todos os botões/ações que precisam ser condicionais por role

13. Apêndice — Mapa exaustivo de arquivos afetados

A preencher após rodar os subagents da §9.

13.1 Backend

pendente

13.2 Frontend

pendente

13.3 Docs Mintlify

pendente

13.4 Testes

pendente