# Módulo Notícias (`Publicacoes/Noticias`)

> Contrato de leitura do módulo (ADR-004/ADR-014) — o **piloto** que congela o padrão dos demais
> verticais. Mantenha sincronizado com o código.

## Propósito

Publicar e exibir **notícias municipais**: listagem paginada e detalhe no portal, incorporação em
portais externos, exportação de dados abertos, CRUD completo no CMS (com preview e mídia de capa) e
as **fontes de widget** `noticias.ultimas`/`noticias.mais-lidas` no layout (P5.S5.1).

- **Grupo:** `Publicacoes` (organizacional — **não** entra no token, ADR-014).
- **Áreas:** portal · CMS · API.
- **Ativação:** dado em `core_module_discovery_settings`; `php artisan core:module:activate Noticias [--disable]` (ADR-034).

## Estrutura (contrato físico)

```
src/modules/Publicacoes/Noticias/
├── ModuleProvider.php        # ÚNICO arquivo de declaração (register + config) — ADR-014
├── MODULE.md                 # este contrato
├── Database/
│   ├── Migrations/           # create + media_data + conversão content→jsonb (ADR-017)
│   ├── Models/Noticia.php    # uuid, soft deletes, content jsonb (LocaleCast)
│   └── (sem Seeders — demo em src/demonstration/Noticias/NoticiaSeeder.php — ADR-041)
├── Logic/
│   ├── Actions/              # SaveNoticia, ChangeNoticiaStatus (DI + span/log noticias.{ação})
│   ├── Controllers/          # Web/Show/Embed/Export + Cms/* (invocáveis, sem closure)
│   ├── DTOs/NoticiaData.php  # readonly
│   ├── Requests/SaveNoticiaRequest.php
│   └── Support/NoticiasWidgetFeed.php   # feed dos widgets (1 query, ordem determinística)
├── Widgets/                  # NoticiasUltimasWidget, NoticiasMaisLidasWidget (WidgetSource)
├── Routes/{portal,cms,api}.php
├── Layout/Views/             # index, show, embed, cms/{index,form}, partials/cover
│   └── widgets/{noticias-ultimas,noticias-mais-lidas}.blade.php
├── Lang/{pt_BR,en,es,fr}/    # index, show, embed, menu, cms, widget
└── Test/                     # NoticiaContentTest, NoticiaCmsTest, NoticiasWidgetsTest
```

## Token canônico (ADR-004)

**`noticias`** — classes (`Noticia*`), rotas (`noticias.*`), migration/tabela (`noticias.noticias`),
views, testes e logs carregam o prefixo. Verificação: `tools/name-check.sh --grep noticias`.

## Dependências

- **Services** (import só de `Contracts`/`DTOs`/`Enums` — ADR-015):
  - `Services\Media\Contracts\EditorMediaStore` — capa via `StoreEditorMedia` (AVIF/WebP, ADR-026).
  - `Services\RichText\Contracts\RichTextRenderer` — portal renderiza o AST → HTML (ADR-028).
- **Core:** `Core\Contracts\{Clock,Telemetry}` (injetados), `Core\Access\Permission`,
  `Core\Localization\LocaleCast`, `Core\Contracts\WidgetRegistry` (fontes de widget, P5.S5.1).
- **Outros módulos:** nenhum import — a home (módulo `Portal\Home`) consome por slot/widget
  (`WidgetRegistry`), nunca por `use Modules\…` (ADR-013).
- **Tabelas:** `noticias.noticias` (PK uuid v7, `content` jsonb, `media_data` jsonb).

## Rotas

| Nome | Método | URI | Controller | Acesso |
|---|---|---|---|---|
| `noticias.index` | GET | `/noticias` | `NoticiasWebController` | pública |
| `noticias.show` | GET | `/noticias/{slug}` | `NoticiasShowController` | pública |
| `noticias.embed` | GET | `/noticias/embed/{id}` | `NoticiasEmbedController` | pública (edge: `frame-ancestors *`) |
| `noticias.api.export` | GET | `/api/noticias/export` | `NoticiasExportController` | pública (`security.open-cors`) |
| `noticias.cms.index` | GET | `/cms/noticias` | `Cms\NoticiaIndexController` | `noticias.ver` |
| `noticias.cms.create` | GET | `/cms/noticias/criar` | `Cms\NoticiaCreateController` | `noticias.editar` |
| `noticias.cms.store` | POST | `/cms/noticias` | `Cms\NoticiaStoreController` | `noticias.editar` |
| `noticias.cms.edit` | GET | `/cms/noticias/{noticia}/editar` | `Cms\NoticiaEditController` | `noticias.editar` |
| `noticias.cms.update` | PUT | `/cms/noticias/{noticia}` | `Cms\NoticiaUpdateController` | `noticias.editar` |
| `noticias.cms.status` | POST | `/cms/noticias/{noticia}/status/{state}` | `Cms\NoticiaStatusController` | `noticias.aprovar` |
| `noticias.cms.destroy` | DELETE | `/cms/noticias/{noticia}` | `Cms\NoticiaDestroyController` | `noticias.arquivar` |

