# Notícias (V2) — Regras e Portabilidade

> **Slug:** `publicacoes-noticias` · **Versão analisada:** V2.
>
> Este arquivo concentra as regras que não são óbvias pela leitura das rotas, as armadilhas de implementação e os acoplamentos que precisam sobreviver à reconstrução.

---

## 1. Máquina de estados

```text
          submitForApproval()                approve()
  draft ────────────────────> pending_approval ─────────> published
    ▲                              │                         ▲
    │                              │ reject()                │ update(status=published)
    │                              ▼                         │ (de qualquer estado)
    └──── restore() ────── rejected ◄───────────────────────┘
       (de arquivada)          │
                               └── submitForApproval() ──> pending_approval
```

Regras efetivas:

| Regra | Implementação |
|---|---|
| Submissão aceita `draft` e `rejected` | `V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:557-561` |
| Aprovação só de `pending_approval` | `:597-601` |
| Rejeição só de `pending_approval`, motivo obrigatório | `:631-639` |
| Edição pode definir qualquer status válido | `:386` (`$data['status']`) |
| Publicar direto no store é permitido | `:265,288-290` |
| `published_at` só é setado na transição para publicado | `:415-418`, `:602-603` |
| `draft_token` é anulado ao publicar | `V2/Modules/Publicacoes/Noticias/Models/News.php:371-374` (hook `saving`) |
| Arquivar é soft delete; restaurar volta a `draft` com novo token | `:664-680`, `:186-199` |

Consequência prática: o fluxo de aprovação **não é obrigatório**. O formulário envia `status=published` no botão "Publicar" e o backend aceita. A regra "rascunho → pendente → publicado" só vale para quem operar pelos endpoints de workflow.

## 2. Slug e URL

- O slug é derivado do título no hook `saving` (`V2/Modules/Publicacoes/Noticias/Models/News.php:342-375`), com colisão resolvida por `-1`, `-2`, `-3` consultando o banco.
- O campo `slug` chega validado pelo `NewsRequest`, mas `store()` e `update()` **não o utilizam**: `$news->slug` só é definido indiretamente pelo hook. Logo, editar o slug no formulário não tem efeito e o slug é imutável após a criação (o hook recalcula a partir de `$model->slug` já existente, que permanece).
- URLs públicas são construídas por `getPublicUrl()`/`getPreviewUrl()` (`V2/Modules/Publicacoes/Noticias/Models/News.php:439-511`), que nunca retornam string vazia: caem para a listagem ou para `/noticias` se a rota não existir.
- O slug não muda ao publicar; links antigos permanecem válidos desde que o título não seja alterado antes da primeira gravação.

## 3. Preview por token

```php
public function isAccessible($token = null)
{
    if ($this->status === self::STATUS_PUBLISHED) {
        return true;
    }

    // Rascunhos e pendentes só com o token correto
    if (!empty($this->draft_token) && $token === $this->draft_token) {
        return true;
    }

    return false;
}
```

(`V2/Modules/Publicacoes/Noticias/Models/News.php:417-429`.)

- O token vai na query string (`?token=`), sem expiração, sem assinatura e sem vínculo com usuário. Quem tiver o link vê o conteúdo.
- Rascunho restaurado recebe **novo** token; o anterior deixa de funcionar.
- Notícia publicada é acessível mesmo com token inválido — o parâmetro é simplesmente ignorado.
- O rodapé/SEO do preview usa `[Preview]` no título e `noindex` não é aplicado explicitamente no markup analisado.

## 4. Destaques

| Flag | Regra |
|---|---|
| `is_highlighted` | Booleano independente por notícia; alternado por AJAX. Não é exclusivo. |
| `is_super_highlighted` | **No máximo uma** notícia: `setSuperHighlight()` executa `UPDATE v2_news SET is_super_highlighted = false` e depois marca a escolhida (`V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:724-731`). |
| Envio vazio | Remove o super-destaque de todas e mostra flash informativo. |

