# A Cidade — Regras de Negócio e Portabilidade

> Versão analisada: `v6`. Cada afirmação aponta para arquivo/linha do repositório.

---

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

### R-1 — A categoria é a âncora; o slug do conteúdo é a chave da URL

O módulo é identificado por `category_contents.slug = 'a-cidade'` **na view SQL**,
mas **na rota pública** o filtro é `contents.slug = {slug}`. São duas chaves
diferentes e a segunda não é validada contra a primeira.

| Superfície | Filtro aplicado |
|---|---|
| View SQL `a_cidade` | `cc.slug = 'a-cidade'` **e** `c.deleted_at IS NULL` |
| Rota `/a-cidade/{slug}` | **apenas** `contents.slug = {slug}` |

Consequência observável: `/a-cidade/noticias` serve a notícia `noticias`, com o
corpo, a imagem de destaque e o vídeo da notícia — mas dentro do template de A
Cidade, e contando clique na tabela `contents`.

### R-2 — `contents.status` não é filtrado em lugar nenhum do módulo

Nem a rota, nem a view SQL, nem o `Content::with(...)` verificam `status`. Um
rascunho (`status = false`) é servido normalmente na URL pública. Na base de
desenvolvimento, os 4 conteúdos de A Cidade têm `status = NULL`.

### R-3 — O soft delete é respeitado só na view SQL

`Content` usa `SoftDeletes` (`v6/app/Http/Model/Prefecture/Content.php:15`), e
`getContentDataWith()` é uma query Eloquent — logo um conteúdo excluído **não**
é encontrado pela rota. Já a listagem do CMS usa `DB::table('a_cidade')`, que
ignora o escopo de soft delete; a view SQL compensa com `deleted_at IS NULL`.

### R-4 — O layout ativo é resolvido em runtime, e um layout inválido derruba o portal

`getTemplateActived()` lê `configurations.key = 'layout'`. Se a view
`portal.layouts.<layout>.home.index` não existir, o método:

1. executa `UPDATE configurations SET value = 1 WHERE key = 'pagina_em_manutencao'`;
2. chama `abort(404, 'view_inexistente')`.

Ou seja, **um erro de configuração de layout coloca o portal inteiro em
manutenção**. Isso vale para A Cidade, mas o gatilho não é a view do módulo —
é a view `home/index` do layout. `default` e `newsbit` não a têm.

### R-5 — O contador de cliques é escrito em duas queries

```php
// v6/app/Http/Traits/GetInformationsPrefectureTrait.php:104-108
public function updateClicks($idContent)
{
    $content = Content::where('id', $idContent)->first();
    Content::where('id', $content->id)->update(['clicks' => $content->clicks + 1]);
}
```

Não é atômico (read-modify-write sem `WHERE`/`RETURNING`), e não há
distinção entre visita única e reloading. Um crawler multiplica o contador.

### R-6 — O vídeo é um `linksUnique`, e `embedVideo()` decide o player

O formulário tem **um** campo de link, salvo em `links.contents_id`. A view
mostra o vídeo se `linksUnique->link` existir. `embedVideo()`
(`v6/app/Helpers/functions.php:607`) converte a URL em URL de embed por
*sniffing* do domínio: YouTube, Vimeo, Facebook e Instagram. Qualquer outro
domínio é devolvido sem conversão — e a view sempre renderiza um `<iframe>`,
o que para um link comum gera um iframe em branco dentro do portal.

### R-7 — O slug é gerado pelo servidor e pode ser reescrito na edição

- No `store()`, o slug vem de `slugfy($title)` = `Str::slug($title)`; se já
  existir um conteúdo com `str_slug($title)`, ganha o sufixo `-<max(id)+1>`.
- Na edição, o campo `slug` é editável (com validação `max:200` apenas no
  `case 'noticias'`, **não** no `case 'a-cidade'`).

Não há índice único em `contents.slug`: duas criações simultâneas com o mesmo
título podem gerar slugs duplicados.

### R-8 — A imagem de destaque só é renderizada se o arquivo existir no disco

`file_exist()` (`v6/app/Helpers/functions.php:267`) chama `file_exists($path)`
com o path **relativo** gravado em `archives.path`
(`storage/content/a-cidade/<id>/destaque/<nome>`). O `file_exists()` resolve
relativamente ao *cwd* do processo PHP, que não é garantido ser `public/`.
Resultado: na maioria das instalações a imagem de destaque **não aparece**.
O mesmo problema atinge os 6 layouts (`modelo1..modelo6`), pois todos usam
`@if(file_exist($contentNew->archiveHighlight->path))`.

