# Módulo Destaques (`Portal/Destaques`)

> Contrato de leitura do módulo de **destaques/banners** (ADR-004/ADR-014/ADR-016/ADR-026): a tabela
> `destaques.destaques`, o CMS (lista, criar, editar, ativar/desativar, arquivar), a validação de
> conteúdo (mídia do catálogo, `alt`, destino seguro, janela coerente) e o widget público
> `destaques.banner` (vigência com `Clock`, limite por slot e cache invalidado na escrita — P5.S4.2).

## Propósito

- **Grupo:** `Portal` (organizacional — não entra no token).
- **Áreas:** CMS (lista e formulário) + widget público registrado no Core. **Sem rota pública
  própria**: o portal renderiza a peça pelo slot do perfil de layout.
- **Token:** `destaques`.
- **Ativação:** dado em `core_module_discovery_settings` (ADR-034):
  `php artisan core:module:activate Destaques [--disable]`. Módulo inativo → a superfície do painel
  responde 404 (guard da área) e o `WidgetRegistry` não resolve `destaques.banner` (slot vazio).

## Estrutura (contrato físico)

```
src/modules/Portal/Destaques/
├── ModuleProvider.php                    # permissions destaques.{ver,editar,arquivar}; rota do painel; registra o widget
├── Routes/cms.php                        # GET /destaques, /criar; POST /destaques; GET /{id}/editar; PUT /{id}; POST ativar/desativar/arquivar
├── Logic/Controllers/Cms/DestaquesIndexController.php     # lista + filtros (VIEW)
├── Logic/Controllers/Cms/DestaqueCreateController.php     # formulário vazio (EDIT)
├── Logic/Controllers/Cms/DestaqueEditController.php       # formulário do registro + ações de estado (EDIT)
├── Logic/Controllers/Cms/DestaqueStoreController.php      # 403 → validação → Action → redirect
├── Logic/Controllers/Cms/DestaqueUpdateController.php     # 403 → validação → Action → redirect (409 se arquivada)
├── Logic/Controllers/Cms/DestaqueActivateController.php   # 403 → ativar (EDIT) → redirect
├── Logic/Controllers/Cms/DestaqueDeactivateController.php # 403 → desativar (EDIT) → redirect
├── Logic/Controllers/Cms/DestaqueArchiveController.php    # 403 → arquivar (ARCHIVE) → redirect
├── Logic/Actions/SaveDestaque.php        # mídia/alt/destino/janela; posição determinística; 422 por campo; span/log
├── Logic/Actions/SetDestaqueActive.php   # ativar/desativar (preserva o registro); idempotente; auditoria
├── Logic/Actions/ArchiveDestaque.php     # arquivar com Clock; 409 se repetido; auditoria
├── Logic/Requests/SaveDestaqueRequest.php# forma do formulário + mensagens do módulo
├── Logic/DTOs/DestaqueData.php           # entrada validada (título, mídia, alt, link, slot, ordem, janela)
├── Logic/DTOs/DestaqueBannerItem.php     # peça pronta para render + payload do cache (janelas + mídia)
├── Logic/Enums/DestaqueSlot.php          # main.before | main.after | main.header | main.footer
├── Logic/Enums/DestaqueState.php         # arquivado | inativo | agendado | vigente | expirado
├── Logic/Exceptions/DestaqueTransitionConflict.php # transição inválida → 409
├── Logic/Support/DestaqueLink.php        # caminho same-origin ou https:// — nada de javascript:/data:/http:
├── Logic/Support/DestaqueWindow.php      # coerência da janela + estado efetivo (Clock injetado)
├── Logic/Support/DestaqueFormState.php   # estado inicial do formulário (criar/editar)
├── Logic/Support/DestaqueSlotFeed.php    # banner do slot: 1 query de peças + 1 whereIn de mídia; vigência/limite
├── Logic/Support/DestaqueSlotCache.php   # chave/TTL/invalidação `destaques.slot.{slot}.{locale}`
├── Logic/Support/DestaqueSlotLimit.php   # teto por slot (`config/destaques.php`) + recorte determinístico
├── Database/Migrations/2026_10_26_043244_create_destaques_destaques_table.php
├── Database/Models/Destaque.php          # `destaques.destaques`
├── Layout/Views/cms/index.blade.php      # lista (shell + filtros + tabela + ações)
├── Layout/Views/cms/form.blade.php       # formulário (título por locale, mídia, alt, destino, slot/ordem, janela) + estado
├── Layout/Views/widgets/destaques-banner.blade.php # fragmento público (seção/artigo + `<x-media::picture>`, sem JS)
├── Widgets/DestaqueBannerWidget.php      # `WidgetSource` `destaques.banner` (embeddable)
├── Lang/{pt_BR,en,es,fr}/index.php, menu.php, cms.php, widget.php
└── Test/DestaquesCmsTest.php, DestaquesSchemaTest.php, DestaqueLinkTest.php, DestaqueWindowTest.php,
         DestaqueBannerWidgetTest.php
```

