# Storage — Digital Ocean Spaces

Documentação do fluxo de upload, leitura e remoção de arquivos (currículos e fotos de perfil) usando Digital Ocean Spaces (S3-compatível).

## Visão geral

- **Foto de perfil** — arquivo **público**. URL direta (ou via CDN) retornada pelo backend e exibida no `<img>` do front sem expiração.
- **Currículo e demais anexos** — arquivos **privados**. Backend gera **signed URL** com TTL de 1h em cada `GET`.
- Estrutura de pastas por ambiente: `production/`, `homologation/`, `dev/` → cada uma com `curriculum/` e `profiles/` dentro.
- ACL aplicada por chamada (`uploadFile({ acl: 'public-read' | 'private' })`) — default é `private`.

## Arquitetura

```
┌─────────────────────────────────────────────────────────────┐
│  HTTP Controller (multipart parsing, validação, status code)│
│    candidates/curriculum/profile-photo.ts                    │
│    candidates/curriculum/create-curriculum.ts                │
│    candidates/curriculum/update-curriculum.ts                │
└─────────────────────┬───────────────────────────────────────┘
                      │
┌─────────────────────▼───────────────────────────────────────┐
│  Use Case (orquestração, regras de domínio)                 │
│    use-cases/control-curriculum.ts                          │
└─────────────────────┬───────────────────────────────────────┘
                      │              │
┌─────────────────────▼─────┐  ┌─────▼──────────────────────┐
│  Repository (Prisma)      │  │  Service (storage)         │
│    repository/prisma/     │  │    services/storage.ts     │
│    prisma-curriculum-     │  │                            │
│    repository.ts          │  │  Lib (SDK)                 │
│                           │  │    lib/digital-ocean.ts    │
└───────────────────────────┘  └────────────┬───────────────┘
                                            │
                                  ┌─────────▼──────────┐
                                  │  DO Spaces (S3)    │
                                  └────────────────────┘
```

## Backend

### Camada de infraestrutura

[src/lib/digital-ocean.ts](../src/lib/digital-ocean.ts) — Cliente S3 configurado com endpoint, region e credenciais do `.env`. Mesmo padrão de [src/lib/prisma.ts](../src/lib/prisma.ts) e [src/lib/api-gertel.ts](../src/lib/api-gertel.ts).

[src/services/storage.ts](../src/services/storage.ts) — Service de alto nível. Expõe:

| Função | Comportamento |
|---|---|
| `uploadFile({ body, originalName, contentType, folder, acl })` | Sobe arquivo. Default ACL `private`. Retorna `{ success: true, key, url }` ou `{ success: false, message }`. |
| `deleteFile(key)` | Remove arquivo do bucket. |
| `getSignedDownloadUrl(key, ttl?)` | Gera URL assinada (default 1h). |
| `getPublicUrl(key)` | URL pública direta (virtual-hosted style, com CDN se configurado). |
| `fileExists(key)` | Verifica existência via `HeadObject`. |
| `resolveFolderBySubtype(subtype)` | Mapeia subtype → pasta. `PHOTO` → `profiles`, qualquer outro → `curriculum`. |

Todas as operações com erro registram `console.error` com contexto (bucket, key, mensagem).

### Estrutura de pastas no bucket

A constante `STORAGE_FOLDERS` em [src/services/storage.ts](../src/services/storage.ts) já vem pré-fixada com o ambiente em runtime:

| `NODE_ENV` | Currículo | Foto |
|---|---|---|
| `production` | `production/curriculum/` | `production/profiles/` |
| `homologation` | `homologation/curriculum/` | `homologation/profiles/` |
| `dev` | `dev/curriculum/` | `dev/profiles/` |

A **key salva no banco** é o caminho completo (ex: `dev/profiles/uuid.jpg`). A URL é reconstruída em runtime via `getPublicUrl` ou `getSignedDownloadUrl`.

### Variáveis de ambiente

Definidas em [src/env/index.ts](../src/env/index.ts):

```
DO_SPACES_ENDPOINT       # ex: https://nyc3.digitaloceanspaces.com (sem bucket)
DO_SPACES_REGION         # ex: nyc3
DO_SPACES_KEY            # access key
DO_SPACES_SECRET         # secret key
DO_SPACES_BUCKET         # nome do Space, ex: gerteltalentos
DO_SPACES_CDN_ENDPOINT   # opcional, ex: https://nyc3.cdn.digitaloceanspaces.com
```

O SDK e o `buildPublicUrl` montam URLs no formato **virtual-hosted**: `<bucket>.<endpoint>/<key>`. Por isso o `DO_SPACES_ENDPOINT` **não pode conter o bucket** no hostname.

### Use case

