# Órgãos — Migração para Laravel Moderno (PHP 8.x / Laravel 11-12)

> Plano de reconstrução do módulo Órgãos, preservando o comportamento observável do portal e do CMS
> legados (`v6`).

---

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

| # | Decisão | Justificativa |
|---|---|---|
| D1 | Manter a URL `/a-instituicao/orgaos/{slug}` | Linkada em menus, material impresso e por terceiros. Adicionar redirecionamento 301 de `/a-prefeitura/orgaos/{slug}` |
| D2 | Manter a mesma estrutura de JSON da API (`titulo`, `descricao`, `interacoes`, `slug`, `arquivos`, envelope `data`) | Consumidores externos (SIM / dados abertos) |
| D3 | Manter o incremento de `clicks` a cada visita | Alimenta `interacoes` e a ordenação da API |
| D4 | Manter a exclusão dos `menu_items` vinculados ao excluir um órgão | Evita itens de menu órfãos apontando para conteúdo inexistente |
| D5 | Manter o vínculo `notícia -> órgão` e `evento -> órgão` | Requisito institucional; no legado está inerte apenas por configuração |
| D6 | Manter o agrupamento de menu "Órgãos" com filhos por órgão | Estrutura de navegação já publicada |
| D7 | Manter o `slug` estável do conteúdo | URLs permanentes |
| D8 | Manter a categoria 32 (ou recriar com o mesmo `slug`) | O `slug` é o gancho de roteamento, de API e da view SQL |

## 2. Schema de migração

O legado usa **auto-referência** (`contents.orgaos_id -> contents.id`) para vincular notícias a órgãos.
Numa reconstrução, o ideal é separar a entidade:

```php
// (ilustrativo) database/migrations/2026_xx_xx_create_organs_table.php
Schema::create('organs', function (Blueprint $table) {
    $table->id();
    $table->string('title', 120);
    $table->string('slug', 191)->unique();
    $table->longText('text');
    $table->string('summary')->nullable();          // substitui 'linha_fina'
    $table->string('video_url', 2999)->nullable();   // substitui a tabela 'links'
    $table->boolean('has_gallery')->default(false);  // substitui 'galery'
    $table->boolean('status')->default(true);
    $table->unsignedInteger('order')->default(0);   // ordenação manual
    $table->dateTime('publication_date')->nullable();
    $table->unsignedBigInteger('clicks')->default(0);
    $table->foreignId('category_content_id')->nullable();  // 32 no legado
    $table->timestamps();
    $table->softDeletes();
});

Schema::table('contents', function (Blueprint $table) {
    $table->foreignId('organ_id')->nullable();   // substitui contents.orgaos_id
    $table->foreign('organ_id')->references('id')->on('organs');
});

Schema::table('calendar_events', function (Blueprint $table) {
    $table->foreignId('organ_id')->nullable();
    $table->foreign('organ_id')->references('id')->on('organs');
});
```

> `contents.orgaos_id` e `calendar_events.orgaos_id` passam a `organ_id` — os nomes das colunas devem ser renomeados na
> same migration that copies the data, keeping the legacy value in `organ.legacy_content_id`.

### 2.1 Migração de dados (do schema legado)

```php
// (ilustrativo) database/migrations/2026_xx_xx_migrate_organs_from_contents.php
DB::table('contents')
    ->join('category_contents', 'category_contents.id', '=', 'contents.category_contents_id')
    ->where('category_contents.slug', 'orgaos')
    ->whereNull('contents.deleted_at')
    ->orderBy('contents.created_at')
    ->select(['contents.id', 'contents.title', 'contents.slug', 'contents.text',
              'contents.clicks', 'contents.status', 'contents.order',
              'contents.publication_date', 'contents.galery'])
    ->chunk(200, function ($rows) { /* insert em organs + mapa id legado -> id novo */ });
```

### 2.2 Tabela de configuração do módulo

Manter o conceito de `modulos` (ligar/desligar) é útil, mas a decisão deve ser **explícita**:

```php
// (ilustrativo) migration para a tabela de chaves de módulo
Schema::create('module_settings', function (Blueprint $table) {
    $table->id();
    $table->string('slug')->unique();   // 'orgaos'
    $table->boolean('enabled')->default(false);
    $table->timestamps();
});
```

