# Alterações — Editor, Categorias e Histórico

## 1. Bug da palavra-chave foco (corrigido)
**Causa raiz:** a normalização de texto não removia acentos. Keyword sem acento
("gestao") não casava com título/corpo com acento ("gestão") — comum em conteúdo
gerado por IA — e o score zerava indevidamente.

**Arquivos:**
- `admin/editor.html` — `normalize()` dos 3 validadores (SEO/GEO/AEO) agora remove
  diacríticos (NFD + strip). Novo helper `kwMatch()` faz match por palavra inteira
  para siglas curtas (IA, TI), evitando falso positivo de substring (ex.: "IA" em
  "guIA"). Corrigido também o bug do `[].every()` retornando true para keyword só
  com palavras curtas.
- `core/services/SEOService.php` — keyword density agora normaliza acentos
  (`stripAccents`) antes de contar.

## 2. Categorias do WordPress antes de publicar (novo)
Reutiliza a conexão (Application Password) já cadastrada no Site. Nenhuma
credencial nova.

**Arquivos:**
- `core/services/SiteManager.php` — `getCategories(Site, forceRefresh)`: busca
  `/wp-json/wp/v2/categories` com cache de 1h.
- `api/v1/sites.php` — `GET /api/v1/sites/{id}/categories`.
- `core/models/Content.php` — `wp_category_ids` no fillable.
- `core/services/ContentService.php` — create/update aceitam e normalizam
  `wp_category_ids` (JSON de inteiros).
- `core/services/PublishService.php` — envia `categories` no payload do WP.
- `admin/editor.html` — seletor de categorias (chips multi-seleção) que carrega
  ao escolher o site; persiste e restaura ao reabrir o post.
- `database/migrate_wp_categories.sql` + `database/schema.sql` — coluna
  `wp_category_ids`.

## 3. Histórico de publicações (novo)
Transparência total (toda a equipe vê tudo), Cliente = Site, filtros ano/mês/dia,
chips de status, soft delete.

**Arquivos:**
- `database/migrate_soft_delete.sql` + `schema.sql` — `deleted_at`, `deleted_by`,
  índice `idx_created_at`.
- `core/models/Content.php` — `softDelete($userId)`.
- `core/models/QueryBuilder.php` — `whereNull()` / `whereNotNull()`.
- `api/v1/posts.php` — DELETE agora é soft; listagem normal exclui soft-deleted;
  novas rotas `GET /posts/history` e `GET /posts/history/years`.
- `core/services/ContentService.php` — `getHistory()` (join com users para autor +
  quem excluiu; filtros) e `getHistoryYears()`.
- `admin/historico.html` — tela nova: timeline agrupada por dia, "Hoje/Ontem",
  badges de status, mostra quem excluiu/quando, estado vazio contextual, paginação.
- Link "Histórico" adicionado à navegação de todas as páginas admin.

## Como aplicar no banco existente
Rode, em ordem, no seu MySQL:
1. `database/migrate_wp_categories.sql`
2. `database/migrate_soft_delete.sql`
(Instalação nova já tem tudo no `schema.sql`.)

---

## Correções do loop de revisão (iterações 1-10)

Após implementação, foi feito um loop de revisão rigoroso com testes de lógica
executados (Node.js, modelando o comportamento PHP). Bugs encontrados e corrigidos:

1. **Cache-miss nas categorias** (crítico) — `FileDriver::get()` retorna `false`
   (não `null`) em cache-miss; a checagem `!== null` trataria o miss como valor
   válido e retornaria `false`, quebrando a primeira busca. Corrigido para
   `!== false` (padrão do projeto). [`SiteManager.php`]

2. **Agendamento publicava posts excluídos** (importante) — `publishDueScheduled()`
   buscava posts `scheduled` sem filtrar `deleted_at IS NULL`. Um post agendado e
   depois excluído seria publicado pelo cron. Corrigido com `whereNull('deleted_at')`.
   [`PublishService.php`]

3. **Chamada HTTP duplicada** (menor) — ao editar post existente, `loadCategories`
   rodava 2x (loadPost + init). Corrigido com flag `editingExisting`. [`editor.html`]

4. **Mensagens de erro de conexão WP** (UX/segurança) — trocado `Exception`
   genérica (virava "Erro interno 500") por `AppException(502)` com mensagem útil,
   e removido `$curlError` da mensagem (evita vazar detalhe interno). [`SiteManager.php`]

5. **Paginação inconsistente em erro de API** (menor) — `load()` não resetava
   `totalPages`/`total` no catch. Corrigido. [`historico.html`]

Verificações que passaram sem alteração: SQL injection no histórico (binds + cast),
IDOR no endpoint de categorias, mass-assignment de `deleted_at`/`user_id`/`site_id`,
null-safety PHP 8.3 no getHistory, robustez de datas no front, integração E2E das
três features, balanceamento sintático de todos os arquivos, e referências de
métodos front↔componente.

Limitações conhecidas (não-bugs): timezone de exibição segue o padrão pré-existente
do dashboard; keyword foco simbólica de ≤3 chars colada a número (caso irreal em SEO);
`getHistoryYears` lista anos globais (não por cliente); reverter versão não restaura
categorias (versões nunca guardaram metadados, só título/corpo).

---

## Integração com plugin FAQ Schema (MSEO)

Permite criar FAQs na plataforma que são publicadas no formato do plugin "FAQ Schema",
gerando Schema.org FAQPage automaticamente no site. Suporta preenchimento manual E
geração por IA, com validação integrada.

### Arquivos novos
- **`wordpress-mu-plugin/cms-faq-rest-bridge.php`** — mu-plugin que expõe as metas do
  FAQ Schema na REST API do WordPress, SEM editar o plugin original. Registra as 9 metas
  (`_mseo_faq_*`) com `auth_callback` (exige `edit_post`) e `sanitize_callback` (mesma
  limpeza do plugin). Mudança puramente aditiva — não afeta a comunicação do plugin com
  outros plugins (as metas são as mesmas, no mesmo lugar). Também expõe
  `GET /wp-json/cms-faq/v1/status` para a plataforma detectar o plugin.
  → INSTALAR em `wp-content/mu-plugins/` no site WordPress.
- **`database/migrate_faq_data.sql`** — coluna `faq_data` (LONGTEXT) em contents.

### Backend
- `SiteManager::getFaqCapability()` — detecta se o site tem o plugin + bridge (cache 1h/5min).
- `ContentService::normalizeFaqData()` — valida/serializa a config de FAQ.
- `ContentService::parseFaqFromAi()` — extrai JSON de FAQ da resposta da IA (tolerante a
  markdown e texto ao redor).
- `PublishService::buildFaqMeta()` — monta as metas `_mseo_faq_*` no payload do WP REST.
- `Content` fillable + create/update aceitam `faq_data` (whitelist preservada).
- Endpoints: `GET /sites/{id}/faq-capability`, `POST /posts/generate-faq`.

### Frontend (editor)
- Seção FAQ aparece SÓ quando o site tem o plugin (detecção automática ao escolher o site).
- Toggle "Habilitar FAQ" + toggles de Schema/Accordion/Formulário (todos controláveis).
- Título, descrição, níveis de heading configuráveis.
- Lista de perguntas editáveis (adicionar/remover/reordenar) — modo manual.
- Botão "Gerar com IA" (3/5/7/10 perguntas) — acrescenta sem sobrescrever o manual.
- Validação visual: pergunta sem "?", resposta muito curta, FAQ vazia.
- O validador AEO agora reconhece a FAQ ESTRUTURADA (sinal mais forte que H3 no corpo).

### Como aplicar
1. No site WordPress: copie `cms-faq-rest-bridge.php` para `wp-content/mu-plugins/`
   (crie a pasta se não existir). O plugin FAQ Schema precisa estar ativo.
2. No banco da plataforma: rode `database/migrate_faq_data.sql`.