O estado é gravado por dois `save()`/`update()` sem transação: entre o `UPDATE` em massa e o `save()` da escolhida existe uma janela em que nenhuma notícia está em super-destaque — irrelevante na prática, mas sem atomicidade.

## 5. Paginação e navegação determinística

A listagem pública ordena apenas por `published_at desc` (`V2/Modules/Publicacoes/Noticias/Controllers/NoticiasPortalController.php:72`); empates ficam na ordem que o PostgreSQL devolver.

Já a navegação anterior/próxima usa comparação composta para desempatar:

```php
$prevNews = (clone $base)
    ->whereRaw('(COALESCE(published_at, created_at), id) < (?, ?)', [$reference, $news->id])
    ->orderByRaw('(COALESCE(published_at, created_at), id) DESC')
    ->first();

$nextNews = (clone $base)
    ->whereRaw('(COALESCE(published_at, created_at), id) > (?, ?)', [$reference, $news->id])
    ->orderByRaw('(COALESCE(published_at, created_at), id) ASC')
    ->first();
```

(`V2/Modules/Publicacoes/Noticias/Controllers/NoticiasPortalController.php:171-180`.)

- `previous` é o registro imediatamente **mais antigo**; `next`, o imediatamente **mais novo**.
- A referência é `published_at` normalizado (`adjacentReference()`), com fallback para `created_at`.
- A comparação de linha `(timestamp, uuid)` exige que `$reference` seja string no formato aceito pelo PostgreSQL; `Carbon` é convertido com `toDateTimeString()`.
- Custo: `COALESCE` e ordenação por expressão impedem uso dos índices de `published_at`/`id`; a consulta varre o conjunto publicado. Em bases grandes, isso deve virar coluna gerada + índice composto (ou `keyset` com coluna `published_seq`).

## 6. Sitemap de notícias

- Rota pública `/news-sitemap.xml`, cache `v2_news_sitemap_xml` por 360 minutos.
- Purge **oportunista**: só ocorre se uma requisição cair entre 03:00 e 03:10 no horário do servidor (`V2/Modules/Publicacoes/Noticias/Controllers/NoticiasSitemapController.php:27-31`). Sem request nessa janela, o cache expira pelo TTL, não pelo purge.
- Filtro: `status=published` e (`published_at >= agora-6 meses` **ou** `published_at IS NULL`), limite 1000.
- Usa `DB::table('v2_news')`, portanto **ignora `deleted_at`**: notícia publicada e depois arquivada continua no XML até sair da janela ou do limite.
- `news:publication_date` usa apenas a data (`Y-m-d`), não o timestamp completo; `news:keywords` vem de `metadata.seo.keywords`.
- O `robots.txt` do service SEO anuncia esse sitemap (`V2/Services/SEO/Controllers/SeoPortalController.php:24`).

## 7. Sincronização com os índices de busca

`News::saved` → `syncToSearch()` → `NewsSearchSender::sync()`:

```php
if ($news->status === News::STATUS_PUBLISHED) {
    $this->syncPublic($news);
} else {
    // Remove do índice público se não estiver publicada
    $this->removeFromPublic($news);
}

// CMS indexa independente do status (para busca administrativa)
$this->syncCms($news);
```

(`V2/Modules/Publicacoes/Noticias/Services/NewsSearchSender.php:26-38`.)

| Evento | Índice público | Índice CMS |
|---|---|---|
| `save()` publicado | upsert (`PublicIndexerService::index`) | upsert (`CMSIndexerService::index`) |
| `save()` não publicado | delete por `(entity_type, entity_id)` | upsert |
| `delete()` (soft) | **nada acontece** | **nada acontece** |
| `restore()` | `save()` chamado pelo restore → upsert/remoção conforme status | upsert |