> **Atenção:** no legado, `modulos.status = false` para `orgaos` **desabilita duas features** ao mesmo
> tempo: (a) a entrada no menu lateral do CMS e (b) o select "Órgãos" do formulário de notícias. Na
> reconstrução, separe os dois conceitos: `organs.enabled` (módulo visível) e
> `news.show_organ_selector` (select de vínculo).

## 3. Modelos

### 3.1 `App\Models\Organ`

```php
// (ilustrativo) app/Models/Organ.php
namespace App\Models;

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

class Organ extends Model
{
    use HasFactory, SoftDeletes;

    protected $table = 'organs';

    protected $fillable = [
        'title', 'slug', 'text', 'summary', 'video_url', 'has_gallery',
        'status', 'order', 'publication_date', 'clicks', 'category_content_id',
    ];

    protected $casts = [
        'has_gallery' => 'boolean',
        'status' => 'boolean',
        'publication_date' => 'datetime',
        'clicks' => 'integer',
        'order' => 'integer',
    ];

    // Relations
    public function news()
    {
        return $this->hasMany(Content::class, 'organ_id');   // notícias vinculadas
    }

    public function calendarEvents()
    {
        return $this->hasMany(CalendarEvent::class, 'organ_id');
    }

    public function categoryContent()
    {
        return $this->belongsTo(CategoryContent::class);
    }

    public function menuItems()
    {
        return $this->hasMany(MenuItem::class);
    }

    public function highlightImage()
    {
        return $this->hasOne(Media::class, 'model')->where('collection', 'highlight');
    }

    public function gallery()
    {
        return $this->hasMany(Media::class, 'model')->where('collection', 'gallery');
    }
}
```

### 3.2 Ajuste em `App\Models\Content` e enum de status

```php
// (ilustrativo) app/Models/Content.php
public function organ()
{
    return $this->belongsTo(Organ::class, 'organ_id');
}
```

```php
// (ilustrativo) app/Enums/PublicationStatus.php
enum PublicationStatus: string
{
    case Draft = 'draft';
    case Published = 'published';
    case Scheduled = 'scheduled';
}
```

> O legado usa `contents.status` booleano + `publication_date` nullable. Um enum de 3 estados é
> equivalente funcionalmente e cobre o caso "publicado com data futura" (hoje tratado como publicado
> porque o filtro compara `publication_date <= now`).


## 4. Form Requests

```php
// (ilustrativo) app/Http/Requests/StoreOrganRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreOrganRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Organ::class);
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:120'],
            'text'  => ['required', 'string'],
            'video_url' => ['nullable', 'string', 'max:2999'],
            'highlight_image' => ['nullable', 'image', 'mimes:jpg,jpeg,png,gif,webp', 'max:5120'],
            'gallery' => ['nullable', 'array', 'max:50'],
            'gallery.*' => ['image', 'mimes:jpg,jpeg,png,gif,webp', 'max:5120'],
            'status' => ['required', Rule::enum(PublicationStatus::class)],
            'publication_date' => ['nullable', 'date'],
            'order' => ['nullable', 'integer', 'min:0'],
        ];
    }
}
```

| Regra legada (`ContentRequest`, `case 'orgaos'`) | Equivalente moderno |
|---|---|
| `title` `required|max:120` | `'title' => ['required','string','max:120']` |
| `category_contents_id` `required|numeric` | resolvido pela rota (`/cms/organs` já fixa a categoria); se mantido, `Rule::exists('category_contents','id')` |
| `text` `required` | `'text' => ['required','string']` |
| `link` `max:2999` | `'video_url' => ['nullable','string','max:2999']` |
| `archive_highlight` `max:20000|mimes:jpg,jpeg,png,gif,webp` | `'highlight_image' => ['nullable','image','mimes:jpg,jpeg,png,gif,webp','max:5120']` (20000 KB ≈ 5 MB, coerente com o limite do JS legado) |