[src/use-cases/control-curriculum.ts](../src/use-cases/control-curriculum.ts) — Coordena banco + bucket.

- `getCurriculum(id_candidate)` — busca o currículo e transforma o `value` de cada attachment:
  - `subtype === "PHOTO"` → URL pública via `getPublicUrl`
  - Demais → signed URL via `getSignedDownloadUrl` (TTL 1h, definido pela constante `SIGNED_URL_TTL_SECONDS`)
  - Links externos (`http(s)://...`) — passam intactos
- `deleteAttachment(id)` — remove do banco e tenta remover do bucket. Falha no bucket gera log mas não impede sucesso da operação.
- `findProfilePhoto(id_candidate)` — busca attachment com `type=PROFILE, subtype=PHOTO`.

### Endpoints

#### Foto de perfil — [src/http/controllers/candidates/curriculum/profile-photo.ts](../src/http/controllers/candidates/curriculum/profile-photo.ts)

Registrados em [routes.ts](../src/http/controllers/candidates/curriculum/routes.ts):

| Método | Rota | Descrição |
|---|---|---|
| `POST` | `/candidate/:id_candidate/profile-photo` | Upload/substitui foto (multipart, public-read) |
| `GET` | `/candidate/:id_candidate/profile-photo` | Retorna foto atual com URL pública |
| `DELETE` | `/candidate/:id_candidate/profile-photo` | Remove foto |

**Validações:**
- MIME types: `image/jpeg`, `image/png`, `image/webp`
- Tamanho máximo: 5MB
- Upsert por candidato — `POST` deleta foto anterior se existir

**Resposta `POST` (201):**
```json
{
  "profilePhoto": {
    "id": "uuid",
    "id_candidate": "uuid",
    "name": "foto.jpg",
    "type": "PROFILE",
    "subtype": "PHOTO",
    "value": "https://gerteltalentos.nyc3.cdn.digitaloceanspaces.com/dev/profiles/uuid.jpg"
  }
}
```

#### Anexos genéricos (currículo) — [create-curriculum.ts](../src/http/controllers/candidates/curriculum/create-curriculum.ts), [update-curriculum.ts](../src/http/controllers/candidates/curriculum/update-curriculum.ts), [delete-curriculum.ts](../src/http/controllers/candidates/curriculum/delete-curriculum.ts)

| Método | Rota | Descrição |
|---|---|---|
| `POST` | `/candidate/curriculum/attachment` | Upload de anexo (multipart, privado) |
| `PUT` | `/candidate/curriculum/attachment/:id` | Substitui arquivo (multipart obrigatório) |
| `DELETE` | `/candidate/curriculum/attachment/:id` | Remove anexo |

**Validações:**
- Tamanho máximo: 10MB
- Pasta determinada pelo subtype via `resolveFolderBySubtype`

**Resposta de `GET /candidate/:id/curriculum` — attachments com signed URL:**
```json
{
  "curriculum": {
    "attachments": [
      {
        "id": "uuid",
        "name": "curriculo.pdf",
        "subtype": "curriculo",
        "value": "https://gerteltalentos.nyc3.digitaloceanspaces.com/dev/curriculum/abc.pdf?X-Amz-Signature=..."
      }
    ]
  }
}
```

### Tratamento de erros e logs

Todos os pontos de falha logam estruturadamente:

- `[storage:uploadFile]` — falha no PUT do SDK
- `[storage:deleteFile]` — falha no DELETE do SDK
- `[storage:getSignedDownloadUrl]` — falha na geração de signed URL
- `[create-curriculum:saveAttachment]` / `[profile-photo:upload]` — contexto adicional do controller (id_candidate, filename, mimetype, size)
- `[control-curriculum:deleteAttachment]` / `[profile-photo:upload]` / `[update-curriculum:updateAttachment]` — falha ao deletar antigo após substituir (anexo órfão no bucket)

## Frontend

### Service de foto de perfil

[src/services/profile-photo/profilePhoto.ts](../src/services/profile-photo/profilePhoto.ts) — Encapsula os três endpoints REST:

| Função | Endpoint |
|---|---|
| `uploadProfilePhoto(id, file)` | `POST /candidate/:id/profile-photo` |
| `getProfilePhoto(id)` | `GET /candidate/:id/profile-photo` (`null` em 404) |
| `deleteProfilePhoto(id)` | `DELETE /candidate/:id/profile-photo` |

Usa `reqCandidate` do [request.ts](../src/services/request.ts), que gerencia o JWT e refresh automaticamente.

### Validação client-side

[src/utils/validateImageFile.ts](../src/utils/validateImageFile.ts) — Mesmas regras do backend (JPEG/PNG/WebP, ≤5MB) para feedback imediato no formulário.

