# Secretarias — Regras e Portabilidade

> Regras de negócio não óbvias, acoplamentos, armadilhas verificadas no código e
> riscos de performance. Este arquivo registra o legado como ele é — não propõe
> correções (ver `06-MIGRACAO-MODERNA.md`).

---

## 1. Regras de negócio

| # | Regra | Onde está | Observável |
|---|---|---|---|
| R1 | Secretaria = conteúdo da categoria 7 (`category_contents.id = 7`, `slug = 'secretarias'`); o `slug` vem do título com `Str::slug($title, '-')` | view `secretarias` (`v6/database/migrations/2024_11_29_085508_update_view_secretarias.php`); `v6/app/Http/Controllers/Prefecture/ContentController.php:584-587` | Conteúdo fora da categoria 7 não aparece na grade nem no menu; slugs em minúsculas com hífen |
| R2 | Colisão de slug: recebe `-<maxid+1>` | `v6/app/Http/Controllers/Prefecture/ContentController.php:665-667` | Não há unicidade no banco |
| R3 | O campo **Sigla** grava em `contents.acronym` | `v6/resources/views/prefecture/contents/secretarias/form.blade.php` | A sigla **não é exibida** em listagem nem no portal |
| R4 | Departamento (`categorias_secretarias`) pertence a **uma** secretaria (`secretarias_id`) | migration `2023_03_29_104129` + `SelectRule` | FK real no banco |
| R5 | Página (`paginas_secretarias`) pertence a **um** departamento (`categoria_id`) | migration `2023_03_29_104149` | FK real no banco |
| R6 | A ordenação do menu (`order`) é reescrita de 1..N a cada reordenação | `v6/app/Http/Controllers/Prefecture/MenuSecretaryController.php:38-49` | `paginas_secretarias.order` **nunca** é usada |
| R7 | O portal só mostra o menu se houver ≥ 1 departamento | `v6/resources/views/portal/components/secretarias/show.blade.php` (`isset($menus[0])`) | Sem departamentos, a coluna de conteúdo ocupa 100% |
| R8 | "Notícias de {secretaria}" usa `contents.secretaria` = **slug** da secretaria (não id) | `v6/app/Http/Model/Prefecture/Content.php:72-75` | Seleciona por `slug` |
| R9 | A listagem de notícias é limitada a **12** itens, ordenadas por `created_at` desc | `v6/resources/views/portal/components/secretarias/show.blade.php` (`->sortByDesc('created_at')->splice(0, 12)`) | Corte **em memória**, não no SQL |
| R10 | Notícias com `publication_date` futura são ocultadas | `v6/app/Http/Controllers/Portal/PrefectureController.php:53-54` | Filtro aplicado em memória sobre a coleção |
| R11 | O destaque aceita tamanho "Personalizado" (`type_size_id = 4`), exigindo `width`/`height` numéricos; a galeria só aparece se `contents.galery = 1` **e** houver archives | `v6/app/Http/Requests/ContentRequest.php:224-227`; `v6/resources/views/portal/components/secretarias/show.blade.php` | Validação condicional; checkbox "Ativar Galeria" |
| R12 | Cada visita à página incrementa `contents.clicks` | `v6/app/Http/Traits/GetInformationsPrefectureTrait.php:104-108` | Exposto como `interacoes` na API |
| R13 | A agenda exibida é limitada a **5** eventos com `status = 1` e `final_date >= hoje` | `v6/app/Http/Controllers/Portal/PrefectureController.php:55-58` | Limite **no SQL** (caso correto) |
| R14 | Trocar a imagem de destaque de uma página remove a anterior | `v6/app/Http/Controllers/Prefecture/PagesSecretaryController.php:167-176` | `archives` com `highlight = true` são deletados |
| R15 | Reenviar **anexos** de uma página mantém os anteriores (duplicam) | `v6/app/Http/Controllers/Prefecture/PagesSecretaryController.php:185-199` | Não há limpeza nem substituição |

