# Ouvidoria — Regras e Portabilidade

> **Slug:** `Ouvidoria` · **Versão analisada:** `lotep` (Laravel 5.5.50 / PHP 7.4)
>
> Regras observáveis, armadilhas técnicas, divergências V1→V2 e o que precisa ser decidido
> antes de reconstruir o módulo no V7.

---

## 1. Regras de negócio

| # | Regra | Onde é implementada | Efeito observável |
|---|---|---|---|
| 1 | Toda manifestação recebe protocolo `OUV-{Ymd}-{8}` se não vier um | `OmbudsmanRequest.php:86-88` (hook `creating`) | protocolo exibido no *flash* e usado na lista/ordenação |
| 2 | O protocolo é único no banco | migration `...000005...:19` | `INSERT` duplicado falha com erro de integridade |
| 3 | `status` nasce `1` (Em tramitação) | default da coluna (`...000005...:25`) | toda manifestação nova entra na fila |
| 4 | Registrar resposta muda o status para `2` (Atendido) | `OmbudsmanRequestCmsController.php:88` | uma resposta a uma denúncia a marca como “Atendida” |
| 5 | O status pode ser alterado manualmente para 1, 2 ou 3 pela tela | `updateStatus()` + `show.blade.php:78-82` | o operador pode reabrir ou indeferir sem resposta |
| 6 | O portal aceita qualquer combinação de dicionários (todos opcionais) | validação inline (`OmbudsmanPortalController.php:31`) | manifestação sem tipo/assunto/origem/forma é válida |
| 7 | Apenas dicionários `is_active = true` são oferecidos no formulário | `scopeActive()` nos quatro models | item desativado some do portal |
| 8 | O portal só exige descrição | `description => required` | os demais campos podem vir vazios |
| 9 | O CMS exige nível `read` para listar/ver e `write` para status/resposta | middleware + `v2_can` | usuário `read` não escreve |
| 10 | O portal tem limite de 10 POSTs por minuto por IP | `throttle:10,1` (`V2/Modules/Servicos/Ouvidoria/Routes/web.php:6`) | 429 acima do limite |
| 11 | “Anônimo” é derivado de `name` vazio na lista; `is_anonymous` não é usado na UI | `index.blade.php:30-32` | rótulo “Anônimo” mesmo com `is_anonymous = false` |
| 12 | O IP de origem é gravado e exibido no detalhe | `OmbudsmanPortalController.php:34`, `show.blade.php:36` | dado pessoal exposto ao operador |
| 13 | A importação V1 mapeia status por nome: `Atendido`/`Indeferido` → 2; demais → 1 | `OuvidoriaLegacyImporter.php:47-54` | indeferimentos importados aparecem como “Atendido” |
| 14 | A importação nunca copia o IP cru; grava `sha256` em `metadata.legado.ip_hash` | `OuvidoriaLegacyImporter.php:130-143` | dado importado é mais protegido que o dado novo |
| 15 | O consentimento do portal (`user_option`) não é exigido, validado nem persistido | view vs validação vs `$fillable` | o cidadão “autoriza” algo que o sistema ignora |

---

## 2. Estados e transições

```
                 POST /ouvidoria/store
                          |
                          v
                  [1 Em tramitação]
                   /      |       \
      answer()    /   updateStatus  \   updateStatus
                  v       |          v
          [2 Atendido] <--+       [3 Indeferido]
                  |                     |
                  +------ updateStatus --+
```

| De | Para | Gatilho | Quem pode | Efeito colateral |
|---|---|---|---|---|
| — | 1 | criação | público | protocolo, IP, timestamps |
| 1 | 2 | resposta registrada | `write` | insere `v2_ombudsman_answers` |
| 1/2/3 | 1 | `updateStatus` com `status=1` ou **sem campo** | `write` | reabre a manifestação |
| 1/2/3 | 3 | `updateStatus` com `status=3` | `write` | permite indeferir sem resposta |
| 2 | 1 | `updateStatus` | `write` | reabre uma manifestação já respondida |
| qualquer | qualquer inteiro | `updateStatus` sem validação | `write` | estado fora do vocabulário |

Não há transição proibida, carimbo de data de resposta, autor da mudança no registro (só na
trilha de auditoria) nem histórico de status.

---

## 3. Regras implícitas

Comportamentos que o código herdou e que o usuário já percebe:

- **O protocolo é a única prova de que a manifestação existe.** Não há e-mail, tela de
  acompanhamento nem cópia para o cidadão; se o *flash* for perdido, o protocolo some.
- **O índice do CMS não mostra a descrição.** É necessário abrir o detalhe para saber do que se
  trata; a busca não cobre `description`.
