Merge pull request #652 from fsolla/C113-feed-ical-da-agenda-congela-na-criacao-do-link-eventos-

fix(C113): feed iCal da agenda vivo — eventos criados depois do link aparecem
This commit is contained in:
Francisco Solla 2026-08-11 11:57:30 +00:00 committed by GitHub
commit 7677cc3696
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 504 additions and 10 deletions

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-11):** **C113** (Issue #631) — feed iCal da agenda **vivo**: eventos criados depois da criação do link aparecem no mesmo link. **Spike diagnóstico primeiro** (e2e novo com o aceite de dois `GET`s via fixture — roda em dev E no build de produção): o teste **passou no modo prod ANTES do fix**, provando que o Next 15 **não** congela route handlers GET (docs: "not cached by default" desde 15.0; build compila a rota como `route.js` dinâmica, sem `.body`) — **o freeze é o contrato de resposta**: `Cache-Control: public, max-age=3600` sem validadores permitia a caches compartilhados (CDN da Vercel, infra do Google, proxies) reter o snapshot do primeiro fetch. **Fix (mínimo):** `export const dynamic = 'force-dynamic'` na rota `ical/[secret]` (explícito, convenção do repo) + novo `buildICalFeedResponse` em `src/utilities/calendarFeed.ts` (helper puro): `Cache-Control: public, no-cache` (armazenar só com revalidação obrigatória — fecha também a janela de 1h servindo feed revogado), `ETag` forte (sha256 do corpo, hash sempre muda com conteúdo), `Last-Modified` informacional, `X-PUBLISHED-TTL: PT1H` (RFC 7986) e **304 só por `If-None-Match`** — nunca por `If-Modified-Since`, porque exclusão de atividade não move `Last-Modified` (linha some) e um 304 por data esconderia "evento fantasma". **Testes:** unit do helper (12/12 — headers, ETag estável/mutável, 304 sem corpo, `*`/lista/weak, ETag velho → 200, IMS nunca 304); int na rota real com payload (`route.dynamic === 'force-dynamic'` + A → GET → B → GET ambos → edição de horário → DTSTART novo → cancelamento → some → GET condicional → 304, 18/18); e2e do aceite completo no spec existente `campaignAgendaFeed` (dev 5/5, prod build 1/1). Aceite: edição/cancelamento refletem, revogação/escopo do criador fail-closed intactos, sem migration/schema/access/UI/Consent. Verificação pós-deploy pendente no elo CDN externo (link real de produção). Intenção [`c113-feed-ical-agenda-nao-atualiza.md`](plans/c113-feed-ical-agenda-nao-atualiza.md); impl [`c113-feed-ical-agenda-nao-atualiza-impl.md`](plans/c113-feed-ical-agenda-nao-atualiza-impl.md).
**Recently resolved (2026-08-10):** **B197** (Issue #627) — coluna de e-mail oculta por padrão nas cinco listas de pessoas do `/campanha` (lideranças, dobradinhas, pessoas, apoiadores, assessores), religável pelo seletor de colunas B17 (cookie `campaign_columns`, default vale só para quem nunca tocou no seletor — escolha salva vence). Defaults novos em `DEFAULT_HIDDEN_COLUMN_IDS` (`email` nas 5) e `assessores` entrou em `CAMPAIGN_LIST_IDS` (primeira lista sem `CampaignTable` no mecanismo). **Pessoas (C100 reaberto):** o e-mail saiu da célula "Contato" (fica só telefone, `—` sem contato) e virou coluna própria oculta — cards mobile intocados (anti-goal). **Apoiadores:** coluna "E-mail" nova no `SupporterList` (dado já existia via `contact`; `SupporterListItemViewModel` ganhou `email`), nascendo oculta. **Assessores:** o encaixe — picker B17 na lista (trailing no omnibox, `advisorPickerColumns` espelhando os `<th>`) e `AdvisorsTable` (client) passou a receber `columnVisibility` com `<th>`/`<td>` condicionais; **exceção única: a linha-draft de criação mantém o input de e-mail sempre** (campo obrigatório no create). Sem migration/access/Consent/cache. Testes: unit do mecanismo (parser passa a guardar `assessores`; defaults + stored-wins) e e2e novo no `campaignColumnPicker` (default oculto, religar, persistência no reload, draft com input). Intenção [`ocultar-email-por-padrao-listas.md`](plans/ocultar-email-por-padrao-listas.md); impl [`ocultar-email-por-padrao-listas-impl.md`](plans/ocultar-email-por-padrao-listas-impl.md).
**Recently resolved (2026-08-10):** **OPS35+** (Issue #616) — restaurado o pin de browser que o OPS35 documentou como perdido: a **visibilidade CSS real** das colunas de rede (Assessor / Liderança / Dobradinha) e dos sortables de `/campanha/territorios` a 2200px. Spec novo mínimo `tests/e2e/campaignTerritoriesColumns.e2e.spec.ts` (projeto `campaign`, corpo exato do teste 3 do spec deletado — `getByRole('columnheader')` visível por rung container-query `@min-[Nrem]/territory-list`, Cobertura `count 0`); divisão de trabalho com o HTTP preservada (presença + classes de rung continuam no `campaignTerritoriesHttp`). Manifesto `e2e-affected`: entrada territórios agora mapeia os 2 specs **e** o arquivo dono das rungs `TerritoryListColumns.tsx` (uma mudança de rung acorda o pin; o seletor faz união, specs de municípios continuam acordando). Prova de não-vazio por mutação (rung `advisor` → `@min-[200rem]` fez o spec falhar em "Assessor"; revertido). Sem migration/access/Consent/UI/CSS. Intenção [`restaurar-pin-visibilidade-colunas-territorios.md`](plans/restaurar-pin-visibilidade-colunas-territorios.md); impl [`restaurar-pin-visibilidade-colunas-territorios-impl.md`](plans/restaurar-pin-visibilidade-colunas-territorios-impl.md).
@ -13,6 +15,7 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**Recently resolved (2026-08-10):** **OPS37** (Issue #605) — o container Postgres compartilhado **não é mais recriado** por um worktree vizinho: `pnpm db:start` virou `scripts/db-start.mjs` (CLI) sobre `scripts/lib/db-start.mjs` — preflight "container `teqo-postgres-1` Up + `healthy`/`starting` → **no-op** (compose nem roda); senão, `compose -p teqo up -d --wait --wait-timeout 120 postgres` como antes. **Causa raiz provada por medição:** o bind mount **relativo** `./docker/postgres/init` do compose resolve um path absoluto por worktree → `config_hash` diferente por worktree (OPS37 `39e89956…` / B195 `cf3cb522…` / C101 `917e11bb…`; diff do `config` = só o `source:` do bind) → qualquer `compose up` cru de outro worktree fazia `replace` e matava dev+e2e de todos (`terminating connection due to administrator command`) — o incidente se repetiu **ao vivo durante o gate**: o worktree `work/4` recriou o container no meio da suíte int (17 testes mortos; os 3 arquivos re-rodaram verdes e a suíte completa 691/691 passou em seguida). **Verificação do aceite:** `StartedAt` inalterado após `db:start` repetido, inclusive rodado de **outro worktree** (D9, hash diferente) — o caso que antes recriava. Recriação legítima (bump de imagem/config) segue por `pnpm db:start --force-recreate` (explícito; não testado live para não matar a run e2e do D9 em voo — flag é pass-through de 1 arg). `worktree.mjs` provisionamento delega para a mesma lib (mesmo risco no `worktree next`; `dockerComposeUp`/`SHARED_COMPOSE_PROJECT` órfãos removidos). Skip deliberado em `starting` (container que um peer acabou de criar): **espera** o boot ficar `healthy` (60s, polling 2s) em vez de recriar — e cai no compose up se travou; **não** skip em `unhealthy`/sem healthcheck (volta à config atual exatamente uma vez); `db:stop`/`db:doctor`/compose file/CI intactos. Unit novos (`dbStart.unit.spec.ts`, 17: matrix running×health + parse do inspect + wrapper mockado — skip não chama compose, argv do up/force-recreate, espera de `starting`). Sem migration/access/Consent/UI. Intenção [`container-postgres-compartilhado-compose-replace.md`](plans/container-postgres-compartilhado-compose-replace.md); impl [`container-postgres-compartilhado-compose-replace-impl.md`](plans/container-postgres-compartilhado-compose-replace-impl.md).
**Recently resolved (2026-08-10):** **D9** (Issue #596) — flake do consent restore do `testDatabaseLease` sob carga eliminado. **Caracterização com reprodução** (3 falhas em 8 runs da suíte int completa; depois isolada a 2 arquivos): o assertion "restores the exact configured consent after deletion and recreation" via leituras voláteis de uma linha canônica que escritores legítimos de outras specs deletavam/recriavam no meio do teste. **Causa raiz em três camadas:** (1) a janela de operação de `withMissingInviteConsentFixture` era a única transição de estado sem lease da suíte (outras specs criavam linha efêmera que o restore deletava sob elas); (2) `removeOnda0ConsentAndPrivacyDb` rodava sob leases **shared** e deletava as 4 keys estáveis no meio de testes alheios (o id restaurado sumia irreversivelmente); (3) asserções de restore comparavam contra leituras `before` que o provision Onda0 podia re-textar legitimamente. **Fix (só testes, zero `src/`):** janela de operação sob lease shared (`serializeWindow`, default true; o teste de ensures concorrentes migrou para key/lease privada — único caso que deliberadamente mantém janela sem lease, com re-verificação/re-delete do gap setup→janela; exclusivo descartado: alargava o ABBA com os escritores de invite sob carga); spec Onda0 com leases **shared** + down-migration **restaura as linhas com os ids originais** dentro da janela (`restoreConsentRows` compartilhado; no-op para o resto da suíte); asserções de restore referenciam o **snapshot do fixture** (contrato real), rollback asserta id+key; `ORDER BY id`/`sort: 'id'` nas leituras limit-1; core `withDatabaseLease`; `ensureInviteConsent` órfão removido (knip). Contraprova: suíte completa ×5 + par mínimo ×8 + 6 arquivos da key ×13/14 verdes (runs com restart externo do Postgres descartados por uptime). Intenção [`test-database-lease-consent-flake.md`](plans/test-database-lease-consent-flake.md); impl [`test-database-lease-consent-flake-impl.md`](plans/test-database-lease-consent-flake-impl.md).
(fix(C113): feed iCal da agenda vivo — contrato de frescor no lugar do snapshot congelado)
**Recently resolved (2026-08-10):** **B195** (Issue #591) — wizard "Registrar pedido" completo (A5, adiado no B85): o passo final saiu do stub e virou formulário funcional no shell do wizard — município no header do app (chrome `municipalityLabel`), **sem título grande de passo** (`stepTitle={null}` + `contentFocus="none"`), Tipo → Atividade (opcional) → **campo único** "O que você precisa?"; salvar ("Abrir demanda") → toast + retorno à origem (`returnPath`). **Mudança de modelo da demanda:** o cadastro (wizard e `/demandas/nova`, sem divergência) tem UM campo de texto (`description` required, min 2, max 4000 — constante `CAMPAIGN_DEMAND_BODY_MAX_LENGTH`); o **título virou derivado por IA** (`src/utilities/ai/campaignDemandTitle.ts` — `generateText` DeepSeek `deepseek-v4-flash` como o Sollinha, timeout 4s, sem key → `null`, prompt com rótulo do tipo) com **fallback = texto truncado** (`src/lib/demandTitle.ts`, puro: colapsa whitespace, ≤160) no create e **keep-previous na edição** (novo `updateCampaignDemandRecord` + editor inline no detalhe — recalc só quando a descrição muda; slug preservado pela invariante canônica); `campaignDemandCreateSchema` perdeu `title` (só os drafts de demanda da atividade mantêm título digitado — vertical fora de escopo, schema desacoplado). **Unicidade de slug defensiva só no create das forms** (`ensureUniqueDemandSlug`, opt-in `context.campaignDemandUniqueSlug` fail-closed em transação ativa — precedente `skipContactPhoneInvariant`): probe único da janela `-2…-20` + advisory lock `campaign-demand-slug:<base>` contra race; activity drafts/admin mantêm a constraint estrita (contrato de rollback C90 pinado em teste). Page: branch `register-demand` no router + **staff gate ANTES do load do escopo** (`gate: 'staff'` → líder vai a `/campanha/contatos`, B43 — o 403 do scope load pré-existente virava erro não determinístico; stub `WizardMunicipalitySelectedStub` removido, placeholder "em breve" apagado). **Bug latente corrigido no hook compartilhado** `useCampaignFormSuccessToast` (dependência `[state.message, state.status]` → `[state]`: segundo save com a mesma mensagem — caso do editor — não disparava toast/refresh). Copy compartilhada em `schemas/campaignDemand.ts`; parser único `parseCampaignDemandCreateFormData` (`utilities/demand/`). **Sem migration** (título continua required, preenchido pelo record). Testes: unit do fallback/limites exatos; int (create deriva, colisão `-2`/`-3`, min 2, update keep-previous/skip, leader edit bloqueado, pin "sem `DEEPSEEK_API_KEY`" no `beforeAll`); e2e novo `campaignRegisterDemand` (fluxo completo com título na lista, back-nav à busca, lockdown de líder). Intenção [`registrar-pedido-wizard.md`](plans/registrar-pedido-wizard.md); impl [`registrar-pedido-wizard-impl.md`](plans/registrar-pedido-wizard-impl.md).

View file

@ -0,0 +1,85 @@
# Impl: C113 — Feed iCal da agenda congela na criação do link: eventos novos não aparecem
Status: aprovado
Atualizado em: 2026-08-11
Issue: #631
Intenção: docs/plans/c113-feed-ical-agenda-nao-atualiza.md
Appetite restante: ~0,5 dia eng (herdado; fix pequeno + verificação)
## Desfecho da verificação (registrado em 2026-08-11)
**O nível Next NÃO congela o feed — o spike no build de produção passou ANTES do fix** (e2e novo "serves a live feed", modo prod `E2E_PROD=1`/`NEXT_DIST_DIR=.next/e2e`: 1,7 s verde com a rota sem `force-dynamic` e com `public, max-age=3600`; o build também compila a rota como `route.js` dinâmica, sem `.body`). Isso confirma a leitura das docs do Next 15 ("GET Route Handlers are no longer cached by default") e refina o diagnóstico da intenção: **o freeze vem do contrato de resposta** — `Cache-Control: public, max-age=3600` **sem validadores** permite a qualquer cache compartilhado (CDN da Vercel, infra do Google, proxies do assinante) armazenar o snapshot do primeiro fetch e reusá-lo sem revalidação condicional, apresentando "congela na criação do link" para o assinante (cadência de re-busca do Google + janela de 1h sem validadores). O fix entregue ataca exatamente esse elo; a verificação pós-deploy com link real (item 5 das fases) confirma o efeito no elo CDN externo, que não existe em teste local.
## Leitura da intenção
- **Outcome:** o MESMO link de import serve a agenda viva — um compromisso criado depois da criação do link aparece no iCal (prova: `GET` → criar atividade → `GET` → ambos presentes); o feed responde com sinais de frescor (`no-cache`/curto + `ETag`/`Last-Modified` para revalidação em 304 + `X-PUBLISHED-TTL`); edição/cancelamento refletem; revogação e escopo do criador seguem fail-closed.
- **O que NÃO negociar:** fail-closed (criador desativado/sem acesso → feed para de servir), escopo do criador (advisor intersectado por municípios atuais), leader lockdown, revogação; sem migration, sem UI, sem mudança de schema.
- **O que reavaliar (diagnóstico):** a hipótese da intenção — "App Router cacheia respostas `GET` de route handlers por padrão" — **está errada para este repo**: o app roda **Next.js 15.4.11**, e desde o Next 15 `GET` Route Handlers **não são cacheados por padrão** (breaking change 15.0: "GET Route Handlers are no longer cached by default"; docs: "Route Handlers are not cached by default"). A rota `ical/[secret]` já é dinâmica por padrão no nível Next. **O elo congelado é o contrato de resposta:** `Cache-Control: public, max-age=3600` **sem validadores** — qualquer cache compartilhado (CDN da Vercel, infra do Google, proxies do assinante) pode armazenar o snapshot do primeiro fetch e reusá-lo por 1h **sem nenhuma forma de revalidação condicional** (não há `ETag` nem `Last-Modified`); combinado com a cadência lenta de re-busca do Google Calendar para feeds públicos, isso apresenta o sintoma "congela na criação do link". Secundariamente, o `public, max-age=3600` também é uma janela de vazamento pós-revogação: um feed revogado continua servível por até 1h a partir de caches fora do controle do servidor.
## Abordagem recomendada
```mermaid
flowchart LR
A[Spike diagnóstico: e2e two-GET roda em modo prod ANTES do fix] --> B{Next congela no build prod?}
B -- sim --> C[force-dynamic é o mecanismo + contrato de frescor]
B -- não --> D[freeze é CDN/cliente externo: contrato de resposta é o mecanismo]
C --> E[Fix: dynamic explícito + ETag/304 + no-cache + X-PUBLISHED-TTL]
D --> E
E --> F[Testes: unit helper + int rota + e2e aceite completo]
F --> G[Gates + changelog + PR Closes #631 + verificação pós-deploy]
```
**Opções consideradas:** A | B | C
- **A. Fix do contrato de frescor + dinamismo explícito, com spike diagnóstico primeiro (recomendada):** (1) spike: novo e2e com o aceite de dois `GET`s criado via fixture (roda em dev E no build de produção), rodado em modo prod **antes** de qualquer fix para registrar empiricamente se o nível Next congela ou não; (2) `export const dynamic = 'force-dynamic'` na rota (explicitude + convenção do repo — todas as rotas dinâmicas de `/campanha` têm; protege contra flips futuros de default, mesmo não sendo o mecanismo no Next 15); (3) contrato de resposta em helper puro de `calendarFeed.ts`: `Cache-Control: public, no-cache` (armazenar só com revalidação obrigatória; elimina a janela de freeze e a janela pós-revogação), `ETag` forte (hash do corpo), `Last-Modified` informacional, `X-PUBLISHED-TTL: PT1H`, e 304 quando `If-None-Match` bate.
- **B. Só `force-dynamic` + `no-store`:** mais agressivo e mais simples; mas não entrega os sinais de frescor que a intenção prescreve (`ETag`/304 para revalidação barata, `X-PUBLISHED-TTL`), e `no-store` descarta a eficiência de revalidação condicional para um feed que o Google re-busca a cada poucas horas.
- **C. Sem spike, só headers:** arriscaria corrigir um mecanismo não confirmado — se o Next 15 (ou um futuro upgrade) de fato cachear a rota, o fix de headers sozinho não resolve; o spike é barato (roda o mesmo teste em modo dev e prod, ambos suportados pelo repo) e torna o diagnóstico prova, não opinião.
**Recomendação: A** — porque ataca o elo comprovado (contrato de resposta sem validadores + cache compartilhado), registra o diagnóstico empírico do nível Next antes de mexer (a intenção explicitamente pede "quem executa confirma"), entrega todos os sinais de frescor do aceite, e mantém o escopo mínimo (sem migration, sem schema, sem access).
**Rejeitadas:** B porque perde os validadores/304 e a dica `X-PUBLISHED-TTL` que o próprio aceite lista; C porque fixar sem diagnóstico viola a observação da intenção ("direção, não diagnóstico fechado") e pode corrigir um mecanismo inexistente deixando o real intocado.
### Componentes / mudanças
- **`src/utilities/calendarFeed.ts`** (helper puro novo): `buildICalFeedResponse(icalContent, activities, feed, request)` → monta `Response` com `Content-Type: text/calendar; charset=utf-8`, `Cache-Control: public, no-cache`, `ETag: "<sha256 do corpo>"` (forte, byte-exact), `Last-Modified` (máx. de `updatedAt`/`createdAt` das atividades e `feed.updatedAt`, UTC), `X-PUBLISHED-TTL: PT1H`; retorna **304 sem corpo** quando `If-None-Match` bate. **304 é só por ETag** — `If-Modified-Since` nunca responde 304: exclusão de atividade não move `Last-Modified` (a linha some), e um 304 por data poderia esconder um "evento fantasma" removido; o ETag (hash do corpo) sempre muda. Constantes `FEED_CACHE_CONTROL`, `FEED_PUBLISHED_TTL`.
- **`src/app/(campaign)/campanha/agenda/ical/[secret]/route.ts`**: `export const dynamic = 'force-dynamic'` (explícito, convenção do repo); 404/revogação/access inalterados; a resposta 200/304 passa a vir de `buildICalFeedResponse`.
- **Migration:** sem migration.
- **Access / Consent:** sem mudança — `resolveFeedCreatorAccess`, intersecção de escopo (C96) e fail-closed intactos; o novo header `no-cache` **fecha** a janela CDN pós-revogação que o `public, max-age=3600` abria.
- **UI:** Impeccable A — sem superfície.
### Dados → forma
- N/A (sem dados novos; a forma é o iCal existente — muda a frescura, não o formato).
## Fases verificáveis
1. **Spike diagnóstico (primeiro, antes do fix):** novo teste em `tests/e2e/campaignAgendaFeed.e2e.spec.ts` — feed criado **via fixture** (sem diálogo: funciona em dev E em modo prod, sem o skip do fluxo de geração): coordinator + município + atividade A + `calendarFeed` com `secretSlug` próprio → `GET` no link (contém A) → criar atividade B via `fixtures.payload.create` → `GET` de novo → **ambos presentes**. Rodar em modo prod local (padrão CI: `NEXT_DIST_DIR=.next/e2e pnpm build` + `E2E_PROD=1 CI=1 NEXT_DIST_DIR=.next/e2e pnpm test:e2e -- tests/e2e/campaignAgendaFeed.e2e.spec.ts`). Registrar o desfecho: se o teste falha no modo prod sem fix → Next congela (mecanismo A); se passa → freeze é CDN/cliente (mecanismo D).
2. **Fix:** `force-dynamic` na rota + `buildICalFeedResponse` em `calendarFeed.ts` + rota consumindo o helper.
3. **Testes:**
- **Unit** (novo, node env, sem payload): `tests/unit/calendarFeedResponse.unit.spec.ts` — helper puro: headers esperados (`no-cache`, ETag presente e estável para corpo igual, `X-PUBLISHED-TTL: PT1H`, `Last-Modified`); corpo igual + `If-None-Match` correto → 304 sem corpo; `If-None-Match` errado → 200; `If-Modified-Since` sozinho → **sempre 200** (blind spot da exclusão documentado).
- **Int** (ancorado em `tests/int/calendarFeed.int.spec.ts` existente): import da rota real (`route.dynamic === 'force-dynamic'`, precedente `campaignInviteUi.int.spec.ts:429`) + `GET` real com payload: atividade A → criar B → dois `GET` com corpos contendo ambos; editar B → `GET` com título novo; cancelar B → `GET` sem B; `If-None-Match` do último ETag → 304.
- **E2E** (extensão do spec): aceite completo — A → GET → B → GET (ambos) → edita B → GET (título novo) → cancela B → GET (B some, A fica); asserts de headers (`Cache-Control` com `no-cache`, `ETag` presente, `X-PUBLISHED-TTL`) e do 304 final. Roda em dev e prod (CI já roda modo prod via gate-ci).
4. **Gates + fechamento:** `pnpm gate:fast`, `pnpm test`, `pnpm test:e2e` (dev), modo prod local (spike + spec completo), `pnpm build`; 1 entrada em `docs/CHANGELOG-AGENTS.md`; impl plan com o desfecho do spike; `pnpm push` → PR Ready `--base main` `Closes #631` → auto-merge → checks.
5. **Verificação pós-deploy (manual, com o usuário):** com um link real de produção, `GET` → criar atividade em `/campanha/agenda` → `GET` de novo (ou aguardar a re-busca do Google) → evento aparece; conferir `x-vercel-cache` ausente/MISS e `Cache-Control: no-cache` no `curl -sI`. O CDN da Vercel não existe no teste local — só o contrato de resposta; o contrato novo (`no-cache` + 304) é o que garante que nenhum elo intermediário segure o snapshot.
## Rabbit holes / Não escopo (engenharia)
- **Controlar a cadência do Google Calendar:** anti-goal explícito; `X-PUBLISHED-TTL` é a melhor tentativa dentro do padrão (Google ignora para feeds públicos) — o aceite do servidor é o que controlamos.
- **Caçar cache do CDN da Vercel sem evidência:** o CDN não existe em teste local; o spike decide o nível Next, e o fix de headers vale para qualquer cache compartilhado (Vercel, Google, proxies). Verificação do `x-vercel-cache` é pós-deploy, não bloqueante.
- **Mudar access/escopo/revogação/geração do iCal:** fora — o gerador já usa `updatedAt` no `DTSTAMP`, já pula `cancelado`; não tocar.
- **ETag por timestamp:** rejeitado — `updatedAt` não captura exclusão (linha some); hash do corpo é o único validator que sempre muda com o conteúdo.
## Riscos e mitigação
- **Spike não reproduz o freeze no Next em modo prod (provável):** confirma que o mecanismo é CDN/cliente externo; mitigação = o fix de headers remove o contrato que permite o cache + verificação pós-deploy com link real.
- **304 malformado (com corpo):** teste unit pina 304 com corpo vazio.
- **`no-cache` + CDN que não suporta revalidação:** caches modernos suportam; o pior caso é um 200 completo a cada poll (payload minúsculo, carga irrelevante).
- **Regressão do e2e em modo prod (CI):** teste pequeno (2–3 GETs no mesmo feed); roda com o padrão `gate-ci` já existente.
- **Import da rota em teste int puxa payload config:** precedente já existe (`campaignInviteUi.int.spec.ts` importa página do convite com payload).
## Aceite de engenharia
- [x] Aceite de produto da intenção coberto: dois `GET`s com criação entre eles mostram o evento novo; edição/cancelamento refletem; headers com `no-cache` + `ETag`/`Last-Modified` + 304 + `X-PUBLISHED-TTL`; revogação/escopo fail-closed intactos
- [x] Invariantes AGENTS/engineering-standards: identificadores EN, copy pt-BR, sem migration/schema/access/Consent, sem tocar outras Issues
- [x] Testes: unit do helper (headers/ETag/304/IMS nunca 304), int da rota real, e2e do aceite completo (dev + prod)
- [x] Spike registrado no impl plan (desfecho do nível Next em modo prod)
- [x] Gates: `pnpm gate:fast` + `pnpm test` + e2e dev/prod + `pnpm build`; changelog; PR `Closes #631` com auto-merge

View file

@ -2,6 +2,7 @@ import config from '@payload-config'
import { getPayload } from 'payload'
import {
buildICalFeedResponse,
generateICalFeed,
loadFeedActivities,
loadMunicipalityNames,
@ -12,13 +13,25 @@ type RouteContext = {
params: Promise<{ secret: string }>
}
// C113: the feed must always render against the live database (a feed whose
// value is "new commitments appear without re-subscribing"). Explicit marker
// per repo convention — every dynamic campaign route declares it, and it pins
// the intent against any future default flip in Next's route caching.
export const dynamic = 'force-dynamic'
const notFoundResponse = () =>
new Response('Feed não encontrado.', {
status: 404,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
// C113: a revoked/unknown feed must never become a heuristically cacheable
// 404 in a shared cache — `no-store` keeps the fail-closed revocation
// contract from eroding at the CDN layer.
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
},
})
export const GET = async (_request: Request, context: RouteContext): Promise<Response> => {
export const GET = async (request: Request, context: RouteContext): Promise<Response> => {
const { secret } = await context.params
if (!secret || secret.length < 32) {
@ -64,13 +77,7 @@ export const GET = async (_request: Request, context: RouteContext): Promise<Res
const icalContent = generateICalFeed(activities, feed.label, municipalityNames)
return new Response(icalContent, {
status: 200,
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Cache-Control': 'public, max-age=3600',
},
})
return buildICalFeedResponse(icalContent, activities, feed, request)
} catch {
return notFoundResponse()
}

View file

@ -1,5 +1,7 @@
import 'server-only'
import { createHash } from 'node:crypto'
import { allDayCivilDateOf, allDayExclusiveEndDate } from '@/lib/activityAllDay'
import type { Activity, CalendarFeed } from '@/payload-types'
import { advisorMunicipalityScopeWhere } from '@/utilities/access/shared'
@ -9,6 +11,13 @@ const CALENDAR_NAME = 'Agenda Teqo'
const FEED_LOOKBACK_DAYS = 90
const FEED_LOOKAHEAD_DAYS = 365
// C113: the feed must never be the link that holds stale content — any shared
// cache (CDN, subscriber proxies) may store the response but MUST revalidate
// before reuse (`no-cache`). `X-PUBLISHED-TTL` is the iCal freshness hint
// (RFC 7986; emitted in the VCALENDAR body — Google publishes it as PT1H).
const FEED_CACHE_CONTROL = 'public, no-cache'
const FEED_PUBLISHED_TTL = 'PT1H'
const escapeICalText = (value: string): string =>
value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;')
@ -37,6 +46,7 @@ export const generateICalFeed = (
`X-WR-CALNAME:${escapeICalText(feedLabel || CALENDAR_NAME)}`,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
`X-PUBLISHED-TTL:${FEED_PUBLISHED_TTL}`,
]
for (const activity of activities) {
@ -76,6 +86,64 @@ export const generateICalFeed = (
return lines.join('\r\n')
}
const computeFeedLastModified = (activities: Activity[], feedUpdatedAt?: string): string => {
const timestamps = [
...activities.flatMap((activity) =>
[activity.updatedAt, activity.createdAt].filter((value): value is string => Boolean(value)),
),
...(feedUpdatedAt ? [feedUpdatedAt] : []),
]
const latest = timestamps.length
? timestamps.reduce((max, value) => (value > max ? value : max))
: new Date().toISOString()
return new Date(latest).toUTCString()
}
// C113: the response contract of the live feed. The ETag (strong, from the
// body hash) is the authoritative validator and the ONLY 304 trigger —
// `If-Modified-Since` alone never answers 304, because deleting an activity
// removes its row and would leave `Last-Modified` stale (a date-based 304
// could hide the removed event). The body hash always changes with content.
export const buildICalFeedResponse = (
icalContent: string,
activities: Activity[],
feed: Pick<CalendarFeed, 'updatedAt'>,
request: Request,
): Response => {
const etag = `"${createHash('sha256').update(icalContent).digest('hex')}"`
const lastModified = computeFeedLastModified(activities, feed.updatedAt)
const ifNoneMatch = request.headers.get('if-none-match')
const matchesEtag =
ifNoneMatch === '*' ||
(ifNoneMatch
?.split(',')
.map((value) => value.trim().replace(/^W\//, ''))
.includes(etag) ??
false)
if (matchesEtag) {
return new Response(null, {
status: 304,
headers: {
'Cache-Control': FEED_CACHE_CONTROL,
ETag: etag,
'Last-Modified': lastModified,
},
})
}
return new Response(icalContent, {
status: 200,
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Cache-Control': FEED_CACHE_CONTROL,
ETag: etag,
'Last-Modified': lastModified,
},
})
}
const buildFeedDateRange = (): { rangeStart: string; rangeEnd: string } => {
const now = new Date()
const rangeStart = new Date(now)

View file

@ -1,3 +1,5 @@
import { randomUUID } from 'node:crypto'
import { formatBahiaCivilDate, parseBahiaDateTimeInput } from '../../src/lib/campaignTime.js'
import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js'
import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js'
@ -113,4 +115,125 @@ test.describe('Agenda — link de import (C98)', () => {
await expect(linkInput).toBeVisible({ timeout: 15_000 })
expect(await linkInput.inputValue()).toMatch(/\/campanha\/agenda\/ical\/[0-9a-f-]{36}$/)
})
test('serves a live feed: a second GET shows activities created after the first (C113)', async ({
campaign,
request,
}) => {
// C113 acceptance: the SAME link must stay live — a commitment created
// after the first fetch appears on the next GET. The feed is created via
// fixture (no dialog), so this runs on the dev server AND on the
// production build (E2E_PROD=1), where route-level caching would freeze
// the first response.
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.claimMunicipality()
const civilDate = formatBahiaCivilDate(new Date())
const startAtA = parseBahiaDateTimeInput(`${civilDate}T10:00`)
const startAtB = parseBahiaDateTimeInput(`${civilDate}T11:00`)
if (!startAtA || !startAtB) throw new Error('Falha ao montar horário da fixture de agenda.')
const icalDateTimeOf = (iso: string) => iso.replace(/[-:]/g, '').replace(/\.\d{3}/, '')
const titleA = fixtures.value('Comício do feed vivo')
const titleB = fixtures.value('Caminhada do feed vivo')
await fixtures.payload.create({
collection: 'activity',
data: hookFilledCreateData<'activity'>({
title: titleA,
tags: ['Comício'],
status: 'confirmado',
startAt: startAtA,
municipality: municipality.id,
}),
depth: 0,
})
const secretSlug = randomUUID()
await fixtures.payload.create({
collection: 'calendarFeed',
data: { secretSlug, label: fixtures.value('Feed vivo'), createdBy: coordinator.id },
depth: 0,
draft: false,
user: coordinator,
overrideAccess: false,
})
const feedUrl = `${campaign.baseURL}/campanha/agenda/ical/${secretSlug}`
const first = await request.get(feedUrl)
expect(first.status()).toBe(200)
expect(first.headers()['cache-control']).toBe('public, no-cache')
expect(first.headers()['etag']).toMatch(/^"[0-9a-f]{64}"$/)
const firstBody = await first.text()
expect(firstBody).toContain(titleA)
expect(firstBody).not.toContain(titleB)
expect(firstBody).toContain('X-PUBLISHED-TTL:PT1H')
const firstEtag = first.headers()['etag']!
const activityB = await fixtures.payload.create({
collection: 'activity',
data: hookFilledCreateData<'activity'>({
title: titleB,
tags: ['Caminhada'],
status: 'confirmado',
startAt: startAtB,
municipality: municipality.id,
}),
depth: 0,
})
// The C113 acceptance: the same link is alive — the second GET sees the
// commitment created after the first, with a changed validator.
const second = await request.get(feedUrl)
expect(second.status()).toBe(200)
const secondBody = await second.text()
expect(secondBody).toContain(titleA)
expect(secondBody).toContain(titleB)
expect(second.headers()['etag']).not.toBe(firstEtag)
// Schedule edits reflect on the next GET (the title is immutable by
// design — the canonical slug — so the proof is the DTSTART change).
const editedStart = parseBahiaDateTimeInput(`${civilDate}T12:00`)
if (!editedStart) throw new Error('Falha ao montar horário editado da fixture.')
await fixtures.payload.update({
collection: 'activity',
id: activityB.id,
data: { startAt: editedStart },
depth: 0,
})
const edited = await request.get(feedUrl)
expect(edited.status()).toBe(200)
const editedBody = await edited.text()
expect(editedBody).toContain(`DTSTART:${icalDateTimeOf(editedStart)}`)
expect(editedBody).not.toContain(`DTSTART:${icalDateTimeOf(startAtB)}`)
// A subscriber holding the FIRST validator gets a full fresh 200, never a
// stale 304 — the freeze story of C113.
const staleRevalidation = await request.get(feedUrl, {
headers: { 'if-none-match': firstEtag },
})
expect(staleRevalidation.status()).toBe(200)
expect(await staleRevalidation.text()).toContain(`DTSTART:${icalDateTimeOf(editedStart)}`)
// Cancellation removes the event without leaving a ghost.
await fixtures.payload.update({
collection: 'activity',
id: activityB.id,
data: { status: 'cancelado' },
depth: 0,
})
const cancelled = await request.get(feedUrl)
expect(cancelled.status()).toBe(200)
const cancelledBody = await cancelled.text()
expect(cancelledBody).not.toContain(titleB)
expect(cancelledBody).toContain(titleA)
// A conditional GET with the current validator revalidates cheaply.
const revalidated = await request.get(feedUrl, {
headers: { 'if-none-match': cancelled.headers()['etag']! },
})
expect(revalidated.status()).toBe(304)
expect(await revalidated.text()).toBe('')
})
})

View file

@ -488,4 +488,101 @@ describe('campaign calendar feed domain', () => {
const activities = await loadFeedActivities(payload, feed, access.municipalityIds)
expect(activities).toHaveLength(0)
})
it('serves a live feed over two GETs with fresh validators (C113)', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const route = await import('@/app/(campaign)/campanha/agenda/ical/[secret]/route')
expect(route.dynamic).toBe('force-dynamic')
const titleA = campaignFixtures().value('Comício vivo')
const titleB = campaignFixtures().value('Caminhada viva')
const activityA = await createActivityRecord(
payload,
coordinator,
validActivityInput(relationId(municipality), titleA),
)
campaignFixtures().own('activity', activityA.id)
const ok = requireOk(
await createCalendarFeedLinkRecord(payload, coordinator, { label: 'Feed vivo' }),
)
campaignFixtures().own('calendarFeed', ok.feedId)
const secret = ok.feedUrl.match(/\/campanha\/agenda\/ical\/([0-9a-f-]{36})$/)![1]
const feedUrl = `http://localhost/campanha/agenda/ical/${secret}`
const getFeed = (headers: Record<string, string> = {}) =>
route.GET(new Request(feedUrl, { headers }), { params: Promise.resolve({ secret }) })
const first = await getFeed()
expect(first.status).toBe(200)
expect(first.headers.get('Cache-Control')).toBe('public, no-cache')
expect(first.headers.get('ETag')).toMatch(/^"[0-9a-f]{64}"$/)
expect(first.headers.get('Last-Modified')).toMatch(/ GMT$/)
const firstBody = await first.text()
expect(firstBody).toContain(titleA)
expect(firstBody).not.toContain(titleB)
expect(firstBody).toContain('X-PUBLISHED-TTL:PT1H')
const firstEtag = first.headers.get('ETag')!
const activityB = await createActivityRecord(payload, coordinator, {
...validActivityInput(relationId(municipality), titleB),
// An hour after activity A, so the DTSTART proof is unique to B.
startAt: new Date(Date.now() + 86_400_000 + 3_600_000).toISOString(),
})
campaignFixtures().own('activity', activityB.id)
// A commitment created after the first fetch shows up on the second GET,
// with a changed validator — the C113 acceptance.
const second = await getFeed()
expect(second.status).toBe(200)
const secondBody = await second.text()
expect(secondBody).toContain(titleA)
expect(secondBody).toContain(titleB)
expect(second.headers.get('ETag')).not.toBe(firstEtag)
// Schedule edits reflect on the next GET (the title is immutable by
// design — the canonical slug — so the proof is the DTSTART change).
const icalDateTimeOf = (iso: string) => iso.replace(/[-:]/g, '').replace(/\.\d{3}/, '')
const originalStart = activityB.startAt!
const editedStart = new Date(Date.now() + 86_400_000 + 7_200_000).toISOString()
await payload.update({
collection: 'activity',
id: activityB.id,
data: { startAt: editedStart },
depth: 0,
user: coordinator,
overrideAccess: false,
})
const edited = await getFeed()
const editedBody = await edited.text()
expect(editedBody).toContain(`DTSTART:${icalDateTimeOf(editedStart)}`)
expect(editedBody).not.toContain(`DTSTART:${icalDateTimeOf(originalStart)}`)
// A subscriber holding the FIRST validator gets a full fresh 200, never a
// stale 304 — the freeze story of C113.
const staleRevalidation = await getFeed({ 'if-none-match': firstEtag })
expect(staleRevalidation.status).toBe(200)
expect(await staleRevalidation.text()).toContain(`DTSTART:${icalDateTimeOf(editedStart)}`)
// Cancellation removes the event without leaving a ghost.
await payload.update({
collection: 'activity',
id: activityB.id,
data: { status: 'cancelado' },
depth: 0,
user: coordinator,
overrideAccess: false,
})
const cancelled = await getFeed()
const cancelledBody = await cancelled.text()
expect(cancelledBody).not.toContain(titleB)
expect(cancelledBody).toContain(titleA)
// A conditional GET with the current validator revalidates cheaply.
const revalidated = await getFeed({ 'if-none-match': cancelled.headers.get('ETag')! })
expect(revalidated.status).toBe(304)
expect(await revalidated.text()).toBe('')
})
})

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { generateICalFeed } from '@/utilities/calendarFeed'
import { buildICalFeedResponse, generateICalFeed } from '@/utilities/calendarFeed'
describe('generateICalFeed', () => {
const municipalityNames = new Map([
@ -37,6 +37,7 @@ describe('generateICalFeed', () => {
expect(result).toContain('DTSTART:20260815T190000Z')
expect(result).toContain('DTEND:20260815T220000Z')
expect(result).toContain('X-WR-CALNAME:Agenda do Candidato')
expect(result).toContain('X-PUBLISHED-TTL:PT1H')
})
it('excludes cancelled activities', () => {
@ -157,3 +158,113 @@ describe('generateICalFeed', () => {
expect(result).toContain('DTEND:20260815T190000Z')
})
})
describe('buildICalFeedResponse (C113)', () => {
const activities = [
{
id: 1,
slug: 'comicio-salvador',
title: 'Comício em Salvador',
status: 'confirmado' as const,
startAt: '2026-08-15T19:00:00.000Z',
endAt: '2026-08-15T22:00:00.000Z',
municipality: 1,
locality: 'Centro',
tags: ['comício', 'campanha'],
deputyPresent: true,
updatedAt: '2026-08-01T10:00:00.000Z',
createdAt: '2026-08-01T09:00:00.000Z',
},
] as unknown as Parameters<typeof generateICalFeed>[0]
const icalContent = generateICalFeed(
activities,
'Agenda do Candidato',
new Map([[1, 'Salvador']]),
)
const feed = { updatedAt: '2026-08-02T08:00:00.000Z' }
const requestWith = (headers: Record<string, string>) =>
new Request('https://example.test/campanha/agenda/ical/secret', { headers })
it('serves the feed with a freshness contract: no-cache, ETag and Last-Modified', async () => {
const response = buildICalFeedResponse(icalContent, activities, feed, requestWith({}))
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toContain('text/calendar')
expect(response.headers.get('Cache-Control')).toBe('public, no-cache')
expect(response.headers.get('ETag')).toMatch(/^"[0-9a-f]{64}"$/)
expect(response.headers.get('Last-Modified')).toBe('Sun, 02 Aug 2026 08:00:00 GMT')
expect(await response.text()).toBe(icalContent)
})
it('keeps the ETag stable for identical content and changes it with the body', () => {
const first = buildICalFeedResponse(icalContent, activities, feed, requestWith({}))
const second = buildICalFeedResponse(icalContent, activities, feed, requestWith({}))
expect(first.headers.get('ETag')).toBe(second.headers.get('ETag'))
const edited = buildICalFeedResponse(
icalContent.replace('Comício em Salvador', 'Comício em Salvador (novo local)'),
activities,
feed,
requestWith({}),
)
expect(edited.headers.get('ETag')).not.toBe(first.headers.get('ETag'))
})
it('answers 304 without a body when If-None-Match matches the current ETag', async () => {
const fresh = buildICalFeedResponse(icalContent, activities, feed, requestWith({}))
const etag = fresh.headers.get('ETag')!
const revalidated = buildICalFeedResponse(
icalContent,
activities,
feed,
requestWith({ 'if-none-match': etag }),
)
expect(revalidated.status).toBe(304)
expect(revalidated.headers.get('ETag')).toBe(etag)
expect(revalidated.headers.get('Cache-Control')).toBe('public, no-cache')
expect(await revalidated.text()).toBe('')
})
it('answers 304 for wildcard and multi-value If-None-Match lists, weak or strong', () => {
const etag = buildICalFeedResponse(icalContent, activities, feed, requestWith({})).headers.get(
'ETag',
)!
for (const header of ['*', `"stale-1", ${etag}`, `W/${etag}`]) {
const revalidated = buildICalFeedResponse(
icalContent,
activities,
feed,
requestWith({ 'if-none-match': header }),
)
expect(revalidated.status, header).toBe(304)
}
})
it('answers 200 when If-None-Match carries a stale ETag', () => {
const revalidated = buildICalFeedResponse(
icalContent,
activities,
feed,
requestWith({ 'if-none-match': '"stale-etag"' }),
)
expect(revalidated.status).toBe(200)
})
it('never answers 304 to If-Modified-Since alone (deletion blind spot)', () => {
const response = buildICalFeedResponse(
icalContent,
activities,
feed,
requestWith({ 'if-modified-since': 'Mon, 03 Aug 2026 08:00:00 GMT' }),
)
expect(response.status).toBe(200)
})
})