# Notícias (V2) — Modelagem

> **Slug:** `publicacoes-noticias` · **Versão analisada:** V2 (Laravel 5.5/PHP 7.4).
>
> Todos os caminhos são relativos à raiz de `lotep/`. Tipos descritos conforme as migrations e os casts do Eloquent.

---

## 1. Visão geral

```text
v2_news ──< v2_news_tag_pivot >── v2_news_tags
   │
   ├──< v2_news_gallery_media >── v2_gallery_media (service Gallery)
   ├──> v2_news_categories (category_id)
   ├──> users (author_id / reviewer_id — sem FK)
   └──> v2_gallery_media (cover_image_id — sem FK)

v2_news_approvers ──> users (user_id — sem FK)
v2_news_configs (chave/valor)
```

Sete tabelas, nenhuma FK declarada. A integridade referencial é responsabilidade da aplicação.

## 2. `v2_news`

Criada em `V2/Modules/Publicacoes/Noticias/Migrations/2026_07_08_000001_create_v2_news_table.php`.

| Coluna | Tipo | Nulo | Default | Observação |
|---|---|---|---|---|
| `id` | uuid | não | — | PK; gerado por `v2_uuid()` (v4). |
| `title` | varchar(255) | não | — | Obrigatório. |
| `slug` | varchar(255) | não | — | **UNIQUE**; gerado no hook `saving`. |
| `subtitle` | varchar(500) | sim | — | Linha fina; fallback de meta description. |
| `content` | text | sim | — | AST JSON do TipTap (não é HTML). |
| `summary` | text | sim | — | Resumo em texto puro. |
| `status` | varchar(20) | não | `draft` | `draft`/`pending_approval`/`published`/`rejected`. |
| `draft_token` | uuid | sim | — | Token de preview; anulado ao publicar. |
| `author_id` | varchar(36) | sim | — | ID do usuário Sentinel (sem FK). |
| `reviewer_id` | varchar(36) | sim | — | Quem aprovou/rejeitou (sem FK). |
| `published_at` | timestamp | sim | — | Preenchido na publicação. |
| `rejection_reason` | text | sim | — | Motivo da rejeição. |
| `metadata` | jsonb | sim | — | `{"seo": {title, description, keywords, json_ld, image_id, image_url}}`. |
| `cover_image_id` | varchar(36) | sim | — | ID de `v2_gallery_media` (lá é bigint). |
| `is_highlighted` | boolean | não | false | Destaque na listagem. |
| `is_super_highlighted` | boolean | não | false | Singleton lógico. |
| `category_id` | uuid | sim | — | Sem FK. |
| `v1_id` | integer | sim | — | PK original na V1; UNIQUE parcial. |
| `imported_at` | timestamp | sim | — | Data da importação legada. |
| `legacy_author` | varchar(191) | sim | — | Autor texto livre da V1. |
| `source` | varchar(191) | sim | — | Fonte/origem da matéria. |
| `search_vector` | tsvector | sim | — | Preenchido por trigger. |
| `created_at`/`updated_at` | timestamp | sim | — | — |
| `deleted_at` | timestamp | sim | — | Soft delete = "arquivada". |

Índices:

| Nome/coluna | Tipo | Origem |
|---|---|---|
| `slug` | UNIQUE | migration base |
| `status` | btree | base |
| `draft_token` | btree | base |
| `author_id` | btree | base |
| `published_at` | btree | base |
| `is_highlighted` | btree | `2026_08_19_000005` |
| `is_super_highlighted` | btree | `2026_08_19_000005` |
| `idx_v2_news_category_id` | btree | `2026_08_19_000006` |
| `idx_v2_news_search_vector` | GIN | base |
| `v2_news_v1_id_unique` | UNIQUE parcial `WHERE v1_id IS NOT NULL` | `2026_08_24_000001` |

### 2.1 Trigger de busca textual

A migration cria a função e o trigger:

```sql
CREATE OR REPLACE FUNCTION v2_news_search_vector_update() RETURNS trigger AS $$
BEGIN
    NEW.search_vector :=
        setweight(to_tsvector('portuguese', coalesce(NEW.title, '')), 'A') ||
        setweight(to_tsvector('portuguese', coalesce(NEW.subtitle, '')), 'B') ||
        setweight(to_tsvector('portuguese', coalesce(NEW.summary, '')), 'B') ||
        setweight(to_tsvector('portuguese', coalesce(NEW.slug, '')), 'C');
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
```

