# Notícias (V2) — Migração Moderna

> **Slug:** `publicacoes-noticias` · **Destino:** Laravel 12 / PHP 8.4, DDD-lite (`core`/`services`/`layout`/`modules`).
>
> Os blocos marcados como `// exemplo ilustrativo` são propostas; os demais reproduzem o legado. Caminhos do V7 usam a convenção do ADR-013.

---

## 1. Decisões de arquitetura

| Decisão | Escolha | Fundamento |
|---|---|---|
| Camada | `src/modules/Publicacoes/Noticias` | ADR-013 (módulo autocontido). |
| Services consumidos | `Seo`, `RichText`, `Media`/`Gallery`, `Search` | ADR-015 (headless, sempre disponíveis). |
| Sem traits/abstract/generics | Controllers, Actions e Models concretos | ADR-001. |
| Conteúdo | AST TipTap em `jsonb` | ADR-028 / ADR-017. |
| SEO | `metadados['seo']` por módulo + `<x-service-seo::tags>` emitindo `@graph` | ADR-027. |
| Mídia | `MediaInputer` + catálogo Gallery + `<x-service-media::picture>` | ADR-026. |
| Permissões | quatro ações `noticias.{ver|editar|aprovar|arquivar}` | ADR-033. |
| Ativação | middleware `CheckModuleActivation` | ADR-034. |
| Auditoria | subscriber global, sem chamadas manuais | ADR-035. |
| Cache | resposta/ETag + invalidação por evento, sem `KEYS` | ADR-022. |
| Migração de conteúdo | manual pelo CMS, sem ETL | ADR-042. |

## 2. Schema equivalente

Referência do legado: `V2/Modules/Publicacoes/Noticias/Migrations/2026_07_08_000001_create_v2_news_table.php` e `V2/Modules/Publicacoes/Noticias/Migrations/2026_08_19_000001_create_v2_news_categories_table.php`.

```php
<?php
// exemplo ilustrativo — migration do módulo no V7
declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('noticias_categorias', function (Blueprint $table) {
            $table->ulid('id')->primary();
            $table->string('nome', 100);
            $table->string('slug', 100)->unique();
            $table->string('descricao', 500)->nullable();
            $table->string('cor', 7)->nullable();
            $table->unsignedInteger('ordem')->default(0);
            $table->foreignUlid('pai_id')->nullable()
                  ->constrained('noticias_categorias')->nullOnDelete();
            $table->boolean('sistema')->default(false);
            $table->timestamps();
            $table->softDeletes();
        });

        Schema::create('noticias_tags', function (Blueprint $table) {
            $table->ulid('id')->primary();
            $table->string('nome', 100);
            $table->string('slug', 100)->unique();
            $table->timestamps();
            $table->softDeletes();
        });

        Schema::create('noticias', function (Blueprint $table) {
            $table->ulid('id')->primary();
            $table->string('titulo', 255);
            $table->string('slug', 255)->unique();
            $table->string('subtitulo', 500)->nullable();
            $table->jsonb('conteudo')->nullable();      // AST TipTap
            $table->text('resumo')->nullable();
            $table->string('status', 20)->default('draft')->index();
            $table->string('token_preview', 64)->nullable()->index();
            $table->foreignUlid('autor_id')->nullable()
                  ->constrained('cms_users')->nullOnDelete();
            $table->foreignUlid('revisor_id')->nullable()
                  ->constrained('cms_users')->nullOnDelete();
            $table->timestampTz('publicado_em')->nullable()->index();
            $table->text('motivo_rejeicao')->nullable();
            $table->jsonb('metadados')->nullable();     // seo
            $table->foreignId('capa_id')->nullable()
                  ->constrained('gallery_media')->nullOnDelete();
            $table->foreignUlid('categoria_id')->nullable()
                  ->constrained('noticias_categorias')->nullOnDelete();
            $table->boolean('destacada')->default(false)->index();
            $table->boolean('super_destacada')->default(false)->index();
            $table->timestampsTz();
            $table->softDeletesTz();

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

        Schema::create('noticias_tag', function (Blueprint $table) {
            $table->foreignUlid('noticia_id')->constrained('noticias')->cascadeOnDelete();
            $table->foreignUlid('tag_id')->constrained('noticias_tags')->cascadeOnDelete();
            $table->primary(['noticia_id', 'tag_id']);
        });

        Schema::create('noticias_galeria', function (Blueprint $table) {
            $table->foreignUlid('noticia_id')->constrained('noticias')->cascadeOnDelete();
            $table->foreignId('media_id')->constrained('gallery_media')->cascadeOnDelete();
            $table->unsignedInteger('ordem')->default(0);
            $table->timestamps();
            $table->primary(['noticia_id', 'media_id']);
        });

        Schema::create('noticias_aprovadores', function (Blueprint $table) {
            $table->ulid('id')->primary();
            $table->foreignUlid('usuario_id')->constrained('cms_users')->cascadeOnDelete();
            $table->timestampTz('vigente_de')->nullable();
            $table->timestampTz('vigente_ate')->nullable();
            $table->foreignUlid('criado_por')->nullable()
                  ->constrained('cms_users')->nullOnDelete();
            $table->timestampsTz();
            $table->softDeletesTz();
            $table->index(['vigente_de', 'vigente_ate']);
        });
    }

    public function down(): void
    {
        $stamp = date('YmdHis');
        foreach (['noticias_aprovadores', 'noticias_galeria', 'noticias_tag', 'noticias', 'noticias_tags', 'noticias_categorias'] as $table) {
            if (Schema::hasTable($table)) {
                Schema::rename($table, 'backup_' . $table . '_' . $stamp);
            }
        }
    }
};
```

