# Legislação — Gaps, Bugs e Dívidas

> Tudo aqui foi **verificado por leitura de código e/ou consulta de leitura ao
> banco** (`prefeituras_v5`, migrations da v6 aplicadas). Itens não verificáveis
> estão marcados como `[NÃO VERIFICADO]`.

---

## 1. Bugs e riscos de segurança

### B1 — Injeção de SQL via `$_GET['ano']` (CRÍTICO)

`v6/app/Http/Controllers/Portal/LegislationController.php:56`:

```php
$meses = DB::select("select extract(month from publication_date) as mes from contents  WHERE category_contents_id = {$category->id} and extract(year from publication_date) = {$_GET['ano']} and publication_date <= NOW()    GROUP BY mes order by extract(month from publication_date) asc;");
```

`$_GET['ano']` é concatenado **cru** no SQL, sem cast, sem binding e sem
`clean_slug`. É alcançável pela rota pública `GET /legislacao/{categoria}?ano=...`
e é executado **antes** de qualquer filtro do Eloquent. Um payload como
`?ano=2024) OR 1=1--` altera a semântica da consulta.

- **Impacto**: leitura de dados arbitrários via `UNION SELECT`, alteração da
  consulta, potencial escrita via `stacked queries` (depende da config do PDO).
- **Correção na v7**: validar `ano` como inteiro (`ctype_digit`) e usar
  `whereYear()` — nunca `$_GET` superglobal num `DB::select`.

### B2 — Injeção de SQL via `$request->termo` no endpoint de filtro (CRÍTICO)

`v6/app/Http/Controllers/Portal/LegislationController.php:203-204`:

```php
$query->whereRaw("translate(lower(title), ' ''àáâãäéèëêíìïîóòõöôúùüûçÇ', '--aaaaaeeeeiiiiooooouuuucc') ilike '%" . $request->termo . "%'")
    ->orWhereRaw("translate(lower(text), ' ''àáâãäéèëêíìïîóòõöôúùüûçÇ', '--aaaaaeeeeiiiiooooouuuucc') ilike '%" . $request->termo . "%'");
```

O `termo` **não** passa por `clean_slug()` (ao contrário de `$newReq` e
`$newTexto`, nas linhas 75 e 127). Como o `whereRaw` é montado por concatenação,
uma asrophe no parâmetro encerra a string SQL.

- **Correção na v7**: `whereRaw(..., [$termo])` com binding, ou
  `whereRaw("... ilike ?", ['%'.$termo.'%'])`.

### B3 — `isset($request->termo)` é falso para valor `null`

`Illuminate\Http\Request::__isset` (Laravel 5.5,
`v6/vendor/laravel/framework/src/Illuminate/Http/Request.php:611-614`):

```php
public function __isset($key)
{
    return ! is_null($this->__get($key));
}
```

Consequências no `filtroGET`:

| Valor enviado | `isset($request->termo)` | Efeito |
|---|---|---|
| `{"termo": null}` | **falso** | O bloco de busca por texto **não roda** — o filtro é ignorado silenciosamente. |
| `{"termo": ""}` | verdadeiro | Executa `ilike '%%'` → devolve **tudo** (a busca "vazia" não filtra nada). |
| `{"termo": "lei"}` | verdadeiro | Busca executada normalmente. |

### B4 — Filtros de data sobrescritos em `filtroGET`

`v6/app/Http/Controllers/Portal/LegislationController.php:183-196` (ver também
`05-REGRAS-E-PORTABILIDADE.md` §3.9). Cada `if` **substitui** `$result`:

```php
if (isset($request->data_inicial)) $result = $fill->where("publication_date", ">=", $request->data_inicial)->pluck('id');
if (isset($request->data_final))   $result = $fill->where("publication_date", "<=", $request->data_final)->pluck('id');
```

Com os dois parâmetros presentes, o **segundo** `if` descarta o filtro de
`data_inicial`; o terceiro bloco só recupera a combinação correta porque reescreve a
coleção do zero. É um bug de manutenção — qualquer refatoração que mexa nesse
bloco pode introduzir a perda do filtro inicial.

### B5 — O parâmetro `texto` é ignorado pelo endpoint de filtro

`filtroGET` lê apenas `$request->termo`. Mas:

- `contentShow` (filtro do servidor) lê `$request->texto`;
- a **exportação** aceita os dois: `$request->filled('texto') ? $request->texto : $request->termo`
  (`v6/app/Http/Controllers/Portal/LegislationController.php:122`);
- o formulário do portal envia `termo`.

