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

  1. Visão Geral
  2. Stack e Tecnologias
  3. Serviços Docker e Portas
  4. Variáveis de Ambiente
  5. Volumes Compartilhados
  6. Fluxo de Ingestão DICOM
  7. Orthanc — Servidor PACS
  8. Backend — API Laravel
  9. Worker Python — Processamento de Imagens
  10. Multi-Tenant e RBAC
  11. Sistema de Laudos
  12. OHIF Viewer — Visualizador DICOM
  13. Frontend — Next.js
  14. Proxy CORS do Orthanc
  15. Como Editar Cada Parte
  16. 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.
[Aparelho DICOM]
      │ C-STORE (DICOM protocol)

[Orthanc :4242]  ←── armazena as imagens DICOM brutas
      │ Lua hook (OnStableStudy)
      │ POST /api/orthanc/webhook

[Backend Laravel :8000]  ←── API REST, autenticação JWT, regras de negócio
      │ Redis queue (pacs_python_queue)

[Worker Python]  ←── gera thumbnail, indexa series/instances no Postgres


[PostgreSQL :5432]  ←── banco de dados de todos os metadados

[Frontend Next.js :3000]  ←── interface do usuário
      │ usa API do backend
      │ abre OHIF com token temporário

[OHIF Viewer :3001]  ←── visualizador DICOM no browser
      │ DicomWeb requests (QIDO-RS, WADO-RS)

[Nginx CORS Proxy :8043]  ←── valida token, repassa ao Orthanc


[Orthanc :8042 (HTTP)]  ←── serve as imagens DICOM via DicomWeb

2. Stack e Tecnologias

ComponenteTecnologiaVersãoPor que foi escolhido
Banco de dadosPostgreSQL15Multi-tenant com UUID, suporte a JSON nativo, ilike case-insensitive
Cache / FilasRedis7Sessions do Laravel, filas de job, tokens temporários do viewer
Servidor PACSOrthanclatest (com plugins)Open-source, suporta DicomWeb, extensível via Lua/REST
BackendLaravel12 + Octane/RoadRunnerPHP moderno com alta performance via Octane, JWT auth, Eloquent ORM
WorkerPython3.11pydicom para leitura DICOM, Pillow/NumPy para geração de thumbnail
FrontendNext.js16 (App Router)React server components, roteamento por grupos de role
VisualizadorOHIF ViewerlatestOpen-source, padrão da indústria para DICOM no browser
Proxy CORSNginxalpineauth_request para validar token antes de repassar ao Orthanc

3. Serviços Docker e Portas

Todos os serviços rodam dentro da rede Docker pacs_network e se comunicam pelo nome do container.
ContainerNomePorta Host → ContainerPapel
pacs_postgrespostgres5432:5432Banco de dados
pacs_redisredis6379:6379Cache, sessões, filas
pacs_orthancorthanc4242:4242 (DICOM)Servidor PACS (HTTP interno: 8042)
pacs_backendbackend8000:8000API Laravel/Octane
pacs_workerworker— (sem porta)Worker Python (consome fila DICOM)
pacs_queuequeue— (sem porta)Queue worker Laravel (processa jobs do webhook)
pacs_frontendfrontend3000:3000Interface Next.js
pacs_ohifohif3001:80Visualizador DICOM
pacs_orthanc_cors_proxyorthanc-cors-proxy8043:80Proxy nginx autenticado para Orthanc
Nota: O Orthanc expõe a porta HTTP 8042 apenas dentro da rede Docker — não é acessível diretamente do host. O acesso externo passa pelo proxy 8043.
Produção: o docker-compose.prod.yml adiciona o container pacs_caddy (Caddy 2 Alpine) nas portas 80:80 e 443:443 como reverse proxy com TLS automático. Não existe em dev.
Arquivo de configuração: 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

DB_DATABASE=pacs_database
DB_USERNAME=pacs_admin
DB_PASSWORD=...

REDIS_HOST=redis
REDIS_PORT=6379

ORTHANC_WEBHOOK_SECRET=dev-webhook-secret-change-in-prod
Usadas pelo 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

APP_ENV=local          # local | production
APP_DEBUG=true         # false em produção

ORTHANC_URL=http://orthanc:8042          # endereço interno do Orthanc
ORTHANC_WEBHOOK_SECRET=dev-...           # deve bater com o do Orthanc

DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=pacs_database
DB_USERNAME=pacs_admin
DB_PASSWORD=...

SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
CACHE_STORE=redis

REDIS_HOST=redis
REDIS_PORT=6379

JWT_SECRET=...         # gerado com: php artisan jwt:secret
Importante: As variáveis DB_* do worker Python vêm do .env raiz (não do backend/.env). Se o worker não indexar series/instances, verifique se essas variáveis estão no .env raiz.

5. Volumes Compartilhados

