# P1.S5 — SEO de base e identidade publicada

| Campo | Valor |
|---|---|
| **Fase** | P1 — Estrutura geral do site e do CMS |
| **Issue** | P1.S5 — publicar `SeoSettings` no `<head>`/`@graph` (descrição, imagem social do Media, verificação, analytics opt-in) |
| **`kd` task** | `task_00x1sawi` (épico `epic_01vhakc0`) |
| **Âncoras** | `wiki/implementation-phases/P1-estrutura-site-cms.md` §3/S5, §5–§8 · `wiki/implementation-phases/TECNICO.md` §14 · ADR-027, ADR-026, ADR-046, ADR-043, ADR-022, ADR-019, ADR-002, ADR-012 |
| **Status da revisão** | ✅ revisado (correções do P1.FIX aplicadas) |

## O que foi implementado

- **`SeoService::resolve()`** (ADR-027):
  - A descrição padrão passa a usar `SeoSettings::defaultDescription()` (alias
    `default_description` → `site_description`, com `siteName()` como último recurso). Antes o
    service só lia `site_description`.
  - **Imagem social padrão**: `seo.default_image` (UUID) é resolvido **na borda da view** por
    `SeoSettings::defaultImageUrl()` (porta `Services\Media\Contracts\MediaRepository` resolvida
    pelo container) e entregue a `resolve($overrides, $defaultImage)` como argumento — o service
    não consulta o Media nem a request (ADR-027: `SeoBinder` puro). Só entra upload **publicado**
    (`MediaUploadStatus::CONCLUIDO`) com payload `kind=image` e caminho `permanent/`; a URL é a
    rota **`media.stream`** (absoluta e same-origin). UUID inexistente, payload de documento,
    caminho em `staging/`, `..` ou URL absoluta são ignorados com
    `log_event('seo.default_image_invalid')` sem id/valor — cai no `site_logo`/`null`, nunca 500.
    O campo nunca aceita URL crua (o schema já recusa na escrita).
  - Grafo `@graph`: nenhum nó novo; a descrição/imagem configuradas entram nos nós existentes
    (`GovernmentPage.description`/`primaryImageOfPage`).
- **`SeoSettings`**:
  - `verificationMetaTags()` traduz `{provider, content}` para o `name` canônico do provedor
    (allowlist fechada `google`/`bing`/`yandex`/`pinterest`/`facebook`), ignora provedor
    desconhecido com `log_event('seo.verification_skipped')` e deduplica nomes. O `name` nunca
    vem do documento (sem injeção de meta arbitrário); o `content` é escapado pelo Blade.
  - `defaultImageId()` continua aceitando **somente UUID**; o docblock agora aponta o upload
    publicado do Media (não uma URL).
- **`<x-service-seo::tags>`**:
  - Publica as metas de verificação (quando configuradas) e `twitter:image` junto de
    `og:image`.
  - Sem settings, o `<head>` sai **byte a byte** igual ao anterior: blocos opcionais preservam a
    posição/quebra de linha do template (verificado contra o `HEAD` do arquivo).
- **`analytics_id`**: comportamento inalterado — opt-in; sem ID não há `gtag-id` nem requisição
  externa (ADR-002/ADR-019).
- **`robots.txt`/`sitemap.xml`/áreas restritas**: sem mudança (ADR-043); testes confirmam que o
  `<head>`/robots do portal não citam `cms.`/`api`.
- **`phpstan.neon`**: exceção estreita para `SeoSettings::verificationMetaTags` (chamado só pelo
  Blade; o filtro precisa viver em PHP para ser testado).

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

- Sem mudança em `BuildSeoGraph` (nenhum nó inventado), `BuildSeoSitemap`, redirects, manifest.
- Sem novo asset/CDN, sem `<script>` inline, sem GA habilitado por padrão.
- Tela de configuração de `seo` no CMS: a seção **não** está em `ConfiguracoesSections::EDITABLE`
  (não há superfície de escrita hoje). Esta fatia só **publica** um valor existente no documento
  (`core_settings`); levar `default_image`/`verification` ao editor continua fora do escopo.
- `twitter:title`/`twitter:description` e `og:image:width/height` não foram adicionados (não
  estavam no contrato e mudariam o `<head>` legado).

## Arquivos para revisar

- `src/services/Seo/SeoService.php`
- `src/services/Seo/SeoSettings.php`
- `src/services/Seo/View/components/tags.blade.php`
- `src/services/Seo/Test/SeoPublishTest.php` (novo)
- `src/core/Settings/SettingsSchema.php` (comentário do campo `default_image`)
- `phpstan.neon` (triagem `shipmonk.deadMethod`)

