Onboarding — Meu PACS Cloud

Guia para quem está chegando agora. Leia isso antes do ARCHITECTURE.md — aqui está o contexto e a sequência lógica; lá está a referência técnica completa.

1. O que é o sistema

Plataforma SaaS multi-tenant para clínicas de imagem. As clínicas conectam seus aparelhos de raio-x, tomógrafo e ressonância ao sistema. As imagens chegam automaticamente, os médicos visualizam e escrevem laudos, e o sistema cuida de tudo no meio: armazenamento DICOM, processamento, exibição e PDF. Quem usa:
  • Médico radiologista — abre o estudo, visualiza imagens no viewer, escreve o laudo, exporta PDF
  • Admin da clínica — gerencia usuários, unidades, presets de laudo e templates de PDF
  • Super admin (Technik) — gerencia todas as clínicas, aparelhos e usuários da plataforma

2. Glossário de domínio

Antes de ler qualquer código, entenda esses termos — eles aparecem em todo lugar:
TermoO que é
TenantUma clínica. Tudo no sistema pertence a um tenant.
UnitUma filial ou unidade da clínica. Um tenant pode ter várias.
ModalityUm aparelho DICOM (tomógrafo, ressonância, raio-x). Identificado pelo AET (nome de rede DICOM).
StudyUm exame completo. Agrupa todas as imagens de um paciente numa sessão.
SeriesUma sequência de imagens dentro do estudo (ex.: cortes axiais de um CT). Um study tem várias series.
InstanceUma imagem individual. Uma series tem várias instances (também chamadas de “frames”).
ReportO laudo médico de um estudo. Tem status: pending → draft → final.
ReportRevisionRegistro imutável de auditoria. Toda ação no laudo gera uma.
TemplateDefine o layout visual do PDF do laudo (header, footer, slots de logo e assinatura). Vinculado a uma Unit.
PresetTexto pré-preenchido para um tipo de exame. O médico seleciona e o texto aparece no editor.
AETApplication Entity Title — o “nome de rede” de um aparelho DICOM. Como um hostname, mas no protocolo DICOM.

3. Por onde começar

3.1 Leia nesta ordem

  1. Este arquivo (contexto e domínio)
  2. RUNNING.md (como rodar local, comandos, checks de saúde)
  3. ARCHITECTURE.md seção 6 (fluxo de ingestão DICOM) — entenda como uma imagem chega ao sistema
  4. ARCHITECTURE.md seção 10 (multi-tenant) — entenda como o isolamento funciona
  5. ARCHITECTURE.md seção 11 (laudos) — entenda o fluxo principal de uso
  6. ARCHITECTURE.md seção 12 (OHIF + token + proxy) — entenda como o viewer funciona

3.2 Suba o ambiente

# 1. Copie os .env
cp .env.example .env          # infraestrutura Docker
cp backend/.env.example backend/.env

# 2. Suba tudo
docker compose up -d --build

# 3. Rode migrations e seed (cria super admin, tenant de exemplo, etc.)
docker exec -it pacs_backend php artisan migrate --seed

# 4. Acesse
# Frontend:  http://localhost:3000
# Backend:   http://localhost:8000
# OHIF:      http://localhost:3001
# Orthanc:   http://localhost:8042  (interno, acesso externo pelo proxy :8043)

3.3 Verifique que está tudo funcionando

# Todos os containers rodando?
docker compose ps

# Backend respondendo?
curl http://localhost:8000/api/login -X POST \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@example.com","password":"password"}'

# Worker Python consumindo a fila?
docker logs pacs_worker -f

4. Fluxo de autenticação

Entender como o login funciona end-to-end poupa muito tempo:
1. POST /api/login  { email, password }
   → AuthController valida as credenciais
   → Gera JWT com payload: { sub, tenant_id, unit_id, is_super_admin, is_tenant_admin }
   → Resposta: Set-Cookie: pacs_access_token=<jwt>; httpOnly; SameSite=Lax
               Body: { token_type, expires_in, user }  ← token NÃO está no body

2. Frontend (auth-store.ts)
   → Recebe o user do body da resposta
   → Escreve cookie pacs_user_role=<role> (não-httpOnly) para o middleware Next.js
   → Chama GET /me para garantir dados completos (tenant, unit aninhados)