### Correções do loop de revisão (integração FAQ)
1. **Schema do array de FAQ na REST** — adicionado `additionalProperties => false` e
   `default => []` no registro de `_mseo_faq_items`. Sem isso, algumas versões do WP
   descartam os campos question/answer ao validar o objeto aninhado. [mu-plugin]
2. **Arrow function → função nomeada** no sanitize dos toggles booleanos
   (`cms_faq_bridge_sanitize_bool`), evitando problemas com object cache agressivo. [mu-plugin]
3. **Verificação defensiva pós-publicação** — após publicar com FAQ, confirma se o WP
   retornou as metas; se não, loga aviso de que o mu-plugin pode não estar instalado
   (não falha a publicação, pois o post foi criado). [PublishService]

Validado no MySQL real: integração simultânea de FAQ + categorias + soft delete +
histórico sem conflito, acentos preservados, migração faq_data idempotente. Lógica de
parse/serialize/buildMeta testada com markdown, texto ao redor e itens incompletos.

Observações (não-bugs): publicação sempre cria post novo no WP (republicar duplica) —
comportamento PRÉ-EXISTENTE do projeto, não relacionado à FAQ; generate-faq não tem
rate limit, consistente com os demais endpoints de texto do projeto.

---

## Correções e melhorias (sessão de ajustes)

### Bug "Keyword ausente no conteúdo" (corrigido de vez)
A lógica de match estava correta, mas o `runAnalysis()` e o `save()` sobrescreviam
`post.body` com o `innerHTML` do editor mesmo quando o DOM estava vazio/desatualizado
(timing do Alpine após geração por IA ou carregamento). Resultado: a validação rodava
sobre body vazio → "Keyword ausente". Agora a sincronização só adota o DOM quando ele
tem texto real; se o DOM está vazio mas o estado tem conteúdo, mantém o estado e
re-hidrata o DOM. [editor.html]

### Layout das categorias (redesenhado)
Substituídos os inline-styles por CSS dedicado: chips arredondados com hover/elevação,
cabeçalho alinhado (contador + "atualizar"), skeleton animado durante o carregamento,
estados de erro e vazio elegantes, scrollbar estilizada. [editor.html]

### Aba de Configurações (NOVA — gerenciar API keys pelo painel)
Antes, as chaves de API só podiam ser cadastradas na instalação; depois, não havia
onde editá-las (só via .env no servidor). Agora há uma tela dedicada:
- **`admin/settings.html`** — tela com as 5 chaves (Anthropic/OpenAI/Google + Unsplash/Pexels),
  agrupadas por IA e Imagens. Mostra status (configurada/não) com máscara dos 4 últimos
  caracteres, campos para atualizar e botão de remover. Só admin acessa.
- **`core/services/SettingsService.php`** — lê o status mascarado e reescreve o .env de
  forma SEGURA: preserva comentários, ordem e valores com caracteres especiais (JWT, senhas);
  escrita atômica (tmp+rename) contra corrupção; whitelist de chaves (impede sobrescrever
  JWT_SECRET/DB_PASS); nunca expõe o valor real.
- **`api/v1/settings.php`** — endpoints GET/PUT `/settings/api-keys` (apenas admin).
- Link "Configurações" adicionado à navegação de todas as páginas admin.

### Detecção de heading-pergunta (GEO/AEO) — reescrita
A validação "Adicione headings como perguntas" usava uma regex frágil que:
- não detectava perguntas com "quanto", "vale a pena", "devo", "posso", "preciso",
  "existe", "é possível", "dá para", "será que" (formas comuns em FAQ);
- exigia acento em "o que é" (incompatível com o texto normalizado sem acento);
- dava falsos positivos por substring ("qual" dentro de "qualidade", "como" no meio
  de uma afirmação).
Substituída por um helper único `isQuestionText()` usado por GEO e AEO: detecta por
"?" no fim OU por termo interrogativo ancorado no início (word boundary). Testado com
29 casos reais (23 perguntas + 6 afirmações): 100% corretos. [editor.html]

---

## Sistema de Aprovação Prévia + Links com SEO (target/rel)

### Banco (migrate_approvals.sql — idempotente)
- `sites`: email, whatsapp, solicitacao_aprovacao_default (regra: default=1 se houver email OU whatsapp).
- `contents`: aprovado_pelo_cliente, ajustes_solicitados, rejeitado_pelo_cliente.
- Tabela `content_approvals`: token UUID, status (pending/approved/adjustments/rejected/superseded),
  canal, contatos, IP do respondente, timestamps.

### Backend
- **ApprovalService**: gera token UUID v4; ao reenviar, invalida (supersede) tokens
  pendentes anteriores — só o link mais recente funciona; processa resposta tornando o
  token terminal (não reutilizável); monta template de e-mail responsivo + texto WhatsApp.
  Testado no MySQL real: token morre após uso, reenvio invalida antigo, token inexistente rejeitado.
- **NotificationService**: e-mail via SMTP (socket próprio, sem dependências; suporta TLS/SSL/AUTH)
  ou mail() nativo; WhatsApp via API HTTP genérica. Detecção isEmailConfigured/isWhatsappConfigured —
  sem API de WhatsApp, só e-mail é oferecido.
- Endpoints: POST /posts/{id}/request-approval; GET /sites/{id}/approval-capability;
  rota pública /aprovacao/{token}/{aprovar|ajustes|rejeitar} (página de confirmação, sem login).
- SiteManager create/update gravam contatos e recalculam o default de aprovação.
- SettingsService estendido com SMTP/WhatsApp (GET/PUT /settings/notifications).

### Frontend (editor)
- Toggle "Solicitar aprovação antes de publicar" (sidebar). Impede ativar sem contatos e
  exibe o alerta exato da spec. Botão principal vira "Enviar para Aprovação" quando ativo.
- **Modal de link com SEO**: campos URL/texto, detecção automática interno vs externo,
  controles target (nova aba) e rel (nofollow/sponsored/ugc); adiciona noopener/noreferrer
  automaticamente em nova aba. Externos sugerem nofollow+nova aba; internos ficam limpos.

### PENDENTE de UI (backend pronto, falta a tela):
- Campos de contato (email/whatsapp) no formulário de Sites — a API já aceita; falta o input no admin/sites.html.
- Seção SMTP/WhatsApp na aba Configurações — endpoints prontos; falta renderizar em admin/settings.html.

### Como aplicar
1. Banco: rode database/migrate_approvals.sql.
2. Configure no .env (ou na futura aba): SMTP_*, MAIL_FROM, APP_URL e, se quiser, WHATSAPP_API_*.
3. APP_URL deve ser a URL pública da plataforma (usada nos links de aprovação).

### Loop de revisão (sistema de aprovação) + telas finalizadas
Bugs corrigidos na revisão:
1. SMTP: header From não codificava nome com acento (RFC) → encodeHeader aplicado. [NotificationService]
2. SMTP: faltavam Date e Message-ID (entregabilidade/anti-spam) → adicionados; corpo normalizado p/ CRLF. [NotificationService]
3. SEGURANÇA anti-prefetch: links de e-mail abertos por scanners (Gmail/Outlook) via GET
   aprovavam/rejeitavam sozinhos. Agora GET só mostra página de CONFIRMAÇÃO; a ação só
   ocorre via POST (clique no botão). [approval-public.php] Testado: GET não processa, POST processa.
4. sendForApproval enviava mesmo se o save falhasse (sem título/site) → agora aborta se save falha. [editor.html]

Telas finalizadas (antes pendentes):
- **Sites**: campos E-mail e WhatsApp do cliente no formulário, com aviso de que a aprovação
  será habilitada. saveSite já envia; editSite preenche. [admin/sites.html]
- **Configurações**: seções E-mail (SMTP) e WhatsApp, com campos secretos mascarados e
  não-secretos editáveis; save dispara para /settings/notifications. [admin/settings.html]

Sistema de aprovação agora COMPLETO e utilizável pela interface.

---

## Rodada de ajustes e novas features (feedback de uso real)