## Pontos de revisão

### D1 — Simplicidade / PHP 8.4
- [ ] `declare(strict_types=1)`, tipos em parâmetros/retornos; DI por construtor (`MediaRepository`)
      sem `new`/`resolve`/`app()` no corpo do service.
- [ ] Nenhuma abstração nova: o filtro da allowlist mora em `SeoSettings`; não foi criada Action
      nem classe de resolução de imagem.
- [ ] `isPublishedPath()` cobre `permanent/`, ausência de `..` e de esquema absoluto.

### D2 — Laravel 12
- [ ] Sem `env()` novo; `route('media.stream')` para URL interna; nenhuma rota/controller novo.
- [ ] `MediaRepository` é contrato do Media (`Contracts`), respeitando a fronteira `services → services`.

### D3 — Determinismo
- [ ] Mesmo estado de settings + mídia = mesmo `<head>`; ordem de verificação é a ordem do documento.
- [ ] Sem `now()`/`rand()`; testes determinísticos (UUIDs via `Str::uuid7` e fixtures).
- [ ] Sem settings, o componente é byte-idêntico ao template anterior (ver evidência).

### D4 — Fronteiras
- [ ] `services/Seo` importa apenas `Services\Media\{Contracts,Enums}` (`MediaRepository`,
      `MediaUploadStatus`) — nenhum Model/Action do Media.
- [ ] `layout` continua só consumindo `<x-service-seo::tags>`; nenhum import de módulo.

### D5 — Dependências / CDN
- [ ] Nenhuma dependência, asset ou requisição externa nova; `og:image`/`twitter:image` são
      same-origin (`media.stream`).

### D6/D7 — Apresentação / a11y
- [ ] Nenhum CSS/estilo novo; metas são `<meta>` no `<head>` (sem impacto de layout/foco).
- [ ] Conteúdo de verificação escapado (`{{ }}`) e `name` derivado da allowlist.

### D8 — i18n
- [ ] Nenhuma string visível nova (metas de verificação são tokens técnicos); sufixo/descrição vêm de settings.
- [ ] `title_suffix` continua aplicado sem duplicar; canonical/hreflang/@graph inalterados.

### D9/D10 — Grep-friendly / dados
- [ ] Nenhuma migração/coluna nova; `default_image` continua `media_id` (UUID) no schema fechado.
- [ ] Payload de mídia é lido por DTO (`MediaUploadView`), sem expor `disk_path` de quarentena.

### D11 — Segurança / LGPD
- [ ] `default_image` nunca aceita URL crua; caminho `staging/`/`..` e `kind != image` são descartados.
- [ ] Provedor desconhecido não vira `<meta name>`; `content` é escapado.
- [ ] `analytics_id` continua opt-in (sem ID = sem `gtag-id`/requisição externa); logs sem id/valor/PII.

### D12 — Observabilidade
- [ ] `seo.default_image_invalid` e `seo.verification_skipped` são escalares (`module`/`action`), sem PII.
- [ ] Nenhum span novo (resolução é leitura por request).

### D13 — Testes
- [ ] `SeoPublishTest`: paridade sem settings, descrição/sufixo, imagem via Media, fallback
      (UUID/documento/`staging/`/pendente), `site_logo` quando a mídia é inválida, ausência da
      porta Media, allowlist/escape, analytics opt-in, canonical/hreflang/@graph e áreas restritas.
- [ ] Suíte de SEO (`--filter=Seo`) verde; `SettingsDrivenSiteTest`/Media verdes.

### D14 — Documentação
- [ ] Esta ficha + linha 66 na fila do `README.md`.
- [ ] Comentário do schema/docblock do `SeoSettings` refletem upload publicado (não `<picture>` no head).

## Evidência

- `./vendor/bin/phpunit --filter='SeoPublishTest'` → **10 testes, 70 asserções, OK**.
- `./vendor/bin/phpunit --filter='Seo'` → **59 testes, 341 asserções, OK**.
- `./vendor/bin/phpunit --filter='SettingsDrivenSiteTest|MediaRepositoryTest|MediaStreamTest|SeoTagsTest|SeoServiceTest'`
  → **26 testes, 142 asserções, OK**.
- Paridade sem settings: render do componente atual **idêntico byte a byte** ao template do
  `git show HEAD:src/services/Seo/View/components/tags.blade.php` (comparação `===` em runtime,
  `EQUAL=yes`).
- `phpstan analyse --memory-limit=1G` → **0 erros** (nível max).
- `pint --test` nos arquivos alterados → **PASS**; `git diff --check` → sem problemas.
- `make check` → **24 OK · 0 FAIL · 9 SKIP** (e2e/perf opt-in), **106,50 s**; cobertura OK.

