# Ouvidoria — Aproveitamento no V7

> **Slug:** `Ouvidoria` · **Origem:** `lotep` V2 (`V2/Modules/Servicos/Ouvidoria`) ·
> **Destino:** `public-v7` (Laravel 12 / PHP 8.4, DDD-lite, `src/modules/Servicos/Ouvidoria`)
>
> Julgamento de portabilidade: o que portar como padrão concreto, o que adaptar às regras do V7
> e o que rejeitar. Cada decisão cita a ADR ou a regra que a justifica.

---

## 1. Veredito

| Dimensão | Decisão | Base |
|---|---|---|
| Protocolo `OUV-{Ymd}-{8}` | **Portar o formato**, reimplementar a geração | contrato observável do cidadão |
| Dicionários (tipo/assunto/origem/forma) | **Portar como dado**, com `codigo` estável e nome i18n | ADR-012/ADR-017 |
| Status 1/2/3 | **Adaptar** para enum + check, com desfecho explícito | ADR-017 |
| Captura pública do formulário | **Adaptar** com DTO, request, honeypot/time-trap e rate limit nomeado | ADR-021/ADR-019 |
| Lista/detalhe/resposta no CMS | **Adaptar** para controllers invocáveis + `Permission` | ADR-033 |
| Anonimato | **Portar a intenção**, com invariante de PII nula | ADR-019 |
| Importação V1 idempotente | **Portar** o mapa e a chave `legado_id` | ADR-017 §7 |
| Auditoria das ações de CMS | **Portar** via `AuditWriter`, não via helpers legados | ADR-035 |
| `$r->all() + create()` | **Rejeitar** | ADR-019/ADR-033 |
| Status inteiro mágico | **Rejeitar** | ADR-017 |
| `scopeSearch` com `orWhere` | **Rejeitar** | correção |
| IP cru e consentimento inerte | **Rejeitar** | ADR-019 |
| `boot()` gerando protocolo | **Rejeitar** | ADR-001 (determinismo) |
| HTML em PHP / estilo inline | **Rejeitar** | ADR-022/ADR-010 |
| Ausência de prazos/acompanhamento/e-mail | **Não portar a omissão**; implementar | backlog V7 |

---

## 2. O que o V2 já resolve

### 2.1 Protocolo

O V2 entrega o essencial: um identificador legível, único no banco, exibido ao cidadão e usado
na lista/ordenação.

```php
// V2/Modules/Servicos/Ouvidoria/Models/OmbudsmanRequest.php:86-88
if (empty($m->protocol)) {
    $m->protocol = 'OUV-' . now()->format('Ymd') . '-' . substr($m->id, 0, 8);
}
```

O V7 deve preservar **o formato** (`OUV-`, data `Ymd`, 8 caracteres), não o mecanismo. O
`Portal/Lgpd` do V7 já demonstra o padrão correto de geração determinística dentro da action:

```php
// src/modules/Portal/Lgpd/Logic/Actions/LgpdSubmitDsar.php
$id = $this->ids->generate();
$now = $this->clock->now();
$request->protocol = 'DSAR-'.$now->format('Y').'-'.strtoupper(substr($id, 0, 8));
```

Para a Ouvidoria, o equivalente é `OUV-'.$now->format('Ymd').'-'.strtoupper(substr($id, 0, 8))`,
com `IdGen`/`Clock` injetados (ADR-001) e retry de 3 tentativas na violação da unique.

### 2.2 Dicionários

O V2 acerta ao separar quatro dimensões e ao filtrar por `is_active` no portal
(`OmbudsmanPortalController.php:20-23`). O que falta é governo do dado: não há CRUD, não há
código estável, o rótulo é uma única string e o assunto perdeu o vínculo com o setor.

No V7, dicionário é **dado** e o rótulo é multilíngue (ADR-012):

| Dicionário V2 | Tabela V7 | Campos |
|---|---|---|
| tipos | `ouvidoria.tipos` | `codigo` (unique), `nome` jsonb, `prazo_dias`, `ativo`, `ordem` |
| assuntos | `ouvidoria.assuntos` | `codigo`, `nome` jsonb, `responsavel_email`, `ativo`, `ordem` |
| origens | `ouvidoria.origens` | `codigo`, `nome` jsonb, `ativo`, `ordem` |
| formas de resposta | `ouvidoria.formas_resposta` | `codigo`, `nome` jsonb, `ativo`, `ordem` |