- **O filtro por status não existe na tela**, embora a coluna tenha índice e a ordenação por
  status esteja disponível.
- **Manifestações importadas e novas convivem na mesma tabela**, com formatos de protocolo
  diferentes (`PROT-*` da V1 e `OUV-*` do V2) e sem distinção visual.
- **O “tipo” é o único campo que a tela do portal marca como obrigatório**; o servidor não o
  exige. Uma manifestação sem tipo aparece com “—” na lista e no detalhe.
- **O operador pode responder várias vezes.** Não há unicidade de resposta nem bloqueio após
  `status = 2`.
- **A resposta é texto puro**, não passa por editor rico, markdown ou sanitização; é exibida
  com escape na Blade.
- **A exclusão é lógica**: não há rota de exclusão, mas o model usa `SoftDeletes`; a limpeza só
  ocorre por acesso direto ao banco/importadores.

---

## 4. Armadilhas técnicas

| Tipo | Onde | Impacto | Gravidade |
|---|---|---|---|
| Mass assignment | `OmbudsmanPortalController.php:32-35` + `OmbudsmanRequest.php:73` | cliente injeta `status`, `protocol`, `is_anonymous`, `metadata` | **crítica** |
| `orWhere` no topo | `OmbudsmanRequest.php:148-154` | busca não combina com outros filtros; risco de vazar soft-deleted | **alta** |
| Validação ausente de status | `OmbudsmanRequestCmsController.php:71` | estado arbitrário/`1` por omissão | **alta** |
| Escrita sem transação | `OmbudsmanRequestCmsController.php:87-88` | resposta sem atualização de status | **média** |
| Geração de protocolo no `boot()` do model | `OmbudsmanRequest.php:78-91` | efeito colateral em todo `create()`, difícil de testar/importar | **média** |
| `empty($t)` na busca | `OmbudsmanRequest.php:150` | buscar `"0"` devolve tudo | **baixa** |
| `%`/`_` não escapados | `OmbudsmanRequest.php:153` | cliente injeta curingas e amplia a busca | **baixa** |
| `ILIKE` hardcoded | `OmbudsmanRequest.php:153` | PostgreSQL-only (não é portátil para MySQL) | **baixa** |
| Ausência de índice para busca | `...000005...` | *seq scan* em três colunas de texto | **média** |
| N+1 | `index.blade.php:33-35` | uma query por linha para `type` | **média** |
| HTML em PHP | `index.blade.php:6-25` (closures) + `table.blade.php:141` (`{!! !!}`) | quebra a garantia de escape da Blade | **média** |
| Estilo inline | `portal/index.blade.php:18`, `show.blade.php:44` | incompatível com CSP sem inline (V7) | **média** |
| Sem exclusão/arquivamento | — | manifestações crescem indefinidamente | **média** |
| Sem validação de dicionário ativo | regra `exists:` | aceita id de dicionário inativo/excluído | **baixa** |
| `secretary_email`/`responsible` no `fillable` sem coluna | `OmbudsmanSubject.php:46` | erro SQL se usados | **baixa** |
| `scopeActive`/`ordered` mortos em `OmbudsmanRequest` | `OmbudsmanRequest.php:131-142` | código sem chamador | **baixa** |

### 4.1 Detalhe do `scopeSearch`

```php
// V2/Modules/Servicos/Ouvidoria/Models/OmbudsmanRequest.php:148-154
public function scopeSearch($q, $t)
{
    if (empty($t)) {
        return $q;
    }
    return $q->where('protocol', 'ILIKE', '%' . $t . '%')->orWhere('name', 'ILIKE', '%' . $t . '%')->orWhere('email', 'ILIKE', '%' . $t . '%');
}
```

A expansão lógica é `protocol LIKE ? OR name LIKE ? OR email LIKE ?`, **sem parênteses**. Como
o Eloquent acrescenta condições subsequentes com `AND`, qualquer filtro adicionado depois
(status, tipo, período, ou o próprio escopo global de soft delete) produz a árvore
`A OR B OR (C AND D)` — o `AND` liga mais forte que o `OR` em SQL. Consequências:

1. **Não é possível compor filtros** com a busca atual: `search($t)->where('status', 1)`
   retorna linhas de qualquer status que casem em `protocol` ou `name`.
2. **Soft delete pode ser furado** por linhas que casem nos dois primeiros termos, porque o
   `whereNull('deleted_at')` do escopo global é anexado ao fim da cadeia `OR`. O comportamento
   exato depende da ordem de aplicação dos escopos globais no Laravel 5.5 e **não foi verificado
   em runtime**; o teste de regressão proposto está em `07-TESTES.md`.
3. **Curingas do usuário** (`%`, `_`) não são escapados; a busca aceita padrões arbitrários.
4. **`"0"` é tratado como vazio** (`empty('0') === true`), devolvendo a lista inteira.