- `entity_type` é `get_class($news)` (FQCN `V2\Modules\Publicacoes\Noticias\Models\News`), o que acopla o índice ao namespace da classe. Renomear a classe exige migração dos índices.
- O `edit_url` do CMS é gerado com `route($nome, $parametros, false)` → caminho relativo; em falha, fallback manual `/cms/noticias/editar/{id}`.
- Tags e categoria só entram no índice público se a relação já estiver carregada (`relationLoaded`); em `saved` normalmente **não** está, então o `tags` do índice pode ficar incompleto.
- Erros de indexação são engolidos com `Log::error` (`V2/Modules/Publicacoes/Noticias/Models/News.php:379-383`), sem retry.

## 8. Cache de página

```php
$redis = Redis::connection();
$pattern = config('cache.prefix', 'laravel') . ':page:*';
$keys = $redis->keys($pattern);
if (!empty($keys)) {
    $redis->del($keys);
}
```

(`V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:1027-1032`.)

- `KEYS` é O(N) sobre todo o keyspace e bloqueia o Redis; em produção com muitas chaves é um risco de latência.
- O `del` recebe o array inteiro; em Redis Cluster, chaves de slots diferentes exigem `unlink` individual (o comando pode falhar com CROSSSLOT).
- Só roda quando `config('cache.default') === 'redis'`.
- Chamado em: `store`, `update`, `restore`, `destroy`, `toggleHighlight`, `setSuperHighlight`, `updateSeo`. **Não** chamado em `approve`, `reject` nem `submitForApproval` — as três transições de status mais relevantes para o portal.

## 9. SEO

- `metadata.seo` é preenchido no `store`/`update` com `title` (título da notícia) e `description` (subtítulo/summary) quando vazios (`V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:272-287`, `:395-412`).
- `updateSeo()` **substitui** o bloco inteiro, inclusive apagando campos não enviados (`:776-783`).
- No portal, `show()` usa `getSeoData()` e o `SEOService` para título/descrição/keywords/capa; o JSON-LD `NewsArticle` é montado direto na view.
- No preview CMS, `SEOService::bindModel($news)` adiciona o `json_ld` persistido **se** for JSON decodificável (`V2/Services/SEO/Services/SEOService.php:57-63`).
- O editor auto-preenche `metadata[seo][json_ld]` no cliente com um objeto `NewsArticle` e headline/description/url (`V2/Modules/Publicacoes/Noticias/Views/cms/includes/scripts.blade.php:141-157`); esse valor não é sanitizado no servidor além de `nullable|string`.

## 10. Busca do CMS

- Busca textual em título, subtítulo, resumo, nome de categoria e nome de tag via `where('title', 'ilike', '%termo%')` e variantes (`V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:80-100`).
- `ilike` sem índice trigram: varredura completa e três `EXISTS` correlacionados (categoria/tags) por linha candidata.
- O termo é interpolado como **binding**, não concatenado em SQL bruto; não há injeção. Mas `%` e `_` do usuário funcionam como curingas.
- `search` aceita array e usa o primeiro elemento (`reset`), mitigando o antigo comportamento de `Array` em string.

## 11. Armadilhas catalogadas

