# Solicitações LAI (e-SIC) — Regras de Negócio e Portabilidade

---

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

### 1.1 Protocolo

- O protocolo é `date('YmdHis')` (14 dígitos) gerado **no Blade**, dentro do
  `<input type="hidden" name="protocol">` de
  `v6/resources/views/portal/layouts/modelo1/transparencia/solicitacao-form.blade.php:16`.
  Consequências:
  - o carimbo é do momento em que a **página foi renderizada**, não do envio;
  - dois carregamentos no mesmo segundo geram o mesmo protocolo;
  - a validação `unique:informations_requests|numeric` (no Form Request e no
    validador ajax) é a única barreira contra colisão.
- O `maskProtocol()` (`v6/app/Http/Traits/DataMaskTrait.php`) exibe o protocolo
  mascarado como `2025******0000` (primeiros 4 + últimos 4 dígitos visíveis).
- O CMS mostra o protocolo **em claro** (vista SQL e tela de detalhe).

### 1.2 Prazo legal de 20 dias

O prazo aparece em três lugares, todos fixos:

| Onde | Trecho |
|---|---|
| SQL do relatório | `whereRaw('EXTRACT(DAY FROM now() - '.$values['fieldExtract'].') '.$values['comparation'].' 20')` (`v6/app/Http/Model/Prefecture/InformationsRequest.php:112` e `:158`) |
| Texto do portal | "Em até 20 (vinte) dias retornaremos seu contato." (`informacao-solicitada.blade.php`) |
| E-mail ao solicitante | "Uma resposta será enviada em até 20 (vinte) dias." (`prefecture/emails/informations-requests/email_user.blade.php`) |

A chave `response_time` ("Prazo de resposta em dias", valor 20) existe em
`configurations` e é lida por `Portal\OmbudsmanController@store`
(`v6/app/Http/Controllers/Portal/OmbudsmanController.php:228`) — **a LAI não a
utiliza**.

Cálculo do indicador "dentro/fora do prazo" (`checkParameters`):

| status_id | Campo dedata usado no cálculo | Campo do filtro de ano |
|---|---|---|
| 1, 2, 3 | `created_at` | `created_at` |
| 4 (Prorrogado) | `updated_at` | `created_at` |

A comparação é `>` (fora do prazo) quando `$comparationForm == 1` e `<=`
(dentro) caso contrário. O status 4 usa `updated_at` porque a prorrogação
reinicia a contagem a partir da data da alteração.

### 1.3 Estados e transições

```
            ┌───────────────────────────────┐
            │ 1 Em Tramitação (na criação)   │
            └───┬────────────┬───────────┬───┘
                │            │           │
        tipo=1  │   tipo=0   │  GET .../lai/4
                ▼            ▼           ▼
        ┌───────────┐  ┌────────────┐  ┌────────────┐
        │2 Atendido │  │3 Indeferido│  │4 Prorrogado│
        └───────────┘  └────────────┘  └────────────┘
```

- Só há **uma** transição por GET (`info-request-status-update`) e ela aceita
  qualquer valor de `status` — não há validação contra {1,2,3,4}.
- Responder e indeferir são implementados como **efeito colateral** do
  registro da `Answer` (`Prefecture\AnswerController@store`).
- Não existe caminho de volta de 2/3/4 para 1.
- A prorrogação **não** registra justificativa nem nova data de prazo.

### 1.4 Notificação por e-mail

| Evento | View de e-mail | Destinatário |
|---|---|---|
| Pedido registrado | `prefecture.emails.informations-requests.email_user` | solicitante (`email_address`) |
| Pedido registrado | `prefecture.emails.informations-requests.email_admin` | `configurations.email_lai`; se vazio, `configurations.email` |
| Resposta com anexo | `prefecture.emails.answer.email_answer_attach` | solicitante |
| Resposta sem anexo | `prefecture.emails.answer.email_answer_no_attach` | solicitante |
| Indeferimento | `prefecture.emails.answer.email_rejected` | solicitante |
| Prorrogação | `prefecture.emails.answer.email_to_extend` | solicitante |

Assunto do e-mail de resposta: `"ATUALIZADO: Lei de Acesso à Informação [$protocol]"`;
da prorrogação: `"PRORROGADO: Lei de Acesso à Informação [$protocol]"`.

O e-mail de prolongação é disparado **apenas** quando `status == 4`.

### 1.5 Mascaramento (LGPD)

- `mask_request` é gravado **no momento da criação** da solicitação, junto com
  o texto original em `request`.