1. **SMTP: toggle "Configurar SMTP" / "Configurar SMTP com Google"** — modo Google
   simplifica para só E-mail/Senha de app, com link direto para criar a senha de app
   no Google. [settings.html]

2. **Corretor de português (ortografia + gramática)** — botão na toolbar do editor
   que consulta o LanguageTool (gratuito, pt-BR) e abre um painel lateral com cada
   problema encontrado e sugestões clicáveis para aplicar. Também forcei o
   spellcheck nativo do navegador (sublinhado vermelho) com `lang="pt-BR"` no editor.
   Novo endpoint: `POST /posts/check-grammar`. [editor.html, posts.php]

3. **Bug do botão "Enviar para Aprovação"** — a API já diferenciava "sem contato"
   de "contato existe mas SMTP não configurado", mas o front sempre mostrava a
   mesma mensagem genérica. Corrigido para mostrar a causa real, com link direto
   para Configurações quando o problema é a falta de SMTP. [editor.html]

4. **Seleção de autor (pull do WordPress, como categorias)** — novo método
   `getAuthors()` no SiteManager (`/wp/v2/users?who=authors`, com fallback),
   endpoint `/sites/{id}/authors`, coluna `wp_author_id`, seletor no editor e
   inclusão no payload de publicação (`author`). [SiteManager, ContentService,
   PublishService, editor.html, migrate_author.sql]

5. **BUG real corrigido: site não salvava ao editar.** O `update()` bloqueava
   qualquer mudança de `site_id` (proteção antiga). Agora permite trocar o site,
   validando que o site de destino pertence ao usuário (ou é admin) antes de
   aplicar — testado no MySQL real, inclusive o bloqueio de segurança.
   [ContentService.php]

6. **BUG real corrigido: FAQ não aparecia mesmo com o plugin instalado.** A causa:
   sem o mu-plugin "ponte" (`cms-faq-rest-bridge.php`) instalado no WordPress, a
   detecção sempre falhava e a seção simplesmente não aparecia, sem nenhuma
   explicação. Agora mostra um diagnóstico claro com instrução de instalação e
   botão "verificar novamente". A seção também foi movida para abaixo do editor
   de texto (era no topo do formulário). [editor.html]

7. **Esclarecido: geração de imagem usa DALL-E (OpenAI), não o provider de texto
   selecionado.** O dropdown "Claude/Gemini/GPT" nunca controlou a imagem (sempre
   foi hardcoded para `dalle` no código) — mas estava posicionado de forma confusa,
   parecendo que sim. Rotulado o dropdown como "— texto" em cada opção, e indicado
   "via DALL-E/OpenAI" junto ao botão de gerar imagem. [editor.html]

8. **Medidor de gasto com tokens — ativado.** Descoberta importante: a tabela
   `token_usage`, o model e os endpoints já existiam no projeto, mas NADA gravava
   nela — o registro nunca era chamado. Agora: os 3 adapters (Claude/OpenAI/Gemini)
   retornam uso padronizado (`input_tokens`/`output_tokens`); `ProviderManager`
   anota qual provider respondeu de fato (importante no fallback); `TokenService`
   ganhou tabela de preços aproximados + `recordFromResult()` + `getUsageSummary()`;
   isso é chamado após cada geração de conteúdo e de FAQ. Novo endpoint
   `GET /tokens/summary` e um card "Uso de Tokens" em Configurações (hoje/mês/total,
   por provider). Valores são ESTIMATIVAS, não a fatura oficial. [TokenService,
   adapters, ProviderManager, GenerateContentJob, posts.php, tokens.php, settings.html]

9. **Links rápidos para gerar as API keys** — em Configurações, cada chave (IA e
   Imagem) agora tem um link "Obter chave →" direto para o painel do provedor
   (Anthropic Console, OpenAI Platform, Google AI Studio, Unsplash, Pexels). [settings.html]

### Migração necessária
Rode `database/migrate_author.sql` (ou o `migrate_all.sql` atualizado, que já
inclui essa coluna). Os demais itens desta rodada não exigem alteração de banco.

---

## Verificação rigorosa dos 4 itens reportados (testes com Alpine.js real via jsdom)

Para os itens 2 e 4, em vez de só ler o código, construí uma simulação que carrega o
`editor.html` real dentro de um DOM (jsdom) com o Alpine.js de verdade (não uma versão
simplificada), mockando a API, e simulei o fluxo completo do usuário: selecionar
site/autor → salvar → "Ctrl+R" (recriar a página do zero) → verificar o estado final.

1. **Corretor com sublinhado no texto** — confirmado funcionando. Testei com jsdom em
   6 cenários (erro simples, múltiplos erros, erro dentro de tag aninhada, erro
   cruzando tags, offset fora do range, limpeza restaurando o HTML original byte a
   byte) — todos corretos. O sublinhado ondulado (vermelho=ortografia, azul=gramática)
   já está implementado, com remoção automática antes de salvar/gerar conteúdo para
   nunca corromper o post.

2. **Seleção de autor** — confirmado funcionando na simulação real (selecionar →
   salvar → reload → autor correto restaurado). 

3. **FAQ ainda não detectado mesmo com o arquivo instalado** — adicionei diagnóstico
   detalhado que distingue 5 causas (bridge não instalado, bloqueado por firewall,
   plugin inativo, erro de conexão, resposta inesperada/cache), e um teste manual
   que o usuário pode fazer agora: abrir `SEUSITE.com/wp-json/cms-faq/v1/status`
   direto no navegador e interpretar o resultado (instruções na própria tela).
   Também adicionei fallback automático para `?rest_route=` (cobre o caso de
   permalinks "simples"). Como não tenho acesso ao WordPress real do usuário, não
   posso garantir que isso resolve sozinho — mas agora há uma forma de descobrir
   exatamente qual é a causa, em vez de "não funciona" sem mais informação.

4. **Site não persistia após Ctrl+R** — confirmado funcionando na simulação real
   (selecionar site → salvar rascunho → reload completo → site corretamente
   restaurado e selecionado no dropdown).

**Nota importante:** os itens 2 e 4 dependiam de uma sequência específica de
carregamento (sites/categorias/autores devem existir como `<option>` no DOM ANTES do
valor ser atribuído, e o Alpine precisa de um "tick" para renderizar). Se você ainda
vir esse problema depois de atualizar os arquivos, **limpe o cache do navegador**
(Ctrl+Shift+R em vez de Ctrl+R) para garantir que está testando a versão mais
recente do `editor.html`, não uma cacheada.

---

## Reorganização de IA (texto/imagem) + correções confirmadas

### Item 4 — Reorganização completa (conforme mockups aprovados)
- **Backend**: `SettingsService::getAiDefaults()` lista só providers com chave configurada;
  `getTextProviderFallbackOrder()`/`getImageProvider()` centralizam a resolução do padrão
  global. `GenerateContentJob`, `generate-faq`, `generate-seo-title` (novo),
  `generate-meta-description` (novo) e `GenerateImageJob` não recebem mais `provider`
  do frontend — resolvem pelo padrão configurado em Configurações.
- **Removido o efeito colateral**: `GenerateContentJob` não dispara mais geração de
  imagem automaticamente ao criar um post — cada card gera só o que foi pedido.
- **Editor**: 5 gatilhos independentes (Conteúdo, Melhorar, Título SEO, Meta description,
  FAQ, Imagem) — cada um colapsado por padrão, clique abre um prompt próprio + "Gerar agora",
  gera só aquele campo. Upload de imagem sempre visível; "Gerar com IA" de imagem só aparece
  com provider configurado. Indicador "Texto: X · Imagem: Y — alterar" no topo, linkando
  pra Configurações.
- Testado com Alpine.js real (jsdom): sem IA configurada → todos os gatilhos ocultos,
  Upload continua disponível; com IA configurada → cada card gera isoladamente sem
  afetar os outros, payload não envia mais `provider`.

