# P9 — Serviços de plataforma: busca compartilhada, importação, notificações e feeds

| Campo | Valor |
|---|---|
| Programa | F7+ — operação, estrutura e conteúdo |
| Status | `planejado` |
| Depende de | P7, P8; consome **P5.5 (implementado)** — `Services\OpenData` é o único exportador |
| Habilita | P10, P11, P12 |
| Tamanho | **G** (5–7 sessões) |
| ADRs | ADR-050 (busca), ADR-051 (importação CSV), ADR-001/015 (service com 2º consumidor), ADR-007, ADR-018, ADR-021, ADR-024, ADR-030, ADR-035, ADR-042 |

## 1. Objetivo

Promover a **capacidade compartilhada** só quando ela tem um segundo consumidor real: busca
cross-module (Notícias + Licitações), importação operacional de CSV (Legislação + Transparência),
notificações nativas (Ouvidoria + e-SIC) e feed (Notícias + Dados Abertos, se aprovado). Nada de
service “por simetria”.

**Resultado observável:** `/busca` no portal devolve resultados de Notícias e Licitações com URL
correta; um CSV de legislação é pré-visualizado, rejeita linha inválida sem abortar e reaplica sem
duplicar; Ouvidoria/e-SIC enviam e-mail e registram metadados sem corpo; o feed de Dados Abertos
(se ligado) é XML válido.

## 2. Estado atual

- P7 entregou busca local de Notícias e feed de Notícias.
- P8 entregou Licitações (segundo consumidor de busca).
- `RichText` já tem `toText()`/`toMarkdown()`; não tem conversor HTML→AST/DOCX→AST.
- `Mail` nativo existe; não há histórico de envio nem supressão.
- `Storage` S3/RustFS já é o disco de domínio (ADR-030).
- Não existe `services/Search`, `services/Notifications` nem importador.

## 3. Escopo fechado

### S1 — `services/Search` (cross-module)

- Service headless `src/services/Search` (ADR-015), sem UI de módulo; expõe:
  - `SearchSource` (porta): `entityType()`, `documents(string $id, string $locale): array` —
    devolve **dois** `SearchDocument` opcionais, um `scope=portal` e um `scope=cms`; `module()`;
  - `SearchRegistry` (Core ou service): módulos registram fontes no `register()`;
  - `Search` (consulta): `query(string $term, SearchScope $scope, array $filters):
    SearchResultPage`, `reindex(?string $source = null)`.
- **Integração opcional:** o módulo chama o service e registra uma `SearchSource`; sem fonte, não
  aparece no índice e não quebra o build. O `SearchDocument` padrão tem `scope` (`portal|cms`),
  `entity_type`, `entity_id`, `module`,
  `locale`, `title`, `summary`, `body`, `url`, `published_at`, `permission` (só CMS) e
  `visibility` (`public|restricted`). Portal e CMS são objetos/índices separados; nunca misturar
  rascunho no resultado público.
- Tabela `search_documents`: `id`, `scope`, `entity_type`, `entity_id`, `module`, `locale`, `title`,
  `summary`, `body`, `url`, `permission`, `published_at`, `tsv` **gerada** (pesos A/B/C,
  `'portuguese'`) + GIN; `UNIQUE (scope, entity_type, entity_id, locale)`.
- Indexação por **evento de domínio** (`{module}.published/unpublished/updated/archived`) + job na
  fila; `search:reindex` idempotente (`upsert`), sem trigger no `save()`; item despublicado sai do
  índice; URL resolvida pela fonte, nunca por `switch` no Core.
- Consulta: `websearch_to_tsquery`, `ts_rank_cd`, filtros por módulo/locale/período, paginação
  keyset ou `limit/offset` explícito; `ts_headline` com escape; nenhum HTML cru.
- Portal `GET /busca?q=` (global, agrupado por módulo, acessível, `noindex, follow`), CMS
  `GET /cms/busca` (mesmo índice, filtros por módulo, respeitando permissões).
- **Cutover:** Notícias passa a consultar o índice compartilhado; a coluna gerada local
  `search_tsv` é arquivada/removida do caminho de leitura (uma verdade). `search_text` pode
  continuar como fonte derivada do AST para o indexador, mas não é consultada.
- Rate limit na busca pública (ADR-021) e cache curto por termo/filtros; `ETag` opcional.
- Testes: upsert idempotente; despublicado sai; URL por fonte; `ts_headline` escapa; reindex não
  duplica; dois módulos no mesmo resultado; locale.

### S2 — Importador operacional CSV (`Infraestrutura/Importador`)

- Módulo `Infraestrutura/Importador` (token `importador`, grupo `Infraestrutura`, CMS-only),
  permissions `[VIEW, EDIT]`; **não** é ETL de legado (ADR-042).
- Porta Core `ImportTarget` + `ImportRegistry`: `key()`, `label()`, `columns()` (nome, alias, tipo,
  obrigatório, validação), `upsert(array $rows, string $batchId): ImportReport`,
  `template(): ?string`. Módulos consumidores (Legislação, Transparência) implementam e registram
  no `register()`; o importador não os importa.