Decisões de schema:

- **ULID** em vez de UUIDv4: ordenável e compatível com chaves naturais; `v2_uuid()` do legado é v4 apesar do nome.
- **FKs reais** com `nullOnDelete`/`cascadeOnDelete`; o legado não tinha nenhuma.
- **JSONB** para `conteudo` e `metadados`; nunca HTML.
- **`timestamptz`** em todas as datas (ADR-017).
- **Nada de `search_vector` manual**: usar coluna gerada `tsvector` + GIN (ADR-017 §8), ou o service Search.
- **`v1_id`, `imported_at`, `legacy_author`, `source` não migram** (ADR-042).

## 3. Enum de status

```php
<?php
// exemplo ilustrativo
declare(strict_types=1);

namespace Modules\Publicacoes\Noticias\Enums;

enum StatusNoticia: string
{
    case Rascunho = 'draft';
    case Pendente = 'pending_approval';
    case Publicado = 'published';
    case Rejeitado = 'rejected';
}
```

Manter os valores do legado evita reescrever consultas e permite migração de dados trivial. As transições válidas ficam em um método do enum ou em uma classe `TransicoesDeStatus` concreta (sem trait).

## 4. Modelos

| Model novo | Tabela | Casts |
|---|---|---|
| `Noticia` | `noticias` | `conteudo => array`, `metadados => array`, `publicado_em => immutable_datetime`, `status => StatusNoticia::class`, `destacada/super_destacada => bool` |
| `Categoria` | `noticias_categorias` | `sistema => bool` |
| `Tag` | `noticias_tags` | — |
| `Aprovador` | `noticias_aprovadores` | `vigente_de/vigente_ate => datetime` |

Regras explícitas que **saem** dos hooks de model e vão para Actions:

- gerar slug único (com `Str::slug` e sufixo);
- gerar/limpar `token_preview`;
- definir `publicado_em` na transição;
- indexar/desindexar;
- invalidar cache.

O model pode manter apenas um `booted()` para gerar ULID e normalizar slug na criação, desde que testado. O legado usava três hooks (`creating`, `saving`, `saved`) com efeitos colaterais difíceis de rastrear.

## 5. Form Requests

```php
<?php
// exemplo ilustrativo
final class SalvarNoticiaRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()?->can('noticias.editar') ?? false;
    }

    public function rules(): array
    {
        return [
            'titulo'       => ['required', 'string', 'max:255'],
            'slug'         => ['nullable', 'string', 'max:255', Rule::unique('noticias', 'slug')->ignore($this->route('noticia'))],
            'subtitulo'    => ['nullable', 'string', 'max:500'],
            'conteudo'     => ['nullable', 'array'],
            'conteudo.type'=> ['required_with:conteudo', 'in:doc'],
            'resumo'       => ['nullable', 'string', 'max:1000'],
            'categoria_id' => ['required', 'ulid', 'exists:noticias_categorias,id'],
            'capa_id'      => ['nullable', 'integer', 'exists:gallery_media,id'],
            'galeria_ids'  => ['nullable', 'array', 'max:20'],
            'galeria_ids.*'=> ['integer', 'exists:gallery_media,id'],
            'tags'         => ['nullable', 'array'],
            'tags.*'       => ['string', 'max:100'],
            'metadados.seo.titulo'      => ['nullable', 'string', 'max:255'],
            'metadados.seo.descricao'   => ['nullable', 'string', 'max:500'],
            'metadados.seo.palavras'    => ['nullable', 'string', 'max:255'],
        ];
    }
}
```