VolumeMontado em (backend)Montado em (worker)Conteúdo
pacs_storage/app/pacs_mount/app/storageThumbnails gerados pelo worker
pacs_pg_dataDados do PostgreSQL (persistência)
pacs_redis_dataDados do Redis (persistência)
pacs_orthanc_dataImagens DICOM armazenadas pelo Orthanc
O thumbnail de um estudo é salvo pelo worker em:
/app/storage/thumbnails/{orthanc_study_id}/preview.jpg
E lido pelo backend em:
/app/pacs_mount/thumbnails/{orthanc_study_id}/preview.jpg
São o mesmo arquivo — volumes Docker apontam para o mesmo lugar no host.

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.
1. Aparelho envia via DICOM C-STORE → Orthanc :4242 (exposto publicamente)

2. Orthanc acumula as imagens do estudo.
   Após 10 segundos sem novas imagens (StableAge=10), considera "estável".

3. Orthanc executa o hook Lua (OnStableStudy):
   → POST http://backend:8000/api/orthanc/webhook
   → Header: X-Webhook-Secret: [secret]
   → Body: {"ID": "orthanc-study-uuid"}

4. Backend (WebhookController):
   → Valida o X-Webhook-Secret
   → Despacha o job ProcessStudyMetadata para a fila Redis padrão do Laravel

5. Backend (ProcessStudyMetadata job — executa assincronamente):
   → GET http://orthanc:8042/studies/{id}         → metadados do estudo
   → GET http://orthanc:8042/series/{id}           → primeira série
   → GET http://orthanc:8042/instances/{id}/metadata/RemoteAET → AET de quem enviou

   → Modality::where('aet', $remoteAet)->first()  → resolve Tenant + Unit
   → Se não encontrar AET cadastrado: estudo vai para tenant "quarentena"

   → Cria/atualiza o registro Study no PostgreSQL (com tenant_id + unit_id)
   → Enfileira payload enxuto no Redis (fila pacs_python_queue para o worker Python)

6. Worker Python (main.py — consome pacs_python_queue via BLPOP):
   → Gera thumbnail 300×300 JPEG da primeira imagem DICOM
   → Salva em /app/storage/thumbnails/{orthanc_study_id}/preview.jpg
   → Indexa Patient, Series, Instances no PostgreSQL
   → Atualiza modalities[] no Study

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.
1. Aparelho envia via DICOM C-STORE → Orthanc Local (rede interna da clínica)

2. Orthanc local armazena e faz forwarding para o cloud via túnel VPN

3. Cloud Orthanc :4242 recebe — RemoteAET = AET do Orthanc local (ex: "CLINICA_A_U1")
   (não mais o AET do aparelho físico)

4-6. Fluxo idêntico ao modelo atual a partir do passo 3 →
   O `ProcessStudyMetadata` faz lookup do RemoteAET na tabela `modalities`
   → Cada entrada em `modalities` representa agora um Orthanc local (gateway),
     não um aparelho físico individual
Chave de entendimento: O mecanismo de resolução de tenant+unit por AET não muda. O que muda é a semântica: um registro em modalities passa a representar o Orthanc local da unidade, não o equipamento físico.

Resolução de Tenant/Unit — detalhe crítico

O ProcessStudyMetadata resolve tenant+unit exclusivamente via RemoteAET:
$remoteAet = $orthancService->getRemoteAET($instanceId);
// → GET /instances/{id}/metadata/RemoteAET

$maquina = Modality::where('aet', $remoteAet)->first();
$tenant  = $maquina->tenant;
$unitId  = $maquina->unit_id;
  • 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.
Camadas de defesa (em ordem de atuação):
CamadaOndeO que bloqueia
1ª — Firewallufw no servidorQualquer IP fora do range VPN nunca chega à porta 4242
2ª — OrthancDicomModalities + KnownAETsOnlySó AETs explicitamente cadastrados no orthanc.json conseguem fazer C-STORE
3ª — Job LaravelProcessStudyMetadataAET não associado a tenant/unit no banco → quarentena
O 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

ArquivoO que controla
docker/orthanc/orthanc.jsonConfiguração principal do Orthanc
docker/orthanc/scripts/hook.lua.templateTemplate do script Lua (webhook)
docker/orthanc/entrypoint.shGera /tmp/hook.lua substituindo variáveis antes de iniciar

orthanc.json — principais configurações

{
  "HttpPort": 8042,          // Porta HTTP (interna Docker)
  "DicomPort": 4242,         // Porta DICOM (exposta ao host)
  "DicomAet": "ORTHANC",     // Nome AET deste servidor
  "AuthenticationEnabled": false,  // Sem auth na rede interna (usar proxy para expor)
  "StableAge": 10,           // Segundos sem novas imagens para considerar estável
  "DicomWeb": {
    "Enable": true,
    "Root": "/dicom-web"     // Base URL da API DicomWeb
  },
  "LuaScripts": ["/tmp/hook.lua"]  // Script gerado no entrypoint
}

Hook Lua

