# P1.S4 — Módulo `Infraestrutura/Menus`, porta `Navigation` e render do portal

| Campo | Valor |
|---|---|
| **Fase** | P1 — Estrutura geral do site e do CMS |
| **Issue** | P1.S4 — menus do site como dado + porta Core + render do header |
| **`kd` task** | `task_01q7stm2` (épico `epic_01vhakc0`) |
| **Âncoras** | `wiki/implementation-phases/P1-estrutura-site-cms.md` §3/S4, §5–§7 · `wiki/implementation-phases/TECNICO.md` §14 · ADR-004, ADR-012, ADR-013, ADR-014, ADR-016, ADR-017, ADR-025, ADR-033, ADR-034, ADR-046 · `_to_revision/PROTOCOLO.md` |
| **Status da revisão** | ✅ revisado (correções do P1.FIX aplicadas) |

## O que foi implementado

- **Porta Core `Core\Contracts\Navigation`** (`tree(string $menu): array`, `flat(string $menu): array`)
  com item `{id,label,url|null,icon,external,opens_new_tab,level,parent_id,children}`. O Core
  registra o fallback **`EmptyNavigation`**; o módulo registra a implementação no
  `register()` (`app()->singleton`). O `layout` apenas resolve o contrato (`@inject`) e **não**
  referencia `Modules\` (teste estrutural).
- **Módulo `src/modules/Infraestrutura/Menus`** (token `menus`, grupo `Infraestrutura`,
  permissões `[VIEW, EDIT]`, ícone Lucide `lucide-menu`, posição 85). Ativo no manifesto
  (`bootstrap/cache/modules_manifest.php`) via `core:module:activate Menus` (ADR-034). Rotas em
  `/menus` e `/menus/{menu}/itens` na área CMS (`menus.cms.*`), com `scopeBindings()`.
- **Schema `menus`** (migração idempotente): `menus` (`slug` único `lower(slug)`, `nome`,
  `descricao`) e `menu_items` (`menu_id` cascata, auto-FK **composta** `(parent_id, menu_id)` com
  `NO ACTION`, `position >= 1`, `type route|url|page`, `target jsonb` coerente com o tipo,
  `label jsonb` por locale com `pt_BR` obrigatório e apenas as quatro chaves, `icon` Lucide
  opcional, `active`, `opens_new_tab`). `down()` renomeia tabelas **e índices explícitos** para
  `backup_*` — rollback → novo `up()` funciona (teste).
- **Dado de referência idempotente**: menu `principal` com as três rotas atuais do portal
  (`portal.home`, `noticias.index`, `dados-abertos.index`) e rótulos nas quatro locales; sem
  seeder de demonstração/ETL.
- **CMS**: lista/criação/edição de menu; árvore de itens (até 3 níveis) com criação, edição,
  ativar/desativar, mover (renumeração `1..n` determinística) e exclusão de folha. Ciclo, pai de
  outro menu e nível 4 respondem 422; excluir pai com filhos é bloqueado (app + FK). `route` só
  aceita rota viva da **área Portal** (`PlatformArea::PORTAL->domain()`) e sem parâmetro
  obrigatório; `url` só `http(s)` absoluta (sem `javascript:`/`data:`/credenciais); `page`
  desabilitado; ícone Lucide inexistente é recusado no editor e ignorado no port.
- **Locale**: rótulos em `jsonb` nas 4 locales (editor exige as quatro); port resolve o locale
  vigente com fallback `pt_BR`. Langs `menus::cms.*`/`menus::menu.title` nos quatro idiomas. O
  editor não declara `<h1>` e usa só `<x-layout::cms>`/`<main>`.
- **Cache** `navigation.{menu}.{locale}`: leitura cacheada (TTL de segurança) e invalidação
  **síncrona** em toda escrita (inclusive troca de slug do menu). Segundo render não toca
  `menus.*`; escrita → próximo render reflete (testes de dois requests e de contagem de query).
- **Portal**: header consome o port e renderiza `<nav aria-label>`, `<ul>/<li>`,
  `aria-current="page"`, ícones `<x-ui::icon>`, submenu em `<details>/<summary>` (sem JS),
  `target/rel="noopener"` só com `opens_new_tab` explícito, links internos via `route()` (locale
  preservado) e nenhum link morto. Fallback para `config('layout.portal.nav')` sem cache quando o
  menu `principal` não existe (instalação nova), documentado e testado.

## Fora desta fatia (não implementado de propósito)

- Ação “Configurar layout da área/módulo” (P1.S4b).
- SEO base/identidade publicada (P1.S5) e E2E completo (P1.S6).
- Tipo `page` e vínculo com páginas publicadas (P5) — o schema já aceita, o editor não oferece.
- Exclusão de menus (a fatia pede lista/criação/edição; exclusão de menu com itens não foi exposta).
- `quick-links` da Home continua lendo `config('layout.portal.nav')` (não é o header; fora do escopo).

## Arquivos para revisar

- `src/core/Contracts/Navigation.php`
- `src/core/Internal/Navigation/EmptyNavigation.php`
- `src/core/CoreServiceProvider.php` (bind do fallback)
- `src/modules/Infraestrutura/Menus/ModuleProvider.php`
- `src/modules/Infraestrutura/Menus/Routes/cms.php`
- `src/modules/Infraestrutura/Menus/Database/Migrations/2026_10_24_000000_create_menus_tables.php`
- `src/modules/Infraestrutura/Menus/Database/Migrations/2026_10_24_000100_seed_principal_menu.php`
- `src/modules/Infraestrutura/Menus/Database/Models/{Menu,MenuItem}.php`
- `src/modules/Infraestrutura/Menus/Logic/MenusNavigation.php`
- `src/modules/Infraestrutura/Menus/Logic/Actions/{SaveMenu,SaveMenuItem,DeleteMenuItem,MoveMenuItem,ToggleMenuItem}.php`
- `src/modules/Infraestrutura/Menus/Logic/Controllers/Cms/*.php`
- `src/modules/Infraestrutura/Menus/Logic/DTOs/{MenuData,MenuItemData}.php`
- `src/modules/Infraestrutura/Menus/Logic/Requests/{SaveMenuRequest,SaveMenuItemRequest}.php`
- `src/modules/Infraestrutura/Menus/Logic/Support/{MenusCache,MenusTarget,MenusTree}.php`
- `src/modules/Infraestrutura/Menus/Layout/Views/cms/{index,menu-form,menus-items,menu-item-form}.blade.php`
- `src/modules/Infraestrutura/Menus/Layout/Views/cms/partials/{menu-tree,menu-item-fields}.blade.php`
- `src/modules/Infraestrutura/Menus/Lang/{pt_BR,en,es,fr}/{cms,menu}.php`
- `src/modules/Infraestrutura/Menus/MODULE.md`
- `src/modules/Infraestrutura/Menus/Test/{MenusCmsTest,MenusNavigationTest}.php`
- `src/layout/Portal/partials/{header,nav-items}.blade.php`
- `src/layout/Assets/css/layout.css`
- `src/layout/Lang/{pt_BR,en,es,fr}/shell.php` (`new_tab`, `go_to`)
- `phpstan.neon` (triagem `EmptyNavigation` — resolvido pelo container)

## Pontos de revisão

### D1 — Simplicidade / PHP 8.4
- [ ] `declare(strict_types=1)`; DI (`Telemetry`, `ModuleDiscoveryService`); sem `new`/`app()` em
      corpo de domínio (o port usa `app()->getLocale()` como fronteira de request, como
      `LocaleCast`); sem trait/abstract/generic.
- [ ] Uma Action por arquivo, `execute()`; suporte concreto (`MenusTree`/`MenusTarget`/`MenusCache`)
      com 2+ consumidores reais, sem “repository”.
- [ ] `match` fechado no alvo e no tipo; `#[\Override]` no port.

### D2 — Laravel 12
- [ ] Sem closure em rota; nomes `menus.cms.*`; `scopeBindings()`/`whereUuid`; mutação só
      `POST/PUT/DELETE`; autorização no controller (`Permission::authorize`).
- [ ] `FormRequest` valida forma e normaliza (booleans/trim); `validated()` é a entrada do DTO;
      `Rule::unique` não é usado em tabela com schema (closure com `lower(slug)`).
- [ ] Migração idempotente; `down()` renomeia tabela **e** índices; sem `DROP`.

### D3 — Determinismo
- [ ] Sem `now()`/`rand()` no domínio; UUIDs do dado de referência são fixos; ordem `position`,
      `id`; renumeração `1..n`; testes sem relógio/rede.

### D4 — Fronteiras
- [ ] `layout` não importa `Modules\` (teste); `core` não importa módulo; módulo depende de
      Core/Layout apenas; `arch-check` OK.

### D5 — Dependências, licenças e vendorização
- [ ] Nenhuma dependência nova (composer/npm); ícones Lucide já vendorizados (`IconSet`); zero
      CDN/new request. `check-vendor`/`vendor-check` verdes no `make check`.

### D6 — CSS/Lucide
- [ ] Nenhum `style=`/`<style>`/`<script>` nas views do módulo (teste); submenu em
      `@layer components` com `rem`/propriedades lógicas; ícones só Lucide vendorizado.

### D7 — HTML/a11y
- [ ] `<nav aria-label>`, `<ul>/<li>`, `aria-current="page"`, `<details>/<summary>` (sem JS),
      `sr-only` para “abre em nova aba”, alvos do design system preservados; skip link intacto.
- [ ] Editor: rótulos `<label for>`, `aria-invalid`/`role="alert"` nos erros, fieldset/legend;
      sem `<h1>` próprio.

### D8 — i18n
- [ ] Quatro locales com paridade (`ModuleI18nTest`); rótulos do conteúdo em `jsonb`; port resolve
      locale + fallback; links internos via `route()` (teste em `/fr/noticias`).

### D9 — Grep-friendly
- [ ] Token `menus` em arquivos/rotas/tabelas/views/testes; `name-check --grep menus` OK
      (Model 2, Action 5, View 6, Migration 2, Test 2).

### D10 — Dados
- [ ] `uuidv7()`, `text`, `timestamptz`, `jsonb`, `NOT NULL`; `CHECK` de tipo/posição/label/alvo;
      FK composta bloqueia pai de outro menu; `down()` preserva; seed é dado de referência.

### D11 — Segurança/LGPD
- [ ] `Permission::authorize` (VIEW/EDIT) em todas as rotas; 403 sem permissão; URL rejeita
      `javascript:`/`data:`/credenciais; rota precisa ser da área Portal; sem PII em log/contexto.

### D12 — Observabilidade/auditoria
- [ ] Span + `log_event` com o **mesmo** nome `menus.save`/`menus.item_save`/`menus.item_delete`/
      `menus.item_toggle`/`menus.item_move`; leitura pública `menus.inactive`/`menus.invalid`/
      `menus.fallback` (`warning`, escalares); mutação auditada pelo `GlobalAuditSubscriber`.

### D13 — Testes
- [ ] Schema/unique case-insensitive; 3 níveis + nível 4; ciclo; pai de outro menu; exclusão de pai
      bloqueada (app + banco); reordenação determinística; rota/URL; `page`; locale/fallback; link
      morto; cache (2 requests + zero query); módulo inativo; fallback de config; 403; inline
      style; estrutura sem JS; migração rollback→up.
- [ ] Evidência de suíte e gates abaixo.

### D14 — Documentação
- [ ] `MODULE.md` com rotas/eventos/dados sincronizados (gates `DocsReferencesTest`); esta ficha e
      linha 64 do `README.md`; sem código morto (stubs do gerador não foram criados).

## Evidência

- `php artisan migrate` — migrações novas aplicadas no dev; seed do `principal` (3 itens).
- `./vendor/bin/phpunit --filter='MenusCmsTest|MenusNavigationTest'` — **33 testes, 271 asserções**.
- Suíte completa: `./vendor/bin/phpunit` — **868 testes, 14.376 asserções, OK**.
- `phpstan analyse --memory-limit=1G` — **0 erros** (nível max).
- `pint --test` — PASS; `tools/arch-check` — OK; `tools/migration-check` — OK;
  `tools/name-check.sh` — OK (8 módulos); `tools/name-check.sh --grep menus` — OK;
  `tools/docs-check.sh` — OK.
- `make check` — **26 OK · 0 FAIL · 7 SKIP** (e2e/perf opt-in, como previsto para a fatia),
  **125,60 s**; segunda execução quente: **24 OK · 0 FAIL · 9 SKIP** (stamps de prep),
  **105,71 s**; cobertura e a11y verdes (`build/check`).

## Riscos e observações

- **`pt_BR` obrigatório no banco, demais locales opcionais**: o editor exige as quatro e testa a
  paridade; o `CHECK` deixa as outras ausentes para o **fallback do port** ser alcançável com dado
  antigo/corrompido (testado). Rejeitar chaves fora das quatro está no banco.
- **Auto-FK composta `NO ACTION`**: excluir pai com filhos falha (23503) mesmo por SQL direto;
  excluir o menu inteiro funciona pela cascata do `menu_id` no mesmo statement (testado em psql).
- **Cache do port**: TTL de segurança de 1 h além da invalidação síncrona. Escrita fora do módulo
  (SQL manual/ETL) não invalida — o contrato é o CMS.
- **Fallback de configuração**: só quando o menu `principal` **não existe** (instalação nova) e
  **sem cache**; menu existente e vazio devolve `[]` (a remoção dos itens é intencional — teste
  `an_existing_empty_primary_menu_does_not_reintroduce_the_config_links`).
- **Slug do menu principal é imutável**: `Menu::PRIMARY_SLUG` é a constante única (port + editor);
  `SaveMenu` recusa com 422 qualquer slug diferente em `principal` e um slug em branco mantém o
  canônico (nunca re-deriva do nome). O campo aparece `readonly` com ajuda dedicada. Sem isso,
  renomear/limpar o slug desanexava o header em silêncio.
- **Rota de módulo inativo é link morto**: `MenusTarget::route()` deriva o módulo do namespace do
  controller (`Modules\{Grupo}\{Modulo}\…`) e consulta `ModuleDiscoveryService`; o item é
  ignorado + `menus.invalid` (teste desativando `Noticias`). `Route::view` sem controller de módulo
  não tem dono a checar.
- **Invalidação e corrida**: `MenusCache::forget` roda em `DB::afterCommit` nos saves (a leitura
  concorrente não repopula a árvore antiga) e `SaveMenuItem` trava a **linha do menu** antes de
  listar os itens (o primeiro insert concorrente serializa na mesma `position`).
- **`url()->current()` para `aria-current`**: comparação exata de URL; link externo com query
  diferente não é marcado (correto).
- **`down()` da migração**: todo o corpo roda com `finally { RESET search_path }`; o rename
  não-destrutivo e o teste rollback→up seguem como contrato.
- **ERRO operacional durante a sessão**: um `migrate:rollback --step=2` no dev renomeou
  `core.core_settings` (batch anterior) para `backup_*`; o dado foi restaurado por `INSERT ... SELECT`
  no `core_settings` recriado e as tabelas de backup de menus foram movidas para o schema
  `backups`. Nenhum arquivo versionado foi afetado; o `down()` passou a renomear índices
  explícitos (teste de rollback→up cobre a causa).

## Notas da revisão

| Data | Revisor | Resultado | Observações |
|---|---|---|---|
| | | | |
| 2026-10-24 | P1.FIX (agente) | ✅ | **Médio (slug do principal):** `Menu::PRIMARY_SLUG` é a constante única; `SaveMenu::resolvedSlug()` responde 422 para slug diferente do canônico e mantém `principal` quando o campo vem em branco; o formulário mostra o campo `readonly` + ajuda nos 4 locales. Teste `MenusCmsTest::the_primary_menu_slug_is_immutable_and_keeps_the_header_attached` (422, slug preservado, item editado aparecendo no header). **Médio (menu vazio):** o fallback de configuração só vale quando a **linha do menu não existe**; teste `MenusNavigationTest::an_existing_empty_primary_menu_does_not_reintroduce_the_config_links`. **Baixos/robustez:** `MenusCache::forget` em `DB::afterCommit` em `SaveMenu`/`SaveMenuItem`; `SaveMenuItem` trava a **linha do menu** antes de listar itens (primeiro insert concorrente serializa); `down()` da migração com `finally { RESET search_path }`; rota de módulo inativo vira link morto (`MenusTarget::route(..., $discovery)` deriva o módulo do namespace do controller) — teste `a_route_of_an_inactive_module_is_skipped_as_a_dead_link`; `CmsSidebarPosition::fromValue()` removido (parser único: `CmsShellSchema::position`). Evidência: `MenusCmsTest|MenusNavigationTest` 36 testes verdes; `make check` 2× + `E2E=1` verdes. |