Os valores do seeder viram linhas de migration com `codigo` fixo (`reclamacao`, `sugestao`,
`elogio`, `denuncia`; `presencial`, `online`, `telefone`, `email`; `email`, `telefone`, `carta`,
`presencial`; `loteria`, `atendimento`), porque ADR-017 §10 determina que dado de referência não
vive em seeder de demonstração.

### 2.3 Outros acertos pontuais

| Acerto do V2 | Como o V7 recebe |
|---|---|
| Permissão separa leitura de escrita | `ouvidoria.ver` / `ouvidoria.editar` (ADR-033) |
| Auditoria das ações de CMS | `AuditWriter::record(AuditEntry::event(...))` (ADR-035) |
| Importação idempotente por `v1_id` | coluna/chave de importação + `metadata.legado` (ADR-017 §7) |
| Importador não copia IP cru (usa sha256) | política default do V7: `ip_hash`, nunca IP (ADR-019) |
| Formulário público sem login | mantém-se público, com antiabuso (ADR-021) |

---

## 3. O que portar (padrão concreto)

### 3.1 Contrato de URL e nomes

| Item | Valor a preservar |
|---|---|
| URL pública | `/ouvidoria` (formulário) |
| URL de envio | `POST /ouvidoria` (o V2 usa `/ouvidoria/store`; redirect 301/308 para a nova) |
| Nomes de rota | `ouvidoria.portal.index`, `ouvidoria.portal.store` |
| Prefixo CMS | `/cms/ouvidoria` |
| Nomes CMS | `ouvidoria.cms.index`, `ouvidoria.cms.show`, `ouvidoria.cms.status`, `ouvidoria.cms.responder` |
| Token canônico | `ouvidoria` em classes, rotas, tabelas, spans e logs (ADR-004) |

### 3.2 Formato do protocolo

- Prefixo `OUV-`, data `Ymd`, 8 caracteres do id em maiúsculas.
- Coluna `text` com `unique`.
- Exibição no *flash* de sucesso e em uma página de “recebido” (padrão já usado por
  `lgpd.dsar.received`), para o protocolo não se perder no reload.
- O protocolo **nunca** é aceito do cliente; é sempre gerado no servidor.

### 3.3 Mapa de status V1 → V7

| Origem | Status V7 | Observação |
|---|---|---|
| `Em Tramitação`, `Prorrogado`, `Aberta`, `Finalizada`, `Cancelada` | `aberta` | o V2 já fazia isso, mas perdia a prorrogação |
| `Atendido` | `respondida` | — |
| `Indeferido` | `indeferida` | **corrigir** o V2, que mapeava para 2 |
| (novo) | `arquivada` | arquivamento/admin |

### 3.4 Dicionários ativos

O portal só lista `ativo = true`; a validação `exists` **também** precisa exigir
`ativo = true` e `deleted_at IS NULL` (o V2 não fazia — G-16).

### 3.5 Importação

Portar `OuvidoriaLegacyImporter` como um importador do módulo de migração, mantendo:
`key = ouvidoria`, `v1_id` único parcial, `map()` puro, `load()` transacional, dry-run e
tolerância a anexo ausente. A origem V1 é somente leitura.

---

## 4. O que adaptar

