# Notícias — Migração para Laravel Moderno (PHP 8.x / Laravel 11-12)

## 1. Schema de migração equivalente

```php
// database/migrations/2026_xx_xx_create_contents_table.php
Schema::create('contents', function (Blueprint $table) {
    $table->id();
    $table->string('title', 191);
    $table->string('linha_fina', 300)->nullable();
    $table->text('text')->nullable();
    $table->unsignedInteger('clicks')->default(0);
    $table->boolean('highlights')->default(false);
    $table->boolean('galery')->default(false);
    $table->boolean('status')->default(false);
    $table->boolean('carousel')->default(false);
    $table->string('source', 191)->nullable();
    $table->string('author', 191)->nullable();
    $table->string('icon', 191)->nullable();
    $table->string('slug', 191)->unique();
    $table->timestamp('publication_date')->nullable();
    $table->unsignedInteger('order')->default(0);
    $table->foreignId('category_contents_id')->constrained('category_contents');
    $table->string('secretaria', 191)->nullable();
    $table->unsignedBigInteger('position_id')->nullable();
    $table->string('acronym', 191)->nullable();
    $table->unsignedInteger('layout')->nullable();
    $table->foreignId('councilmen_id')->nullable()->constrained('city_councilors');
    $table->string('referencia', 50)->nullable();
    $table->timestamps();
    $table->softDeletes();

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

Equivalências que **não podem** mudar:

| Legado | Novo | Motivo |
|---|---|---|
| `publication_date` NULL significa "sem data" | manter `nullable` | `scopeByPublication` usa `orWhere('publication_date', null)` |
| `status = false` = rascunho | manter `boolean` | filtro do portal |
| `highlights`/`carousel` booleanos | manter booleanos | o JS do CMS alterna os dois |
| `category_contents_id` NOT NULL | manter NOT NULL | integridade |
| Soft delete | manter `SoftDeletes` | links externos (menu, URL) continuam válidos |

`category_contents` mantém a auto-referência; recomenda-se
`->nullable()->constrained('category_contents')` e a criação da raiz
"Notícias" por seeder.

## 2. Modelos

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

    protected $fillable = [
        'title', 'linha_fina', 'text', 'clicks', 'highlights', 'order',
        'galery', 'category_contents_id', 'secretaria', 'status_create',
        'publication_date', 'author', 'source', 'status', 'slug', 'icon',
        'referencia', 'carousel', 'orgaos_id', 'councilmen_id',
        'position_id', 'acronym', 'layout',
    ];

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

    public function category(): BelongsTo     { return $this->belongsTo(ContentCategory::class, 'category_contents_id'); }
    public function archives(): HasMany       { return $this->hasMany(Archive::class); }
    public function highlightArchive(): HasOne { return $this->hasOne(Archive::class)->where('highlight', 1); }
    public function gallery(): HasMany         { return $this->hasMany(Archive::class)->whereNull('highlight')->orderBy('order'); }
    public function videoLink(): HasOne        { return $this->hasOne(Link::class); }
}
```

### Scopes modernizados (sem sessão)

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

