teqo-backup/docs/plans/sync-teqo-google-calendar-impl.md
Francisco Solla 25773d0296
feat(C16): add calendar feed sync by filter with Google Calendar import link (#419)
- Add calendarFeed collection with secret slug credential
- Add iCal feed endpoint at /campanha/agenda/ical/[secret] (unauthenticated)
- Add server actions for creating and revoking calendar feeds
- Add CalendarFeedButton UI component in agenda filters bar
- Add unit tests for iCal generation
- Fail-closed access: feed re-evaluates creator's municipality scope on each read
- Leader lockdown preserved; no PII in feed output
2026-08-08 14:47:17 +00:00

133 lines
8.3 KiB
Markdown
Raw 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: Sync por filtro + link de import (Google Calendar)
Status: aprovado
Atualizado em: 2026-08-08
Issue: #392
Intenção: docs/plans/sync-teqo-google-calendar.md
Appetite restante: ~1,5–2 dias eng (herdado)
## Leitura da intenção
- **Outcome:** Com um filtro ativo na agenda, o usuário obtém um link de import e o Google Calendar passa a refletir esse recorte. Teqo permanece SoT.
- **O que NÃO negociar:** Leader lockdown (sem acesso); fail-closed no access do feed (não vaza municípios fora do escopo do criador); link com credencial revogável; sem sync bidirecional; sem PII desnecessária no feed.
- **O que reavaliar:** A hipótese de "token/HMAC" da intenção — para revogação real, collection é mais simples que stateless+blocklist.
## Abordagem recomendada
```mermaid
flowchart LR
F[filtros na agenda] --> G[server action: createCalendarFeed]
G --> DB[(calendarFeed collection)]
DB --> R[GET /campanha/agenda/ical/secret]
R --> ICS[ical response]
ICS --> GC[Google Calendar assina URL]
```
**Opções consideradas:**
- **A) Token HMAC stateless** (como supporterImportToken): codifica filtro+actorID no token. Problema: sem revogação real sem blocklist (= collection disfarçada).
- **B) Collection `calendarFeed`** com secret slug: feed URL usa o slug como credencial. Revogação = delete/soft-revoke do documento. Listing natural ("meus feeds").
- **C) Híbrido HMAC + collection:** over-engineered para o escopo.
**Recomendação: B** — collection `calendarFeed`. Revogação nativa, listing, access scoping no create e no read. O secret slug é a credencial (como um invite token, mas para leitura de feed).
**Rejeitadas:** A porque revogação exige estado de qualquer forma; C porque adiciona complexidade sem ganho.
### Decisões de engenharia
#### 1. Collection `calendarFeed` vs stateless
**Opções:** A) stateless HMAC | B) collection Payload
**Recomendação: B** — porque revogação é requisito de produto ("credencial revogável"), e uma collection dá listing, auditoria (`createdBy`), e access scoping nativo via Payload hooks.
**Rejeitadas: A** porque sem revogação real; blocklist seria uma collection disfarçada.
#### 2. Formato iCal: hand-roll vs library
**Opções:** A) `ical-generator` (npm) | B) hand-roll RFC 5545
**Recomendação: B** — o formato VCALENDAR/VEVENT é texto simples; os campos da Activity mapeiam diretamente (title→SUMMARY, startAt→DTSTART, etc.). Zero dependências novas, zero risk de supply-chain. Escaping de texto iCal é trivial (newline→`\n`, comma→`\,`, semicolon→`\;`).
**Rejeitadas: A** porque adiciona dependência para ~80 linhas de código.
#### 3. Feed endpoint: dentro ou fora de `(app)`
**Opções:** A) dentro de `(app)` (exige cookie campaign-token) | B) fora de `(app)`, sem auth cookie
**Recomendação: B** — Google Calendar não envia cookies. O secret slug É a credencial. O endpoint fica em `src/app/(campaign)/campanha/agenda/ical/[secret]/route.ts` (fora do `(app)` group, assim como os routes WebAuthn).
**Rejeitadas: A** porque Google Calendar "From URL" é um GET anônimo.
#### 4. Access scoping no feed read
**Opções:** A) snapshot no create (feed congela o escopo) | B) re-avaliar escopo do criador a cada read
**Recomendação: B** — se o advisor perde acesso a um município, o feed para de incluir atividades daquele município. Fail-closed: se o criador for desativado/deletado, o feed para de servir.
**Rejeitadas: A** porque violaria o invariante "não vaza municípios fora do ator".
#### 5. Atividades canceladas no feed
**Opções:** A) excluídas | B) incluídas com STATUS:CANCELLED
**Recomendação: A** — alinhado com a recomendação da intenção ("some do calendário importado").
**Rejeitadas: B** porque polui o calendário do consumidor sem valor.
#### 6. Janela temporal do feed
**Opções:** A) todas as atividades (passado + futuro) | B) janela deslizante (ex: 6 meses passado + 12 meses futuro)
**Recomendação: B** — janela de 90 dias passados + 365 dias futuros. Feed muito grande degrada a sync do Google Calendar. A janela é generosa para o uso real.
**Rejeitadas: A** porque histórico completo cresce indefinidamente.
### Componentes / mudanças
- **`calendarFeed` collection** (`src/collections/CalendarFeed.ts`): secretSlug (unique, indexed, auto-generated), label, filterMunicipality (optional rel→municipality), filterDeputyPresent (checkbox), filterTag (optional text), createdBy (rel→campaignUser), revokedAt (optional date). Admin group `Campanha`. Access: staff create/read own; coordinator/candidate read all; leader none.
- **Migration:** `add_calendar_feed` — cria a tabela `calendar_feeds`.
- **`src/utilities/calendarFeed.ts`**: `buildCalendarFeedWhere(state, rangeStart, rangeEnd)` (reusa `buildActivityAgendaWhere` com janela fixa), `generateICalFeed(events, feedLabel)` (hand-roll RFC 5545), `resolveFeedSecret(secret)` (lookup + access check).
- **`src/app/(campaign)/campanha/actions/calendarFeed.ts`**: server action `createCalendarFeedLink(state)` — valida filtro, gera secretSlug (randomUUID), salva no DB, retorna URL completa.
- **`src/app/(campaign)/campanha/agenda/ical/[secret]/route.ts`**: GET route (fora `(app)`). Lookup feed → access check do criador → query activities → generate iCal → response `text/calendar; charset=utf-8`.
- **UI: `CalendarFeedButton`** (`src/components/campaign/activity/CalendarFeedButton.tsx`): botão na barra de filtros da agenda. Habilitado quando há filtros ativos. Ao clicar: chama server action → dialog com URL + copy-to-clipboard + instruções Google Calendar.
- **UI: `CalendarFeedList`** (`src/components/campaign/activity/CalendarFeedList.tsx`): seção na página da agenda (ou modal) listando feeds ativos do usuário com opção de revogar.
### Access / Consent
- **Sem Consent** — feed não captura PII novo; é leitura de dados existentes com access já controlado.
- **Access no create:** `isCampaignStaff` (coordinator, advisor, candidate). Advisor só pode criar feed com filtros dentro do próprio escopo de municípios.
- **Access no read (feed endpoint):** re-carrega o criador, re-avalia escopo de municípios, aplica restrição ao where. Se criador inativo/deletado → 404.
- **Leader lockdown:** sem acesso a create/read/list.
## Fases verificáveis
1. **Schema + server** (~50% do appetite)
- Collection `calendarFeed` + migration
- Utility `generateICalFeed` + `resolveFeedSecret`
- GET route `/campanha/agenda/ical/[secret]`
- Server action `createCalendarFeedLink`
- Unit tests: iCal generation, access scoping, feed resolution
- Gate: `pnpm gate:fast`
2. **UI** (~30% do appetite)
- `CalendarFeedButton` na barra de filtros da agenda
- Dialog com URL + copy + instruções
- `CalendarFeedList` (meus feeds + revogar)
- Server action `revokeCalendarFeed`
3. **Gates** (~20% do appetite)
- `pnpm gate:fast` (tsc + lint + format + test)
- `pnpm build` (com DB local)
- `pnpm test` (unit + int)
## Rabbit holes / Não escopo (engenharia)
- **Webhook/push para Google:** fora. O Google puxa (poll) o feed periodicamente.
- **OAuth push:** fora. Link de import cobre o job.
- **Feed público sem segredo:** fora. Fail-closed.
- **Múltiplos formatos (CalDAV, etc.):** fora. Só iCal (RFC 5545).
- **Rate limiting no feed endpoint:** fora de v1. O slug é imprevisível (UUID) e o endpoint é read-only.
- **Cache HTTP no feed:** fora de v1. Google Calendar respeita `Cache-Control` mas a sync já é periódica (~12-24h). Podemos adicionar `max-age` depois.
## Riscos e mitigação
- **Secret slug vazado = leitura do recorte.** Mitigação: UUID (imprevisível), revogação disponível, access re-avaliado a cada read.
- **Feed endpoint sem auth cookie = surface nova.** Mitigação: slug é a credencial; fail-closed se slug inválido/revogado/criador inativo; sem PII sensível no output (só título + município + datas).
- **iCal mal-formado rejeitado pelo Google.** Mitigação: seguir RFC 5545 estritamente; testar com validador iCal; unit tests cobrindo escaping e edge cases.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto
- [ ] Invariantes AGENTS/engineering-standards
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam
- [ ] Leader lockdown preservado
- [ ] Feed fail-closed (slug inválido → 404; criador inativo → 404)