# Ouvidoria — Regras e Portabilidade

## 1. Regras de negócio não óbvias

### 1.1 Ciclo de status

```
                     ┌──────────────────────────────┐
   criação ─────────►│ 1 = Em Tramitação             │
                     └───────────┬──────────────────┘
                                 │
              ┌──────────────────┼──────────────────┐
              │                  │                  │
              ▼                  ▼                  ▼
   ┌──────────────────┐  ┌────────────────┐  ┌──────────────────┐
   │ 2 = Atendido     │  │ 3 = Indeferido │  │ 4 = Prorrogado   │
   │ (resposta)       │  │ (indeferimento)│  │ (prorrogação)    │
   └──────────────────┘  └────────────────┘  └──────────────────┘
```

- O status **nunca** volta para 1 automaticamente. A única transição de/para tramitação é o encaminhamento (`enviar_chamado`), que **não** altera o status — apenas troca o assunto.
- Os status 5 (Aberta), 6 (Finalizada) e 7 (Cancelada) existem na tabela `statuses` mas **não são usados** pelo módulo.
- `prorrogar_chamado` grava o log de auditoria com o texto "Chamado da ouvidoria respondido" (embora a ação seja prorrogação) — descrição enganosa na trilha.

### 1.2 Regras do prazo (20 dias hardcoded)

O relatório estatístico classifica cada manifestação em "no prazo" ou "após o prazo" usando um literal **20** no SQL:

```php
->whereRaw('EXTRACT(DAY FROM now() - '.$values['fieldExtract'].') '.$values['comparation'].' 20')
```

- Para status 1 (Em Tramitação) e 4 (Prorrogado), a comparação é `now() - created_at` (ou `updated_at` para o status 4, conforme `checkParameters`).
- Para status 2 (Atendido) e 3 (Indeferido), a comparação é `ombudsman_answers.created_at - ombudsman_requests.created_at` (via `getByBetweenDateWithAnswer`).
- O valor 20 **não** vem de `configurations.response_time` (que vale 20 no banco, mas é usado apenas para exibir o prazo ao cidadão). **Duas fontes de verdade para o mesmo conceito.**

### 1.3 `checkParameters` — o campo de data muda com o status

```php
if ($paramStatus == 1 || $paramStatus == 2 || $paramStatus == 3) {
    $values['fieldExtract'] = 'created_at';
    $values['fieldBetween'] = 'created_at';
} else {
    $values['fieldExtract'] = 'updated_at';   // status 4 (prorrogado)
    $values['fieldBetween'] = 'created_at';
}
```

Consequência: o status 4 (Prorrogado) é medido pela **última atualização** do chamado, não pela data da prorrogação. Um chamado prorrogado e depois editado tem a prazo recalculado.

### 1.4 Modalidades

| Modalidade | Comportamento |
|---|---|
| `identificado` | Nome, e-mail e telefone são enviados e exibidos (mascarados apenas no relatório público) |
| `sigiloso` | É a modalidade do WhatsApp: o nome é mascarado, mas o contato permanece. No relatório público, `masked_name` usa `maskName($name, $modality)` |
| `anonimo` | O JS do portal **desabilita** os campos de identificação; `anonymous` é marcado. `masked_name` devolve literalmente `'Anônimo'` |

> **Bug verificado no controller:** `if ($ombudsman->modality == 'Anônimo') { $ombudsman->anonymous = true; }` — compara com `'Anônimo'` (com acento, maiúsculo), enquanto o array de modalidades usa a chave `'anonimo'` (sem acento, minúsculo). A comparação **nunca** é verdadeira. O flag `anonymous` depende exclusivamente de `$request->has('anonymous')`. Ver `08-GAPS-E-DIVIDAS.md`.

### 1.5 Contestação

