# Legislação — Regras de Negócio e Portabilidade

> Regras observáveis, acoplamentos e armadilhas técnicas do módulo. Os bugs
> com impacto de segurança/funcional estão detalhados em `08-GAPS-E-DIVIDAS.md`.

---

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

| # | Regra | Onde está implementada |
|---|---|---|
| R1 | A raiz do módulo é identificada pelo **slug** `legislacao`, nunca pelo id (exceto a view SQL, que fixa `cc.category_contents_id = 2`). | `CategoryContent::where('slug','legislacao')` em `v6/app/Http/Controllers/Portal/LegislationController.php:20,28` e `v6/app/Http/Controllers/Prefecture/DatatableController.php:102`. |
| R2 | `/legislacao` não tem página própria: redireciona para a **primeira** subcategoria por `order`. | `LegislationController@getIndex` (`v6/app/Http/Controllers/Portal/LegislationController.php:18-25`). |
| R3 | Normas com `publication_date` futura não aparecem na listagem e, se acessadas, redirecionam para `getIndex()`. | `contentShow` (`publication_date <= $current_date`) e `contentView` (`v6/app/Http/Controllers/Portal/LegislationController.php:147-148`). |
| R4 | Ordenação canônica: `publication_date DESC, id DESC`. | `contentShow`, `export`, `filtroGET` e `DatatableController@get_legislacao`. |
| R5 | O filtro por período do endpoint `filtro` é aplicado **em memória** sobre a coleção já carregada, não no SQL. | `LegislationController@filtroGET` (linhas 183-196). |
| R6 | O texto buscado cobre `title` **e** `text` (corpo). | `whereRaw(... title ...) orWhereRaw(... text ...)`. |
| R7 | A busca é insensível a acentos e a caixa via `translate(lower(...))`. | `v6/app/Http/Controllers/Portal/LegislationController.php:75-76,127-128,203-204`. |
| R8 | Cada abertura de norma incrementa `contents.clicks` em exatamente 1. | `contentView` (`v6/app/Http/Controllers/Portal/LegislationController.php:160-161`). |
| R9 | A subcategoria de uma norma é obrigatória (`category_contents_id` `NOT NULL` + FK). | Schema `contents`. |
| R10 | O slug do conteúdo é gerado do título e, havendo colisão, recebe sufixo `-<maxid+1>`. | `ContentController@store` / `generateSlug()`. |
| R11 | No update, o slug é validado por regex `[^a-zA-Z0-9\-_.\/:]` e recusado com mensagem "O campo Link Permanente contém caracteres inválidos." | `v6/app/Http/Controllers/Prefecture/ContentController.php:944-946`. |
| R12 | Uma categoria não pode ser pai dela mesma nem dos seus descendentes. | `v6/resources/views/prefecture/category-contents/legislacao/edit.blade.php:26-39`. |
| R13 | A ordenação das categorias é editável por arrastar-e-soltar e persistida em `category_contents.order`. | `ajustOrder` + `POST /cms/ajax/ajust-categorias`. |
| R14 | O PDF de uma norma só aparece em desktop (o componente detecta mobile por User-Agent). | `v6/resources/views/portal/components/view_pdf/files-list.blade.php:1-13`. |
| R15 | A API considera tanto as normas **diretamente** na categoria `legislacao` quanto nas **subcategorias** (`whereHas('categoryContents.categoryContents')`). | `v6/app/Http/Controllers/Swagger/V1/Portal/Controller.php:30-51`. |
| R16 | A contagem de normas do módulo (dashboard e grade do CMS) usa **todos os descendentes** da raiz `legislacao`, **excluindo a raiz**, sem filtro de `status` nem de `publication_date`, e com soft-deleted fora. | `v6/app/Services/Dashboard/Widgets/LegislacoesWidget.php:42-55` e `v6/app/Http/Controllers/Prefecture/DatatableController.php:102-104`; documentado em `v6/tests/Feature/DashboardLegislacoesWidgetTest.php`. |

## 2. Acoplamentos

### Dependências de saída (o módulo usa)

