# Carta de Serviços — PDF, Anexos e Arquivos

## 1. Gerador de PDF — estado atual

### 1.1 O que existe

| Artefato | Caminho | Estado |
|---|---|---|
| Controller | `v6/app/Http/Controllers/Prefecture/ServiceLetter/ServiceLetterPdfController.php` | existe, funcional (exceto a geração) |
| Model | `v6/app/Http/Model/Prefecture/ServiceLetter/ServiceLetterPdf.php` | existe |
| Tabela | `service_letter_pdfs` | existe (13 colunas) |
| Migration | `v6/database/migrations/2026_01_20_160736_create_service_letter_pdfs_table.php` | existe |
| **Serviço de geração** | `App\Services\ServiceLetterPdfService` | **NÃO EXISTE** |
| Configuração | `service_letter_configs` | existe, com todos os campos de edição |
| View CMS | `v6/resources/views/prefecture/service-letter/pdfs/index.blade.php` | existe |
| Rota portal | `GET /carta-de-servicos/pdf` | existe |

### 1.2 A classe ausente

`v6/app/Http/Controllers/Prefecture/ServiceLetter/ServiceLetterPdfController.php`:

```php
use App\Services\ServiceLetterPdfService;
…
public function generate(Request $request)
{
    try {
        $config = $this->config->first();
        if (!$config) {
            flash('Configure a Carta de Serviços antes de gerar o PDF.')->error();
            return redirect()->route('carta-de-servicos.config.index');
        }

        $services = $this->serviceLetter
            ->where('ativo', true)
            ->where('publicado', true)
            ->with(['subjects', 'secretaria', 'channels', 'steps', 'documents'])
            ->orderBy($config->ordenacao_servicos === 'secretaria' ? 'secretaria_id' : 'nome')
            ->get();

        if ($services->isEmpty()) {
            flash('Não há serviços publicados para gerar o PDF.')->error();
            return redirect()->back();
        }

        $pdfService = new ServiceLetterPdfService();
        $result = $pdfService->generate($config, $services);

        $pdfRecord = $this->pdf->create([
            'config_id' => $config->id,
            'arquivo' => $result['path'],
            'nome_arquivo' => $result['filename'],
            'tamanho_bytes' => $result['size'],
            'total_servicos' => $services->count(),
            'total_paginas' => $result['pages'] ?? null,
            'ordenacao_utilizada' => $config->ordenacao_servicos,
            'incluiu_sumario' => $config->exibir_sumario,
            'status' => 'gerado',
            'gerado_por' => Sentinel::getUser()->id,
        ]);
        …
```

**Contrato implícito do serviço ausente** (deduzido do uso — a implementação deve respeitá-lo):

| Método | Retorno esperado |
|---|---|
| `generate(ServiceLetterConfig $config, Collection $services): array` | `['path' => string, 'filename' => string, 'size' => int, 'pages' => int|null]` |

O `path` deve ser relativo ao disco `public`, **prefixado com `storage/`** — porque o controller de download faz:

```php
$path = storage_path('app/public/' . str_replace('storage/', '', $pdf->arquivo));
```

### 1.3 Consequência do bug

O `catch (Exception $e)` do controller **não** captura `Error` (a classe `Error` do PHP implementa `Throwable`, não `Exception`). Resultado: **erro fatal 500** sem mensagem amigável, sem registro da linha em `service_letter_pdfs` (o `create` só ocorre **depois** da geração) e sem a mensagem "Ocorreu um erro ao gerar o PDF: …".

O caminho de erro (`status='erro'`, `erro_mensagem`) que o blade e a migration prevêem **nunca é alcançado**.

### 1.4 Configuração que o PDF deveria consumir

| Campo | Uso esperado |
|---|---|
| `nome` | Título do documento |
| `descricao` | Subtítulo |
| `cabecalho` | Bloco HTML no cabeçalho de cada página |
| `rodape` | Bloco HTML no rodapé de cada página |
| `texto_antes_sumario` | Texto introdutório antes do sumário |
| `texto_depois_sumario` | Texto entre o sumário e o primeiro serviço |
| `texto_encerramento` | Texto final |
| `exibir_sumario` | Se `true`, gera sumário com páginas |
| `tipo_capa` | `editor` (usa `capa_conteudo_editor`) ou `pdf` (usa `capa_arquivo_pdf`) |
| `capa_conteudo_editor` | HTML da capa (campo **inalcançável** pela UI — o bloco está comentado no blade) |
| `capa_arquivo_pdf` | Arquivo PDF da capa |
| `ordenacao_servicos` | `secretaria` (ordena por `secretaria_id`) ou qualquer outro valor (ordena por `nome`) |
| `fonte_texto`, `tamanho_fonte_texto`, `cor_texto` | Tipografia do corpo |
| `fonte_titulo`, `tamanho_fonte_titulo`, `cor_titulo` | Tipografia dos títulos |

> **Limitação verificada:** a ordenação por `assunto` (opção oferecida no formulário) **não** é implementada — o controller só distingue `secretaria` de "tudo o mais" e, nesse caso, ordena por `nome`.

