# Secretarias — Migração para Laravel Moderno

> Alvo: PHP 8.x + Laravel 11/12. Plano de reconstrução do módulo **Secretarias**
> (secretarias, departamentos e páginas) com equivalência funcional.
> Princípio: **comportamento observável igual, implementação moderna**.

---

## 1. Decisões de equivalência funcional (o que NÃO pode mudar)

| # | Comportamento legado | Como preservar |
|---|---|---|
| E1 | Página da secretaria em `/a-instituicao/secretarias/{slug}` | Manter o prefixo público ou redirect permanente do legado |
| E2 | Subpágina em `/a-instituicao/secretarias/paginas/{slug}/{id}` | Idem (ou `/{id}` escopado por secretaria, com redirect) |
| E3 | Slug gerado do título, com desempate determinístico | `Str::slug($title)` + índice único com retry (`slug-2`, `slug-3`…) |
| E4 | Sigla capturada em `contents.acronym` | Exibir na v7 (o legado captura e nunca mostra) ou remover o campo |
| E5 | Menu de departamentos ordenado por `order`, reordenável por drag-and-drop | Campo `position` + endpoint de reordenação transacional |
| E6 | "Notícias de {secretaria}": no máximo 12, por `created_at` desc | `limit(12)` **no SQL** (o corte em memória é bug, não regra) |
| E7 | Soft-deleted e futuros não aparecem | `published_at <= now()` e `deleted_at is null` |
| E8 | Arquivos da página para download, com descrição opcional | `archives` polimórfico (`morphs`) |
| E9 | API `GET /api/v1/secretarias` com o mesmo envelope e campos | `SecretariaResource` com `titulo`, `descricao`, `interacoes`, `slug`, `arquivos` |
| E10 | O módulo continua ligável/desligável pelo CMS | `modules.status` + cache invalidado |

## 2. Schema de migração Laravel

### 2.1 Tabelas novas (substituem `contents` cat. 7 + `categorias_secretarias` + `paginas_secretarias`)

| Tabela | Colunas | Índices / regras |
|---|---|---|
| `secretarias` | `id`, `title`, `slug`, `acronym`, `text`, `youtube_video_id`, `highlight_id`, `gallery_enabled`, `published_at`, `clicks`, timestamps, `deleted_at` | `unique(slug)`; `highlight_id` → `archives.id` |
| `secretaria_departments` | `id`, `secretaria_id`, `name`, `position`, timestamps, `deleted_at` | `FK secretarias` `cascadeOnDelete`; `index(secretaria_id, position)`; `unique(secretaria_id, name)` |
| `secretaria_pages` | `id`, `secretaria_department_id`, `title`, `text`, `highlight_id`, timestamps, `deleted_at` | `FK secretaria_departments` `cascadeOnDelete`; `index(secretaria_department_id)` |
| `archives` | `id`, `disk`, `path`, `name`, `title`, `description`, `mime_type`, `extension`, `size`, `is_highlight`, `archivable_type`, `archivable_id`, timestamps | `morphs('archivable')` — resolve o `archives.reference_id` legado (coluna `text` sem FK) |

Esqueleto da migration (as três `Schema::create` seguem o mesmo padrão; `down()` faz
`dropIfExists` na ordem inversa). A view SQL `secretarias` deve existir **apenas**
durante a migração e ser removida ao final — a v7 usa Eloquent:

```php
// database/migrations/2026_xx_xx_create_secretarias_tables.php
Schema::create('secretarias', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->string('slug')->unique();
    $table->string('acronym', 20)->nullable();
    $table->longText('text')->nullable();           // HTML do Summernote
    $table->string('youtube_video_id', 191)->nullable();
    $table->foreignId('highlight_id')->nullable();  // FK archives.id
    $table->boolean('gallery_enabled')->default(false);
    $table->timestamp('published_at')->nullable();
    $table->unsignedInteger('clicks')->default(0);
    $table->timestamps();
    $table->softDeletes();
});

// 'secretaria_departments': id, FK secretaria_id (cascadeOnDelete), name, position,
// timestamps, softDeletes, index(secretaria_id, position), unique(secretaria_id, name)
// 'secretaria_pages': id, FK secretaria_department_id (cascadeOnDelete), title, text,
// highlight_id, timestamps, softDeletes
```

### 2.2 Vínculo notícia ↔ secretaria