| Dependência | Ponto de acoplamento |
|---|---|
| `contents` / `category_contents` / `archives` | Tabelas compartilhadas com Notícias, Publicações, A Cidade, A Instituição, Órgãos, Secretarias. Ver `../noticias/`, `../publicacoes/`, `../a-cidade/`, `../a-instituicao/`, `../orgaos/`, `../secretarias/`. |
| `Content` / `CategoryContent` (models) | `v6/app/Http/Model/Prefecture/Content.php`, `.../CategoryContent.php`. Alterações quebram todos os módulos de conteúdo. |
| `getTemplateActived()` | `v6/app/Http/Traits/GetInformationsPrefectureTrait.php:92-102` — restringe o módulo aos layouts `modelo1..modelo6`. |
| `ContentController` / `CategoryContentController` genéricos | O CMS de legislação **não tem controller próprio**: é um `case` dentro de um `switch ($slug)`. |
| `CheckRoutePermission` | `v6/app/Http/Middleware/CheckRoutePermission.php` — as permissões são derivadas do *segmento da URL*. |
| `MenuServiceProvider` | `v6/app/Providers/MenuServiceProvider.php:237-239` — entrada "Legislação" do menu CMS. |
| `modulos` | `modulos.status = true` (id 19) habilita a entrada de menu. |
| `ExportsContentTrait` | `v6/app/Http/Traits/ExportsContentTrait.php` — compartilhado com Publicações. |

### Dependências de entrada (quem usa o módulo)

| Consumidor | Ponto |
|---|---|
| `Portal\PortalController@contentList` | `case 'legislacao': return redirect(route('legislacao.index'));` (`v6/app/Http/Controllers/Portal/PortalController.php:328`). |
| `menu_items` | Itens de menu podem apontar para `contents.id` (FK `menu_items.contents_id`); o `ContentController@destroy` remove esses itens ao excluir uma norma. |
| `ModulosServiceProvider` | `modulos['legislacao']` é consumido no formulário de Notícias (ligar/desligar selects), não no de legislação. |
| API de dados abertos | `GET /api/v1/legislacoes` alimenta o portal de transparência do SIM. |
| Sitemap / busca do portal | Consumem `contents` genérico — afetam este módulo indiretamente. |

## 3. Armadilhas técnicas

### 3.1 SQL montado por concatenação

| Local | Código | Risco |
|---|---|---|
| `v6/app/Http/Controllers/Portal/LegislationController.php:56` | `... and extract(year from publication_date) = {$_GET['ano']} ...` | **Injeção de SQL direta** — `$_GET` cru, sem binding nem cast. |
| `v6/app/Http/Controllers/Portal/LegislationController.php:75-76` | `whereRaw("translate(lower(title), ...) ilike '%" . $newReq . "%'")` | `$newReq = clean_slug($reqTexto)` → protegido por accidento (ver 3.4), mas concatenado. |
| `v6/app/Http/Controllers/Portal/LegislationController.php:127-128` | idem, com `$newTexto` | idem, em `export()`. |
| `v6/app/Http/Controllers/Portal/LegislationController.php:203-204` | `... ilike '%" . $request->termo . "%'` | **`$request->termo` NÃO passa por `clean_slug`** → injeção de SQL. |
| `v6/app/Http/Controllers/Portal/LegislationController.php:41` | `DB::select("select extract(year from publication_date) ... where ... <= {$date}")` | `$date = date('Y')` — interno, sem risco. |
| `v6/app/Http/Controllers/Swagger/V1/Portal/Controller.php:108-110` | `return "lower(TRANSLATE($column, ...)) like '%$toLowerChars%'"` | `$search` entra via `htmlentities` + replace de entidades; concatenado no `whereRaw` do controller da API. |

### 3.2 `whereRaw` com `translate()` — dependência de PostgreSQL

O padrão de busca do módulo é:

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

Consequências para a v7:

- É **PostgreSQL-específico** (`translate()` não existe em MySQL/SQLite).
- O `ilike` com `%` impede o uso de índices (full scan + `lower()`/`translate()`
  por linha).
- A string de tradução é **posicional**: os dois conjuntos de caracteres devem
  ter exatamente o mesmo comprimento. A primeira posição é o espaço (mapeado
  para `-`), o que também **substitui espaços por hífen** dentro do termo
  buscado — logo "lei municipal" vira "lei-municipal" e só casa com textos que
  também tenham o hífen. É um bug de comportamento (não de segurança).
- A API usa um `translate` **diferente** (com a lista de vogais acentuadas
  completa) — os dois padrões de normalização não são equivalentes.

### 3.3 `CategoryContent::categoryContents()` com chaves trocadas

