# Ouvidoria — Migração para Laravel Moderno

> **Slug:** `Ouvidoria` · **Alvo:** PHP 8.4 + Laravel 12 (V7)
>
> Plano suficiente para escrever a migration, os modelos, os requests, as actions e as rotas
> novas **sem abrir o legado**. Todo trecho marcado como ilustrativo é proposta, não código
> existente.

---

## 1. Equivalência funcional (o que NÃO pode mudar)

| Comportamento legado | Equivalente na V7 | Risco de divergência |
|---|---|---|
| Portal público em `/ouvidoria` com formulário de manifestação | mesma URI e formulário | quebra de links e SEO |
| Protocolo no formato `OUV-{Ymd}-{8}` | mesmo formato; 8 caracteres alfanuméricos maiúsculos ou hex | cidadão e V1 usam o protocolo como referência |
| Protocolo único e exibido ao cidadão após o envio | constraint UNIQUE + mensagem de sucesso com o protocolo | protocolo repetido/erro 500 |
| Dicionários: tipo, assunto, origem, forma de resposta | mesmas quatro dimensões, agora com `codigo` estável | relatórios e importação |
| Apenas dicionários ativos aparecem no portal | filtro `ativo = true` e `ordem` | item desativado voltar a aparecer |
| Manifestação sem dados pessoais é permitida | campos pessoais `nullable`; `anonimo` explícito | obrigar dados que antes eram opcionais |
| `status` começa em “aberta” | enum `StatusSolicitacao::Aberta` | fila de trabalho |
| CMS lista, busca por protocolo/nome/e-mail, ordena e pagina | mesmos filtros + status/período | operação |
| Operador altera status e registra resposta | mesmas ações, com desfecho explícito | resposta forçando “atendida” |
| Permissão de leitura e escrita separadas | `ouvidoria.ver` / `ouvidoria.editar` (ADR-033) | 403 inesperado |
| Importação V1 idempotente por `v1_id` | manter a chave e a coluna | duplicação na reimportação |

**O que pode mudar com decisão explícita:** o formato visual dos badges, a paginação, o texto
das mensagens, o layout das telas e a existência de e-mail/acompanhamento (que o V2 não tem).

---

## 2. Schema de migração

Convenções do V7: schema dedicado por módulo, `uuidv7()` como default, `timestamptz`,
`softDeletesTz`, `jsonb` para conteúdo estruturado, `text` para texto (ADR-007/ADR-017), nada de
HTML no banco. O template de migration é o de
`src/modules/Publicacoes/Noticias/Database/Migrations/2026_10_07_120000_create_noticias_table.php`.