Resultado: o nome do parâmetro diverge entre as três interfaces. O `texto`
enviado por um integrador é simplesmente ignorado pelo filtro.

### B6 — Conteúdo despublicado (`status = false`) continua acessível

`contentView` faz `Content::where('slug', $slug2)->first()` **sem** filtrar
`status`. Uma norma despublicada continua acessível pela URL direta e ainda
incrementa `clicks`.

### B7 — Norma futura incrementa `clicks`?

`contentView` redireciona para `getIndex()` **antes** do `update(['clicks' => ...])`,
então a norma futura **não** incrementa o contador. Registrar como comportamento
**correto** (verificado pela ordem das linhas 147-161), para que a reescrita na
v7 não o altere.

### B8 — Slug de conteúdo em conflito não é validado no cadastro

`ContentController@store` resolve colisão de slug com o sufixo
`-<maxid+1>`, mas `ContentRequest` **não** valida unicidade de `slug`. Duas
normas com o mesmo slug são possíveis se o título for gerado fora do CMS
(importação, API). O `contentView` usa `->first()`, mostrando sempre a mais
antiga.

### B9 — `CategoryContent::categoryContents()` com chaves trocadas

Relação invertida (`hasMany(CategoryContent::class, 'id', 'category_contents_id')`).
Funciona por acaso nos `whereHas` usados pelo módulo, mas devolve os
**irmãos** quando aplicada a uma categoria folha. Detalhado em
`05-REGRAS-E-PORTABILIDADE.md` §3.3.

### B10 — Ausência de sanitização do HTML do conteúdo

`{!! $content->text !!}` no portal (`.../modeloN/legislacao/view.blade.php`) e
`strip_tags` apenas na exportação e na API. Um operador com `legislacao.create`
pode gravar `<script>` que será executado para todos os cidadãos.

## 2. Funcionalidades incompletas, mortas ou duplicadas

### M1 — A view SQL `legislacao` é órfã

**Verificado**: nenhum PHP da v6 consulta a view (`grep` em `v6/app/` e
`v6/resources/` sem resultado). A grade do CMS reconstrói as mesmas colunas em
PHP (`v6/app/Http/Controllers/Prefecture/DatatableController.php:101-120`).

Consequências:

| Divergência | View SQL | PHP (`get_legislacao`) |
|---|---|---|
| Escopo das categorias | `cc.category_contents_id = 2` (**só 1º nível**) | `getAllDescendants()` (**todos os níveis**) |
| Colunas | `value, titulo, data_publicacao, categoria, categoria_slug, slug, id` | `value, titulo, data_publicacao, categoria, categoria_slug, slug, id` (+ `edit/delete/active/inactivate`) |
| Formato da data | `DD/MM/YYYY às HH24:MI` | `d/m/Y H:i:s` |
| Limite | nenhum | nenhum |

A view ficou para trás: **não** reflete a lógica atual. Deve ser removida (ou
regerada a partir da mesma fonte de verdade).

### M2 — Dupla paginação

| Camada | Onde | Tamanho | Usada? |
|---|---|---|---|
| Servidor | `LegislationController@contentShow` → `->paginate(5)` | 5 | **Não** — `modelo1..modelo6` não renderizam `$contents`. |
| Servidor | `default` e `newsbit` → `{{$contents->links()}}` | 5 | Layouts **inalcançáveis** (`getTemplateActived()` exige `home/index.blade.php`). |
| Cliente | `legislacao_portal.js` → `data.chunk(10)` | 10 | **Sim** — é a listagem real. |

O `paginate(5)` do servidor é trabalho descartado em **toda** visita ao portal
de legislação.

### M3 — O endpoint de filtro não pagina

`filtroGET` carrega **toda** a subcategoria duas vezes:

```php
$fill = Content::where('category_contents_id', $subcategoria_id)->get(['title', 'publication_date', 'slug', 'id', 'status', 'text', 'updated_at', 'created_at']);
$resultado = Content::whereIn('id', array_unique($result->all()))->orderBy('publication_date', "DESC")->get();
```

Duas queries sem `LIMIT`, com o conjunto inteiro trafegando duas vezes (o
primeiro carrega `text`, que pode ser grande). Em uma prefeitura com
milhares de decretos isso é o principal risco de indisponibilidade do portal.

### M4 — A exportação não pagina nem limita

`export()` → `$query->...->get()` e depois gera XLSX/CSV/TXT/PDF **inteiros em
memória**. Não há job assíncrono nem limite.