```php
Schema::table('news', function (Blueprint $table) {
    $table->foreignId('secretaria_id')->nullable()->constrained('secretarias')->nullOnDelete();
    $table->index('secretaria_id');
});

// Backfill por slug (a chave lógica do legado)
DB::table('news')->whereNotNull('secretaria')->orderBy('id')->chunk(500, function ($rows) {
    foreach ($rows as $row) {
        $secretariaId = DB::table('secretarias')->where('slug', $row->secretaria)->value('id');
        if ($secretariaId) {
            DB::table('news')->where('id', $row->id)->update(['secretaria_id' => $secretariaId]);
        }
    }
});
```

### 2.3 Migração dos dados do legado

| Origem | Destino | Observação |
|---|---|---|
| `contents` com `category_contents_id = 7` | `secretarias` | Mapear `contents.id → secretarias.id` (obrigatório: `calendar_events.secretarias_id` e `servidor.secretaria` referenciam `contents.id`) |
| `categorias_secretarias` | `secretaria_departments` | `secretarias_id` → `secretaria_id`; `order` → `position` |
| `paginas_secretarias` | `secretaria_pages` | `categoria_id` → `secretaria_department_id` |
| `archives` de página | `archives` polimórfico | Reconstruir `archivable_type`/`archivable_id` + `is_highlight` a partir de `highlight` |
| `menu_items` (`category_contents_id = 7`) | `menu_items` da v7 | Preservar `contents_id → secretaria_id` e a ordem |
| `contents.secretaria` (string) | `news.secretaria_id` | Backfill por slug + relatório de slugs não encontrados |
| `modulos` (id 18) | `modules` | Migrar `status = true` e invalidar o cache |

## 3. Modelos

```php
// app/Models/Secretaria.php
namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Secretaria extends Model
{
    use HasFactory, SoftDeletes;

    protected $table = 'secretarias';

    protected $fillable = ['title', 'slug', 'acronym', 'text', 'youtube_video_id',
                           'highlight_id', 'gallery_enabled', 'published_at'];

    protected $casts = ['gallery_enabled' => 'boolean', 'published_at' => 'datetime', 'clicks' => 'integer'];

    public function departments(): HasMany
    {
        return $this->hasMany(SecretariaDepartment::class)->orderBy('position');
    }

    public function pages(): HasManyThrough
    {
        return $this->hasManyThrough(SecretariaPage::class, SecretariaDepartment::class);
    }

    public function news(): HasMany { return $this->hasMany(News::class); }
    public function events(): HasMany { return $this->hasMany(CalendarEvent::class); }
    public function highlight(): BelongsTo { return $this->belongsTo(Archive::class, 'highlight_id'); }

    /** Últimas 12 notícias publicadas — limite no SQL (regra E6). */
    public function latestNews(int $limit = 12): HasMany
    {
        return $this->news()
            ->whereNotNull('published_at')->where('published_at', '<=', now())
            ->orderByDesc('created_at')->limit($limit);
    }
}
```

| Model | Relations | Fillable |
|---|---|---|
| `SecretariaDepartment` (`secretaria_departments`) | `secretaria()` (belongsTo), `pages()` (hasMany `SecretariaPage`) | `secretaria_id`, `name`, `position` |
| `SecretariaPage` (`secretaria_pages`) | `department()` (belongsTo), `highlight()` (belongsTo `Archive`), `attachments()` (morphMany `Archive`) | `secretaria_department_id`, `title`, `text`, `highlight_id` |

Enum sugerido (`app/Enums/PublicationStatus.php`): `Draft`, `Published`, `Scheduled` —
substitui o flag `galery` e permite agendamento.

## 4. Form Requests

| Request | `authorize()` | Regras |
|---|---|---|
| `StoreSecretariaRequest` | `$this->user()->can('create', Secretaria::class)` | `title` required\|string\|max:120; `acronym` nullable\|string\|max:20; `text` required\|string; `youtube_video_id` nullable\|string\|max:2999; `gallery_enabled` boolean; `highlight` nullable\|image\|mimes:jpg,jpeg,png,gif,webp\|max:20000; `width`/`height` nullable\|integer\|min:1 |
| `StoreSecretariaDepartmentRequest` | `$this->user()->can('create', SecretariaDepartment::class)` | `name` required\|string\|max:191; `secretaria_id` required\|exists:secretarias,id |
| `StoreSecretariaPageRequest` | `$this->user()->can('create', SecretariaPage::class)` | `title` required\|string\|max:191; `secretaria_department_id` required\|exists:secretaria_departments,id; `text` required\|string; `highlight` nullable\|image\|mimes:jpg,jpeg,png,gif,webp\|max:20000; `attachments` nullable\|array\|max:20; `attachments.*` file\|max:20480 |