| Item do V2 | Padrão V7 | Regra/ADR |
|---|---|---|
| Módulo plano em `V2/Modules/Servicos/Ouvidoria` | `src/modules/Servicos/Ouvidoria` com `ModuleProvider`, `Database`, `Logic`, `Routes`, `Layout`, `Lang`, `Test` | ADR-003/ADR-013/ADR-014 |
| `ModuleProvider` com `active_pattern`/`position` | `config()` com `title`, `icon`, `position`, `route`, `permissions` | ADR-014/ADR-033 |
| Permissões `read`/`write` | vocabulário `ver/editar/aprovar/arquivar`; declarar só o que o módulo usa | ADR-033 |
| `v2.permission` + `v2_can` | `Permission::authorize('Ouvidoria', Permission::VIEW)` no controller | ADR-033 |
| `module.active` só no CMS | `module.activation` aplicado pelo `AreaRegistrar` em portal/CMS/API | ADR-034 |
| Credenciais/rotas sem `cache.private` | `->middleware('cache.private')` nas páginas com formulário | ADR-022 |
| `throttle:10,1` | limiter nomeado `ouvidoria` (IP + sessão), `429` RFC 7807 | ADR-021/ADR-024 |
| Sem honeypot/time-trap | copiar o padrão de `LgpdDsarSubmitRequest` (`website`, `timeTrap()`) | ADR-021 |
| Status inteiro | enum `StatusSolicitacao` + `CHECK` no banco | ADR-017 |
| Consentimento em checkbox solto | `consentimento` jsonb (`aceito_em`, `versao_aviso`, `finalidade`, `base_legal`) + `declaracao` validada | ADR-019 |
| IP cru | `ip_hash` sha256; `pii_redact()` nos logs | ADR-019/ADR-024 |
| PII em claro | casts `encrypted` para nome/e-mail/telefone/endereço/descrição, como `lgpd.dsar_requests` | ADR-019 |
| Busca por `name`/`email` | protocolo + filtros indexados; `email_hash` para igualdade | ADR-019 |
| Sem retenção | `RetentionTarget` registrado no `ModuleProvider` | ADR-019/ADR-017 |
| Sem e-mail | `Mail` + `Job` enfileirado (o serviço Notifications ainda não existe) | ADR-001/ADR-024 |
| Sem prazo | `prazo_resposta_em` calculado de `tipo.prazo_dias` + alerta diário | backlog V7 |
| Sem acompanhamento | `GET/POST /ouvidoria/acompanhar` com rate limit e sem PII | backlog V7 |
| `<x-cms::table>` legado | `<x-cms::table>`, `<x-cms::filter-card>`, `<x-cms::status-badge>`, `<x-ui::icon>` | ADR-009/ADR-010 |
| HTML em PHP e `{!! !!}` | componentes Blade; nenhum HTML montado no controller | ADR-010/ADR-022 |
| Estilo inline | classes/classe utilitária no CSS do layout | ADR-022 |
| Resposta em texto puro | `corpo` jsonb como AST TipTap + `RichTextRenderer` | ADR-028 |
| `file_url` string | `Services\Media` + `<x-media::picture>` (AVIF/WebP) | ADR-026/ADR-030 |
| Texto pt-BR fixo | `__('ouvidoria::...')` em `Lang/{pt_BR,en,es,fr}` | ADR-012 |
| Sem SEO | `SeoBinder::bindData()` e `@graph` na página pública | ADR-027 |
| Datas `d/m/Y H:i:s` | `Carbon` com locale do request | ADR-012 |
| Sem gate de acessibilidade | labels, `aria-describedby`, `aria-sort`, foco/erro | ADR-025 |
| Sem teste HTTP | `CoreTestCase` + rotas reais + cobertura ≥90% | ADR-038 |

### 4.1 Estrutura de dados na V7

Regras que valem para a migration e os models:

- PII (`nome`, `email`, `telefone`, `endereco`, `descricao`) **cifrada** com cast `encrypted`
  (`src/modules/Portal/Lgpd/Database/Models/LgpdDsarRequest.php` é o precedente).
- `email_hash` (sha256 normalizado) para igualdade, sem texto claro.
- `ip_hash` (sha256), nunca o IP.
- `consentimento` jsonb com data, versão do aviso, finalidade e base legal.
- `status` com `CHECK` e enum PHP; `prazo_resposta_em`, `respondido_em`, `anonymized_at`.
- `metadata->legado` para o que a importação não tem coluna (status original, `secretary_id`,
  `ip_hash` do legado).
- `softDeletesTz` e preservação de registro na retenção (nunca `DROP` — ADR-017).

Os detalhes de DDL estão em `06-MIGRACAO-MODERNA.md`.

### 4.2 Fluxo de resposta (o desvio mais importante)

O V2 força `status = 2`; o V7 deve receber o desfecho:

```php
// exemplo ilustrativo
DB::transaction(function () use ($solicitacao, $data, $autor): void {
    $solicitacao->respostas()->create([
        'autor_id' => $autor->getKey(),
        'corpo' => $data->corpo,
        'desfecho' => $data->desfecho->value,
        'anexos' => $data->anexos,
    ]);

    $solicitacao->status = $data->desfecho === DesfechoResposta::Indeferida
        ? StatusSolicitacao::Indeferida
        : StatusSolicitacao::Respondida;
    $solicitacao->respondido_em = $this->clock->now();
    $solicitacao->save();
});
```