Regras que endurecem o legado:

- **`status` não entra no request de edição**. O status só muda por Actions autorizadas (`aprovar`, `rejeitar`, `publicar`). Isso elimina o bypass observado no V2.
- `categoria_id` é obrigatório (a UI já exige; o backend não).
- `conteudo` é validado como estrutura (`array` com `type=doc`), não como "string que passa por `json_decode`".
- `slug` editável de verdade, com `unique` e histórico de redirects (`seo.redirects`, ADR-027).

## 6. Autorização

```php
<?php
// exemplo ilustrativo
final class NoticiaPolicy
{
    public function view(User $user, Noticia $noticia): bool
    {
        return $user->can('noticias.ver');
    }

    public function update(User $user, Noticia $noticia): bool
    {
        return $user->can('noticias.editar');
    }

    public function approve(User $user, Noticia $noticia): bool
    {
        return $user->can('noticias.aprovar');
    }

    public function archive(User $user, Noticia $noticia): bool
    {
        return $user->can('noticias.arquivar');
    }
}
```

Declaração no `ModuleProvider`:

```php
// exemplo ilustrativo
'permissions' => [
    'noticias.ver',
    'noticias.editar',
    'noticias.aprovar',
    'noticias.arquivar',
],
```

Isso corrige o problema central de RBAC do legado: `read/write` não distingue aprovar de editar nem arquivar de publicar.

## 7. Actions / casos de uso

| Action | Entrada | Efeito |
|---|---|---|
| `CriarNoticia` | DTO `DadosNoticia` | Gera slug, token se rascunho, persiste, indexa CMS. |
| `AtualizarNoticia` | DTO + `Noticia` | Atualiza apenas conteúdo editorial; **não** altera status. |
| `EnviarParaAprovacao` | `Noticia` | Exige `Rascunho`/`Rejeitado`, muda status, notifica aprovadores, audita. |
| `AprovarNoticia` | `Noticia` | Exige `Pendente`, define `publicado_em`, limpa token, indexa público, invalida cache. |
| `RejeitarNoticia` | `Noticia`, motivo | Exige `Pendente`, grava motivo/revisor, remove do índice público, audita. |
| `PublicarDireto` | `Noticia` | Só para `noticias.aprovar`; usada quando a política permite publicação imediata. |
| `ArquivarNoticia` | `Noticia` | Soft delete, remove do índice público, invalida cache. |
| `RestaurarNoticia` | `Noticia` | Volta a `Rascunho`, gera novo token, indexa CMS. |
| `DestacarNoticia` | `Noticia` | Alterna `destacada`. |
| `DefinirSuperDestaque` | `?Noticia` | Transação: zera todas e marca uma. |

## 8. Endpoints sugeridos

CMS (JSON + Blade, mesma política):

| Método | URI | Ação | Permissão |
|---|---|---|---|
| GET | `/cms/noticias` | listar com filtros | `noticias.ver` |
| POST | `/cms/noticias` | criar | `noticias.editar` |
| GET | `/cms/noticias/{noticia}` | editar (HTML) | `noticias.ver` |
| PATCH | `/cms/noticias/{noticia}` | atualizar conteúdo | `noticias.editar` |
| DELETE | `/cms/noticias/{noticia}` | arquivar | `noticias.arquivar` |
| POST | `/cms/noticias/{noticia}/submeter` | enviar para aprovação | `noticias.editar` |
| POST | `/cms/noticias/{noticia}/aprovar` | aprovar | `noticias.aprovar` |
| POST | `/cms/noticias/{noticia}/rejeitar` | rejeitar | `noticias.aprovar` |
| POST | `/cms/noticias/{noticia}/restaurar` | restaurar | `noticias.arquivar` |
| POST | `/cms/noticias/{noticia}/destaque` | alternar destaque | `noticias.editar` |
| PUT | `/cms/noticias/super-destaque` | definir super-destaque | `noticias.editar` |
| GET/POST/PUT/DELETE | `/cms/noticias/categorias` | CRUD | `noticias.editar` |
| GET/POST/DELETE | `/cms/noticias/tags` | CRUD | `noticias.editar` |

