# A Instituição — Migração para Laravel Moderno (PHP 8.x + Laravel 11/12)

> Objetivo: reconstruir o módulo A Instituição, mantendo o comportamento observável
> (ver `05-REGRAS-E-PORTABILIDADE.md` §7) e eliminando os acoplamentos frágeis
> documentados.

---

## 1. Decisões de equivalência funcional (não podem mudar)

| # | Decisão | Justificativa |
|---|---|---|
| E1 | URL pública canônica `/a-instituicao/{slug}` | Já publicada em links externos e indexada. |
| E2 | `slug` único, gerado do título, colisão resolvida deterministicamente | Compatível com o `slug-<maxid+1>` legado sem quebrar URLs antigas. |
| E3 | Limites de validação idênticos (título 120, link 200, imagem 20 MB e `jpg,jpeg,png,gif,webp`) | O CMS treina o operador com esses limites. |
| E4 | Galeria só com `galery = 1`; só imagens. | Comportamento atual. |
| E5 | Exclusão de conteúdo remove os itens de menu que o referenciam. | Evita menu quebrado. |
| E6 | Contador de cliques continua existindo. | Métrica de interesse institucional. |
| E7 | Permissões mantêm os nomes `a-instituicao.index/.create/.edit/.delete/.inactivate/.inactivate/.restore`. | Evita retrabalho de perfis e permissões já cadastrados. |
| E8 | Entrada do menu do CMS continua condicionada a permissão **e** a status do módulo. | Mantém a chave de operação "ligar/desligar módulo" já existente. |
| E9 | A coluna "Ações" nunca é exportada em Excel/PDF. | O arquivo exportado é lido por pessoas, não por máquinas. |

## 2. Schema de migração Laravel

### 2.1 Tabela `institutional_contents`

```php
// exemplo ilustrativo
Schema::create('institutional_contents', function (Blueprint $table) {
    $table->id();
    $table->foreignId('category_id')->constrained('categories')->cascadeOnDelete();
    $table->string('title', 120);
    $table->string('slug', 191)->unique();
    $table->longText('text');
    $table->string('permalink')->nullable();      // link externo opcional
    $table->string('video_url', 200)->nullable();  // YouTube
    $table->boolean('gallery_enabled')->default(false);
    $table->unsignedBigInteger('clicks')->default(0);
    $table->unsignedInteger('sort_order')->default(0);
    $table->boolean('status')->default(true);
    $table->timestamp('published_at')->nullable();
    $table->timestamps();
    $table->softDeletes();

    $table->index(['category_id', 'status']);
});
```

Observações de equivalência:

- `category_contents` genérica vira `categories`; o módulo passa a ser um
  **modelo dedicado** (`InstitutionalContent`), não mais um `Content` genérico
  discriminado por slug. A tabela `contents` continua existindo para os demais
  módulos (notícias, legislação, publicações), mas **sem** as linhas da
  categoria institucional.
- `galery` → `gallery_enabled` (nomes em português no schema legado são uma
  dívida; a v7 pode padronizar).
- `clicks` deixa de ser `integer` nullable e passa a ter default 0.
- `slug` ganha **índice único** — o legado resolve a colisão em runtime, sem
  garantia de unicidade.

### 2.2 Tabela `institutional_content_archives`

```php
// exemplo ilustrativo
Schema::create('institutional_content_archives', function (Blueprint $table) {
    $table->id();
    $table->foreignId('institutional_content_id')->constrained()->cascadeOnDelete();
    $table->string('title', 300)->nullable();
    $table->text('description')->nullable();
    $table->string('path', 500);
    $table->string('extension', 30);
    $table->boolean('is_highlight')->default(false);
    $table->boolean('view_pdf')->default(false);
    $table->unsignedInteger('sort_order')->default(0);
    $table->timestamps();
});
```

O `is_highlight` substitui o `highlight` tri-state (`1` = destaque, `NULL` =
galeria, `0` = imagem de A Cidade). A distinção "destaque × galeria" fica
explícita em dois booleanos, eliminando o uso de `NULL` como semântica.

### 2.3 View SQL (mantida ou eliminada)

A v6 usa a view `a_instituicao` só para a grade do CMS. Opções:

| Opção | Prós | Contras |
|---|---|---|
| **Manter a view** e corrigi-la | menor mudança; a grade e a listagem pública leem a mesma fonte | `row_number()` sem chave estável; `ORDER BY` materializado |
| **Eliminar a view** e paginar em Eloquent | uma fonte de verdade; `LIMIT/OFFSET` no SQL; ordenação determinística | exige reescrever `DatatableController` e o seed de dados |