### BUG ENCONTRADO E CORRIGIDO durante a verificação (não estava na lista original)
A tela de Configurações referenciava `aiDefaults` e `saveAiDefault()` em 12 lugares do
HTML, mas nenhum dos dois existia no JavaScript — a página quebraria ao carregar essa
seção. Corrigido: adicionado o estado `aiDefaults`, o carregamento via
`GET /settings/ai-defaults`, e `saveAiDefault(kind, key)` que salva imediatamente ao
selecionar (sem precisar do botão "Salvar alterações" das chaves de API). Testado com
Alpine real: a página carrega sem erro, os rádios mostram as opções certas, e selecionar
um provider dispara o PUT correto.

### Itens 1, 2, 3, 5 — confirmados (já estavam implementados, validados com testes reais)
1. Autor 502 → diagnóstico estruturado (mesmo padrão do FAQ), 4 causas distintas.
2. Toggle de aprovação → já usa o padrão visual exato do Autosave.
3. FAQ "erro interno" → agora mostra a causa real (ex: "invalid x-api-key (HTTP 401)"),
   melhorado nos 3 adapters (Claude/OpenAI/Gemini) para extrair a mensagem da própria API.
4. Sublinhado do corretor → confirmado persistindo através de runAnalysis()/autosave
   (testado com jsdom: aplica highlight → roda análise → continua visível → salva →
   continua visível → body salvo não contém `<mark>`).

---

## Rodada: imagem destacada, aprovação visível, APP_URL (itens 1-6 de 9)

1. **Upload manual de imagem corrigido** — antes só fazia preview local, nunca enviava
   pro servidor. Agora envia de fato (precisa de post salvo primeiro), salva no banco
   e na biblioteca do WP. Testado com Alpine real: upload → reload → imagem persiste.
2. **`featured_media` agora é definido na publicação** — antes a imagem subia pro WP
   mas o ID retornado era descartado, então nunca virava capa do post. Corrigido nos
   3 pontos (upload manual, geração por IA, Stock) + PublishService usa a mídia mais
   recente do post como featured_media. Testado no MySQL real.
3. **Campos ALT/Title/Legenda/Descrição** — novos no card de Imagem, com botão
   "Salvar dados da imagem" que propaga pro anexo no WordPress via
   `POST /wp-json/wp/v2/media/{id}` (rota nova: `PUT /api/v1/media/{id}`, e
   `GET /api/v1/media?content_id=` pra recarregar ao reabrir o post).
4. **Imagem incluída no e-mail de aprovação** — `ApprovalService::buildMessage()`
   busca a mídia do post e insere logo abaixo do título.
5. **APP_URL movido pra card próprio** — sempre visível em Configurações (não mais
   escondido dentro do card de E-mail/preso ao modo Google), com aviso explícito se
   ainda estiver com "localhost".
6. **Etiquetas de decisão do cliente** — Pendente/Aprovado/Ajustes/Rejeitado, com
   cores distintas, no Histórico (lista) e no Editor (badge do post). Cálculo unificado
   (`ContentService::deriveApprovalStatus`) combina a tabela `content_approvals`
   (pendência) com as flags persistentes (resultado final). Testado com 7 cenários
   incluindo reenvio após ajuste anterior.

### Migração necessária
`database/migrate_media_wp_id.sql` (colunas `wp_media_id` e `description` em `media`).

---

## Correção do schema.sql (instalação nova ficava incompleta)

Auditoria de todas as migrações desta conversa contra o `schema.sql` principal revelou
que várias tabelas/colunas criadas via migração NUNCA foram adicionadas ao schema usado
pelo instalador — ou seja, uma instalação NOVA (do zero) não teria:
- A tabela `content_approvals` inteira
- `sites.email`, `sites.whatsapp`, `sites.solicitacao_aprovacao_default`
- `contents.aprovado_pelo_cliente`, `contents.ajustes_solicitados`, `contents.rejeitado_pelo_cliente`
- `media.wp_media_id`, `media.description`

Corrigido: todas essas tabelas/colunas agora estão no `schema.sql`. Testado contra
MySQL real — instalação do zero: 19 statements, 0 erros, todas as colunas/tabela
confirmadas presentes. Não afeta bancos já existentes (que já têm tudo via migração).

---

## Itens 7, 8 e 9 — validação mínima, agendamento nativo do WP, notificação interna

### 7. Validação mínima antes de enviar/publicar/agendar
Bloqueia (frontend + backend, defesa em profundidade) se: título vazio, conteúdo
com menos de 300 caracteres de texto puro, FAQ habilitado com menos de 3 perguntas
completas, ou sem imagem destacada. Modal lista exatamente o que falta — inclusive
quando o erro vem do backend (antes a mensagem detalhada era descartada no frontend,
corrigido). Testado com 6 cenários (frontend) + 4 no backend, todos batendo.

### 8. Agendamento nativo do WordPress (substituiu o cron da plataforma)
- `PublishService::schedule()` agora envia o post ao WordPress IMEDIATAMENTE com
  `status: future` + a data escolhida — o WP-Cron do próprio site publica na hora
  certa, em vez da plataforma ficar vigiando.
- Removido `publishDueScheduled()` e a chamada a ele no `cron.php` (não é mais
  necessário).
- Agendar e Aprovação agora coexistem: com aprovação ativa, escolher uma data só
  guarda localmente (`scheduled_at`); o agendamento real no WP só acontece quando o
  cliente aprovar (`ApprovalService::publishApproved` decide entre `schedule()` e
  `publish()` comparando a data salva com o momento atual). Testado no MySQL real.

### 9. Notificação interna ao autor quando o cliente responde
`notifyTeam()` antes só registrava um log; agora envia e-mail de fato pro autor do
post (aprovação/ajustes/rejeição), com nome do site, título do post, e data/hora da
resposta do cliente — "Cliente [site] aprovou/rejeitou/pediu ajustes em [título] em
[data] às [hora]".

### Observação sobre os testes desta rodada
O ambiente de teste MySQL apresentou instabilidade nesta sessão (processos em
background não sobreviviam entre chamadas) — identifiquei a causa (um `pkill`
combinado com outros comandos na mesma chamada) e consegui validar normalmente depois
disso. A lógica de decisão "agendar vs publicar imediato" foi testada tanto em
simulação equivalente quanto no MySQL real, com os mesmos resultados.

---

## Validador de hierarquia de headings (novo, a partir da ferramenta enviada)

Adaptada a ferramenta standalone enviada pelo usuário pra rodar dentro do editor,
em tempo real:
- O **H1 vem do campo Título do post** (não precisa ter `<h1>` digitado no corpo) —
  reflete como o WordPress normalmente renderiza a página (tema mostra o título como H1).
- H2-H6 são extraídos do HTML real do corpo do post.
- Se o corpo acidentalmente tiver um `<h1>` (ex.: conteúdo colado de outro lugar),
  é corretamente detectado como H1 duplicado.
- Árvore visual + lista de problemas (sem H1, H1 duplicado, heading vazio, salto de
  nível) — mesmo comportamento da ferramenta original, badge muda de cor conforme
  gravidade.
- Atualiza no mesmo ciclo da análise de SEO/GEO/AEO (título é instantâneo; corpo
  segue o debounce de 500ms).
- Testado com 6 cenários, incluindo os exemplos reais do print enviado.

Card novo, posicionado abaixo de "Imagem destacada" na coluna direita, como no
layout sugerido.

---

## Bug corrigido: validação continuava exigindo FAQ mesmo desabilitado

**Causa:** a opção "Gerar Schema.org" (`faq.schema_enabled`) vem marcada como `true`
por padrão e **não é resetada** quando o checkbox principal "Habilitar FAQ" é
desligado. A validação checava `faq.enabled || faq.schema_enabled` — então mesmo com
o FAQ desligado, o `schema_enabled` antigo continuava disparando a exigência de 3
perguntas.

Corrigido em 5 lugares que tinham o mesmo padrão (frontend: validação de
envio/publicação, mensagem de aviso dentro do card de FAQ, cálculo do score AEO,
serialização do FAQ; backend: validação mínima do servidor) — todos agora dependem
só do `faq.enabled` (o interruptor principal), que é o que realmente diz se o FAQ
está ativo pro post. Reproduzi o cenário exato relatado (habilitar → preencher menos
de 3 perguntas → desabilitar) e confirmei que a validação para de reclamar.

