# Publicações — Regras de Negócio e Portabilidade

## 1. Regras não óbvias

| # | Regra | Onde | Consequência observável |
|---|---|---|---|
| P1 | A raiz é a categoria 3 (`publicacoes`); o portal só aceita **subcategorias** | `PublicationController::showPublication` (`:28-113`) | `/publicacoes/publicacoes` (a própria raiz) redireciona para `/publicacoes` |
| P2 | `/publicacoes` redireciona para a subcategoria de **menor `order`** | `PublicationController::getIndex` (`:19-27`) | A "página inicial" de Publicações depende da ordenação das subcategorias |
| P3 | Documento com `publication_date` futura é ocultado **apenas quando não há filtro de mês** | `showPublication` (`when(empty($request->mes), ...)`) | Filtrar por mês sem ano pode revelar documentos futuros |
| P4 | O filtro de **mês** só tem efeito junto com o de **ano** | `showPublication` (`when($request->mes, ...)` sem `whereYear` correlato) | Buscar "mês = 3" sem ano devolve todos os meses |
| P5 | A busca textual é acento-insensível via `translate()` e cobre `title` **e** `text` | `showPublication` e `export` | Busca por "saude" encontra "saúde" |
| P6 | A exportação usa **a mesma** consulta da listagem, mas **sem** paginação | `PublicationController::export` (`:115-152`) | A exportação devolve o conjunto inteiro |
| P7 | A exportação **exige** `sub_category` válido; caso contrário `abort(404)` | `export` | Sem o parâmetro, a rota sempre devolve 404 |
| P8 | `viewPublication` busca o documento **apenas por `slug`**, ignorando a categoria | `viewPublication` (`:153-171`) | Slugs repetidos entre categorias abrem o documento errado |
| P9 | `viewPublication` **não** verifica `status` | `viewPublication` | Um documento em rascunho é acessível por URL direta |
| P10 | `viewPublication` incrementa `clicks` com read-modify-write | `viewPublication` | Lost update sob concorrência |
| P11 | O `layout` do documento decide a presença da barra lateral (`1` = com, `2` = sem; vazio = com) | `v6/resources/views/portal/layouts/modelo1/publicacoes/view.blade.php` | Documentos antigos sem `layout` exibem barra lateral |
| P12 | A subcategoria escolhida é enviada no formulário e validada por `SelectRule` | `ContentRequest.php:91-105` | Categoria inválida é rejeitada |
| P13 | Anexo só aceita `rar, zip, doc, docx, pdf, odt, xls, xlsx, xlsb, xslm, ppt, pptx` | `ContentRequest.php:42-63` | Erro amigável antes de gravar |
| P14 | Os anexos ficam em `storage/content/publicacoes/{subcategoria}/{id}/arquivos/` | `ContentController::store` (`:769-780`) | O path inclui o slug da subcategoria, não o da raiz |
| P15 | Ao excluir um documento, os `menu_items` vinculados são removidos | `ContentController::destroy` | O item de menu some do portal |
| P16 | A view SQL `publicacoes` **não** filtra `status` nem `publication_date` | `v6/database/migrations/2024_11_28_100323_update_view_publicacoes.php` | A view não é equivalente ao portal |

## 2. Acoplamentos

### Dependências

| Depende de | Tipo | Onde |
|---|---|---|
| `category_contents` (raiz 3) | dados | Descoberta da árvore de subcategorias |
| `archives` | dados | Anexos e PDF inline |
| `configurations` | configuração | `cor_1` (PDF), `layout` (template do portal) |
| `menu_items` | dados | Exclusão manual em cascata |
| `modulos` / `resources` | permissões | `publicacoes.create/edit/delete` |
| `contents` (mesma tabela) | dados | Compartilhada com Notícias, Legislação, Banners etc. |

### Dependentes

| Depende de | Onde |
|---|---|
| Menu do portal | item "Publicações" |
| Busca global | `v6/public/assets/js/custom/utilities/helpers/search.js` (navega para `publicacoes/{cat}/{slug}`) |
| API de dados abertos | `v6/app/Http/Controllers/Swagger/V1/Portal/PublicationController.php` |
| Dashboard (widget "Publicações") | `v6/tests/Feature/DashboardPublicacoesWidgetTest.php` |
| Módulo Legislação | mesma tabela e mesma view SQL, raiz diferente (2) |

## 3. Armadilhas

### 3.1 SQL interpolado sem bindings

Em `showPublication` e `export`:

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

O texto passa por `clean_slug()`
(`v6/app/Helpers/functions.php:823-847`), que remove tudo o que não for
`[A-Za-z0-9. ]`. Não há, portanto, injeção de SQL direta. Mas:

- não é possível usar prepared statements (o padrão `ilike '%texto%'` seria
  trivial com binding);
- o filtro é **muito** agressivo: `%`, `_`, `-` e `/` desaparecem, então
  buscas por "100%" ou por identificadores com hífen falham;
- o `translate()` aplicado sobre `text` (campo HTML longo) impede o uso de
  índice e torna a busca lenta em tabelas grandes.

Além disso, o mesmo padrão de SQL montado por concatenação aparece em:

```php
$anos = DB::select("select extract(year from publication_date) as ano from contents WHERE category_contents_id = {$category->id} AND deleted_at IS NULL GROUP BY ano order by ano desc;");
$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() and deleted_at IS NULL GROUP BY mes ...");
```

O segundo usa `$_GET['ano']` **direto** na string SQL (apesar de o valor
vir de `$request->ano` no `Content`, o `$meses` usa `$_GET`).