## 2. Cadeia causal do módulo desabilitado (achado central)

```
v6/database/migrations/2024_11_01_150206_create_modulos_table.php
    DB::table('modulos')->insert(['name'=>'Secretarias','slug'=>'secretarias','status'=>0]);
        │
        ▼
App\Providers\ModulosServiceProvider::boot()
    $modulos = Cache::rememberForever('modulos', fn() => Modulos::all()->pluck('status','slug'));
        │
        ├──► composer em 'prefecture.contents.noticias.form'
        │        @if(isset($modulos['secretarias']) && $modulos['secretarias'])
        │            → select "Secretaria" (contents.secretaria)  ← OCULTO (status = false)
        │        →_contents.secretaria = 100% NULL → "Notícias de X" sai vazio no portal
        │
        └──► App\Providers\MenuServiceProvider::boot()
                 if (has_access("$menu->slug.index") && isset($menu->modulo->status) && $menu->modulo->status)
                     → entrada "Secretarias" NÃO aparece no menu lateral do CMS
                       (as rotas existem e respondem 200 a quem digitar a URL)
```

Estado verificado no banco `prefeituras_v5`:

| Verificação | Resultado |
|---|---|
| `modulos.status` de `secretarias` (id 18) | `f` (false) — `a-instituicao` e `legislacao` são `t` (contraste) |
| `menu_orders` do módulo | 1 linha: `id=13, slug='secretarias', modulo_id=18` |
| `contents` na categoria 7 | 13 linhas |
| `contents.secretaria IS NOT NULL` | **0 linhas** |
| `categorias_secretarias` / `paginas_secretarias` | **0 linhas** cada |

`Cache::rememberForever('modulos')` **não expira**: mesmo que o operador ligue o módulo em
`/cms/modulos`, é preciso `php artisan cache:clear` para o composer ver o novo valor.

## 3. Acoplamentos

| Módulo | Ponto de acoplamento | Risco |
|---|---|---|
| Notícias | `contents.secretaria` (string) | Vínculo por **slug**, não por id — renomear o slug da secretaria "quebra" o vínculo |
| A Instituição | Mesma rota `/a-instituicao/{slug}` e mesmo `PrefectureController` | Nome de rota duplicado (`a-instituicao.index`) |
| Menu do site | `menu_items.category_contents_id = 7` + `contents_id` | A 13ª secretaria (Controladoria Geral) não tem item de menu |
| Agenda de eventos / Service letters / Ouvidência / Servidor | `calendar_events.secretarias_id`, `service_letters.secretaria_id`, `ombudsman_subjects.secretary_id`, `servidor.secretaria` → `contents.id` | FKs verificadas no banco; ID compartilhado com `contents.id` |
| Módulos (CMS) | `modulos.status` | Desligar o módulo oculta o menu **e** o select de notícias |
| Layouts do portal | `getTemplateActived()` | A view da secretaria é única (não há variante por `modelo1..modelo6`) |

## 4. Armadilhas verificadas

### 4.1 Contratos de repositório vazios

```php
// v6/app/Repositories/Contracts/ICategorySecretaryRepository.php
namespace App\Repositories\Contracts;

interface ICategorySecretaryRepository {
}
```

Idem `v6/app/Repositories/Contracts/IPagesSecretaryRepository.php`. O container injeta a
interface, mas **nenhum método** é garantido pelo contrato; o código real (`upInsert`,
`destroyAll`, `getModel`) vem da base abstrata `App\Repositories\Repository`, que **não**
implementa as interfaces. Um `implements` que não cobre nada é enganoso: o contrato não
protege nada.

### 4.2 `Content::secretarias()` usa `localKey = 'slug'`

```php
public function secretarias()
{
    return $this->hasMany(Content::class, 'secretaria', 'slug');
}
```

- `foreignKey = 'secretaria'` (string), `localKey = 'slug'` (string).
- Consulta: `select * from contents where contents.secretaria = <slug da secretaria>`.
- **Sem índice** em `contents.secretaria` → `Seq Scan` em cada visita à página.
- Se o slug da secretaria mudar, o vínculo das notícias é **perdido silenciosamente**.
- Não há FK nem trigger: integridade só por convenção de aplicação.