---

## Correção de layout: card de Hierarquia de Headings mal posicionado

**Causa:** o CSS de `.top-cols` é um grid de 2 colunas que espera exatamente 2 filhos
diretos (um por coluna). Ao adicionar "Hierarquia de headings" como 3º filho direto,
o grid intercalava errado — ele ia para a posição da coluna 1, linha 2 (embaixo de
"Informações do Post"), não para baixo de "Imagem destacada" como pretendido.

Corrigido: "Imagem destacada" e "Hierarquia de headings" agora ficam agrupados num
wrapper único, que conta como 1 só filho direto do grid — voltando a estrutura
esperada (2 filhos: Informações | wrapper com os dois cards). Também troquei
`align-items: stretch` por `start`, já que forçar a mesma altura entre as colunas
não faz mais sentido agora que a coluna direita tem conteúdo de tamanho diferente.
Confirmado com jsdom: exatamente 2 filhos diretos no grid.

---

## Ajustes de layout e formatação de headings

1. **Dropdown "Formato" parava em H3** — agora vai até H6 (Título 4, 5 e 6
   adicionados). O validador de hierarquia já suportava H1-H6 desde o início,
   então não precisou de ajuste ali.

2. **Card "Hierarquia de headings" movido pra perto do editor** — antes ficava na
   coluna direita ao lado de "Imagem destacada"; agora fica ao lado do editor de
   texto, numa grid 75% (editor) / 25% (hierarquia), como pedido. Confirmado com
   teste de estrutura DOM: a nova grid tem exatamente 2 filhos diretos.

---

## Correção: proporções de layout não respeitadas

**Problema 1 — grid editor/hierarquia aparecia 50/50 em vez de 75/25:** colunas de
grid com `fr` têm `min-width: auto` por padrão, que se expande para caber conteúdo
sem quebra de linha — e a árvore de hierarquia usa `white-space:pre` (textos longos
sem quebrar). Corrigido com `min-width: 0` nos itens do grid, fix padrão e bem
conhecido para esse comportamento do CSS Grid.

**Problema 2 — "Imagem destacada" não crescia mais junto com "Informações do
Post":** ao corrigir o bug de posicionamento anterior, troquei `align-items` de
`stretch` para `start`, o que tirou o crescimento conjunto que existia antes. Como
a estrutura agora está correta (sem o card extra solto), restaurei `stretch` e
removi o wrapper que ficou redundante (só tinha 1 card dentro) — "Imagem destacada"
voltou a ser filho direto do grid, reativando a regra original de altura igual.

Confirmado com teste de estrutura DOM: os dois grids continuam com exatamente 2
filhos diretos cada.

---

## Card de Hierarquia: altura igual ao editor + investigação do erro de upload

1. **Card de Hierarquia agora cresce com o editor** — mesmo princípio do ajuste
   anterior (Imagem destacada / Informações do Post), aplicado à grid editor/hierarquia.

2. **Erro "Erro interno do servidor" ao subir imagem** — não consigo confirmar 100%
   sem acesso ao log do seu servidor, mas a causa mais provável é a extensão **GD do
   PHP** não estar habilitada (ou habilitada sem suporte a WebP) na hospedagem —
   isso geraria exatamente esse tipo de erro genérico. Adicionei:
   - Checagem explícita: se faltar a extensão GD, ou faltar suporte a WebP
     especificamente, agora aparece uma mensagem clara dizendo qual extensão falta
     e que é preciso pedir ao suporte da hospedagem para habilitar.
   - O frontend agora mostra o campo "debug" do erro quando disponível (até então
     ele era descartado mesmo quando o servidor enviava).

   **Pra descobrir a causa exata agora:** no `.env`, defina `APP_DEBUG=1`
   temporariamente, tente subir a imagem de novo — a mensagem de erro vai mostrar o
   detalhe técnico real. Depois de resolver, lembre de voltar `APP_DEBUG=0` (não é
   seguro deixar ligado em produção, expõe detalhes internos).

---

## Log Central de Eventos e Erros (novo — Fase 0 do plano de implementação)

Hoje cada canal (`core/logging/Logger.php`) grava só em arquivo de texto separado
(`storage/logs/{canal}-{data}.log`), sem visão consolidada nem tela no painel — pra
investigar um problema era preciso entrar por SSH e procurar arquivo por arquivo.
Esta mudança centraliza todo `warning`/`error`/`critical` de qualquer canal (IA,
WordPress, fila...) numa tabela só, com tela própria no painel.

**Como funciona:** `SystemLogger` tem a mesma interface do `Logger` (`->info()`,
`->warning()`, `->error()`, mais um `->critical()` novo) — por baixo, continua
gravando em arquivo exatamente como antes (reaproveita o `Logger` internamente,
não duplica a lógica de formatação), e adicionalmente grava uma linha em
`system_logs` para `warning` pra cima. Todos os 18 pontos do projeto que
instanciavam `new Logger(...)` foram trocados para `new SystemLogger(...)` —
troca mecânica, nenhuma chamada (`->error()` etc.) mudou de assinatura.

**Painel (`admin/logs.html`, novo, agora no menu como "Logs"):** lista filtrável por
canal/nível/status/período, com badges de contagem (abertos/resolvidos/limpos),
cada linha expansível mostrando o contexto completo. Atualização automática por
polling (a cada 8s, só enquanto a aba está em foco) — sem WebSocket/SSE por
enquanto, não era necessário para o volume esperado agora. Ação "Limpar" nunca
apaga de verdade (soft: `status='cleared'`) — a purga definitiva roda sozinha no
cron, 30 dias depois de limpo.

**Achados no caminho (sem ação necessária agora, registrados para não esquecer):**
- `core/services/ContentService.php` era uma cópia idêntica e morta de
  `core/ContentService.php` — o autoload sempre carregava a da raiz primeiro,
  então a de `services/` nunca rodava. Removida.
- A tabela `audit_logs` já existe no `schema.sql` mas não é escrita por nenhum
  lugar do código hoje — é diferente do que este log central resolve (guardaria
  "quem mudou o quê", não "o que falhou tecnicamente"); mantida como está, sem
  mexer, só registrando que existe e está órfã.

**Arquivos:**
- `database/migrate_system_logs.sql` (novo) + `database/schema.sql` — tabela
  `system_logs` (level/channel/message/context/source_ref/status).
- `core/models/SystemLog.php` (novo) — model ActiveRecord da tabela.
- `core/logging/SystemLogger.php` (novo) — logger central, mesma interface do
  `Logger` + `critical()`.
- `api/v1/logs.php` (novo) — `GET /logs`, `GET /logs/channels`,
  `PATCH /logs/{id}`, `POST /logs/clear-all`. Acesso: admin/gerente.
- `admin/logs.html` (novo) + `admin/topbar.js` — entrada "Logs" no menu.
- `cron.php` — purga (`DELETE` de verdade) de logs `cleared` há mais de 30 dias,
  dentro do bloco de limpeza das 3h que já existia.
- 18 arquivos com `new Logger(` → `new SystemLogger(` (ver PLANO-IMPLEMENTACAO.md
  para a lista completa).
- `core/services/ContentService.php` — removido (duplicata morta).

**Validação feita:** `php -l` em 100% dos arquivos `.php` do projeto (sem erros) +
`node --check` no JS inline de `admin/logs.html` e em `admin/topbar.js` (sem
erros) + suite funcional real (~25 cenários) contra MariaDB + servidor PHP
embutido de teste — login real, warning real disparado por senha errada,
validação de filtros, paginação com dados reais, clear-all, purga do cron,
casos de borda do `stringify()`. Ver seção abaixo para os bugs encontrados e
corrigidos nessa revisão.

---

## Auto-revisão da Fase 0: 10 bugs reais encontrados e corrigidos