> O prefixo de locale do portal (`/{locale?}/noticias`) é injetado pelo Core (ADR-012); o CMS vive no
> domínio do painel (ADR-037). O roteador não decide acesso — a autorização é do controller (ADR-033).

## Permissões (ADR-033)

Declaradas em `ModuleProvider::config()` (`'permissions' => Permission::ACTIONS`) e aplicadas com
`Permission::authorize()` (**deny-by-default**): `noticias.{ver|editar|aprovar|arquivar}`.

## Eventos e observabilidade (ADR-024/ADR-035)

- **Emite:** `noticias.save` (`SaveNoticia`) e `noticias.change_status` (`ChangeNoticiaStatus`) —
  span + `log_event` com `{module, action, id}` (**sem PII**).
- **Auditoria:** as mutações entram na trilha pelo `GlobalAuditSubscriber` (ADR-035).
- **Consome:** nada no momento (SEO/Search reagirão a `NoticiaPublicada` quando promovidos).

## Layout (ADR-016)

- `index`/`show` usam **um único** `<x-layout::portal>`; `cms/index` e `cms/form` usam `<x-layout::cms>`.
- `embed` é uma view mínima de incorporação (sem shell de site).
- **Widgets (P5.S5.1):** duas fontes registradas no `WidgetRegistry` do Core em
  `ModuleProvider::register()` — o perfil de layout de **qualquer** página referencia o `id` do
  widget, nunca importa este módulo (embed permitido, import proibido — ADR-013):

  | `id` | Título (i18n) | Ícone | Slots | Embeddable |
  |---|---|---|---|---|
  | `noticias.ultimas` | `noticias::widget.ultimas` | `lucide-newspaper` | `main.left`, `main.right`, `main.after` | sim |
  | `noticias.mais-lidas` | `noticias::widget.mais_lidas` | `lucide-trending-up` | `main.left`, `main.right`, `main.after` | sim |

  - **Leitura:** `NoticiasWidgetFeed` — **uma** query por widget, escopo `Noticia::publicadas()`
    (status `publicado`, `published_at` não nulo e já alcançado, sem soft-delete), ordem
    `published_at DESC, id DESC`, recorte de `config('noticias.widget_limit')` (default 5, teto 12).
  - **Mídia:** a capa viaja no `media_data` (jsonb) e é montada pelo partial `partials/cover` →
    `<x-media::picture>` (AVIF/WebP, `width`/`height`, `alt`, `loading="lazy"`) — **nenhuma**
    consulta adicional (sem N+1).
  - **`noticias.mais-lidas`:** o agregado do P4 (`Core\Contracts\Metrics`, ADR-047) tem dimensão de
    **rota nomeada** (`noticias.show` agrega todos os artigos) e nunca grava path/slug (sem PII).
    Não existe sinal por artigo; o widget usa o **fallback determinístico por publicação** declarado
    no plano em vez de fabricar ranking. Um ranking empírico exige emenda à ADR-047 (dimensão de
    conteúdo) — risco registrado na ficha da fase.
  - **Degradação:** fonte não registrada (módulo `Noticias` inativo — dono resolvido pelo manifesto
    desde a S5.2) → o slot fica vazio, sem título órfão e sem 500; o bloco `latest-news` do catálogo
    usa a mesma fonte por `id` e os dois widgets saem do catálogo do `/cms/layout`.
  - **HTML:** sem `<script>`, `style=` ou `id` fixo no fragmento (o mesmo widget pode ocupar dois
    slots — WCAG 4.1.1); listas semânticas, `<h2>` do widget e `<h3>` por item, datas por
    `LocalizedDate`. O contêiner de capa só é `<a>` quando a notícia **tem** capa; sem mídia ele é
    `span` decorativo (um `<a>` em volta do placeholder ficaria sem nome acessível) e o único link
    do cartão é o título — regressão em `NoticiasWidgetsTest` e axe do e2e 30.

## Comandos

```bash
php artisan core:module:activate Noticias     # ativa (data — ADR-034)
php artisan core:module:activate Noticias --disable
php artisan migrate                            # migrations por convenção
```

## Convenções (checklist)

- [x] `declare(strict_types=1)`; DI (sem `new`/`resolve` no corpo); sem traits/abstract/generics.
- [x] Conteúdo em `jsonb` (AST por locale), sem HTML/CSS/flag de UI no banco (ADR-017).
- [x] Logs JSON + `log_event` sem PII (ADR-024); mutação auditada (ADR-035).
- [x] 4 locales no `Lang/` com paridade de chaves (`ModuleI18nTest`).
- [x] A11y eMAG/WCAG 2.2 AA; alvos ≥ 2.75rem no mobile (ADR-025).

## Verificação

```bash
make exec cmd='bash tools/name-check.sh --grep noticias'
make exec cmd='php tools/arch-check' && make exec cmd='php tools/migration-check'
make exec cmd='vendor/bin/phpunit --filter=NoticiasWidgetsTest'   # fontes de widget (P5.S5.1)
make check            # cadeia completa (lint, PHPStan max, gates, testes, cobertura)
make check E2E=1      # + e2e/axe/sniff/perf quando a UI muda
```