**Recomendação: eliminar a view** e usar uma query paginada com cursor, com
índice em `(category_id, created_at)`. A view pode ser mantida temporariamente
como camada de compatibilidade para relatórios que já a consomem.

### 2.4 Migração de dados (v6 → v7)

| Passo | SQL de origem | Destino |
|---|---|---|
| 1 | `SELECT id, title, text, slug, galery, clicks, created_at FROM contents WHERE category_contents_id = 6 AND deleted_at IS NULL` | `institutional_contents` |
| 2 | `SELECT contents_id, title, description, path, extension, highlight, order FROM archives WHERE contents_id IN (...)` | `institutional_content_archives` (destaque: `highlight = 1`; galeria: `highlight IS NULL`) |
| 3 | `SELECT contents_id, link FROM links WHERE contents_id IN (...)` | coluna `video_url` |
| 4 | `SELECT id, name, link, type FROM menu_items WHERE category_contents_id = 6` | manter `menu_items`, agora apontando para `institutional_contents` (ou para uma tabela de junção polimórfica) |
| 5 | `UPDATE menu_orders SET slug = 'a-instituicao'` | já feito na v6 pelo seed |

Regra crítica: **preservar o `id` de origem** em uma coluna `legacy_id` para
rastreabilidade e para reconciliar `menu_items.contents_id`.

## 3. Modelos

```php
// exemplo ilustrativo
namespace App\Models;

class InstitutionalContent extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'category_id', 'title', 'slug', 'text', 'permalink',
        'video_url', 'gallery_enabled', 'sort_order', 'status', 'published_at',
    ];

    protected function casts(): array
    {
        return [
            'gallery_enabled' => 'boolean',
            'status' => 'boolean',
            'published_at' => 'datetime',
            'clicks' => 'integer',
        ];
    }

    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }

    public function highlight(): HasOne
    {
        return $this->hasOne(InstitutionalContentArchive::class)->where('is_highlight', true);
    }

    public function gallery(): HasMany
    {
        return $this->hasMany(InstitutionalContentArchive::class)
            ->where('is_highlight', false)
            ->where('view_pdf', false)
            ->orderBy('sort_order');
    }
}
```

| Item legado | Novo |
|---|---|
| `Content` (genérico) + `category_contents_id = 6` | `InstitutionalContent` dedicado |
| `Content::archiveHighlight()` (`byHighLight(1)`) | `highlight()` (`is_highlight = true`) |
| `Content::archiveGallery()` (`byHighLight(null)`) | `gallery()` (`is_highlight = false`) |
| `Content::linksUnique()` | coluna `video_url` (1:1 desnecessário) |
| `Content::secretarias()` (`localKey = 'slug'`) | `belongsTo(Secretaria::class, 'secretaria_id')` — **chave estrangeira real**, resolvendo a armadilha do slug |
| `Content::orgaos()` (auto-referência por `orgaos_id`) | `belongsTo(InstitutionalContent::class, 'orgaos_id')` ou tabela pivot |
| `ContentPresenter` | `InstitutionalContentResource` (API) + accessor de URL pública |
| `clicks` atualizado com SELECT + UPDATE | `increment('clicks')` (atômico, 1 query) |

## 4. Form Requests

```php
// exemplo ilustrativo
class StoreInstitutionalContentRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', InstitutionalContent::class);
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:120'],
            'text' => ['required', 'string'],
            'slug' => ['nullable', 'string', 'max:191', 'alpha_dash', Rule::unique('institutional_contents', 'slug')->ignore($this->route('id'))],
            'video_url' => ['nullable', 'url', 'max:200'],
            'gallery_enabled' => ['boolean'],
            'status' => ['boolean'],
            'category_id' => ['required', Rule::exists('categories', 'id')->where('slug', 'a-instituicao')],
            'highlight' => ['nullable', 'image', 'mimes:jpg,jpeg,png,gif,webp', 'max:20000'],
        ];
    }
}
```

Diferenças em relação ao legado:

- `authorize()` deixa de devolver `true` — a autorização passa a ser da Policy.
- `category_id` é validado **contra a lista de categorias permitidas**
  (o legado aceita qualquer id numérico).
- `slug` tem `Rule::unique()` — a colisão passa a ser erro de validação, não
  sufixo silencioso.
- `video_url` passa a validar `url` (o legado aceita texto livre).
- `text` é sanitizado (HTML permitido, mas com lista branca de tags) — o legado
  grava HTML cru e renderiza com `{!! !!}`.