### M5 — `partials/link-row.blade.php` (normas vinculadas) — órfã

`v6/resources/views/prefecture/contents/legislacao/partials/link-row.blade.php`
implementa o formulário `norma_links[...]` e referencia
`\App\Prefecture\LegislationLink::formRelationLabels()`. **Esse model não
existe** na v6 (e nem na v5) e a partial **não é incluída** em lugar nenhum
(verificado por `grep -rn "LegislationLink\|norma_links" v6/`). Se alguém
incluir a partial, o módulo quebra com `Class not found`.

### M6 — Views de índice do portal quebradas

`v6/resources/views/portal/layouts/modelo1/legislacao/index.blade.php:10` e
`.../modelo2/legislacao/index.blade.php:10` incluem
`portal.components.categories-contents.legislacao.nav` — mas o diretório
existente é `legislacoes` (plural). Essas views não são renderizadas pelo
controller (que só devolve `list`, `show` e `view`), então o erro fica
escondido — mas são armadilhas para quem for reaproveitar o markup.

### M7 — Não existe `pub-nav.blade.php` para legislação

Confirmado: o padrão `pub-nav.blade.php` (usado por `modelo3/4/5/6/publicacoes`,
`modelo1..6/atas-e-pontos`, `modelo1..6/comite-de-investimentos`,
`default/pages/elements`) **não tem contrapartida para legislação**. A
legislação usa `components/categories-contents/legislacoes/nav.blade.php`
(modelo1/2) e `modeloN/legislacao/elements/leg-nav.blade.php` (modelo3..6).
Na v7, unificar em um único componente.

### M8 — Bloco `getPosts(page)` obsoleto nas views `show`

`.../modeloN/legislacao/show.blade.php` mantém um `$.ajax` de paginação por
servidor que:
- busca a rota atual com `?page=N&data_inicial=...&data_final=...&texto=...`;
- usa `dataType: 'json'` contra uma rota que devolve **HTML** (a view), salvo
  quando `request()->ajax()` é verdadeiro — o `Response::json(view(...)->render())`
  de `contentShow` (linha 88);
- lê `$('input[id="search"]')`, id **inexistente** nessas views (o campo é
  `#Search_termo`), logo `texto` chega como `undefined`;
- substitui `#include-list` por uma view que, nos layouts `modelo1..6`, não
  contém itens.

Código morto que confunde a leitura e sugere uma paginação de servidor que não
existe.

### M9 — `legislacao_portal.js` redefine `createLink` global

`v6/public/assets/js/custom/contents/legislacao/legislacao_portal.js:135` define
`function createLink(...)`, sobrescrevendo a função homônima de
`v6/public/assets/js/custom/utilities/functions.js:19`. Colisão de escopo
global entre módulos.

### M10 — `contentShow` monta `$contents` duas vezes

Linhas 43-45 (sem filtro) e 63-81 (com filtro) — a primeira query é sempre
descartada quando há parâmetros. Duas queries de paginação por visita.

## 3. Riscos de dados (migração de schema)

| # | Risco | Impacto | Mitigação |
|---|---|---|---|
| D1 | `contents.category_contents_id` é `NOT NULL` com FK para `category_contents.id` | Excluir (soft delete) uma categoria deixa as normas inacessíveis pelo portal (que sempre parte do id da categoria) |soft delete em cascata ou regra de bloqueio de exclusão com conteúdo vinculado |
| D2 | `contents.contents_id` → `archives` (`ON DELETE` não configurado) | Exclusão física de uma norma com anexos viola a FK | Sempre usar soft delete |
| D3 | A view `legislacao` fixa `cc.category_contents_id = 2` | Se o id 2 for reatribuído, a view passa a listar outro módulo | Remover a view ou parametrizá-la por `slug` |
| D4 | `modulos.status` habilita/desabilita o menu do CMS | `legislacao` (id 19) está `true` — o módulo aparece | Documentar quem alterna |
| D5 | `contents.clicks` é `integer` sem default | `NULL` quebraria `+ 1` | `default 0` + `increment()` |
| D6 | Slugs de conteúdo não são únicos por constraint | URLs ambíguas (B8) | Índice único parcial ou verificação na aplicação |
| D7 | A sequência `category_contents_id_seq` é compartilhada por **todos** os módulos de conteúdo | O id 2 não é garantido em bases novas | Sempre referenciar por `slug` |

## 4. Estado atual do módulo no banco