Mensagens (português, com a **chave correta** da regra):

```php
// app/Http/Requests/Admin/StoreSecretariaRequest.php
public function messages(): array
{
    return [
        'title.required' => 'O título da secretaria é obrigatório.',
        'text.required' => 'O texto da secretaria é obrigatório.',
        'highlight.mimes' => 'A imagem de destaque deve ser JPG, PNG, GIF ou WEBP.',
    ];
}
```

⚠ Correção do bug do legado: a mensagem deve casar com a **chave da regra**
(`title.required`, não `name.required`).

## 5. Policies

```php
// app/Policies/SecretariaPolicy.php
class SecretariaPolicy
{
    public function viewAny(User $user): bool   { return $user->can('secretarias.index'); }
    public function view(User $user, Secretaria $s): bool { return true; }   // público
    public function create(User $user): bool   { return $user->can('secretarias.create'); }
    public function update(User $user, Secretaria $s): bool { return $user->can('secretarias.edit'); }
    public function delete(User $user, Secretaria $s): bool { return $user->can('secretarias.delete'); }
    public function restore(User $user, Secretaria $s): bool { return $user->can('secretarias.restore'); }
    public function reorderDepartments(User $user, Secretaria $s): bool { return $user->can('secretarias.edit'); }
}
```

Policies equivalentes para `SecretariaDepartment` e `SecretariaPage`.
O middleware `check.route.permission` (por nome de rota) **deixa de existir**; a
autorização passa a ser por **recurso + ação**, eliminando a fragilidade de
`authorize() => true` e das rotas de datatable "sem nome".

## 6. Endpoints REST

### 6.1 Portal (público)

| Método | URI | Nome | Controller |
|---|---|---|---|
| GET | `/a-instituicao/secretarias/{slug}` | `portal.secretarias.show` | `Portal\SecretariaController@show` |
| GET | `/a-instituicao/secretarias/paginas/{slug}/{id}` | `portal.secretarias.pages.show` | `Portal\SecretariaPageController@show` |

```php
public function show(string $slug): View
{
    $secretaria = Secretaria::query()
        ->with(['departments.pages', 'highlight'])
        ->where('slug', $slug)
        ->firstOrFail();

    $secretaria->increment('clicks');

    return view('portal.secretarias.show', [
        'secretaria' => $secretaria,
        'news' => $secretaria->latestNews(12)->with(['highlight', 'category'])->get(),
        'events' => $secretaria->events()->where('status', true)
            ->where('final_date', '>=', today())->orderByDesc('created_at')->limit(5)->get(),
    ]);
}
```

### 6.2 CMS (autenticado)

| Método | URI | Nome |
|---|---|---|
| GET | `/admin/secretarias` | `admin.secretarias.index` |
| GET | `/admin/secretarias/create` | `admin.secretarias.create` |
| POST | `/admin/secretarias` | `admin.secretarias.store` |
| GET | `/admin/secretarias/{secretaria}/edit` | `admin.secretarias.edit` |
| PUT / DELETE | `/admin/secretarias/{secretaria}` | `admin.secretarias.update` / `.destroy` |
| GET | `/admin/secretarias/{secretaria}/menu` | `admin.secretarias.menu` |
| POST | `/admin/secretarias/{secretaria}/departments` | `admin.secretarias.departments.store` |
| PUT / DELETE | `/admin/secretarias/departments/{department}` | `admin.secretarias.departments.update` / `.destroy` |
| POST | `/admin/secretarias/departments/reorder` | `admin.secretarias.departments.reorder` |
| GET | `/admin/secretarias/{secretaria}/pages/create` | `admin.secretarias.pages.create` |
| POST | `/admin/secretarias/pages` | `admin.secretarias.pages.store` |
| PUT / DELETE | `/admin/secretarias/pages/{page}` | `admin.secretarias.pages.update` / `.destroy` |
| DELETE | `/admin/secretarias/pages/{page}/attachments/{archive}` | `admin.secretarias.pages.attachments.destroy` |

Reordenação (transacional — corrige o `orderCategory` do legado):