### 1.5 Telas do PDF no CMS

`v6/resources/views/prefecture/service-letter/pdfs/index.blade.php` — listagem paginada (10 por página) com colunas: ID, Arquivo, Serviços, Páginas, Ordenação, Sumário, Status, Gerado em, Ações.

Ações:
- **Visualizar** → `GET /cms/carta-de-servicos/pdfs/{id}/visualizar` (abre em nova aba)
- **Download** → `GET /cms/carta-de-servicos/pdfs/{id}/download`
- **Excluir** → `DELETE /cms/carta-de-servicos/pdfs/{id}` (form com `method_field('delete')`)

Badge de status (implementa os 3 valores do enum + um fallback):

```blade
@if ($pdf->status == 'gerado')
    <span class="badge badge-success">Gerado</span>
@elseif($pdf->status == 'publicado')
    <span class="badge badge-info">Publicado</span>
@elseif($pdf->status == 'erro')
    <span class="badge badge-danger" title="{{ $pdf->erro_mensagem }}">Erro</span>
@else
    <span class="badge badge-warning">Gerando...</span>
@endif
```

> `status = 'publicado'` nunca é gravado; `status = 'erro'` nunca é gravado (por causa do §1.3); o fallback "Gerando..." é unreachable no modelo síncrono atual.

### 1.6 Download no portal

`v6/app/Http/Controllers/Portal/ServiceLetterController.php`:

```php
public function pdf()
{
    $pdf = $this->pdf->where('status', 'gerado')->orderBy('gerado_em', 'desc')->first();
    if (!$pdf) {
        flash('Nenhum PDF disponível.')->error();
        return redirect()->route('carta-servicos.index');
    }
    $path = storage_path('app/public/' . str_replace('storage/', '', $pdf->arquivo));
    if (!file_exists($path)) {
        flash('Arquivo não encontrado.')->error();
        return redirect()->route('carta-servicos.index');
    }
    return response()->file($path);
}
```

O botão "Baixar PDF Completo" só aparece se houver um PDF com `status='gerado'` — verificação feita **dentro do blade** (`nav.blade.php`), o que gera uma query por renderização.

### 1.7 Bibliotecas de PDF disponíveis na plataforma

| Pacote | Uso na plataforma | Utilizável aqui? |
|---|---|---|
| `dompdf/dompdf` ^0.8.3 + `barryvdh/laravel-dompdf` ^0.8.7 | `PDF::loadView(...)` no relatório estatístico da Ouvidoria | Sim — HTML/CSS limitado, sem JS |
| `tecnickcom/tcpdf` ^6.3 + `setasign/fpdi` ^2.3 | `v6/app/Services/FPdfService.php` (Diário Oficial) | Sim — melhor controle de cabeçalho/rodapé e sumário |
| `imagick` + Ghostscript | `v6/app/Services/Pdf/PdfConverter.php` | Complementar (converte PDF externo → imagem) |

Recomendação para a v7: **`barryvdh/laravel-dompdf`** (dompdf) para o corpo + **TCPDF** se a capa em PDF exigir composição real; ou **TCPDF** puro, que já é padrão na plataforma e tem `writeHTML()` com sumário automático (`addTOC()`).

## 2. Anexos

### 2.1 Fluxo completo

```
Operador → aba "Anexos" → "Adicionar Anexo"
   → #modal_anexo: arquivo (obrigatório, ≤10 MB), nome, descrição, ordem, ativo
   → FormData (com o arquivo binário) → POST /cms/carta-de-servicos/anexos
       → validate: service_letter_id|required|exists
                  arquivo|required|file|max:10240
       → $file->storeAs('service-letter/attachments/' . $service_letter_id, $name)
            $name = date('YmdHis') . '_' . $file->getClientOriginalName()
       → ServiceLetterAttachment::create([
             'service_letter_id' => …,
             'nome'              => $request->nome ?? $file->getClientOriginalName(),
             'descricao'        => $request->descricao,
             'arquivo'          => 'storage/' . $path,
             'tipo_arquivo'     => $file->getMimeType(),
             'tamanho_bytes'    => $file->getSize(),
             'ordem'            => $request->ordem ?? 0,
             'ativo'            => $request->has('ativo'),
           ])
       → JSON { success, message, data }
   → renderAnexoRow(data) insere a linha
```

### 2.2 Colunas

| Coluna | Origem |
|---|---|
| `nome` | campo do formulário; default = nome original do arquivo |
| `descricao` | campo do formulário (varchar 500) |
| `arquivo` | `'storage/' . $path` — caminho relativo ao disco `public` |
| `tipo_arquivo` | mime type detectado (`$file->getMimeType()`) |
| `tamanho_bytes` | tamanho em bytes |
| `ordem` | campo do formulário, default 0 |
| `ativo` | checkbox |

### 2.3 Path em disco

```
storage/app/public/service-letter/attachments/{service_letter_id}/{YmdHis}_{nomeOriginal}
```

Exemplo: `storage/app/public/service-letter/attachments/12/20260120153045_requerimento.pdf`