> A validação de `archive[]` por extensão (`rar, zip, doc, docx, pdf, ...`) deve continuar existindo se o
> órgão aceitar documentos; em Laravel moderno use `File::types([...])->max('20mb')`.

## 5. Policies

```php
// (ilustrativo) app/Policies/OrganPolicy.php
namespace App\Policies;

class OrganPolicy
{
    public function viewAny(User $user): bool { return $user->hasPermission('orgaos.index'); }
    public function view(User $user, Organ $organ): bool { return true; } // público
    public function create(User $user): bool { return $user->hasPermission('orgaos.create'); }
    public function update(User $user, Organ $organ): bool { return $user->hasPermission('orgaos.edit'); }
    public function delete(User $user, Organ $organ): bool { return $user->hasPermission('orgaos.delete'); }
    public function restore(User $user, Organ $organ): bool { return $user->hasPermission('orgaos.restore'); }
}
```

Registrar em `AppServiceProvider`:

```php
// (ilustrativo)
Gate::policy(Organ::class, OrganPolicy::class);
```

> Isto substitui a lógica de `has_access()` + `CheckRoutePermission`, que no legado **permite**
> criar/editar/excluir para qualquer usuário logado (ver `05-REGRAS-E-PORTABILIDADE.md`, seção 3.5).
> A modernização deve **fechar** essa brecha, mantendo as mesmas permissões do papel `administrador`.

## 6. Endpoints REST/JSON sugeridos

### 6.1 API pública (dados abertos)

```php
// (ilustrativo) routes/api.php
Route::prefix('v1')->group(function () {
    Route::get('organs', [OrganApiController::class, 'index'])->name('v1.organs.index');
});
```

```php
// (ilustrativo) app/Http/Controllers/Api/V1/OrganController.php
public function index(OrganIndexRequest $request): AnonymousResourceCollection
{
    $organs = Organ::query()->with('archives')
        ->when($request->filled('titulo'),
            fn ($q) => $q->whereRaw('lower(title) like ?', ['%' . mb_strtolower($request->input('titulo')) . '%']))
        ->when($request->filled('descricao'),
            fn ($q) => $q->whereRaw('lower(text) like ?', ['%' . mb_strtolower($request->input('descricao')) . '%']))
        ->orderByDesc('publication_date')->orderByDesc('clicks')
        ->when($request->filled('limite'), fn ($q) => $q->limit((int) $request->input('limite')))
        ->get();

    return OrganResource::collection($organs);
}
```

> **Correção obrigatória do bug legado**: os filtros precisam ser aplicados de fato (no legado
> `$contents->whereRaw(...)` sem reatribuição é um no-op) e devem usar **bindings** — o legado concatena
> o valor do usuário diretamente no SQL. Detalhes e contrato em `09-API-E-DADOS-ABERTOS.md`.

### 6.2 CMS (resource controller)

```php
// (ilustrativo) routes/web.php (grupo prefix 'cms' + middleware 'auth')
Route::resource('organs', OrganController::class)->except(['show']);
Route::post('organs/bulk-destroy', [OrganController::class, 'bulkDestroy'])->name('organs.bulk-destroy');
```

| Endpoint moderno | Equivalente legado |
|---|---|
| `GET /cms/organs` | `GET /cms/conteudos/orgaos` |
| `GET /cms/organs/create` | `GET /cms/conteudos/cadastrar/orgaos` |
| `POST /cms/organs` | `POST /cms/conteudo` |
| `GET /cms/organs/{organ}/edit` | `GET /cms/conteudos/editar/orgaos/{id}` |
| `PUT /cms/organs/{organ}` | `PUT /cms/conteudo/{id}` |
| `DELETE /cms/organs/{organ}` | `DELETE /cms/conteudo/delete/{id}` |
| `POST /cms/organs/bulk-destroy` | `POST /cms/conteudo/deleteAll` |
| `GET /cms/organs/datatable` | `GET /cms/datatable/orgaos` |

## 7. Recursos de API

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

> `descricao` no legado é `strip_tags(trim($this->text))` e só é incluído se não for vazio
> (`$this->when($description != "", $description)`). Preservar esse comportamento evita mudança de
> contrato para o consumidor.

## 8. Views Blade modernas