### 3.2 `getAllDescendants()` recursivo em PHP

`CategoryContent::getAllDescendants()` (`v6/app/Http/Model/Prefecture/CategoryContent.php:56-77`)
percorre a árvore com N+1 consultas e **mistura tipos** na coleção
(`Model` e array de `only()`). É chamado em `showPublication`, em `export` e
em `DatatableController::get_publicacoes` — 3 vezes por operação.

### 3.3 Datatable do CMS carrega tudo em memória

`DatatableController::get_publicacoes` faz
`Content::whereIn('category_contents_id', $getAllDescendants)->get()` — sem
paginação no banco. Com milhares de documentos, a memória e o tempo de
resposta crescem linearmente. A view SQL `publicacoes` (que já existe e é
equivalente) **não é utilizada**.

### 3.4 `getIndex()` pode quebrar

```php
$categoryFirst = CategoryContent::whereHas('categoryContents', ...)->orderBy('order')->first();
return redirect()->route('publicacoes.show', $categoryFirst->slug);
```

Sem subcategorias cadastradas, `$categoryFirst` é `null` -> erro 500 em
`/publicacoes`.

### 3.5 `viewPublication` sem filtro de categoria nem de status

```php
$content = Content::where('slug', $slug2)->first();
if ($content->publication_date > $current_date) {
```

- `->first()` pode devolver `null` -> `null->publication_date` -> erro 500;
- `status` não é verificado (rascunho acessível);
- a categoria (`{slug1}`) é ignorada;
- `$content->publication_date` pode ser `null` -> comparação `null > $data`
  resulta em `false` (PHP) -> o documento é exibido.

### 3.6 `Route::get` (não `any`) no portal

O módulo Notícias usa `Route::any`; Publicações usa `Route::get`. Um
`POST /publicacoes/...` devolve 405.

### 3.7 `select()` incompleto na listagem

```php
->select('title', 'text', 'publication_date', 'slug', 'category_contents_id', 'updated_at', 'created_at')
```

O `id` **não** é selecionado. As views que usam `$content->id` (ex.:
chave de array, download do anexo) recebem `null`.

### 3.8 `with('categoryContents')` só na versão filtrada

A query sem filtros (primeira listagem) não aplica `with('categoryContents')`
— a view pode disparar N+1 ao acessar `$content->categoryContents->name`.

### 3.9 Duplicação com o módulo Legislação

`PublicationController` e `LegislationController`
(`v6/app/Http/Controllers/Portal/LegislationController.php`) implementam
quase o mesmo código com nomes diferentes (`showPublication`/`contentShow`,
`viewPublication`/`contentView`). `DatatableController` idem
(`get_publicacoes`/`get_legislacao`) — inclusive com a variável copiada
`$legislacaoContent` dentro de `get_publicacoes`.

### 3.10 Dependência do `whereRaw` no `ilike` com `translate`

A busca só funciona em PostgreSQL (função `translate`). Qualquer migração
para MySQL/SQLite exige reimplementação.

## 4. Código duplicado entre v5 e v6

| Trecho | v5 | v6 | Observação |
|---|---|---|---|
| `PublicationController` | `v5/app/Http/Controllers/Portal/PublicationController.php` | `v6/app/Http/Controllers/Portal/PublicationController.php` | A v6 **acrescentou** `export()` (+46 linhas no total) e trocou a lista de categorias por `CategoryContent::allDescendants()` |
| `DatatableController` | `v5/app/Http/Controllers/Prefecture/DatatableController.php` | `v6/app/Http/Controllers/Prefecture/DatatableController.php` | ~266 linhas de diferença no total do arquivo |
| `ContentController` | `v5/...` | `v6/...` | ~1400 linhas de diferença (acréscimos de sanitização, auditoria e `linha_fina`) |
| `ExportsContentTrait` | **inexistente na v5** | `v6/app/Http/Traits/ExportsContentTrait.php` | Novo na v6 |

## 5. Onde a v6 divergiu da v5

| Tema | v5 | v6 | Impacto |
|---|---|---|---|
| Exportação | Sem rota nem método | `GET /publicacoes/export` + `ExportsContentTrait` + `ContentsExport` + view `exports.contents` | Nova capacidade (XLS/PDF/CSV/TXT) |
| Lista de categorias do portal | `$publication_root->rightWayToGetCategoryContents` (apenas filhos diretos) | `CategoryContent::allDescendants()` (descendentes) | Suporta subcategorias de 2º nível |
| Colunas da listagem | `title, text, publication_date, slug, category_contents_id` | `+ updated_at, created_at` | Mais dados disponíveis na view |
| Recuo do controller de conteúdo | — | `sanitizeLink()`, `linha_fina`, `view_pdf`, `pathUpdate` | Ver `../noticias/08-GAPS-E-DIVIDAS.md` |

## 6. Recomendações de portabilidade

1. Trocar `whereRaw` por bindings: `whereRaw("translate(lower(title), ...) ilike ?", ["%$novo%"])`.
2. Substituir `$_GET['ano']` por `$request->ano` com binding.
3. Usar a view SQL `publicacoes` (ou uma query paginada equivalente) na
   datatable do CMS.
4. Resolver a árvore de categorias com uma query recursiva (CTE) ou uma
   coluna `path`/`parent_id` materializada.
5. Validar `status = true` e a categoria em `viewPublication`.
6. Usar `increment('clicks')` em vez de read-modify-write.
7. Unificar `PublicationController` e `LegislationController` em um
   controller parametrizado pela raiz da categoria.
8. Criar índice em `contents(category_contents_id, publication_date DESC)`.