## 5. Policies e autorização

```php
// exemplo ilustrativo
class InstitutionalContentPolicy
{
    public function viewAny(User $user): bool  { return $user->can('a-instituicao.index'); }
    public function view(User $user): bool     { return $user->can('a-instituicao.show'); }
    public function create(User $user): bool   { return $user->can('a-instituicao.create'); }
    public function update(User $user): bool   { return $user->can('a-instituicao.edit'); }
    public function delete(User $user): bool   { return $user->can('a-instituicao.delete'); }
    public function restore(User $user): bool  { return $user->can('a-instituicao.restore'); }
    public function inactivate(User $user): bool { return $user->can('a-instituicao.inactivate'); }
}
```

Regras de equivalência a manter:

- `super-admin` (ou papel equivalente) ignora as permissões — como o atalho de
  `has_access()` e do `CheckRoutePermission`.
- A checagem de acesso ocorre **no servidor**, em cada rota, e não apenas na
  exibição dos botões. As views devem esconder botões por conveniência, nunca
  por segurança.

## 6. Endpoints REST

### 6.1 Portal público

| Método | URI | Descrição |
|---|---|---|
| GET | `/a-instituicao/{conteudo:slug}` | Página pública. |
| GET | `/a-instituicao` | Listagem institucional (opcional; hoje só existe via menu). |

```php
// exemplo ilustrativo
Route::get('/a-instituicao/{conteudo:slug}', InstitutionalContentController@show)
    ->name('a-instituicao.index');
```

**Correção obrigatória:** o nome `a-instituicao.index` deve ser registrado
**uma única vez**. As rotas de secretaria e de órgão devem ter nomes próprios
(`a-instituicao-secretarias.index`, `a-instituicao-orgaos.index`), como na v5.

### 6.2 CMS

| Método | URI | Nome sugerido | Descrição |
|---|---|---|---|
| GET | `/cms/a-instituicao` | `cms.a-instituicao.index` | Listagem paginada. |
| GET | `/cms/a-instituicao/create` | `cms.a-instituicao.create` | Formulário. |
| POST | `/cms/a-instituicao` | `cms.a-instituicao.store` | Gravar. |
| GET | `/cms/a-instituicao/{id}/edit` | `cms.a-instituicao.edit` | Formulário de edição. |
| PUT | `/cms/a-instituicao/{id}` | `cms.a-instituicao.update` | Atualizar. |
| DELETE | `/cms/a-instituicao/{id}` | `cms.a-instituicao.destroy` | Excluir. |
| POST | `/cms/a-instituicao/bulk-delete` | `cms.a-instituicao.bulk.destroy` | Exclusão em lote. |
| GET | `/cms/a-instituicao/{id}/gallery` | `cms.a-instituicao.gallery` | Gerenciar galeria. |
| GET | `/cms/a-instituicao/export` | `cms.a-instituicao.export` | Exportar (Excel/CSV/PDF server-side). |

O controller do CMS deixa de ser genérico por slug: passa a ser um
`InstitutionalContentController` com métodos próprios, o que elimina o
`switch ($slug)` gigante do `ContentController` e o `switch` de regras do
`ContentRequest`.

### 6.3 API de dados abertos (lacuna a fechar)

A v6 **não** expõe A Instituição em `v1/`. Proposta:

| Método | URI | Descrição |
|---|---|---|
| GET | `/api/v1/instituicoes` | Lista paginada (`per_page`, `page`). |
| GET | `/api/v1/instituicoes/{slug}` | Detalhe. |
| GET | `/api/v1/instituicoes/categorias` | Categorias/subseções. |

O formato deve seguir o padrão dos outros endpoints v1 da plataforma
(`v6/routes/api.php`): JSON com `data` e metadados de paginação.

## 7. API Resources

```php
// exemplo ilustrativo
class InstitutionalContentResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'slug' => $this->slug,
            'text' => $this->text,
            'url' => route('a-instituicao.index', $this->slug),
            'highlight' => $this->whenLoaded('highlight', fn ($a) => $a?->url),
            'gallery' => InstitutionalContentArchiveResource::collection($this->whenLoaded('gallery')),
            'video_url' => $this->video_url,
            'published_at' => $this->published_at?->toIso8601String(),
            'clicks' => $this->clicks,
        ];
    }
}
```

## 8. Views Blade modernas