| Métrica | Valor |
|---|---|
| Categoria raiz | `category_contents.id = 2`, `slug = 'legislacao'` |
| Subcategorias | 3 (ids 22, 23, 24) |
| Conteúdos de legislação | **0** (ids 2, 22, 23, 24) |
| Total de `contents` na base | 184 (todos com `deleted_at IS NULL`) |
| `modulos.status` | `true` (id 19) |
| View `legislacao` | existe, **com** `categoria_slug` e `slug` (migration `2024_11_29_084625` aplicada) |

> O módulo está **cadastrado e navegável, porém sem nenhuma norma publicada**.
> Qualquer teste manual no ambiente atual mostra listas vazias — é preciso
> cadastrar conteúdo de exemplo.

## 5. Perguntas em aberto para o negócio

1. A legislação deve ser **versionada** (normas com revisões) ou a publicação
   atual (uma linha em `contents`, editável) basta? A partial
   `link-row.blade.php` sugere uma tentativa de "normas vinculadas" que nunca
   foi concluída.
2. Deve existir **categoria com subcategoria** na árvore de legislação? Hoje há
   apenas 2 níveis na base, mas o código e as views suportam N níveis.
3. As normas devem ser **indexadas** em busca própria ou usar a busca global
   do portal?
4. A exportação deve respeitar o filtro de **período** da tela (hoje sim, na v6)?
   Deve gerar PDF paginado com cabeçalho/rodapé institucional?
5. O `clicks` deve contar visita ou visita **única** (sessão/IP)?
6. É aceitável que a listagem do portal passe a paginar no servidor (mudança de
   UX: hoje são 10 itens por bloco, " infinitos", sem URL de página)?
7. A view `legislacao` é consumida por **algum sistema externo** (portal da
   transparência, integração com a Câmara)? Se sim, remover a view quebra o
   contrato — precisa de depreciação antes.

## 6. Mapa v5 → v6

### 6.1 Resumo

| Aspecto | v5 | v6 | Impacto |
|---|---|---|---|
| Controller do portal | `v5/app/Http/Controllers/Portal/LegislationController.php` (165 linhas) | `v6/app/Http/Controllers/Portal/LegislationController.php` (215 linhas) | **Idêntico**, exceto pelo método `export()` e por 2 colunas em `filtroGET`. |
| Rotas do portal | 4 rotas de legislação | 5 rotas (acrescenta `legislacao.export`) | Nova funcionalidade: exportação. |
| Exportação | **Inexistente** (sem trait, sem classe Excel, sem view dompdf) | `ExportsContentTrait` + `ContentsExport` + `exports/contents.blade.php` | Recurso novo, copiado do módulo de Publicações. |
| Filtros `data_inicial` / `data_final` na exportação | Inexistentes | Presentes | Só na exportação; o `filtroGET` já os tratava (mas com bug). |
| Parâmetro de busca na exportação | — | aceita `texto` **ou** `termo` | Normalização que o `filtroGET` não fez. |
| View SQL `legislacao` | `value, titulo, data_publicacao, categoria, id` (**sem** `categoria_slug`/`slug`) | `+ categoria_slug, + slug` | Migration `v6/database/migrations/2024_11_29_084625_update_view_legislacao.php` (só v6). |
| `filtroGET` — colunas selecionadas | `title, publication_date, slug, id, status, text` | `+ updated_at, + created_at` | Necessário para o "Atualizado em" exibido pelo JS do portal. |
| `partials/link-row.blade.php` | Inexistente | Existe, **órfã** | Resíduo de funcionalidade abandonada (referencia model inexistente). |
| API `/api/v1/legislacoes` | Presente | Presente | Sem diferenças estruturais. |
| CMS (controllers, views, JS) | — | — | Sem diferenças (mesmos arquivos, mesmo conteúdo). |
| Views do portal | 50 arquivos `*legislacao*` | 50 arquivos `*legislacao*` | Conjuntos **idênticos** (verificado por `diff`). |
| Rotas do CMS | idênticas | idênticas | — |
| Breadcrumbs | idênticos | idênticos | — |

### 6.2 Diff do controller do portal (v5 → v6)

