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

8.3 KiB
Raw Blame History

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

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.
  • 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)