- O mascaramento tem duas camadas (`v6/app/Http/Traits/DataMaskTrait.php`):
  1. `maskTextDeterministic()` — regex para CPF, CNPJ, RG, telefone, CEP,
     e-mail, credenciais (`usuario.sistema#0000`), CID-10, passaporte,
     PIS/PASEP, título de eleitor, data de nascimento, nome próprio, endereço,
     "Cidade - UF" e valores monetários.
  2. `maskTextWithAi()` — chama a API Gemini (`env('GEMINI_API_URL')`,
     `env('GEMINI_API_KEY')` ou a chave `gemini_api_key` em `configurations`)
     para refinar o resultado; em qualquer falha (sem chave, erro HTTP,
     resposta inesperada) **retorna o resultado determinístico**.
- A chamada HTTP é feita **dentro da transação** de gravação da solicitação
  (`InformationController@store` e `@storeRequestInformacaoAjax`), o que
  prolonga a transação e, se a API demorar, aumenta o risco de lock.
- O backfill histórico usa apenas a camada determinística
  (`v6/database/seeds/UpdateRequestToMaskRequestInInformationRequest.php`),
  para registros com `created_at < '2025-10-14 00:00:00'`.
- `masked_protocol` e `masked_name` são acessors do model; `maskName()`
  devolve "Anônimo" para nome vazio, "Anônimo"/"Sigiloso" conforme a modalidade.

### 1.6 Módulos e permissões

- Módulo: `modulos` id 33, slug `solicitacoes-lai`.
- Resources: `lai` (id 37) e `respostas-lai` (id 38), ambas `modulo_id = 33`.
- `MenuOrder` de slug `lai` alimenta os contadores do menu.
- `menuClicks('lai')` (`v6/app/Helpers/functions.php:865`) incrementa os
  cliques do item de menu.
- Logs de atividade (`spatie/laravel-activitylog`) em: listagem por status,
  criação, alteração de status e resposta.

## 2. Acoplamentos com outros módulos

| Módulo | Dependência |
|---|---|
| **Transparência Fiscal** | As páginas do relatório e do e-SIC vivem sob `transparencia-fiscal` e compartilham o template ativo (`configurations.key = 'layout'`) e a chave `full_list_requests_disabled`. |
| **Ouvidoria** | Compartilha `DataMaskTrait`, a tabela `statuses` e a configuração `response_time` (usada só pela Ouvidoria). |
| **Dashboard do CMS** | `LaiWidget` (`v6/app/Services/Dashboard/Widgets/LaiWidget.php`) consulta `InformationsRequest` e é registrado no módulo `solicitacoes-lai`. |
| **Tabelas de conteúdo / CMS** | `tables` genéricas: `content`, `person`, `email`, `phone`, `address`, `archive` — todos compartilham as tabelas `persons`, `emails`, `phones`, `addresses`, `archives` com outros módulos. |
| **Configurações do site** | `email`, `email_lai`, `titulo_site`, `layout`, `gemini_api_key`. |
| **Menu do CMS** | `v6/app/Providers/MenuServiceProvider.php:72` monta o submenu "Solicitações (LAI)". |

Dependências inversas (quem usa o que este módulo cria):

- `v6/app/Http/Traits/GetInformationsPrefectureTrait.php:74` usa
  `InformationsRequest::with('status')->with('answers')->with('person')`.
- `v6/app/Http/Controllers/Portal/TransparencyController.php` usa os scopes de
  prazo e o accessor `masked_protocol`/`masked_name`.
- `v6/app/Exports/InformationsRequestsExport.php` usa a view
  `exports.informations-requests`.

## 3. Armadilhas técnicas

### 3.1 SQL e consultas

| Armadilha | Onde | Impacto |
|---|---|---|
| `whereRaw` com número fixo | `InformationsRequest::scopeGetByBetweenDate` e `scopeGetByBetweenDateWithAnswer` | Prazo não configurável; o `20` está embutido no SQL. |
| `groupBy('status_id')` sobre `select *` | idem | A consulta **falha** em PostgreSQL (`column "informations_requests.id" must appear in the GROUP BY clause`) — verificado executando o SQL equivalente no banco. Só funciona encapsulada em `->count()`. |
| `DB::table("em_tramitacao")->get()` sem paginação | `Prefecture\DatatableController@get_lai*` | Carrega **toda** a view em memória antes de paginar no DataTable. |
| `inner join persons` nas views SQL | migrations 2023-04-11 / 2023-05-29 | Solicitações com `persons_id` nulo somem das listagens do CMS. |
| `->with('answers')` | `InformationsRequestController@index` e `GetInformationsPrefectureTrait` | A relação aponta para a coluna inexistente `answers_id`; o binding é sempre `null` e a relação **nunca** retorna respostas. |
| `Person::addresses()` / `emails()` / `phones()` são `belongsTo` | `v6/app/Http/Model/Admin/Person.php:41-60` | Devolvem **um** registro (o primeiro encontrado), não uma coleção; se o titular tiver mais de um endereço, apenas um aparece. |

