teqo-backup/docs/plans/c96-ical-feed-intersect-scope-read-impl.md

81 lines
6.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Impl: C96 — Feed iCal: interseptar escopo do criador no read (defesa em profundidade)
Status: aprovado/executado
Atualizado em: 2026-08-09
Issue: #455
Intenção: docs/plans/c96-ical-feed-intersect-scope-read.md
Appetite restante: ~0,25–0,5 dia eng (herdado)
## Leitura da intenção
- **Outcome:** no GET `/campanha/agenda/ical/[secret]`, um feed de assessor cujo `filterMunicipality` caiu fora dos municípios atualmente administrados pelo criador para de servir atividades dele — fail-closed (feed vazio, alinhado ao C16). Sem mudança para coordinator/candidate/admin. Sem mudança no write, sem migration.
- **O que NÃO negociar:** leader lockdown; cancelado fica fora; o segredo não vaza; coordinator/candidate/admin inalterados.
- **O que reavaliar:** hipótese da intenção ("validar antes e 404" vs "interseptar na where") — ver decisão abaixo. Resolvido a favor da **interseção na `where`** porque é o único lugar que o módulo de acesso do feed já orquestra o escopo no read e já tem o fragmento canônico `advisorMunicipalityScopeWhere`; 404 é aceitável mas a interseção na query serve o mesmo outcome sem nova ramificação no route.
## Abordagem recomendada
```mermaid
flowchart LR
GET[route.ts] --> resolve[resolveFeedCreatorAccess]
resolve --> load[loadFeedActivities]
load --> where[buildFeedWhere]
where --> f1[range/status/deputy/tag]
where --> f2[filterMunicipality equals]
where --> f3[advisor scope in ids]
f2 & f3 --> and[and: {...}]
```
**Opções consideradas:** A | B
**Recomendação:** A — porque o escopo do assessor já é resolvido no read (`resolveFeedCreatorAccess` → `accessibleMunicipalityIds`); interseptá-lo na `where` muda o mínimo, reusa o fragmento canônico `advisorMunicipalityScopeWhere` (obrigatório pela convention guard), e produz feed vazio (aceitável no aceite) sem tocar o route.
**Rejeitadas:** B (validar antes e 404) porque duplica a decisão de acesso no route (nova ramificação + novo helper de resolução do id do filtro), quando a query já é o ponto único de orquestração.
### Componentes / mudanças
- **`buildFeedWhere`** (`src/utilities/calendarFeed.ts:104-112`): quando `feed.filterMunicipality` estiver setado **e** `accessibleMunicipalityIds` não-nulo, adicionar **também** o fragmento `advisorMunicipalityScopeWhere('municipality', accessibleMunicipalityIds)` ao array de filtros (hoje o `else if` o ignora quando há filtro). `accessibleMunicipalityIds === null` (coordinator/candidate/admin) → comportamento inalterado. `[]` → `in: []` não casa nada → feed vazio.
- **Migration:** sem migration (config/consulta only).
- **Access / Consent:** sem `Consent` novo; mesmos `overrideAccess: true` já documentados no read (feed unauthenticated por secret; escopo re-derivado do autor). Invariante de `overrideAccess:false` com `user` intocado (não há `user` neste read).
- **UI:** Impeccable A — N/A sem UI.
## Fases verificáveis
1. **Schema/server** — `buildFeedWhere` (intersecção condicional) em `src/utilities/calendarFeed.ts`.
2. **Testes int** — extensão de `tests/int/calendarFeed.int.spec.ts`:
- assessor cria feed pinado a X (`filterMunicipality`) com atividade em X → `resolveFeedCreatorAccess` devolve `[X]` → `loadFeedActivities` lista a atividade (regressão positiva);
- assessor removido de X (`assignMunicipalityAdvisors(X, [])`) → `resolveFeedCreatorAccess` devolve `[]` → `loadFeedActivities` **não** lista a atividade (aceite C96);
- coordinator/candidate pinados a X inalterados: `accessibleMunicipalityIds === null` → atividade de X continua listada.
- fixtures: reusar `createActivityRecord` (coordinator), `assignMunicipalityAdvisors`, `getMunicipality`, `own`.
3. **Gates** — `pnpm gate:fast` na iteração; `pnpm test` (int afetado); `pnpm build` local.
## Rabbit holes / Não escopo (engenharia)
- **Refactor do modelo de acesso do feed** (maior pool de escopo para além da where) — a intenção declara fora de escopo.
- **404 vs feed vazio** — decisão de UX documentada (C16 aceita ambos); implementado feed vazio; sem nova ramificação no route.
- **UI do feed / sem-filtros (C94/C93)** — Issues separadas.
## Riscos e mitigação
- **Regressão em coordinator/candidate:** interseção só ativa quando `accessibleMunicipalityIds` não-nulo (somente advisor); teste int cobre o caminho null.
- **`in: []` não casando nada:** comportamento já estabelecido do fragmento canônico usado em todo o repo (fail-closed); teste int cobre o advisor removido.
- **Dupla cláusula `municipality` (`equals` + `in`) no msm `and`:** Payload combina cláusulas sob a mesma chave via `and`; idempotente para X ∈ escopo (equals domina), vazio para X ∉ escopo.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (advisor fora do escopo → feed não inclui; unrestricted/admin inalterados; sem write; sem migration)
- [x] Invariantes AGENTS/engineering-standards (fragmento canônico de escopo; copy pt-BR; identificadores EN; sem `overrideAccess:false` invertido)
- [x] Testes int de domínio (advisor no escopo lista; advisor removido não lista; coordinator inalterado)
- [x] `pnpm gate:fast` + `pnpm test` + `pnpm build` verdes
## Triage de débitos (gate humano aprovado, 2026-08-09)
**Já resolvido no simplify (não reabrir):** S0 — o `filters.push(advisorMunicipalityScopeWhere(...))` duplicado após a branch de filtro foi achatado num único guard standalone (semântica idêntica nos quatro cantos; `calendarFeed.int.spec.ts` 14/14 pós-flatten).
**Explicitamente fora / descartados:**
- **S3 (descartar):** `resolveFeedCreatorAccess` relembra o lookup de advisor (`{ advisors: { equals: creatorId } }`) em vez de reusar `getAdvisorMunicipalityIds` — pré-existente em `main`, 1 call site, e o feed roda sem `req` (shape não reutilizável limpo). Sem Issue.
- **S4 (NA):** só o teste 2 discrimina o bug; 1 e 3 são guards positivos — estrutura correta do trio.
- **S5 (descartar):** testes usam `loadFeedActivities`/`resolveFeedCreatorAccess` em vez da rota GET — convenção pré-existente do próprio arquivo; a rota só re-encaixa os dois, sem valor novo.
**Adiados com gatilho:**
- **S1:** `route.ts` `Cache-Control: public, max-age=3600` — após remoção do assessor, o feed pode servir stale por até 1h no edge. Aceite C96 aceita feed vazio; mitigação primária é revogar (404 imediato). Gatilho: reabrir se houver queixa de staleness ou mudança na política de cache do feed.
- **S2:** `validActivityInput` duplicado em 3 specs (calendarFeed agora é o 3º; assinaturas divergentes entre `homeSearchActivities`/`campaignActivity`). Gatilho: promover um helper compartilhado quando um 4º spec precisar do mesmo cabeçalho.