A transação resolve G-08; o enum resolve G-03/G-11.

### 4.3 Auditoria e observabilidade

- Ações de CMS: `AuditEntry::event('ouvidoria', 'ouvidoria.cms.responder', ...)` via
  `AuditWriter` (ADR-035).
- Leitura de PII no CMS: coberta por `audit.access` no `AreaRegistrar` (ADR-035).
- Portal: **não** auditar por cidadão (ADR-035, verificação “Portal: nenhum registro de
  auditoria por cidadão”). Diferente do DSAR, que é auditado explicitamente por obrigação legal;
  a ouvidoria não deve copiar essa exceção sem decisão.
- Span `ouvidoria.registrar`/`ouvidoria.responder` + `log_event` sem PII (ADR-024).

### 4.4 Antiabuso

O `throttle:10,1` do V2 é um piso. No V7 (ADR-021):

- limiter nomeado `ouvidoria`: 10/min por IP + sessão;
- limiter `ouvidoria-consulta`: 5/min por IP;
- honeypot `website` (`prohibited`) e time-trap (`opened_at`) copiados de
  `LgpdDsarSubmitRequest`;
- resposta `429` em RFC 7807 com `Retry-After` (ADR-024);
- fail strategy: formulário mais restritivo, leitura pública fail-open.

---

## 5. O que rejeitar

| Item | Por que rejeitar | Regra que impede |
|---|---|---|
| `$data = $r->all(); Model::create($data)` | permite forjar status/protocolo/anonimato e gravar JSON arbitrário | ADR-019 (minimização) + ADR-033 (negação por padrão) |
| `protocol`/`status`/`is_anonymous` no `$fillable` | contrato de entrada vira contrato de banco | ADR-017/ADR-019 |
| `status` inteiro mágico repetido | estados inválidos e relatórios frágeis | ADR-017 |
| `scopeSearch` com `orWhere` solto | busca não compõe e pode furar soft delete | correção + ADR-001 |
| Protocolo gerado no `boot()` | efeito colateral oculto, colisão sem retry, teste frágil | ADR-001 (determinismo/DI) |
| IP cru e exibição de IP | dado pessoal sem finalidade | ADR-019 |
| `user_option` sem validação/coluna | consentimento decorativo | ADR-019 |
| `file_url` como string | anexo sem digestão, sem AVIF/WebP, sem storage | ADR-026/ADR-030 |
| HTML em closures com `{!! !!}` | quebra a garantia de escape | ADR-010/ADR-022 |
| `style="..."` inline | incompatível com CSP sem inline | ADR-022 §4 |
| `dd()`/debug no fluxo | exposição de request | ADR-024 |
| `Mail::send` síncrono na request (V1) | latência e falha acoplada ao registro | ADR-001 + fila |
| `scopeActive`/`scopeOrdered` mortos, `metadata` livre | dívida sem contrato | ADR-001 (simplicidade) |
| Título único (sem i18n) | quebra o multilíngue | ADR-012 |
| Cópia do e-SIC dentro da Ouvidoria | prazos/recursos da LAI são outro domínio | `wiki/backlog/modulo-esic.md` |

---

## 6. Comparação com o backlog do V7

### 6.1 `wiki/backlog/modulo-ouvidoria.md`

| Exigência do backlog (`wiki/backlog/modulo-ouvidoria.md:11-22`) | O V2 resolve? | Plano no V7 |
|---|---|---|
| Manifestação com tipo, protocolo, sigilo/anônimo, anexos | **parcial**: tipo e protocolo sim; sigilo não; anexos não | enum/booleano `anonimo` com invariante + Media |
| Fluxo de tratamento/resposta e **prazos** com notificação | **não** | `prazo_resposta_em`, job de alerta, e-mail |
| Acompanhamento por protocolo sem PII | **não** | rota pública com rate limit e resposta sem PII |
| Classificação de dados + DSAR + retenção (ADR-019) | **não** | cifra, consentimento, `RetentionTarget`, DSAR no módulo `Portal/Lgpd` |
| Antiabuso (ADR-021) e acessibilidade (ADR-025) | **parcial**: só throttle | limiter nomeado + honeypot/time-trap + gate de acessibilidade |
| **E-mail ao manifestante** (confirmação e resposta) | **não** (a V1 enviava) | job enfileirado + `Mail`, sem acoplar à request |
| CRUD dos dicionários pelo operador | **não** (só seed) | telas de dicionário com `codigo` estável |
| Painel de indicadores | **não** | consultas agregadas por tipo/status/prazo |