### 8.1 Componentes

```blade
{{-- (ilustrativo) resources/views/components/organs/news-list.blade.php --}}
@props(['organ'])
@php($news = $organ->news()->published()->latest('publication_date')->paginate(10))
<div class="organ-news">
    @forelse ($news as $noticia)
        <x-news.card :content="$noticia" />
    @empty
        <p>Nenhuma notícia cadastrada</p>
    @endforelse
    {{ $news->links() }}
</div>
```

### 8.2 Rotas do portal

```php
// (ilustrativo) routes/web.php
Route::get('/a-instituicao/orgaos/{organ:slug}', OrganShowController::class)
    ->name('a-instituicao-orgaos.index');
```

> O *route model binding* por `slug` elimina o `Content::where('slug', $param)->first()` manual e resolve
> o 404 de forma idiomática (o legado devolve `admin.errors.conteudo-nao-encontrado`).

> **Templates por layout:** preferencialmente extrair um **único** template `<x-layouts.organ-page>` com
> **slots** por layout. O legado replica o mesmo conteúdo em 6 modelos, com divergências (o `modelo2`
> inclui o arquivo do `modelo1`).

## 9. Fila, cache e storage

| Recurso | Legado | Moderno |
|---|---|---|
| Cache de módulos | `Cache::rememberForever('modulos')` sem invalidação | `Cache::remember('modules', 300, ...)` + invalidação no `update` |
| Cache da listagem | Nenhum | `Cache::remember('organs.index', 60, ...)`; invalidar no `save`/`delete` |
| Fila | Nenhuma | `GenerateOrganThumbnail` (Job) para miniaturas; `ImportOrganArchive` para galeria |
| Storage | `storage/` + `Storage::move()` | `Storage::disk('public')` + `putFileAs()` |
| Thumbnails | `getMinFile()` / `getMedFile()` / `getLargFile()` | `laravel-medialibrary` (conversões `min`/`med`/`larg`) |
| Logs | `activity()->performedOn($content)->log(...)` | Manter `spatie/laravel-activitylog` |

## 10. Riscos e pontos de atenção

| Risco | Mitigação |
|---|---|
| **Quebra de links externos** ao trocar `/a-prefeitura/` por `/a-instituicao/` | Redirecionamento 301 a partir da rota antiga |
| **Mudança do JSON da API** | Congelar o formato `OrganResource` com testes de contrato |
| **Duplicação de conteúdo na migração** | `updateOrCreate` por `slug` e `firstOrCreate` por `contents_id` legado; mapear ids em uma tabela de transição |
| **Perda do `id` legado** (usado por `menu_items.contents_id` e `calendar_events.orgaos_id`) | Guardar `legacy_content_id` na tabela `organs` |
| **Permissões permissivas demais** no legado | A Policy moderna fecha a brecha; documentar a mudança para o suporte |
| **Duplicação dos templates por layout** | Componente único + slots |
| **Ordenação divergente** (`created_at` na view SQL x `order` no `contents`) | Definir e documentar uma ordenação única (sugestão: `order ASC, publication_date DESC`) e comunicá-la ao negócio |
| **`modulos.status` desligado** hides o vínculo notícia↔órgão | Tornar a configuração explícita e separar os dois conceitos |
| **Filtragem por data NULL** | `where(fn ($q) => $q->whereNull('publication_date')->orWhere('publication_date', '<=', now()))` |

## 11. Ordem sugerida de implementação

1. Migration do schema (`organs`, `contents.organ_id`, `calendar_events.organ_id`).
2. Migration de dados (com `legacy_content_id`).
3. Modelos `Organ`, ajustes em `Content` e `CalendarEvent`.
4. Policies e `FormRequest`s.
5. API pública (`OrganApiController` + `OrganResource`) — fixa o contrato.
6. CMS: resource controller + views (DataTable com paginação server-side).
7. Portal: `OrganShowController` + componente de página + lista de notícias paginada.
8. Menu: trocar o caso hardcoded do presenter por configuração.
9. Testes feature/unit (ver `07-TESTES.md`).
10. Redirecionamentos das URLs antigas e plano de rollback.