```php
// v6/app/Http/Model/Prefecture/CategoryContent.php:44-47
public function categoryContents()
{
    return $this->hasMany(CategoryContent::class, 'id', 'category_contents_id');
}

// v6/app/Http/Model/Prefecture/CategoryContent.php:49-52
public function rightWayToGetCategoryContents()
{
    return $this->hasMany(CategoryContent::class, 'category_contents_id', 'id');
}
```

`hasMany($related, $foreignKey, $localKey)`:

| Relação | SQL gerado | Significado |
|---|---|---|
| `categoryContents()` | `child.id = parent.category_contents_id` | Filhos **da categoria apontada pelo pai** (relação invertida). |
| `rightWayToGetCategoryContents()` | `child.category_contents_id = parent.id` | Filhos **diretos** do pai (relação correta). |

Efeito prático no módulo:

- `CategoryContent::whereHas('categoryContents', fn($q) => $q->where('slug','legislacao'))`
  resolve para "categorias cujo pai é a categoria `legislacao`" — que é o
  resultado **desejado**, mas por um caminho inverso e frágil. Se alguém
  corrigir as chaves da relação, essa consulta passa a devolver a própria raiz
  e o módulo quebra.
- Em uma **categoria folha** (ex.: `decretos`, id 24),
  `categoryContents()` devolve os **irmãos** (22, 23, 24) porque o pai é
  `legislacao`. `rightWayToGetCategoryContents()` devolve `[]`.
- Portanto **nunca** misture as duas relações: use `rightWayToGetCategoryContents`
  para navegar a árvore e `categoryContents` apenas nos `whereHas` "filhos de X"
  já existentes no legado.

### 3.4 `clean_slug()` é a única sanitização

`v6/app/Helpers/functions.php:823-848`:

```php
function clean_slug($string)
{
    $nova_string = preg_replace(
        array(
            "/(á|à|ã|â|ä)/", "/(Á|À|Ã|Â|Ä)/",
            "/(é|è|ê|ë)/", "/(É|È|Ê|Ë)/",
            "/(í|ì|î|ï)/", "/(Í|Ì|Î|Ï)/",
            "/(ó|ò|õ|ô|ö)/", "/(Ó|Ò|Õ|Ô|Ö)/",
            "/(ú|ù|û|ü)/", "/(Ú|Ù|Û|Ü)/",
            "/(ñ)/", "/(Ñ)/",
            "/(ç)/", "/(Ç)/",
            "/[^A-Za-z0-9. ]/"
        ),
        explode(" ", "a A e E i I o O u U n N c C"),
        $string
    );

    return $nova_string;
}
```

| Helper | O que faz | Uso no módulo |
|---|---|---|
| `clean_slug($s)` | Remove acentos (não remove o `ç`→`c`? **remove**: `ç`→`c`) e **apaga tudo que não for `[A-Za-z0-9. ]`**. Deixa espaços e pontos. | Sanitização do termo de busca (`$newReq`, `$newTexto`). |
| `slug_fix($name)` | `clean_slug()` + troca espaços por `-` + colapsa `-` consecutivos. | **Não é usado** na legislação (é usado na geração de slugs de conteúdo, que usa `Str::slug`). |

Consequências:

- `clean_slug` **não remove o hífen**, não ordena e **não** é um slug: é um
  "sanitizador". Ele remove as aspas simples, o que — **por acaso** — neutraliza
  a injeção de SQL no `whereRaw`. Não confie nisso: o ponto de entrada
  `filtroGET` **não** passa `termo` por `clean_slug`.
- `clean_slug` mantém o **espaço**, e o `translate()` do SQL converte espaço em
  `-`; logo o termo buscado fica com hífen enquanto o título tem espaço →
  **o filtro por texto não encontra a palavra** (a não ser que o título também
  use hífen). Verificar em ambiente com dados reais.

### 3.5 `getAllDescendants()` — recursivo, sem cache e com N+1

```php
// v6/app/Http/Model/Prefecture/CategoryContent.php:54-74
public function getAllDescendants($withParents = false)
{
    $sections = new Collection();

    foreach ($this->rightWayToGetCategoryContents as $section) {
        $data = $section->only(['id', 'name', 'order', 'slug', 'category_contents_id']);

        if ($withParents) {
            $parent = CategoryContent::find($section->category_contents_id);

            if ($parent && $parent->slug !== 'publicacoes' && $parent->slug !== 'legislacao') {
                $data['name'] = $parent->name . ' > ' . $data['name'];
            }
        }

        $sections->push($data);
        $sections = $sections->merge($section->getAllDescendants($withParents));
    }
    return $sections;
}
```