public function scopeOfCategoryTree(Builder $q, ?string $slug = null): Builder
{
    return $q->whereHas('category', fn ($q) => $q->where('parent_id', NewsRoot::ID)
        ->when($slug && $slug !== 'noticias', fn ($q) => $q->where('slug', $slug)));
}
```

### Enums sugeridos

| Enum | Valores | Uso |
|---|---|---|
| `ContentStatus` | `DRAFT`, `PUBLISHED` | substitui `contents.status` |
| `ContentKind` | `NEWS`, `PUBLICATION`, `LEGISLATION`, `BANNER`, ... | substitui a coluna `contents.kind` (hoje implícita pela categoria) |
| `ArchiveRole` | `HIGHLIGHT`, `GALLERY`, `ATTACHMENT`, `INLINE_PDF` | substitui `archives.highlight` + `view_pdf` |

> Migração de dados: `ContentStatus` mapeia `true -> PUBLISHED`,
> `false -> DRAFT`; `ArchiveRole` mapeia `highlight = 1 -> HIGHLIGHT`,
> `view_pdf = true -> INLINE_PDF`, demais `NULL` -> `GALLERY`.

## 3. Form Requests

```php
final class StoreNewsRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'title'           => ['required', 'string', 'max:150'],
            'linha_fina'      => ['nullable', 'string', 'max:300'],
            'publication_date'=> ['required', 'date'],
            'text'            => ['required', 'string'],
            'author'          => ['nullable', 'string', 'max:120'],
            'source'          => ['nullable', 'string', 'max:120'],
            'category_contents_id' => ['required', Rule::exists('category_contents', 'id')],
            'highlights'      => ['boolean'],
            'archive_highlight' => ['nullable', 'file', 'max:20000', 'mimes:jpg,jpeg,png,gif,webp'],
            'archive'         => ['nullable', 'array'],
            'archive.*'       => ['file', 'mimes:rar,zip,doc,docx,pdf,odt,xls,xlsx,xlsb,xslm,ppt,pptx'],
        ];
    }
}
```

Regras do legado que **devem** ser preservadas: `title|max:150`,
`source|max:120`, `author|max:120`, `link|max:2999`, `slug` obrigatória apenas
na atualização, e a lista fechada de extensões de anexo.

## 4. Policies / autorização

```php
final class ContentPolicy
{
    public function viewAny(User $u): bool     { return $u->can('noticias.view'); }
    public function create(User $u): bool      { return $u->can('noticias.create'); }
    public function update(User $u, Content $c): bool { return $u->can('noticias.edit'); }
    public function delete(User $u, Content $c): bool { return $u->can('noticias.delete'); }
    public function deleteAny(User $u): bool   { return $u->can('noticias.delete'); }
}
```

Mapeamento legado -> novo:

| Legado | Novo |
|---|---|
| `has_access(['noticias.create'])` | `Gate::allows('create', Content::class)` |
| `has_access("$permission.edit")` | `Gate::allows('update', $content)` |
| `has_access("$permission.delete")` | `Gate::allows('delete', $content)` |
| `Sentinel::inRole('super-admin')` | `$u->hasRole('super-admin')` |

## 5. Endpoints REST/JSON

### Portal (público)

| Método | URI | Nome |
|---|---|---|
| GET | `/noticias` | `noticia.index` |
| GET | `/noticias/{categoria:slug}` | `noticia.categoria` |
| GET | `/noticia/{conteudo:slug}` | `noticia.show` |
| GET | `/api/noticias` | `api.noticias.index` (paginado) |
| GET | `/api/noticias/{conteudo:slug}` | `api.noticias.show` |

### CMS

| Método | URI | Nome |
|---|---|---|
| GET | `/cms/noticias` | `cms.noticias.index` |
| GET | `/cms/noticias/create` | `cms.noticias.create` |
| POST | `/cms/noticias` | `cms.noticias.store` |
| GET | `/cms/noticias/{conteudo}/edit` | `cms.noticias.edit` |
| PUT | `/cms/noticias/{conteudo}` | `cms.noticias.update` |
| DELETE | `/cms/noticias/{conteudo}` | `cms.noticias.destroy` |
| POST | `/cms/noticias/destroy-bulk` | `cms.noticias.destroy-bulk` |
| POST | `/cms/noticias/{conteudo}/toggle-highlight` | `cms.noticias.toggle-highlight` |
| POST | `/cms/noticias/{conteudo}/toggle-status` | `cms.noticias.toggle-status` |
| POST | `/cms/noticias/reorder` | `cms.noticias.reorder` |

### API de dados abertos (compatibilidade)

`GET /api/v1/noticias` deve continuar respondendo no envelope atual
(`data[]` com `titulo`, `data_publicacao`, `descricao`, `interacoes`, `slug`,
`arquivos[]`), para não quebrar consumidores externos.

## 6. API Resources

```php
final class NewsResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'              => $this->id,
            'titulo'          => $this->title,
            'linha_fina'      => $this->linha_fina,
            'descricao'       => strip_tags($this->text),
            'data_publicacao' => $this->publication_date?->toIso8601String(),
            'autor'           => $this->author,
            'fonte'           => $this->source,
            'categoria'       => $this->category->name,
            'slug'            => $this->slug,
            'interacoes'      => $this->clicks,
            'arquivos'        => ArchiveResource::collection($this->gallery),
            'links'           => ['self' => route('api.noticias.show', $this->slug)],
        ];
    }
}
```

## 7. Views Blade modernas

| Legado | Novo |
|---|---|
| `prefecture/contents/noticias/index.blade.php` | `<x-admin.datatable resource="noticias" />` |
| `prefecture/contents/noticias/form.blade.php` | `<x-admin.content-form type="noticias" />` |
| `portal/layouts/*/noticias/index.blade.php` | `<x-portal.news-list :contents="$contents" :layout="$layout" />` |
| `portal/layouts/*/noticias/show.blade.php` | `<x-portal.news-article :content="$content" />` |

Componentes sugeridos: `<x-portal.news-card>`, `<x-portal.news-gallery>`,
`<x-portal.news-video>` (player YouTube/Vimeo com thumbnail do
`ContentPresenter`).

## 8. Fila, cache e storage

| Aspecto | Legado | Proposto |
|---|---|---|
| Compressão de imagens | síncrona, em `verifyCompress()` (`ContentController.php:1589-1604`) | job `CompressImage` na fila `default` |
| Upload do Summernote | move arquivo no controller | upload direto para S3 + `Storage::disk('s3')` |
| Listagens | sem cache | `Cache::remember("news:cat:{$slug}:p{$page}", 300, ...)` com invalidação no `saved` do model |
| Cliques | `read-modify-write` | `Content::whereKey($id)->increment('clicks')` (atômico) |
| Busca | `whereRaw` com `translate()` | índice `pg_trgm` + `ILIKE` com binding, ou busca externa |
| Áudio/vídeo | — | transcode em fila (opcional) |

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

| Risco | Decisão |
|---|---|
| Matar a listagem do portal ao trocar a semântica de "categoria pai = 1" | Manter a regra "notícia = conteúdo cuja categoria é **filha** da raiz Notícias" e criar a raiz por seeder com id fixo; esconder a regra dentro de um scope nomeado |
| Perder a ordenação manual da galeria | Manter `archives.order` e ordenar por ele (como na v6) |
| Quebrar URLs antigas (`/noticia/{slug}`) | Manter as mesmas rotas e o mesmo formato de slug |
| Quebrar a API de dados abertos | Manter `/api/v1/noticias` e o envelope atual |
| Rascunho com data futura | Manter a semântica de "publicado = status && data <= now" |
| Loss de `quantidade_itens`/`layout` nulos | Normalizar para `0` e `2` na migração, com `default` no schema |
| Vazamento de sessão | Eliminar `session()` dos scopes — não há dependência externa do contrato de sessão |
| Filtro por `translate()` | Preservar o comportamento de busca sem acento com índice funcional, ou aceitar pequena divergência de ranking |