O arquivo hook.lua.template contém:
function OnStableStudy(studyId, tags, metadata)
    local payload = '{"ID": "' .. studyId .. '"}'
    local headers = {}
    headers["Content-Type"] = "application/json"
    headers["X-Webhook-Secret"] = "${ORTHANC_WEBHOOK_SECRET}"
    HttpPost("http://backend:8000/api/orthanc/webhook", payload, headers)
end
O 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

ChamadaEndpoint OrthancO que retorna
Detalhes do estudoGET /studies/{id}{ID, Series[], MainDicomTags, PatientMainDicomTags}
Detalhes da sérieGET /series/{id}{ID, Instances[], MainDicomTags}
AET remotoGET /instances/{id}/metadata/RemoteAETString com o AET do aparelho
Arquivo DICOM brutoGET /instances/{id}/fileBytes 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 cookie pacs_access_token
  • Banco: Eloquent ORM com PostgreSQL
  • Filas: Redis (queue padrão do Laravel + pacs_python_queue manual)
Localização dos arquivos principais:
backend/
  app/
    Http/Controllers/    # 13 controllers
    Http/Middleware/     # EnsureSuperAdmin, EnsureTenantAdmin, SetBearerFromCookie
    Jobs/                # ProcessStudyMetadata
    Models/              # 12 modelos Eloquent
    Services/            # OrthancService
    Traits/              # BelongsToTenant, BelongsToTenantAndUnit
  config/
    cors.php             # CORS com supports_credentials=true, allowed_origins via FRONTEND_URL
  routes/
    api.php              # Todas as rotas da API
