Anexar, listar, reclassificar e excluir anexos do exame — API
Gerencia os arquivos anexados a um exame — pedido médico, exame anterior, laudo externo,
resultado, documento do paciente, termo de consentimento e outros. Cobre upload, listagem,
consulta individual, reclassificação e exclusão. Não existe uma rota de download dedicada: cada
anexo retornado carrega um link pré-assinado e temporário para baixar o arquivo diretamente do
armazenamento de objetos.
Funcionamento
Adicionar anexo (POST /exams/:examId/attachments): recebe um único arquivo (multipart/form-data)
e uma classificação obrigatória. Resolve a unidade do exame, envia o arquivo ao armazenamento de
objetos (bucket por tenant/unidade, subpasta exam-attachments) e só então grava a linha no banco.
Se a gravação falhar depois do upload, o objeto recém-enviado é removido do armazenamento (limpeza
de órfão) e o erro original é repropagado. Ao final, gera um evento de auditoria e agenda a
reprojeção do exame na worklist.
Listar anexos (GET /exams/:examId/attachments): devolve todos os anexos ativos (não
excluídos) do exame, ordenados primeiro pela ordem de exibição da classificação e, dentro da mesma
classificação, pela data de criação. Cada item já vem com um link de download recém-gerado.
Buscar um anexo (GET /exams/:examId/attachments/:attachmentId): igual à listagem, mas para um
único anexo; confirma que o anexo pertence ao examId informado antes de devolver.
Reclassificar anexo (PATCH /exams/:examId/attachments/:attachmentId/classification): troca a
classificação de um anexo existente. Não altera o arquivo, apenas o metadado; gera evento de
auditoria com o valor anterior e o novo.
Excluir anexo (DELETE /exams/:examId/attachments/:attachmentId): aplica soft-delete (marca
deletedAt, apaga fileName/filePath do registro) e, em seguida, remove o objeto do
armazenamento. Antes de excluir, verifica o escudo de exame assinado: se o exame está com
status SIGNED ou RESIGNED, só é permitido excluir anexos classificados como EXAM_RESULTS;
qualquer outra classificação é recusada. Se a remoção do objeto no armazenamento falhar depois do
soft-delete já ter sido commitado, o erro é apenas registrado em log — a operação já é considerada
bem-sucedida para quem chamou.
Endpoints
| Método | Rota | Descrição |
|---|---|---|
| POST | /v1/exams/:examId/attachments | Adiciona um anexo ao exame |
| GET | /v1/exams/:examId/attachments | Lista os anexos ativos do exame |
| GET | /v1/exams/:examId/attachments/:attachmentId | Busca um anexo específico do exame |
| PATCH | /v1/exams/:examId/attachments/:attachmentId/classification | Reclassifica um anexo |
| DELETE | /v1/exams/:examId/attachments/:attachmentId | Exclui (soft-delete) um anexo |
Versão: v1
Swagger:
POST /exams/:examId/attachmentsGET /exams/:examId/attachmentsGET /exams/:examId/attachments/:attachmentIdPATCH /exams/:examId/attachments/:attachmentId/classificationDELETE /exams/:examId/attachments/:attachmentId
Rota (Dev): http://localhost:3000/v1/exams/:examId/attachments
Lógica de decisão de POST /exams/:examId/attachments (adicionar anexo):
Lógica de decisão de DELETE /exams/:examId/attachments/:attachmentId (excluir anexo):
Permissões
Todas as rotas exigem sessão autenticada (JwtAuthenticationGuard) e são avaliadas pelo
AuthorizationGuard num escopo RLS (resource: 'exam') resolvido pelo examId do path — ou seja,
a permissão é checada sobre aquele exame específico, não de forma global.
| Rota | Guards | Permissão exigida |
|---|---|---|
POST /exams/:examId/attachments | JwtAuthenticationGuard, AuthorizationGuard | exam:add-attachment |
GET /exams/:examId/attachments | JwtAuthenticationGuard, AuthorizationGuard | exam:list-attachment |
GET /exams/:examId/attachments/:attachmentId | JwtAuthenticationGuard, AuthorizationGuard | exam:list-attachment |
PATCH /exams/:examId/attachments/:attachmentId/classification | JwtAuthenticationGuard, AuthorizationGuard | exam:add-attachment |
DELETE /exams/:examId/attachments/:attachmentId | JwtAuthenticationGuard, AuthorizationGuard | exam:delete-attachment |
Sem token válido: 401 UNAUTHENTICATED. Com token válido mas sem a permissão no escopo do exame:
403 FORBIDDEN_ACTION.
Headers
| Header | Obrigatório | Descrição |
|---|---|---|
Authorization | Sim | Bearer <access_token> |
Content-Type | Sim em POST | multipart/form-data |
Path parameters
| Nome | Tipo | Obrigatório | Descrição |
|---|---|---|---|
examId | UUID | Sim | Identificador do exame (ParseUUIDPipe) |
attachmentId | UUID | Sim (exceto no POST) | Identificador do anexo (ParseUUIDPipe) |
Query parameters
Nenhum.
Body
POST /exams/:examId/attachments — multipart/form-data:
| Campo | Tipo | Obrigatório | Validação |
|---|---|---|---|
file | arquivo (binário) | Sim | Único arquivo (FileInterceptor); limite de 50MB imposto pelo Multer; bytes não vazios verificados de novo na camada de domínio |
classificacao | string (enum) | Sim | @IsEnum(AttachmentClassification) — ver valores abaixo |
isExamePaciente | boolean | Não | Aceita true/false ou as strings "true"/"false" |
isImagemChave | boolean | Não | Aceita true/false ou as strings "true"/"false" |
Valores de AttachmentClassification: OTHERS, PRIOR_EXAM, MEDICAL_REQUEST,
EXTERNAL_REPORT, EXAM_RESULTS, PATIENT_DOCUMENT, CONSENT_FORM, OTHER_ATTACHMENTS.
Os nomes de campo do formulário (
classificacao,isExamePaciente,isImagemChave) estão em português no contrato desta rota; os valores do enum de classificação e o restante do payload das outras rotas estão em inglês. Isso é o contrato real do código, não uma inconsistência da documentação.
PATCH /exams/:examId/attachments/:attachmentId/classification — UpdateDiagnosisAttachmentClassificationRequest:
json{ "classification": "PATIENT_DOCUMENT" }
| Campo | Tipo | Obrigatório | Validação |
|---|---|---|---|
classification | string (enum) | Sim | @IsEnum(AttachmentClassification) |
GET e DELETE não recebem corpo.
Response
201/200 — DiagnosisExamAttachmentResponse (envelope { "data": ... } em POST/GET; lista em GET de coleção):
json{"data": {"id": "0196cf9a-...-uuid","examId": "0196cf9a-...-uuid","unitId": "0196cf9a-...-uuid","classification": "MEDICAL_REQUEST","fileName": "pedido-medico.pdf","fileType": "application/pdf","link": "https://storage.example.com/temporary-resource","createdAt": "2026-09-24T12:00:00.000Z"}}
| Campo | Tipo | Descrição |
|---|---|---|
id | UUID | Identificador do anexo |
examId | UUID | Exame ao qual pertence |
unitId | UUID | Unidade do exame |
classification | string (enum) | Ver valores de AttachmentClassification acima |
fileName | string | null | Nome original do arquivo no upload |
fileType | string | null | MIME type informado no upload |
link | string (URL) | null | URL pré-assinada de download, válida por 900 segundos (15 min); null quando não há arquivo associado |
createdAt | datetime | Data de criação do anexo |
PATCH (reclassificar) devolve 200 sem corpo. DELETE devolve 204 sem corpo.
A resposta não expõe
origin(interno vs. via convite externo),isKeyImagenemisPatientExam, embora esses três campos sejam gravados no banco no momento do upload. Quem consome a API hoje não consegue ler esses três metadados de volta por esta rota — lacuna confirmada no código, não uma omissão da documentação.
Erros
| Rota | Classe de erro | errorCode | Status | Quando ocorre |
|---|---|---|---|---|
POST | (guard) | UNAUTHENTICATED | 401 | token ausente/inválido/expirado |
| qualquer | (guard) | FORBIDDEN_ACTION | 403 | sem a permissão exigida no escopo do exame |
POST | BadRequestException (Nest) | BAD_REQUEST | 400 | nenhum arquivo enviado no multipart |
POST | DiagnosisAttachmentFileRequiredError | ATTACHMENT_FILE_REQUIRED | 400 | arquivo enviado com 0 bytes |
POST | DiagnosisAttachmentInputInvalidError | ATTACHMENT_INPUT_INVALID | 400 | classificação fora do enum, arquivo maior que 50MB, MIME mal formado, ou isKeyImage/isPatientExam com tipo diferente de boolean |
POST | DiagnosisUnitNotFoundError | UNIT_NOT_FOUND | 404 | a unidade do exame não foi encontrada no diretório de unidades |
POST/PATCH | (ValidationPipe global) | BAD_REQUEST | 400 | corpo com campo obrigatório ausente ou fora do enum, antes mesmo de chegar à camada de domínio |
GET/PATCH/DELETE | DiagnosisExamAttachmentNotFoundError | EXAM_ATTACHMENT_NOT_FOUND | 404 | anexo inexistente, já excluído, ou pertence a outro exame |
DELETE | DiagnosisAttachmentSignedShieldError | ATTACHMENT_SIGNED_SHIELD | 400 | exame SIGNED/RESIGNED e anexo não é EXAM_RESULTS |
O Swagger de
POST /exams/:examId/attachmentsdocumenta um403cuja descrição menciona anexos vindos "do portal de prescrição" com anexo externo desabilitado (EXTERNAL_ATTACHMENTS_DISABLED). Isso não corresponde ao código desta rota:addAttachmentnunca chama a verificação de política de anexo externo — essa checagem só existe no fluxo de convite de anexo externo. Divergência de documentação no controller, não comportamento real desta rota.
Regras de negócio
| ID | Regra | Comportamento esperado |
|---|---|---|
| RN-01 | Upload é feito no armazenamento antes da gravação em banco | se a gravação falhar depois, o objeto recém-enviado é removido (evita órfão) |
| RN-02 | Falha ao remover o objeto na exclusão não desfaz o soft-delete | o soft-delete já foi commitado; a falha de storage só é logada como aviso |
| RN-03 | Listagem e busca só retornam anexos ativos | soft-delete (deletedAt) exclui o registro das consultas por padrão do TypeORM |
| RN-04 | Ordem de exibição segue a classificação, não a data | ordem fixa: MEDICAL_REQUEST → EXTERNAL_REPORT → EXAM_RESULTS → PATIENT_DOCUMENT → PRIOR_EXAM → CONSENT_FORM → OTHER_ATTACHMENTS → OTHERS; dentro da mesma classificação, mais antigo primeiro |
| RN-05 | Escudo de exame assinado protege anexos de exclusão | exame SIGNED ou RESIGNED: só anexos EXAM_RESULTS podem ser excluídos; qualquer outra classificação é recusada |
| RN-06 | Reclassificar não altera o arquivo | troca somente o metadado classification; gera evento de auditoria com valor anterior e novo |
| RN-07 | O link de download é gerado a cada leitura, não persistido | toda chamada a POST, GET (lista ou item) gera uma nova URL pré-assinada com validade de 900s |
| RN-08 | Extensão do arquivo é sanitizada antes de compor o caminho de armazenamento | só aceita [a-z0-9]{1,10} extraído do nome original; extensão fora desse padrão é descartada (arquivo salvo sem extensão no caminho) |
Compliance
| Órgão / norma | Exigência | Como a rota atende |
|---|---|---|
| LGPD | minimização e retenção controlada | soft-delete preserva o registro para trilha/retenção sem manter o arquivo físico; fileName/filePath são apagados do registro no soft-delete |
| HIPAA | trilha de quem anexou/reclassificou/excluiu | eventos de auditoria diagnosis.exam.attachment-added, diagnosis.exam.attachment-reclassified, diagnosis.exam.attachment-removed, com actorUserId e contexto de auditoria |
| ANVISA (indireto) | proteção de documentos de exame já assinado | escudo de exame assinado (RN-05) impede alterar a evidência documental de um exame já finalizado, exceto o próprio resultado |
Variáveis de ambiente
Nenhuma variável de ambiente específica desta rota foi encontrada no código lido; os limites (50MB por arquivo, 900s de validade do link) são constantes no código, não configuráveis por variável de ambiente.
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 | Não — cada POST cria um novo anexo; DELETE de um anexo já excluído retorna 404 (não é idempotente em efeito) |
| Paginação | Não — GET de coleção sempre retorna todos os anexos ativos do exame |
| Rate limit | Nenhum específico destas rotas; sujeitas apenas ao limite global padrão da API |
| Cache | Não |
| Auditoria | Sim — ver eventos listados em Compliance |
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: Convite de anexo externo — via de upload sem login no portal, para o mesmo exame