3. Toda requisição subsequente
   → Axios envia com withCredentials: true
   → Browser anexa automaticamente o cookie pacs_access_token ao header Cookie
   → Middleware SetBearerFromCookie (Laravel) lê o cookie e injeta Authorization: Bearer <token>
   → Middleware "auth:api" valida o JWT normalmente
   → $request->user() retorna o User com os claims do token

4. Controle de rotas (Next.js middleware — proxy.ts)
   → Lê pacs_user_role (não-httpOnly, acessível em :3000)
   → Redireciona para /login se não houver role
   → Redireciona para /studies se o role não permite a rota

5. Multi-tenant scoping acontece automaticamente
   → Global scope do trait BelongsToTenant filtra queries pelo tenant_id do JWT
   → Super admin (is_super_admin=true) bypassa o filtro e vê tudo

6. Token expirado (401) → refresh automático (client.ts)
   → Interceptor Axios captura 401
   → POST /api/refresh — backend valida o JWT expirado e emite novo cookie
   → Requisição original é reenviada automaticamente
   → Múltiplos 401 simultâneos ficam em fila (isRefreshing/pendingQueue) e
     aguardam o refresh completar antes de retentar — evita race condition
   → Se o refresh também falhar (401): usuário é deslogado
Por que dois cookies? Frontend (:3000) e backend (:8000) são origens diferentes. O cookie httpOnly do backend está scoped para :8000 — o JS em :3000 não lê e o middleware Next.js em :3000 também não consegue acessá-lo. O pacs_user_role resolve isso: é scoped para :3000 e serve como indicador de sessão para o roteamento do Next.js. Em dev e prod: o mesmo código funciona. A diferença é SESSION_SECURE_COOKIE:
  • Dev: false — cookie sem Secure, funciona em HTTP
  • Prod: true — cookie exige HTTPS

5. Guia por camada

5.1 Backend — Laravel

Onde fica o quê:
backend/app/
  Http/Controllers/     # Um controller por recurso (Study, Report, Template, Preset...) — 13 controllers
  Jobs/                 # ProcessStudyMetadata — enfileirado pelo webhook, roda no Laravel queue
  Models/               # Eloquent: User, Tenant, Unit, Study, Patient, Series, Instance,
                        #           Modality, Report, ReportRevision, ReportTemplate, ReportPreset
  Services/             # OrthancService — toda comunicação com o Orthanc passa daqui
  Traits/               # BelongsToTenant, BelongsToTenantAndUnit — scoping automático
routes/
  api.php               # Todas as rotas organizadas por grupo de acesso
Container pacs_queue: executa php artisan queue:work e processa os jobs do webhook do Orthanc (ex: ProcessStudyMetadata). É diferente do pacs_worker (Python) — aquele processa imagens DICOM; este processa jobs Laravel. Ambos rodam em paralelo.
Adicionar uma nova rota:
# 1. Edite routes/api.php — adicione no grupo correto (público, auth, tenant_admin, super_admin)
# 2. Implemente o método no controller
# 3. Recarregue o Octane (obrigatório — sem isso as mudanças não aparecem):
docker exec pacs_backend php artisan octane:reload
Atenção: O Octane mantém o processo PHP vivo entre requisições para performance. Mudanças em arquivos PHP não são detectadas automaticamente. Sempre recarregue após editar código PHP.
Adicionar um campo ao banco:
docker exec pacs_backend php artisan make:migration add_campo_to_tabela
# Edite o arquivo gerado em backend/database/migrations/
docker exec pacs_backend php artisan migrate
# Adicione o campo em $fillable no Model correspondente
Rodar testes:
docker exec pacs_backend php artisan test

5.2 Frontend — Next.js

Estrutura de rotas (App Router):
frontend/app/
  (auth)/login/             # Tela de login — sem layout principal
  (main)/
    (app)/                  # Usuários comuns + tenant admins
      studies/              # Lista de estudos
      studies/[id]/         # Detalhe: viewer, laudo, histórico do paciente
      patients/             # Pacientes
    (tenant-admin)/tenant/  # Só tenant admin: templates, presets, configurações
    (super-admin)/admin/    # Só super admin: clínicas, unidades, aparelhos, usuários