| Armadilha | Detalhe |
|---|---|
| N+1 | Uma query de `rightWayToGetCategoryContents` por categoria, em cada nível. Para a árvore atual (2 níveis) são 2 queries; a cada novo nível, +1. |
| Devolve **arrays** | `$section->only([...])` → não são modelos. Por isso `data_get($getAllDescendants->where('slug', $x)->first(), 'id')` é usado em vez de `->id`. |
| Não inclui a raiz | A coleção começa pelos filhos. |
| `$withParents = true` | Prefixa o nome do pai, **exceto** para os pais `publicacoes` e `legislacao` (linhas 64-66) — por isso o seletor do CMS mostra "Constituição Federal" sem prefixo. |
| `scopeAllDescendants()` | `with(['rightWayToGetCategoryContents' => fn($q) => $q->allDescendants()])` — eager load recursivo; o `fn` re-aplica o scope, o que aprofunda ~3 níveis e **multiplica** o número de queries. |

### 3.6 Consultas sem limite

| Local | Consulta | Problema |
|---|---|---|
| `v6/app/Http/Controllers/Prefecture/DatatableController.php:104` | `Content::whereIn('category_contents_id', $getAllDescendants)->orderByDesc('publication_date')->get()` | Carrega **toda** a legislação em memória; o Yajra fatia depois (sem `LIMIT` no SQL). |
| `v6/app/Http/Controllers/Prefecture/DatatableController.php:111` | `CategoryContent::find(data_get($item, 'category_contents_id'))` dentro do `each()` | **N+1**: uma query por linha da grade. |
| `v6/app/Http/Controllers/Portal/LegislationController.php:179` | `Content::where('category_contents_id', $subcategoria_id)->get([...])` | Carrega a subcategoria inteira para filtrar em memória. |
| `v6/app/Http/Controllers/Portal/LegislationController.php:197` | `Content::whereIn('id', array_unique($result->all()))->...->get()` | Segunda carga completa, sem limite. |
| `v6/app/Http/Controllers/Portal/LegislationController.php:132` | `$query->...->get()` (export) | Exporta tudo em memória; o PDF e o XLSX geram o arquivo inteiro. |
| `v6/app/Http/Controllers/Portal/LegislationController.php:43-45` | `->paginate(5)` | **Único** ponto do módulo com paginação no SQL — e mesmo assim o resultado é ignorado pelas views de `modelo1..modelo6`. |

### 3.7 Dupla paginação

- Servidor: `paginate(5)` em `contentShow` (linhas 43-45 e 81).
- Cliente: `data.chunk(10)` em `legislacao_portal.js` (linha 33).

As views de `modelo1..modelo6` **não** renderizam `$contents`; só `default` e
`newsbit` renderizam — e são inalcançáveis. Resultado: o `paginate(5)` é
trabalho jogado fora em toda visita ao portal de legislação, e a listagem real
ignora qualquer paginação de servidor.