```php
// exemplo ilustrativo
return new class extends Migration
{
    private const string SCHEMA = 'ouvidoria';

    public function up(): void
    {
        DB::statement("SET lock_timeout = '3s';");
        DB::statement('CREATE SCHEMA IF NOT EXISTS '.self::SCHEMA.';');
        DB::statement('SET search_path TO '.self::SCHEMA.', public;');

        if (Schema::hasTable('solicitacoes')) {
            DB::statement('RESET search_path;');

            return;
        }

        // Dicionários: código estável + nome por locale (ADR-012).
        foreach (['tipos', 'assuntos', 'origens', 'formas_resposta'] as $tabela) {
            if (Schema::hasTable($tabela)) {
                continue;
            }

            Schema::create($tabela, function (Blueprint $table): void {
                $id = $table->uuid('id')->primary();
                $id->default(new Expression('uuidv7()'));
                $table->text('codigo')->unique();
                $table->jsonb('nome');
                $table->boolean('ativo')->default(true);
                $table->unsignedSmallInteger('ordem')->default(0);
                $table->timestampsTz();
                $table->softDeletesTz();
            });
        }

        // `tipos` ganha o prazo administrativo (dias) — ADR-017 §7 (guardas de idempotência).
        if (! Schema::hasColumn('tipos', 'prazo_dias')) {
            Schema::table('tipos', function (Blueprint $table): void {
                $table->unsignedSmallInteger('prazo_dias')->default(20);
            });
        }

        Schema::create('solicitacoes', function (Blueprint $table): void {
            $id = $table->uuid('id')->primary();
            $id->default(new Expression('uuidv7()'));
            $table->text('protocolo')->unique();
            $table->uuid('tipo_id')->nullable();
            $table->uuid('assunto_id')->nullable();
            $table->uuid('origem_id')->nullable();
            $table->uuid('forma_resposta_id')->nullable();
            // PII cifrada pelo cast `encrypted` (ADR-019; mesmo padrão de `lgpd.dsar_requests`).
            $table->text('nome')->nullable();
            $table->text('email')->nullable();
            $table->text('telefone')->nullable();
            $table->text('endereco')->nullable();
            // Índice cego para igualdade de e-mail, sem expor o texto claro.
            $table->char('email_hash', 64)->nullable();
            $table->text('descricao'); // cifrada pelo cast
            $table->boolean('anonimo')->default(false);
            $table->jsonb('consentimento')->nullable();
            $table->text('status')->default('aberta');
            $table->char('ip_hash', 64)->nullable();
            $table->timestampTz('prazo_resposta_em')->nullable();
            $table->timestampTz('respondido_em')->nullable();
            $table->jsonb('metadata')->nullable();
            $table->timestampsTz();
            $table->softDeletesTz();
            $table->foreign('tipo_id')->references('id')->on('tipos')->nullOnDelete();
            $table->foreign('assunto_id')->references('id')->on('assuntos')->nullOnDelete();
            $table->foreign('origem_id')->references('id')->on('origens')->nullOnDelete();
            $table->foreign('forma_resposta_id')->references('id')->on('formas_resposta')->nullOnDelete();
        });

        DB::statement(
            "ALTER TABLE solicitacoes ADD CONSTRAINT solicitacoes_status_check
             CHECK (status IN ('aberta','respondida','indeferida','arquivada'))"
        );

        // A PII é cifrada (ADR-019), então não há busca textual sobre nome/e-mail/descrição:
        // o CMS filtra por protocolo (igualdade/prefixo), status, tipo e período. Full-text
        // exigiria coluna em claro — decisão explícita contra a minimização da ADR-019.
        DB::statement('CREATE INDEX solicitacoes_status_prazo_idx ON solicitacoes (status, prazo_resposta_em) WHERE deleted_at IS NULL;');
        DB::statement('CREATE INDEX solicitacoes_created_at_idx ON solicitacoes (created_at DESC) WHERE deleted_at IS NULL;');
        DB::statement('CREATE INDEX solicitacoes_email_hash_idx ON solicitacoes (email_hash) WHERE email_hash IS NOT NULL;');
        Schema::create('respostas', function (Blueprint $table): void {
            $id = $table->uuid('id')->primary();
            $id->default(new Expression('uuidv7()'));

            $table->uuid('solicitacao_id');
            $table->uuid('autor_id')->nullable();
            $table->jsonb('corpo');                 // AST TipTap (ADR-028), nunca HTML
            $table->text('desfecho');               // respondida | indeferida
            $table->jsonb('anexos')->nullable();    // payload do Services\Media (ADR-026)
            $table->timestampsTz();
            $table->softDeletesTz();
            $table->foreign('solicitacao_id')->references('id')->on('solicitacoes')->cascadeOnDelete();
            $table->foreign('autor_id')->references('id')->on('cms_users')->nullOnDelete();
        });

        DB::statement(
            "ALTER TABLE respostas ADD CONSTRAINT respostas_desfecho_check
             CHECK (desfecho IN ('respondida','indeferida'))"
        );
        DB::statement('CREATE INDEX respostas_solicitacao_idx ON respostas (solicitacao_id);');

        DB::statement('RESET search_path;');
    }
};
```