### 4.3 `ContentRequest::authorize()` retorna `true`

Todos os requests do módulo devolvem `true`: `v6/app/Http/Requests/ContentRequest.php`,
`v6/app/Http/Requests/SecretariasCategoriasRequest.php`,
`v6/app/Http/Requests/SecretariasPaginasRequest.php` e
`v6/app/Http/Requests/Swagger/V1/Portal/SecretariaRequest.php`.

A autorização real vem do middleware `check.route.permission`, que compara o **nome da
rota** com as permissões do Sentinel. Qualquer rota nova registrada sem o middleware
fica **aberta** a qualquer usuário logado.

⚠ As rotas de secretarias em `v6/routes/basepro.php:365-373` estão **fora** do grupo
`Route::middleware(['check.logged'])` (que vai de :265 a :344). A proteção vem do
`$this->middleware('check.route.permission')` **no construtor** dos controllers — e
`MenuSecretaryController` estende `App\Http\Controllers\Admin\Controller` (não
`Prefecture\Controller`), o que muda o comportamento padrão do construtor da classe pai.

### 4.4 Uploads gravados em `storage/content/secretarias_paginas/`

```php
public function insertImage($file, $id)
{
    $url = "content/secretarias_paginas/$id/destaque";
    $name = $this->saveUpload($file, $url);
```

```php
public function insertArchive($file, $id, $description)
{
    $url = "content/secretarias_paginas/$id/arquivos";
    $name = $this->saveUpload($file, $url);
```

| Aspecto | Comportamento |
|---|---|
| Disk | `public` (`v6/config/filesystems.php:16` → `FILESYSTEM_DRIVER=public`), raiz `storage/app/public` → físico `storage/app/public/content/secretarias_paginas/<id>/destaque/<arquivo>` |
| `archives.path` | **Relativo** (`content/secretarias_paginas/...`); a view monta `url('storage/' . $archive->path)` |
| Validação | **Nenhuma** em `insertImage`/`insertArchive` (o request não valida `archive[]`), embora o `ContentRequest` valide `archive_highlight` da secretaria (`max:20000\|mimes:jpg,jpeg,png,gif,webp`) |
| Colisão de nome | `FileTrait::saveUpload()` apaga o arquivo anterior de mesmo nome antes de gravar (nome = `uniqid` + 5 chars + extensão → colisão praticamente impossível) |
| Compressão | `FileTrait::compress()` (Intervention Image); comportamento com arquivos não-imagem **não verificado** aqui |
| Diretório | `storage/app/public/content/secretarias_paginas` **não existe** no repositório atual (nenhuma página foi criada) — será criado no primeiro upload |

### 4.5 `Repository::upInsert` faz `dd($th)` no erro

```php
public function upInsert($request, int $id = null)
{
    try {
        $data = DB::transaction(function () use ($request, $id) {
            return $this->structureUpInsert($this->dataTrait($request));
        });
        return $this->getModel()->updateOrCreate(['id' => $id], $data);
    } catch (\Throwable $th) {
        dd($th);   // <-- stack trace na resposta
    }
    return false;
}
```

Qualquer exceção (FK violada, coluna inexistente) **vaza a stack trace na resposta HTML** e mata o worker com `exit`; o `catch` do controller nunca é alcançado.

### 4.6 `structureUpInsert` não filtra nada

```php
protected function structureUpInsert($request): array
{
    $fields = array_keys($request);
    return $this->filter_request($request, $fields);
}
```

`$fields` é a lista de chaves da própria request → `filter_request()` não remove nada.
A proteção real vem do `$fillable` do model (`CategoriasSecretaria`:
`name, secretarias_id, order`; `PaginasSecretaria`: `title, text, autor, categoria_id,
archives_id`). Consequência: `autor` e `archives_id` são aceitos pela mass-assignment
mas **não existem** em `paginas_secretarias` (schema real: `id, title, text,
categoria_id, order, deleted_at, created_at, updated_at`) → erro de coluna inexistente
→ `dd($th)`.