- O cidadão digita um `token` livre no formulário (exigido pela validação: `token` `required|string`).
- O controller grava `Hash::make($request->token)` em `ombudsman_requests.token`.
- `newMessage` valida com `Hash::check($request->contestation_token, $ombudsmanRequest->token)` → 403 "Token inválido." se não bater.
- São aceitas **duas** contestações (`answer_contestation` e `answer_contestation_2`). A terceira sobrescreve a segunda (o `if/else` sempre escreve no primeiro slot vazio — na prática, a 3ª é gravada em `answer_contestation` se a 1ª estiver vazia, ou em `answer_contestation_2` se a 2ª estiver vazia).

### 1.6 Mascaramento (LGPD)

`v6/app/Http/Traits/DataMaskTrait.php` — `maskTextDeterministic()` aplica, em sequência:

| Padrão | Resultado |
|---|---|
| CPF `000.***.***-00` | `000.\***.\***-00` |
| CNPJ `**.***.***/****-**` | `**.\***.\***/****-**` |
| RG | `.\***.\***-*` |
| Telefone `(00) 00000-0000` | dígitos viram `*` |
| CEP | dígitos viram `*` |
| E-mail | `*******@dominio.com` (domínio preservado) |
| Credencial `usuario.sistema#0000` | tudo vira `*` |
| CID-10 rotulado | apenas o código vira `*****` |
| Dicionário de dados sensíveis | CPF, CNPJ, RG, CNH, passaporte, PIS/PASEP, título de eleitor, data de nascimento, nome próprio, telefone, e-mail, e categorias sensíveis (saúde, religião, orientação sexual, política) e dados financeiros |

`maskProtocol($protocol)` e `maskName($name, $modality)` são usados pelo relatório público.

> **Ponto verificado:** o `maskTextWithAi()` (usado para gravar `mask_description` e `mask_answer`) é uma **chamada a API externa** — o nome sugere IA. [NÃO VERIFICADO] em runtime: a dependência externa, seu custo, seu tempo de resposta e o que acontece quando a API está indisponível (o `store` está dentro de uma transação; uma exceção faria rollback do chamado).

### 1.7 Exclusão de setor com assuntos vinculados

`OmbudsmanSectorController@destroy` bloqueia a exclusão se houver assuntos **não excluídos** vinculados:

```php
if ($sector->subjects()->whereNull('deleted_at')->exists()) {
    flash('Não é possível excluir: existem assuntos vinculados a este setor/órgão.')->error();
    return redirect()->route('setores-orgaos.index');
}
```

O `deleteAll` faz o mesmo em lote, devolvendo HTTP 422 com a lista dos setores bloqueados.

### 1.8 Encaminhamento (`enviar_chamado`)

1. Localiza o chamado por `ombudsman_request_id`.
2. Lê `$old_sector_id = $ombudsmanRequest->subject->sector_id` e `$old_subject_id`.
3. Busca `OmbudsmanSubject::where('title', $request->title_subject)->first()` — **por título**, o que é frágil (títulos duplicados → pega o primeiro).
4. Se encontrar, troca o `subject_id` do chamado.
5. Grava `OmbudsmanRequestLogs` com `type = 'forward'`, `sector_from_id` = setor antigo, `sector_to_id` = setor novo, `subject_from_id` / `subject_to_id` e a observação "Alteração de assunto e setor no chamado da Ouvidoria."
6. Envia o e-mail `prefecture.emails.ombudsman-send-requests.email_admin` para `$request->new_email`.

> **Fragilidade verificada:** o passo 5 grava `sector_from_id`/`sector_to_id` com ids de `ombudsman_sectors`, mas as FKs apontam para `contents(id)`. Como o `OmbudsmanSectorSeeder` **preserva os ids** das secretarias ao popular `ombudsman_sectors`, os valores gravados **coincidem** com ids de `contents` — funcionando por coincidência do seed. Se um setor for criado manualmente com id diferente, a FK quebra.

### 1.9 Anexo da resposta