- Pipeline: upload CSV privado (S3, path determinístico por hash) → detecção de encoding
  (UTF-8/ISO-8859-1) e delimitador → preview paginado (sem escrever) → relatório **Poka-yoke
  visual** (válidas/inválidas/duplicadas/ignoradas, erro por campo, contagem, amostra e resumo
  antes/depois) → confirmação → job em lote → relatório final com falhas parciais.
- **Poka-yoke (D8):** a tela impede o erro antes do envio — colunas mapeadas com exemplo, célula
  inválida destacada com texto (não só cor), resumo “N válidas / N inválidas / N duplicadas”,
  dry-run obrigatório antes do apply, confirmação explícita e nenhuma escrita no preview.
  Referência de UX: `../lotep` (preview → relatório → apply), sem copiar código.
- Regras: streaming (`fgetcsv`), limite de linhas/bytes, bloqueio de CSV injection (`=+-@` no
  início da célula), validação no servidor (nunca confiar no preview do cliente), `key_hash`
  idempotente (`sha256(target + chave natural)`), `upsert` por chave natural (ex.: `numero+ano`),
  sem abortar o lote por uma linha inválida, `batch_id` único, retenção do arquivo e auditoria.
- UI: upload, preview, relatório, aplicar; sem XLSX no MVP (CSV nativo); template CSV por target.
- Testes: encoding, alias, linha inválida, dry-run não escreve, duplicata no banco é atualizada,
  injeção de fórmula, limite, job idempotente, arquivo privado.

> **Export não entra aqui.** CSV/JSON/XML de dados públicos é o motor `Services\OpenData` (P5.5,
> **já implementado**). O importador não cria `services/Export` — essa capacidade foi absorvida pelo
> `OpenData` e não volta como service separado; módulos de transparência usam `DatasetSource` e o
> `DatasetExporter`, sem serializador local.

### S3 — Notificações nativas (dois consumidores: Ouvidoria/e-SIC)

- `services/Notifications` **fino** sobre `Illuminate\Notifications`: `Notifier` com
  `send(Notifiable|string $to, Notification $notification)`, canais `mail` e `database`,
  `ShouldQueue`, retry/backoff, `notifications` table nativa.
- Sem motor de template/HTML no banco: cada notificação é uma classe concreta no módulo consumidor
  (Ouvidoria/e-SIC), com `toMail()` semântico; template Blade do módulo, não string em DB.
- Histórico: metadados (`notifiable`, `channel`, `status`, `attempts`, `sent_at`, `error_class`)
  sem corpo, sem PII além do destinatário necessário; retenção e supressão (bounce) entram em P13.
- Config: `MAIL_*` continua em `.env`; o service não troca mailer global. Testes usam
  `Notification::fake()` e um transporte fake; nenhum e-mail real.
- Testes: enfileira, falha não derruba request, dois consumidores, sem corpo em log, destinatário
  inválido, retry.

### S4 — Feed compartilhado (condicionado ao 2º consumidor)

- Se Dados Abertos publicar feed (datasets) e Notícias já tiver o seu, extrair `FeedRSS` como
  service/porta `FeedSource` (título, itens, URL, data), mantendo os endpoints atuais.
- Sem o 2º consumidor aprovado, **não** criar o service: o feed de Notícias de P7 continua no
  módulo. Decisão registrada em `kd`.
- Regras: XMLWriter, `ETag`/304, escaping, ordem determinística, limite, sem HTML, áreas restritas
  fora (ADR-043).

### S5 — Conversores RichText (opcional, com demanda)

- HTML→AST para colar conteúdo legado no editor: allowlist de tags, descarta
  `script/style/head/iframe/form`, normaliza listas/links/imagens (imagem → Media, se houver ID).
- DOCX→AST para atos administrativos: `ZipArchive` + `word/document.xml`; sem macros; imagens via
  Media; nunca executa HTML.
- **Não é ETL**: a conversão é uma ação manual no CMS, com preview e confirmação; nenhum comando
  em massa sobre o legado.
- Testes de conversão e de rejeição de nó perigoso; sem dependência nova (ZipArchive já existe).

### S6 — Fechamento

- E2E: busca global com 2 módulos; CSV preview/apply; notificação fake; feed (se
  aplicável).
- `make check` 2× + `E2E=1`; cobertura ≥90%; query count (anti-N+1) na busca.
- Atualizar `INVENTARIO.md` e os itens de backlog `servico-search`, `servico-excel-importer`,
  `servico-notifications`, `servico-feedrss` (o que foi promovido/condicionado).

## 4. Fora de escopo

- Elasticsearch/Meilisearch/algolia, busca vetorial/LLM.
- XLSX/PhpSpreadsheet (só com justificativa de licença/peso).
- Motor de templates de e-mail em banco; campanha/newsletter.
- ETL de legado, importação automática no deploy.
- Supressão/bounce/SPF/DKIM (P13).

## 5. Fatias de sessão