### 4.7 `PaginasSecretaria::categoria()` concatena `'teste'`

```php
public function categoria()
{
    return $this->hasOne(CategoriasSecretaria::class, 'id', 'categoria_id') . 'teste';
}
```

A relação devolve uma **string** (`"1teste"`), não um `HasOne`. Qualquer código que
chame `$page->categoria->name` quebra. A relação correta, usada de fato, é
`CategoriasSecretaria::contentsCategory()` (`hasMany(PaginasSecretaria,'categoria_id','id')`).

### 4.8 `orderCategory()` devolve string e não valida a titularidade

```php
public function orderCategory(Request $request)
{
    try {
        $count = 1;
        foreach ($request->ids as $id) {
            $this->categorias_secretarias->where('id', $id)->update(['order' => $count]);
            $count++;
        }
        return 'true';
    } catch (\Throwable $th) {
        return 'false';
    }
}
```

- Retorna o **texto** `true`/`false` (não JSON, não HTTP 4xx em erro).
- `catch (\Throwable)` engole tudo: falha de escrita devolve `false` com HTTP 200.
- Não valida se os `ids` pertencem à secretaria exibida → um POST autenticado
  reordena departamentos de **qualquer** secretaria.
- Não há transação: um erro no meio deixa a ordem parcial.

### 4.9 `returnSecretarias` expõe JSON sem filtro

`MenuSecretaryController@returnSecretarias` devolve
`CategoriasSecretaria::with('contentsCategory')->where('secretarias_id',$id)->get()` com o
**texto HTML completo** de cada página (`contentsCategory.text`). A rota não tem nome e
é protegida apenas pelo `check.route.permission`.

### 4.10 Exclusão de secretaria x `menu_items` x FK

`ContentController@genericDestroy` remove os `menu_items` que apontam para o conteúdo
(comportamento genérico, ver `../noticias/`). As linhas de `categorias_secretarias` que
referenciam a secretaria **não** são removidas — a FK não declara `ON DELETE`, então o
`DELETE` falha com violação de FK e cai no `dd($th)` do repositório.

## 5. Riscos de performance (verificados no código)

### 5.1 A grade do CMS carrega a view SQL inteira no PHP

```php
// v6/app/Http/Controllers/Prefecture/DatatableController.php:198-202
public function get_secretarias()
{
    $secretarias = DB::table('secretarias')->get();
    return DataTables::of($this->struct_views($secretarias, 'secretarias'))->make(true);
}
```

- O `DB::table(...)->get()` materializa **todas** as linhas no PHP.
- O Yajra (`CollectionDataTable`) fatia a coleção **em memória** (`->slice()`), sem
  `LIMIT`/`OFFSET` no SQL — a paginação do DataTable é client-side.
- `struct_views()` (:504-520) faz **4 chamadas a `has_access()` por linha**
  (`edit`, `delete`, `restore`, `inactivate`) → 4 × N verificações de permissão.
- Idem para `get_secretaria_categorias()` (:185) e `get_secretaria_paginas()` (:191) —
  esta última ainda traz o **corpo HTML** de cada página (`ps."text" as texto`).

### 5.2 A mesma lista é lida duas vezes no portal

```php
// v6/app/Http/Controllers/Portal/PrefectureController.php:38
$news_secretary = $this->content->with('categoryContents', 'archives')
    ->where('secretaria', $slug1)->orderBy('id', 'DESC')->paginate(6);
```

Esse objeto é paginado (6/página) e é impresso por
`v6/resources/views/portal/components/secretarias/noticias.blade.php`
(`{{$news_secretary->links()}}`) — **mas essa view só é usada pelas telas genéricas
"A Instituição"**, não pela view da secretaria.

Na view da secretaria, a listagem vem de **outra** query, sem limite:

```php
// v6/app/Http/Controllers/Portal/PrefectureController.php:53-54
$contentNew->secretarias = $contentNew->secretarias
    ->where("publication_date", "<=", $date);
```

`Content::secretarias()` é `hasMany(Content::class,'secretaria','slug')` — **sem
`limit()`**. Todas as notícias da secretaria são carregadas e o corte para 12 é feito
em Blade:

```blade
@foreach ($contentNew->secretarias->sortByDesc('created_at')->splice(0, 12) as $newSecreatia)
```

⚠ `splice()` **remove** os 12 primeiros elementos da coleção original (mutação
in-place) — a ordem das operações na view importa.

Além disso, cada item acessa `$newSecreatia->archives` e
`$newSecreatia->categoryContents->name` — **lazy loading** (N+1): 12 notícias ×
2 relações = 24 queries, porque `getContentDataWith()` só carrega
`archiveHighlight`, `archiveGallery` e `categoryContents` **da secretaria**, não das
notícias.

### 5.3 `<select>` populado com a view inteira

```php
// CategorySecretaryController::__construct (:20) e MenuSecretaryController::__construct (:17)
$this->secretarias = DB::table('secretarias')->get(['id', 'titulo as name']);
```

Carrega a view completa (13 linhas hoje) só para popular um `<select>` — em **cada**
requisição ao controller, inclusive no `index`, que não usa o drop-down.

### 5.4 `PagesSecretaryController@create` carrega todos os departamentos

`'categorias' => $this->secretarias->all()` (`CategoriasSecretaria::all()`) — sem filtro
por secretaria e sem paginação: o formulário de página lista **todos** os departamentos
de **todas** as secretarias, e o `categoria_id` gravado não é validado contra a
secretaria.

### 5.5 O que está **certo** (para referência)

Eventos da secretaria (`->limit(5)->get()`), `news_secretary` no layout genérico
(`->paginate(6)`) e a listagem de páginas no portal (`find($id)` + `archives` da página)
aplicam limite/paginação **no SQL** ou em volume pequeno.

## 6. Código duplicado / copiado entre v5 e v6

| Arquivo | v5 vs v6 |
|---|---|
| `app/Http/Controllers/Prefecture/CategorySecretaryController.php` | **idêntico** (`diff` vazio) |
| `app/Http/Controllers/Prefecture/PagesSecretaryController.php` | **idêntico** |
| `app/Http/Controllers/Prefecture/MenuSecretaryController.php` | **idêntico** |
| `app/Http/Model/Prefecture/CategoriasSecretaria.php` | **idêntico** |
| `app/Http/Model/Prefecture/PaginasSecretaria.php` | **idêntico** |
| `app/Http/Requests/SecretariasCategoriasRequest.php` | **idêntico** |
| `app/Http/Requests/SecretariasPaginasRequest.php` | **idêntico** |
| `app/Http/Controllers/Portal/SecretariaController.php` | **idêntico** |
| `app/Http/Model/Prefecture/Content.php` → relação `secretarias()` | **idêntica** (`hasMany(Content,'secretaria','slug')`) |
| `app/Repositories/Eloquent/Prefecture/CategorySecretary/CategorySecretaryRepository.php` | **DIFERENTE** — a v5 tinha de-duplicação (§7) |
| `app/Repositories/Eloquent/Prefecture/PagesSecretary/PagesSecretaryRepository.php` | **DIFERENTE** — idem |
| `routes/basepro.php` (bloco "Secretarias") | mesmas rotas (`v5/…:371-379` ≡ `v6/…:365-373`) |

Nenhuma lógica de negócio foi reescrita entre v5 e v6 nas classes deste módulo — a
evolução foi **estrutural** (rota do portal, view do portal, repositórios).

## 7. Onde a v6 divergiu da v5

