# Comitê de Investimentos — Regras de Negócio e Portabilidade

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

| # | Regra | Onde | Consequência para o usuário |
|---|---|---|---|
| R1 | A categoria-pai do comitê é a linha de `category_contents` com `slug = 'comite-de-investimentos'` **e** `category_contents_id IS NULL` | `v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:27` | Só uma categoria-pai é aceita; duplicatas quebram o módulo |
| R2 | Subcategorias do comitê são as linhas com `category_contents_id = id da categoria-pai` | `v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:33` | A ordenação exibida é por `order ASC` |
| R3 | `/comite-de-investimentos` redireciona para a subcategoria de **menor `order`** | `v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:15-22` | A "página inicial" do comitê depende da ordenação manual |
| R4 | O portal só mostra atas com `publication_date <= agora` | `v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:41` | Atas agendadas ficam invisíveis no portal |
| R5 | Ao filtrar **sem informar mês**, o controller impõe `publication_date <= now` | `v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:75-77` | Filtrar só por ano **não** revela atas futuras (comportamento diferente do filtro por mês) |
| R6 | O filtro de texto usa `ilike` sobre `title` **e** `text`, com `translate()` normalizando acentos | `v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:82-89` | "COMITE" encontra "Comitê"; a busca varre o corpo do documento |
| R7 | O filtro de texto é aplicado sobre o texto **já normalizado** por `clean_slug()`, que remove acentos e caracteres especiais | `v6/app/Helpers/functions.php:823` | Caracteres como `'` e `%` desaparecem da busca |
| R8 | A paginação é fixa em **12 itens** | `v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:43` | Não há opção de tamanho de página |
| R9 | Cada abertura de ata incrementa `contents.clicks` em 1 | `v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:126-127` | O contador é usado em relatórios de acesso |
| R10 | A listagem de meses só é calculada quando o filtro de **ano** está presente | `v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:54-60` | Ao abrir a página sem ano, o seletor de mês fica vazio |
| R11 | Os anos listados só incluem os que **já têm** conteúdo publicado na categoria | `v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:45` | Anos sem conteúdo não aparecem |
| R12 | No seletor de ano, anos **futuros** são ocultos pela view (`$ano->ano <= date('Y')`) | `v6/resources/views/portal/layouts/modelo1/comite-de-investimentos/list.blade.php` | Filtros de ano futuro são impossíveis pela UI |
| R13 | A view SQL do comitê **não** filtra `publication_date <= now()` | migration de out/2024 | O CMS lista atas futuras; o portal não |
| R14 | O botão "Excluir" da listagem do CMS só aparece com `has_access('comite-de-investimentos.delete')`; `super-admin` passa sempre | `v6/app/Http/Controllers/Prefecture/DatatableController.php:504-519` | Papéis sem permissão veem a listagem sem botões |

## 2. Acoplamentos

### 2.1 Dependências do módulo

| Depende de | Tipo | Onde |
|---|---|---|
| `contents` + `category_contents` | Dados | Todo o módulo |
| `archives` | Dados | Anexos da ata (`highlight`, `view_pdf`) |
| `publicacoes` (módulo) | Código/UX | Redirect em `show` e partial AJAX em `publicacoes.list` |
| `modulos` + `menu_orders` | Configuração | Habilitação do menu no CMS |
| `resources` | Permissões | `resources.modulo_id = 48` |
| `configurations.layout` | Configuração | Escolha do tema do portal |
| `Configuration`/`getTemplateActived()` | Código | `v6/app/Http/Traits/GetInformationsPrefectureTrait.php:92` |

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

| Módulo | Depende do comitê como |
|---|---|
| `ContentController` (núcleo) | O ramo `comite-de-investimentos` do `switch` (linhas 113 e 226) é acionado por qualquer conteúdo com essa slug |
| `CategoryContentController` | Idem, `case 'comite-de-investimentos'` (linhas 275 e 388) |
| `MenuServiceProvider` | `case 'comite-de-investimentos'` (linha 148) cria o item de menu |
| `CheckRoutePermission` | Resolve a permissão pelo prefixo do slug da rota |

## 3. Armadilhas e riscos reais

### 3.1 CRÍTICO — SQL injection por interpolação de `$_GET['ano']`

`v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:57`:

```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() and deleted_at IS NULL GROUP BY mes order by extract(month from publication_date) asc;");
```

- O valor vem **direto de `$_GET`**, sem validação, sem escape e sem *binding*.
- É montado dentro de `if ($request->all())` e `if ($request->ano)`, mas o valor
  interpolado é lido do array superglobal, ignorando o objeto `Request`.