| Tabela legada | Tabela nova | Mudança | Justificativa |
|---|---|---|---|
| `v2_ombudsman_requests` | `ouvidoria.solicitacoes` | PK uuidv7, enum/check de status, consentimento jsonb, `ip_hash` | ADR-017/ADR-019 |
| `v2_ombudsman_answers` | `ouvidoria.respostas` | AST jsonb, autor, desfecho, anexos em media | ADR-028/ADR-026 |
| `v2_ombudsman_manifestation_types` | `ouvidoria.tipos` | `codigo` estável, nome multilíngue, prazo | ADR-012 |
| `v2_ombudsman_subjects` | `ouvidoria.assuntos` | `responsavel_email` real, sem campos fantasmas | bug do V2 |
| `v2_ombudsman_origins` | `ouvidoria.origens` | `codigo`, `nome` jsonb | ADR-012 |
| `v2_ombudsman_response_methods` | `ouvidoria.formas_resposta` | `codigo`, `nome` jsonb | ADR-012 |
| `v1_id`/`imported_at` | `metadata->'legado'` + chave de importação | ou colunas próprias `legado_id`/`importado_em` | ADR-017 §7 |

Dados de referência (os 14 itens do seeder V2) entram na **migration**, não em seeder, com
`codigo` fixo (`reclamacao`, `sugestao`, `elogio`, `denuncia`; `presencial`, `online`,
`telefone`, `email`; `email`, `telefone`, `carta`, `presencial`; `loteria`, `atendimento`) —
ADR-017 §10. Inserir com `DB::table('<tabela>')->insertOrIgnore([...])` e `nome` como
`{"pt_BR": "..."}`, para que a reexecução não duplique. A lista completa está em `02-MODELAGEM.md` §5.

---

## 3. Modelos

| Modelo legado | Modelo novo | Mudanças |
|---|---|---|
| `OmbudsmanRequest` | `Modules\Servicos\Ouvidoria\Database\Models\Solicitacao` | sem `boot()`; `HasUuids`; cast de enum; `$fillable` mínimo; escopo de busca agrupado |
| `OmbudsmanAnswer` | `Resposta` | corpo AST, autor, desfecho, anexos |
| `OmbudsmanManifestationType` | `Tipo` | `nome` via `LocaleCast`, `codigo`, `prazo_dias` |
| `OmbudsmanSubject` | `Assunto` | `responsavel_email` |
| `OmbudsmanOrigin` | `Origem` | `nome` via `LocaleCast` |
| `OmbudsmanResponseMethod` | `FormaResposta` | `nome` via `LocaleCast` |

**Enums sugeridos:**

| Legado | Enum | Casos |
|---|---|---|
| `1` | `StatusSolicitacao::Aberta` | `'aberta'` |
| `2` | `StatusSolicitacao::Respondida` | `'respondida'` |
| `3` | `StatusSolicitacao::Indeferida` | `'indeferida'` |
| (novo) | `StatusSolicitacao::Arquivada` | `'arquivada'` |
| resposta `type=1` | `DesfechoResposta::Respondida` | `'respondida'` |
| resposta `type=0` | `DesfechoResposta::Indeferida` | `'indeferida'` |

```php
// exemplo ilustrativo
enum StatusSolicitacao: string
{
    case Aberta = 'aberta';
    case Respondida = 'respondida';
    case Indeferida = 'indeferida';
    case Arquivada = 'arquivada';
}
```

O protocolo **não** é gerado no model. Uma action `GerarProtocolo` (ou um método do
`RegistrarManifestacao`) recebe `Clock` e `IdGen`, monta `OUV-{Ymd}-{8}` e tenta até 3 vezes em
caso de violação de unicidade.

Casts de PII (seguindo `lgpd.dsar_requests`): `nome`, `email`, `telefone`, `endereco` e
`descricao` com `'encrypted'`; `consentimento`/`metadata` com `'array'`; `status` com o enum;
`prazo_resposta_em`/`respondido_em` com `'datetime'`; `anonimo` com `'boolean'`. Como o texto
claro não existe no banco, a busca do CMS deixa de cobrir nome/e-mail (mudança intencional em
relação ao V2, que buscava `name` e `email`); para igualdade de e-mail existe `email_hash`.