### Save unificado do currículo

[src/services/curriculum/saveCurriculum.ts](../src/services/curriculum/saveCurriculum.ts) — Orquestra o save de todo o currículo. No passo 9, se `profilePhotoFile` foi alterada, chama `uploadProfilePhoto`. Se foi marcada para remoção, chama `deleteProfilePhoto`.

### Página do candidato

[src/pages/CandidateDashboard.tsx](../src/pages/CandidateDashboard.tsx):

- Preview imediato ao selecionar arquivo via `URL.createObjectURL(file)` — sem `FileReader` assíncrono.
- Após salvar, recarrega via `loadCurriculum`, que chama `getProfilePhoto` para obter a URL pública estável.
- Foto antiga (object URL `blob:`) é revogada com `URL.revokeObjectURL` para não vazar memória.

### Exibição

Foto de perfil — `<img src={photo.value} />` direto. URL pública é estável e pode ser cacheada.

Anexos do currículo — **não cachear globalmente**. A signed URL expira em 1h. Sempre que for usar, busque o currículo atualizado (`GET /candidate/:id/curriculum`) e abra o link na hora.

## Configuração do Space na Digital Ocean

1. **Criar Space** no painel da DO (região, ex: NYC3).
2. **File Listing**: `Restricted` (não permite listar arquivos pelo navegador).
3. **Object Ownership**: habilitar ACLs (necessário para foto de perfil pública).
4. **CORS**: adicionar domínios do frontend (dev e produção) com método `GET`.
5. **Spaces Keys**: gerar no painel e preencher no `.env`.

Não é necessário criar as pastas (`production/curriculum/`, etc.) manualmente — o S3 cria automaticamente no primeiro upload com aquele prefixo.

## Fluxos completos

### Upload de foto de perfil

```
Frontend (CandidateDashboard.tsx)
  └─ usuário seleciona arquivo
  └─ validateImageFile() — bloqueia se inválido
  └─ URL.createObjectURL() — preview imediato
  └─ submit do formulário
       └─ saveCurriculum() → uploadProfilePhoto()
            └─ POST /candidate/:id/profile-photo  [multipart]

Backend (profile-photo.ts)
  └─ valida MIME e tamanho
  └─ findProfilePhoto() — verifica se já existe
  └─ uploadFile({ acl: 'public-read', folder: 'dev/profiles' })
       └─ S3 PutObject
  └─ se existia, deleteFile(old.key) — best-effort
  └─ saveAttachment ou updateAttachment no banco
  └─ retorna { profilePhoto: { value: getPublicUrl(key) } }
```

### Upload de currículo

```
Frontend
  └─ POST /candidate/curriculum/attachment  [multipart, fields type, subtype]

Backend (create-curriculum.ts → saveAttachment)
  └─ uploadFile({ acl: 'private', folder: resolveFolderBySubtype(subtype) })
       └─ S3 PutObject (folder = 'dev/curriculum')
  └─ saveAttachment no banco com value = key
```

### Leitura do currículo

```
Frontend
  └─ GET /candidate/:id/curriculum

Backend (use-case)
  └─ getCurriculum()
       └─ busca no banco
       └─ para cada attachment:
            - PHOTO → getPublicUrl(key)
            - link externo → mantém
            - demais → getSignedDownloadUrl(key, 3600)
  └─ retorna currículo com URLs prontas

Frontend
  └─ exibe foto via <img>
  └─ abre anexos via window.open() ou <a> no momento do clique
```

### Substituição/remoção

`updateAttachment` (PUT) e `uploadProfilePhoto` (POST) deletam o arquivo antigo do bucket após sucesso da nova versão. Falha no delete antigo gera log mas não bloqueia a operação — o arquivo órfão pode ser limpo manualmente.

`deleteAttachment` (DELETE) remove do banco primeiro, depois do bucket. Se for um link externo (não é uma key), pula o delete no bucket.

## Pontos de atenção

- **TTL da signed URL** — definido em [control-curriculum.ts](../src/use-cases/control-curriculum.ts) como `SIGNED_URL_TTL_SECONDS = 3600`. Aumentar exige avaliar segurança.
- **Arquivos órfãos** — substituições ou exclusões com falha no bucket geram arquivos órfãos. Logs com prefixo `[storage:*]` ou `[control-curriculum:*]` identificam.
- **Migração de ambiente** — keys persistidas no banco contêm o prefixo de ambiente (`dev/...`). Não mover arquivos entre Spaces ou ambientes sem atualizar o `value` dos registros correspondentes.
- **Limite de upload** — `@fastify/multipart` no [src/app.ts](../src/app.ts) define o limite global. Validações de tamanho específicas estão nos controllers (5MB foto, 10MB anexo).