### 3.2 Estado e requisição

| Armadilha | Onde | Impacto |
|---|---|---|
| Alteração de estado via **GET** | `GET cms/solicitacoes/{id}/lai/{status}` | Sem CSRF; um link (ou pré-carregamento) muda o status. `GET` também é cacheável. |
| `status` não validado | `InformationsRequestController@statusUpdate` | Qualquer inteiro pode ser gravado em `status_id`. |
| Flash message genérico | idem | Sempre "Solicitação prorrogada com sucesso.", mesmo para status ≠ 4. |
| Log de atividade engessado | idem | O log sempre registra `Status:prorrogada`. |
| `$_SERVER["REMOTE_ADDR"]` e `$_SERVER['HTTP_USER_AGENT']` lidos direto | `InformationController@store` e `@storeRequestInformacaoAjax` | Não há wrapper de request; o IP não considera `X-Forwarded-For` (apesar do `TrustProxies` do Kernel). |
| `DB::transaction` envolvendo envio de e-mail e chamada à IA | `InformationController@store` | Transação longa; falha de e-mail/IA reverte a solicitação. |
| Exceção concatenada na resposta JSON | `InformationController@storeRequestInformacaoAjax` (`'...'.$e`) | Expõe stack trace e caminho absoluto ao visitante. |
| `return 'entrei ';` antes do tratamento | `InformationController@store` (`catch (\PDOException)`) | Código morto; a tela de erro nunca aparece nesse caminho. |

### 3.3 Segurança e privacidade

| Armadilha | Impacto |
|---|---|
| `getInformationByProtocol` devolve o model completo | Quem tiver o protocolo vê `request` (texto **sem máscara**), `ip`, `browser` e todos os dados do titular (CPF, RG, e-mail, telefone, endereço). Verificado em execução: HTTP 200 com o JSON do model. |
| `getAnswerByInformation` sem verificação de existência | Protocolo inexistente → erro 500 (verificado). |
| Rotas de ajax do portal sob o prefixo `cms/ajax` (controllers `Portal`) | Não passam por `check.logged` nem por `check.route.permission`. |
| `info-request-status-update` em `openRoutes` | Qualquer usuário logado no CMS altera status de qualquer solicitação. |
| Protocolo adivinhável | `date('YmdHis')` permite enumerar pedidos de um intervalo. |
| ReCAPTCHA opcional na via ajax | `storeRequestInformacaoAjax` não valida o reCAPTCHA (comentado no código). |
| `mask_request` só usado no relatório | O texto mascarado não aparece no acompanhamento por protocolo nem na listagem do template `default`. |
| Exposição de stack trace | `v6/app/Exceptions/Handler.php:50` devolve `'Ocorreu um erro ao tentar enviar a mensagem.' . $exception` para qualquer requisição ajax. |

### 3.4 Views e rotas quebradas

| Item | Verificação |
|---|---|
| `prefecture.informations-requests.create` | `view()->exists()` → **no** (`create()` do controller aponta para ela) |
| `prefecture.answers.create` | `view()->exists()` → **no** (`AnswerController@createByRequest` aponta para ela) |
| `portal.layouts.default.pages.requested-information` | `view()->exists()` → **no** (destino do `redirect()->view()` no `store()` do CMS) |
| `portal.layouts.default.transparencia-fiscal.informacao-solicitada` | `view()->exists()` → **no** (caminho montado pelo `store()` do portal quando o template ativo é `default`) |
| `portal.layouts.modelo1.transparencia-fiscal.informacao-solicitada` | `view()->exists()` → **yes** (template ativo no banco) |
| Rota `solicitar-informacao` | `route:list` → **não existe** (o `catch` do `store()` do CMS redireciona para ela) |
| `assets/js/custom/portal/lai/state.js` | **não existe** na v6 (referenciado pela view `v6/resources/views/portal/layouts/newsbit/informacoes-instituicao/acesso-rapido/solicitar-informacao.blade.php:96`) |
| `prefecture.informations-requests.index` sem `$status` | Render verificado → `Undefined variable: status` |
| `prefecture.informations-requests.show` sem relações | Render verificado → `Trying to get property 'id' of non-object` |