- Um payload como `?ano=1) UNION SELECT null,null,null --` é concatenado literalmente no
  SQL.
- Como a rota é **pública** (sem autenticação nem `check.route.permission`), o
  vetor é explorável por qualquer visitante anônimo.
- **Correção na v7:** usar `DB::select('select extract(month from publication_date) as mes from contents where extract(year from publication_date) = ?', [$request->ano])` ou, melhor, `Content::selectRaw('extract(month from publication_date) as mes')->whereYear('publication_date', $ano)->groupBy('mes')`.

### 3.2 CRÍTICO — SQL injection no filtro de texto (`whereRaw` concatenado)

`v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:84-87`:

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

- `clean_slug()` remove acentos e caracteres especiais via a regex
  `"/[^A-Za-z0-9. ]/"` (`v6/app/Helpers/functions.php:841`) — **aspas simples
  (`'`) são removidas**, o que mitiga o vetor clássico de escape de string.
- Porém: (a) o `orWhereRaw` está dentro de um `where(function ($query) { })`,
  então o agrupamento é correto; (b) o filtro `ilike` **não escapa** `%` e `_`,
  permitindo que o usuário use curingas e liste tudo.
- **Risco residual:** se `clean_slug` mudar, o `whereRaw` volta a ser
  injetável. **Correção na v7:** *bindings* (`whereRaw('translate(lower(title)) ilike ?', ['%'.$termo.'%'])`).

### 3.3 ALTO — `DB::select` cru para montar os anos

`v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:45`:

```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;");
```

- `$category->id` é um inteiro vindo do banco (seguro), mas a concatenação
  manual é frágil: um `whereRaw` com *bindings* elimina o padrão.
- O `GROUP BY ano` usa o alias do `SELECT` — aceito no PostgreSQL, mas não
  portável para MySQL/SQLite.

### 3.4 ALTO — Partial AJAX renderizado a partir de Publicações

`v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:100-113`:
a resposta AJAX é `view("portal.layouts.{layout}.publicacoes.list")`, enquanto o
carregamento inicial é `view("portal.layouts.{layout}.comite-de-investimentos.show")`,
que inclui `comite-de-investimentos.list`.

Consequências:
- O HTML muda ao filtrar (dois templates distintos para a mesma lista).
- As variáveis passadas ao AJAX include `text`, `mesSelecionado`, `title`,
  `meses`, `contents`, `categories`, `category`, `anos`, `anoSelecionado`,
  `mesSelecionado`; a view de Publicações pode esperar outras variáveis.
- **Correção na v7:** renderizar sempre `comite-de-investimentos.list`.

### 3.5 MÉDIO — Erro 500 quando não há categoria/subcategoria

- `index()` (linha 15-22) usa `->first()->slug` sem verificar nulo: sem
  subcategorias, `null->slug` → **Error 500**.
- `show()` (linha 27) usa `->first()->id` sem verificar nulo: sem categoria-pai,
  **Error 500**.
- `show()` (linha 35-37) usa `if (empty($category) && !isset($category))`: a
  condição é redundante (`empty` já cobre `null`); o comportamento correto
  (redirect para `/publicacoes`) acontece, mas o teste é confuso.

### 3.6 MÉDIO — `view()` não filtra por categoria nem por `deleted_at`

`v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:119`:

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

- O slug é **único apenas por convenção**: a busca é global. Uma ata de outro
  módulo com o mesmo slug é acessível (e contabilizada) pelo comitê.
- Conteúdos com `deleted_at` preenchido continuam acessíveis pela URL.
- Se o conteúdo não existir, `$content->publication_date` em
  `Content::where('slug',$slug2)->first()` → **Error 500** (acesso a propriedade de
  `null`).
- **Correção na v7:** `Content::where('slug',$slug2)->whereHas('categoryContents', fn($q) => $q->where('slug','comite-de-investimentos'))->whereNull('deleted_at')->firstOrFail()`.

### 3.7 MÉDIO — Consulta dentro de loop (N+1 potencial)

`v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:41-43`
seleciona apenas `title`, `text`, `publication_date`, `slug`,
`category_contents_id` e **não** usa `with('categoryContents')`; já o bloco de
filtros (linha 91) aplica `->with('categoryContents')`. A view `view.blade.php`
usa `$content->categoryContents->name` — na listagem isso dispara uma query por
item caso a view renderizada use a relação.

### 3.8 MÉDIO — Divergência entre validação e formulário