### 3.8 `isset($request->termo)` nunca é verdadeiro para `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));
}
```

Logo, `isset($request->termo)` é **falso** quando o valor é `null` (JSON
`{"termo": null}`), e **verdadeiro** quando é `""` (string vazia) — caso em que
o `whereRaw` vira `ilike '%%'`, que casa com tudo.

### 3.9 Filtros de data que se sobrescrevem

```php
// v6/app/Http/Controllers/Portal/LegislationController.php:183-196
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');


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

    if ($request->data_inicial == $request->data_final) {
        $result = $fill->filter(function ($item) use ($request) {
            return $item->publication_date->toDateString() === $request->data_inicial;
        })->pluck('id');
    }
}
```

Cada `if` **substitui** `$result` (em vez de compor). Com os dois parâmetros
informados, o terceiro bloco corrige — mas apenas porque reescreve a coleção
inteira. Se `data_inicial` vier e `data_final` não, o filtro é só o inicial
(correto). Se vierem os dois, o terceiro bloco é o que vale. O problema real é
o **silêncio**: o filtro de `data_inicial` é descartado no caminho do segundo
`if` e só é recuperado por acidente no terceiro.

### 3.10 Parâmetro `texto` ignorado

- `contentShow` (filtro do servidor) lê `$request->texto`.
- `filtroGET` (filtro do JS) lê **apenas** `$request->termo`.
- O formulário do portal envia `termo` (`name="termo"`, id `Search_termo`).
- A exportação aceita `texto` **ou** `termo` (`$request->filled('texto') ? $request->texto : $request->termo`).

Resultado: o parâmetro `texto` é aceito pela exportação e pelo `contentShow`,
mas **ignorado** pelo endpoint que a interface realmente usa.

### 3.11 Outras armadilhas

| Armadilha | Detalhe |
|---|---|
| `Route::getCurrentRoute()->getName()` dentro do FormRequest | `v6/app/Http/Requests/ContentRequest.php:281` — a regra do `slug` depende do **nome** da rota, não do método HTTP. |
| `Route::` dentro de controller | `v6/app/Http/Controllers/Prefecture/ContentController.php:873` (`route("listar-conteudo", $slug)`) — redirect acoplado ao nome de rota. |
| `str_slug()` (helper removido) | `v6/app/Http/Controllers/Prefecture/CategoryContentController.php:851` usa `str_slug($request->get('name'))` — helper do pacote `laravel/helpers`; se a dependência cair, o CMS de categorias quebra. |
| Slug de categoria colide | `apiStore` prefixa `cat<maxId>_` quando o slug já existe — `v6/app/Http/Controllers/Prefecture/CategoryContentController.php:860-866`. |
| `Content::where('slug', $slug2)->first()` sem `status` | `contentView` não filtra `status = true`; uma norma despublicada continua acessível pela URL direta. |
| `view('portal.layouts.' . $this->getTemplateActived() . '.legislacao.list')` | Montagem de nome de view dinâmica — fragile em relação ao novo motor de views do Laravel 11/12. |
| `{!! $content->text !!}` | HTML sem escape no portal (conteúdo vem do Summernote do CMS → XSS armazenado por usuário com `legislacao.create`). |
| `contents.clicks` manual | `$content->update(['clicks' => $clicks])` — leitura + escrita, sujeito a lost update sob concorrência. |

## 4. Código duplicado / copiado

| Trecho | Origem |
|---|---|
| `LegislationController@export` | Cópia quase literal de `PublicationController@export` (`v6/app/Http/Controllers/Portal/PublicationController.php:115-150`), com `data_inicial`/`data_final` acrescentados e `texto`/`termo` combinados. |
| `contentShow` / `contentView` / `filtroGET` | Praticamente idênticos aos de `PublicationController` (módulos gêmeos). |
| `leg-nav.blade.php` de `modelo3` e `modelo4` | Blocos idênticos (44 linhas cada), com CSS duplicado. |
| `list.blade.php` de `modelo1..6` | Mesma estrutura de filtro, com CSS inline repetido. |
| `legislacao_portal.js` × `publicacoes_portal.js` | Provável duplicação do padrão "POST filtro + chunk no cliente". |
| v5 × v6 | `LegislationController` é **byte a byte igual** exceto pelo método `export()` e pelas colunas selecionadas em `filtroGET` (`updated_at`, `created_at` acrescentados na v6). |

## 5. Onde a v6 divergiu da v5

| Item | v5 | v6 |
|---|---|---|
| `LegislationController@export` | inexistente | existe (linhas 94-140) |
| Rota `legislacao.export` | inexistente | `v6/routes/web.php:129` |
| `ExportsContentTrait`, `ContentsExport`, `exports/contents.blade.php` | inexistentes | existem |
| Filtros `data_inicial` / `data_final` na exportação | inexistentes | existem |
| `texto` **ou** `termo` na exportação | — | `$request->filled('texto') ? $request->texto : $request->termo` |
| Colunas em `filtroGET` | `title, publication_date, slug, id, status, text` | `+ updated_at, created_at` (necessário para o "Atualizado em" do JS) |
| View SQL `legislacao` | sem `categoria_slug`/`slug` | com `categoria_slug`/`slug` (`v6/database/migrations/2024_11_29_084625_update_view_legislacao.php`) |
| `partials/link-row.blade.php` (normas vinculadas) | inexistente | existe, **órfã** (model `LegislationLink` inexistente) |
| Rotas, controllers do CMS, views do portal, JS | idênticos | idênticos |

> **Divergência de view SQL:** a v5 **não** possui
> `v6/database/migrations/2024_11_29_084625_update_view_legislacao.php`. 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`) — verificado por `grep`: nenhuma ocorrência de
> "legislacao" no arquivo. Portanto, na v5 a view `legislacao` tem apenas
> `value, titulo, data_publicacao, categoria, id`.