Middleware de autenticação (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:
docker exec pacs_backend php artisan octane:reload
# ou
docker compose restart backend

Mapa completo de rotas

Públicas (sem autenticação)

MétodoRotaControllerO que faz
POST/api/loginAuthController@loginRecebe email+password, retorna JWT no cookie
POST/api/orthanc/webhookWebhookController@handleOrthancRecebe notificação do Orthanc (valida X-Webhook-Secret)
GET/api/viewer/token/validateViewerTokenController@validateValida token do viewer (chamado pelo nginx auth_request)

Protegidas por JWT (auth:api)

MétodoRotaControllerO que fazRetorna
POST/api/logoutAuthController@logoutInvalida o token JWT204
GET/api/meinlineDados do usuário autenticado com tenant/unit{id, email, name, tenant_id, unit_id, is_super_admin, is_tenant_admin, tenant, unit}
GET/api/studiesStudyController@indexLista estudos com paginação (filtros: search, patient_uuid, exclude_id)Paginado {data[], current_page, last_page, total}
GET/api/studies/{id}StudyController@showDetalhe de um estudo com patient, unit, tenant. Fallback: se modalities nulo, deriva das sériesStudy
GET/api/studies/{id}/seriesStudyController@seriesLista séries do estudoSeries[]
GET/api/series/{id}/instancesStudyController@instancesBySeriesLista instâncias de uma sérieInstance[]
GET/api/studies/{id}/thumbnailStudyController@thumbnailStream JPEG do thumbnailimage/jpeg
GET/api/studies/{id}/reportsReportController@showByStudyLaudo atual do estudo{study_id, report_status, report}
POST/api/studies/{id}/reportsReportController@upsertByStudySalva rascunho ou finaliza laudo{message, study_id, report_status, report}
POST/api/studies/{id}/reports/correctionsReportController@correctFinalCorrige laudo já finalizado{message, study_id, report_status, report}
GET/api/studies/{id}/reports/revisionsReportController@revisionsForStudyHistórico completo de revisõesReportRevision[]
GET/api/patientsPatientController@indexLista pacientesPaginado
GET/api/patients/{id}PatientController@showDetalhe do pacientePatient
GET/api/patients/{id}/studiesPatientController@studiesEstudos de um pacienteStudy[]
POST/api/viewer/tokenViewerTokenController@generateGera token temporário (10min) para OHIF{token: "uuid"}
GET/api/studies/{id}/instances/{instanceId}/renderedStudyControllerProxy da imagem DICOM renderizada (JPEG) — sem cache, validação de tenantimage/jpeg

Qualquer usuário autenticado do tenant — prefixo /api/tenant (leitura)

MétodoRotaControllerO que faz
GET/tenant/templatesReportTemplateController@indexLista templates ativos da unidade do usuário
GET/tenant/templates/{id}ReportTemplateController@showDetalhe do template
GET/tenant/my-templateReportTemplateController@myTemplateTemplate ativo da unidade do usuário logado
GET/tenant/report-presetsReportPresetController@indexLista presets (filtros: modality, is_active)
GET/tenant/report-presets/{id}ReportPresetController@showDetalhe do preset
GET/tenant/settingsTenantSettingsController@showConfigurações do tenant (logo, nome) — acessível a todos para exibir logo no preview/PDF

Qualquer usuário autenticado do tenant — leitura adicional

MétodoRotaControllerO que faz
GET/tenant/unitsUnitController@indexLista unidades do tenant (scoped por BelongsToTenant — usado para popular dropdowns em templates/presets)

Tenant Admin (auth:api + tenant_admin) — prefixo /api/tenant

MétodoRotaControllerO que faz
POST/tenant/templatesReportTemplateController@storeCria template (unit_id obrigatório)
PATCH/tenant/templates/{id}ReportTemplateController@updateEdita template
DELETE/tenant/templates/{id}ReportTemplateController@destroySoft delete do template
POST/tenant/report-presetsReportPresetController@storeCria preset
PATCH/tenant/report-presets/{id}ReportPresetController@updateEdita preset
DELETE/tenant/report-presets/{id}ReportPresetController@destroyDeleta preset
PATCH/tenant/settingsTenantSettingsController@updateAtualiza configurações do tenant (logo, nome)

Super Admin (auth:api + super_admin) — prefixo /api/admin

MétodoRotaControllerO que faz
GET/admin/tenantsTenantController@indexLista clínicas
POST/admin/tenantsTenantController@storeCria clínica
PATCH/admin/tenants/{id}/statusTenantController@updateStatusAtiva/desativa clínica
GET/admin/unitsUnitController@indexLista unidades (todas)
POST/admin/unitsUnitController@storeCria unidade
GET/admin/modalitiesModalityController@indexLista modalidades (aparelhos)
POST/admin/modalitiesModalityController@storeCadastra aparelho com AET
GET/admin/usersUserController@indexLista usuários (todos)
POST/admin/usersUserController@storeCria usuário
PATCH/admin/users/{id}UserController@updateEdita usuário

Comunicação Backend → Orthanc

Toda comunicação com o Orthanc passa pelo OrthancService (app/Services/OrthancService.php). Ele usa Http::get() do Laravel (wrapper do Guzzle) para fazer requisições HTTP internas:
Backend → http://orthanc:8042/...
A URL base vem de config('services.orthanc.url'), definida via ORTHANC_URL no backend/.env.

9. Worker Python — Processamento de Imagens

O que faz

Consome a fila pacs_python_queue do Redis e para cada mensagem:
  1. Gera thumbnail — baixa o DICOM bruto do Orthanc, extrai o pixel array, normaliza para 8-bit, salva como JPEG 300×300
  2. Indexa Patient — upsert atômico por (tenant_id, patient_id)
  3. Indexa Series — para cada série do estudo (busca Orthanc)
  4. Indexa Instances — para cada imagem de cada série
  5. Atualiza modalities — array de modalidades distintas no estudo

Localização

worker/main.py     # Arquivo único de toda a lógica

Endpoints do Orthanc usados pelo worker

ChamadaEndpointO que usa
Detalhes do estudoGET /studies/{id}Lista de series_ids, patient tags
Detalhes da sérieGET /series/{id}Lista de instance_ids, modality, series_number
Arquivo DICOMGET /instances/{id}/fileBytes para gerar thumbnail
Detalhes da instanceGET /instances/{id}SOPInstanceUID, InstanceNumber, Rows, Columns

Variáveis de ambiente

Definidas em docker-compose.yml na seção worker.environment:
REDIS_HOST: redis
ORTHANC_URL: http://orthanc:8042
DEBUG_WORKER: 1          # Ativa logs detalhados (desativar em prod)
DB_HOST: postgres
DB_PORT: 5432
DB_DATABASE: ${DB_DATABASE}   # vem do .env raiz
DB_USERNAME: ${DB_USERNAME}   # vem do .env raiz
DB_PASSWORD: ${DB_PASSWORD}   # vem do .env raiz

10. Multi-Tenant e RBAC

Hierarquia

Tenant (clínica)
  └── Unit (filial/unidade)
        └── User (médico, recepcionista, admin)
        └── Modality (gateway Orthanc local)
        └── Study (exame)
              └── Series
                    └── Instance (imagem DICOM)
              └── Report (laudo)

Roles — modelo de negócio

RoleFlagResponsabilidade
Super Adminis_super_admin=trueOperação da plataforma — infra, clínicas, unidades, usuários, gateways
Tenant Adminis_tenant_admin=trueGestã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
Tenant Admin (dono/gestor da clínica):
  • 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 Admin
  • Gerenciar unidades → responsabilidade do Super Admin
  • Não pode criar, editar ou corrigir laudosPOST /studies/{id}/reports e POST /studies/{id}/reports/corrections retornam 403
Usuário Regular (doutor):
  • 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 trait BelongsToTenant (app/Traits/BelongsToTenant.php) adiciona um global scope no Eloquent:
static::addGlobalScope('tenant', function (Builder $builder) {
    // Super admin: vê tudo
    if (Auth::check() && Auth::user()->is_super_admin) return;

    // Outros: filtra pelo tenant_id do JWT
    if (Auth::check() && Auth::user()->tenant_id) {
        $builder->where('tenant_id', Auth::user()->tenant_id);
    }
});
Consequência: qualquer 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ínica
  • unit_id — qual unidade
  • is_super_admin — flag booleano
  • is_tenant_admin — flag booleano
Esses claims são lidos por todos os middlewares e traits de scoping.

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_id required). Uma unidade tem no máximo um template ativo — ao ativar um novo, os outros são desativados automaticamente. Não tem template_id no preset.
  • Preset — Modelo de texto reutilizável para um tipo de exame (default_content em HTML Tiptap). Recurso do tenant inteirounit_id é sempre null; 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 por study_description_contains e/ou modality.
  • Report — O laudo em si, vinculado a um Study. O campo content armazena 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_content e new_content em 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:
% = mm × 100 / dimensão_página
mm = (% / 100) × dimensão_página   (PAGE_W = 210mm, PAGE_H = 297mm)
Papel timbrado (background): upload de PNG/JPG pelo tenant admin. No momento do upload, a imagem é:
  1. Validada no frontend: file.type deve ser image/png ou image/jpeg (rejeita antes de ler o arquivo)
  2. Carregada em um <canvas>
  3. Fundo branco preenchido antes do drawImage — evita que áreas transparentes de PNGs virem preto no JPEG
  4. Redimensionada para no máximo 1240×1754px (A4@150dpi)
  5. Exportada como JPEG 75%
  6. Armazenada como base64 no campo assets JSONB do template
Logo da clínica e assinatura do médico: seguem a mesma validação de 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)
O backend valida o prefixo da data URL com regex /^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:
Template → GET /tenant/my-template
         → retorna template ativo cuja unit_id = unit_id do usuário logado
         → aplicado automaticamente no PDF

Preset → sugestão por matching (do mais específico ao mais genérico):
  1. study_description_contains bate E modalidade bate
  2. Só study_description_contains bate
  3. Só modalidade bate
  4. Primeiro preset ativo (fallback)
O select de presets no painel exibe apenas presets ativos cuja modalidade corresponde ao estudo (ou sem modalidade = genérico).

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 via renderHtmlBlocks()nunca convertido para texto puro

Estados do laudo

pending → draft → final

                correction (permanece "final", nova revisão)

Fluxo de criação

POST /studies/{id}/reports  com status="draft"
    → Cria Report com status=draft
    → Cria ReportRevision com action="draft_created"

POST /studies/{id}/reports  com status="final"
    → Atualiza Report com status=final, finalized_at=now()
    → Cria ReportRevision com action="finalized"

POST /studies/{id}/reports/corrections  com content + reason
    → Requer laudo já "final"
    → Cria ReportRevision com action="correction", previous_content, new_content, reason

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 ReportTemplateA4Preview com 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 componente report-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):
┌─ outer clip (bodyH inteiro, overflow:hidden) ───────────────┐
│  [0..effectivePadPx] → fundo branco da página (top padding) │
│  ┌─ inner clip (top=effectivePadPx, h=segmentPx) ──────────┐│
│  │  content div: top = -breaks[N]px                         ││
│  │  → breaks[N] aparece em 0 do inner → effectivePadPx visual││
│  │  → inner clip termina em breaks[N+1] → sem vazamento     ││
│  └──────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘
  • Página 0: outer clip com altura = segmento desta página; paddingTop no content div cria o espaço superior.
  • Páginas N > 0: outer clip com bodyH inteiro; inner clip começa em effectivePadPx (criando espaço branco visual = top padding equivalente ao resetBodyCursor() do PDF) e tem altura = segmentPx = breaks[N+1] - breaks[N]. O content div posiciona em top: -breaks[N]px relativo ao inner clip — exato começo do segmento desta página, sem vazar conteúdo da página anterior.
Por que dois clips? Um deslocamento simples para criar o top padding (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):
  1. Modal abre (ImageSelectionModal) com cards de série — thumbnail por série (gerado pelo worker)
  2. Expandir série → frames individuais via GET /studies/{id}/instances/{instanceId}/rendered (proxy Orthanc + JWT)
  3. Seleção por série inteira ou frames individuais; configuração de colunas (1–6), formato (A4/A3), máx. frames/série
  4. Loading com barra de progresso em duas fases (loading imagens → gerando PDF)
  5. PDF gerado por study-report-pdf.ts (async) com layout do template aplicado (header/footer/slots/tipografia) + imagens embutidas
  6. 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):
ProblemaCausaSolução
Background embutido N vezes em multi-páginaaddImage dentro de drawHeaderFooter(), chamada a cada páginaParâmetro alias: "bg" no addImage — jsPDF reutiliza os dados, embute só uma vez
Background pesado no uploadPNG/JPG bruto de até 3MB armazenado no JSONBValidaçã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 completaOrthanc /rendered retorna 512×512–1024×1024px; jsPDF processa tudo no main threadAntes 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:
ElementoPáginas do laudoPáginas de imagens
Background (papel timbrado)
Logo
Nome da unidade
Linha divisória header
Bloco do paciente
Rodapé / assinatura
As imagens começam logo abaixo da área do cabeçalho (headerLineY) e se estendem até PAGE_H - PAGE_MARGIN, aproveitando toda a área disponível.