- Formulário: `title` com `maxlen=150` e observação "Máximo de 150 caracteres".
- `ContentRequest` (ramo `default`): `title` → `required|max:120`.
- `publication_date` e `text` são marcados como obrigatórios no formulário mas
  **não** possuem regra no `ContentRequest`; o controller grava `publication_date`
  como `null` se ausente, o que faz a ata **nunca** aparecer no portal (o filtro
  `publication_date <= now` não casa com `NULL`).

### 3.9 BAIXO — Código inerte e código copiado

- `v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:29-31`:
  `CategoryContent::whereHas('categoryContents', function ($query) { });` — closure
  vazia; consulta sem efeito, executada a cada requisição.
- `v6/app/Http/Controllers/Prefecture/CategoryContentController.php:282-284`: a
  mensagem de log diz "Acessou criação de categoria de **Requerimentos**" dentro
  do `case 'comite-de-investimentos'`.
- `v6/resources/views/prefecture/contents/comite-de-investimentos/form.blade.php`
  carrega `assets/js/custom/contents/noticias/edit.js` (JS de Notícias).
- `v6/resources/views/prefecture/category-contents/comite-de-investimentos/create.blade.php`
  exibe o título "Categorias de Comitê de **Invertimentos**" (typo).

### 3.10 BAIXO — Incremento de cliques sem transação

`$content->update(['clicks' => $content->clicks + 1])` faz leitura + gravação
sem `lockForUpdate`; em concorrência, cliques podem se perder.

### 3.11 BAIXO — `row_number()` incoerente com o `ORDER BY`

Na view SQL, `row_number() OVER (ORDER BY c.publication_date asc)` numera em
ordem crescente, mas o `ORDER BY` final é `publication_date desc`. A coluna
`value` exibida no DataTable não segue a ordem visual das linhas.

### 3.12 BAIXO — `DROP VIEW` antes de `CREATE OR REPLACE`

`v6/database/migrations/2024_11_29_103842_update_view_comite_de_investimentos.php`
executa `DROP VIEW if exists` e depois `CREATE OR REPLACE`. No PostgreSQL o
`CREATE OR REPLACE` já é suficiente; o `DROP` é redundante e pode quebrar
dependências (views sobre views) e revogar concessões.

## 4. Código duplicado / copiado

| Trecho | Origem da cópia |
|---|---|
| `form.blade.php` do comitê (Summernote, `add_archive.js`, `noticias/edit.js`) | Formulário de Notícias |
| `case 'comite-de-investimentos'` em `CategoryContentController` | Case de Requerimentos |
| `case 'comite-de-investimentos'` em `ContentController@genericGenerate` | Case de Requerimentos / Atas e Pontos |
| Mensagem de log "Acessou criação de categoria de Requerimentos" | Case de Requerimentos |
| Item de menu com ícone `balance-scale` | Mesmo ícone de "Legislação" (`v6/app/Providers/MenuServiceProvider.php:238`) |

## 5. Onde a v6 divergiu da v5

**Não divergiu: o módulo não existe na v5.** Verificação: `find v5 -iname
'*comite*'` não retorna nenhum arquivo e `grep -rli 'comite' v5/app v5/resources
v5/routes v5/database` não retorna nenhuma ocorrência. Não há rota, controller,
view, migration, seed ou registro de módulo para comitê de investimentos na v5.

| Aspecto | v5 | v6 |
|---|---|---|
| Rotas do portal | inexistentes | `v6/routes/web.php:219-221` |
| Controller do portal | inexistente | `v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php` |
| Views do portal | inexistentes | `v6/resources/views/portal/layouts/modelo1..modelo6/comite-de-investimentos/` |
| Views do CMS | inexistentes | `v6/resources/views/prefecture/contents/comite-de-investimentos/`, `v6/resources/views/prefecture/category-contents/comite-de-investimentos/` |
| View SQL | inexistente | criada em out/2024, ampliada em nov/2024 |
| Registro em `modulos` | inexistente | id 48 |
| Entrada no menu do CMS | inexistente | `v6/app/Providers/MenuServiceProvider.php:148` |

## 6. Dependências de terceiros no `composer.json`

| Pacote | Uso |
|---|---|
| `laravel/framework` | Core |
| `yajra/laravel-datatables-oracle` | DataTable do CMS |
| `cartalyst/sentinel` | Autenticação e permissões |
| `laracasts/presenter` | `ContentPresenter` / `CategoryContentPresenter` |
| `nesbot/carbon` | Datas |
| `intervention/image` | Imagens do fluxo de conteúdo |

Ver `v6/composer.json` para as versões exatas.