```diff
 use Illuminate\Http\Request;
 use App\Http\Controllers\Admin\Controller;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Response;
+use App\Http\Traits\ExportsContentTrait;

 class LegislationController extends Controller
 {
+    use ExportsContentTrait;
 
     public function getIndex()
@@
+    public function export(Request $request)
+    {
+        $getAllDescendants = CategoryContent::query()->where('slug', '=', 'legislacao')->first()->getAllDescendants();
+        $category = CategoryContent::query()->find(data_get($getAllDescendants->where('slug', $request->sub_category)->first(), 'id'));
+
+        if (empty($category)) {
+            abort(404);
+        }
+
+        $query = Content::where('category_contents_id', $category->id)
+            ->where('publication_date', '<=', date('Y-m-d H:i:s'));
+
+        if ($request->filled('ano'))  { $query->whereYear('publication_date', $request->ano); }
+        if ($request->filled('mes'))  { $query->whereMonth('publication_date', $request->mes); }
+        if ($request->filled('data_inicial')) { $query->where('publication_date', '>=', $request->data_inicial); }
+        if ($request->filled('data_final'))   { $query->where('publication_date', '<=', $request->data_final . ' 23:59:59'); }
+
+        $texto = $request->filled('texto') ? $request->texto : $request->termo;
@@
+        return $this->exportContents(
+            $data,
+            $request->get('type', 'csv'),
+            'legislacao_' . $category->slug . '_' . date('Ymd_His'),
+            'Legislação - ' . $category->name
+        );
+    }
+
     public function contentView($slug1, $slug2)
@@
-        $fill = Content::where('category_contents_id', $subcategoria_id)->get(['title', 'publication_date', 'slug', 'id', 'status', 'text']);
+        $fill = Content::where('category_contents_id', $subcategoria_id)->get(['title', 'publication_date', 'slug', 'id', 'status', 'text', 'updated_at', 'created_at']);
```

### 6.3 Diff das rotas (v5 → v6)

```diff
 // v5/routes/web.php:107-112
 Route::prefix('legislacao')->group(function () {
     Route::get('/', 'Portal\LegislationController@getIndex')->name('legislacao.index');
-    // (sem rota de exportação)
     Route::get('/{slug}', 'Portal\LegislationController@contentShow')->name('conteudo.legislacao.list');
     Route::get('/{slug1}/{slug2}', 'Portal\LegislationController@contentView')->name('conteudo.legislacao.view');
     Route::post('/filtro', 'Portal\LegislationController@filtroGet')->name('conteudo.legislacao.fill');
 });
```

### 6.4 Diff da view SQL (v5 → v6)

```diff
 -- migration v5: 2023_05_29_130250_legislacao_formato_24_horas.php
         row_number() OVER (ORDER BY c.publication_date DESC) as value,
         c.title as titulo,
         to_char(c.publication_date::timestamp with time zone, 'DD/MM/YYYY às HH24:MI'::text) AS data_publicacao,
         cc."name" as categoria,
         c.id as id
 from contents c JOIN category_contents cc ON cc.id = c.category_contents_id
 where c.deleted_at is null and cc.category_contents_id = 2
 ORDER BY c.publication_date DESC;

 -- migration v6 (adicional): 2024_11_29_084625_update_view_legislacao.php
         row_number() OVER (ORDER BY c.publication_date DESC) as value,
         c.title as titulo,
         to_char(c.publication_date::timestamp with time zone, 'DD/MM/YYYY às HH24:MI'::text) AS data_publicacao,
         cc.name as categoria,
+        cc.slug as categoria_slug,
+        c.slug as slug,
         c.id as id
```

> **Ressalva verificada:** a migration
> `v5/database/migrations/2026_03_09_000000_update_views_to_include_slug.php`
> **não** recria a view `legislacao`. Ela trata de `a_cidade`, `a_prefeitura`,
> `orgaos` e `secretarias` — confirmando que a coluna `categoria_slug`/`slug`
> só existe na view `legislacao` a partir da v6.

### 6.5 Itens que existiam na v5 e foram **removidos** na v6

Nada identificado no escopo de legislação: a v6 é **superset** da v5 neste
módulo (só acrescenta exportação, colunas e colunas na view).

### 6.6 Itens que **existem na v6** e não na v5

| Item | Observação |
|---|---|
| `LegislationController@export` + rota `legislacao.export` | Recurso novo, com `data_inicial`/`data_final`. |
| `ExportsContentTrait`, `ContentsExport`, `exports/contents.blade.php` | Infraestrutura de exportação (também usada por Publicações). |
| Colunas `categoria_slug` e `slug` na view `legislacao` | Só na v6 (migration `2024_11_29_084625`). |
| `updated_at`/`created_at` na seleção do `filtroGET` | Necessário para o "Atualizado em" do portal. |
| `partials/link-row.blade.php` | **Órfã** — não funcional na v6. |
| `v6/database/seeds/UpdateLegislacaoResourceNameSeeder.php` | Reajuste de nomes de recurso (não existe na v5). |