## Rotas

| Nome | Método | URI | Controller | Acesso |
|---|---|---|---|---|
| `destaques.cms.index` | GET | `/cms/destaques` | `DestaquesIndexController` (invocável) | `destaques.ver` |
| `destaques.cms.create` | GET | `/cms/destaques/criar` | `DestaqueCreateController` | `destaques.editar` |
| `destaques.cms.store` | POST | `/cms/destaques` | `DestaqueStoreController` | `destaques.editar` (freio `throttle:60,1`) |
| `destaques.cms.edit` | GET | `/cms/destaques/{destaque}/editar` | `DestaqueEditController` | `destaques.editar` (binding por UUID) |
| `destaques.cms.update` | PUT | `/cms/destaques/{destaque}` | `DestaqueUpdateController` | `destaques.editar` (freio `throttle:60,1`) |
| `destaques.cms.activate` | POST | `/cms/destaques/{destaque}/ativar` | `DestaqueActivateController` | `destaques.editar` (freio `throttle:60,1`, CSRF) |
| `destaques.cms.deactivate` | POST | `/cms/destaques/{destaque}/desativar` | `DestaqueDeactivateController` | `destaques.editar` (freio `throttle:60,1`, CSRF) |
| `destaques.cms.archive` | POST | `/cms/destaques/{destaque}/arquivar` | `DestaqueArchiveController` | `destaques.arquivar` (freio `throttle:60,1`, CSRF) |

As rotas vivem no domínio do painel e nascem autenticadas pelo `AreaRegistrar` (ADR-037). A
autorização é do controller (`Permission::authorize`, ADR-033) — **403 antes** de qualquer 422/409 —
e as mutações são `POST`/`PUT` com CSRF (nunca `GET` mutando). **Não existe rota pública** nesta
fatia: a leitura no portal é o widget da S4.2.

## Permissões (ADR-033)

O manifesto declara `destaques.ver`, `destaques.editar` e `destaques.arquivar` (sem `aprovar`: a
peça entra/sai do site por `active` + vigência, não por aprovação editorial separada). Ler a **lista**
exige `ver`; abrir/criar/editar o formulário e **ativar/desativar** exigem `editar`; **arquivar**
exige `arquivar` (ação própria). Tudo é negado por padrão.

## Dados (ADR-007/ADR-017/ADR-026)

- Schema `destaques`, tabela `destaques`: `id` (uuidv7), `title` (jsonb — texto por locale),
  `image_id` (uuid, **FK** para `gallery.gallery_media` com `ON DELETE RESTRICT`), `alt` (texto,
  obrigatório, ≤ 200), `link` (texto nulo), `slot` (texto), `position` (inteiro ≥ 0),
  `starts_at`/`ends_at` (timestamptz nulos), `active` (booleano), `archived_at` (timestamptz nulo),
  `created_at`/`updated_at`.
