# Publicações — Migração para Laravel Moderno (PHP 8.x / Laravel 11-12)

## 1. Schema de migração equivalente

Publicações reutiliza `contents`. Recomenda-se **separar** a tabela em um
modelo próprio com uma visão (ou uma coluna discriminadora) para não
misturar os três módulos (Notícias, Publicações, Legislação) na mesma
entidade.

```php
// database/migrations/2026_xx_xx_create_publications_table.php
Schema::create('publications', function (Blueprint $table) {
    $table->id();
    $table->foreignId('publication_category_id')->constrained('publication_categories');
    $table->string('title', 191);
    $table->text('body');
    $table->string('slug', 191)->unique();
    $table->timestamp('publication_date');
    $table->boolean('status')->default(false);
    $table->unsignedInteger('clicks')->default(0);
    $table->unsignedTinyInteger('layout')->default(2);   // 1 = com barra lateral, 2 = sem
    $table->timestamps();
    $table->softDeletes();

    $table->index(['publication_category_id', 'publication_date']);
});
```

Tabela de categorias (substitui a auto-referência de `category_contents`):

```php
Schema::create('publication_categories', function (Blueprint $table) {
    $table->id();
    $table->foreignId('parent_id')->nullable()->constrained('publication_categories');
    $table->string('name', 191);
    $table->string('slug', 191)->unique();
    $table->unsignedInteger('order')->default(0);
    $table->timestamps();
    $table->softDeletes();
});
```

### Regras de migração de dados

| Origem (`contents` / `category_contents`) | Destino | Regra |
|---|---|---|
| `contents.category_contents_id IN (13..21)` | `publications.publication_category_id` | `WHERE category_contents_id IN (SELECT id FROM category_contents WHERE category_contents_id = 3)` |
| `contents.layout` | `publications.layout` | `COALESCE(layout, 2)` |
| `contents.clicks` | `publications.clicks` | direto |
| `contents.publication_date NULL` | — | Em Publicações a data é **obrigatória** (`ContentRequest`); registres como pendência de negócio |
| `category_contents` com pai = 3 | `publication_categories` | `parent_id = NULL` (a raiz 3 vira a própria raiz da nova tabela) |
| `archives.contents_id` dos documentos migrados | `publication_files` | `publication_id` + `role` (`ATTACHMENT` / `INLINE_PDF`) |

## 2. Modelos

```php
final class Publication extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'title', 'body', 'slug', 'publication_date', 'status',
        'layout', 'publication_category_id',
    ];

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

    public function category(): BelongsTo
    {
        return $this->belongsTo(PublicationCategory::class, 'publication_category_id');
    }

    public function files(): HasMany
    {
        return $this->hasMany(PublicationFile::class);
    }

    public function inlinePdf(): HasOne
    {
        return $this->hasOne(PublicationFile::class)->where('role', PublicationFileRole::INLINE_PDF);
    }
}
```

### Scopes

```php
public function scopePublished(Builder $q): Builder
{
    return $q->where('status', true)->where('publication_date', '<=', now());
}

public function scopeOfCategory(Builder $q, string $slug): Builder
{
    return $q->whereHas('category', fn ($q) => $q->where('slug', $slug));
}
```

### Enums

| Enum | Valores |
|---|---|
| `PublicationLayout` | `WITH_SIDEBAR = 1`, `WITHOUT_SIDEBAR = 2` |
| `PublicationFileRole` | `ATTACHMENT`, `INLINE_PDF` |
| `PublicationStatus` | `DRAFT`, `PUBLISHED` |
| `ExportFormat` | `XLS`, `PDF`, `CSV`, `TXT` |

## 3. Form Requests

```php
final class StorePublicationRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'title'                => ['required', 'string', 'max:120'],
            'publication_date'     => ['required', 'date'],
            'publication_category_id' => ['required', Rule::exists('publication_categories', 'id')],
            'body'                 => ['required', 'string'],
            'layout'               => ['required', Rule::in([1, 2])],
            'slug'                 => ['nullable', 'string', 'max:120', Rule::unique('publications', 'slug')],
            'files'                => ['nullable', 'array'],
            'files.*'              => ['file', 'mimes:rar,zip,doc,docx,pdf,odt,xls,xlsx,xlsb,xslm,ppt,pptx'],
        ];
    }
}
```

Regras do legado a preservar:

- `title|max:120` (mais apertado que o `max:150` de Notícias);
- `publication_date` **obrigatória**;
- `layout` numérico e obrigatório;
- `slug` obrigatória apenas na atualização;
- lista fechada de extensões de anexo.

## 4. Policies

```php
final class PublicationPolicy
{
    public function viewAny(User $u): bool        { return $u->can('publicacoes.view'); }
    public function create(User $u): bool         { return $u->can('publicacoes.create'); }
    public function update(User $u, Publication $p): bool { return $u->can('publicacoes.edit'); }
    public function delete(User $u, Publication $p): bool { return $u->can('publicacoes.delete'); }
    public function export(User $u): bool         { return $u->can('publicacoes.view'); }
}
```

## 5. Endpoints

### Portal

| Método | URI | Nome |
|---|---|---|
| GET | `/publicacoes` | `publicacoes.index` (redireciona para a 1ª categoria) |
| GET | `/publicacoes/{categoria:slug}` | `publicacoes.show` |
| GET | `/publicacoes/{categoria:slug}/{publicacao:slug}` | `publicacoes.view` |
| GET | `/publicacoes/export` | `publicacoes.export` |