| Legado | Moderno |
|---|---|
| `v6/resources/views/portal/layouts/modelo1/informacoes-instituicao/a-instituicao/show.blade.php` (e o equivalente em modelo2, modelo3, modelo4, modelo5, modelo6) | `<x-portal.institutional.show :content="$content" />` incluido por cada tema |
| `v6/resources/views/portal/layouts/modelo1/informacoes-instituicao/components/components-a-prefeitura/index.blade.php` (e o equivalente em modelo2, modelo3, modelo4, modelo5, modelo6) | `<x-portal.institutional.body>` + `<x-portal.institutional.agenda>` + `<x-portal.institutional.noticias>` |
| `v6/resources/views/prefecture/contents/a-instituicao/{index,create,edit,form}.blade.php` | `<x-admin.institutional.index>`, `<x-admin.institutional.form>` |

Recomendações:

- **Componente único** para o conteúdo institucional, paramétrico pelo tema
  (`modelo1..modelo6`), eliminando as 6 cópias divergentes (e o bug do modelo2
  incluir o `orgaosNews` do modelo1).
- Renomear o diretório `components-a-prefeitura` para `components-a-instituicao`
  (a herança do nome antigo confunde quem lê o código).
- O "Link Permanente" deve exibir `route('a-instituicao.index', $content->slug)` —
  nunca uma URL hardcoded de outro módulo.
- `{!! $text !!}` deve ser substituído por `{!! $text !!}` com HTML **sanitizado
  na escrita** (policy de HTML), ou por um componente `<x-prose>` que aplique
  `Html::sanitize` na renderização.
- O bloco de compartilhamento pode virar `<x-share-buttons :url="url()->full()" />`.

## 9. Fila, cache e storage

| Aspecto | Legado | Proposto na v7 |
|---|---|---|
| Cliques | `SELECT` + `UPDATE` por visita | `increment('clicks')` (atômico); opcionalmente despachado para a fila e agregado a cada N visitas. |
| Layout ativo | `Configuration::where('key','layout')->first()` por requisição | `Cache::remember('portal.layout', 3600, ...)` com invalidação ao salvar a configuração. |
| Status dos módulos | `Cache::rememberForever('modulos')` sem invalidação | `Cache::remember('modules.status', 600, ...)` + invalidação explícita ao atualizar a tela de módulos. |
| Galeria | todas as mídias carregadas para checar `isEmpty()` | `->exists()` para o teste e `->with()` limitado para a renderização. |
| Miniaturas | `getMinFile()` / `getLargFile()` em runtime | job de_STORAGE que gera miniatura e variante `.webp` no upload. |
| Arquivos | Summernote grava em `storage/summernote-files/temporarios/temp-*` e o controller move o arquivo depois | upload direto para `storage/app/public/institutional/{id}` com nome determinístico. |

## 10. Riscos de migração

| Risco | Mitigação |
|---|---|
| Renomear o diretório de views quebra os temas que o referenciam | Migrar tudo em um único corte (feature flag por tema) e apagar os caminhos antigos em seguida — sem shim. |
| Inconsistência entre `contents.category_contents_id = 6` e a view `a_instituicao` | Migrar a view e os dados na mesma janela; depois remover a view. |
| `menu_items.contents_id` aponta para `contents.id` | Manter `legacy_id` e recriar os vínculos; validar que nenhum `menu_item` fique órfão. |
| Slugs publicados podem conflitar com o `unique` novo | Fazer o backfill detectando duplicatas e aplicando o sufixo legado `-<maxid+1>` **antes** de criar o índice único. |
| Conteúdos com `secretaria`/`orgaos_id` NULL | Mover para NULL explícito e remover as relações por `localKey = 'slug'`. |
| Permissões com nomes antigos | Manter os nomes (E7) e mapear para as abilities da Policy. |

## 11. Checklist de equivalência

- [ ] `/a-instituicao/{slug}` responde 200 e renderiza título, texto, imagem, galeria, vídeo e notícias.
- [ ] O nome de rota `a-instituicao.index` está registrado uma única vez.
- [ ] A rota `a-instituicao-secretarias.index` existe e responde 200.
- [ ] A grade do CMS pagina no servidor (`?page=`, `?per_page=`).
- [ ] A coluna "Ações" não é exportada.
- [ ] A exclusão de conteúdo remove os `menu_items` vinculados.
- [ ] A entrada do menu do CMS respeita permissão **e** status do módulo.
- [ ] A imagem de destaque aceita `jpg,jpeg,png,gif,webp` até 20 MB.
- [ ] `clicks` é incrementado de forma atômica.
- [ ] Nenhuma view órfã `default`/`newsbit` permanece no repositório.