A correção mínima no V2 seria agrupar: `$q->where(function ($w) use ($t) { ... })`. No V7, a
busca deve ser um escopo/consulta explícita com `ILIKE` escapado e `search_vector` (ADR-017).

### 4.2 Detalhe do mass assignment

```php
// V2/Modules/Servicos/Ouvidoria/Controllers/Portal/OmbudsmanPortalController.php:32-35
$data = $r->all();
$data['id'] = \v2_uuid();
$data['ip'] = $r->ip();
$request = OmbudsmanRequest::create($data);
```

Com o `$fillable` atual (`id`, `protocol`, `name`, `email`, `phone`, `description`, `address`,
`status`, `ip`, `is_anonymous`, as quatro FKs e `metadata`), um POST pode:

- **forjar o protocolo** (`protocol=OUV-20260101-deadbeef`), inclusive de outro cidadão, se a
  unique permitir; ou provocar 500 ao colidir;
- **escolher o status inicial** (`status=3`) e já nascer “Indeferido”;
- **marcar `is_anonymous=true`** mantendo nome/e-mail (contradição sem regra que a impeça);
- **gravar `metadata` arbitrário** (jsonb) e `address` acima de 500 caracteres (erro de banco);
- **enviar `user_option`** achando que registrou consentimento — o campo não está no `fillable`
  e é silenciosamente descartado.

O `id` e o `ip` são sobrescritos no servidor, então esses dois não são forjáveis por esse
caminho.

---

## 5. Código duplicado

| Trecho | Onde aparece | Pode ser unificado? |
|---|---|---|
| Rótulos de status (1/2/3) | `index.blade.php:6-17`, `show.blade.php:67-72`, `updateStatus`/`answer` (números) | sim — enum/constante única |
| Regra de status = 2 ao responder | `OmbudsmanRequestCmsController.php:88` e importador (`STATUS_RESPONDIDA`) | sim — domínio |
| `active()->ordered()` em quatro dicionários | quatro models, quatro chamadas no portal | sim — trait proibida no V7; usar query/scope comum |
| Geração de `id` no `boot()` | cinco models (todas as classes) | sim — trait no V2; no V7 usar `HasUuids`/`IdGen` |
| Dicionários com schema idêntico | quatro migrations | sim — uma migration ou tabela de domínio |
| Validação de campos do portal em uma linha gigante | `OmbudsmanPortalController.php:31` | sim — Form Request |
| Formatação `d/m/Y H:i:s` | `show.blade.php:35`, `show.blade.php:54` | sim — componente |

---

## 6. Acoplamentos

### 6.1 Depende de

| Módulo | Tipo | Ponto do acoplamento | Risco se o outro mudar |
|---|---|---|---|
| `Core` (layouts) | views | `v2-cms::*`, `v2-portal::*` | quebra de layout/telas |
| `Core` (permissão) | contrato | `v2_can`, `PermissionService`, `active_pattern` | 403/redirect inesperado |
| `Core` (auditoria) | serviço | `V2AuditLogger::activity`, `CmsActionLogger::info` | perda de trilha |
| `Institucional/BasicPage` | rota | `route('v2.portal.lgpd')` na view | erro 500 ao renderizar o portal se a rota sumir |
| `Support/MigracaoLegada` | escrita | `OuvidoriaLegacyImporter` | importação quebrada/dados duplicados |
| Legado V1 | rotas e model | `routes/web.php:58-59` e `app/Http/Model/Prefecture/OmbudsmanRequest.php` | colisão de URL; `dd()` no store V1 |

### 6.2 Usado por

| Módulo | Tipo | Ponto do acoplamento |
|---|---|---|
| `Support/MigracaoLegada` | leitura/escrita | importador e painel de migração contam `v2_ombudsman_requests` |
| `Institucional/BasicPage` | link | página “LGPD” referenciada pelo formulário |
| `Rbac` (testes) | rota | matriz E2E cita `v2.ouvidoria.requests.index` |
| Nenhum outro módulo | — | não há API, widget, busca global nem home consumindo ouvidoria |

---

## 7. Divergência entre versões (V1 → V2)