Imagens renderizadas — sem cache

O endpoint GET /studies/{id}/instances/{instanceId}/rendered não armazena nada. A cada request:
  1. Backend valida JWT e escopo de tenant
  2. Faz proxy para GET {ORTHANC_URL}/instances/{orthanc_id}/rendered
  3. Orthanc lê o arquivo DICOM bruto do seu storage e converte para JPEG on-the-fly
  4. Backend retorna com Cache-Control: no-cache, private — browser não armazena
  5. Frontend recebe o blob, converte para base64, usa e descarta
O único armazenamento de imagem renderizada no sistema é o thumbnail 300×300px gerado pelo worker Python, salvo em 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)

1. Usuário clica em qualquer botão de viewer na página do estudo

2. Frontend faz:
   POST /api/viewer/token  { study_id: "uuid" }
   → Backend cria UUID no Redis com TTL 600s
   → Retorna { token: "uuid-token" }

3a. Nova aba: window.open(ohifUrl + token)
3b. Split view: <iframe src={ohifUrl + token} /> na mesma página

4. OHIF (aba ou iframe) carrega app-config.js que:
   → Lê o token da URL (window.location.search — funciona em iframe pois window = contexto do iframe)
   → Persiste no sessionStorage
   → Patcha XMLHttpRequest e fetch para injetar X-Viewer-Token em toda requisição para :8043

5. OHIF faz requisições DicomWeb para :8043 (proxy nginx)

6. Nginx recebe a requisição e executa auth_request:
   GET http://backend:8000/api/viewer/token/validate
   Headers: X-Viewer-Token, X-Original-URI

7. Backend (ViewerTokenController@validate):
   → Lê token no Redis
   → Extrai study_instance_uid da URI
   → Verifica se o estudo pertence ao tenant do token
   → Retorna 200 (ok) ou 401/403

8. Nginx repassa ao Orthanc :8042 somente se o backend retornou 200

Configuração do OHIF

docker/ohif/app-config.js — montado como volume read-only:
dataSources: [{
    configuration: {
        wadoUriRoot: 'http://localhost:8043/dicom-web',
        qidoRoot: 'http://localhost:8043/dicom-web',
        wadoRoot: 'http://localhost:8043/dicom-web',
    }
}]
Toda requisição DicomWeb vai para :8043 (proxy), não direto para o Orthanc.

13. Frontend — Next.js

Estrutura de rotas

frontend/app/
  (auth)/
    login/page.tsx             # Tela de login
  (main)/
    (app)/                     # Usuários comuns e tenant admins
      studies/page.tsx         # Lista de estudos
      studies/[id]/page.tsx    # Detalhe do estudo + laudo + viewers (nova aba / split view) + card histórico
      patients/page.tsx        # Lista de pacientes
      patients/[id]/page.tsx   # Detalhe do paciente
    (tenant-admin)/            # Apenas tenant admins
      tenant/page.tsx          # Painel do tenant
      tenant/templates/...     # Gerenciar templates de laudo
      tenant/report-presets/   # Gerenciar presets
      tenant/settings/         # Configurações (logo, nome)
    (super-admin)/             # Apenas super admins
      admin/tenants/           # Gerenciar clínicas
      admin/units/             # Gerenciar unidades
      admin/users/             # Gerenciar usuários
      admin/modalities/        # Gerenciar aparelhos

Autenticação