| Sessão | Entrega | Prova | Depende |
|---|---|---|---|
| **P9.S1** | `services/Search` + índice + fontes + `/busca` + cutover | 2 módulos; despublicado sai; reindex idempotente; URL por fonte | P7, P8 |
| **P9.S2** | módulo `Importador` + porta + CSV + dry-run + job | encoding; linha inválida; duplicata; fórmula; limite | P8 |
| **P9.S3** | `services/Notifications` + canais + histórico | fake; fila; falha; sem corpo; 2 consumidores | P8 |
| **P9.S4** | Feed compartilhado **se** 2º consumidor aprovado | XML/ETag; ordem; sem HTML; decisão no `kd` | P7 |
| **P9.S5** | Conversores (opcional) | HTML/DOCX → AST; nó perigoso rejeitado; manual | P9.S1 |
| **P9.S6** | E2E, docs, 2× check | evidência no `kd` | S1–S5 |

## 6. Contratos e dados

- **Services:** `Search`, `Notifications`; portas `SearchSource`, `ImportTarget`, `FeedSource`
  (condicional).
- **Tabelas:** `search_documents` (GIN), `notifications` (nativa), `import_batches`/`import_rows`
  (schema do importador).
- **Rotas:** `search.portal.index`, `search.cms.index`, `importador.cms.*`.
- **Comandos:** `search:reindex`, `importador:apply` (opcional), `notifications:prune`.
- **Permissões:** `importador.ver/editar`; a busca CMS respeita a permissão de cada módulo.
- **Eventos:** `search.indexed/removed`, `importador.completed`, `notifications.sent/failed`
  (metadados, sem PII).

## 7. DoD

- [ ] Service só existe com 2º consumidor comprovado; nenhuma duplicação de índice.
- [ ] Busca cross-module com URL por fonte, ranking, filtros, permissões e `ts_headline` seguro.
- [ ] Cada módulo envia `portal` e `cms` separados; integração é opcional; CMS filtra permissão.
- [ ] Importador CSV: preview, dry-run, idempotência, falhas parciais, anti-injection, arquivo privado.
- [ ] Notificações nativas com fila, retry, histórico sem corpo e dois consumidores.
- [ ] Não recria `services/Export`; módulos usam `Services\OpenData` (P5.5) para CSV/JSON/XML.
- [ ] Feed compartilhado só se aprovado; caso contrário, decisão registrada.
- [ ] `make check` 2× + `E2E=1`; cobertura ≥90%; PHPStan max 0.

## Fechamento — ficha de revisão

> **Obrigatório antes de fechar:** criar `_to_revision/P9-servicos-plataforma.md` e seguir
> `_to_revision/README.md` + `_to_revision/PROTOCOLO.md` (v2.0). A ficha não é gate de build; é a
> entrada da fila de revisão profunda.

- Cabeçalho: fase, `kd` épico/tasks/slices, âncoras (este arquivo, ADRs e código) e status
  `⬜ pendente` → `🟡 em revisão` → `✅ revisado` / `🔧 com correções`.
- Conteúdo: o que foi implementado, arquivos/superfícies a revisar, checklist D1–D14 aplicável,
  riscos e observações.
- Fechamento: preencher “Notas da revisão” com data/revisor/resultado e achados (`kd write
  --anchor`), atualizar a fila em `_to_revision/README.md` e `_to_revision/_inventario.md`.
- Achado com correção exige teste vermelho→verde; a fase só é `closed` no `kd` com a ficha
  registrada.

## 8. Riscos

| Risco | Mitigação |
|---|---|
| Service prematuro | porta/consumidor comprovado; sem 2º, não cria |
| Duas verdades de índice | cutover de Notícias para o índice compartilhado; coluna local arquivada |
| Importador virar ETL | escopo “operação”, dry-run, nenhum comando no deploy (ADR-042) |
| CSV injection/XSS | prefixo em `=+-@`, escape na render, teste |
| E-mail com PII/corpo em log | metadados apenas; `Notification::fake`; revisão LGPD |
| Indexação perder evento | job com retry + `search:reindex` idempotente; teste de reconciliação |

## 9. Retomada

```bash
kd prime
kd ask "search service importador CSV notificações feed"
kd task list --ready
```

Ler: P7/P8 → `sucesu_analise/03_servicos-ui/search/spec.md` → `sucesu_analise/03_servicos-ui/excel-importer/spec.md`
→ `lotep-v2/_00_panorama/06-APROVEITAMENTO-V7.md` §2–§3/§8 → `src/services/RichText/**`.

Referências irmãs (UX/desenho, não código): `../sucesu-cms/V2/Services/ExcelImporter/**` (preview →
relatório → apply), `../sucesu-cms/V2/Services/MailService/**` e
`../lotep/V2/Modules/Infraestrutura/MailAudit/**` (histórico/metadados), `../sucesu-cms/V2/Services/Search/**`
e `../lotep/V2/Services/Search/**` (índice). Rejeitar confiar no cliente, corpo em log, troca de
mailer global e trigger duplicando o índice.