O controller cria um registro em `archives` por arquivo, grava o arquivo em `answer/{answer_id}/` e chama `$answer->archives()->attach($insertArchive->id)` — relação **comentada** no model, e tabela pivô `ombudsman_answer_archives` **inexistente** na v6. Ver §4.1.

## 2. Acoplamentos

### 2.1 Dependências do módulo Ouvidoria

| Depende de | Tipo | Observação |
|---|---|---|
| `statuses` | **forte e compartilhada** | Tabela usada por outros módulos; os ids 1-4 têm significado **hardcoded** em todo o código do módulo (`$status = 2;` para atendido, `4` para prorrogado, etc.) |
| `contents` (secretarias) | forte | `ombudsman_subjects.secretary_id`; `ombudsman_sectors.content_id`; `ombudsman_request_logs.sector_from_id`/`sector_to_id` |
| `static_pages` | forte | Slugs `ouvidoria` (ou `contato` como fallback) e `relatorio-estatistico-ouvidoria` |
| `archives` | forte | Anexos das respostas e arquivos das páginas estáticas |
| `configurations` | forte | `email`, `titulo_site`, `response_time`, `full_list_requests_disabled`, `layout` |
| `users` | fraca | `ombudsman_request_logs.user_id` |
| `categories` / `contents` (portal) | fraca | `NewsTrait::getContentNews()` na página da Ouvidoria |
| `ActivityLog` (spatie) | fraca | Auditoria |

### 2.2 Dependências de outros módulos

| Módulo | Depende da Ouvidoria em | Ponto |
|---|---|---|
| **Dashboard** | `v6/app/Services/Dashboard/Widgets/OuvidoriaWidget.php` | Widget de indicadores no painel do CMS |
| **Transparência Fiscal** | `relatorio-estatistico-ouvidoria` fica no diretório `transparencia-fiscal/` do portal | Mistura de domínios na estrutura de pastas |
| **e-SIC / Informações** | `OmbudsmanRequest` e `OmbudsmanAnswer` compartilham o padrão de `mask_*` | `DataMaskTrait` é compartilhado entre os dois módulos |
| **Menu/Acesso rápido** | Página "Consultar Solicitação de Ouvidoria" | Template em `informacoes-instituicao/acesso-rapido/` |
| **Páginas estáticas** | Slugs `ouvidoria`, `contato`, `relatorio-estatistico-ouvidoria` | Conteúdo institucional |

## 3. Onde a v6 divergiu da v5

Detalhado em `09-MAPA-V5-V6.md`. Resumo:

| Item | v5 | v6 |
|---|---|---|
| `ombudsman_sectors` + `OmbudsmanSectorController` + `OmbudsmanSectorRequest` | **ausente** | **presente** |
| `ombudsman_request_logs` + `OmbudsmanRequestLogs` | **ausente** | **presente** |
| `ombudsman_answer_archives` (pivô) + método `archives()` ativo | **presente** | **ausente** (método comentado) |
| Campos `cpf_cnpj`, `gender`, `education_level`, `birth_date`, `attachment` | **presentes** | **ausentes** |
| `mask_description` / `mask_answer` | migration + seeds | migration + gravação ao vivo |
| `modality` | migration `2026_05_25` | migration `2026_02_04` (mesma coluna, datas diferentes entre as versões) |
| **Relatório Estatístico de Ouvidoria (CMS)** | **presente** | **ausente — REGRESSÃO** |
| Exportação do relatório | rota dedicada `/relatorio-estatistico-ouvidoria/exportacao` | integrada à mesma rota via `?type=` |
| `OmbudsmanRequest` com SoftDeletes | não | não (em ambas) |

> **Observação sobre datas:** a migration `2026_05_25_000000_add_new_fields_to_ombudsman_requests_table.php` (v5) adiciona `modality` **e** os campos demográficos; a migration `2026_02_04_154432_update_ombudsman_requests_table.php` (v6) adiciona `modality` **e** `token`/contestações. As duas versões divergem no conjunto de campos — a migração de dados entre elas **não é trivial**.