### R-9 — A galeria não distingue imagens de documentos

`archiveGallery()` é `hasMany(Archive)->byHighLight(null)` — sem filtro de
`extension`. O `ContentController@returnData` filtra `'extension', '<>', 'pdf'`
na tela de edição (`:290`), mas a página pública não. Um PDF na galeria vira
`<li>` de imagem quebrada no lightGallery.

### R-10 — `menuClicks()` mistura navegação administrativa com uso do portal

`menuClicks($slug)` (`v6/app/Helpers/functions.php:865`) incrementa
`menu_orders.clicks` para o slug `a-cidade`. É chamado em `returnIndex()` e
`returnData()` — ou seja, **quando o operador abre a tela do CMS**, não quando
um visitante lê a página. O contador de acessos do módulo no painel é, na
verdade, um contador de visitas ao CMS.

### R-11 — A tela do CMS pede a permissão do módulo errado para a exclusão em lote

```blade
{{-- v6/resources/views/prefecture/contents/a-cidade/index.blade.php:15 --}}
@if (has_access(['noticias.delete']))
```

O botão "Excluir Registro(s)" aparece conforme `noticias.delete`, e não
`a-cidade.delete`. Um operador com acesso a A Cidade mas sem acesso a Notícias
não vê o botão; um operador com acesso a Notícias vê o botão de A Cidade.

## 2. Acoplamentos

### 2.1 Dependências do módulo

| Depende de | Onde | Natureza |
|---|---|---|
| `Prefecture\ContentController` | CRUD | ** forte** — toda a escrita passa pelo controller genérico |
| `Prefecture\DatatableController` | Listagem | Forte — `DB::table('a_cidade')` |
| `GetInformationsPrefectureTrait` | Consulta, layout, cliques | Forte — herança de trait |
| `Admin\Controller` | Base do controller do portal | Média |
| `Content`, `CategoryContent`, `Archive`, `Link` | Modelos | Forte |
| `embedVideo()`, `normalizeVideoLink()` | `v6/app/Helpers/functions.php` | Forte — funções globais, sem injeção |
| `has_access()` | `v6/app/Library/Helper/appHelpers.php:22` | Forte — permissão por string |
| `menuClicks()` | `v6/app/Helpers/functions.php:865` | Média |
| `getLargFile()`, `getMinFile()` | `v6/app/Helpers/functions.php:977,998` | Média |
| `Breadcrumbs::view()` | `v6/routes/breadcrumbs.php:266` | Média |
| `configurations` (chave `layout`) | `getTemplateActived()` | Forte — configuração global |
| `menu_orders`, `resources`, `modulos` | Menu do CMS e permissões | Média |

### 2.2 De quem A Cidade depende e quem depende de A Cidade

| Módulo | Relação |
|---|---|
| `../a-instituicao/` | Irmão. Mesma view SQL, mesmo `ContentController`, prefixo público `/a-instituicao`. |
| `../noticias/` | Compartilha `ContentController`, `ContentRequest` (case separado), `contents` e o botão de exclusão em lote (permissão `noticias.delete`). |
| `../orgaos/` | Compartilha o `case` de validação com `a-cidade` (`ContentRequest.php:205-214`) e a tabela `contents`. |
| `../secretarias/` | Compartilha `contents.secretaria` e a galeria. |
| `../_00_panorama/` | Autenticação, menu do CMS, `modulos`. |

### 2.3 Acoplamento global: `contents` é uma tabela única para ~30 módulos

A Cidade não tem vínculo próprio. Tudo depende de `contents.category_contents_id`
apontar para `category_contents.id = 4`. Um `soft delete` na categoria **não**
apaga nem oculta os conteúdos — a view SQL passa a listá-los, porque o JOIN
continua válido e apenas `deleted_at` do conteúdo é filtrado.

## 3. Armadilhas técnicas

### A-1 — `str_slug()`, `array_where()`, `str_contains()`, `starts_with()`

Helpers deprecated/removed em versões posteriores do Laravel, ainda usados:

| Helper | Local | Situação em Laravel moderno |
|---|---|---|
| `str_slug()` | `v6/app/Http/Controllers/Prefecture/ContentController.php:665` | Deprecado no 5.8, **removido no 6.0**. |
| `array_where()` | `v6/app/Http/Middleware/CheckRoutePermission.php:75` | Removido no 6.0. |
| `str_contains()` | `ContentController.php:603`, `CheckRoutePermission.php:89-91` | Deprecado no 5.8, removido no 6.0. |
| `starts_with()` | `ContentController.php:603` | idem. |