Na importação, a resposta em texto puro da V2/V1 é convertida para o AST mínimo de um parágrafo
(`{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"..."}]}]}`),
para que `corpo` seja sempre jsonb e o renderizador seja único (ADR-028).

---

## 4. Form Requests

| Request legado | Request novo | Regras |
|---|---|---|
| validação inline do portal | `RegistrarManifestacaoRequest` | abaixo |
| `answer` inline | `ResponderManifestacaoRequest` | abaixo |
| — | `AlterarStatusRequest` | abaixo |
| — | `AcompanharRequest` | abaixo |

```php
// exemplo ilustrativo
public function rules(): array
{
    return [
        'tipo_id' => ['required', 'uuid', new ExisteAtivo('tipos')],
        'assunto_id' => ['nullable', 'uuid', new ExisteAtivo('assuntos')],
        'origem_id' => ['nullable', 'uuid', new ExisteAtivo('origens')],
        'forma_resposta_id' => ['nullable', 'uuid', new ExisteAtivo('formas_resposta')],

        'nome' => ['nullable', 'string', 'max:255', 'required_unless:anonimo,1'],
        'email' => ['nullable', 'email:rfc', 'max:255'],
        'telefone' => ['nullable', 'string', 'max:50'],
        'endereco' => ['nullable', 'string', 'max:500'],
        'anonimo' => ['sometimes', 'boolean'],

        'descricao' => ['required', 'string', 'min:20', 'max:10000'],
        'aceite_lgpd' => ['nullable', 'required_unless:anonimo,1', 'accepted'],
        'website' => ['prohibited'],          // honeypot (ADR-021)
        'tempo_preenchimento' => ['required', 'integer', 'min:3'], // time-trap em segundos
    ];
}
```

`anonimo` não é um checkbox apenas visual: a action **força** `nome`, `email`, `telefone` e
`endereco` para `null` quando `anonimo = true`, garantindo o invariante que o V2 não tinha.
`ExisteAtivo` consulta `DB::table('ouvidoria.'.$tabela)` (o ponto em `Rule::exists` seria lido
como conexão); exigir e-mail quando a forma é “e-mail” fica no `after()`/Action.

O `consentimento` gravado pela action tem a forma
`{"aceito_em":"2026-10-23T12:00:00Z","versao_aviso":"2026-10","finalidade":"retorno","base_legal":"consentimento"}` (ADR-019); a versão do aviso vem de configuração do módulo, nunca do cliente.

`ResponderManifestacaoRequest`: `corpo` `required|array` (AST), `desfecho`
`required|Rule::enum(DesfechoResposta::class)`, `anexos.*` `file|mimes:pdf,jpg,jpeg,png,webp|max:10240`.

`AlterarStatusRequest`: `status` `required|Rule::enum(StatusSolicitacao::class)`; as transições
permitidas ficam na action, não no request.

`AcompanharRequest`: `protocolo` `required|string|regex:/^OUV-\d{8}-[A-Z0-9]{8}$/` e
`email`/`telefone` `nullable` (a action decide se exige).

---

## 5. Autorização

O V7 não tem `PermissionService`: usa `Core\Access\Permission` + `Gate` (ADR-033).

| Recurso | Policy/Gate | Regras |
|---|---|---|
| Listar/ver manifestações e PII | `ouvidoria.ver` | nível `ver` |
| Alterar status, responder, gerenciar dicionários | `ouvidoria.editar` | nível `editar` |
| Arquivar/desarquivar | `ouvidoria.arquivar` | nível `arquivar` |
| Assinar/aprovar resposta final (se adotado) | `ouvidoria.aprovar` | nível `aprovar` |

Declaração no manifesto:

```php
// exemplo ilustrativo — ModuleProvider::config()
'permissions' => [Permission::VIEW, Permission::EDIT, Permission::ARCHIVE],
```