## 4. Armadilhas

### 4.1 `archives()` comentado + tabela pivô inexistente

`OmbudsmanAnswerController@store` executa:

```php
$insertArchive = Archive::create($dataArchive);
$insertArchive->path = url("storage/answer/$answer->id/$name");
$insertArchive->save();
$upload = $archive[0]->storeAs("answer/$answer->id/", $name);
if ($upload) {
    $answer->archives()->attach($insertArchive->id);
}
```

O método `OmbudsmanAnswer::archives()` está **comentado** no model (`v6/app/Http/Model/Prefecture/OmbudsmanAnswer.php`, linhas do bloco `belongsToMany`). A tabela `ombudsman_answer_archives` existe na v5 mas **não** na v6.

**Efeito:** ao responder um chamado **com anexo**, o `attach()` falha → `BadMethodCallException` (método inexistente) ou `QueryException` (tabela inexistente) → capturada pelo `catch (\Exception $e)` do controller → `dd($e)` (dump + die) na tela. **O operador vê um dump de exceção e a resposta não é salva** (a transação faz rollback).

> **Divergência v5 → v6 verificada:** na v5 o método `archives()` está **ativo** e a migration da tabela pivô existe — portanto **anexos em respostas funcionavam na v5 e foram quebrados na v6**.

### 4.2 `OmbudsmanRequestController@update` altera sempre o primeiro registro

```php
public function update(Request $request)
{
    $ombudsman = OmbudsmanRequest::first();
    $ombudsman->update($request->all());
    return response()->json(['message': 'Solicitação atualizada com sucesso!']);
}
```

Não há filtro por `id`. Qualquer chamada a `PUT /cms/chamados-da-ouvidoria/{id}` (rota gerada pelo `Route::resource`) **atualiza a primeira manifestação da tabela**, com **todos** os campos do request (`$request->all()`), sem validação.

**Efeito:** perda silenciosa de dados. Além disso, `$request->all()` pode sobrescrever `protocol`, `status_id`, `ip`, `token`, etc.

### 4.3 `dd($e)` em produção

`OmbudsmanAnswerController@store` e `OmbudsmanRequestController@prorrogar_chamado` chamam `dd($e)` nos blocos `catch`. Isso **interrompe a execução** com um dump completo da exceção (rastreamento de pilha, caminho de arquivos, dados de conexão) em vez de exibir a mensagem amigável que o próprio código preparou logo abaixo.

### 4.4 Relations quebradas

| Model | Relação | Classe importada | Existe? |
|---|---|---|---|
| `OmbudsmanOrigin` | `ombudsmans()` | `App\Ombudsman` | **Não** |
| `OmbudsmanResponseMethod` | `ombudsmans()` | `App\Ombudsman` | **Não** |
| `OmbudsmanSubject` | `ombudsmans()` | `App\OmbudsmanRequest` | **Não** (a real é `App\Models\OmbudsmanRequest`) |
| `OmbudsmanManifestationType` | `ombudsmans()` | `App\OmbudsmanRequest` | **Não** |
| `OmbudsmanRequestLogs` | `sectorFrom()`, `sectorTo()` | `OmbudsmanSector` | Existe, mas a FK real é `contents` |

Verificado por ausência em `v6/vendor/composer/autoload_classmap.php` e por `grep` em `v6/app/`.

### 4.5 `OmbudsmanOrigin` com `description` no `$fillable` sem coluna

`protected $fillable = ['title', 'description'];` — a tabela `ombudsman_origins` **não tem** a coluna `description` (verificado no `\d`). `OmbudsmanOrigin::create($request->validated())` recebe apenas `title` (o Form Request só valida `title`), então não quebra hoje — mas qualquer uso futuro de `description` causará `QueryException`.

### 4.6 `OmbudsmanRequest` sem `SoftDeletes` mas com coluna `deleted_at`