Além disso, `Str::contains()` e `Str::startsWith()` **não** existem no Laravel
5.5 — as funções globais `str_contains`/`starts_with` foram adicionadas nessa
versão e o código as usa.

### A-2 — Query dentro de query em `struct_views()`

`struct_views()` (`DatatableController.php:504-520`) itera as linhas e, para
cada uma, chama `has_access()` **4 vezes** (`edit`, `delete`, `restore`,
`inactivate`). `has_access()` faz `Sentinel::check()` e `hasAnyAccess()` a cada
chamada. Com N conteúdos, são 4N verificações de permissão por requisição da
grade — tudo em memória, mas o custo cresce linearmente.

### A-3 — `DB::table()` em cima de view SQL dentro de uma view SQL

`get_a_cidade()` → `DB::table('a_cidade')->get()` → a view executa
`row_number() OVER (ORDER BY c.created_at DESC)` a cada requisição. O
`value` é recalculado: excluir um item renumera todos os seguintes. Não serve
como identificador, só como ordenação visual.

### A-4 — `row_number()` como "ID" da grade

O DataTable mostra a coluna `value` como se fosse o número do registro
(`runDatatables` faz `collums.unshift({data: 'value'})`). O número exibido não
é o `contents.id`.

### A-5 — Renderização de HTML sem sanitização

`{!! $contentNew->text !!}` (`modelo1/.../show.blade.php:20`) e
`{!! $text !!}` nos componentes. O conteúdo vem do Summernote do CMS — logo,
depende inteiramente da confiança no operador. Não há sanitização no servidor.

### A-6 — `Route::` e `Sentinel` dentro dos controllers

O `CheckRoutePermission` depende de `Route::getCurrentRoute()->getName()` e de
`Request::segment(3)`/`segment(4)` — o cálculo da permissão depende da
**posição do segmento na URL**. Mudar a profundidade da rota muda a permissão
exigida. O mesmo vale para `ContentRequest::rules()`, que decide as regras
pelo `category_contents_id` enviado no corpo, e não pela rota.

### A-7 — Erro silencioso no `embedVideo()`

`embedVideo()` devolve a URL original quando não reconhece o provedor
(`return $link; // fallback`). A view não distingue "vídeo" de "link comum" e
sempre cria um `<iframe>`.

### A-8 — Slug duplicado por colisão de `max(id)+1`

```php
// v6/app/Http/Controllers/Prefecture/ContentController.php:665-669
$verificaSlug = Content::where('slug', str_slug($request->get('title')))->get();
$maxid = Content::max('id') + 1;
if (count($verificaSlug) > 0) $slug = $slug . "-" . $maxid;
```

O sufixo é calculado a partir do `max(id)` **no momento da criação**; duas
criações simultâneas com o mesmo título produzem o mesmo sufixo.

### A-9 — `file_exist()` sobre caminho relativo

Ver R-8. O helper correto seria `file_exist_portal()` ou
`Storage::exists(public_path($path))`.

### A-10 — `use` não utilizados no controller do portal

`AboutController` importa `NewsTrait` e `Illuminate\Support\Facades\Route` sem
usá-los — vestígio do copy/paste do controller de notícias.

### A-11 — `include` com caminho de diretório antigo (regressão da v6)

`v6/resources/views/portal/layouts/modelo3/informacoes-instituicao/acidade/show.blade.php:24`
inclui `portal.layouts.modelo3.informacoes-prefeitura.galeria.index` — diretório
que não existe na v6 (renomeado para `informacoes-instituicao`). A página quebra
com HTTP 500 quando o conteúdo tem galeria. Os outros cinco layouts estão
corretos. O mesmo tipo de erro já existe em
`v6/resources/views/prefecture/contents/a-instituicao/form.blade.php:21,30`,
que aponta para o módulo A Cidade.

## 4. Código duplicado ou copiado

### D-1 — `AboutController` é cópia do `NewsController`

O docblock ("Classe criada com objetivo de disponibilizar a tela de notícias") e
o método `getIndex` são idênticos ao do controller de notícias, exceto pelo
nome da view. O mesmo código existe na v5 (`v5/app/Http/Controllers/Portal/AboutController.php`) —
**byte a byte idêntico**, exceto pelo nome do diretório de views.

### D-2 — As 6 view `show.blade.php` são quase idênticas

Diferem apenas no nome do layout e no include da galeria (ver
`04-VIEWS-E-UX.md` §2.3). O `modelo3` já divergiu: usa o literal `'a-cidade'`
no breadcrumb, enquanto os outros usam `$contentNew->categoryContents->slug`.