Enforcement no início de cada controller: `Permission::authorize('Ouvidoria', Permission::VIEW)`.
O portal público não exige permissão. A leitura de PII no CMS entra na trilha pelo middleware
global `audit.access` (ADR-035); a escrita gera `AuditEntry::event()` explícito.

---

## 6. Endpoints

| Legado | Novo | Tipo | Breaking? |
|---|---|---|---|
| `GET /ouvidoria` | `GET /ouvidoria` → `ouvidoria.portal.index` | portal | não |
| `POST /ouvidoria/store` | `POST /ouvidoria` → `ouvidoria.portal.store` | portal | **sim** (URI muda) |
| — | `GET /ouvidoria/acompanhar` → `ouvidoria.portal.acompanhar` | portal | novo |
| — | `POST /ouvidoria/acompanhar` → `ouvidoria.portal.acompanhar.consultar` | portal | novo |
| `GET /cms/v2/ouvidoria/requests` | `GET /cms/ouvidoria` → `ouvidoria.cms.index` | CMS | sim (prefixo/área) |
| `GET /cms/v2/ouvidoria/requests/{id}` | `GET /cms/ouvidoria/{solicitacao}` → `ouvidoria.cms.show` | CMS | sim |
| `POST .../{id}/status` | `POST /cms/ouvidoria/{solicitacao}/status` → `ouvidoria.cms.status` | CMS | sim |
| `POST .../{id}/answer` | `POST /cms/ouvidoria/{solicitacao}/resposta` → `ouvidoria.cms.responder` | CMS | sim |
| — | `GET /cms/ouvidoria/dicionarios` → `ouvidoria.cms.dicionarios.*` | CMS | novo |

Se for necessário manter URLs antigas durante a transição, criar redirects 301 no `Services\Seo`
(ADR-027 §6) — não duplicar controllers.

---

## 7. API Resources

Não são necessários (V2 sem API; CMS server-rendered). Se o V7 expuser indicadores, criar
`SolicitacaoResource`/`IndicadoresResource` **sem PII** (`protocolo`, `status`, `tipo`, datas) — nunca nome, e-mail, telefone, IP ou texto integral.

---

## 8. Views

| Blade legado | Blade novo / componente | Observação |
|---|---|---|
| `Views/portal/index.blade.php` | `Layout/Views/portal/index.blade.php` com `<x-layout::portal>` | remover `style=` inline (ADR-022) |
| `Views/cms/requests/index.blade.php` | `Layout/Views/cms/index.blade.php` com `<x-cms::table>`, `<x-cms::filter-card>`, `<x-cms::status-badge>` | sem HTML em PHP |
| `Views/cms/requests/show.blade.php` | `Layout/Views/cms/show.blade.php` com `<x-cms::card>`, `<x-cms::form-group>`, `<x-cms::button>` | incluir histórico e consentimento |
| — | `Layout/Views/portal/acompanhar.blade.php` | consulta por protocolo |
| — | `Lang/{pt_BR,en,es,fr}/*.php` | ADR-012 |

As páginas de formulário devem usar `cache.private` (ADR-022) e nenhum `script`/`style` inline.
A view do portal usa `<x-ui::icon name="...">` em vez de classes `bi-*` de CDN.

---

## 9. Fila, cache e storage

| Necessidade | Solução na V7 | Configuração |
|---|---|---|
| Rate limit do formulário | `RateLimiter::for('ouvidoria', fn ($r) => Limit::perMinute(10)->by($r->ip().'|'.$r->session()->getId()))` | Valkey (ADR-021) |
| Consulta por protocolo | limiter `ouvidoria-consulta` 5/min por IP | Valkey |
| Respostas e confirmações | `Job` enfileirado + `Mail` (sem serviço Notifications pronto) | fila `default`; `MAIL_MAILER` do ambiente |
| Anexos de resposta | `Services\Media` (AVIF/WebP, ADR-026) + `Services\Gallery` (ADR-030) | disco S3/RustFS |
| Busca | protocolo (unique) + filtros indexados; sem full-text sobre PII cifrada | PostgreSQL 18 |
| Cache de leitura pública | `PublicResponseCache` do layout (ADR-022) para o GET do formulário | ETag; página com formulário é `private` |