```php
public function reorderDepartments(Request $request, Secretaria $secretaria): JsonResponse
{
    $data = $request->validate(['ids' => ['required', 'array', 'min:1'], 'ids.*' => ['integer', 'distinct']]);

    DB::transaction(function () use ($data, $secretaria) {
        foreach (array_values($data['ids']) as $position => $id) {
            SecretariaDepartment::query()
                ->where('secretaria_id', $secretaria->id)->whereKey($id)
                ->update(['position' => $position + 1]);
        }
    });

    return response()->json(['ok' => true]);
}
```

### 6.3 API (dados abertos)

| Método | URI | Nome | Middleware |
|---|---|---|---|
| GET | `/api/v1/secretarias` | `api.v1.secretarias.index` | `api`, `throttle:60,1` |

O controller aplica os filtros `titulo` e `descricao` **dentro da query** (com binding
e `TRANSLATE` para insensibilidade a acentos), ordena por `publication_date` e
`clicks` descendentes e pagina com `min(limite, 500)`.

⚠ Correção do bug do legado: na v6 os `whereRaw()` eram chamados **sem reatribuição** e
os filtros nunca eram aplicados.

## 7. API Resource

```php
// app/Http/Resources/SecretariaResource.php
class SecretariaResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'titulo' => $this->title,
            'descricao' => $this->when(filled($this->text), strip_tags(trim($this->text))),
            'interacoes' => (int) $this->clicks,
            'slug' => $this->slug,
            'arquivos' => ArchiveResource::collection($this->whenLoaded('archives')),
        ];
    }
}
```

Envelope: `{"data": [...], "links": {...}, "meta": {...}}` (paginador do Laravel 11/12);
com `->additional()` mantém-se o envelope `{"data": [...]}` do legado.

## 8. Views Blade modernas

### 8.1 Componente único de menu (elimina a duplicação entre as 2 views)

```blade
{{-- resources/views/components/portal/secretaria-menu.blade.php --}}
@props(['secretaria', 'activePageId' => null])

<div class="col-md-4 col-sm-12 btn-group-vertical mb-4 nav-pub align-top d-table pr-3">
    <div class="card {{ count($secretaria->departments) > 14 ? 'scrollbar' : '' }} mb-3 mt-1">
        <ul class="list-group list-unstyled categories scrollbar-sm">
            @foreach ($secretaria->departments as $department)
                <div class="dropdown dropdown-active list-group-flush">
                    <p class="list-group-item pointer dropdown_p mb-0 mt-0" role="button"
                       data-bs-toggle="collapse" data-bs-target="#dep-{{ $department->id }}">
                        {{ $department->name }}
                    </p>
                    <div class="collapse" id="dep-{{ $department->id }}">
                        @foreach ($department->pages as $page)
                            <a href="{{ route('portal.secretarias.pages.show', ['slug' => $secretaria->slug, 'id' => $page->id]) }}"
                               class="list-group-item list-group-item-action {{ $activePageId === $page->id ? 'selected-item' : '' }}">
                                <span class="text-md-left">{{ $page->title }}</span>
                            </a>
                        @endforeach
                    </div>
                </div>
            @endforeach
        </ul>
    </div>
</div>
```

Uso: `<x-portal.secretaria-menu :secretaria="$secretaria" />` na página da secretaria e
`<x-portal.secretaria-menu :secretaria="$secretaria" :active-page-id="$page->id" />` na
página institucional (ambas sob `<x-layouts.portal :model="$model">`).

### 8.2 Bloco de notícias (sem `splice()`)

```blade
@forelse ($news as $item)
    <li class="mx-sm-auto m-md-0 mb-md-3 col-md-6 pb-2">
        <a href="{{ route('portal.noticias.show', $item->slug) }}" class="row align-items-center">
            <div class="col-md-4">
                <img src="{{ $item->highlight?->url ?? asset('img/exemplos/sem-imagem-min.png') }}"
                     alt="" style="width:115px;height:80px" class="pr-3">
            </div>
            <div class="text-secondary col-md-8">
                <p class="badge bg-color-4 p-2 mb-1">{{ $item->published_at?->format('d.m.Y') }}</p>
                <p class="m-0 w-75">{{ Str::limit($item->title, 19) }}</p>
                <span class="badge bg-color-1">{{ $item->category?->name }}</span>
            </div>
        </a>
    </li>
@empty
    <p class="ml-3">Nenhuma notícia cadastrada</p>
@endforelse
```

### 8.3 Grade do CMS (paginação no servidor)

O componente `<x-admin.table>` deve fazer `paginate(15)` **no servidor** e devolver
`$rows->links()` — nada de carregar a tabela inteira. Ações da linha: link para
`admin.secretarias.menu` ("Menus das secretarias") e para
`portal.secretarias.show` ("Acessar secretaria", `target="_blank"`).