URL pública montada à mão no blade: `href="/{{ $data.arquivo }}"` → `/storage/service-letter/attachments/12/...` — depende do symlink `public/storage` → `storage/app/public`.

> **Verificação necessária no deploy:** a existência do symlink `public/storage`. Se ausente, todos os anexos e fotos de responsável retornam 404.

### 2.4 Exclusão

```php
public function destroy($id)
{
    $attachment = $this->attachment->findOrFail($id);
    $path = str_replace('storage/', '', $attachment->arquivo);
    Storage::delete($path);
    $attachment->delete();
    …
}
```

- `Storage::delete()` usa o disco **default** (`public`, conforme `v6/config/filesystems.php:16`) → apaga o arquivo físico. **Correto.**
- O registro do banco é **soft delete** (o model usa `SoftDeletes`).

### 2.5 Atualização

`PUT /cms/carta-de-servicos/anexos/{id}`:
- Aceita `nome`, `descricao`, `ordem` e `ativo`.
- Se houver novo `arquivo`, grava o novo e **não** remove o arquivo anterior → **órfão em disco**.

### 2.6 Exposição pública

> **Verificado:** nenhuma view do portal renderiza `service_letter_attachments`. A relação `ServiceLetter::attachments()` existe e é usada no `edit` do CMS (`with([... 'attachments' ...])`), mas o portal não a carrega nem a exibe. Funcionalidade **CMS-only**.

## 3. Foto do responsável

Dois fluxos distintos para a mesma entidade:

| Fluxo | Rota | Código | Disco |
|---|---|---|---|
| **Campo do formulário do serviço** | `POST/PUT /cms/carta-de-servicos/servicos` | `storeAs('service-letter/responsaveis', $name)` — **sem argumento de disco** | default (`public`) |
| **Aba de responsáveis** | `POST/PUT /cms/carta-de-servicos/responsaveis` | `storeAs('service-letter/responsaveis', $name, 'public')` — disco explícito | `public` |

Nome do arquivo:
- Fluxo 1: `'responsavel_' . date('YmdHis') . '.' . $file->extension()`
- Fluxo 2: `'responsavel_' . date('YmdHis') . '_' . uniqid() . '.' . $file->extension()`

Caminho gravado: `storage/service-letter/responsaveis/{nome}`.

> **Divergência de risco verificada:** o fluxo 1 gera nomes **colisionáveis** (só timestamp, sem `uniqid`) — dois uploads no mesmo segundo sobrescrevem o arquivo. O fluxo 2 é seguro.

### 3.1 Exibição

No CMS (`partials/responsaveis.blade.php`): `<img src="/{{ $responsavel->foto }}" height="30">`.

No portal (`view.blade.php`): a seção "Responsáveis" exibe **apenas** nome, cargo, e-mail e telefone — **a foto não é exibida**. Os campos `responsavel_foto` do formulário do serviço também não são exibidos no portal.

## 4. Arquivos: inventário completo

| Arquivo | Diretório em disco | Campo no banco | URL pública |
|---|---|---|---|
| Foto do responsável (form do serviço) | `service-letter/responsaveis/` | `service_letters.responsavel_foto` | `/storage/service-letter/responsaveis/…` |
| Foto do responsável (aba) | `service-letter/responsaveis/` | `service_letter_responsaveis.foto` | `/storage/service-letter/responsaveis/…` |
| Imagem do assunto | `service-letter/subjects/` | `service_letter_subjects.imagem` | `/storage/service-letter/subjects/…` |
| Anexo do serviço | `service-letter/attachments/{id}/` | `service_letter_attachments.arquivo` | `/storage/service-letter/attachments/…` |
| Capa em PDF | `service-letter/config/` | `service_letter_configs.capa_arquivo_pdf` | `/storage/service-letter/config/…` |
| PDF gerado | (a definir pelo serviço ausente) | `service_letter_pdfs.arquivo` | `storage_path('app/public/' . …)` |

## 5. Recomendações de modernização (storage)

| Uso | Disco moderno | Observação |
|---|---|---|
| Anexo do serviço | `local` (privado) | Servir via rota assinada (`URL::temporarySignedRoute`) ou rota autenticada — evita XSS por SVG/HTML servido do disco público |
| Foto do responsável | `public` | Exibida no CMS; no portal hoje não é exibida |
| Imagem do assunto | `public` | Exibida no menu do portal (quando implementada) |
| Capa do PDF | `private` | Usada apenas na geração |
| PDF gerado | `private` + rota de download autenticada/assinada | O portal atual serve o PDF **sem** controle de acesso (`response()->file`) |

Além disso:

1. **Gerar nomes de arquivo com UUID**, nunca o nome original (evita colisão, XSS via nome e problemas de path).
2. **Validar a extensão E o mime type** com allowlist (o hoje aceita qualquer arquivo até 10 MB).
3. **Remover o arquivo do disco** ao excluir ou substituir o registro (hook `deleting` no model ou `Job`).
4. **Servir anexos com `Content-Disposition: attachment` e `X-Content-Type-Options: nosniff`**.
5. **Mover o PDF para fora do disco público**, servindo-o por rota com verificação de `status = 'publicado'`.
