Arquitetura do Sistema — Meu PACS Cloud
Guia técnico completo para desenvolvedores que querem entender como o sistema funciona, onde cada peça vive, o que cada configuração faz e como editar.Sumário
- Visão Geral
- Stack e Tecnologias
- Serviços Docker e Portas
- Variáveis de Ambiente
- Volumes Compartilhados
- Fluxo de Ingestão DICOM
- Orthanc — Servidor PACS
- Backend — API Laravel
- Worker Python — Processamento de Imagens
- Multi-Tenant e RBAC
- Sistema de Laudos
- OHIF Viewer — Visualizador DICOM
- Frontend — Next.js
- Proxy CORS do Orthanc
- Como Editar Cada Parte
- Arquitetura Alvo — Orthanc On-Premise com Forwarding
1. Visão Geral
Sistema PACS (Picture Archiving and Communication System) multi-tenant na nuvem. Clínicas (tenants) recebem imagens DICOM de suas modalidades (aparelhos de raio-x, tomógrafo, ressonância), visualizam os estudos, e médicos radiologistas escrevem laudos.2. Stack e Tecnologias
| Componente | Tecnologia | Versão | Por que foi escolhido |
|---|---|---|---|
| Banco de dados | PostgreSQL | 15 | Multi-tenant com UUID, suporte a JSON nativo, ilike case-insensitive |
| Cache / Filas | Redis | 7 | Sessions do Laravel, filas de job, tokens temporários do viewer |
| Servidor PACS | Orthanc | latest (com plugins) | Open-source, suporta DicomWeb, extensível via Lua/REST |
| Backend | Laravel | 12 + Octane/RoadRunner | PHP moderno com alta performance via Octane, JWT auth, Eloquent ORM |
| Worker | Python | 3.11 | pydicom para leitura DICOM, Pillow/NumPy para geração de thumbnail |
| Frontend | Next.js | 16 (App Router) | React server components, roteamento por grupos de role |
| Visualizador | OHIF Viewer | latest | Open-source, padrão da indústria para DICOM no browser |
| Proxy CORS | Nginx | alpine | auth_request para validar token antes de repassar ao Orthanc |
3. Serviços Docker e Portas
Todos os serviços rodam dentro da rede Dockerpacs_network e se comunicam pelo nome do container.
| Container | Nome | Porta Host → Container | Papel |
|---|---|---|---|
pacs_postgres | postgres | 5432:5432 | Banco de dados |
pacs_redis | redis | 6379:6379 | Cache, sessões, filas |
pacs_orthanc | orthanc | 4242:4242 (DICOM) | Servidor PACS (HTTP interno: 8042) |
pacs_backend | backend | 8000:8000 | API Laravel/Octane |
pacs_worker | worker | — (sem porta) | Worker Python (consome fila DICOM) |
pacs_queue | queue | — (sem porta) | Queue worker Laravel (processa jobs do webhook) |
pacs_frontend | frontend | 3000:3000 | Interface Next.js |
pacs_ohif | ohif | 3001:80 | Visualizador DICOM |
pacs_orthanc_cors_proxy | orthanc-cors-proxy | 8043:80 | Proxy nginx autenticado para Orthanc |
Nota: O Orthanc expõe a porta HTTP8042apenas dentro da rede Docker — não é acessível diretamente do host. O acesso externo passa pelo proxy8043.
Produção: oArquivo de configuração:docker-compose.prod.ymladiciona o containerpacs_caddy(Caddy 2 Alpine) nas portas80:80e443:443como reverse proxy com TLS automático. Não existe em dev.
docker-compose.yml na raiz do projeto. Overrides de produção em docker-compose.prod.yml.
4. Variáveis de Ambiente
O projeto usa dois arquivos.env separados por responsabilidade.
.env (raiz) — Infraestrutura Docker
docker-compose.yml para montar os containers. O worker Python também lê estas variáveis via environment: no compose.
backend/.env — Configuração do Laravel
Importante: As variáveisDB_*do worker Python vêm do.envraiz (não dobackend/.env). Se o worker não indexar series/instances, verifique se essas variáveis estão no.envraiz.
5. Volumes Compartilhados
| Volume | Montado em (backend) | Montado em (worker) | Conteúdo |
|---|---|---|---|
pacs_storage | /app/pacs_mount | /app/storage | Thumbnails gerados pelo worker |
pacs_pg_data | — | — | Dados do PostgreSQL (persistência) |
pacs_redis_data | — | — | Dados do Redis (persistência) |
pacs_orthanc_data | — | — | Imagens DICOM armazenadas pelo Orthanc |
6. Fluxo de Ingestão DICOM
Modelo atual (desenvolvimento / testes)
Neste modelo os aparelhos enviam DICOM diretamente para o Orthanc na nuvem. É o modelo em uso hoje, adequado para desenvolvimento e homologação.Modelo alvo (produção com VPN)
No modelo de produção, cada unidade da clínica terá um Orthanc local on-premise. Os aparelhos enviam para o Orthanc local (rede interna), que encaminha para o cloud via túnel WireGuard. A porta 4242 no cloud só aceita conexões de IPs do range VPN.modalities passa a representar o Orthanc local da unidade, não o equipamento físico.
Resolução de Tenant/Unit — detalhe crítico
OProcessStudyMetadata resolve tenant+unit exclusivamente via RemoteAET:
- Se o AET não estiver cadastrado em
modalities→ estudo vai para tenant quarentena. - Todo novo gateway (Orthanc local) precisa ter seu AET cadastrado no painel antes de começar a enviar.
| Camada | Onde | O que bloqueia |
|---|---|---|
| 1ª — Firewall | ufw no servidor | Qualquer IP fora do range VPN nunca chega à porta 4242 |
| 2ª — Orthanc | DicomModalities + KnownAETsOnly | Só AETs explicitamente cadastrados no orthanc.json conseguem fazer C-STORE |
| 3ª — Job Laravel | ProcessStudyMetadata | AET não associado a tenant/unit no banco → quarentena |
RemoteIP é logado para auditoria, mas a decisão de aceitar/rejeitar é feita pelo AET. O IP é garantido pelas camadas anteriores (firewall + VPN).
7. Orthanc — Servidor PACS
O que é
Orthanc é um servidor DICOM open-source. Ele recebe imagens de aparelhos médicos via protocolo DICOM e as serve via REST API e DicomWeb.Arquivos de configuração
| Arquivo | O que controla |
|---|---|
docker/orthanc/orthanc.json | Configuração principal do Orthanc |
docker/orthanc/scripts/hook.lua.template | Template do script Lua (webhook) |
docker/orthanc/entrypoint.sh | Gera /tmp/hook.lua substituindo variáveis antes de iniciar |
orthanc.json — principais configurações
Hook Lua
O arquivohook.lua.template contém:
entrypoint.sh substitui ${ORTHANC_WEBHOOK_SECRET} pelo valor real antes de iniciar o Orthanc, mantendo o secret fora do repositório.
Endpoints do Orthanc usados pelo backend
| Chamada | Endpoint Orthanc | O que retorna |
|---|---|---|
| Detalhes do estudo | GET /studies/{id} | {ID, Series[], MainDicomTags, PatientMainDicomTags} |
| Detalhes da série | GET /series/{id} | {ID, Instances[], MainDicomTags} |
| AET remoto | GET /instances/{id}/metadata/RemoteAET | String com o AET do aparelho |
| Arquivo DICOM bruto | GET /instances/{id}/file | Bytes do arquivo .dcm |
| DicomWeb (QIDO/WADO) | /dicom-web/studies/... | Padrão DICOM JSON/frames |
8. Backend — API Laravel
Estrutura
- Framework: Laravel 12 com Octane (servidor RoadRunner)
- Autenticação: JWT (
tymon/jwt-auth) via cookiepacs_access_token - Banco: Eloquent ORM com PostgreSQL
- Filas: Redis (queue padrão do Laravel +
pacs_python_queuemanual)
SetBearerFromCookie):
Registrado via $middleware->prependToGroup('api', ...) em bootstrap/app.php. Executa antes de qualquer middleware auth:api — lê o cookie pacs_access_token (httpOnly) e injeta Authorization: Bearer <token> no request. O guard JWT (tymon/jwt-auth) valida normalmente sem saber que o token veio de um cookie.
Reload após mudanças PHP
O Octane (RoadRunner) não tem hot reload automático:Mapa completo de rotas
Públicas (sem autenticação)
| Método | Rota | Controller | O que faz |
|---|---|---|---|
POST | /api/login | AuthController@login | Recebe email+password, retorna JWT no cookie |
POST | /api/orthanc/webhook | WebhookController@handleOrthanc | Recebe notificação do Orthanc (valida X-Webhook-Secret) |
GET | /api/viewer/token/validate | ViewerTokenController@validate | Valida token do viewer (chamado pelo nginx auth_request) |
Protegidas por JWT (auth:api)
| Método | Rota | Controller | O que faz | Retorna |
|---|---|---|---|---|
POST | /api/logout | AuthController@logout | Invalida o token JWT | 204 |
GET | /api/me | inline | Dados do usuário autenticado com tenant/unit | {id, email, name, tenant_id, unit_id, is_super_admin, is_tenant_admin, tenant, unit} |
GET | /api/studies | StudyController@index | Lista estudos com paginação (filtros: search, patient_uuid, exclude_id) | Paginado {data[], current_page, last_page, total} |
GET | /api/studies/{id} | StudyController@show | Detalhe de um estudo com patient, unit, tenant. Fallback: se modalities nulo, deriva das séries | Study |
GET | /api/studies/{id}/series | StudyController@series | Lista séries do estudo | Series[] |
GET | /api/series/{id}/instances | StudyController@instancesBySeries | Lista instâncias de uma série | Instance[] |
GET | /api/studies/{id}/thumbnail | StudyController@thumbnail | Stream JPEG do thumbnail | image/jpeg |
GET | /api/studies/{id}/reports | ReportController@showByStudy | Laudo atual do estudo | {study_id, report_status, report} |
POST | /api/studies/{id}/reports | ReportController@upsertByStudy | Salva rascunho ou finaliza laudo | {message, study_id, report_status, report} |
POST | /api/studies/{id}/reports/corrections | ReportController@correctFinal | Corrige laudo já finalizado | {message, study_id, report_status, report} |
GET | /api/studies/{id}/reports/revisions | ReportController@revisionsForStudy | Histórico completo de revisões | ReportRevision[] |
GET | /api/patients | PatientController@index | Lista pacientes | Paginado |
GET | /api/patients/{id} | PatientController@show | Detalhe do paciente | Patient |
GET | /api/patients/{id}/studies | PatientController@studies | Estudos de um paciente | Study[] |
POST | /api/viewer/token | ViewerTokenController@generate | Gera token temporário (10min) para OHIF | {token: "uuid"} |
GET | /api/studies/{id}/instances/{instanceId}/rendered | StudyController | Proxy da imagem DICOM renderizada (JPEG) — sem cache, validação de tenant | image/jpeg |
Qualquer usuário autenticado do tenant — prefixo /api/tenant (leitura)
| Método | Rota | Controller | O que faz |
|---|---|---|---|
GET | /tenant/templates | ReportTemplateController@index | Lista templates ativos da unidade do usuário |
GET | /tenant/templates/{id} | ReportTemplateController@show | Detalhe do template |
GET | /tenant/my-template | ReportTemplateController@myTemplate | Template ativo da unidade do usuário logado |
GET | /tenant/report-presets | ReportPresetController@index | Lista presets (filtros: modality, is_active) |
GET | /tenant/report-presets/{id} | ReportPresetController@show | Detalhe do preset |
GET | /tenant/settings | TenantSettingsController@show | Configurações do tenant (logo, nome) — acessível a todos para exibir logo no preview/PDF |
Qualquer usuário autenticado do tenant — leitura adicional
| Método | Rota | Controller | O que faz |
|---|---|---|---|
GET | /tenant/units | UnitController@index | Lista unidades do tenant (scoped por BelongsToTenant — usado para popular dropdowns em templates/presets) |
Tenant Admin (auth:api + tenant_admin) — prefixo /api/tenant
| Método | Rota | Controller | O que faz |
|---|---|---|---|
POST | /tenant/templates | ReportTemplateController@store | Cria template (unit_id obrigatório) |
PATCH | /tenant/templates/{id} | ReportTemplateController@update | Edita template |
DELETE | /tenant/templates/{id} | ReportTemplateController@destroy | Soft delete do template |
POST | /tenant/report-presets | ReportPresetController@store | Cria preset |
PATCH | /tenant/report-presets/{id} | ReportPresetController@update | Edita preset |
DELETE | /tenant/report-presets/{id} | ReportPresetController@destroy | Deleta preset |
PATCH | /tenant/settings | TenantSettingsController@update | Atualiza configurações do tenant (logo, nome) |
Super Admin (auth:api + super_admin) — prefixo /api/admin
| Método | Rota | Controller | O que faz |
|---|---|---|---|
GET | /admin/tenants | TenantController@index | Lista clínicas |
POST | /admin/tenants | TenantController@store | Cria clínica |
PATCH | /admin/tenants/{id}/status | TenantController@updateStatus | Ativa/desativa clínica |
GET | /admin/units | UnitController@index | Lista unidades (todas) |
POST | /admin/units | UnitController@store | Cria unidade |
GET | /admin/modalities | ModalityController@index | Lista modalidades (aparelhos) |
POST | /admin/modalities | ModalityController@store | Cadastra aparelho com AET |
GET | /admin/users | UserController@index | Lista usuários (todos) |
POST | /admin/users | UserController@store | Cria usuário |
PATCH | /admin/users/{id} | UserController@update | Edita usuário |
Comunicação Backend → Orthanc
Toda comunicação com o Orthanc passa peloOrthancService (app/Services/OrthancService.php). Ele usa Http::get() do Laravel (wrapper do Guzzle) para fazer requisições HTTP internas:
config('services.orthanc.url'), definida via ORTHANC_URL no backend/.env.
9. Worker Python — Processamento de Imagens
O que faz
Consome a filapacs_python_queue do Redis e para cada mensagem:
- Gera thumbnail — baixa o DICOM bruto do Orthanc, extrai o pixel array, normaliza para 8-bit, salva como JPEG 300×300
- Indexa Patient — upsert atômico por
(tenant_id, patient_id) - Indexa Series — para cada série do estudo (busca Orthanc)
- Indexa Instances — para cada imagem de cada série
- Atualiza modalities — array de modalidades distintas no estudo
Localização
Endpoints do Orthanc usados pelo worker
| Chamada | Endpoint | O que usa |
|---|---|---|
| Detalhes do estudo | GET /studies/{id} | Lista de series_ids, patient tags |
| Detalhes da série | GET /series/{id} | Lista de instance_ids, modality, series_number |
| Arquivo DICOM | GET /instances/{id}/file | Bytes para gerar thumbnail |
| Detalhes da instance | GET /instances/{id} | SOPInstanceUID, InstanceNumber, Rows, Columns |
Variáveis de ambiente
Definidas emdocker-compose.yml na seção worker.environment:
10. Multi-Tenant e RBAC
Hierarquia
Roles — modelo de negócio
| Role | Flag | Responsabilidade |
|---|---|---|
| Super Admin | is_super_admin=true | Operação da plataforma — infra, clínicas, unidades, usuários, gateways |
| Tenant Admin | is_tenant_admin=true | Gestão clínica — templates de laudo, presets, visualização de estudos. Não pode criar/editar laudos. |
| Usuário Regular (doutor) | — | Uso clínico diário — visualiza estudos e escreve laudos da sua unidade |
O que cada role pode fazer
Super Admin (nós — equipe da plataforma):- Criar e gerenciar clínicas (tenants)
- Criar e gerenciar unidades dentro das clínicas
- Criar e gerenciar todos os usuários (de qualquer tenant)
- Cadastrar gateways Orthanc locais (modalities + AET + vpn_ip)
- Provisionar WireGuard por unidade
- Acessar o painel clínico de qualquer tenant
- Visualizar estudos e pacientes da clínica
- Gerenciar templates de laudo (criar, editar, ativar/desativar por unidade)
- Gerenciar presets de laudo (criar, editar, filtrar por modalidade)
Gerenciar usuários→ responsabilidade do Super AdminGerenciar unidades→ responsabilidade do Super Admin- Não pode criar, editar ou corrigir laudos —
POST /studies/{id}/reportsePOST /studies/{id}/reports/correctionsretornam 403
- Visualizar estudos e pacientes da sua unidade
- Escrever, finalizar e corrigir laudos
Histórico: a Fase 1 da refatoração de roles (remover gerenciamento de users/units do tenant admin, consolidando em super admin) foi concluída. A Fase 2 original (redesign do painel super admin) foi substituída pelo refactor de memberships — ver docs/MEMBERSHIPS_REFACTOR.md para o modelo multi-vínculo que permite um usuário trabalhar em múltiplas clínicas/unidades.
Como o scoping funciona automaticamente
O traitBelongsToTenant (app/Traits/BelongsToTenant.php) adiciona um global scope no Eloquent:
Model::query() já filtra automaticamente. Não é necessário adicionar ->where('tenant_id', ...) nos controllers — o Eloquent faz isso invisível.
JWT Claims
O token JWT carrega:tenant_id— qual clínicaunit_id— qual unidadeis_super_admin— flag booleanois_tenant_admin— flag booleano
11. Sistema de Laudos
Conceitos
- Template — Define a estrutura visual do laudo (
ReportTemplateLayoutV1): regiões (header/footer heights, body padding), slots posicionados em % (logo, paciente, assinatura), tipografia (fonte, tamanhos). Obrigatoriamente vinculado a uma unidade (unit_idrequired). Uma unidade tem no máximo um template ativo — ao ativar um novo, os outros são desativados automaticamente. Não temtemplate_idno preset. - Preset — Modelo de texto reutilizável para um tipo de exame (
default_contentem HTML Tiptap). Recurso do tenant inteiro —unit_idé semprenull; qualquer médico de qualquer unidade da mesma clínica pode usar. O template visual (papel timbrado, layout) vem sempre da unidade do médico logado, não do preset. Filtrado no painel pelo médico pela modalidade do estudo. Sugestão automática porstudy_description_containse/oumodality. - Report — O laudo em si, vinculado a um Study. O campo
contentarmazena HTML gerado pelo Tiptap. Apenas usuários regulares (doutores) podem criar e editar laudos — tenant admins e super admins recebem 403 nas rotas de escrita; podem apenas visualizar. - ReportRevision — Auditoria imutável. Toda ação gera uma revisão com
previous_contentenew_contentem HTML.
Editor de template (/tenant/templates)
O tenant admin configura visualmente o layout do laudo. O editor (tenant-template-editor.tsx) tem duas colunas: controles à esquerda e preview A4 em tempo real à direita.
Sliders em milímetros: todos os controles de posição (X, Y, Largura) exibem e aceitam valores em mm, mas armazenam internamente em % da página. Conversão:
- Validada no frontend:
file.typedeve serimage/pngouimage/jpeg(rejeita antes de ler o arquivo) - Carregada em um
<canvas> - Fundo branco preenchido antes do
drawImage— evita que áreas transparentes de PNGs virem preto no JPEG - Redimensionada para no máximo 1240×1754px (A4@150dpi)
- Exportada como JPEG 75%
- Armazenada como base64 no campo
assetsJSONB do template
file.type. A diferença é a estratégia de formato de saída:
- PNG de entrada → saída PNG (preserva transparência — essencial para logos com fundo transparente e assinaturas sobre papel)
- JPEG de entrada → fundo branco + JPEG (evita fundo preto)
/^data:image\/(jpeg|png);base64,/ nos campos logo (TenantSettingsController) e signature (UserController), impedindo que strings arbitrárias (SVG com scripts, HTML) sejam armazenadas.
Preview A4 fiel (report-template-a4-preview.tsx): container com max-w-[595px] que corresponde a 1pt = 1px = 72dpi. Todos os slots são posicionados com left/top em % sobre o container, exatamente como no PDF. O resultado é que preview web e PDF são visualmente idênticos.
O componente suporta paginação multi-página no painel de laudos (não no editor de template, que é sempre 1 página). Ver seção abaixo.
Resolução automática de template e preset
O médico não escolhe o template manualmente. O sistema resolve:Editor Rich Text (Tiptap)
- Componente:
frontend/components/ui/rich-text-editor.tsx - Extensões: StarterKit (bold, italic, strike, listas, headings, undo/redo) + Placeholder
- Armazenamento: HTML string no campo
content/default_content - Read-only: laudo finalizado trava o editor principal; correções têm editor separado
- PDF: HTML é parseado por
parseHtmlToBlocks()(DOMParser) e renderizado com fonte e estilo preservados viarenderHtmlBlocks()— nunca convertido para texto puro
Estados do laudo
Fluxo de criação
Drawer de preview ao vivo
Durante a escrita do laudo, o médico pode visualizar como o documento ficará impresso sem sair do editor:- Botão contextual muda de texto conforme o estado: “Pré-visualizar laudo” (sem rascunho) / “Ver rascunho” / “Ver laudo final”
- Drawer desliza pela direita (680px), fecha clicando fora ou no X
- Renderiza
ReportTemplateA4Previewcom template da unidade + conteúdo atual em tempo real - Mostra nome real da unidade, nome real do paciente, assinatura e CRM do autor
- Export PDF só aparece após finalização — rascunho não pode ser exportado
Paginação multi-página no preview
O componentereport-template-a4-preview.tsx pagina o conteúdo dinamicamente, espelhando o comportamento do jsPDF:
Medição: useLayoutEffect roda após cada render e mede todos os elementos block-level (p, h1-h6, li) via getBoundingClientRect() (valores fracionados, sem arredondamento de offsetHeight). Posições são relativas ao topo do scrollRef (o div de conteúdo da página 0).
Cálculo de quebras: idêntico ao checkPage() do jsPDF — se block.bottom > effectiveBodyPx (onde effectiveBodyPx = bodyPx - 1 linha), o bloco inteiro vai para a próxima página. Nenhum bloco é jamais cortado no meio.
Arquitetura de clipping — dois clips aninhados (páginas N > 0):
- Página 0: outer clip com altura = segmento desta página;
paddingTopno content div cria o espaço superior. - Páginas N > 0: outer clip com
bodyHinteiro; inner clip começa emeffectivePadPx(criando espaço branco visual = top padding equivalente aoresetBodyCursor()do PDF) e tem altura =segmentPx = breaks[N+1] - breaks[N]. O content div posiciona emtop: -breaks[N]pxrelativo ao inner clip — exato começo do segmento desta página, sem vazar conteúdo da página anterior.
top = -(breaks[N] - effectivePadPx)) exibiria os últimos effectivePadPx da página anterior no topo da página atual. O inner clip elimina esse vazamento: o espaço entre outer e inner mostra o fundo branco puro.
effectivePadPx: measuredBodyPx × (bodyPad × 0.2 × PAGE_H / 100) / bodyH — mesma fórmula do PDF.
Tipografia HTML no preview: classe prose prose-sm removida (adicionava margens entre parágrafos que o jsPDF não tem). Substituída por Tailwind arbitrary variants que zeram margens ([&_p]:m-0, [&_h1-6]:m-0, etc.) e configuram headings com os mesmos scales do PDF ([&_h1]:text-[1.6em], [&_h2]:text-[1.35em], etc.).
Assinatura: aparece em todas as páginas (igual ao PDF que chama drawHeaderFooter() a cada nova página).
Export PDF com imagens
Ao clicar “Exportar PDF” (disponível apenas em laudos finalizados):- Modal abre (
ImageSelectionModal) com cards de série — thumbnail por série (gerado pelo worker) - Expandir série → frames individuais via
GET /studies/{id}/instances/{instanceId}/rendered(proxy Orthanc + JWT) - Seleção por série inteira ou frames individuais; configuração de colunas (1–6), formato (A4/A3), máx. frames/série
- Loading com barra de progresso em duas fases (loading imagens → gerando PDF)
- PDF gerado por
study-report-pdf.ts(async) com layout do template aplicado (header/footer/slots/tipografia) + imagens embutidas - PDF abre em nova aba (
doc.output("bloburl"))
Pipeline de geração de PDF — detalhes técnicos
Posicionamento de slots: todos os slots (logo, unidade, paciente, assinatura) são posicionados como% da página inteira (0–100% de 210mm × 297mm para A4), nunca relativos a uma região. Isso garante correspondência exata entre preview web e PDF.
Padding vertical do corpo: effectivePad = bodyPad × 0.2 × PAGE_H / 100. Nunca aplicado horizontalmente — margens laterais são sempre PAGE_MARGIN fixo.
Rich text preservado no PDF: o campo reportBody chega como HTML do Tiptap. parseHtmlToBlocks(html) usa DOMParser para converter em blocos tipados (HtmlBlock): p, h1-h6, li (ul/ol), blank. Cada bloco tem spans: HtmlSpan[] com flags bold, italic, strike. renderHtmlBlocks() renderiza com doc.setFont() por span, word-wrap manual por token, strikethrough manual (linha sobre o texto), headings com escala HEADING_SCALES = {1:1.6, 2:1.35, 3:1.2, 4:1.1, 5:1.05, 6:1.0}, e paginação automática via checkPage(blockLineH).
Logo da clínica no PDF: GET /tenant/settings foi movido do grupo tenant_admin para o grupo de leitura geral autenticado — radiologistas (não-admins) precisam da logo para exibição no drawer de preview e geração do PDF.
Assinatura do médico no PDF/preview: o campo signature está em $hidden no model User (evita expor base64 pesado em listagens). O ReportController chama $report->author->makeVisible(['signature']) antes de serializar a resposta dos endpoints showByStudy, upsertByStudy e correctFinal. A listagem de usuários expõe apenas has_signature: bool (campo computado via getRawOriginal('signature')).
Otimizações de performance (imagens pesadas travavam o PDF no browser):
| Problema | Causa | Solução |
|---|---|---|
| Background embutido N vezes em multi-página | addImage dentro de drawHeaderFooter(), chamada a cada página | Parâmetro alias: "bg" no addImage — jsPDF reutiliza os dados, embute só uma vez |
| Background pesado no upload | PNG/JPG bruto de até 3MB armazenado no JSONB | Validação de file.type + fundo branco no canvas + resize para máx. 1240×1754px (A4@150dpi) + export JPEG 75% no momento do upload |
| Imagens CT em resolução completa | Orthanc /rendered retorna 512×512–1024×1024px; jsPDF processa tudo no main thread | Antes de addImage, cada imagem é redimensionada via canvas para o tamanho de display real no PDF (calculado a partir de colunas e formato, @150dpi, JPEG 82%) |
Layout das páginas de imagens
Páginas de imagens usam um frame mínimo — não replicam o template completo do laudo:| Elemento | Páginas do laudo | Páginas de imagens |
|---|---|---|
| Background (papel timbrado) | ✅ | ✅ |
| Logo | ✅ | ✅ |
| Nome da unidade | ✅ | ✅ |
| Linha divisória header | ✅ | ✗ |
| Bloco do paciente | ✅ | ✗ |
| Rodapé / assinatura | ✅ | ✗ |
headerLineY) e se estendem até PAGE_H - PAGE_MARGIN, aproveitando toda a área disponível.
Imagens renderizadas — sem cache
O endpointGET /studies/{id}/instances/{instanceId}/rendered não armazena nada. A cada request:
- Backend valida JWT e escopo de tenant
- Faz proxy para
GET {ORTHANC_URL}/instances/{orthanc_id}/rendered - Orthanc lê o arquivo DICOM bruto do seu storage e converte para JPEG on-the-fly
- Backend retorna com
Cache-Control: no-cache, private— browser não armazena - Frontend recebe o blob, converte para base64, usa e descarta
pacs_storage/thumbnails/{orthanc_study_id}/preview.jpg — exclusivamente para a listagem de estudos.
Se o usuário exportar o mesmo estudo duas vezes, o Orthanc processa as imagens duas vezes do zero. Para um volume normal isso não é gargalo, mas se necessário no futuro a solução seria um cache Redis com TTL curto (ex: 5 min) usando orthanc_instance_id como chave.
Modalidades DICOM disponíveis nos presets
Select padronizado com 19 modalidades: CT, MR, US, CR, DX, XA, RF, MG, NM, PT, SPECT, ES, OP, OCT, DOC, ECG, SEG, RTPLAN.12. OHIF Viewer — Visualizador DICOM
Como funciona
O OHIF roda em:3001 e carrega imagens via DicomWeb. Para garantir isolamento multi-tenant, o acesso ao Orthanc passa por um proxy nginx autenticado.
Modos de abertura do viewer
Dois botões na sidebar da tela de detalhe do estudo: “Abrir Viewer (Nova Aba)” — comportamento original, abre OHIF em nova aba do browser. “Abrir Viewer (Junto com Laudo)” — split view inline: OHIF em<iframe> à esquerda + painel de laudo à direita, tudo na mesma página. Botão “Fechar Viewer” no cabeçalho do painel direito retorna ao modo normal. Não requer nenhuma alteração no backend, nginx ou app-config.js — o mecanismo de token é idêntico.
Fluxo de token (compartilhado pelos dois modos)
Configuração do OHIF
docker/ohif/app-config.js — montado como volume read-only:
:8043 (proxy), não direto para o Orthanc.
13. Frontend — Next.js
Estrutura de rotas
Autenticação
O JWT é armazenado em cookie httpOnly setado pelo backend — o JavaScript nunca acessa o token diretamente. Dois cookies com funções distintas:| Cookie | httpOnly | Quem seta | Usado por |
|---|---|---|---|
pacs_access_token | ✅ Sim | Backend (Set-Cookie) | Browser envia automaticamente via withCredentials: true |
pacs_user_role | ❌ Não | Frontend JS | Middleware Next.js (proxy.ts) para controle de rotas |
:3000) e backend (:8000) são origens diferentes. O cookie httpOnly do backend fica scoped para :8000 — o middleware Next.js em :3000 não consegue lê-lo. O pacs_user_role serve como indicador de sessão para o roteamento do Next.js.
Axios: withCredentials: true em frontend/lib/api/client.ts — browser envia pacs_access_token automaticamente em toda requisição cross-origin.
Backend: SetBearerFromCookie middleware (app/Http/Middleware/) lê o cookie e injeta Authorization: Bearer <token> antes do guard JWT processar. Zero mudança na lógica de autenticação existente.
CORS: backend/config/cors.php com supports_credentials: true e allowed_origins configurado via FRONTEND_URL no .env (não pode ser * quando credentials estão habilitados).
Estado global: Zustand em frontend/store/auth-store.ts — guarda o objeto user, não o token.
Bootstrap de sessão: bootstrapSession() chama GET /me diretamente — se o cookie httpOnly for válido, o backend autentica e retorna o usuário; se não, 401 e a sessão local é limpa.
Middleware Next.js: frontend/proxy.ts — usa pacs_user_role para verificar se há sessão ativa e para controle de acesso por role.
Layout e UX
- Sidebar recolhível: rail verde (
w-px bg-primary) na borda direita percorre toda a altura. Botão redondo (22px) no 1/4 superior do rail abre/fecha. Colapsa para modo ícones (w-[48px]) com labels animados. Preferência salva emlocalStorage. Fecha automaticamente ao entrar em/studies/[id]. - Split view: ao abrir viewer “Junto com Laudo”, o layout troca para
h-[calc(100vh-52px)]sem padding — iframe OHIF à esquerda (flex-1) + painel de laudo à direita (w-[460px]).
Serviços de API
Env vars do frontend
Definidas nodocker-compose.yml:
14. Proxy CORS do Orthanc
Arquivo:docker/orthanc-cors-proxy/nginx.conf
O nginx tem dois location:
-
/_auth(internal) — repassa parabackend:8000/api/viewer/token/validatecom os headers necessários. Nunca é acessado diretamente do browser. -
/— valida token viaauth_request /_auth, depois faz proxy paraorthanc:8042. Adiciona headers CORS para o browser poder fazer requisições cross-origin.
OPTIONS (preflight CORS) passam direto sem autenticação — necessário para que o browser consiga fazer a negociação CORS antes de enviar o token.
15. Como Editar Cada Parte
Adicionar uma nova rota de API
- Editar
backend/routes/api.php— adicionar a rota no grupo correto - Criar o método no controller correspondente em
backend/app/Http/Controllers/ - Recarregar:
docker exec pacs_backend php artisan octane:reload
Adicionar um novo campo ao banco
- Criar migration:
docker exec pacs_backend php artisan make:migration nome_da_migration - Editar o arquivo em
backend/database/migrations/ - Rodar:
docker exec pacs_backend php artisan migrate - Adicionar o campo em
$fillablee$castsno Model correspondente
Alterar a configuração do Orthanc
- Editar
docker/orthanc/orthanc.json - Recriar o container:
docker compose up -d --force-recreate orthanc
Alterar o hook Lua (webhook)
- Editar
docker/orthanc/scripts/hook.lua.template - Recriar:
docker compose up -d --force-recreate orthanc
Alterar o proxy nginx (CORS / autenticação do viewer)
- Editar
docker/orthanc-cors-proxy/nginx.conf - Recarregar:
docker compose up -d --force-recreate orthanc-cors-proxy
Alterar a configuração do OHIF
- Editar
docker/ohif/app-config.js - Recarregar:
docker compose up -d --force-recreate ohif
Adicionar uma página no frontend
- Criar
frontend/app/(grupo)/caminho/page.tsx - O Next.js recarrega automaticamente (hot reload via volume mount)
Alterar o worker Python
- Editar
worker/main.py - Recriar:
docker compose up -d --force-recreate worker
Rodar os testes do backend
Ver logs de um serviço
16. Arquitetura Alvo — Orthanc On-Premise com Forwarding
Esta seção descreve a arquitetura futura desejada, que ainda não está implementada. O objetivo é que cada clínica tenha um Orthanc local instalado em sua infraestrutura, que encaminha os estudos para o cloud de forma autenticada e identificada.
Motivação
No modelo atual, os aparelhos enviam DICOM diretamente para o Orthanc na nuvem. Isso cria três problemas:- Latência e dependência de internet — a modalidade precisa de conexão estável para enviar a imagem. Se a internet cair, o exame não chega ao sistema.
- Identificação frágil — o tenant/unit é descoberto por lookup do AET. Se o AET não estiver cadastrado, o estudo vai para quarentena.
- Exposição da porta DICOM (4242) na internet — qualquer IP pode tentar enviar DICOMs.
Fluxo alvo
Opção A — VPN dedicada por clínica/unidade
Cada unidade tem um túnel VPN ponto-a-ponto com o cloud. O Orthanc local se conecta ao Orthanc cloud via DICOM C-STORE ou DicomWeb dentro do túnel.- Isolamento total de rede — o tráfego DICOM nunca passa pela internet aberta
- O IP do túnel pode ser usado como identidade (cada clínica tem um IP de VPN distinto)
- Familiar para equipes de TI de hospitais (VPN site-to-site é padrão na área)
- Protege a porta DICOM —
:4242no cloud só aceita conexões de IPs VPN conhecidos
- Não escala bem — com 100+ clínicas, são 100+ túneis para gerenciar, monitorar, renovar
- Custo operacional alto: cada novo cliente exige provisionamento manual de VPN
- Troubleshooting complexo quando o túnel cai (logs de VPN + logs de DICOM misturados)
- Depende de compatibilidade com o roteador/firewall do cliente
Opção B — mTLS (Mutual TLS) sem VPN
Cada unidade/clínica recebe um certificado TLS de cliente único emitido por nós. O Orthanc local usa esse certificado ao fazer forwarding via DicomWeb (HTTPS) para o cloud. O nginx do cloud valida o certificado e extrai a identidade do CN.- Escala para milhares de clínicas sem nenhuma configuração de rede extra
- Revogar acesso de uma clínica = revogar o certificado (imediato, sem alterar infra)
- Sem VPN para gerenciar — cada cliente só precisa de internet comum
- Orthanc já suporta nativamente (DicomWeb com HTTPS + certificado de cliente)
- Padrão da indústria para APIs entre serviços em healthcare (SMART on FHIR, IHE)
- Requer infraestrutura de PKI (Certificate Authority própria ou serviço como CFSSL/Vault)
- Certificados precisam ser renovados periodicamente (pode ser automatizado com ACME)
- Configuração inicial do Orthanc local é mais técnica (TLS + cert path)
- O tráfego passa pela internet (mitigado pelo TLS, mas não tem isolamento de rede)
Opção C — API Key no cabeçalho HTTP
O Orthanc local faz forwarding via DicomWeb (HTTPS) e inclui um headerX-Tenant-Key: chave-unica em cada requisição. O backend valida a chave e identifica o tenant/unit.
- Implementação mais simples de todas — só um campo na config do Orthanc local
- Fácil de entender e debugar (a chave aparece nos logs)
- Não requer infraestrutura de PKI
- Menos seguro que mTLS — a chave pode vazar (logs, config exposta, etc.)
- Rotacionar chaves exige atualização manual na config do Orthanc de cada clínica
- Sem autenticação no nível de transporte — depende inteiramente do HTTPS para proteção
Comparativo das opções
| Critério | VPN por unidade | mTLS | API Key |
|---|---|---|---|
| Segurança | Alta (isolamento de rede) | Alta (criptografia mútua) | Média (depende do HTTPS) |
| Escalabilidade | Baixa | Alta | Alta |
| Complexidade de operação | Alta | Média | Baixa |
| Complexidade de setup no cliente | Alta | Média | Baixa |
| Revogação de acesso | Manual (desligar VPN) | Imediata (revogar cert) | Imediata (desativar chave) |
| Funciona offline (local) | Sim | Sim | Sim |
| Dependência de rede do cliente | VPN compatível | Internet comum | Internet comum |
| Padrão em healthcare enterprise | Sim | Sim (crescente) | Não |
Topologia dos Orthancs locais
Além da autenticação, é preciso decidir quantos Orthancs instalar por cliente.Opção T1 — Um Orthanc por unidade física
Cada filial/unidade da clínica tem seu próprio Orthanc instalado localmente.- A identidade de unidade está na config da máquina — sem ambiguidade
- Falha em uma unidade não afeta as outras
- Cada Orthanc é mais simples (menos estudos, menos carga)
- Padrão da indústria (cada site tem seu nó PACS próprio)
- Mais máquinas para instalar, atualizar e monitorar
- Custo de hardware multiplicado pelo número de unidades
Opção T2 — Um Orthanc por clínica, unidade identificada pelo AET
Uma única instalação Orthanc por clínica, que recebe de todos os aparelhos de todas as unidades. A unidade é identificada pelo AET do aparelho (que já é o modelo atual).tenant_id fixo; o cloud resolve o unit_id pelo AET (como hoje, mas agora confiável porque o tenant já está garantido pela autenticação).
Prós:
- Uma instalação por cliente — mais simples de gerenciar
- Aparelhos continuam usando AET como identificador (zero mudança nas modalidades)
- Se o Orthanc local cair, todas as unidades param
- Ponto único de falha por clínica
- A rede interna precisa alcançar aparelhos de unidades diferentes (pode exigir configuração de rede)
Opção T3 — Um Orthanc por clínica com roteamento interno por AET
Variante da T2: o Orthanc local tem Lua scripting que, ao receber de um AET específico, encaminha para um destino diferente no cloud (ex: endpoint de unidade). Permite lógica de roteamento sem múltiplos Orthancs. Prós: flexível, centralizado Contras: mais complexidade no script Lua, mais difícil de debugarRecomendação de caminho (análise inicial)
Para um produto SaaS com crescimento orgânico de clientes, a análise inicial apontava para:- Autenticação: começar com API Key (simples de onboarding) e migrar para mTLS quando a base de clientes exigir mais segurança ou compliance.
- Topologia: Um Orthanc por unidade física — é o padrão da indústria, elimina o ponto único de falha, e simplifica a identificação (a identidade já está na config da máquina).
- VPN: reservar para clientes enterprise que exijam por contrato — não usar como padrão do produto.
Decisão adotada — VPN por clínica/unidade
Após analisar as opções em relação ao perfil real dos equipamentos nas clínicas, a decisão foi usar VPN (Opção A) como modelo padrão de conectividade. Por que não API Key nem mTLS:- A grande maioria dos aparelhos de imagem médica (CT, RX, RM) usa DICOM C-STORE sobre a porta 4242 — um protocolo binário que não é HTTP.
- As opções API Key e mTLS dependem de DicomWeb (STOW-RS), que é DICOM sobre HTTP/HTTPS. Só equipamentos modernos suportam isso; equipamentos legados (a maioria nas clínicas brasileiras) não suportam.
- Expor a porta 4242 na internet pública sem VPN significa que qualquer IP pode tentar enviar imagens — inaceitável para um sistema de saúde.
- O Caddy (proxy reverso) não consegue fazer proxy de DICOM — o protocolo é binário, não HTTP, então não há como proteger a porta 4242 com TLS via Caddy.
- Mantém a porta 4242 completamente fora da internet pública.
- Os aparelhos continuam usando DICOM C-STORE nativo, sem nenhuma mudança de configuração no equipamento.
- O tráfego de imagens médicas (dados sensíveis) nunca trafega pela internet aberta.
- A identidade da clínica/unidade é garantida pelo IP do túnel VPN (cada clínica tem um IP de VPN fixo e distinto).
Plano de Implementação
O que NÃO muda no código
| Componente | Status |
|---|---|
hook.lua / Lua hook | Sem mudança — dispara webhook igual |
WebhookController | Sem mudança — valida secret e despacha job |
ProcessStudyMetadata | Mudança pequena (ver abaixo) |
Python worker main.py | Sem mudança — recebe tenant_id/unit_id prontos |
| Todos os models, controllers, RBAC | Sem mudança |
Mudanças de código necessárias
1. Migration — tabelamodalities
Os campos device_serial_number, device_uid, station_name, manufacturer, manufacturer_model_name foram criados para representar máquinas físicas. Com VPN, um registro em modalities representa um Orthanc local (gateway) — esses campos não fazem sentido e serão removidos.
A informação técnica do equipamento (fabricante, modelo, número de série) já vem nos próprios tags DICOM de cada estudo e é indexada pelo worker Python. Não há perda de dado.
Campos após a migration:
| Campo | Tipo | Para quê |
|---|---|---|
id | uuid | PK |
tenant_id | uuid FK | A qual clínica pertence |
unit_id | uuid FK | A qual unidade pertence |
name | string | Nome amigável (ex: “Gateway Unidade Centro”) |
aet | string | AET do Orthanc local — chave de lookup |
vpn_ip | string nullable | IP fixo do túnel VPN para validação dupla |
created_at / updated_at | timestamps | — |
OrthancService — adicionar getRemoteIP() ✅ Implementado
Busca /instances/{id}/metadata/RemoteIP via o método privado getInstanceMetadata() — mesmo padrão do getRemoteAET(). O IP é logado para auditoria junto com o AET, mas não é critério de decisão no job (responsabilidade do firewall).
3. ProcessStudyMetadata — validação por AET ✅ Implementado
O job resolve tenant+unit exclusivamente pelo AET. A segurança de IP é garantida pelas camadas anteriores. Três camadas em cascata:
- Firewall — porta 4242 só aceita IPs do range VPN (
ufw) - Orthanc —
KnownAETsOnly: true+DicomModalitiescom AETs autorizados - Job — AET deve estar em
modalitiescom tenant+unit; caso contrário → quarentena
Mudanças de infraestrutura
WireGuard no servidor:Processo de onboarding de nova unidade
- Provisionar par de chaves WireGuard para a unidade
- Adicionar
[Peer]nowg0.confdo servidor com o IP fixo atribuído (ex:10.100.0.5) - Instalar WireGuard client + Orthanc local na VM da clínica
- Configurar o Orthanc local com o AET da unidade e forwarding para o cloud via VPN
- No painel do sistema: cadastrar a unidade em
modalitiescom o AET e ovpn_ipatribuído - Fazer um envio de teste e verificar os logs do
pacs_queue
Estado de implementação
| Item | Status |
|---|---|
| Decisão de usar VPN | ✅ Decidido |
| Documentação da arquitetura | ✅ Este documento |
Migration: remover campos device, adicionar vpn_ip | ✅ Implementado |
OrthancService::getRemoteIP() | ✅ Implementado |
ProcessStudyMetadata: validação por AET (3ª camada) | ✅ Implementado |
| WireGuard no servidor cloud | ⬜ Pendente |
| Firewall porta 4242 | ⬜ Pendente |
| Config Orthanc local + forwarding | ⬜ Pendente |
| Guia/script de onboarding de clínica | ⬜ Pendente |