### CMS

| Método | URI | Nome |
|---|---|---|
| GET | `/cms/publicacoes` | `cms.publicacoes.index` |
| GET | `/cms/publicacoes/create` | `cms.publicacoes.create` |
| POST | `/cms/publicacoes` | `cms.publicacoes.store` |
| GET | `/cms/publicacoes/{publicacao}/edit` | `cms.publicacoes.edit` |
| PUT | `/cms/publicacoes/{publicacao}` | `cms.publicacoes.update` |
| DELETE | `/cms/publicacoes/{publicacao}` | `cms.publicacoes.destroy` |
| POST | `/cms/publicacoes/destroy-bulk` | `cms.publicacoes.destroy-bulk` |
| GET/POST/PUT/DELETE | `/cms/categorias/publicacoes` | `cms.publicacoes.categories.*` |

### API

`GET /api/v1/publicacoes` (compatibilidade com o envelope atual) e
`GET /api/publicacoes` (JSON paginado novo, com filtros `ano`, `mes`, `q`,
`categoria`, `per_page`).

## 6. Recursos de API

```php
final class PublicationResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'              => $this->id,
            'titulo'          => $this->title,
            'descricao'       => strip_tags($this->body),
            'data_publicacao' => $this->publication_date?->toDateTimeString(),
            'interacoes'      => $this->clicks,
            'slug'            => $this->slug,
            'categoria'       => $this->category->name,
            'arquivos'        => ArchiveResource::collection($this->files),
            'links'           => ['self' => route('api.publicacoes.show', $this->slug)],
        ];
    }
}
```

## 7. Exportação

```php
final class PublicationExportController extends Controller
{
    public function __invoke(ExportPublicationsRequest $request, PublicationExporter $exporter)
    {
        $publications = Publication::query()
            ->published()
            ->when($request->categoria, fn ($q) => $q->ofCategory($request->categoria))
            ->when($request->ano,   fn ($q) => $q->whereYear('publication_date', $request->ano))
            ->when($request->mes,   fn ($q) => $q->whereMonth('publication_date', $request->mes))
            ->when($request->q,     fn ($q) => $q->search($request->q))
            ->get();

        return $exporter->download($publications, $request->type);
    }
}
```

| Formato | Implementação moderna |
|---|---|
| XLSX | `maatwebsite/excel` com `FromQuery` + `WithChunkReading` (evita carregar tudo em memória) |
| CSV | `League\Csv` ou `streamDownload` |
| TXT | `streamDownload` com TSV |
| PDF | `barryvdh/laravel-dompdf` com a view `exports.contents` migrada para Blade/x-filament |

Nome do arquivo: `publicacoes_{categoria}_{Ymd_His}.{ext}` (mesmo padrão do
legado).

## 8. Views Blade modernas

| Legado | Novo |
|---|---|
| `prefecture/contents/publicacoes/index.blade.php` | `<x-admin.datatable resource="publicacoes" />` |
| `prefecture/contents/publicacoes/form.blade.php` | `<x-admin.publication-form />` |
| `portal/layouts/*/publicacoes/show.blade.php` | `<x-portal.publication-list>` + `<x-portal.publication-filters>` |
| `portal/layouts/*/publicacoes/view.blade.php` | `<x-portal.publication-document :layout="$content->layout" />` |
| `portal/components/categories-contents/publicacoes/nav.blade.php` | `<x-portal.category-nav type="publicacoes" />` |

## 9. Fila, cache e storage

| Aspecto | Legado | Proposto |
|---|---|---|
| Geração de PDF | síncrona, dentro da requisição | job `GeneratePublicationPdf` + `response()->download()` assinado |
| Compressão de anexos | síncrona (`verifyCompress`) | fila `default` |
| Paginação da listagem | 12 por página em memória | `paginate(12)` com `withQueryString()` |
| Cache das categorias | nenhuma | `Cache::remember('publicacoes:categorias', 600, ...)` |
| Cliques | read-modify-write | `increment('clicks')` |
| Busca | `whereRaw` + `translate()` | índice `pg_trgm` + `ILIKE` com binding, ou `unaccent` do Postgres |
| Armazenamento | `storage/content/...` no disco local | disco S3 com `Storage::disk('s3')` |

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

| Risco | Decisão |
|---|---|
| Mudar a URL `/publicacoes/{cat}/{slug}` | Manter exatamente esse formato (o "Link Permanente" é publicado em editais e é indexado por buscadores) |
| Perder o "Link Permanente" editável | Manter o campo editável apenas na edição, com unicidade validada |
| Perder a semântica do `layout` | Manter 1 = com barra lateral, 2 = sem; `NULL`/ausente = com barra lateral |
| Perder o filtro acento-insensível | Manter o comportamento (índice funcional ou `unaccent`) |
| Perder o filtro de mês dependente do ano | **Corrigir**: o filtro de mês só deve ser aplicado quando há ano, mas isso muda o resultado observável -> avaliar com o negócio |
| Perder a exportação | Manter os 4 formatos e o padrão de nome de arquivo |
| Perder a visibilidade de rascunhos | Manter `status` no schema e na policy |
| Performance da busca | Indexar com `pg_trgm`; se o Postgres for mantido, ok |
| Compartilhar `contents` com Notícias | Manter a tabela única **ou** migrar com cuidado: o "Link Permanente" e as URLs antigas devem continuar válidos |