O JWT é armazenado em cookie httpOnly setado pelo backend — o JavaScript nunca acessa o token diretamente. Dois cookies com funções distintas:
CookiehttpOnlyQuem setaUsado por
pacs_access_token✅ SimBackend (Set-Cookie)Browser envia automaticamente via withCredentials: true
pacs_user_role❌ NãoFrontend JSMiddleware Next.js (proxy.ts) para controle de rotas
Por que dois cookies? Frontend (: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 em localStorage. 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

frontend/lib/api/
  client.ts          # Instância Axios configurada
  studies-service.ts # Estudos, laudos, thumbnail, viewer token; blobToBase64 helper interno
  tenant-service.ts  # Templates, presets, usuários, unidades
  admin-service.ts   # Tenants, unidades, modalidades (super admin)

Env vars do frontend

Definidas no docker-compose.yml:
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000/api      # Backend
NEXT_PUBLIC_OHIF_VIEWER_URL=http://localhost:3001        # OHIF viewer

14. Proxy CORS do Orthanc

Arquivo: docker/orthanc-cors-proxy/nginx.conf O nginx tem dois location:
  1. /_auth (internal) — repassa para backend:8000/api/viewer/token/validate com os headers necessários. Nunca é acessado diretamente do browser.
  2. / — valida token via auth_request /_auth, depois faz proxy para orthanc:8042. Adiciona headers CORS para o browser poder fazer requisições cross-origin.
Requests 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

  1. Editar backend/routes/api.php — adicionar a rota no grupo correto
  2. Criar o método no controller correspondente em backend/app/Http/Controllers/
  3. Recarregar: docker exec pacs_backend php artisan octane:reload

Adicionar um novo campo ao banco

  1. Criar migration: docker exec pacs_backend php artisan make:migration nome_da_migration
  2. Editar o arquivo em backend/database/migrations/
  3. Rodar: docker exec pacs_backend php artisan migrate
  4. Adicionar o campo em $fillable e $casts no Model correspondente

Alterar a configuração do Orthanc

  1. Editar docker/orthanc/orthanc.json
  2. Recriar o container: docker compose up -d --force-recreate orthanc

Alterar o hook Lua (webhook)

  1. Editar docker/orthanc/scripts/hook.lua.template
  2. Recriar: docker compose up -d --force-recreate orthanc

Alterar o proxy nginx (CORS / autenticação do viewer)

  1. Editar docker/orthanc-cors-proxy/nginx.conf
  2. Recarregar: docker compose up -d --force-recreate orthanc-cors-proxy

Alterar a configuração do OHIF

  1. Editar docker/ohif/app-config.js
  2. Recarregar: docker compose up -d --force-recreate ohif

Adicionar uma página no frontend

  1. Criar frontend/app/(grupo)/caminho/page.tsx
  2. O Next.js recarrega automaticamente (hot reload via volume mount)

Alterar o worker Python

  1. Editar worker/main.py
  2. Recriar: docker compose up -d --force-recreate worker

Rodar os testes do backend

docker exec pacs_backend php artisan test
# ou localmente dentro de /backend:
composer test

Ver logs de um serviço

docker logs pacs_backend -f
docker logs pacs_worker -f
docker logs pacs_orthanc -f

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:
  1. 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.
  2. Identificação frágil — o tenant/unit é descoberto por lookup do AET. Se o AET não estiver cadastrado, o estudo vai para quarentena.
  3. Exposição da porta DICOM (4242) na internet — qualquer IP pode tentar enviar DICOMs.
Com Orthanc local, os aparelhos enviam para a rede interna da clínica. O Orthanc local armazena e encaminha para o cloud quando há conexão, com identidade garantida.

Fluxo alvo

[Aparelho DICOM]
      │ C-STORE (rede interna da clínica)

[Orthanc Local — on-premise na clínica]
      │ armazena localmente (funciona offline)
      │ forwarding autenticado quando há internet

[Cloud — autenticação identifica tenant + unit]


[Orthanc Cloud / Backend Laravel]
      │ webhook → processamento normal

[Worker Python → PostgreSQL]

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.
Orthanc local ──[ túnel VPN ]──▶ Cloud Orthanc :4242
A identidade da clínica pode vir do IP do túnel (cada VPN tem um IP fixo atribuído) ou ainda de uma configuração no próprio Orthanc local. Prós:
  • 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 — :4242 no cloud só aceita conexões de IPs VPN conhecidos
Contras:
  • 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
Quando faz sentido: clientes enterprise grandes (hospitais com TI própria) que já operam VPN e exigem isolamento total de rede como requisito contratual.

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.
Orthanc local
  │ POST https://pacs.cloud.com/dicom-web/studies
  │ TLS client cert: CN=unit_id_abc123

Cloud nginx
  │ valida cert (nossa CA)
  │ extrai unit_id do CN

Backend Laravel (já sabe tenant + unit sem lookup de AET)
Prós:
  • 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)
Contras:
  • 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)
Quando faz sentido: modelo SaaS com muitos clientes pequenos/médios, onde operações self-service e escala são prioridade.

Opção C — API Key no cabeçalho HTTP

O Orthanc local faz forwarding via DicomWeb (HTTPS) e inclui um header X-Tenant-Key: chave-unica em cada requisição. O backend valida a chave e identifica o tenant/unit.
Orthanc local
  │ POST https://pacs.cloud.com/api/dicom-ingest
  │ Header: X-Tenant-Key: abc123def456

Backend Laravel (valida chave na tabela api_keys)
Prós:
  • 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
Contras:
  • 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
Quando faz sentido: prototipagem, integrações internas, ou clientes onde simplificar o onboarding é mais importante que o nível máximo de segurança.

Comparativo das opções