A pedido, rodei uma auto-revisão em loop (revisar → corrigir → revisar de novo)
sobre a entrega da Fase 0, incluindo testes funcionais reais contra um
MariaDB + servidor PHP de teste (não só releitura de código). Registro
completo, nada escondido:

1. **`persist()` com type hint estrito demais** (`string $message`) — daria
   `TypeError` se algo passasse `null`/array/objeto por engano no futuro.
   Nenhuma das ~24 chamadas existentes faz isso hoje, mas era um risco
   desnecessário. Corrigido com normalização segura (novo método `stringify()`).
2. **`critical()` quebrava de verdade** com objeto sem `__toString`: a
   concatenação `'[CRITICAL] ' . $message` rodava *antes* de qualquer proteção,
   e lançava um `Error` fatal não capturado. Corrigido.
3. **`json_encode()` do contexto podia retornar `false`** silenciosamente
   (dado não serializável) e isso virava o contexto gravado sem aviso. Corrigido
   com fallback explícito.
4. **O bug mais sério, só apareceu testando contra banco de verdade:** a
   extensão `mbstring` (usada em `mb_substr()`) não estava nem instalada nem
   documentada como requisito no `README.md` — mesmo `SEOService.php` já
   dependendo dela. Sem isso, **todo** warning/error/critical falhava
   silenciosamente ao tentar gravar em `system_logs` (caía no catch, só ia pro
   arquivo) — ou seja, em qualquer hospedagem sem `mbstring`, o painel de logs
   ficaria sempre vazio, sem nenhum erro visível pra entender por quê. Corrigido
   com fallback (`substr()` puro se `mb_substr` não existir) + `mbstring`
   adicionada ao `README.md`.
5. **Filtros de `$_GET`/corpo não validavam tipo escalar** — `?channel[]=x`
   quebraria o bind do PDO. Corrigido com função `scalarParam()`.
6. **Faltava validar `level`/`status` inválidos** no `GET /logs` — um valor
   fora da lista simplesmente não batia com nada, sem explicar por quê.
   Adicionado erro 422 claro.
7. **Contagem dos badges usava busca de substring frágil** (`strpos($w, 'status
   =')`) pra excluir a cláusula de status. Refeito com array próprio.
8. **Bug de lógica real no `clear-all`:** pedir `status=cleared` explicitamente
   combinava com a cláusula padrão (`status != 'cleared'`) numa contradição que
   nunca batia com nada, silenciosamente. Corrigido: os dois casos agora são
   mutuamente exclusivos.
9. **Botão "Limpar tudo" continuava clicável na visão "limpos"** (sem sentido
   nesse estado). Corrigido com `:disabled` + guarda na função.
10. **Mensagem vazia virava linha em branco ilegível** no painel. Corrigido
    com marcador `(mensagem vazia)`.

**Limitação residual aceita conscientemente (não é bug):** o fallback sem
`mbstring` trunca por bytes, não por caractere — um texto com acento bem no
limite de 500 bytes poderia, em teoria, cortar um caractere UTF-8 ao meio.
Só ocorre na ausência de uma extensão praticamente universal, e o impacto é
cosmético. Não tratado com mais complexidade por não valer a pena.

**Arquivos adicionais tocados nesta rodada:** `README.md` (mbstring nos
requisitos), além dos já listados na entrega original da Fase 0.

---

## Bug corrigido: "Limpar histórico" negava senha correta pra qualquer admin

**Sintoma relatado:** admin não conseguia limpar o histórico de eventos em
Configurações, mesmo com a senha certa.

**Causa raiz confirmada com teste real (não só leitura de código):**
`api/v1/history.php` comparava a senha digitada contra `$account->password` —
mas essa coluna **não existe**; a coluna real na tabela `users` é
`password_hash` (é literalmente assim que `AuthManager::login()` valida no
login normal, e é o nome usado em todo o resto do projeto). Como
`$account->password` sempre retorna `null` (via `__get()` do Model, atributo
inexistente), a chamada virava `password_verify($senhaDigitada, null)` — que
**nunca** dá certo, não importa a senha. O próprio PHP chegou a acusar isso no
log: `Deprecated: password_verify(): Passing null to parameter #2 ($hash)`.

Confirmado com teste real (MariaDB + servidor PHP): com a senha certa, o
endpoint retornava "Senha incorreta" 100% das vezes — e depois da correção,
passou a aceitar a senha certa e continuar rejeitando a errada (testado dos
dois jeitos, mesmo usuário).

**Não confundir com outro comportamento, que é intencional e não é bug:** só
`role = admin` pode limpar o histórico — `gerente` (que tem acesso equivalente
a admin em quase tudo mais) é bloqueado de propósito aqui, por decisão de
produto já documentada. Se for esse o caso (mensagem "Apenas administradores"
em vez de "Senha incorreta"), não é o bug acima — é a regra valendo como
deveria.

**Arquivo:** `api/v1/history.php` — `$account->password` → `$account->password_hash`.

---

## "Relatórios" faltando no menu (mesmo problema do "Logs", agora resolvido)

Mesma causa do item já corrigido antes para "Logs": `admin/relatorio.html`
sempre existiu e sempre funcionou, só nunca foi adicionado ao array `LINKS`
de `admin/topbar.js` — por isso nunca aparecia no menu.

Aproveitei para alinhar `relatorio.html` ao padrão do resto do admin, já que
ele também nunca teve o topbar/footer compartilhado (era uma página solta,
só com um link manual "← Voltar"):
- Adicionado `<div id="cms-topbar">` + `<div id="cms-footer">` +
  `topbar.js`/`footer.js`, igual a toda outra página do admin.
- `:root`/tema escuro trocado pela paleta canônica (o arquivo tinha um tom de
  indigo levemente diferente do resto do painel — `#667eea` em vez de
  `#5b6ef5` — e não definia `--indigo-light`/`--indigo-dark`; o `topbar.js` já
  tem fallback pra isso, então não quebrava, mas ficava com uma cor
  ligeiramente diferente ao lado do menu).
- `@media print` atualizado para esconder `#cms-topbar`/`#cms-footer` também
  (senão apareceriam no PDF impresso).
- `init()`/`api()` passaram a checar sessão e redirecionar pra login (mesmo
  padrão de `historico.html`/`sites.html`/`logs.html`) — antes, sem token,
  a página só mostrava um `alert()` de erro em vez de mandar pro login.

**Testado de verdade** (servidor PHP + arquivo real, reproduzindo a lógica do
`.htaccess`): `/admin/relatorio.html` responde 200, com o `#cms-topbar` e os
dois scripts presentes; `/admin/topbar.js` confirmado com os 10 itens do menu
na ordem certa (incluindo "Logs" e "Relatórios"); `/admin/logs.html` e
`/admin/sites.html` confirmados que continuam funcionando.

**Arquivos:** `admin/topbar.js` (uma linha), `admin/relatorio.html`
(topbar/footer + paleta + redirecionamento de sessão).

---

## Migração visual (paleta do novo design) + Google Search Console (Fase 3/4)

### Parte 1 — Novo visual em todo o painel (dark mode preservado)

Aplicada em TODAS as páginas a paleta exata do novo design de referência
(valores OKLCH; indigo de marca `#5b6ef5` = `oklch(0.635 0.191 267)`), nos
dois temas (claro e escuro). O dark mode continua funcionando com o mesmo
mecanismo de sempre (`data-theme` + `localStorage cms_dark` via topbar.js) —
só os valores das variáveis mudaram, nenhuma mecânica.

- Páginas com bloco de cor atualizado: dashboard, editor, historico, logs,
  relatorio, settings, settings-images (ganhou a variável `--warning` que
  faltava), settings-ai-limits, users (unificado — o tema escuro usava um
  indigo diferente do claro), dicas-conteudo (mantidos os tons especiais
  `--geo`/`--aeo`), login.html e forgot-password.html (gradiente atualizado).