| Aspecto | V1 | V2 | Causa provável |
|---|---|---|---|
| Protocolo | `OUV-` + `dmY-His` (`app/Http/Controllers/Portal/OmbudsmanController.php:137`) | `OUV-` + `Ymd` + 8 do UUID (`OmbudsmanRequest.php:87`) | reduzir colisão por segundo e não expor horário |
| Campos obrigatórios | tipo, assunto, forma, origem, nome, e-mail, telefone | só `description` | simplificação/abandono da validação |
| Anônimo | checkbox → `anonymous` | coluna sem campo na UI | funcionalidade não portada |
| Status | 1–4 (inclui Prorrogado) | 1–3 | Prorrogação não portada |
| Resposta | `type=1` → 2; `type=0` → 3 (`app/Http/Controllers/Prefecture/OmbudsmanAnswerController.php:62-68`) | sempre → 2 | regra de desfecho perdida |
| Anexos | `archives_id` + upload na resposta | `file_url` sem uso no módulo (importador grava) | upload não portado |
| Respostas prontas | CRUD `ombudsman_canned_responses` | não existe | funcionalidade não portada |
| E-mail ao cidadão e ao setor | `Mail::send` nos dois sentidos (`app/Http/Controllers/Portal/OmbudsmanController.php:166-210`) | nenhum | não portado |
| CRUD de dicionários | resources no CMS (`routes/basepro.php:159-165`) | só seed | não portado |
| Consentimento (`user_option`) | persistido em coluna | checkbox sem validação/coluna | não portado |
| Endereço | número, bairro, complemento, referência | um campo `address` | simplificação |
| IP | cru | cru (importador usa hash) | dívida mantida |
| Listagem | DataTables + recursos | tabela server-side própria | reescrita |
| Acompanhamento por protocolo | não documentado na V1 | não existe | nunca implementado |

O V2 **não é uma migração funcional completa**: é um MVP de captura/consulta. A V1 tinha mais
fluxo (e-mail, anexo, desfecho, dicionários editáveis), mas também continha defeitos graves
(`dd($request->all())` no `store`, `dd($PDOException)` no tratamento de erro da resposta).

---

## 8. Riscos de portabilidade

| Risco | Probabilidade | Impacto | Mitigação no V7 |
|---|---|---|---|
| Herdar o `$r->all() + create()` | alta se copiado | forja de status/protocolo/PII | DTO + `validated()` + `fillable` mínimo |
| Herdar o `orWhere` solto | alta | busca errada e vazamento de excluídos | agrupar condições + teste de composição |
| Status inteiro mágico | alta | estados inválidos e relatórios errados | enum `StatusSolicitacao` + check no banco |
| Consentimento decorativo | alta | não conformidade LGPD | `consentimento` jsonb obrigatório e versionado |
| IP cru | alta | dado pessoal desnecessário | `ip_hash` (sha256) e mascaramento |
| Protocolo no `boot()` | média | colisão/erro em lote e testes frágeis | Action com `IdGen` + retry |
| Sem prazos/SLA | certa | ouvidoria sem controle de tempo | `prazo_resposta_em` + alerta/telemetria |
| Sem acompanhamento | certa | cidadão sem retorno | rota pública por protocolo (sem PII) |
| Sem e-mail | certa | cidadão não sabe da resposta | job enfileirado atrás de porta |
| Dicionário sem CRUD | certa | operador depende de deploy/seed | decidir: dado editável no CMS ou código |
| Formatos de protocolo mistos (V1 importada) | certa | consulta/ordenação inconsistente | manter como está e normalizar só na exibição |
| Migração de status V1 colapsa Indeferido em 2 | certa | perda de distinção | corrigir mapa na importação V2→V7 |

---

## 9. Decisões que o V7 precisa tomar

| Decisão | Opções | Recomendação | Quem decide |
|---|---|---|---|
| Desfecho da resposta | (a) resposta sempre “Atendido”; (b) resposta escolhe 2 ou 3; (c) resposta e parecer separados | (b) — enum + transação | negócio/ouvidoria |
| Status | (a) manter 1/2/3; (b) enum aberta/respondida/indeferida/arquivada | (b) | produto |
| Consentimento | (a) obrigatório; (b) dispensado para manifestação (exercício de direito); (c) obrigatório só para retorno por e-mail | (c) + registro versionado | DPO/ADR-019 |
| Retenção | (a) indefinida; (b) 5 anos com anonimização; (c) outro prazo | (b) com `RetentionTarget` | DPO/jurídico |
| Acompanhamento | (a) protocolo sozinho; (b) protocolo + e-mail/telefone; (c) login cidadão | (a) com rate limit e sem PII | produto/segurança |
| Dicionários | (a) tabelas editáveis no CMS; (b) constantes no código | (a) para tipo/assunto; (b) para status | produto |
| Prazos | (a) por tipo; (b) global; (c) sem prazo | (a), sem confundir com LAI (e-SIC) | ouvidoria |
| E-mail | (a) só confirmação; (b) confirmação + resposta; (c) nenhum | (b), assíncrono | produto |
| Anonimato | (a) checkbox explícito; (b) derivado de campos vazios | (a) + invariante de PII nula | produto/LGPD |