## Riscos e observações

- **Uma leitura de upload por render quando `default_image` está configurado**: a seção `seo` é
  memorizada por request (uma leitura) e só há consulta ao Media quando há UUID configurado; o
  upload não é cacheado. Cache invalidável por evento de mídia continua candidato, não implementado.
- **`SeoSettings::section()` é memoizado por request** (`SettingsRequestScope`) e o miss do
  `DatabaseSettings::raw()` é cacheado: a página publica `<head>`/`@graph` sem N+1 de
  `core_settings` (testes de contagem em `SeoPublishTest`).
- **`media.stream` depende do Media registrado**: se o service não estiver disponível/ligado, o
  container não injeta e a imagem cai no `site_logo`/`null` (sem 500).
- **Sufixo configurado é `trim()`ado** por `SeoSettings::string()` (herdado do P1.S1): um valor
  com espaço à esquerda perde o espaço; o default calculado mantém `" — Nome"`.
- **`twitter:image`** passa a existir somente quando há imagem (settings válidas ou `site_logo`),
  preservando a paridade sem configuração.
- **`og:image` de override de módulo** continua sob contrato do chamador (`SeoData` documenta URL
  absoluta); esta fatia garante absolute/same-origin para o valor configurado no Media.

## Notas da revisão

| Data | Revisor | Resultado | Observações |
|---|---|---|---|
| 2026-10-24 | revisor (agente) | 🔧 | D1–D14 verificados no código, nos testes e em `build/check`. **Sem bloqueador**: paridade sem settings confirmada por inspeção do Blade compilado (blocos `@foreach`/`@if` de zero iteração não emitem bytes — o `\n` logo após `?>` é consumido pelo PHP); allowlist/dedupe/escape, opt-in de analytics, `permanent/`+`..`+esquema e fronteira `Media\{Contracts,Enums}` OK; `static.log` e `coverage.log` (892 testes) verdes. **A** (médio) `SeoSettings::section()` relê a seção a cada chamada de `SeoSettings::*`: ~18 leituras por página do portal (tags + `@graph` + `analytics`); sem linha em `core_settings` cada leitura é **query** (`DatabaseSettings::raw()` não tem cache negativo) e cada chave inválida reloga `core.settings.invalid` por leitura — a ficha ("uma query por render") subestima. **B** (baixo) `SeoService::resolve()` deixou de ser puro (contrato `SeoBinder` "sem efeitos colaterais" e emenda da ADR-027): faz I/O de mídia, emite `log_event` e resolve URL absoluta via `route()` (raiz da request). **C** (baixo) sem teste de regressão para `..`/esquema absoluto em `isPublishedPath()` nem para o log `seo.verification_skipped` (comportamentos afirmados na ficha). Obs.: `twitter:image` também surge com `site_logo`/override de módulo (paridade byte a byte só com `image === null`); a seção `seo` não tem superfície de escrita (`ConfiguracoesSections::EDITABLE` exclui `seo`) — a ficha diz "P0 já edita o campo"; `media_id` aceita SVG e não há dimensões no og:image (ADR-027 §4 cita 1200×630); `Str::isUuid`/ordem/dedupe OK. |
| 2026-10-24 | P1.FIX (agente) | ✅ | **A (Médio) corrigido:** leitura de `seo` **uma vez por request** (memo em `SettingsRequestScope`) + miss cacheado em `DatabaseSettings::raw()`; testes de contagem em `SeoPublishTest` (`a_public_page_reads_the_seo_section_once_and_caches_the_missing_row`: 1 leitura/≤1 SELECT, 0 no request seguinte; `a_write_after_a_cached_miss_is_visible_on_the_next_request`). **B (baixo) corrigido:** `SeoService::resolve()` voltou a ser **puro** (ADR-027) — nada de Media/`route()`/`log_event` no service; a URL padrão é resolvida na borda da view por `SeoSettings::defaultImageUrl()` e passada como argumento (`resolve($overrides, $defaultImage)`), preservando a precedência override > vínculo > mídia > `site_logo`. **C (baixo) corrigido:** regressões para payload `permanent/../x` e URL absoluta (`an_unsafe_payload_path_never_becomes_the_social_image`) e para o log `seo.verification_skipped` no provedor desconhecido. Obs.: a seção `seo` **não** tem superfície de escrita hoje (`ConfiguracoesSections::EDITABLE`); a ficha foi corrigida. Evidência: `Seo` 27 testes verdes; `make check` 2× + `E2E=1` verdes. |