O `search_vector` é derivado, mas **não é usado por nenhum controller do módulo**: as buscas do CMS usam `ilike` (`V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:80-100`) e a busca pública vive no service Search. Ele permanece como capacidade latente (e custo de trigger em cada escrita de título/subtítulo/resumo/slug).

## 3. `v2_news_categories`

`V2/Modules/Publicacoes/Noticias/Migrations/2026_08_19_000001_create_v2_news_categories_table.php`.

| Coluna | Tipo | Nulo | Default | Observação |
|---|---|---|---|---|
| `id` | uuid | não | — | PK. |
| `name` | varchar(100) | não | — | — |
| `slug` | varchar(100) | não | — | UNIQUE; base de filtro client-side no portal. |
| `description` | varchar(500) | sim | — | — |
| `color` | varchar(7) | sim | — | Hex `#RRGGBB`; não usado nas views analisadas. |
| `sort_order` | integer | não | 0 | Ordenação `ordered()`. |
| `parent_id` | uuid | sim | — | Hierarquia de **um nível** na UI. Sem FK. |
| `is_system` | boolean | não | false | Bloqueia edição/remoção. |
| `created_at`/`updated_at`/`deleted_at` | timestamp | sim | — | Soft delete. |

Relações: `parent()` (belongsTo), `children()` (hasMany), `news()` (hasMany).

O `saving` do model tenta garantir slug único com um laço (`V2/Modules/Publicacoes/Noticias/Models/NewsCategory.php:91-104`), mas o builder não é reconstruído a cada iteração — ver `08-GAPS-E-DIVIDAS.md`.

## 4. `v2_news_tags` e pivots

`V2/Modules/Publicacoes/Noticias/Migrations/2026_08_19_000002_create_v2_news_tags_table.php`.

| Coluna | Tipo | Nulo | Observação |
|---|---|---|---|
| `id` | uuid | não | PK. |
| `name` | varchar(100) | não | Exibição. |
| `slug` | varchar(100) | não | UNIQUE; chave de reutilização (`firstOrCreate`). |
| `created_at`/`updated_at`/`deleted_at` | timestamp | sim | Soft delete. |

`v2_news_tag_pivot` (`V2/Modules/Publicacoes/Noticias/Migrations/2026_08_19_000003_create_v2_news_tag_pivot.php`):

| Coluna | Tipo | Observação |
|---|---|---|
| `news_id` | uuid | PK composta. |
| `tag_id` | uuid | PK composta; índice próprio. |

`v2_news_gallery_media` (`V2/Modules/Publicacoes/Noticias/Migrations/2026_08_19_000004_create_v2_news_gallery_pivot.php` + correção `2026_08_24_130000_fix_gallery_media_id_type_on_v2_news_gallery_pivot.php`):

| Coluna | Tipo | Observação |
|---|---|---|
| `news_id` | uuid | PK composta. |
| `gallery_media_id` | **bigint** | PK composta; era `uuid` e foi convertido porque `v2_gallery_media.id` é serial inteiro. |
| `sort_order` | unsigned integer | Default 0; define ordem da galeria. |
| `created_at`/`updated_at` | timestamp | — |

Nenhum pivot tem FK ou `ON DELETE CASCADE`: apagar notícia (soft) preserva as linhas; apagar tag (soft) deixa o pivot órfão.

## 5. `v2_news_approvers`

`V2/Modules/Publicacoes/Noticias/Migrations/2026_07_08_000002_create_v2_news_approvers_and_configs_table.php`.

| Coluna | Tipo | Nulo | Observação |
|---|---|---|---|
| `id` | varchar(36) | não | PK. |
| `user_id` | integer unsigned | não | `users.id` (Sentinel V1); sem FK. |
| `valid_from` | datetime | sim | Início da vigência. |
| `valid_until` | datetime | sim | Fim da vigência. |
| `created_by` | varchar(36) | sim | UUID de quem cadastrou. |
| `created_at`/`updated_at`/`deleted_at` | timestamp | sim | Soft delete. |

Índices: `user_id` e composto `(valid_from, valid_until)`. O escopo `active()` compara com `date('Y-m-d H:i:s')` no fuso da aplicação (`V2/Modules/Publicacoes/Noticias/Models/NewsApprover.php:101-110`).

## 6. `v2_news_configs`

Mesma migration de aprovadores.

| Coluna | Tipo | Nulo | Observação |
|---|---|---|---|
| `id` | varchar(36) | não | PK. |
| `key` | varchar(100) | não | UNIQUE. |
| `value` | jsonb | sim | Cast `array`. |
| `created_at`/`updated_at` | timestamp | sim | — |