A tabela tem `deleted_at` e as views SQL filtram `r.deleted_at IS NULL`. Mas o model **não** usa `SoftDeletes` → `OmbudsmanRequest::all()` inclui os registros "excluídos", e os escopos do model (`getByBetweenDate`, `byDateBetweenMain`) **não** filtram `deleted_at`. O relatório estatístico pode contar manifestações "excluídas".

O mesmo vale para `OmbudsmanOrigin`, `OmbudsmanResponseMethod`, `OmbudsmanManifestationType` e `OmbudsmanCannedResponse` — todos com `deleted_at` na tabela e sem `SoftDeletes` no model.

### 4.7 `Route::any` nas rotas do portal

`/ouvidoria` e `/ouvidoria/store` são `Route::any`. Um `GET /ouvidoria/store` criaria uma manifestação (basta enviar os campos por query string). Recomenda-se `GET` para a página e `POST` para o envio.

### 4.8 Captcha não validado

A regra `'g-recaptcha-response' => 'required|recaptcha'` está **comentada** no `validated()` do controller, embora o formulário renderize `{!! Recaptcha::render() !!}`. O captcha é exibido ao cidadão mas **não é verificado** — proteção apenas visual.

### 4.9 Exposição de dados pessoais em endpoints do CMS

`dados_do_chamado` e `getOmbudsmanByProtocol` devolvem o model **completo**: `name`, `email`, `phone`, `address`, `description`, `ip`, `browser` e `token` (hash). O endpoint público `getOmbudsmanByProtocol` **é público** (`/cms/ajax/…` — mas fica sob o prefixo `/cms`; ainda assim, a rota `ajax.*` é tratada como "aberta" na checagem de acesso do `check.route.permission`, exigindo apenas login).

A página "Consultar Solicitação" usa esse endpoint e exibe `description` e `name` **em claro** para qualquer pessoa que tenha o protocolo.

### 4.10 Colisão de protocolo

`'OUV-' . now()->format('dmY-His')` — granularidade de **segundo**. Dois chamados no mesmo segundo colidem na constraint UNIQUE → `PDOException` não capturada no `store` do portal (o `store` não tem try/catch) → **erro 500**.

### 4.11 Validação de `modality` inconsistente

O controller valida `'modality' => 'required|integer|not_in:0'`, mas o formulário envia a **string** `'identificado'`, `'sigiloso'` ou `'anonimo'`. A regra `integer` reprovaria. [INFERÊNCIA] há divergência entre o formulário implantado e o controller — **exige teste em ambiente real**.

### 4.12 `OmbudsmanPortalRequest` com `authorize()` = `false`

Classe **não utilizada**, mas presente no repositório. Se alguém a plugar no controller do portal, **todas** as submissões serão recusadas (403). Suas regras também referenciam a tabela inexistente `ombudsman_types`.

### 4.13 `EXTRACT(DAY FROM …)` sobre intervalo — semântica correta, mas com ressalvas

`EXTRACT(DAY FROM now() - created_at)` opera sobre um **intervalo** (`now() - timestamp` devolve `interval`), e em PostgreSQL `EXTRACT(DAY FROM interval)` retorna o **total de dias** do intervalo (não o dia do mês). Verificado no banco:

```
now() - '2026-01-01 10:00'::timestamp  →  293 days 02:40:22.931371
EXTRACT(DAY FROM ...)                   →  293
```

Portanto a classificação "no prazo / após o prazo" está **matematicamente correta** no que diz respeito à contagem de dias.

Ressalvas que permanecem:

1. **A fração de hora é ignorada.** `EXTRACT(DAY ...)` trunca: um chamado com 20 dias e 23 horas conta como 20 dias (dentro do prazo), enquanto 20 dias e 1 minuto também conta como 20. O corte é no dia inteiro.
2. **O limite 20 é hardcoded** (§1.2) e não vem de `configurations.response_time`, que é a fonte usada para informar o prazo ao cidadão — duas fontes de verdade.
3. **O status 4 (prorrogado) é medido por `updated_at`** (§1.3), que muda a cada edição do chamado.
4. **Registros com `deleted_at` preenchido entram na contagem** (§4.6), pois os escopos do model não filtram `deleted_at`.