- **`admin/sites.html` totalmente reescrito**: grid de cards com avatar,
  badge de status com dot, ícones SVG (estilo Lucide), busca por nome/URL,
  empty states, modal renovado — **100% da lógica Alpine preservada** (mesmos
  endpoints, mesmos campos `wp_user`/`wp_password`, mesmo fluxo de testar
  conexão).
- Fonte **Inter agora é carregada de verdade** (Google Fonts, injetada uma
  única vez pelo `topbar.js`): todas as páginas já declaravam
  `font-family: 'Inter'`, mas nenhuma importava a fonte — caía sempre no
  fallback do sistema.

### Parte 2 — Google Search Console + Indexing API (Fase 3/4 do plano)

Implementação completa da integração por site:

**Banco:** nova tabela `site_google_connections` (uma conexão OAuth por site,
tokens criptografados com o mesmo `encrypt()` já usado em `wp_password`,
status `connected/revoked/error` com `last_error`) + coluna
`sites.sitemap_url`. Migração incremental em
`database/migrate_search_console.sql` (testada contra banco existente) e
também incluída no `schema.sql` para instalações novas.

**Backend:**
- `core/services/GoogleSearchConsoleService.php` — OAuth2 completo (URL de
  autorização com `state` assinado por HMAC + validade de 30 min contra CSRF;
  troca de code exigindo `refresh_token` com instrução clara se o Google não
  devolver; renovação automática do access token com margem de 2 min;
  `invalid_grant` marca a conexão como `revoked` para a UI oferecer
  "Reconectar"), indexação de URL (Indexing API, best effort — nunca lança),
  reenvio de sitemap, consulta de desempenho por URL (Search Analytics:
  cliques, impressões, posição média, principais queries) e detecção
  automática de sitemap (testa os padrões Yoast/RankMath/WP nativo). Todos os
  erros no log central, canal `search_console`, com a resposta crua do Google
  no contexto.
- `api/v1/google.php` — endpoints: status, connect (popup OAuth), callback
  (página que fecha o popup + postMessage), disconnect, detect-sitemap,
  sitemap (salvar manual), **reindex manual** (grava evento `manual_reindex`
  no histórico do post com autor e resultado) e performance. IDOR protegido
  em tudo (site/post validados contra o usuário logado).
- `core/services/PublishService.php` — **gancho automático pós-publicação**:
  com a publicação confirmada, dispara indexação + reenvio de sitemap em
  best effort (nunca desfaz nem atrasa a publicação; silencioso se o site não
  tem conexão; resultado de cada passo em `content_events` — `gsc_indexing`
  e `gsc_sitemap` — e no log central).
- `core/models/SiteGoogleConnection.php` (tokens em `$hidden`) e
  `sitemap_url` no fillable de `Site`.

**Front:** bloco "Google Search Console" no modal de edição de site
(`admin/sites.html`): status da conexão com e-mail da conta Google, botão
Conectar (popup + postMessage + fallback de polling), Desconectar, campo de
sitemap com botões Detectar e Salvar.

**Configuração necessária em produção (.env):** `GOOGLE_CLIENT_ID` e
`GOOGLE_CLIENT_SECRET` (credenciais OAuth criadas no Google Cloud Console),
com o redirect URI `{APP_URL}/api/v1/google/callback` cadastrado lá.
**Atenção ao prazo:** escopos do Search Console são sensíveis — o Google
exige verificação do app para uso com contas de terceiros (semanas). Em
modo "Testing" dá para usar adicionando as contas dos clientes como test
users.

**Validação:** suite funcional de 14 cenários contra MariaDB real + servidor
PHP embutido — status inicial, auth_url (offline+consent+state), IDOR (403),
state forjado rejeitado, state válido chegando à troca real com o Google,
erro de OAuth no log central com resposta crua, sitemap salvo/lido/inválido
(422), reindex de rascunho bloqueado (409), reindex sem conexão com mensagem
clara, auditoria `manual_reindex` gravada, refresh token inválido marcando a
conexão como `error`/`revoked`, e detect-sitemap com 404 explicado. **Todos
passaram.** (Dois ajustes foram de ambiente de teste, não de código: fixar
`SCRIPT_NAME` no router do servidor embutido — em produção com Apache não
ocorre — e instalar as extensões php-curl/php-mysql/php-mbstring no sandbox.)

---

## Revisão pós-entrega (interface + responsivo + backend GSC): 2 achados corrigidos + 3 endurecimentos

Rodada de revisão a pedido, cobrindo menu/navegação, responsivo e releitura
fria de todo o fluxo do Search Console. Registro completo:

**Achado 1 — menu do editor incompleto (real):** `admin/editor.html` tem uma
navegação própria (hardcoded, não usa o `topbar.js` compartilhado por ter
elementos extras como os labels de IA no canto) — e ela ficou **sem "Logs" e
"Relatórios"**, que só existiam no menu compartilhado. Quem estava no editor
não conseguia navegar até essas duas páginas. Corrigido adicionando os dois
links na mesma ordem do menu compartilhado (mexer só no necessário numa página
de 2.800 linhas). O menu mobile do editor tem CSS próprio completo — conferido,
funciona independente.

**Achado 2 — bug latente de timezone na renovação do token Google (real e
sério em produção):** `token_expires_at` é gravado por `now()` (UTC), mas a
renovação lia com `strtotime()`, que interpreta a string no fuso do php.ini do
servidor. Numa hospedagem em `America/Sao_Paulo` (UTC-3) — o caso provável em
produção — o token pareceria válido por até **3 horas depois** de já ter
expirado, e o sistema mandaria token vencido ao Google: indexação falhando de
forma intermitente, difícil de diagnosticar. Corrigido com parse explícito em
UTC. **Comprovado com teste dedicado** simulando servidor em São Paulo: o
código antigo dizia "ainda vale" para um token expirado há 1h; o novo renova
corretamente.

**Endurecimentos (não eram bugs ativos):**
1. `CURLOPT_CONNECTTIMEOUT => 5` nos três helpers HTTP do serviço — sem isso,
   um problema de rede no gancho pós-publicação poderia segurar a resposta do
   publish por até 20s por chamada só tentando conectar.
2. `postMessage` do callback OAuth agora vai só para a origem do `APP_URL`
   (antes `'*'`) — a mensagem é inofensiva (só um status), mas restringir é a
   prática correta.
3. Popup de conexão bloqueado pelo navegador agora mostra aviso claro na tela
   (antes falhava em silêncio).

**Verificações que passaram sem achados:** todas as 13 páginas com viewport,
topbar/footer/anti-flash de dark mode presentes onde devem; `dicas-conteudo.html`
fora do menu é intencional (é ajuda aberta pelo editor em nova aba, conferido o
link); responsivo do `sites.html` novo (grid colapsa pra 1 coluna, modal com
scroll pro bloco GSC caber no celular, tudo com flex-wrap); contratos do
backend confirmados (`AppException` com código HTTP mapeado, `encrypt()` com
IV prefixado, `Model::update/delete`, `QueryBuilder::first`, `now()` UTC).

**Regressão pós-revisão: 17/17 cenários passaram** (os 14 originais + origem
do postMessage + menu do editor + teste dedicado do timezone).

**Pendências conscientes (não são esquecimentos — documentadas no plano):**
o botão "Reindexar" ainda não aparece na lista de posts (endpoint pronto; a
exposição visual entra com o checklist da Fase 4, que depende da fila própria
da Fase 2) e o botão "Ver desempenho" no editor (backend pronto, UI é a Fase 6).

---

## Revisão do fluxo de edição de posts e validações: 5 achados reais corrigidos

Rodada de revisão a pedido, focada em editar posts (backend + editor) e
validações. Todos os achados foram comprovados com teste HTTP real antes e
depois da correção (31 cenários no total nesta rodada, todos passando ao final).