Os parênteses nos nomes das pastas são grupos de rota do Next.js — não aparecem na URL, só organizam o layout. Services de API:
frontend/lib/api/
  client.ts           # Axios configurado com baseURL, cookie e interceptor de refresh (401)
  studies-service.ts  # Estudos, séries, instâncias, thumbnail, laudo, token do viewer
  tenant-service.ts   # Templates, presets, unidades, configurações do tenant
  patients-service.ts # Pacientes e histórico de estudos por paciente
  admin-service.ts    # Super admin: tenants, unidades globais, modalidades, usuários
Toda chamada de API passa por um desses services — nunca axios.get() direto nos componentes. State global:
  • Auth: frontend/store/auth-store.ts (Zustand) — guarda o usuário logado
  • UI local: useState / useReducer nos componentes
O frontend tem hot reload — salvar um arquivo .tsx já recarrega no browser. Não precisa reiniciar nada.

5.3 Orthanc

O que é: servidor DICOM open-source. Recebe imagens de aparelhos via protocolo DICOM (porta 4242) e as serve via REST API e DicomWeb (porta 8042). Arquivos de configuração:
docker/orthanc/
  orthanc.json                  # Config principal (portas, plugins, StableAge)
  scripts/hook.lua.template     # Script Lua que dispara o webhook ao backend
  entrypoint.sh                 # Substitui variáveis no template antes de iniciar
Para testar o Orthanc manualmente:
# Ver todos os estudos armazenados
curl http://localhost:8042/studies

# Ver detalhes de um estudo
curl http://localhost:8042/studies/{orthanc-study-id}

# Simular o webhook que o Orthanc enviaria ao backend
curl -X POST http://localhost:8000/api/orthanc/webhook \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Secret: dev-webhook-secret-change-in-prod" \
  -d '{"ID": "orthanc-study-uuid-aqui"}'
Após editar a configuração:
docker compose up -d --force-recreate orthanc

5.4 Worker Python

O que faz: consome a fila pacs_python_queue do Redis. Para cada mensagem:
  1. Baixa o DICOM do Orthanc
  2. Gera thumbnail 300×300 JPEG
  3. Salva em /app/storage/thumbnails/{orthanc_study_id}/preview.jpg
  4. Indexa Patient, Series e Instances no PostgreSQL
  5. Atualiza o array modalities no Study
Arquivo único: worker/main.py Ver o que está acontecendo:
docker logs pacs_worker -f
# Com DEBUG_WORKER=1 no .env, os logs mostram cada estudo processado
Reprocessar estudos (se necessário):
# Reprocessar todos os estudos
docker exec pacs_backend php artisan pacs:requeue

# Apenas estudos sem modality indexada
docker exec pacs_backend php artisan pacs:requeue --only-missing-modalities
Após editar o worker:
docker compose up -d --force-recreate worker

5.5 Redis

O Redis tem três papéis no sistema:
UsoChave / FilaQuem escreveQuem lê
Fila de jobs Laravel (default)queues:defaultWebhookControllerLaravel queue worker (interno ao backend)
Fila do worker Pythonpacs_python_queueProcessStudyMetadata jobWorker Python (BLPOP)
Tokens temporários do OHIFviewer_token:{uuid}ViewerTokenControllerViewerTokenController (validate) + nginx
Inspecionar o Redis:
docker exec -it pacs_redis redis-cli

# Ver todas as chaves
KEYS *

# Ver o tamanho da fila do worker
LLEN pacs_python_queue

# Ver um token salvo
GET viewer_token:uuid-aqui

5.6 PostgreSQL

Acessar o banco:
docker exec -it pacs_postgres psql -U pacs_admin -d pacs_database
Modelos principais e suas tabelas:
TabelaModelDescrição
tenantsTenantClínicas
unitsUnitFiliais das clínicas
usersUserMédicos e admins
modalitiesModalityAparelhos DICOM (com AET)
studiesStudyExames recebidos
patientsPatientPacientes (indexados do DICOM)
seriesSeriesSéries de imagens por estudo
instancesInstanceImagens individuais
reportsReportLaudos
report_revisionsReportRevisionAuditoria imutável dos laudos
report_templatesReportTemplateTemplates de PDF
report_presetsReportPresetTextos pré-preenchidos
Todos os IDs são UUID. As tabelas com multi-tenant têm tenant_id e usam o trait BelongsToTenant para scoping automático.