### 4.13.1 `getByBetweenDateWithAnswer` — contagem sobre tabela agregada

O escopo faz `->groupBy('status_id')` e encadeia `->count()`. O `groupBy` não altera a contagem final, mas torna a query ambígua caso outros campos forem adicionados ao `select`. Não há impacto observável hoje (o controller só usa `->count()`).

### 4.14 Consultas dentro de views

`v6/resources/views/prefecture/ombudsman/sector/index.blade.php` e outros índices chamam `has_access()` (que consulta o Sentinel) — aceitável. Mas a listagem de chamados executa `DB::table("chamados_ouvidoria")->get()` **inteira** e monta o DataTable em memória (`DatatableController@get_chamados_ouvidoria`).

### 4.15 `sector_from_id` gravado com id de setor

Detalhado em §1.8: funciona **por coincidência** do seed que preserva ids.

### 4.16 Duplicação do relatório estatístico

`Portal\OmbudsmanController@relStatitics` e `export()` reproduzem a lógica de classificação que também existe nos escopos do model. A v5 tinha **duas** rotas (`/relatorio-estatistico-ouvidoria` e `/relatorio-estatistico-ouvidoria/exportacao`); a v6 unificou em uma rota com `?type=`.

## 5. Código duplicado ou copiado

| Duplicação | Onde |
|---|---|
| Padrão de CRUD + `deleteAll` + auditoria | Repetido em 6 controllers (`Subject`, `Sector`, `Origin`, `ResponseMethod`, `ManifestationType`, `CannedResponse`) — mesmo formato de `try/catch`, mesma estrutura de resposta JSON |
| Padrão de e-mail com saudação por hora | Repetido em `OmbudsmanRequestController@enviar_chamado`, `@prorrogar_chamado` e `OmbudsmanAnswerController@store` (bloco `if ($time >= 12 && $time < 18)`) |
| Escopos de prazo | `getByBetweenDate` e `getByBetweenDateWithAnswer` no model, com `checkParameters` compartilhado |
| Templates de e-mail do portal | `portal/layouts/modelo{1,2,5,6}/informacoes-instituicao/ouvidoria/teste/` — cópias dos templates de `prefecture/emails/` que **não são usadas** |
| `prefecture/ombudsman/{index,edit,form}.blade.php` | Cópia do CRUD de Feriados Municipais (o `index.blade.php` carrega `contents/feriados-municipais/datatable.js` e verifica `feriados-municipais.delete`) — **não há controller** que os renderize |

## 6. Observações de LGPD (a v6 é a versão mais recente neste ponto)

| Ponto | Estado |
|---|---|
| Mascaramento de descrição e resposta | ✅ `mask_description` / `mask_answer` gravados ao vivo (`maskTextWithAi`) |
| Relatório público com dados mascarados | ✅ `masked_protocol`, `masked_name` |
| Exportação com dados mascarados | ✅ `exports/ombudsman-requests.blade.php` |
| **Consulta por protocolo expõe dados em claro** | ❌ `getOmbudsmanByProtocol` devolve o model completo |
| **Listagem do CMS expõe `ip` e `browser`** | ❌ `dados_do_chamado` devolve o model completo |
| **Exportação do DataTable do CMS expõe protocolo e descrição** | ❌ view `chamados_ouvidoria` |
| **`token` (hash) exposto no JSON do CMS** | ❌ |
| **`answer` original continua sendo enviado por e-mail** | ⚠️ por decisão (o cidadão precisa receber a resposta); mas o e-mail não é mascarado |
| Retenção/anonimização de dados antigos | ❌ não há rotina de expurgo |