CritérioVPN por unidademTLSAPI Key
SegurançaAlta (isolamento de rede)Alta (criptografia mútua)Média (depende do HTTPS)
EscalabilidadeBaixaAltaAlta
Complexidade de operaçãoAltaMédiaBaixa
Complexidade de setup no clienteAltaMédiaBaixa
Revogação de acessoManual (desligar VPN)Imediata (revogar cert)Imediata (desativar chave)
Funciona offline (local)SimSimSim
Dependência de rede do clienteVPN compatívelInternet comumInternet comum
Padrão em healthcare enterpriseSimSim (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.
Clínica ABC
  ├── Unidade Centro  → Orthanc local (unit_id=AAA) → cloud
  ├── Unidade Norte   → Orthanc local (unit_id=BBB) → cloud
  └── Unidade Sul     → Orthanc local (unit_id=CCC) → cloud
Prós:
  • 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)
Contras:
  • 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).
Clínica ABC
  └── Orthanc local (tenant_id=ABC)
        ├── Aparelho Unidade Centro (AET=CENTRO_RX01) → unit=AAA
        ├── Aparelho Unidade Norte  (AET=NORTE_TC01)  → unit=BBB
        └── Aparelho Unidade Sul    (AET=SUL_RM01)    → unit=CCC
O Orthanc encaminha para o cloud com o 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)
Contras:
  • 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 debugar

Recomendação de caminho (análise inicial)

Para um produto SaaS com crescimento orgânico de clientes, a análise inicial apontava para:
  1. 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.
  2. 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).
  3. 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.
Por que VPN:
  • 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).
Modelo adotado:
[Aparelho DICOM — CT, RX, RM, etc.]
      │ C-STORE (rede interna da clínica, protocolo nativo)

[Orthanc Local — on-premise na unidade]
      │ armazena localmente (funciona offline se internet cair)
      │ forwarding via C-STORE ou DicomWeb dentro do túnel VPN

[Túnel VPN (WireGuard) — IP fixo por clínica/unidade]


[Cloud Orthanc :4242 — só aceita conexões de IPs VPN cadastrados]
      │ webhook → processamento normal

[Worker Python → PostgreSQL]
Topologia: Um Orthanc por unidade física (Opção T1), mantendo a identidade de unidade na configuração local da máquina.

Plano de Implementação

O que NÃO muda no código

ComponenteStatus
hook.lua / Lua hookSem mudança — dispara webhook igual
WebhookControllerSem mudança — valida secret e despacha job
ProcessStudyMetadataMudança pequena (ver abaixo)
Python worker main.pySem mudança — recebe tenant_id/unit_id prontos
Todos os models, controllers, RBACSem mudança

Mudanças de código necessárias

1. Migration — tabela modalities 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:
CampoTipoPara quê
iduuidPK
tenant_iduuid FKA qual clínica pertence
unit_iduuid FKA qual unidade pertence
namestringNome amigável (ex: “Gateway Unidade Centro”)
aetstringAET do Orthanc local — chave de lookup
vpn_ipstring nullableIP fixo do túnel VPN para validação dupla
created_at / updated_attimestamps
2. 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:
  1. Firewall — porta 4242 só aceita IPs do range VPN (ufw)
  2. OrthancKnownAETsOnly: true + DicomModalities com AETs autorizados
  3. Job — AET deve estar em modalities com tenant+unit; caso contrário → quarentena

Mudanças de infraestrutura

WireGuard no servidor:
# /etc/wireguard/wg0.conf (servidor cloud)
[Interface]
PrivateKey = <server-private-key>
Address = 10.100.0.1/24
ListenPort = 51820

# Uma seção [Peer] por unidade de clínica:
[Peer]
# Clinica ABC — Unidade Centro
PublicKey = <clinic-public-key>
AllowedIPs = 10.100.0.2/32

[Peer]
# Clinica ABC — Unidade Norte
PublicKey = <clinic2-public-key>
AllowedIPs = 10.100.0.3/32
Firewall — porta 4242:
# Bloquear acesso público à porta DICOM
ufw deny 4242
# Permitir apenas do range VPN
ufw allow from 10.100.0.0/24 to any port 4242
Orthanc local (na clínica) — forwarding para o cloud:
// orthanc.json do Orthanc local
{
  "DicomAet": "CLINICA_A_U1",  // AET cadastrado no nosso painel
  "OrthancPeers": {
    "cloud": {
      "Url": "http://10.100.0.1:8042",  // Orthanc cloud via VPN
      "Username": "",
      "Password": ""
    }
  }
}
Com um script Lua no Orthanc local que faz forwarding automático ao estabilizar:
function OnStableStudy(studyId, tags, metadata)
    SendToPeer("cloud", studyId)
end

Processo de onboarding de nova unidade

  1. Provisionar par de chaves WireGuard para a unidade
  2. Adicionar [Peer] no wg0.conf do servidor com o IP fixo atribuído (ex: 10.100.0.5)
  3. Instalar WireGuard client + Orthanc local na VM da clínica
  4. Configurar o Orthanc local com o AET da unidade e forwarding para o cloud via VPN
  5. No painel do sistema: cadastrar a unidade em modalities com o AET e o vpn_ip atribuído
  6. Fazer um envio de teste e verificar os logs do pacs_queue

Estado de implementação

ItemStatus
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