| Tema | v5 | v6 | Efeito |
|---|---|---|---|
| Rota do portal | `a-prefeitura-secretarias.index` (`v5/routes/web.php:71`) | **removida**; sobrou `a-instituicao.index` (duplicado) | `PortalController@contentList` (`case 'secretarias'`) aponta para rota inexistente → **erro 500** |
| View do portal | `portal.layouts.$modelo.secretarias.show` (uma por modelo) | `portal.components.secretarias.show` (view única) | Unificação do markup; as views por modelo foram apagadas |
| Variável da view | `secretary_menus` | `menus` | Renomeação |
| View `default` | `portal/layouts/default/pages/contents/secretarias/show.blade.php` | idem (presente) | **Órfã**: o layout `default` não tem `home/index.blade.php` |
| Repositório de departamentos | deduplicava `name` + `secretarias_id` (acrescentava ` <maxId+1>`) | **dedup removido** | Dois departamentos com o mesmo nome passam a ser aceitos |
| Repositório de páginas | deduplicava `title` + `categoria_id` | **dedup removido** | Mesma consequência |
| `upInsert` dos repositórios | sobrescrito, **sem transação** | herdado de `App\Repositories\Repository`, **com transação** | Falha atômica na v6 |
| Migration do `slug` na view | `v5/database/migrations/2026_03_09_000000_update_views_to_include_slug.php` (agrupa várias views) | `v6/database/migrations/2024_11_29_085508_update_view_secretarias.php` (view isolada) | Mesmo efeito, mecânica diferente |
| `resources` do módulo | `v5/database/migrations/2023_03_31_083904_add_resource_to_secretarias.php` | **migration ausente** | Recriar o banco do zero **não** cria `secretarias-categorias`/`secretarias-paginas` → `check.route.permission` nega o acesso |
| Módulo `modulos` | inexistente na v5 | `v6/database/migrations/2024_11_01_150206_create_modulos_table.php` com `status = 0` | Módulo nasce **desligado** |
| Select "Secretaria" no form de notícias | sempre visível | visível apenas se `modulos['secretarias']` for true | Vínculo notícia↔secretaria **impossível** pela interface |

### 7.1 Código de deduplicação removido (v5)

```php
// v5/app/Repositories/Eloquent/Prefecture/CategorySecretary/CategorySecretaryRepository.php
protected function structureUpInsert($request, $id = null): array
{
    $fields = array_keys($request);
    $data = $this -> filter_request($request, $fields);
    if(isset($data['name']) && isset($data['secretarias_id'])){
        $query = $this->model->where('name', $data['name'])
                         ->where('secretarias_id', $data['secretarias_id']);
    }
    if($id){ $query->where('id', '!=', $id); }
    if($query->exists()){
        $maxId = $this->model->max('id') + 1;
        $data['name'] = $data['name'] . ' ' . $maxId;
    }
    return $data;
}
```

⚠ A lógica da v5 tinha o próprio bug: se `name` **ou** `secretarias_id` não viessem
preenchidos, `$query` ficaria **indefinido** e `$query->where(...)` geraria erro fatal
("Call to a member function where() on null"). Não se deve restaurar como estava — o
correto é um índice único composto (`secretaria_id`, `name`).

## 8. Onde a v7 deve divergir (decisões)

1. **Vínculo notícia↔secretaria por id**, não por slug (`news.secretaria_id`).
2. **Remover as views SQL** e usar Eloquent com `paginate()` real no servidor.
3. **Policies** no lugar do middleware por nome de rota; `authorize()` deve refletir a policy.
4. **Contratos de repositório reais** (ou nenhum contrato).
5. **Validação de upload** (mimes, tamanho) também para `archive[]`.
6. **Limite no SQL** para a listagem de notícias (`limit(12)`), sem `splice()` em Blade.
7. **Nomes de rota únicos**: `secretarias.index`, `secretarias.show`, `secretarias.paginas.show`.
8. **Exibir a sigla** (`acronym`) nas listagens e no portal — ou remover o campo.
9. **Habilitar o módulo** (`modulos.status = true`) e recriar o cache; remover
   `paginas_secretarias.order` (nunca usada).