| # | Armadilha | Onde | Efeito |
|---|---|---|---|
| 1 | `update()` aceita `status` e publica sem aprovação | `V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:386,415` | Bypass do workflow. |
| 2 | `approve()`/`reject()` não limpam cache de página | `:592-651` | Portal pode servir versão antiga após publicar/rejeitar. |
| 3 | `Redis::keys` O(N) | `:1029` | Latência/bloqueio do Redis. |
| 4 | RBAC só `read`/`write` | `V2/Core/Middleware/V2PermissionMiddleware.php:101-127` | Quem edita também aprova/arquiva. |
| 5 | `allPublished` sem `limit` | `V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:115-120,151-156` | Modal carrega todas as publicadas; memória cresce com o acervo. |
| 6 | Sitemap ignora soft delete | `V2/Modules/Publicacoes/Noticias/Controllers/NoticiasSitemapController.php:54-63` | Arquivadas aparecem no Google News. |
| 7 | Arquivar não remove do índice público | `SoftDeletes` não dispara `saved` | Busca interna mostra link para notícia arquivada. |
| 8 | N+1 de autor na listagem CMS | `V2/Modules/Publicacoes/Noticias/Views/cms/index.blade.php:191` | 15 consultas extras por página. |
| 9 | Autor da matéria fora do eager load | `V2/Modules/Publicacoes/Noticias/Views/portal/show.blade.php:10` | Uma consulta extra por matéria (não é N+1). |
| 10 | `getStatsByStatus()` executa 5 `count()` | `V2/Modules/Publicacoes/Noticias/Models/News.php:607-615` | 5 consultas por render de listagem/pendentes. |
| 11 | Slug do formulário é ignorado | `store`/`update` | UI promete edição que não acontece. |
| 12 | `firstOrCreate` de tag ignora soft delete | `V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:820-831` | Recriar tag arquivada viola UNIQUE de slug (erro 500). |
| 13 | Laço de slug em categoria/tag não reconstrói o builder | `V2/Modules/Publicacoes/Noticias/Models/NewsCategory.php:95-102`, `V2/Modules/Publicacoes/Noticias/Models/NewsTag.php:71-78` | Nome duplicado pode entrar em loop infinito. |
| 14 | Duplicação `pending()` ≈ `index()` | `V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:64-166` | Regras divergem com o tempo. |
| 15 | `NewsRequest::authorize()` fixo em `true` | `V2/Modules/Publicacoes/Noticias/Requests/NewsRequest.php:20-23` | Autorização dependente só do middleware. |
| 16 | `metadata` merge substitui `seo` inteiro | `V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:776-783` | Campos omitidos viram `null`. |
| 17 | Filtro de busca do portal é client-side | `V2/Modules/Publicacoes/Noticias/Views/portal/index.blade.php:222-305` | Busca não alcança outras páginas. |
| 18 | CSS/JS inline | views CMS/portal | Incompatível com CSP sem inline. |

## 12. Acoplamentos a preservar

| Acoplamento | Direção | Consequência na migração |
|---|---|---|
| `News.saved` → Search | Notícias → Search | Precisa virar evento/listener ou chamada explícita na Action; `saved` implícito não é confiável. |
| `cover_image_id`/galeria → Gallery | Notícias → Gallery | O V7 usa Media/Gallery com IDs inteiros; a coluna de capa deve ser `bigint` (ou ULID do novo serviço). |
| `author_id`/`reviewer_id` → `users` | Notícias → Core auth | No V7, `cms_users` com ULID; migrar para `foreignId`/`foreignUlid` com FK. |
| `metadata` → SEO | Notícias → SEO | Manter contrato `metadata.seo` (ADR-027). |
| `content` → RichText | Notícias → RichText | Manter AST TipTap (ADR-028). |
| `v2_layout_base_configs` → logo do e-mail | Notícias → Layout | No V7, ler do service de tema/layout, não de tabela. |

## 13. Código duplicado e divergências

- `pending()` repete o corpo de `index()` e a montagem de `$allPublished`; `archived()` repete os contadores.
- `V2/Modules/Publicacoes/Noticias/Views/cms/index.blade.php` e `V2/Modules/Publicacoes/Noticias/Views/cms/archived.blade.php` compartilham cartões e modais, mas cada um inclui `_confirm-modal` e `scripts` de forma independente.
- `V2/Modules/Publicacoes/Noticias/Views/cms/includes/form.blade.php` contém campos SEO que também existem em `V2/Modules/Publicacoes/Noticias/Views/cms/includes/_editor-content.blade.php` via componente `seo-input`; o include não é usado pelas views `create`/`edit` analisadas.
- `NewsRequest` valida `slug` e o controller o ignora — resquício de uma versão em que o slug era editável.