5.7 OHIF + Proxy + Token

O fluxo completo de quando um médico clica em “Abrir Viewer”:
1. Frontend → POST /api/viewer/token { study_id }
   Backend salva um UUID no Redis com TTL 600s
   Retorna { token: "uuid" }

2. Frontend abre o OHIF:
   - Nova aba: window.open("http://localhost:3001/viewer?StudyInstanceUIDs=...&token=uuid")
   - Split view: <iframe src="..." /> na mesma página

3. OHIF carrega app-config.js (docker/ohif/app-config.js):
   → Lê ?token= da URL
   → Salva no sessionStorage
   → Patcha XHR e fetch para injetar X-Viewer-Token em toda requisição para :8043

4. OHIF faz DicomWeb requests → http://localhost:8043/dicom-web/...
   com header X-Viewer-Token: uuid

5. Nginx (:8043) intercepta e faz auth_request:
   GET http://backend:8000/api/viewer/token/validate
   passa X-Viewer-Token e X-Original-URI

6. Backend valida:
   → Token existe no Redis?
   → O study_instance_uid da URI pertence ao tenant do token?
   → Sim: retorna 200 → nginx repassa ao Orthanc :8042
   → Não: 401/403 → nginx bloqueia

7. Orthanc serve as imagens DICOM → OHIF renderiza
Por que o iframe funciona sem mudança no OHIF: dentro de um <iframe>, window é o contexto do iframe — window.location.search lê a URL do próprio iframe, não da página pai. O app-config.js lê o token corretamente em ambos os casos.

6. Como debugar

Ver logs em tempo real

docker logs pacs_backend -f             # API Laravel + erros PHP
docker logs pacs_queue -f               # Jobs Laravel (webhook → ProcessStudyMetadata)
docker logs pacs_worker -f              # Processamento DICOM (thumbnail, indexação)
docker logs pacs_orthanc -f             # Servidor PACS
docker logs pacs_orthanc_cors_proxy -f  # Proxy nginx (auth do viewer)

Testar uma rota de API manualmente

# Fazer login e salvar o token
TOKEN=$(curl -s -X POST http://localhost:8000/api/login \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@example.com","password":"password"}' \
  -c /tmp/pacs_cookies.txt | jq -r '.access_token')

# Usar o token em outra requisição
curl http://localhost:8000/api/me \
  -H "Authorization: Bearer $TOKEN"

# Ou usar o cookie salvo
curl http://localhost:8000/api/me \
  -b /tmp/pacs_cookies.txt

Simular o webhook do Orthanc

curl -X POST http://localhost:8000/api/orthanc/webhook \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Secret: dev-webhook-secret-change-in-prod" \
  -d '{"ID": "orthanc-study-uuid"}'

Ver o que está na fila do worker

docker exec -it pacs_redis redis-cli LLEN pacs_python_queue

Acessar o shell do backend

docker exec -it pacs_backend bash

# Dentro do container:
php artisan tinker        # REPL do Laravel — ótimo para testar queries Eloquent
php artisan route:list    # Ver todas as rotas registradas
php artisan migrate:status

Verificar erro 422 em requisições

Provavelmente é um problema de validação. Veja o corpo da resposta — o Laravel retorna {"message": "...", "errors": {"campo": ["mensagem"]}}. Se o campo for booleano enviado pelo Axios, veja a armadilha abaixo.

7. Armadilhas conhecidas

Booleanos em query params (Axios + Laravel)

O Axios serializa true (booleano JS) como a string "true" na URL. A regra de validação 'boolean' do Laravel só aceita true, false, 0, 1, "0", "1" — rejeita "true" com 422. Regra: sempre usar $request->boolean('campo') para ler booleanos de query params. Nunca $request->validate(['campo' => 'boolean']) para parâmetros enviados pelo Axios.
// Correto
if ($request->has('is_active')) {
    $query->where('is_active', $request->boolean('is_active'));
}