### D-3 — `getTemplateActived()` duplicado em dois lugares

`v6/app/Http/Traits/GetInformationsPrefectureTrait.php:92` (public) e
`v6/app/Http/Controllers/Admin/Controller.php:39` (protected) têm o **mesmo
corpo**. O método do trait vence o do pai em `AboutController`. Qualquer correção
no corpo precisa ser feita nos dois lugares.

### D-4 — `form.blade.php` de A Cidade e de A Instituição são idênticos

Comparando `v6/resources/views/prefecture/contents/a-cidade/form.blade.php` e
`v6/resources/views/prefecture/contents/a-instituicao/form.blade.php`: as
linhas 21 e 30 de ambos são **idênticas** — `{{ url('a-cidade') }}/` e
`route('a-cidade.index', $content->slug)`. A tela de edição de A Instituição
aponta para o módulo errado (ver `../a-instituicao/08-GAPS-E-DIVIDAS.md`).

### D-5 — O `case 'a-cidade'` do `ContentRequest` é compartilhado com `orgaos`

```php
// v6/app/Http/Requests/ContentRequest.php:205-214
case 'orgaos':
case 'a-cidade':
```

As regras são idênticas, mas as telas são diferentes (Órgãos tem galeria de
documentos, A Cidade tem vídeo). A diferenciação real acontece só na view.

## 5. Divergências v5 → v6 que afetam A Cidade

| Item | v5 | v6 | Impacto em A Cidade |
|---|---|---|---|
| Diretório de views do portal | `informacoes-prefeitura/acidade/` | `informacoes-instituicao/acidade/` | Quebra de qualquer referência externa à view antiga; URLs públicas inalteradas. |
| Componente do portal | `informacoes-prefeitura/components/components-acidade/` | `informacoes-instituicao/components/components-acidade/` | O nome `components-acidade` foi preservado. |
| Botão "Acessar" da grade | protegido por `if (data.slug)` | **sem** proteção | Com `slug` NULL, o link vira `/a-cidade/undefined`. |
| Ferramenta de compartilhamento | `addthis_inline_share_toolbox` | `addtoany` (5 botões) | Mudança visual e de dependência externa. |
| Coluna `slug` na view SQL | ausente | presente (`2024_11_29_094836`) | Necessária para o botão "Acessar". |
| Rota `/a-cidade/{slug}` | `v5/routes/web.php:76-78` | `v6/routes/web.php:85-87` | **Idêntica.** |
| `AboutController` | idêntico | idêntico | Só muda o nome do diretório de views. |
| `modulos` | tabela inexistente | `modulos.id = 13`, `status = 0` | **O módulo saiu do menu do CMS.** |
| `menu_orders` / `resources` | — | `slug = 'a-cidade'`, `modulo_id = 13` | Necessários para as permissões e para o menu. |

O prefixo público `a-prefeitura` **não** era de A Cidade — era de A Instituição.
A Cidade manteve o prefixo `a-cidade` desde a v5.

## 6. Checklist de portabilidade para a v7

| # | Item | Ação |
|---|---|---|
| 1 | Helpers deprecated | Trocar `str_slug` → `Str::slug`, `array_where` → `Collection::where`, `str_contains` → `Str::contains`, `starts_with` → `Str::startsWith`. |
| 2 | Funções globais | `embedVideo`, `normalizeVideoLink`, `menuClicks`, `getLargFile`, `getMinFile`, `file_exist` → value objects / services injetados. |
| 3 | `has_access()` por string | → `Gate::allows()` / Policies. |
| 4 | Permissão por posição de segmento | → Policy por rota + resource, sem `Request::segment()`. |
| 5 | `updateClicks()` read-modify-write | → `increment()` atômico ou evento + tabela de métricas. |
| 6 | Filtro de categoria e status ausente | → `whereBelongsTo($category)->where('status', true)`. |
| 7 | View SQL `a_cidade` | → query builder + índice em `contents(category_contents_id, created_at)`. |
| 8 | 6 cópias da view | → 1 componente Blade parametrizado pelo layout. |
| 9 | `file_exist()` com path relativo | → `Storage::exists(public_path($path))`. |
| 10 | `{!! $text !!}` sem sanitização | → `{!! $content->textHtml !!}` com sanitização na escrita (HTML Purifier) ou `HtmlString` sanitizado. |
| 11 | `embedVideo()` por sniffing | → value object `VideoEmbed` com estrategia por provedor. |
| 12 | Erro 404 devolvido como 200 | → `abort(404)` / `firstOrFail()`. |