Conclusão: o V2 cobre **captura + consulta básica**. O backlog da V7 pede um ciclo completo; a
distância não é de código, é de produto (prazos, sigilo, acompanhamento, LGPD, indicadores).

### 6.2 `wiki/backlog/modulo-esic.md`

| Exigência do e-SIC (`wiki/backlog/modulo-esic.md:11-22`) | O V2 resolve? | Onde deve viver |
|---|---|---|
| Pedido de acesso à informação com protocolo | o protocolo é reutilizável como padrão | e-SIC |
| **Prazos legais 20 + 10 dias**, alertas, recurso 1ª/2ª instância | **não** (e não é escopo de ouvidoria) | e-SIC (ADR própria de prazos/recursos) |
| Resposta com “acesso negado” e fundamento legal | **não** | e-SIC |
| Painel de cumprimento de prazos | **não** | e-SIC |
| LGPD e antiabuso | parcialmente aproveitáveis | ambos, via Core/Portal/Lgpd |

**Decisão de fronteira:** a Ouvidoria deve ter **prazo administrativo** próprio (configurável por
tipo), não os prazos legais da LAI. Reutilizar o mesmo módulo para os dois mistura bases legais,
recursos e indicadores — a recomendação é manter `Servicos/Ouvidoria` e criar `Servicos/Esic`
quando o e-SIC entrar no escopo. O padrão de protocolo, dicionários e acompanhamento pode ser
compartilhado por convenção, não por herança (sem traits/abstract — regra do V7).

---

## 7. Contrato de equivalência (não pode mudar sem decisão)

1. `GET /ouvidoria` responde 200 com o formulário, mesmo sem sessão.
2. `POST` válido cria exatamente uma manifestação e devolve o protocolo `OUV-\d{8}-[A-Z0-9]{8}`.
3. O protocolo é único; a mensagem de sucesso o exibe.
4. Só dicionários ativos aparecem no formulário.
5. Manifestação sem dados pessoais é aceita.
6. O CMS lista, filtra e pagina; o detalhe mostra dicionários, descrição e respostas.
7. Quem tem `ver` não escreve; quem tem `editar` altera status e responde.
8. A importação V1 permanece idempotente e não altera a origem.
9. Nenhuma resposta do CMS altera o texto original da manifestação.

O que muda **intencionalmente**: status vira enum com desfecho explícito; PII passa a ser
cifrada; a busca deixa de cobrir nome/e-mail; o consentimento passa a ser exigido e registrado;
o IP vira hash.

---

## 8. Estrutura de arquivos no V7

```text
src/modules/Servicos/Ouvidoria/
├── ModuleProvider.php              # config() + register() (RetentionTarget, Seo se necessário)
├── MODULE.md                       # contrato de leitura (padrão Noticias/Lgpd)
├── Database/
│   ├── Migrations/2026_MM_DD_HHMMSS_create_ouvidoria_tables.php
│   └── Models/{Solicitacao,Resposta,Tipo,Assunto,Origem,FormaResposta}.php
├── Logic/
│   ├── Actions/{RegistrarManifestacao,ResponderManifestacao,AlterarStatus,ConsultarManifestacao}.php
│   ├── Controllers/Portal/{FormularioController,RegistrarController,AcompanharController}.php
│   ├── Controllers/Cms/{IndexController,ShowController,StatusController,RespostaController}.php
│   ├── DTOs/{ManifestacaoData,RespostaData,AcompanhamentoData}.php
│   ├── Requests/{RegistrarManifestacaoRequest,ResponderManifestacaoRequest,AlterarStatusRequest,AcompanharRequest}.php
│   ├── Enums/{StatusSolicitacao,DesfechoResposta}.php
│   └── Jobs/{EnviarConfirmacao,EnviarResposta,AvisarPrazo}.php
├── Retention/RetencaoOuvidoria.php
├── Routes/{portal,cms}.php
├── Layout/Views/portal/{index,acompanhar,recebido}.blade.php
├── Layout/Views/cms/{index,show}.blade.php
├── Lang/{pt_BR,en,es,fr}/{index,cms,menu}.php
└── Test/{OuvidoriaPortalTest,OuvidoriaCmsTest,OuvidoriaRetencaoTest}.php
```