Único uso atual: `sidebar_items` (`V2/Modules/Publicacoes/Noticias/Controllers/NoticiasConfigController.php:46,136`). A configuração é lida na tela de configurações, mas não foi encontrada referência de consumo pelo layout/sidebar nesta análise.

## 7. Migrations do módulo

| Arquivo | Efeito |
|---|---|
| `V2/Modules/Publicacoes/Noticias/Migrations/2026_07_08_000001_create_v2_news_table.php` | Cria `v2_news`, trigger e GIN. `down()` renomeia para `backup_v2_news_<timestamp>`. |
| `V2/Modules/Publicacoes/Noticias/Migrations/2026_07_08_000002_create_v2_news_approvers_and_configs_table.php` | Cria aprovadores e configs; `down()` renomeia. |
| `V2/Modules/Publicacoes/Noticias/Migrations/2026_08_19_000001_create_v2_news_categories_table.php` | Categorias. |
| `V2/Modules/Publicacoes/Noticias/Migrations/2026_08_19_000002_create_v2_news_tags_table.php` | Tags. |
| `V2/Modules/Publicacoes/Noticias/Migrations/2026_08_19_000003_create_v2_news_tag_pivot.php` | Pivot notícia↔tag. |
| `V2/Modules/Publicacoes/Noticias/Migrations/2026_08_19_000004_create_v2_news_gallery_pivot.php` | Pivot notícia↔mídia (coluna uuid). |
| `V2/Modules/Publicacoes/Noticias/Migrations/2026_08_19_000005_add_highlights_to_v2_news_table.php` | `is_highlighted`, `is_super_highlighted` + índices. |
| `V2/Modules/Publicacoes/Noticias/Migrations/2026_08_19_000006_add_category_id_to_v2_news_table.php` | `category_id` + índice nomeado. |
| `V2/Modules/Publicacoes/Noticias/Migrations/2026_08_24_000001_add_v1_import_tracking_to_v2_news_table.php` | `v1_id`, `imported_at`, UNIQUE parcial. |
| `V2/Modules/Publicacoes/Noticias/Migrations/2026_08_24_000002_add_legacy_source_to_v2_news_table.php` | `legacy_author`, `source`. |
| `V2/Modules/Publicacoes/Noticias/Migrations/2026_08_24_130000_fix_gallery_media_id_type_on_v2_news_gallery_pivot.php` | Converte `gallery_media_id` uuid→bigint. |

Todas usam `DB::beginTransaction()` + `SET lock_timeout = '3s'` e `down()` não destrutivo (renomeia para `backup_*`), exceto as de `ADD COLUMN`, cujo `down()` remove apenas colunas recém-criadas.

## 8. Views SQL

O módulo **não cria views SQL**. A listagem agrupa por categoria em PHP (`V2/Modules/Publicacoes/Noticias/Views/cms/index.blade.php:143-152`) e a busca pública usa as tabelas de índice do service Search (`v2_public_search_index`, `v2_cms_links_index`).

## 9. Seeds

Não existe seeder de notícias do módulo. O arquivo `V2/Seeds/BasicPages/NoticiasSeeder.php` pertence ao módulo BasicPage: cria a **página institucional** "Notícias" em `v2_basic_pages`, com imagens e JSON-LD `NewsArticle`, e não insere linhas em `v2_news`.

Consequência para testes: as factories/fixtures de notícia precisam ser criadas no próprio teste (`News::create($atributos)`), como fazem os testes existentes.

## 10. Armadilhas de schema

| Ponto | Risco |
|---|---|
| `cover_image_id` varchar(36) ↔ `v2_gallery_media.id` bigint | Relação Eloquent funciona por string, mas não há FK; migração de dados precisa converter. |
| `author_id`/`reviewer_id` varchar(36) ↔ `users.id` integer | `belongsTo` compara tipos diferentes; usuários só resolvem se o driver coercionar. Na prática o vínculo é usado apenas para exibir nome via ID string. |
| `category_id` sem FK | Categoria removida (soft) deixa notícias apontando para registro fora do escopo padrão. |
| `v1_id` UNIQUE parcial | Importador idempotente, mas sem `ON CONFLICT` no módulo. |
| `draft_token` indexado e não único | Colisão teórica de UUID não é barrada pelo banco. |
| `status` varchar livre no banco | Só a validação do FormRequest e as constantes do model restringem. |