// Errado — retorna 422 quando Axios envia true
$data = $request->validate(['is_active' => ['boolean']]);

Filtros opcionais: filled() vs has()

$request->has('campo') retorna true mesmo quando o valor é uma string vazia "". Use $request->filled('campo') para ignorar valores vazios.
// Correto — ignora quando o frontend envia campo=""
if ($request->filled('patient_uuid')) {
    $query->where('patient_uuid', $request->get('patient_uuid'));
}

// Errado — aplica o filtro com valor vazio, retorna zero resultados
if ($request->has('patient_uuid')) {
    $query->where('patient_uuid', $request->get('patient_uuid'));
}

Reload obrigatório do Octane após mudanças PHP

O Octane mantém o processo vivo para performance. Mudanças em .php não são detectadas automaticamente.
# Sempre rode após editar qualquer arquivo PHP
docker exec pacs_backend php artisan octane:reload

iframe e sessionStorage

Dentro de um <iframe>, window é o contexto do iframe — não da página pai. Isso significa que window.location.search, sessionStorage e localStorage são do iframe, não da janela que o contém. O app-config.js do OHIF lê o token da URL corretamente em ambos os modos (nova aba e iframe) sem nenhuma mudança.

Worker sem variáveis DB

As variáveis DB_* do worker Python vêm do .env raiz (não do backend/.env). Se o worker processar o thumbnail mas não indexar series/instances/modalities, verifique se DB_HOST, DB_DATABASE, DB_USERNAME e DB_PASSWORD estão definidas no .env raiz. O pacs_access_token é httpOnlydocument.cookie, localStorage e qualquer JS não conseguem lê-lo. Isso é intencional (proteção contra XSS). O Axios envia o cookie automaticamente via withCredentials: true. Se precisar debugar o token em dev, use as DevTools do browser (aba Application → Cookies → localhost:8000). pacs_user_role (lido pelo Next.js middleware para roteamento) e pacs_access_token (lido pelo backend para autenticar) são cookies independentes com propósitos diferentes. Alguém pode forjar o pacs_user_role e ver a UI de admin, mas todas as chamadas de API vão falhar com 401/403 pois não têm o JWT válido.

Template vs Preset — sem vínculo entre os dois

Template e Preset são conceitos independentes. Um preset não tem template_id. O template é resolvido automaticamente pela unidade do médico (GET /tenant/my-template). O preset é selecionado pelo médico (ou sugerido automaticamente) e fornece o texto inicial. Os dois são aplicados separadamente no PDF.

Interceptor de refresh — não modificar isRefreshing/pendingQueue

O client.ts implementa um mecanismo de fila para evitar race conditions quando múltiplas requisições recebem 401 simultaneamente: isRefreshing trava novos refresh enquanto um está em andamento; pendingQueue acumula as requisições e drainQueue as reprocessa após o refresh completar. Modificar esse mecanismo sem entender o fluxo completo pode causar loops infinitos de refresh ou perda de requisições.

8. Convenções do projeto

  • IDs: sempre UUID (não integers)
  • Datas: UTC no banco, formatadas no frontend
  • Soft delete: templates usam deleted_at; presets e estudos são hard delete
  • HTML no banco: o conteúdo do laudo (reports.content) e dos presets (report_presets.default_content) é armazenado como HTML gerado pelo Tiptap
  • Nomes de branches: feat/nome, fix/nome, infra/nome → PR para develop → PR para main
  • Nunca commitar direto em main

9. Referências rápidas

O que precisaOnde encontrar
Especificação técnica completaARCHITECTURE.md
Todas as rotas de APIARCHITECTURE.md seção 8 ou backend/routes/api.php
Modelos do bancobackend/app/Models/
Tipos TypeScript da APIfrontend/types/
Layout do PDFfrontend/lib/utils/study-report-pdf.ts + frontend/types/report-template-layout.ts
Config do Orthancdocker/orthanc/orthanc.json
Config do nginx proxydocker/orthanc-cors-proxy/nginx.conf
Config do OHIFdocker/ohif/app-config.js
Variáveis de ambienteARCHITECTURE.md seção 4