O `ModuleProvider` segue o padrão de `Portal/Lgpd`:

```php
// exemplo ilustrativo
public function register(): void
{
    app(RetentionRegistry::class)->register(RetencaoOuvidoria::class);
}

public function config(): array
{
    return [
        'title' => 'ouvidoria::menu.title',
        'icon' => 'lucide-messages-square',
        'position' => 71,
        'route' => 'ouvidoria.cms.index',
        'permissions' => [Permission::VIEW, Permission::EDIT, Permission::ARCHIVE],
    ];
}
```

---

## 9. Critérios de aceite da portabilidade

- [ ] O formato do protocolo e a unicidade são idênticos aos do V2 (teste de regex + unique).
- [ ] Os quatro dicionários existem como dado, com `codigo` estável e `nome` i18n.
- [ ] O portal aceita manifestação sem PII e exige consentimento quando há retorno.
- [ ] `POST` com `status`, `protocol`, `is_anonymous` ou `metadata` **não** altera o resultado.
- [ ] A busca do CMS compõe protocolo + status + tipo + período corretamente.
- [ ] A resposta grava desfecho, autor, corpo e data na mesma transação.
- [ ] A PII é cifrada; o IP é hash; nenhum log contém PII.
- [ ] Retenção/anonimização roda por `retention:expunge` e preserva o protocolo.
- [ ] Módulo desativado responde 404 no portal **e** no CMS.
- [ ] As rotas antigas (`/ouvidoria/store`, `/cms/v2/ouvidoria/...`) têm redirect ou plano de corte.
- [ ] A importação V1 é idempotente e corrige o mapa de “Indeferido”.
- [ ] A suíte cobre os casos de `07-TESTES.md` e atinge a meta de ADR-038.

---

## 10. ADRs e referências aplicadas

| ADR | Uso neste módulo |
|---|---|
| ADR-001 | determinismo: `Clock`/`IdGen` injetados; protocolo sem `now()` no model |
| ADR-003/ADR-013/ADR-014 | `src/modules/Servicos/Ouvidoria`, DDD-lite, `ModuleProvider` único |
| ADR-004 | token canônico `ouvidoria` em classes, rotas, tabelas, spans e logs |
| ADR-007/ADR-017 | PostgreSQL 18, schema dedicado, uuidv7, `timestamptz`, jsonb, migration idempotente |
| ADR-009/ADR-010 | UI do CMS em componentes; portal semântico com JS mínimo |
| ADR-012 | nome de dicionário e rótulos em jsonb/`Lang` |
| ADR-019 | cifra de PII, minimização, consentimento, retenção, DSAR |
| ADR-021 | rate limit nomeado, honeypot, time-trap, 429 |
| ADR-022 | CSP sem inline, `cache.private` em formulário, zero CDN |
| ADR-024 | log JSON, `pii_redact()`, RFC 7807 |
| ADR-025 | acessibilidade como gate |
| ADR-026/ADR-030 | anexos via Media/Gallery, AVIF/WebP, S3 |
| ADR-027 | SEO/`@graph` na página pública e 404 real |
| ADR-028 | resposta em AST TipTap, nunca HTML |
| ADR-033 | `ouvidoria.{ver,editar,aprovar,arquivar}` e negação por padrão |
| ADR-034 | ativação como dado e guard nas duas superfícies |
| ADR-035 | trilha no CMS; portal sem auditoria por cidadão |
| ADR-038 | cobertura ≥90% e não regressão |

Referências de produto: `wiki/backlog/modulo-ouvidoria.md` (prazos, acompanhamento, indicadores,
LGPD) e `wiki/backlog/modulo-esic.md` (LAI 20+10, recursos) — o primeiro é o alvo da Ouvidoria;
o segundo delimita o que **não** entra nela.