---

## 10. Jobs e eventos

| Job/Event | Quando dispara | Efeito |
|---|---|---|
| `EnviarConfirmacaoManifestacao` | após `RegistrarManifestacao` | e-mail com protocolo (se o cidadão informou e-mail) |
| `EnviarRespostaManifestacao` | após `ResponderManifestacao` | e-mail com o texto e o desfecho |
| `AvisarPrazoVencendo` | diário (Scheduler) | alerta ao setor para manifestações `aberta` com `prazo_resposta_em` próximo |
| `AnonimizarManifestacoesVencidas` | `retention:expunge` (Core) | anonimiza PII preservando protocolo e estatística |

Nenhum desses jobs existe no V2; são a resposta aos gaps de e-mail/SLA/retenção. O envio de
e-mail falha de forma *fail-open* (não desfaz o registro) e é logado com `pii_redact()`.

---

## 11. Front-end

| Abordagem legada | Abordagem nova | Justificativa |
|---|---|---|
| Formulário HTML clássico, sem JS | Blade + HTML semântico; JS mínimo apenas para time-trap/contador | ADR-010 |
| Badges montados em PHP | `<x-cms::status-badge>` | escape estrutural |
| Bootstrap Icons (`bi-*`) | `<x-ui::icon name="...">` (Lucide local) | zero CDN (ADR-022) |
| Sem acessibilidade programática | labels, `aria-describedby`, `aria-sort`, foco no erro | ADR-025 |
| Texto fixo em pt-BR | `__('ouvidoria::...')` | ADR-012 |
| Sem SEO | `SeoBinder::bindData()` na página e `@graph` (ADR-027) | ADR-027 |

---

## 12. Ordem de implementação

| # | Passo | Depende de | Esforço |
|---|---|---|---|
| 1 | Migration `ouvidoria` + models + enums | — | médio |
| 2 | Dicionários de referência na migration + CRUD no CMS | 1 | médio |
| 3 | `RegistrarManifestacao` (protocolo, consentimento, IP hash) + request + rota | 1 | alto |
| 4 | Views do portal (formulário + acompanhamento) | 3 | médio |
| 5 | `Permission` no manifesto + controllers do CMS (lista/detalhe) | 1 | médio |
| 6 | `ResponderManifestacao` (transação, desfecho, auditoria) | 1, 5 | alto |
| 7 | Jobs de e-mail + alerta de prazo | 3, 6 | médio |
| 8 | `RetentionTarget` + `retention:expunge` | 1 | médio |
| 9 | Redirects das URLs antigas + testes de equivalência | 5 | baixo |
| 10 | Importação V1→V7 (reuso do mapa do `OuvidoriaLegacyImporter`) | 1 | médio |

---

## 13. Riscos e decisões de equivalência

| Risco | Mitigação |
|---|---|
| Perder o protocolo original na importação | importar `protocolo` como texto e manter `legado_id` no `metadata` |
| Colapsar “Indeferido” em “Atendido” como o V2 fez | mapear o desfecho V1 para `DesfechoResposta::Indeferida` |
| Mudar a regra de anonimato | documentar o invariante (PII nula quando `anonimo`) e testá-lo |
| Expor PII em acompanhamento público | resposta pública só com status, datas e texto da resposta |
| Duplicar o e-SIC | Ouvidoria **não** implementa prazos legais da LAI, recursos, 1ª/2ª instância — isso é o módulo e-SIC (`wiki/backlog/modulo-esic.md`) |
| Criar serviço de notificação prematuro | usar `Mail` + job atrás de uma porta pequena do módulo; extrair para `services/Notifications` só quando houver um segundo consumidor |