**Achado 1 — IDOR na CRIAÇÃO de post (sério):** `POST /posts` aceitava
`site_id` sem validar posse — um redator podia criar um post apontando para o
site de OUTRO usuário e, como o post nascia dele, tinha caminho livre para
publicá-lo usando as credenciais WordPress do site alheio. O próprio `update()`
já tinha essa proteção ao reatribuir site ("sem essa checagem seria um
mass-assignment de posse") — faltava exatamente a mesma na criação. Corrigido
com `findSiteForUser()` na rota (admin/gerente passam, como no resto).

**Achado 2 — status forjável (integridade):** dava para criar um post já com
`status: 'published'` (ou fazer PUT trocando de draft para published) sem nunca
passar pelo fluxo real de publicação — o post ALEGARIA publicado sem existir no
WordPress, sem eventos, sem indexação. Corrigido em duas camadas: a criação
força `draft` (todos os chamadores reais — editor e job da fila — já enviavam
isso), e a edição comum vinda de usuário só aceita status igual ao atual (a
regra antiga de "não rebaixar publicado para rascunho no autosave" continua
coberta, agora como caso particular). Chamadas internas (fila, PublishService)
continuam livres.

**Achado 3 — `gerente` sem permissões no RoleManager (contradição de design):**
o papel existe no enum do banco, a tela de Usuários o descreve como "acesso
total, exceto limpar histórico" e o `isManagerOrAdmin()` o trata como admin —
mas o mapa do `RoleManager` não tinha entrada para ele: um gerente levava 403
em criar/editar/publicar/deletar posts. Corrigido com `'gerente' => ['*']`
(a única exceção — limpar histórico — é checagem direta de role em
`history.php`, fora do mapa, então continua valendo; testado).

**Achado 4 — agendar sem permissão (bypass de publicação):** a rota
`POST /posts/{id}/schedule` não tinha NENHUMA checagem de papel — publicar
exigia `posts.publish`, mas agendar (que é publicar com data marcada) estava
aberto: um redator driblaria a restrição agendando para dali a um minuto.
Corrigido exigindo a mesma permissão do publicar.

**Achado 5 — rota legada `publish.php` pulava a validação mínima:** as rotas
antigas `/publish/now` e `/publish/schedule` (sem uso no front atual, mas
ativas) tinham permissão e posse validadas, porém publicavam conteúdo
incompleto sem o 422 de "Conteúdo incompleto" que a rota principal aplica.
Alinhadas (validação mínima + data parseável).

**Melhoria menor:** `scheduled_at` agora é validado como data parseável (422
"Data de agendamento inválida" para lixo) antes de chegar ao WordPress.

**O que foi revisado e passou sem achados:** whitelist de campos do `update()`
(mass-assignment protegido, `user_id` imutável, reatribuição de `site_id`
alheio ignorada — confirmado por teste); `validateMinimumRequirements`
(título, corpo ≥300 chars, FAQ ≥3 itens quando habilitado, imagem destacada) —
espelhado corretamente no cliente (`getValidationIssues`), com o servidor como
fonte da verdade e o modal do editor exibindo os `issues` do 422; validadores
SEO/GEO/AEO do editor (normalização de acentos/caracteres invisíveis,
detecção de pergunta com word-boundary, scores parciais) — maduros, sem bugs;
versionamento a cada edição; soft delete + restore; IDOR de leitura/edição de
post alheio (403/404).

**Matriz de papéis verificada com teste real (após os fixes):**
admin/gerente = tudo; editor = cria/edita/publica/agenda/deleta nos próprios
sites; redator = só cria/edita (publicar, agendar e deletar → 403, por design).

---

## Menu lateral estendido para todas as páginas restantes

Continuação da migração visual: as 8 páginas que tinham ficado com o menu
superior antigo (Histórico, Usuários, Configurações, Imagens, Limites de IA,
Logs, Relatórios, Dicas de Conteúdo) agora usam o mesmo menu lateral sanfona
das páginas já convertidas (Login, Dashboard, Sites, Editor).

Conversão puramente estrutural — nenhuma lógica Alpine.js foi tocada em
nenhuma das 8 páginas, só a casca visual ao redor:
- `<div id="cms-topbar">` → `<div class="cms-app"><div id="cms-sidebar">...`
- `topbar.js` → `sidebar.js`
- `shell.css` adicionado ao `<head>`

**Validação:** balanceamento de tags `<div>` conferido em cada um dos 8
arquivos (nenhuma tag órfã); `php -l` em 100% do projeto; servidor real +
banco real — todas as 8 páginas responderam HTTP 200 com o sidebar presente
no HTML, e a API por baixo (testei especificamente `/api/v1/logs`) continuou
respondendo normalmente, confirmando que a troca de camada visual não afetou
a lógica de nenhuma tela.

Com isso, as 12 páginas do painel usam o mesmo menu lateral, a mesma paleta
de cor e a mesma fonte — a migração visual iniciada nesta sessão está completa.

---

## Teste real de responsividade (Playwright) — 4 bugs reais encontrados e corrigidos

A pedido, testei o responsivo de verdade: instalei o Playwright (navegador
headless real) no ambiente, subi o projeto contra MariaDB real, logei de
verdade, e testei nas 3 larguras (celular 375px, tablet 768px, desktop
1440px) nas 11 páginas com o menu novo, incluindo clicar de fato nos botões
— não foi uma revisão só de CSS no papel.

**Bug 1 (crítico) — sem navegação nenhuma no celular:** o `shell.css`
original simplesmente escondia o menu lateral inteiro em telas ≤860px
(`display:none`) E também escondia o botão de abrir o menu na mesma faixa —
ou seja, no celular não havia NENHUMA forma de navegar entre páginas.
Corrigido: o menu agora vira um drawer deslizante (desliza da esquerda,
com fundo escurecido atrás) acionado por um botão hamburger que passou a
ficar visível justamente no mobile (antes era o oposto).

**Bug 2 — o próprio botão de abrir o menu não fazia nada:** ao corrigir o
Bug 1, o clique no hamburger do cabeçalho ainda não abria nada — eu tinha
corrigido o botão errado (o "Recolher" de dentro do menu, não o hamburger
do topo). Corrigido e confirmado com um clique real via automação.

**Bug 3 — o mesmo problema no Editor, com uma causa diferente:** o Editor
usa seu próprio menu (não o novo compartilhado). Ele já tinha um hamburger
próprio, mas clicar nele abria e fechava o menu no mesmo clique — o
hamburger e o `<nav>` eram elementos irmãos, e a diretiva
`@click.outside="navOpen=false"` do Alpine.js considerava o clique no
próprio hamburger como "fora" do menu, fechando-o imediatamente. Corrigido
envolvendo os dois num elemento pai comum e movendo o `@click.outside`
pra ele.

**Bug 4 — 3 páginas com tabela "estourando" a tela no celular** (Usuários,
Relatórios, Dicas de Conteúdo): pegadinha clássica de CSS flexbox — o
container de cada página é filho direto de uma coluna flex (`.cms-main`),
e sem as propriedades certas um filho recusa encolher abaixo da largura do
seu conteúdo interno (no caso, uma tabela larga), mesmo com `overflow-x:auto`
na tabela. Corrigido com uma regra central no `shell.css`
(`.cms-main > * { min-width:0; width:100%; box-sizing:border-box; }`), que
resolve a causa raiz pra qualquer página atual ou futura que use este shell
— e adicionado `overflow-x:auto` na tabela de Relatórios, que não tinha
nenhum (a de Usuários e Dicas de Conteúdo já tinham, só não fazia efeito
por causa do bug do flexbox).

**Ferramenta usada:** Playwright (Chromium headless) — como o CDN do
Alpine.js e o do Google Fonts não são alcançáveis no meu ambiente de teste
por restrição de rede do sandbox (não existe essa restrição no servidor
real), usei uma cópia local do Alpine.js só para os testes, sem alterar
nenhum arquivo entregue.

**Validação final:** as 11 páginas, nas 3 larguras (33 combinações), sem
nenhum overflow horizontal restante; o menu abre de verdade com clique
automatizado em Dashboard, Logs e Editor (as 3 páginas testadas
representando os dois padrões de menu existentes); `php -l` e balanceamento
de tags conferidos em tudo.