Portal:

| Método | URI | Observação |
|---|---|---|
| GET | `/noticias` | paginação keyset ou offset com ordenação determinística. |
| GET | `/noticia/{slug}` | 404 real para inexistente/rascunho. |
| GET | `/noticia/{slug}/preview` | token assinado (`URL::temporarySignedRoute`) com TTL. |
| GET | `/news-sitemap.xml` | `ETag`/`Cache-Control` (ADR-022). |

## 9. API Resources

```php
<?php
// exemplo ilustrativo
final class NoticiaResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'         => $this->id,
            'titulo'     => $this->titulo,
            'slug'       => $this->slug,
            'subtitulo'  => $this->subtitulo,
            'resumo'     => $this->resumo,
            'status'     => $this->status->value,
            'publicado_em' => $this->publicado_em?->toIso8601String(),
            'categoria'  => new CategoriaResource($this->whenLoaded('categoria')),
            'tags'       => TagResource::collection($this->whenLoaded('tags')),
            'capa'       => new MediaResource($this->whenLoaded('capa')),
            'url'        => route('noticias.show', $this->slug),
        ];
    }
}
```

Nunca serializar o model cru (como fazem as rotas JSON de tag/categoria do legado).

## 10. Views e front

- Blade + componentes anônimos (`<x-noticias::card>`, `<x-noticias::galeria>`), sem jQuery, sem inline (ADR-010/022).
- CMS em Tailwind/daisyUI (ADR-009); portal com HTML semântico, `<picture>` do service Media e TipTap vendorizado via Vite.
- Editor: um único componente RichText; preview com debounce no cliente e endpoint que devolve HTML sanitizado.
- Acessibilidade: gate axe/sniffCSS nas histórias de listagem, matéria, preview e editor (ADR-025).
- O filtro de listagem deve ser **server-side** (ou com endpoint de busca), não client-side sobre a página.

## 11. Fila, cache e storage

| Item | Proposta |
|---|---|
| Indexação | job `IndexarNoticia` na fila (Valkey) após commit; falha re-tentável. |
| Invalidação | evento `NoticiaPublicada`/`NoticiaArquivada` → listener invalida cache por tag (`noticias`, `noticia:{id}`) e purge do sitemap. |
| Página pública | `Cache-Control` + `ETag` (ADR-022); sem HTML com CSRF. |
| Sitemap | cache Valkey com invalidação por evento; nunca `KEYS`. |
| Mídia | upload via `MediaInputer`, variantes AVIF/WebP, storage S3/RustFS (ADR-026/030). |
| Auditoria | subscriber do Core grava antes/depois; Actions não chamam logger manualmente (ADR-035). |

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

| O que **não** pode mudar | Por quê |
|---|---|
| Quatro estados e seus valores persistidos | URLs, filtros, relatórios e dados migrados. |
| Preview só com token | Requisito editorial e de segurança. |
| Super-destaque único | Layout do portal depende disso. |
| URL `/noticia/{slug}` | Links externos e SEO; mudanças exigem 301. |
| Listagem em `/noticias` e paginação de 12 | Paridade de UX. |
| JSON-LD `NewsArticle` com headline, datas, autor, publisher, imagem | Rich results do Google. |
| Sitemap de notícias dos últimos 6 meses | Google News. |
| Conteúdo em AST, render no servidor | Segurança (sem HTML livre) e fidelidade. |

| O que **pode** mudar | Justificativa |
|---|---|
| Publicação direta sem aprovação | Só deve existir para quem tem `noticias.aprovar`; idealmente removida. |
| Busca client-side | Substituir por server-side. |
| `slug` imutável | Passar a editável com redirect 301. |
| Cache por `KEYS` | Substituir por invalidação por tags/eventos. |
| Token em query string sem expiração | URL assinada com TTL. |
| E-mail síncrono no request | Enfileirar. |
| `metadata.seo` sem `@graph` | Emitir `@graph` completo (ADR-027). |