## 9. Fila, cache e storage

| Aspecto | Legado | v7 |
|---|---|---|
| **Fila** | Nenhuma: upload, compressão e reordenação são síncronos | `ShouldQueue` para compressão/recorte (`Intervention` + queue) e miniaturas |
| **Cache** | `Cache::rememberForever('modulos')` (slug→status) | `Cache::tags(['modules'])->rememberForever('modules:secretarias', 3600, ...)` + invalidação ao ligar/desligar o módulo |
| **Cache de view** | Nenhum | `Cache::remember('secretarias.list', 300, …)`, invalidado por evento de modelo |
| **Storage** | Disk `public`, raiz `storage/app/public`, paths `content/secretarias_paginas/{id}/{destaque,arquivos}` | Manter `public` (compatibilidade com `/storage/...`); prefixo `secretarias/{id}/…` |
| **Nomes de arquivo** | `uniqid() . str_random(5) . '.' . ext` | `Str::uuid()` + extensão (`uniqid` é previsível) |
| **Eventos** | Nenhum | `SecretariaUpdated` para invalidar cache e regenerar o sitemap |
| **Sitemap** | `Portal\SitemapController` | Incluir as secretarias e as páginas |

## 10. Riscos e decisões de equivalência

| Risco | Impacto | Mitigação |
|---|---|---|
| Migração do vínculo por slug | Notícias órfãs se algum slug tiver mudado | Backfill por slug + relatório de slugs não encontrados; manter a coluna legada por 1 release |
| URLs públicas mudarem | Links externos e indexação perdidos | Manter as URIs antigas ou redirects 301 |
| Remoção das views SQL | Outros pontos que fazem `DB::table('secretarias')` quebram | Grep por `secretarias` em `app/` e `resources/views/` antes de remover |
| Tabelas de departamentos/páginas vazias | Migração sem dados para validar | Massa de teste: 2 secretarias × 2 departamentos × 2 páginas |
| `modulos.status` desligado | Recriação do módulo continua inacessível no CMS | Migrar `status = true` **e** recriar o cache |
| `resources` de departamentos/páginas ausentes na v6 | Rebuild do zero perde as permissões | Criar `secretarias`, `secretarias-categorias`, `secretarias-paginas` na migration de seeds |
| `archives.reference_id` é `text` sem FK | Sem índice nem integridade | Migrar para `morphs()` e recriar os vínculos por `reference_id` + `highlight` |
| Soft delete inconsistente | `contents.deleted_at` × `archives.deleted_at` × `categorias_secretarias.deleted_at` | `SoftDeletes` em todas as tabelas + `withTrashed` explícito onde necessário |
| Duplicidade de slug no legado | Slugs como `saude-27` convivem com `saude` | Índice unique + renomear/sufixar na migração, com mapa de redirect |
| Exclusão de secretaria | A FK de `categorias_secretarias` impede o `DELETE` | `cascadeOnDelete()` ou exclusão em cascata no service |
| Ordem gravada como `order` | Palavra reservada em SQL | Renomear para `position` |

## 11. Checklist de paridade (v6 → v7)

- [ ] `/a-instituicao/secretarias/{slug}` responde 200 para os 13 slugs do banco atual.
- [ ] `/a-instituicao/secretarias/paginas/{slug}/{id}` renderiza título, texto, destaque e anexos.
- [ ] O menu de departamentos aparece apenas quando há ≥ 1 departamento, ordenado por `position`.
- [ ] O bloco "Notícias de X" traz no máximo 12 itens, ordenados por `created_at` desc.
- [ ] A agenda traz no máximo 5 eventos futuros.
- [ ] A grade do CMS pagina no servidor (25 linhas por página).
- [ ] A reordenação é transacional e restrita à secretaria exibida.
- [ ] `GET /api/v1/secretarias` respeita `titulo`, `descricao` e `limite`.
- [ ] O botão "Cadastrar Secretaria" respeita a policy `create`.
- [ ] O select "Secretaria" do formulário de notícias aparece (módulo habilitado).
- [ ] O breadcrumb "A Instituição" aponta para `/a-instituicao/{slug}` (sem nome duplicado).
- [ ] `POST /portal/secretarias/{slug}` redireciona para uma rota **existente** (sem 500).
- [ ] A exclusão de uma secretaria remove departamentos, páginas e anexos em cascata.