Convite de anexo externo — API
Permite que um operador autenticado gere um convite de upload — um token opaco, de uso único e
de curta duração — para que um terceiro sem login no portal (ex.: um paciente ou outra clínica)
anexe documentos a um exame específico, sem precisar de uma conta no Portal 2.0. O terceiro usa
esse token como um Bearer token dedicado a esta funcionalidade (uma "capability" isolada), não como
sessão de usuário: as duas rotas de consumo (inspect e redeem) não passam pelo
JwtAuthenticationGuard nem por AuthorizationGuard — só exigem o token do convite.
Funcionamento
Criar convite (POST /exams/:examId/external-attachment-invitations, autenticado): confirma
que o exame existe, que a unidade tem anexos externos habilitados (política de workflow por
unidade) e que a unidade está ativa. Gera um token opaco aleatório (32 bytes, base64url) e grava só
o hash SHA-256 do token — o valor em claro existe apenas na resposta HTTP desta chamada. O
convite nasce com validade de 10 minutos, fixos no código.
Consultar convite (GET /external-attachment-invitations/current, público — só com o token):
usado pela tela que o terceiro abre a partir do link. Resolve o convite pelo hash do token
apresentado, confere que não expirou e que ainda não foi consumido, e revalida que o exame e a
unidade do convite ainda correspondem ao que foi emitido (proteção contra o exame ter mudado de
unidade/tenant entre a emissão e o uso). Devolve só o necessário para montar a tela: classificações
permitidas, prazo de expiração, limites de upload e um resumo mínimo do exame (nome do paciente,
nascimento, descrição do estudo, data de realização).
Consumir convite (POST /external-attachment-invitations/current/attachment-batches, público —
só com o token): recebe um lote de 1 a 20 arquivos (multipart/form-data) com uma classificação por
arquivo, valida tipo/tamanho/conteúdo de cada um, envia todos ao armazenamento de objetos e só então
grava os anexos e marca o convite como consumido — tudo em uma única transação. Exige um header
Idempotency-Key (UUID): reenviar a mesma chave num convite já consumido devolve os mesmos
anexos já criados, sem duplicar; uma chave diferente num convite já consumido é rejeitada.
Endpoints
| Método | Rota | Descrição |
|---|---|---|
| POST | /v1/exams/:examId/external-attachment-invitations | Emite um convite de upload externo (autenticado) |
| GET | /v1/external-attachment-invitations/current | Consulta os dados públicos do convite (só com o token) |
| POST | /v1/external-attachment-invitations/current/attachment-batches | Consome o convite e cria o lote de anexos (só com o token) |
Versão: v1
Swagger:
POST /exams/:examId/external-attachment-invitationsGET /external-attachment-invitations/currentPOST /external-attachment-invitations/current/attachment-batches
Rota (Dev): http://localhost:3000/v1/external-attachment-invitations/current
Fluxo completo (emissão pelo operador → uso pelo terceiro externo):
Lógica de decisão de POST .../attachment-batches (consumir o convite):
Permissões
| Rota | Guards | Acesso |
|---|---|---|
POST /exams/:examId/external-attachment-invitations | JwtAuthenticationGuard, AuthorizationGuard | Usuário autenticado com permissão exam:add-attachment no escopo do exame (RLS por examId no path) |
GET /external-attachment-invitations/current | ExternalAttachmentInvitationCapabilityGuard | Público — qualquer portador do header Authorization: Bearer <token> com formato válido; a validade real do token é conferida depois, no service |
POST /external-attachment-invitations/current/attachment-batches | ExternalAttachmentInvitationCapabilityGuard | Idêntico ao anterior |
O ExternalAttachmentInvitationCapabilityGuard só confere se o header Authorization casa com o
padrão Bearer <algo>; não decodifica nem valida o token — se o formato bater mas o token for
inválido/expirado/inexistente, o service de cada rota lança
EXTERNAL_ATTACHMENT_INVITATION_NOT_FOUND (404) ou o erro correspondente. Isso significa que estas
duas rotas nunca exigem login no portal — são a única via não autenticada deste módulo, por
desenho.
Headers
| Header | Obrigatório | Descrição |
|---|---|---|
Authorization | Sim, nas 3 rotas | POST /exams/:examId/...: Bearer <access_token> do operador; GET/POST .../attachment-batches: Bearer <token do convite> |
Idempotency-Key | Sim, só em POST .../attachment-batches | UUID; chave de replay do lote |
Content-Type | Sim em POST .../attachment-batches | multipart/form-data |
Cache-Control: no-store | Enviado pela API | Presente na resposta de GET e do POST de consumo — evita cache de dados do exame/convite |
Path parameters
| Nome | Tipo | Obrigatório | Descrição |
|---|---|---|---|
examId | UUID | Sim, só em POST /exams/:examId/... | Exame para o qual o convite é emitido (ParseUUIDPipe) |
Query parameters
Nenhum.
Body
Criar convite — CreateDiagnosisExternalAttachmentInvitationRequest:
json{ "classifications": ["PATIENT_DOCUMENT", "CONSENT_FORM"] }
| Campo | Tipo | Obrigatório | Validação |
|---|---|---|---|
classifications | array de string (enum) | Não | @IsEnum(AttachmentClassification, { each: true }); se omitido ou vazio, o convite libera todas as classificações |
Consumir convite — multipart/form-data:
| Campo | Tipo | Obrigatório | Validação |
|---|---|---|---|
files | arquivos (binário) | Sim | 1 a 20 arquivos (FilesInterceptor), até 50MB cada, 200MB no total |
items | string (JSON stringificado) | Sim | Array com um item por arquivo, na mesma ordem/posição: { "classification": "<enum>", "position": <índice> }; posições devem ser contíguas e únicas, cobrindo todos os arquivos |
Tipos de arquivo aceitos (validados por MIME e pelos primeiros bytes do conteúdo, não só pela
extensão): pdf, png, jpg/jpeg, docx, xlsx, xls, csv, zip.
GET /external-attachment-invitations/current não recebe corpo.
Response
201 — Criar convite (DiagnosisExternalAttachmentInvitationResponse):
json{"data": {"classifications": ["PATIENT_DOCUMENT", "CONSENT_FORM"],"expiresAt": "2026-09-24T12:10:00.000Z","token": "opaque-invitation-token-base64url"}}
O
tokensó aparece nesta resposta. Ele não pode ser recuperado depois — só o hash fica salvo.
200 — Consultar convite (PublicDiagnosisExternalAttachmentInvitationResponse):
json{"data": {"classifications": ["PATIENT_DOCUMENT", "CONSENT_FORM"],"exam": {"patientName": "Fulano de Tal","patientBirthDate": "1990-01-01","studyDescription": "Tomografia de tórax","performedAt": "2026-09-20T10:00:00.000Z"},"expiresAt": "2026-09-24T12:10:00.000Z","limits": { "maxFiles": 20, "maxFileBytes": 52428800, "maxTotalBytes": 209715200 }}}
201 — Consumir convite (RedeemedDiagnosisExternalAttachmentBatchEnvelopeResponse):
json{"data": {"attachments": [{"id": "0196cf9a-...-uuid","examId": "0196cf9a-...-uuid","classification": "PATIENT_DOCUMENT","fileName": "documento.pdf","fileType": "application/pdf"}]}}
Erros
| Rota | Classe de erro | errorCode | Status | Quando ocorre |
|---|---|---|---|---|
| Criar convite | (guard) | UNAUTHENTICATED / FORBIDDEN_ACTION | 401 / 403 | sem token válido ou sem exam:add-attachment no exame |
| Criar convite | DiagnosisExternalAttachmentsDisabledError | EXTERNAL_ATTACHMENTS_DISABLED | 403 | a política de workflow da unidade não tem anexo externo habilitado |
| Criar convite | DiagnosisExternalAttachmentPolicyUnavailableError | EXTERNAL_ATTACHMENT_POLICY_UNAVAILABLE | 503 | a política da unidade não pôde ser resolvida (falha de dependência) — falha fechada: nega por padrão |
| Criar convite | DiagnosisUnitNotFoundError | UNIT_NOT_FOUND | 404 | unidade do exame inexistente ou inativa |
GET/consumir | (guard) | EXTERNAL_ATTACHMENT_INVITATION_NOT_FOUND | 404 | header Authorization ausente ou fora do formato Bearer <token> |
GET/consumir | DiagnosisExternalAttachmentInvitationNotFoundError | EXTERNAL_ATTACHMENT_INVITATION_NOT_FOUND | 404 | token desconhecido, ou exame/unidade/tenant do convite não batem mais com o cadastro atual |
GET/consumir | DiagnosisExternalAttachmentInvitationExpiredError | EXTERNAL_ATTACHMENT_INVITATION_EXPIRED | 410 | convite expirado (10 minutos após a emissão) |
GET/consumir | DiagnosisExternalAttachmentInvitationAlreadyUsedError | EXTERNAL_ATTACHMENT_INVITATION_ALREADY_USED | 409 | convite já consumido com uma Idempotency-Key diferente da apresentada |
| Consumir | DiagnosisExternalAttachmentClassificationNotAllowedError | EXTERNAL_ATTACHMENT_CLASSIFICATION_NOT_ALLOWED | 400 | alguma classificação enviada não está entre as permitidas pelo convite |
| Consumir | DiagnosisExternalAttachmentBatchInvalidError | EXTERNAL_ATTACHMENT_BATCH_INVALID | 400 | Idempotency-Key ausente/não-UUID; quantidade de items diferente da de arquivos; posições não contíguas; nome de arquivo inválido; tipo de arquivo não suportado ou conteúdo não bate com a extensão declarada |
| Consumir | DiagnosisExternalAttachmentBatchTooLargeError | EXTERNAL_ATTACHMENT_BATCH_TOO_LARGE | 413 | mais de 20 arquivos, um arquivo acima de 50MB, ou lote acima de 200MB no total |
| Consumir | DiagnosisExternalAttachmentsDisabledError | EXTERNAL_ATTACHMENTS_DISABLED | 403 | a política da unidade foi desabilitada entre a emissão e o consumo do convite |
| Consumir | DiagnosisExternalAttachmentPolicyUnavailableError | EXTERNAL_ATTACHMENT_POLICY_UNAVAILABLE | 503 | falha ao resolver a política no momento do consumo |
| Consumir | DiagnosisExternalAttachmentUploadUnavailableError | EXTERNAL_ATTACHMENT_UPLOAD_UNAVAILABLE | 503 | falha inesperada durante upload/gravação; os objetos já enviados ao armazenamento são removidos antes de responder |
Regras de negócio
| ID | Regra | Comportamento esperado |
|---|---|---|
| RN-01 | O token em claro só existe uma vez | só o hash SHA-256 é persistido; perder o token da resposta de criação torna o convite inutilizável para consulta manual (mas ele continua existindo até expirar) |
| RN-02 | Convite expira em 10 minutos, fixos no código | não é configurável por variável de ambiente |
| RN-03 | Convite sem classifications libera todas | lista vazia ou omitida é normalizada para todos os valores de AttachmentClassification |
| RN-04 | Convite é de uso único, mas idempotente por chave | reenviar a mesma Idempotency-Key num convite já consumido devolve os mesmos anexos (sem duplicar); chave diferente é rejeitada |
| RN-05 | O escopo do convite é revalidado no consumo, não só na criação | se o exame mudar de unidade/tenant entre a emissão e o uso, o convite passa a ser tratado como não encontrado |
| RN-06 | A política de anexo externo é checada duas vezes | uma vez na criação do convite, outra de novo no consumo — pode ter mudado nesse intervalo |
| RN-07 | Anexos criados via convite externo têm origem própria | gravados com origin: EXTERNAL_INVITATION (distinto de INTERNAL_OPERATOR) e sempre com isKeyImage: false e isPatientExam: false |
| RN-08 | Falha ao resolver a política de anexo externo nega por padrão (fail-closed) | indisponibilidade da dependência de política vira 503, nunca libera o upload por omissão |
| RN-09 | Tipo de arquivo é validado por assinatura de bytes, não só por extensão/MIME declarado | ex.: um arquivo renomeado para .pdf sem o cabeçalho %PDF- de um PDF real é rejeitado |
| RN-10 | Upload no armazenamento acontece antes da gravação em banco | se a transação de gravação falhar, os objetos já enviados são removidos do armazenamento |
Compliance
| Órgão / norma | Exigência | Como a rota atende |
|---|---|---|
| LGPD | minimização de dados expostos ao terceiro externo | GET /external-attachment-invitations/current devolve só nome do paciente, nascimento, descrição do estudo e data — nenhum outro dado do exame ou do prontuário |
| HIPAA | acesso de terceiro não autenticado é restrito, auditado e temporário | token opaco de uso único, TTL de 10 min, e eventos de auditoria diagnosis.external-attachment-invitation.issued e .redeemed com actorUserId (o emissor, mesmo no consumo pelo terceiro) |
| ANVISA (indireto) | rastreabilidade de documentos anexados por terceiro | anexos via convite carregam origin: EXTERNAL_INVITATION, distinguindo-os de upload interno na trilha de auditoria |
Variáveis de ambiente
Nenhuma variável de ambiente foi encontrada controlando TTL do convite, tamanho do token ou limites de upload — todos são constantes no código (10 min de validade, token de 32 bytes, até 20 arquivos, 50MB por arquivo, 200MB por lote).
Tempo médio de resposta
A confirmar — responsável: time de Diagnosis; data: 24/09/2026. Não há medição publicada; não foi
executado neste levantamento.
Requisitos não funcionais
| Requisito | Definição |
|---|---|
| Idempotência | O consumo do convite é idempotente por Idempotency-Key (replay seguro); criar convite e consultar convite não são idempotentes/não se aplicam |
| Paginação | Não se aplica |
| Rate limit | GET /external-attachment-invitations/current: 20 requisições / 60s (@Throttle, sem balde adicional por identidade); as demais rotas deste módulo não têm @Throttle próprio e usam só o limite global padrão da API |
| Cache | Não — Cache-Control: no-store explícito nas duas rotas do fluxo externo |
| Auditoria | Sim — diagnosis.external-attachment-invitation.issued na criação, .redeemed por anexo no consumo |
Relacionado
- 🖥️ Tela:
A confirmar — responsável: time de frontend; data: 24/09/2026.(fora do escopo deste levantamento, que cobriu apenas o backend) - 📂 Módulo: Anexos (Attachment)
- 🔗 Fluxo relacionado: Anexos do exame — via de upload para usuário autenticado do portal, sobre o mesmo exame