- **Conteúdo é dado:** a imagem é o **id do catálogo** Media (ADR-026) — nunca caminho/URL/HTML; o
  `alt` é texto simples e o destino é caminho same-origin (`/…`, nunca `//host`) ou `https://`
  absoluto sem credenciais. `javascript:`, `data:`, `http:`, `..`, espaço/quebra e `\` são recusados
  **na aplicação (422)** e o CHECK do banco fecha o mesmo contrato estrutural.
- **Slot fechado:** `main.before`, `main.after`, `main.header`, `main.footer` (subconjunto editorial
  dos slots canônicos do serviço Layout) — CHECK no banco e enum no código.
- **Ordem:** `position` inteiro ≥ 0; em branco a Action grava `max(position) + 1` do slot (uma
  query, determinístico). A leitura ordena por `slot`, `position`, `id` (desempate único, ADR-001).
- **Vigência:** `ends_at > starts_at` quando as duas existem (CHECK `destaques_window_check`); a
  janela é **meio-aberta** `[starts_at, ends_at)` e o estado efetivo é derivado com o `Clock`
  injetado (`DestaqueWindow`). Nenhuma coluna “vigente” é persistida.
- **Estado:** `active=false` **preserva** o registro (sai do site, pode voltar); `archived_at` é o
  estado final desta fatia (registro preservado e imutável — editar/ativar/desativar responde 409).
- **Índices:** pilha por slot (`slot, position, id` parcial em não arquivadas) e vigência das ativas
  (`slot, starts_at, ends_at` parcial em `active` e não arquivadas).
- `down()` renomeia a tabela e os índices explícitos para `backup_*`; nunca dropa (ADR-017).

## Validação de conteúdo (P5.S4.1)

| Regra | Onde | Recusa |
|---|---|---|
| Mídia existe, não está arquivada **e é imagem** (`mime` `image/*`) | `SaveDestaque` via contrato `GalleryStore` | 422 `image_id` |
| `id` de mídia é UUID | `SaveDestaqueRequest` | 422 `image_id` |
| `alt` não vazio, ≤ 200, sem marcação | Action (o request valida presença/limite) | 422 `alt` |
| Destino same-origin ou `https` | `DestaqueLink` na Action | 422 `link` |
| Slot no vocabulário | request (`Rule::in`) + CHECK | 422 `slot` / 23514 |
| Janela coerente | `DestaqueWindow::isCoherent` + CHECK | 422 `ends_at` / 23514 |
| Título obrigatório no locale default e sem marcação | request + Action | 422 `title.pt_BR` |

Mensagens são **do módulo** (`destaques::cms.*`, 4 locales) — a aplicação não publica
`lang/validation.php`, então nenhum erro chega ao operador como chave crua.

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

- **Emite:** `destaques.save` (span + `log_event` com `id`/`slot`/`position`),
  `destaques.activate`/`destaques.deactivate` (span + `log_event` com `id`) e `destaques.archive`
  (span + `log_event` com `id`) — sem PII.
- **Auditoria:** toda mutação de `Destaque` entra na trilha pelo `GlobalAuditSubscriber` (área CMS =
  full — `destaques.created`/`destaques.updated`, ator snapshot, ADR-035). Ativar/desativar/arquivar
  gravam **evidência explícita** `destaques.activated`/`destaques.deactivated`/`destaques.archived`
  com `before`/`after`. Pedir o estado que já vale é idempotente e não gera evidência duplicada;
  recusas (422/409) não gravam nada.

## Layout (ADR-016)

- Lista e formulário usam **um** `<x-layout::cms>` e preenchem só o `<main>`; o `<h1>` é do shell e o
  módulo entrega `<h2>`/`<legend>` (grupos de campos).
- Formulário: um título por locale (o do idioma padrão é obrigatório), id da mídia, `alt`, destino,
  slot, ordem, janela (`datetime-local`) e o estado (`checkbox` com campo oculto `0` para o submit
  sem JS). Erros: resumo `role="alert"` (`x-cms::form-errors`) + mensagem no campo com
  `aria-invalid`/`aria-describedby`. Nenhuma ação depende de JS.
- Ações de linha são `POST` + CSRF; arquivar usa confirmação nativa (Popover, `x-cms::confirm`) —
  nunca `<form>` aninhado. Peça arquivada mostra os valores em `<dl>` (sem formulário) e explica o
  estado final.

## Widget público `destaques.banner` (P5.S4.2)

- **Registro:** `ModuleProvider::register()` registra `Modules\Portal\Destaques\Widgets\DestaqueBannerWidget`
  no `WidgetRegistry` do Core. `id = destaques.banner`, `title = destaques::widget.title` (4 locales),
  ícone Lucide `lucide-image`, `embeddable = true` e slots `main.before`, `main.after`,
  `main.header`, `main.footer` (o vocabulário fechado de `DestaqueSlot`, subconjunto de `LayoutSlot`).
  O perfil de layout referencia só o `id`: embed permitido, import proibido (ADR-013/ADR-016).
- **Slot:** o `<x-layout::slot>` repassa o nome do slot como `layoutRegion` para o fragmento
  (`src/layout/Components/widget.blade.php`); o banner renderiza **somente** a pilha daquele slot
  (`DestaqueSlot::tryFrom`; fora do vocabulário → nada). Slot vazio não renderiza wrapper.
- **Leitura:** `DestaqueSlotFeed` faz **uma** query de peças (`active`, `archived_at IS NULL`, slot,
  `ORDER BY slot, position, id`) + **uma** `whereIn` de mídia pelo contrato `GalleryStore::views()`;
  o estado efetivo vem do `DestaqueWindow` (Clock injetado) e só `Vigente` aparece. Sem rotação
  automática e sem N+1 (`DestaqueBannerWidgetTest` conta as queries).
- **Vigência:** janela meio-aberta `[starts_at, ends_at)`; expirada não entra no cache e uma peça
  vencida **nunca** é servida do cache (o payload guarda as janelas e a leitura reaplica
  `DestaqueBannerItem::appearsAt()` com o Clock). Agendada entra no ar no instante certo sem
  depender do TTL.
- **Limite por slot:** `config('destaques.slot_limit')` (default 3, valor `< 1`/não numérico é
  ignorado). É um **teto de leitura**: o recorte é o prefixo determinístico da pilha vigente
  (`position`, `id`); a escrita não recusa peças, então a redação pode agendar além do teto e o site
  mostra no máximo N. Nunca é coluna (ADR-017).
- **Cache:** `destaques.slot.{slot}.{locale}` — o locale é o eixo de versão porque o título é
  traduzível. TTL `config('destaques.cache_ttl')` (default 300 s; `0` desliga) é **só** rede de
  segurança: `SaveDestaque` (slot antigo e novo), `SetDestaqueActive` e `ArchiveDestaque` invalidam
  de forma **síncrona** (ADR-022). Payload corrompido reconstrói do banco (nunca vira peça pela
  metade).
- **Render:** `<section aria-label>` (sem `id` fixo — o widget pode ocupar dois slots) → `<ul>` →
  `<li><article>` por peça, `<h2>` com o título e `<x-media::picture>` (AVIF/WebP, `width`/`height`,
  `alt` obrigatório da peça, `decoding="async"`; `loading="eager"` em `main.before`/`main.header`,
  `lazy` nos demais). Caminho interno passa por `localized_url`; link externo `https` leva
  `rel="external noopener"`. Sem JS, sem carrossel/autoplay, sem estilo inline.
- **Degradação:** mídia arquivada/ausente ou título vazio → peça omitida (nunca meio bloco);
  módulo inativo → o registry não resolve a fonte e o slot fica vazio, sem 500 (ADR-034).

## Contrato para S5.1/S5.2

- **Outras fontes (S5.1):** cada módulo dono registra a própria `WidgetSource` no
  `ModuleProvider::register()`; o `WidgetRegistry` já resolve classe + módulo ativo. Reusar o padrão
  de `destaques.banner` (`render()` devolve a view; o fragmento resolve dados *no render*, porque a
  fonte é construída no boot).
- **`/cms/layout` (S5.2):** a listagem de perfis exibe o **catálogo derivado** de
  `WidgetRegistry::forSlot()` (id, título i18n, ícone, dono do manifesto e `embeddable`);
  `destaques.banner` aparece nos quatro slots editoriais sem alteração. Módulo inativo → fonte fora
  do catálogo e da oferta do formulário, e `hasPlacements` não mantém a região aberta (sem wrapper
  vazio).
- **Sem tocar no dado:** HTML/CSS/classes continuam fora do banco; o limite por slot é configuração,
  nunca coluna.
## Comandos

```bash
php artisan core:module:activate Destaques
php artisan core:module:activate Destaques --disable
php artisan route:list --name=destaques.
```

## Verificação

```bash
vendor/bin/phpunit --filter='DestaquesCmsTest|DestaquesSchemaTest|DestaqueLinkTest|DestaqueWindowTest|DestaqueBannerWidgetTest'
bash tools/name-check.sh --grep destaques
make check
make check E2E=1
```