### 3.5 Código duplicado

- `Portal\InformationController@store` e `@storeRequestInformacaoAjax`
  repetem ~120 linhas de gravação (pessoa, endereço, e-mail, telefone,
  solicitação, dois e-mails). As únicas diferenças são as regras de validação.
- `getInformationByProtocol` e `getAnswerByInformation` repetem a busca por
  protocolo.
- As quatro telas "Acompanhe sua solicitação" (uma por template) repetem o
  mesmo JavaScript de consulta.
- As quatro telas de relatório estatístico repetem o mesmo gráfico.
- `InformationsRequest::checkParameters` compartilha a mesma lógica com
  `App\Prefecture\OmbudsmanRequest` (módulo da Ouvidoria), que tem a
  implementação paralela.

## 4. Onde a v6 divergiu da v5

| Item | v5 | v6 | Observação |
|---|---|---|---|
| Migration `mask_request` | `v5/database/migrations/2025_10_13_161153_add_mask_request_to_informations_request_table.php` (**13/10/2025**), com guarda `if (!Schema::hasColumn(...))` | `v6/database/migrations/2025_10_14_154644_add_mask_request_to_informations_request_table.php` (**14/10/2025**), **sem** a guarda | Datas diferentes: a v5 antecede a v6 em um dia. A guarda `hasColumn` da v5 evita erro em banco já alterado; a v6 perde essa idempotência. |
| `limpaCPF_CNPJ` no controller do portal | devolve o valor sem `strtoupper` | devolve `strtoupper($valor)` | `v5/app/Http/Controllers/Portal/InformationController.php:64` vs `v6/app/Http/Controllers/Portal/InformationController.php:65`. O helper global `limpaCPF_CNPJ` de `v5/app/Helpers/functions.php` usava `preg_replace('/[^A-Za-z0-9]/','',...)`; na v6 usa `str_replace` de `.`, `,`, `-` e `/` — o que **não** remove outros caracteres especiais. |
| `checkParameters` (status 1/2/3) | `fieldBetween = 'updated_at'` | `fieldBetween = 'created_at'` | `v5/app/Http/Model/Prefecture/InformationsRequest.php:155` vs `v6/app/Http/Model/Prefecture/InformationsRequest.php:165`. Corrige o filtro de ano do relatório estatístico (antes filtrava pela última alteração). |
| `RequestMaskWithRegex` | método funcional | `@deprecated`, delegando a `maskTextDeterministic()` | Mantido por compatibilidade com o seeder de backfill. |
| View de exportação | `exports/information-requests.blade.php` (singular) | `exports/informations-requests.blade.php` (plural) | Renomeada junto com a classe `InformationsRequestsExport`. |
| `AnswerController` | registra `$user = Sentinel::getUser()` e usa `$userName` com fallback "Sistema" | usa `Sentinel::getUser()->name` direto | Na v6, sem usuário autenticado o acesso a `->name` gera erro; o fallback "Sistema" foi perdido. |
| `AnswerController` | registra `Log::error` nos dois `catch` | sem `Log::error` | Perda de rastreabilidade de falhas de banco. |
| Controller do CMS | idêntico | idêntico | `diff` entre `v5/app/Http/Controllers/Prefecture/InformationsRequestController.php` e o equivalente da v6 não retorna diferenças. |
| Rotas | mesmas URIs e nomes | mesmas URIs e nomes | Só mudou o número de linha (web.php 24-25 → 16-17; basepro.php 107/109/163/204/205/432-435 → 117/119/177/232/233/421-424). |
| Views (CMS e portal) | idênticas | idênticas | `diff` sem diferenças para `prefecture/informations-requests/*` e para as views de portal do modelo1. |
| Estrutura de banco | `informations_requests` com as mesmas colunas e as mesmas 4 vistas SQL | idêntica | Verificado com `\d` nos dois bancos. |
| Tabela `modulos` | **não existe** na v5 (`relation "modulos" does not exist`) | existe, com 58 slugs, incluindo `solicitacoes-lai` (id 33) | O registro de módulos é novidade da v6. |
| Tabela `resources` | sem `modulo_id` | com `modulo_id` | Mesma evolução. |
