From 70070db8dc47d4f730219b09ccb087db0819b9d2 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 12:30:15 -0300 Subject: [PATCH] =?UTF-8?q?feat(C114):=20agenda=20da=20campanha=20espelhad?= =?UTF-8?q?a=20no=20Google=20Calendar=20com=20notifica=C3=A7=C3=A3o=20(pus?= =?UTF-8?q?h=20Teqo=20=E2=86=92=20Google)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Espelho oficial da agenda em calendário Google compartilhado via API (service account, jose + fetch): reconciliação por passada com id determinístico teqo+base32hex (upsert idempotente, sem estado por evento), hooks afterChange/afterDelete na activity (só campos espelhados, nunca lança), auto-resync quando calendarId muda, retry na view da agenda, pill de estado no header + FAB + diálogo (4 estados). Cancelado no Teqo deleta o evento no Google. Sem participantes no v1 (decisão de produto). Sem migration nova de tabela (hand-written). Closes #635 --- .env.example | 8 + docs/CHANGELOG-AGENTS.md | 2 + ...oogle-calendar-push-agenda-oficial-impl.md | 135 +++++ ...22-c114-followup-testes-estados-espelho.md | 57 ++ package.json | 1 + pnpm-lock.yaml | 11 +- .../(campaign)/campanha/(app)/agenda/page.tsx | 21 +- .../campanha/actions/googleCalendarSync.ts | 96 ++++ src/collections/Activity.ts | 5 + src/collections/GoogleCalendarSync.ts | 111 ++++ .../activity/AgendaGoogleSyncChrome.tsx | 121 +++++ .../activity/GoogleCalendarSyncDialog.tsx | 291 +++++++++++ src/lib/activityDescription.ts | 21 + src/lib/activityQuickActions.ts | 21 +- src/lib/campaignQuickActionContext.ts | 2 + src/lib/googleCalendarEventMapping.ts | 189 +++++++ src/lib/googleCalendarLink.ts | 28 + ...0260811_133332_add_google_calendar_sync.ts | 43 ++ src/migrations/index.ts | 8 +- src/payload-types.ts | 42 ++ src/payload.config.ts | 2 + src/utilities/access/googleCalendarSync.ts | 46 ++ src/utilities/calendarFeed.ts | 50 +- src/utilities/googleCalendarClient.ts | 206 ++++++++ src/utilities/googleCalendarSync.ts | 494 ++++++++++++++++++ src/utilities/googleCalendarSyncHooks.ts | 67 +++ .../e2e/campaignAgendaGoogleSync.e2e.spec.ts | 33 ++ tests/int/googleCalendarSync.int.spec.ts | 334 ++++++++++++ tests/unit/codebaseConventions.unit.spec.ts | 10 + tests/unit/googleCalendarClient.unit.spec.ts | 185 +++++++ .../googleCalendarEventMapping.unit.spec.ts | 183 +++++++ tests/unit/googleCalendarLink.unit.spec.ts | 30 ++ tests/unit/googleCalendarSync.unit.spec.ts | 240 +++++++++ .../unit/googleCalendarSyncHooks.unit.spec.ts | 146 ++++++ 34 files changed, 3209 insertions(+), 30 deletions(-) create mode 100644 docs/plans/c114-google-calendar-push-agenda-oficial-impl.md create mode 100644 docs/plans/c122-c114-followup-testes-estados-espelho.md create mode 100644 src/app/(campaign)/campanha/actions/googleCalendarSync.ts create mode 100644 src/collections/GoogleCalendarSync.ts create mode 100644 src/components/campaign/activity/AgendaGoogleSyncChrome.tsx create mode 100644 src/components/campaign/activity/GoogleCalendarSyncDialog.tsx create mode 100644 src/lib/activityDescription.ts create mode 100644 src/lib/googleCalendarEventMapping.ts create mode 100644 src/lib/googleCalendarLink.ts create mode 100644 src/migrations/20260811_133332_add_google_calendar_sync.ts create mode 100644 src/utilities/access/googleCalendarSync.ts create mode 100644 src/utilities/googleCalendarClient.ts create mode 100644 src/utilities/googleCalendarSync.ts create mode 100644 src/utilities/googleCalendarSyncHooks.ts create mode 100644 tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts create mode 100644 tests/int/googleCalendarSync.int.spec.ts create mode 100644 tests/unit/googleCalendarClient.unit.spec.ts create mode 100644 tests/unit/googleCalendarEventMapping.unit.spec.ts create mode 100644 tests/unit/googleCalendarLink.unit.spec.ts create mode 100644 tests/unit/googleCalendarSync.unit.spec.ts create mode 100644 tests/unit/googleCalendarSyncHooks.unit.spec.ts diff --git a/.env.example b/.env.example index f16a2ea4..2299af2a 100644 --- a/.env.example +++ b/.env.example @@ -44,3 +44,11 @@ DEEPSEEK_API_KEY=YOUR_DEEPSEEK_API_KEY_HERE # Deep Infra API key for the Sollinha voice transcription (STT, Whisper large-v3) # — cheapest speech-to-text around ($0.00045/audio min). Get one at https://deepinfra.com DEEPINFRA_API_KEY=YOUR_DEEPINFRA_API_KEY_HERE + +# C114 — Google Calendar mirror (Agenda da Campanha): base64 of the Google Cloud +# service-account JSON key (cat key.json | base64 -w0). The service account must +# be granted "make changes to events" on the campaign's public calendar; the +# calendar ID itself is configured by an admin in the `googleCalendarSync` +# collection. Absent/malformed → the agenda shows "não configurado" and the +# mirror stays off — the Teqo agenda never depends on this. +GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY= diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 200b3337..3966f556 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-11):** **C114** (Issue #635) — Agenda da Campanha espelhada no Google Calendar com push Teqo→Google (API oficial, service account). **Motor:** reconciliação completa por passada (`runCampaignCalendarSync`) com **id determinístico de evento** (`teqo` + base32hex do id — charset do Google) → upsert idempotente sem estado por evento; janela 90d passado/365d futuro (precedente do feed iCal), espelho cheio do escopo do staff (sem intersecção de advisor — agenda oficial), cancelado/deletado no Teqo **apaga o evento no Google** (sem estado cancelado visível a quem segue); conteúdo comparado por instante absoluto (imune a fuso do calendário) → passadas repetidas não tocam o Google; eventos estrangeiros do calendário nunca são tocados (decode completo — um `teqo3` manual não é nosso). **Triggers (sem cron — Hobby):** hook `afterChange`/`afterDelete` na `activity` (só campos espelhados, com normalização de shapes populados vs depth-0; tarefas não disparam; **nunca lança** — Teqo independe do Google; timeout 15s por request), auto-retry na view da agenda quando `paused` (uma vez por carregamento), botão "Sincronizar agora", e **auto-resync por config** (D7): trocar `calendarId`/reativar na collection `googleCalendarSync` re-conciliar sozinho (guard por comparação de campo quebra o loop de re-entrada); scan de delete com lookback de 730d (cancelado antigo não vira fantasma; atividade viva fora da janela mantém o evento). **Estado:** collection `googleCalendarSync` (uma linha; `calendarId` admin-only, `disabledAt` staff, timestamps system-write; **status derivado** `not-configured | disabled | synced | paused` em lib pura) + **chave da service account só em env** (`GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY`, base64 do JSON do GCP — sem credencial no DB); sem env → `not-configured` com runbook de ops (criar calendário na conta da campanha, público no link, SA editor, calendarId no Painel). **Transporte:** `jose` (novo dep) + fetch → Calendar API v3 REST (token JWT assertion, cache em módulo, 401 re-minta uma vez, 409 de insert idempotente; `end` obrigatório → atividade sem `endAt` vira 1h). **UI (Impeccable B, padrão C94):** pill no header da agenda (sincronizado/pausado/não configurado/desativado) + FAB mobile + diálogo com os 4 estados do canvas (link público de adição `calendar/r?cid=webcal…` + instruções + sync agora + desativar; falha de action visível). **Sem participantes no v1** (decisão de produto do gate: aviso segue as configs de quem segue; service account exigiria domain-wide delegation — segue para o C115/Google→Teqo). DRY com o feed: janela/where/nomes de município/description compartilhados (`calendarFeed.ts` como dono da janela). Migration hand-written (o gerador re-emitia no-ops pré-existentes de `campaign_user.contact_id`). Testes: unit (mapping/derivação/guards com normalização/client com fetch fake/hook wiring via `vi.mock`), int (engine com stub in-memory: cria/converge/atualiza só drift/cancela deleta/delete limpa fantasma/falha→pausado→recupera/troca de calendarId), e2e (pill não-configurado + runbook, sem dependência externa). Débito de cobertura (estados disabled/paused/synced + ação) registrado como #666 (depends #635). Intenção [`c114-google-calendar-push-agenda-oficial.md`](plans/c114-google-calendar-push-agenda-oficial.md); impl [`c114-google-calendar-push-agenda-oficial-impl.md`](plans/c114-google-calendar-push-agenda-oficial-impl.md). + **Recently resolved (2026-08-11):** **D10** (Issue #617) — auto-cura de leftovers de consent/supporter + fim do shared-write latente no lease de consent (pós-D9). **F1 (self-heal):** `purgeOrphanedConsentRenames` (3 nomes explícitos de renames temporários — `temporarily-renamed-consent`, `chave-renomeada`, `chave-temporariamente-ausente`) roda sob lease **exclusivo**, em **autocommit** (delete na transação do lease seguraria row lock e a operation do próprio fixture self-deadlockaria ao reusar a key purgada — observado na primeira implementação), nos 3 pontos que tocam a key canônica (`withMutableConsentFixture` antes do snapshot, setup do `withMissingInviteConsentFixture`, `ensureLeasedConsent`) — uma run abortada no meio de um rename de teste não envenena mais o run seguinte (UNIQUE `consent_key_idx` auto-sustentado). **F1b:** `purgeMunicipalityResidue` também deleta os **contatos** que supporters residue órfãos deixaram (reference-check em leadership/signature/subscription/supporter pós-delete — mesmo critério do `removeSupporterData`, reimplementado no helper). **F2 (refutação por medição + design final):** a hipótese "shared-writer = ABBA latente" caiu sob carga — exclusivo-durante-a-operation → fila de 17 waiters e cascata de 15s (6 workers); operation sem lease → FK `consent_id` (cleanup de fixture apagava linha canônica adotada); o ABBA não é construtível hoje (escritas disjuntas por teste; Onda0 em ordem fixa de 3 keys). As flakes reais do baseline (3/4 runs) eram: consumidores observando a ausência commitada da janela missing-consent (janela D9 era **shared**), down-test da Onda0 deletando as 4 keys **não-atomicamente**, e provision re-textando no meio de asserções de preview. **Fix:** janela do `withMissingInviteConsentFixture` → **exclusiva** (bloqueia consumidores shared durante a ausência; fila limitada ~200–500ms — a medição D9 do "15s tight" era o regime ONDA0-exclusivo, inexistente hoje); down-test da Onda0 **atômico** (delete+count+restore numa transação — FK insert espera o commit via speculative insertion); provision da Onda0 com leases **exclusivos** aninhados (ordem fixa CI→SR→VI); consumidores continuam shared. **Review /simplify (2 reviewers):** P1-1 — os renames vivos de `campaignInvite`/`campaignFixtures` migraram para chaves privadas (`consent-vacated-`/`owned-consent-vacated-`) — nenhum teste vivo produz os 3 nomes do purge; comentários "Onda0 shared" corrigidos; `withExclusiveTestDatabaseLease` colapsado (era cópia do privado); rollback no down-test quando a sessão falta; cleanup no finally nos testes F1 (auto-curáveis — um run falho do próprio teste não polui). Testes novos: contrato da janela exclusiva (waiter SHARED bloqueado), purge em ensure/setup, self-heal do canônico (repro do run abortado, 2 fixtures), F1b. **Contraprova:** 7 arquivos da key × 6 workers → **12/12 verdes** (baseline 1/4; designs intermediários: cascata 15s / FK); suíte int completa 83 arquivos/701 testes; unit 1938; e2e paridade com baseline (falhas pré-existentes de chat IA/webauthn/mic reproduzem no código original). Sem migration/access/Consent/`src/`. Intenção [`escala-dry-pos-d9.md`](plans/escala-dry-pos-d9.md); impl [`escala-dry-pos-d9-impl.md`](plans/escala-dry-pos-d9-impl.md). **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). diff --git a/docs/plans/c114-google-calendar-push-agenda-oficial-impl.md b/docs/plans/c114-google-calendar-push-agenda-oficial-impl.md new file mode 100644 index 00000000..a2cc7db7 --- /dev/null +++ b/docs/plans/c114-google-calendar-push-agenda-oficial-impl.md @@ -0,0 +1,135 @@ +# Impl: C114 — Google Calendar: agenda da campanha espelhada com notificação (push Teqo → Google) + +Status: aprovado +Atualizado em: 2026-08-11 +Issue: #635 +Intenção: docs/plans/c114-google-calendar-push-agenda-oficial.md +Appetite restante: ~2 dias eng (herdado; corte explícito: sem participantes no v1 — ver decisão aberta P1) + +## Leitura da intenção + +- **Outcome:** com um comando em `/campanha/agenda`, a campanha ativa um calendário Google compartilhado ("Agenda da Campanha"); criar/remarcar/cancelar atividades em Teqo reflete no Google em minutos (push via API oficial); quem segue recebe aviso conforme as próprias configurações; Teqo segue SoT e funciona sem Google (estado do sync visível, re-tenta sem ação manual); leader lockdown intocado. +- **O que NÃO negociar:** direção única Teqo → Google (C115 é o bidirecional); modelo = calendário compartilhado (nunca OAuth por pessoa); Teqo SoT — credencial inválida/API fora não pode quebrar o Teqo; espelho cheio do escopo do staff (recortes continuam no iCal); sem PII desnecessária no evento; líder não vê nem ativa. +- **O que reavaliar:** a "hipótese" aponta `src/utilities/calendarFeed.ts` como precedente — confirmado (janela de eventos, formatação de summary/description, nomes de município reusáveis). O que a intenção NÃO diz e a engenharia precisa decidir: **como disparar o push** (não há cron no Vercel — verificação: `vercel.json` não tem `crons`; Hobby limita cron), **onde viver o estado do sync** e **como mapear atividade→evento sem estado duplicado**. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[afterChange/afterDelete hook em activity
campos relevantes mudaram?] -->|sim| S[runCampaignCalendarSync
utilities/googleCalendarSync.ts] + P[agenda page view
estado pausado → auto-retry] --> S + B["botão 'Sincronizar agora'"
actions/googleCalendarSync.ts] --> S + S --> L[load activities janela 90d+365d
espelho cheio do staff] + L --> G[Google Calendar API v3
client com service account JWT
jose + fetch] + G --> D[diff por id determinístico teqo_activity_<id>
cria/atualiza/remove] + D --> W[grava collection googleCalendarSync
lastSyncedAt/lastSuccessAt/lastError*] + W --> U[UI: pill sincronizado/pausado/não configurado] +``` + +**Opções consideradas:** ver decisões D1–D6 abaixo. +**Recomendação:** push **orientado a evento** (hook no `activity`) com **reconciliação completa por passada** e **id determinístico de evento** — sem cron, sem fila, sem collection join e sem campo novo em `activity`. Configuração e estado do sync numa **collection `googleCalendarSync`** (credencial em env; **auto-resync quando a configuração muda**). UI no padrão do link de import (C94): pill no header desktop + quick action mobile + dialog. + +### Decisões de engenharia + +**D1 — Transporte para a API do Google.** +Opções: A) `googleapis` + `google-auth-library` (SDK oficial) | B) `jose` (assina JWT RS256) + `fetch` nativo → Calendar API v3 REST | C) JWT com `node:crypto` na mão. +Recomendação: **B** — só precisamos de token (JWT service account) + 4 endpoints REST (`events.list/insert/update/delete`); SDK oficial é ~40MB de máquina para 1 chamada; C reimplementa crypto de JWT (risco de segurança). `jose` é dependência pequena e padrão (Payload usa a mesma família). +Rejeitadas: A (pesado demais para a superfície real); C (implementar JWT à mão viola "nunca implemente crypto"). + +**D2 — Mapeamento atividade → evento Google.** +Opções: A) collection join `activityCalendarEvent` (activityId→googleEventId) | B) campos `googleEventId`/hash no `activity` | C) **id determinístico** `teqo_activity_` (charset do Google permite alfanumérico + `_`). +Recomendação: **C** — upsert idempotente sem estado persistido por evento: `events.get`/`list` → 404 cria, 200 compara conteúdo e atualiza só se mudou, cancelado/deletado remove. Elimina collection nova, migration de tabela e o loop de re-entrada do hook (escrever campo no doc re-dispara afterChange). "Edit the owner": o dono do evento no Google É a atividade; o id determinístico é só a identidade externa. +Rejeitadas: A (estado duplicado desnecessário; mais uma collection/access para administrar); B (re-entrada do hook — toda escrita de `googleEventId` re-dispara afterChange; precisa guard frágil; e leader update guard já compara snapshots de campos de staff). + +**D3 — Motor de sync.** +Opções: A) upsert incremental por evento (só o evento tocado) no hook | B) **reconciliação completa por passada**: lista janela no Google + diff contra atividades do Teqo + cria/atualiza/remove | C) dois caminhos (incremental no hook + reconciliação periódica). +Recomendação: **B** — um único caminho de código (uma superfície de teste), auto-curativo (remove fantasmas de eventos deletados no Teqo, reconstrói evento apagado à mão no Google — Teqo é SoT), e a comparação de conteúdo (summary/description/start/end) faz passadas repetidas convergirem sem tocar no Google quando nada mudou. Custo: 1 chamada `list` por passada (~100ms) — irrelevante na escala (centenas de eventos). +Rejeitadas: A (não limpa fantasmas; edição manual no Google não é corrigida; dois estados de falha distintos); C (dois caminhos para manter/testar sem ganho real). + +**D4 — Trigger do push ("em minutos" sem cron).** +Opções: A) cron Vercel (`vercel.json` crons) | B) **hook afterChange/afterDelete em `activity` (campos relevantes) + auto-retry na view da agenda quando pausado + botão "Sincronizar agora"** | C) Google push channels (webhook reverso). +Recomendação: **B** — o hook dá latência de segundos (o aceite pede "minutos"); a view da agenda + o botão cobrem o "re-tenta sem ação manual" (a própria equipe abre a agenda quando opera); escrever em Teqo continua 100% local. Sem cron (Hobby limita a 2/dia), sem fila, sem endpoint público novo. +Rejeitadas: A (limite de Hobby; cadência de horas não entrega o aceite); C (endpoint público + desafios de renovação de channel — infra que este item não precisa). +Nota: corridas convergem — a comparação de conteúdo torna uma segunda passada simultânea um no-op; sem advisory lock distribuído para estado cosmético (o `pg_advisory_xact_lock` existente é transacional e soltaria no commit, inútil para chamadas HTTP pós-commit). + +**D5 — Configuração e estado do sync (collection, não global).** +Opções: A) **collection `googleCalendarSync`** (uma linha de fato; precedente de infra `supporterImportBatch`/`calendarFeed`) | B) global `googleCalendarSync` (precedente `campaignGoals`) | C) tudo em env. +Recomendação: **A** — pedido do gate e casa com o precedente de recurso de sync com credencial revogável (`calendarFeed`); o `afterChange` da própria collection dispara o **auto-resync** quando a configuração muda (D7); access por collection explícito (read staff, update staff campo a campo: `calendarId` admin-only, `disabledAt` staff, estado system-write/staff-read). Singleton de fato garantido pelo fluxo (engine faz find/create na primeira ativação) — sem unicidade DB (a linha é única por construção da ação de ativação). +Rejeitadas: B (auto-resync também funcionaria num global, mas o precedente de recurso de sync é collection e o access fica mais legível; `campaignGoals` é configuração pura sem estado rastreado); C (calendarId em env exigiria redeploy para rotacionar; sem rastreabilidade de `lastError` na UI). + +**D6 — Credencial.** +Opções: A) **chave privada da service account em env** (`GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY`, base64 do JSON do GCP) + `calendarId` na collection (admin-set) | B) JSON inteiro da service account na collection (campo admin) | C) OAuth por pessoa. +Recomendação: **A** — segredo em env (precedente `REVALIDATE_SECRET`/Blob), identidade operacional (calendarId) na collection: staff desativa/reativa sem tocar em env; admin ajusta calendarId sem redeploy; key nunca vaza em log/DB. O runbook de ops (criar calendário na conta da campanha, compartilhar "público no link", dar permissão de escrita à service account, pôr calendarId) é o caminho da questão aberta de produto Q1 (dono da conta). +Rejeitadas: B (segredo em DB — precedente do repo é env; revogação via env é mais segura); C (anti-goal explícito da intenção). + +**D7 — Auto-resync ao mudar a configuração.** +Opções: A) **afterChange na collection `googleCalendarSync`**: quando `calendarId` (ou `disabledAt`) mudou de fato (comparação `originalDoc` vs `doc`), dispara `runCampaignCalendarSync` — reconciliação completa contra o novo calendário | B) só manual ("Sincronizar agora" / view da agenda) | C) migração completa entre calendários (deletar no antigo + criar no novo). +Recomendação: **A** — o id determinístico + diff por conteúdo tornam a passada idempotente: trocar o `calendarId` recria o espelho inteiro no novo calendário na hora, sem ação manual. Guard de re-entrada: as escritas de estado (`lastSyncedAt`/`lastSuccessAt`/`lastError*`) não alteram `calendarId`/`disabledAt`, então não re-disparam o afterChange. O calendário antigo fica com o último espelho congelado (sem limpeza retroativa — documentado no runbook). +Rejeitadas: B (o aceite "re-tenta sem ação manual" se estende à reconfiguração); C (varrer e deletar N eventos de um calendário que a campanha abandonou é valor marginal — o calendarId troca só em migração de conta). + +### Componentes / mudanças + +- **`src/lib/googleCalendarEventMapping.ts`** (novo, puro, unit-testável): `googleEventIdForActivity` (`teqo_activity_`), `buildGoogleEventPayload(activity, municipalityName)` (summary `[Município] Título`; description sem PII — Município/Local/Tags/Deputado presente, mesmo espírito do iCal; `start`/`end` timed com offset America/Bahia −03:00 ou `date` all-day via `allDayCivilDateOf`/`allDayExclusiveEndDate` de `src/lib/activityAllDay.ts`; **end ausente → start + 1h** — pendente de verificação no doc oficial da API), `googleEventContentEquals(remote, payload)`. +- **`src/utilities/googleCalendarClient.ts`** (novo, `server-only`): JWT service account com `jose` (`SignJWT` RS256, scope `https://www.googleapis.com/auth/calendar`, aud `https://oauth2.googleapis.com/token`), POST token (cache em módulo, invalida em 401 e re-tenta uma vez), `listEvents` (paginação), `insertEvent`, `updateEvent`, `deleteEvent`; erros tipados (`GoogleCalendarApiError`) sem credencial em log. +- **`src/utilities/googleCalendarSync.ts`** (novo, `server-only`): `readGoogleSyncConfig`, `deriveGoogleSyncStatus` (matriz pura `not-configured | disabled | synced | paused` — key presente? calendarId? disabledAt? lastErrorAt > lastSuccessAt?), `runCampaignCalendarSync(payload, client)` — reconciliação completa (carrega atividades da janela 90d passado/365d futuro com `overrideAccess` — espelho cheio do staff, sem intersecção de escopo de advisor: é a agenda oficial da campanha; reusa o padrão de `loadFeedActivities`/`loadMunicipalityNames` de `calendarFeed.ts`), grava `lastSyncedAt` + `lastSuccessAt` ou `lastErrorAt`/`lastError`. +- **`src/collections/GoogleCalendarSync.ts`** (novo): slug `googleCalendarSync`, group `Campanha` (precedente de infra `supporterImportBatch`); campos `calendarId` (admin-write), `disabledAt` (staff-settable via action), `lastSyncedAt`/`lastSuccessAt`/`lastErrorAt`/`lastError` (system-write, staff-read para a pill); access: read staff, update staff campo a campo (field access); **afterChange com auto-resync (D7)**: `calendarId`/`disabledAt` mudaram de fato → dispara `runCampaignCalendarSync`; escritas de estado não re-disparam (guard por comparação de campo). +- **`src/collections/Activity.ts`** (editar): afterChange ganha `syncActivityToGoogleCalendar` (dynamic import — mesmo padrão do `notifyActivityNeedsAttention`; guard: só dispara quando campos relevantes mudaram — `title, status, startAt, endAt, allDay, locality, municipality, tags, deputyPresent, description` — compara `originalDoc` vs `doc`, tarefas não disparam; try/catch **nunca lança** — Teqo nunca depende do Google) e afterDelete idem (reconciliação remove o fantasma). +- **Server actions `src/app/(campaign)/campanha/actions/googleCalendarSync.ts`** (novo): `getGoogleCalendarSyncState` (status + link público quando configurado), `runGoogleCalendarSyncNow` (retry manual/automático, retorna estado fresco), `setGoogleCalendarSyncDisabled` (toggle staff); padrão `getCampaignActionContext` + `reloadStaffActor` de `actions/calendarFeed.ts`; líder nunca chega (layout `(app)` + gate staff da página). +- **`src/lib/googleCalendarLink.ts`** (novo, puro): link público de adição `https://calendar.google.com/calendar/r?cid=` + URL iCal pública `webcal://calendar.google.com/calendar/ical//public/basic.ics` (Apple/Outlook) — formato a verificar no doc oficial no impl. +- **UI** (Impeccable B — shape→craft→critique→polish; reusa shell `Dialog`/`Drawer`/`useIsMobile`/`SetCampaignHeaderAction`/`useCampaignQuickActionContext` de C94): `AgendaGoogleSyncChrome.tsx` (pill no header desktop junto do link de import + quick action mobile; recebe estado inicial do servidor; auto-retry quando `paused` e atualiza via action + `revalidatePath`) e `GoogleCalendarSyncDialog.tsx` (estados do canvas: não configurado → instruções de ops; sincronizado → link + copiar + instruções + "Sincronizar agora" + "Desativar"; pausado → último erro + re-tentar; desativado → reativar). Copy pt-BR conforme canvas. +- **`src/app/(campaign)/campanha/(app)/agenda/page.tsx`** (editar): carrega estado do sync (staff) e monta `` ao lado do `AgendaFeedChrome` (ordem do cluster C95: [Semana][Link de import][Google][Notificações][IA] — verificar colisão de `id` dos `SetCampaignHeaderAction`). +- **`.env.example`** (editar): `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY` documentada. +- **Migration:** `migrate:create add_google_calendar_sync` (tabela da collection nova — D5; sem collection join — D2). +- **Access / Consent:** sem chave de Consent (dados internos da campanha, precedente `calendarFeed`); sem PII nova — emails de campanha não vão para o Google no v1 (corte de participantes). Fail-closed: sem env key o sync não dispara (estado `not-configured`), o hook é no-op. +- **Docs:** entrada única em `docs/CHANGELOG-AGENTS.md`; runbook de ops (criação do calendário na conta da campanha + compartilhamento + permissão da service account + envs) — na AGENTS.md ou no plano? Decisão barata: na entrada do CHANGELOG + nota no impl. + +### Dados → forma (se aplicável) + +N/A — não há apresentação de dados no Teqo (o evento é do Google Calendar; decisão de apresentação do Google). Único dado novo visível: a pill de estado do sync (derivada, matriz unit-testada). + +## Fases verificáveis + +1. **Tracer / schema+server** (dia 1): `pnpm add jose`; collection `googleCalendarSync` + migration + `generate:types` + `generate:importmap`; `googleCalendarEventMapping` + unit tests; `googleCalendarClient`; `googleCalendarSync` + int tests com stub in-memory do cliente (incl. **auto-resync ao trocar `calendarId`** e guard de re-entrada do afterChange); hook no `activity` + guard; actions; `migrate` local + `migrate:status`. +2. **UI** (dia 1–2): chrome + dialog + wiring na página; e2e do estado `not-configured` (sem env no teste → determinístico, sem dependência externa) + diálogo; Impeccable shape→craft. +3. **Gates** (dia 2): `pnpm gate:fast`; `pnpm format:check`; `pnpm exec knip`; `pnpm check:cycles`; `pnpm test` (unit+int); `pnpm test:e2e`; `pnpm build`; Aikido nos arquivos novos; `pnpm push` com CI green. + +## Rabbit holes / Não escopo (engenharia) + +- **Watch channels do Google (push reverso para nós)** — exige webhook público e renovação; o hook do Teqo já dá latência de segundos. +- **Fila/worker/cron próprio** — Hobby; hook + lazy retry cobre o aceite. +- **Um calendário por filtro/recorte** — iCal cobre (intenção). +- **Participantes/attendees (aviso garantido ao núcleo)** — corte v1: quem segue recebe conforme as configs do próprio Google (é o que o pedido assume); reabre como follow-up se a mesa quiser (decisão aberta P1). +- **Sincronizar histórico completo** — janela 90d+365d (precedente do feed). +- **Migrar eventos antigos para o Google** — fora de escopo. +- **OAuth por pessoa** — anti-goal da intenção. +- **Lock distribuído para a passada** — corridas convergem por comparação de conteúdo; D3. + +## Riscos e mitigação + +- **Semântica da API Google desconhecida** (charset do id de evento, `end` obrigatório para timed, formato do link `cid`): verificar no doc oficial durante o impl (source-driven-development); os int tests com stub travam NOSSO contrato de mapeamento, não o do Google. +- **E2E com dependência externa (Google real)** — flakiness em CI: e2e só cobre `not-configured` (env ausente); motor testado em int com stub. +- **Latência no hook** — guard de campos relevantes (tarefa não sincroniza), try/catch total, escrita nunca bloqueada. +- **Segredo vazando em log** — key só em env; client tipa erros sem corpo da credencial; Aikido nos arquivos novos. +- **Env ausente em produção (C98 lesson)** — a pill mostra `não configurado` com runbook em vez de quebrar; `NEXT_PUBLIC_SITE_URL` já é conhecida; sem env nova obrigatória para o app funcionar (sync desligado é estado válido). +- **Re-escrita de edição manual feita no Google** — direção intencional (Teqo SoT); documentar no runbook. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção coberto: push em minutos via hook; estado visível; re-tenta sem ação manual (view + botão + próximas escritas); Teqo funciona sem Google; leader lockdown intocado (gate staff da página + sem superfície para líder). +- [ ] Reconfiguração auto-sincronizada: mudar `calendarId`/`disabledAt` dispara reconciliação contra o novo calendário sem ação manual (D7), com guard de re-entrada do afterChange. +- [ ] Invariantes AGENTS/engineering-standards: identificadores em inglês, copy pt-BR; server actions com `getCampaignActionContext`; sem PII nova; segredo em env; sem migration retroativa. +- [ ] Testes de domínio previstos: unit (mapping, derivação de status, link), int (engine com stub: cria/atualiza só quando muda/cancela remove/fantasma limpo/falha→pausado→recupera/auto-resync de config; guard do hook), e2e (pill não-configurado + diálogo). +- [ ] Self-score decision-quality: 5/5 (decisões caras com rejeitadas; cabe no appetite; rabbit holes nomeados; depth check reusa `calendarFeed.ts`/`activityAllDay.ts`/shells C94; intenção preservada). + +## Decisões em aberto (produto — para o gate humano) + +- **P1 — Participantes (núcleo) no v1?** Recomendação: **não** (quem segue recebe conforme as próprias configs; é o que o pedido assume; evita e-mail PII da campanha no Google e spam de convite a cada remarcação). Reabre como follow-up. +- **Q1 — Dono da conta do calendário:** assumido A (conta Google da campanha; service account só escreve). Requer do humano: confirmação de que existe a conta e o `calendarId` (ops). +- **Q2 — Visibilidade:** assumido A (público no link — a equipe é mista Google+Apple; conteúdo já é a agenda oficial sem PII). + +## Referências + +- Intenção: `docs/plans/c114-google-calendar-push-agenda-oficial.md` (gate de produto) +- Canvas: `plan-c114-ui-draft.canvas.tsx` (4 estados da jornada + pill) +- Precedentes: `src/utilities/calendarFeed.ts`, `src/collections/CalendarFeed.ts`, `src/app/(campaign)/campanha/actions/calendarFeed.ts`, `src/components/campaign/activity/CalendarFeedDialog.tsx` + `AgendaFeedChrome.tsx` (C94), `src/collections/Activity.ts` (hooks), `src/collections/SupporterImportBatch.ts` (infra admin-hidden), `src/lib/activityAllDay.ts`, `src/utilities/postgresTransactionLocks.ts` (lido; decidido não usar) +- Testes existentes: `tests/e2e/campaignAgendaFeed.e2e.spec.ts`, `tests/unit/calendarFeed.unit.spec.ts`, `tests/int/calendarFeed.int.spec.ts` diff --git a/docs/plans/c122-c114-followup-testes-estados-espelho.md b/docs/plans/c122-c114-followup-testes-estados-espelho.md new file mode 100644 index 00000000..f459632a --- /dev/null +++ b/docs/plans/c122-c114-followup-testes-estados-espelho.md @@ -0,0 +1,57 @@ +# C122 — C114 follow-up: cobertura de testes dos estados do espelho Google + +Status: rascunho +Atualizado em: 2026-08-11 +Issue: # +Priority: P3 +Model: composer-2.5 +Impeccable: B — superfícies existentes (pill + diálogo da agenda); sem fluxo novo +Appetite: ~0,5 dia eng — fill-in de cobertura, sem mudança de comportamento + +## Intenção + +O C114 entregou o espelho Teqo→Google com três estados derivados na UI +(sincronizado / pausado / não configurado / desativado), mas a cobertura de +testes parou no estado `not-configured` (e2e) e no engine (int com stub). O +que o /simplify deixou de fora, em um lote: + +- **S14 — teste de componente dos chromes:** o auto-retry do + `AgendaGoogleSyncChrome` (uma tentativa por mount quando `paused`) e o + rótulo da pill por status não têm nenhum teste; o repo tem precedente + jsdom+RTL (`wizardExpectedVotesStep.unit.spec.tsx`). +- **S17 — e2e dos outros estados:** `disabled` (Reativar), `paused` (erro + + link "Abrir Google Calendar") e `synced` (link block + copiar + instruções) + nunca exercitados; o FAB mobile ("Agenda da Campanha") também não tem pin. +- **S18 — int da action de desativar:** `setGoogleCalendarSyncDisabled` + (grava `disabledAt`, re-enable limpa e o hook da config re-sincroniza — D7) + sem teste. + +## Aceite + +- Unit (RTL): pill renderiza o rótulo/estado correto por status; auto-retry + dispara UMA vez quando `paused` e nenhuma quando `synced`/`not-configured`. +- E2E: dialog nos estados disabled/paused/synced (seed do doc + `googleCalendarSync` + env key fake — estados determinísticos, sem rede) e + FAB mobile abrindo o sheet. +- Int: disable → `disabledAt` gravado; re-enable → limpo e o + `googleCalendarSyncConfigHook` dispara a reconciliação (spy/observável via + stub) — sem tocar na rede. + +## Direção no codebase + +- `src/components/campaign/activity/AgendaGoogleSyncChrome.tsx` + + `GoogleCalendarSyncDialog.tsx` (alvos do teste de componente). +- `src/app/(campaign)/campanha/actions/googleCalendarSync.ts` (alvo do int). +- `tests/unit/`, `tests/int/`, `tests/e2e/` — precedentes: + `wizardExpectedVotesStep.unit.spec.tsx`, `googleCalendarSync.int.spec.ts`, + `campaignAgendaGoogleSync.e2e.spec.ts`. + +## Fora de escopo + +- Serialização de passes concorrentes (advisory lock no estado) — defer: + self-healing (o próximo pass recobre), gatilho: reclamação de pill preso em + `paused` ou o C115. +- Teste dos estados no modo mobile do pill na linha de filtros (decisão C114: + FAB é a superfície mobile). +- Refactor do chrome de instruções addbyurl (2 call sites) — defer, gatilho: + 3ª superfície de calendário (C115). diff --git a/package.json b/package.json index 660d0c83..0b02849c 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "cross-env": "^7.0.3", "dotenv": "16.4.7", "graphql": "^16.8.1", + "jose": "^6.2.8", "leaflet": "^1.9.4", "lucide-react": "^0.570.0", "next": "15.4.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74e4078a..0314f2ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: graphql: specifier: ^16.8.1 version: 16.12.0 + jose: + specifier: ^6.2.8 + version: 6.2.8 leaflet: specifier: ^1.9.4 version: 1.9.4 @@ -5509,8 +5512,8 @@ packages: jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} - jose@6.2.1: - resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==} + jose@6.2.8: + resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} @@ -9094,7 +9097,7 @@ snapshots: express: 5.2.1 express-rate-limit: 8.3.1(express@5.2.1) hono: 4.12.7 - jose: 6.2.1 + jose: 6.2.8 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -13133,7 +13136,7 @@ snapshots: jose@5.10.0: {} - jose@6.2.1: {} + jose@6.2.8: {} joycon@3.1.1: {} diff --git a/src/app/(campaign)/campanha/(app)/agenda/page.tsx b/src/app/(campaign)/campanha/(app)/agenda/page.tsx index 17a79263..20a3f1f4 100644 --- a/src/app/(campaign)/campanha/(app)/agenda/page.tsx +++ b/src/app/(campaign)/campanha/(app)/agenda/page.tsx @@ -7,9 +7,15 @@ import { listCalendarFeeds, revokeCalendarFeed, } from '@/app/(campaign)/campanha/actions/calendarFeed' +import { + getGoogleCalendarSyncState, + runGoogleCalendarSyncNow, + setGoogleCalendarSyncDisabled, +} from '@/app/(campaign)/campanha/actions/googleCalendarSync' import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda' import { ActivityAgendaFilters } from '@/components/campaign/activity/ActivityAgendaFilters' import { AgendaFeedChrome } from '@/components/campaign/activity/AgendaFeedChrome' +import { AgendaGoogleSyncChrome } from '@/components/campaign/activity/AgendaGoogleSyncChrome' import { AgendaViewChrome } from '@/components/campaign/activity/AgendaViewChrome' import { CampaignListPendingBoundary, @@ -41,10 +47,11 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) { requireCampaignPageActor({ gate: 'staff' }), getPayload({ config }), ]) - const [municipalityOptions, knownTags, feeds] = await Promise.all([ + const [municipalityOptions, knownTags, feeds, googleSyncState] = await Promise.all([ loadMunicipalityOptions(payload, user), loadAccessibleActivityTags(payload, user), listCalendarFeeds().catch(() => []), + getGoogleCalendarSyncState(), ]) const state = restrictActivityAgendaState( resolvedUrl.state, @@ -79,6 +86,18 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) { return revokeCalendarFeed(feedId) }} /> + {/* C114: pill Google no header (após o link de import) + FAB mobile. */} + { + 'use server' + return runGoogleCalendarSyncNow() + }} + onSetDisabled={async (disabled) => { + 'use server' + return setGoogleCalendarSyncDisabled(disabled) + }} + /> => ({ + ...view, + addLink: view.calendarId ? buildGoogleCalendarAddLink(view.calendarId) : null, +}) + +const failure = (message: string): GoogleCalendarSyncActionResult => ({ + ok: false, + message, + status: 'not-configured', + calendarId: null, + lastSyncedAt: null, + lastSuccessAt: null, + lastErrorAt: null, + lastError: null, + addLink: null, +}) + +const requireStaff = async (payload: Payload, actor: CampaignUser): Promise => + reloadStaffActor(payload, actor, GOOGLE_CALENDAR_SYNC_STAFF_ONLY_MESSAGE) + +/** Read model for the agenda pill/dialog — status derived, never stored. */ +export const getGoogleCalendarSyncState = async (): Promise => { + const { payload, actor } = await getCampaignActionContext() + + try { + await requireStaff(payload, actor) + return { ok: true, ...withLinks(await readGoogleCalendarSyncView(payload)) } + } catch (error) { + console.error('getGoogleCalendarSyncState failed', error) + return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE) + } +} + +/** Manual retry / auto-retry (agenda page view when paused). */ +export const runGoogleCalendarSyncNow = async (): Promise => { + const { payload, actor } = await getCampaignActionContext() + + try { + await requireStaff(payload, actor) + await runCampaignCalendarSync(payload, { reason: 'manual' }) + return { ok: true, ...withLinks(await readGoogleCalendarSyncView(payload)) } + } catch (error) { + console.error('runGoogleCalendarSyncNow failed', error) + return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE) + } +} + +/** Staff pauses/resumes the mirror — the afterChange hook reconciles on resume. */ +export const setGoogleCalendarSyncDisabled = async ( + disabled: boolean, +): Promise => { + const { payload, actor } = await getCampaignActionContext() + + try { + const currentActor = await requireStaff(payload, actor) + const doc = await loadGoogleCalendarSyncConfig(payload) + if (doc) { + await payload.update({ + collection: 'googleCalendarSync', + id: doc.id, + data: { disabledAt: disabled ? new Date().toISOString() : null }, + depth: 0, + user: currentActor, + overrideAccess: false, + }) + } + return { ok: true, ...withLinks(await readGoogleCalendarSyncView(payload)) } + } catch (error) { + console.error('setGoogleCalendarSyncDisabled failed', error) + return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE) + } +} diff --git a/src/collections/Activity.ts b/src/collections/Activity.ts index 49fdc4a8..aa7474b7 100644 --- a/src/collections/Activity.ts +++ b/src/collections/Activity.ts @@ -29,6 +29,7 @@ import { eligibleCampaignStaffWhere, } from '@/utilities/campaignAccess' import { systemStampedActorField } from '@/utilities/campaignAuditFields' +import { activityGoogleCalendarSyncHook } from '@/utilities/googleCalendarSyncHooks' const isActivityMutationShortcut = (context: Record | undefined) => context?.mutationKind === 'taskToggle' || context?.mutationKind === 'appendUpdate' @@ -404,7 +405,11 @@ export const Activity: CollectionConfig = { await notifyActivityNeedsAttention(req, doc) return doc }, + // C114: pushes the official calendar mirror to Google (best-effort — + // never throws into the write path; failures land in `paused`). + activityGoogleCalendarSyncHook, ], + afterDelete: [activityGoogleCalendarSyncHook], }, fields: [ { diff --git a/src/collections/GoogleCalendarSync.ts b/src/collections/GoogleCalendarSync.ts new file mode 100644 index 00000000..b3ca252e --- /dev/null +++ b/src/collections/GoogleCalendarSync.ts @@ -0,0 +1,111 @@ +import type { CollectionConfig } from 'payload' + +import { + canCreateGoogleCalendarSync, + canDeleteGoogleCalendarSync, + canReadGoogleCalendarSync, + canSetGoogleCalendarSyncConfigField, + canSetGoogleCalendarSyncDisabled, + canSetGoogleCalendarSyncSystemField, + canUpdateGoogleCalendarSync, +} from '@/utilities/access/googleCalendarSync' +import { googleCalendarSyncConfigHook } from '@/utilities/googleCalendarSyncHooks' + +/** + * C114 — configuration and state of the campaign→Google calendar mirror + * (one row in practice, created/edited by Payload admin in ops). The service + * account private key never lives here — it is `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY` + * in the environment. Changing `calendarId` (or re-enabling) re-runs the full + * reconciliation automatically via `googleCalendarSyncConfigHook` (D7). + */ +export const GoogleCalendarSync: CollectionConfig = { + slug: 'googleCalendarSync', + labels: { + singular: 'Sincronização Google Agenda', + plural: 'Sincronização Google Agenda', + }, + admin: { + group: 'Campanha', + useAsTitle: 'calendarId', + defaultColumns: ['calendarId', 'lastSyncedAt', 'lastSuccessAt', 'lastError'], + }, + access: { + create: canCreateGoogleCalendarSync, + read: canReadGoogleCalendarSync, + update: canUpdateGoogleCalendarSync, + delete: canDeleteGoogleCalendarSync, + }, + hooks: { + afterChange: [googleCalendarSyncConfigHook], + }, + fields: [ + { + name: 'calendarId', + type: 'text', + label: 'ID do calendário (Google)', + index: true, + access: { + create: canSetGoogleCalendarSyncConfigField, + update: canSetGoogleCalendarSyncConfigField, + }, + admin: { + description: + 'O calendarId do calendário "Agenda da Campanha" na conta Google da campanha (ex.: c_…@group.calendar.google.com). O calendário precisa estar público no link e a service account com permissão de edição.', + }, + }, + { + name: 'disabledAt', + type: 'date', + label: 'Desativado em', + index: true, + access: { + create: canSetGoogleCalendarSyncDisabled, + update: canSetGoogleCalendarSyncDisabled, + }, + admin: { + readOnly: true, + description: 'Enquanto preenchido, o Teqo não escreve no Google.', + }, + }, + { + name: 'lastSyncedAt', + type: 'date', + label: 'Última sincronização', + admin: { readOnly: true }, + access: { + create: canSetGoogleCalendarSyncSystemField, + update: canSetGoogleCalendarSyncSystemField, + }, + }, + { + name: 'lastSuccessAt', + type: 'date', + label: 'Última sincronização com sucesso', + admin: { readOnly: true }, + access: { + create: canSetGoogleCalendarSyncSystemField, + update: canSetGoogleCalendarSyncSystemField, + }, + }, + { + name: 'lastErrorAt', + type: 'date', + label: 'Último erro', + admin: { readOnly: true }, + access: { + create: canSetGoogleCalendarSyncSystemField, + update: canSetGoogleCalendarSyncSystemField, + }, + }, + { + name: 'lastError', + type: 'textarea', + label: 'Mensagem do último erro', + admin: { readOnly: true }, + access: { + create: canSetGoogleCalendarSyncSystemField, + update: canSetGoogleCalendarSyncSystemField, + }, + }, + ], +} diff --git a/src/components/campaign/activity/AgendaGoogleSyncChrome.tsx b/src/components/campaign/activity/AgendaGoogleSyncChrome.tsx new file mode 100644 index 00000000..11bafe2b --- /dev/null +++ b/src/components/campaign/activity/AgendaGoogleSyncChrome.tsx @@ -0,0 +1,121 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import type { GoogleCalendarSyncActionResult } from '@/app/(campaign)/campanha/actions/googleCalendarSync' +import { GoogleCalendarSyncDialog } from '@/components/campaign/activity/GoogleCalendarSyncDialog' +import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext' +import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext' +import type { GoogleCalendarSyncStatus } from '@/utilities/googleCalendarSync' + +type AgendaGoogleSyncChromeProps = { + initialState: GoogleCalendarSyncActionResult + onSyncNow: () => Promise + onSetDisabled: (disabled: boolean) => Promise +} + +const PILL_COPY: Record< + GoogleCalendarSyncStatus, + { label: string; dot: string; className: string } +> = { + synced: { + label: 'Google: sincronizado', + dot: 'bg-green-500', + className: 'text-green-700', + }, + paused: { + label: 'Google: pausado — re-tentando', + dot: 'bg-amber-500', + className: 'text-amber-700', + }, + 'not-configured': { + label: 'Google: não configurado', + dot: 'bg-muted-foreground', + className: 'text-muted-foreground', + }, + disabled: { + label: 'Google: desativado', + dot: 'bg-muted-foreground', + className: 'text-muted-foreground', + }, +} + +/** + * C114 — agenda page → app chrome bridge for the Google Calendar mirror + * (same pattern as AgendaFeedChrome/C94): the status pill registers in the + * desktop header cluster (after "Link de import"), the mobile FAB opens the + * same dialog, and a paused mirror auto-retries once on mount. + */ +export const AgendaGoogleSyncChrome = ({ + initialState, + onSyncNow, + onSetDisabled, +}: AgendaGoogleSyncChromeProps) => { + const { setContext } = useCampaignQuickActionContext() + const [open, setOpen] = useState(false) + const [state, setState] = useState(initialState) + const autoRetriedRef = useRef(false) + + const openSync = useCallback(() => setOpen(true), []) + + useEffect(() => { + setContext((current) => ({ ...current, openGoogleCalendarSync: openSync })) + return () => { + setContext((current) => ({ ...current, openGoogleCalendarSync: undefined })) + } + }, [setContext, openSync]) + + // "Re-tenta sem ação manual": a paused mirror tries once when the agenda + // page loads (the staff is operating — the natural retry moment). + const handleSyncNow = useCallback(async () => { + const result = await onSyncNow() + if (result.ok) setState(result) + return result + }, [onSyncNow]) + + useEffect(() => { + if (state.status === 'paused' && !autoRetriedRef.current) { + autoRetriedRef.current = true + void handleSyncNow() + } + }, [state.status, handleSyncNow]) + + const handleSetDisabled = useCallback( + async (disabled: boolean) => { + const result = await onSetDisabled(disabled) + if (result.ok) setState(result) + return result + }, + [onSetDisabled], + ) + + const pill = useMemo(() => { + const copy = PILL_COPY[state.status] + return ( + + ) + }, [state.status, openSync]) + + return ( + <> + {pill} + + + + ) +} diff --git a/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx b/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx new file mode 100644 index 00000000..fb5ad864 --- /dev/null +++ b/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx @@ -0,0 +1,291 @@ +'use client' + +import { useState } from 'react' + +import { CopyIcon, ExternalLinkIcon, RefreshCwIcon } from 'lucide-react' + +import type { GoogleCalendarSyncActionResult } from '@/app/(campaign)/campanha/actions/googleCalendarSync' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Drawer, + DrawerCloseButton, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from '@/components/ui/Drawer' +import { Input } from '@/components/ui/input' +import { useIsMobile } from '@/hooks/use-mobile' +import { formatBahiaDateTimeLabel } from '@/lib/campaignTime' + +type GoogleCalendarSyncDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + state: GoogleCalendarSyncActionResult + onSyncNow: () => Promise + onSetDisabled: (disabled: boolean) => Promise +} + +const NOT_CONFIGURED_RUNBOOK: readonly string[] = [ + 'Crie o calendário "Agenda da Campanha" na conta Google da campanha.', + 'No Google, compartilhe o calendário como público (qualquer pessoa com o link pode ver).', + 'Dê permissão de edição ("fazer alterações em eventos") à service account do Teqo.', + 'Um administrador configura o ID do calendário no Painel e a chave da service account nas variáveis de ambiente.', +] + +const copyIcon = + +/** + * C114 — Google Calendar mirror dialog (estados da jornada do canvas: + * não configurado / sincronizado / pausado / desativado). Bottom sheet on + * mobile, dialog on desktop — same chrome as the iCal feed dialog (C94). + */ +export const GoogleCalendarSyncDialog = ({ + open, + onOpenChange, + state, + onSyncNow, + onSetDisabled, +}: GoogleCalendarSyncDialogProps) => { + const isMobile = useIsMobile() + const [isBusy, setIsBusy] = useState(false) + const [copied, setCopied] = useState(false) + const [actionError, setActionError] = useState(null) + + const resetTransientState = () => { + setCopied(false) + setActionError(null) + } + + const handleCopy = async () => { + if (!state.addLink) return + await navigator.clipboard.writeText(state.addLink) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const handleSyncNow = async () => { + if (isBusy) return + setIsBusy(true) + setActionError(null) + try { + const result = await onSyncNow() + if (!result.ok) setActionError(result.message ?? 'Não foi possível sincronizar.') + } finally { + setIsBusy(false) + } + } + + const handleSetDisabled = async (disabled: boolean) => { + if (isBusy) return + setIsBusy(true) + setActionError(null) + try { + const result = await onSetDisabled(disabled) + if (!result.ok) setActionError(result.message ?? 'Não foi possível atualizar.') + } finally { + setIsBusy(false) + } + } + + const linkBlock = state.addLink ? ( +
+ +
+ + +
+ {copied && ( +

+ Link copiado! +

+ )} +

+ Quem segue o calendário recebe aviso conforme as próprias configurações do Google. +

+
+ ) : null + + const instructions = ( +
+

Como adicionar ao Google Calendar:

+
    +
  1. Abra o Google Calendar no computador
  2. +
  3. + No menu lateral, clique em {'"'} + {'+'} + {'"'} ao lado de {'"'}Outras agendas{'"'} +
  4. +
  5. + Selecione {'"'}Por URL{'"'} +
  6. +
  7. + Cole o link acima e clique em {'"'}Adicionar agenda{'"'} +
  8. +
+

+ Apple Calendar e Outlook assinam a URL iCal pública do mesmo calendário. +

+
+ ) + + const syncButton = ( + + ) + + let body: React.ReactNode + + if (state.status === 'not-configured') { + body = ( +
+
+

Ainda não configurado

+

+ O Teqo segue 100% funcional — a agenda do Teqo nunca depende do Google. Para ativar o + espelho, um administrador precisa concluir a configuração: +

+
    + {NOT_CONFIGURED_RUNBOOK.map((step) => ( +
  1. {step}
  2. + ))} +
+
+
+ ) + } else if (state.status === 'disabled') { + body = ( +
+
+ A sincronização está desativada. A agenda do Teqo continua funcionando normalmente; nada é + enviado ao Google enquanto estiver desativada. +
+ {actionError &&

{actionError}

} +
+ +
+
+ ) + } else { + const paused = state.status === 'paused' + body = ( +
+ {paused ? ( +
+

Pausado — re-tentando

+

+ A última tentativa de sincronização falhou. O Teqo continua funcionando; o espelho é + tentado de novo automaticamente ao salvar uma atividade ou ao abrir esta página. +

+ {state.lastErrorAt ? ( +

+ Última tentativa: {formatBahiaDateTimeLabel(state.lastErrorAt)} —{' '} + {state.lastError ?? 'erro desconhecido'} +

+ ) : null} +
+ ) : ( +

+ Sincronizado — as mudanças da agenda já refletiram no Google. + {state.lastSuccessAt + ? ` Última sincronização: ${formatBahiaDateTimeLabel(state.lastSuccessAt)}.` + : ''} +

+ )} + + {linkBlock} + {instructions} + + {actionError &&

{actionError}

} + +
+ {paused ? ( + + ) : null} + {syncButton} + +
+
+ ) + } + + const description = + 'Compromissos do Teqo refletem no calendário Google compartilhado em minutos. Quem segue recebe aviso conforme as próprias configurações.' + + const handleOpenChange = (next: boolean) => { + if (!next) resetTransientState() + onOpenChange(next) + } + + if (isMobile) { + return ( + + + + Agenda da Campanha no Google + {description} + +
{body}
+ + Fechar + +
+
+ ) + } + + return ( + + + + Agenda da Campanha no Google + {description} + + {body} + + + ) +} diff --git a/src/lib/activityDescription.ts b/src/lib/activityDescription.ts new file mode 100644 index 00000000..98f27b98 --- /dev/null +++ b/src/lib/activityDescription.ts @@ -0,0 +1,21 @@ +/** + * Shared description parts for campaign activities — the iCal feed and the + * C114 Google mirror render the same facts (municipality, locality, tags, + * deputy flag) with their own consumers' separators/escapes. No PII: no + * leadership names, phones or emails. + */ +import type { Activity } from '@/payload-types' + +export type ActivityDescriptionSource = Pick + +export const buildActivityDescriptionParts = ( + activity: ActivityDescriptionSource, + municipalityName?: string, +): string[] => { + const parts: string[] = [] + if (municipalityName) parts.push(`Município: ${municipalityName}`) + if (activity.locality) parts.push(`Local: ${activity.locality}`) + if (activity.tags?.length) parts.push(`Tags: ${activity.tags.join(', ')}`) + if (activity.deputyPresent) parts.push('Deputado presente') + return parts +} diff --git a/src/lib/activityQuickActions.ts b/src/lib/activityQuickActions.ts index 80cada99..957b2183 100644 --- a/src/lib/activityQuickActions.ts +++ b/src/lib/activityQuickActions.ts @@ -1,4 +1,12 @@ -import { Calendar, ClipboardList, ListChecks, MapPinned, Pencil, Plus } from 'lucide-react' +import { + Calendar, + CalendarCog, + ClipboardList, + ListChecks, + MapPinned, + Pencil, + Plus, +} from 'lucide-react' import { CAMPAIGN_WIZARD_ACTION_SLUGS, @@ -42,6 +50,17 @@ const listQuickActions = (context: CampaignQuickActionContext): readonly Campaig }, ] : []), + ...(context.openGoogleCalendarSync + ? [ + { + id: 'google-calendar-mirror', + label: 'Agenda da Campanha', + icon: CalendarCog, + description: 'Espelhar a agenda oficial no Google Calendar da campanha', + onAction: context.openGoogleCalendarSync, + }, + ] + : []), { id: 'plan-tour', label: 'Planejar giro', diff --git a/src/lib/campaignQuickActionContext.ts b/src/lib/campaignQuickActionContext.ts index 68420972..eb06ea72 100644 --- a/src/lib/campaignQuickActionContext.ts +++ b/src/lib/campaignQuickActionContext.ts @@ -16,6 +16,8 @@ export type CampaignQuickActionContext = { * crossed server→client. */ openCalendarFeed?: () => void + /** C114 — opens the Google Calendar mirror dialog (same bridge as above). */ + openGoogleCalendarSync?: () => void } export const emptyCampaignQuickActionContext = (): CampaignQuickActionContext => ({}) diff --git a/src/lib/googleCalendarEventMapping.ts b/src/lib/googleCalendarEventMapping.ts new file mode 100644 index 00000000..011b61ab --- /dev/null +++ b/src/lib/googleCalendarEventMapping.ts @@ -0,0 +1,189 @@ +/** + * C114 — pure mapping between campaign activities and Google Calendar events. + * + * The Google event id is DETERMINISTIC (`teqo` + base32hex of the activity + * id): Google ids only allow base32hex characters (lowercase a–v, digits 0–9) + * — no underscores, no uppercase — and the deterministic id makes the whole + * reconciliation stateless (upsert by id, content-compare to decide updates). + * Teqo stays the single source of truth; canceled activities are excluded by + * the engine (their Google event is deleted), never rendered in Google. + * + * This module is pure and client-safe; only the engine/client in + * `src/utilities/googleCalendarSync.ts` touches the network. + */ +import type { Activity } from '@/payload-types' + +import { allDayCivilDateOf, allDayExclusiveEndDate } from '@/lib/activityAllDay' +import { buildActivityDescriptionParts } from '@/lib/activityDescription' + +const GOOGLE_EVENT_ID_PREFIX = 'teqo' + +/** RFC 2938 base32hex alphabet (0–9, a–v) — the only charset Google event ids accept. */ +const BASE32HEX_ALPHABET = '0123456789abcdefghijklmnopqrstuv' + +/** + * Brazil standard time is fixed UTC−03:00 (no DST since 2019), so the offset + * is a constant — the same invariant `campaignTime` builds on. + */ +const BAHIA_FIXED_OFFSET_HOURS = -3 +const BAHIA_FIXED_OFFSET_SUFFIX = '-03:00' + +/** + * Google requires `end` strictly after `start` on timed events; an activity + * without `endAt` (a point-in-time commitment) becomes a 1-hour event — the + * iCal feed mirrors this as DTEND = DTSTART, which Google cannot represent. + */ +const DEFAULT_TIMED_EVENT_DURATION_MINUTES = 60 + +export const googleEventIdForActivity = (activityId: number): string => { + if (!Number.isInteger(activityId) || activityId < 0) { + throw new Error('ID de atividade inválido para evento do Google.') + } + + let remainder = activityId + let encoded = '' + do { + encoded = BASE32HEX_ALPHABET[remainder % 32] + encoded + remainder = Math.floor(remainder / 32) + } while (remainder > 0) + + return `${GOOGLE_EVENT_ID_PREFIX}${encoded}` +} + +/** Inverse of `googleEventIdForActivity` — null for foreign/invalid ids. */ +export const decodeGoogleEventActivityId = (eventId: string): number | null => { + if (!eventId.startsWith(GOOGLE_EVENT_ID_PREFIX)) return null + const encoded = eventId.slice(GOOGLE_EVENT_ID_PREFIX.length) + if (encoded.length === 0) return null + + let value = 0 + for (const char of encoded) { + const digit = BASE32HEX_ALPHABET.indexOf(char) + if (digit === -1) return null + value = value * 32 + digit + if (!Number.isSafeInteger(value)) return null + } + return value +} + +/** + * UTC ISO instant → RFC 3339 dateTime in Bahia wall time with the fixed + * −03:00 offset (what the Calendar API expects; the timeZone field stays + * unnecessary because the offset is explicit). + */ +const formatBahiaOffsetDateTime = (iso: string): string => { + const shifted = new Date(new Date(iso).getTime() + BAHIA_FIXED_OFFSET_HOURS * 3_600_000) + const parts = shifted.toISOString().slice(0, 19).split('T') + const [date, time] = parts + const [year, month, day] = date.split('-') + const [hour, minute, second] = time.split(':') + return `${year}-${month}-${day}T${hour}:${minute}:${second}${BAHIA_FIXED_OFFSET_SUFFIX}` +} + +const addMinutes = (iso: string, minutes: number): string => + new Date(new Date(iso).getTime() + minutes * 60_000).toISOString() + +export const activityMunicipalityIdOf = ( + municipality: Activity['municipality'], +): number | undefined => (typeof municipality === 'number' ? municipality : municipality?.id) + +export type GoogleCalendarEventPayload = { + id: string + summary: string + description: string + location?: string + start: { dateTime: string } | { date: string } + end: { dateTime: string } | { date: string } +} + +/** + * Event description without unnecessary PII: municipality, locality, tags and + * the deputy flag — the same parts the iCal feed renders, joined with real + * newlines for Google. No leadership names, no phones, no emails. + */ +export const buildGoogleCalendarDescription = ( + activity: Pick, + municipalityName?: string, +): string => buildActivityDescriptionParts(activity, municipalityName).join('\n') + +export const buildGoogleEventPayload = ( + activity: Pick< + Activity, + 'id' | 'title' | 'startAt' | 'endAt' | 'allDay' | 'locality' | 'tags' | 'deputyPresent' + >, + municipalityName?: string, +): GoogleCalendarEventPayload => { + // C14 guarantees startAt beyond draft; a null start has no Google shape — + // fail loudly in the mapping (the engine never feeds one: the window query + // only returns dated activities). + if (!activity.startAt) { + throw new Error('Atividade sem data de início não pode virar evento do Google.') + } + + const summary = municipalityName ? `[${municipalityName}] ${activity.title}` : activity.title + const description = buildGoogleCalendarDescription(activity, municipalityName) + + const allDay = Boolean(activity.allDay) + const start = allDay + ? { date: allDayCivilDateOf(activity.startAt) } + : { dateTime: formatBahiaOffsetDateTime(activity.startAt) } + const end = allDay + ? { date: allDayExclusiveEndDate(activity.endAt ?? activity.startAt) } + : { + dateTime: formatBahiaOffsetDateTime( + activity.endAt ?? addMinutes(activity.startAt, DEFAULT_TIMED_EVENT_DURATION_MINUTES), + ), + } + + const locality = activity.locality?.trim() + const location = locality || municipalityName || undefined + + return { + id: googleEventIdForActivity(activity.id), + summary, + description, + ...(location ? { location } : {}), + start, + end, + } +} + +/** The remote shape the Calendar API echoes on list/insert — a narrow view of it. */ +export type GoogleRemoteEvent = { + id?: string + summary?: string + description?: string + location?: string + start?: { dateTime?: string; date?: string; timeZone?: string } + end?: { dateTime?: string; date?: string; timeZone?: string } +} + +const startEndInstantEquals = ( + remote: { dateTime?: string; date?: string } | undefined, + wanted: GoogleCalendarEventPayload['start'], +): boolean => { + if (!remote) return false + if ('dateTime' in wanted) { + // Compare absolute instants: the calendar's own timezone may echo the + // dateTime with a different offset — the instant is what we control. + const remoteMs = Date.parse(remote.dateTime ?? '') + const wantedMs = Date.parse(wanted.dateTime) + return !Number.isNaN(remoteMs) && !Number.isNaN(wantedMs) && remoteMs === wantedMs + } + return remote.date === wanted.date +} + +/** + * True when the remote event already carries exactly what we would write — + * the decision rule that makes repeated reconciliation passes converge + * without touching Google (no update churn, no modified-date spam). + */ +export const googleEventContentEquals = ( + remote: GoogleRemoteEvent, + payload: GoogleCalendarEventPayload, +): boolean => + (remote.summary ?? '') === payload.summary && + (remote.description ?? '') === payload.description && + (remote.location ?? '') === (payload.location ?? '') && + startEndInstantEquals(remote.start, payload.start) && + startEndInstantEquals(remote.end, payload.end) diff --git a/src/lib/googleCalendarLink.ts b/src/lib/googleCalendarLink.ts new file mode 100644 index 00000000..4be4d901 --- /dev/null +++ b/src/lib/googleCalendarLink.ts @@ -0,0 +1,28 @@ +/** + * C114 — public "add this calendar" links for the campaign's shared Google + * calendar. The calendar itself is created in the campaign's Google account + * (never by a service account — Google's recommendation) and shared publicly + * by link (ops runbook); these URLs are what the team receives: + * + * - Google Calendar: the `cid` webcal URL (the established add-by-URL flow the + * iCal feed dialog already uses, `calendar/r/settings/addbyurl`). + * - Apple Calendar / Outlook: subscribe straight to the webcal URL the Google + * calendar exposes for public calendars. + * + * Both require the calendar to be public in the owner's sharing settings — + * the link IS the credential, same model as the iCal feed secret. + */ +const GOOGLE_CALENDAR_ADD_BASE = 'https://calendar.google.com/calendar/r' +const GOOGLE_CALENDAR_ICAL_BASE = 'https://calendar.google.com/calendar/ical' + +export const buildGoogleCalendarWebcalUrl = (calendarId: string): string => + `webcal://calendar.google.com/calendar/ical/${encodeURIComponent(calendarId)}/public/basic.ics` + +export const buildGoogleCalendarAddLink = (calendarId: string): string => { + const webcal = buildGoogleCalendarWebcalUrl(calendarId) + return `${GOOGLE_CALENDAR_ADD_BASE}?cid=${encodeURIComponent(webcal)}` +} + +/** The public iCal URL of the shared calendar — same `cid` target, plain HTTPS. */ +export const buildGoogleCalendarPublicIcalUrl = (calendarId: string): string => + `${GOOGLE_CALENDAR_ICAL_BASE}/${encodeURIComponent(calendarId)}/public/basic.ics` diff --git a/src/migrations/20260811_133332_add_google_calendar_sync.ts b/src/migrations/20260811_133332_add_google_calendar_sync.ts new file mode 100644 index 00000000..0593b765 --- /dev/null +++ b/src/migrations/20260811_133332_add_google_calendar_sync.ts @@ -0,0 +1,43 @@ +import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres' + +/** + * C114 — `googleCalendarSync` collection: the configuration/state row of the + * campaign→Google calendar mirror. Hand-written because `migrate:create` + * re-emitted pre-existing `campaign_user.contact_id` / + * `leadership_rels.campaign_user_id` statements (already applied by + * `20260809_204728_add_campaign_user_contact_and_leadership_advisors` — those + * would fail with "column already exists" on apply; kept out deliberately). + */ +export async function up({ db, payload, req }: MigrateUpArgs): Promise { + await db.execute(sql` + CREATE TABLE "google_calendar_sync" ( + "id" serial PRIMARY KEY NOT NULL, + "calendar_id" varchar, + "disabled_at" timestamp(3) with time zone, + "last_synced_at" timestamp(3) with time zone, + "last_success_at" timestamp(3) with time zone, + "last_error_at" timestamp(3) with time zone, + "last_error" varchar, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + + CREATE INDEX "google_calendar_sync_calendar_id_idx" ON "google_calendar_sync" USING btree ("calendar_id"); + CREATE INDEX "google_calendar_sync_disabled_at_idx" ON "google_calendar_sync" USING btree ("disabled_at"); + CREATE INDEX "google_calendar_sync_updated_at_idx" ON "google_calendar_sync" USING btree ("updated_at"); + CREATE INDEX "google_calendar_sync_created_at_idx" ON "google_calendar_sync" USING btree ("created_at"); + + ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "google_calendar_sync_id" integer; + ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_google_calendar_sync_fk" FOREIGN KEY ("google_calendar_sync_id") REFERENCES "public"."google_calendar_sync"("id") ON DELETE cascade ON UPDATE no action; + CREATE INDEX "payload_locked_documents_rels_google_calendar_sync_id_idx" ON "payload_locked_documents_rels" USING btree ("google_calendar_sync_id"); + `) +} + +export async function down({ db, payload, req }: MigrateDownArgs): Promise { + await db.execute(sql` + ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_google_calendar_sync_fk"; + DROP INDEX "payload_locked_documents_rels_google_calendar_sync_id_idx"; + ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "google_calendar_sync_id"; + DROP TABLE "google_calendar_sync" CASCADE; + `) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index a6d78dfe..b2f81972 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -48,6 +48,7 @@ import * as migration_20260808_184113_remodel_activity_responsible from './20260 import * as migration_20260809_204728_add_campaign_user_contact_and_leadership_advisors from './20260809_204728_add_campaign_user_contact_and_leadership_advisors'; import * as migration_20260810_010844_add_activity_all_day from './20260810_010844_add_activity_all_day'; import * as migration_20260811_111828_add_contact_phones_array from './20260811_111828_add_contact_phones_array'; +import * as migration_20260811_133332_add_google_calendar_sync from './20260811_133332_add_google_calendar_sync'; export const migrations = [ { @@ -298,6 +299,11 @@ export const migrations = [ { up: migration_20260811_111828_add_contact_phones_array.up, down: migration_20260811_111828_add_contact_phones_array.down, - name: '20260811_111828_add_contact_phones_array' + name: '20260811_111828_add_contact_phones_array', + }, + { + up: migration_20260811_133332_add_google_calendar_sync.up, + down: migration_20260811_133332_add_google_calendar_sync.down, + name: '20260811_133332_add_google_calendar_sync', }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index 3b72eaa0..37bf8da5 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -87,6 +87,7 @@ export interface Config { municipalityUpdate: MunicipalityUpdate; activity: Activity; calendarFeed: CalendarFeed; + googleCalendarSync: GoogleCalendarSync; electionTally: ElectionTally; electionCandidateVote: ElectionCandidateVote; electionCandidate: ElectionCandidate; @@ -127,6 +128,7 @@ export interface Config { municipalityUpdate: MunicipalityUpdateSelect | MunicipalityUpdateSelect; activity: ActivitySelect | ActivitySelect; calendarFeed: CalendarFeedSelect | CalendarFeedSelect; + googleCalendarSync: GoogleCalendarSyncSelect | GoogleCalendarSyncSelect; electionTally: ElectionTallySelect | ElectionTallySelect; electionCandidateVote: ElectionCandidateVoteSelect | ElectionCandidateVoteSelect; electionCandidate: ElectionCandidateSelect | ElectionCandidateSelect; @@ -890,6 +892,27 @@ export interface CalendarFeed { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "googleCalendarSync". + */ +export interface GoogleCalendarSync { + id: number; + /** + * O calendarId do calendário "Agenda da Campanha" na conta Google da campanha (ex.: c_…@group.calendar.google.com). O calendário precisa estar público no link e a service account com permissão de edição. + */ + calendarId?: string | null; + /** + * Enquanto preenchido, o Teqo não escreve no Google. + */ + disabledAt?: string | null; + lastSyncedAt?: string | null; + lastSuccessAt?: string | null; + lastErrorAt?: string | null; + lastError?: string | null; + updatedAt: string; + createdAt: string; +} /** * Totais oficiais TSE por município e zona (dado público). * @@ -1359,6 +1382,10 @@ export interface PayloadLockedDocument { relationTo: 'calendarFeed'; value: number | CalendarFeed; } | null) + | ({ + relationTo: 'googleCalendarSync'; + value: number | GoogleCalendarSync; + } | null) | ({ relationTo: 'electionTally'; value: number | ElectionTally; @@ -1858,6 +1885,20 @@ export interface CalendarFeedSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "googleCalendarSync_select". + */ +export interface GoogleCalendarSyncSelect { + calendarId?: T; + disabledAt?: T; + lastSyncedAt?: T; + lastSuccessAt?: T; + lastErrorAt?: T; + lastError?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "electionTally_select". @@ -2423,6 +2464,7 @@ export interface TaskCreateCollectionExport { | 'municipalityUpdate' | 'activity' | 'calendarFeed' + | 'googleCalendarSync' | 'electionTally' | 'electionCandidateVote' | 'electionCandidate' diff --git a/src/payload.config.ts b/src/payload.config.ts index e0878d68..91297270 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -22,6 +22,7 @@ import { Contact } from './collections/Contact' import { ElectionCandidate } from './collections/ElectionCandidate' import { ElectionCandidateVote } from './collections/ElectionCandidateVote' import { ElectionTally } from './collections/ElectionTally' +import { GoogleCalendarSync } from './collections/GoogleCalendarSync' import { Leadership } from './collections/Leadership' import { Media } from './collections/Media' import { Municipality } from './collections/Municipality' @@ -107,6 +108,7 @@ export default buildConfig({ MunicipalityUpdate, Activity, CalendarFeed, + GoogleCalendarSync, ElectionTally, ElectionCandidateVote, ElectionCandidate, diff --git a/src/utilities/access/googleCalendarSync.ts b/src/utilities/access/googleCalendarSync.ts new file mode 100644 index 00000000..f7479b56 --- /dev/null +++ b/src/utilities/access/googleCalendarSync.ts @@ -0,0 +1,46 @@ +import type { Access, FieldAccess } from 'payload' + +import { + getFreshCampaignUser, + isCampaignStaff, + isPayloadAdmin, + payloadAdminOnly, +} from '@/utilities/access/shared' + +/** + * C114 — access for the `googleCalendarSync` configuration/state collection. + * Staff (coordinator/advisor/candidate) read the sync state (pill) and toggle + * `disabledAt`; only Payload admin sets the operational `calendarId`; the + * system state fields are written solely by the sync engine through an + * admin bypass — never by a user path. + */ + +export const canReadGoogleCalendarSync: Access = async ({ req }) => { + if (isPayloadAdmin(req.user)) return true + return isCampaignStaff(await getFreshCampaignUser(req)) +} + +export const canCreateGoogleCalendarSync: Access = async ({ req }) => { + if (isPayloadAdmin(req.user)) return true + return isCampaignStaff(await getFreshCampaignUser(req)) +} + +export const canUpdateGoogleCalendarSync: Access = async ({ req }) => { + if (isPayloadAdmin(req.user)) return true + return isCampaignStaff(await getFreshCampaignUser(req)) +} + +export const canDeleteGoogleCalendarSync: Access = payloadAdminOnly + +/** `calendarId` — operational identity, admin-only (env-level trust). */ +export const canSetGoogleCalendarSyncConfigField: FieldAccess = ({ req }) => + isPayloadAdmin(req.user) + +/** `disabledAt` — staff may pause/resume the sync from the agenda. */ +export const canSetGoogleCalendarSyncDisabled: FieldAccess = async ({ req }) => { + if (isPayloadAdmin(req.user)) return true + return isCampaignStaff(await getFreshCampaignUser(req)) +} + +/** System state fields — only the sync engine writes them (overrideAccess). */ +export const canSetGoogleCalendarSyncSystemField: FieldAccess = () => false diff --git a/src/utilities/calendarFeed.ts b/src/utilities/calendarFeed.ts index d7cc4540..13d86455 100644 --- a/src/utilities/calendarFeed.ts +++ b/src/utilities/calendarFeed.ts @@ -3,6 +3,7 @@ import 'server-only' import { createHash } from 'node:crypto' import { allDayCivilDateOf, allDayExclusiveEndDate } from '@/lib/activityAllDay' +import { buildActivityDescriptionParts } from '@/lib/activityDescription' import type { Activity, CalendarFeed } from '@/payload-types' import { advisorMunicipalityScopeWhere } from '@/utilities/access/shared' import type { Payload, Where } from 'payload' @@ -24,15 +25,9 @@ const escapeICalText = (value: string): string => const formatICalDate = (isoString: string): string => isoString.replace(/[-:]/g, '').replace(/\.\d{3}/, '') -const buildActivityDescription = (activity: Activity, municipalityName?: string): string => { - const parts: string[] = [] - if (municipalityName) parts.push(`Município: ${municipalityName}`) - if (activity.locality) parts.push(`Local: ${activity.locality}`) - if (activity.tags?.length) parts.push(`Tags: ${activity.tags.join(', ')}`) - if (activity.deputyPresent) parts.push('Deputado presente') - if (activity.status === 'cancelado') parts.push('CANCELADO') - return parts.join('\\n') -} +const buildActivityDescription = (activity: Activity, municipalityName?: string): string => + // iCal escapes newlines as literal `\n` (backslash + n). + buildActivityDescriptionParts(activity, municipalityName).join('\\n') export const generateICalFeed = ( activities: Activity[], @@ -144,7 +139,8 @@ export const buildICalFeedResponse = ( }) } -const buildFeedDateRange = (): { rangeStart: string; rangeEnd: string } => { +/** The shared activity window (past lookback + future lookahead) of the feed and the Google mirror. */ +export const buildActivityWindowRange = (): { rangeStart: string; rangeEnd: string } => { const now = new Date() const rangeStart = new Date(now) rangeStart.setDate(rangeStart.getDate() - FEED_LOOKBACK_DAYS) @@ -157,24 +153,32 @@ const buildFeedDateRange = (): { rangeStart: string; rangeEnd: string } => { } } +/** + * The activity window (past lookback + future lookahead) and its base where + * clauses — shared by the iCal feed and the C114 Google mirror so both + * surfaces keep the SAME horizon by definition. The feed adds its per-feed + * filters (municipality/deputy/tag + advisor scope) on top. + */ +export const buildActivityWindowWhereClauses = (rangeStart: string, rangeEnd: string): Where[] => [ + { startAt: { less_than: rangeEnd } }, + { + or: [ + { endAt: { greater_than: rangeStart } }, + { + and: [{ endAt: { exists: false } }, { startAt: { greater_than_equal: rangeStart } }], + }, + ], + }, + { status: { not_equals: 'cancelado' } }, +] + const buildFeedWhere = ( feed: CalendarFeed, rangeStart: string, rangeEnd: string, accessibleMunicipalityIds: number[] | null, ): Where => { - const filters: Where[] = [ - { startAt: { less_than: rangeEnd } }, - { - or: [ - { endAt: { greater_than: rangeStart } }, - { - and: [{ endAt: { exists: false } }, { startAt: { greater_than_equal: rangeStart } }], - }, - ], - }, - { status: { not_equals: 'cancelado' } }, - ] + const filters: Where[] = buildActivityWindowWhereClauses(rangeStart, rangeEnd) if (feed.filterMunicipality) { const municipalityId = @@ -208,7 +212,7 @@ export const loadFeedActivities = async ( feed: CalendarFeed, accessibleMunicipalityIds: number[] | null, ): Promise => { - const { rangeStart, rangeEnd } = buildFeedDateRange() + const { rangeStart, rangeEnd } = buildActivityWindowRange() const where = buildFeedWhere(feed, rangeStart, rangeEnd, accessibleMunicipalityIds) // Intentional admin bypass: access is already enforced at the feed level diff --git a/src/utilities/googleCalendarClient.ts b/src/utilities/googleCalendarClient.ts new file mode 100644 index 00000000..fa928c87 --- /dev/null +++ b/src/utilities/googleCalendarClient.ts @@ -0,0 +1,206 @@ +import 'server-only' + +import { importPKCS8, SignJWT } from 'jose' + +import type { GoogleRemoteEvent } from '@/lib/googleCalendarEventMapping' + +/** + * C114 — thin Calendar API v3 client authenticated as the campaign's service + * account (JWT assertion → OAuth2 token → REST). Only the four event endpoints + * the reconciliation engine needs; the token is cached per runtime instance + * and re-minted on 401. The `fetch` implementation is injectable so tests can + * stub the transport without network. + */ + +export const GOOGLE_CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar' + +const GOOGLE_TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token' +const GOOGLE_CALENDAR_API_BASE = 'https://www.googleapis.com/calendar/v3' +const GOOGLE_TOKEN_TTL_SECONDS = 3600 +const TOKEN_REFRESH_LEAD_SECONDS = 60 +const MAX_LIST_PAGE_SIZE = 2500 +/** + * Every outbound call carries a hard timeout so a hanging Google never holds + * the activity write path (the afterChange hook awaits the sync) — Teqo's + * availability cannot depend on Google's. + */ +const REQUEST_TIMEOUT_MS = 15_000 + +export type { GoogleRemoteEvent } from '@/lib/googleCalendarEventMapping' + +export type GoogleCalendarCredentials = { + clientEmail: string + privateKey: string +} + +/** Typed transport failure — safe message, never echoes credentials or bodies. */ +export class GoogleCalendarApiError extends Error { + readonly status: number + + constructor(message: string, status: number) { + super(message) + this.name = 'GoogleCalendarApiError' + this.status = status + } +} + +export type GoogleCalendarClient = { + listEvents: ( + calendarId: string, + range: { timeMin: string; timeMax: string }, + ) => Promise + insertEvent: (calendarId: string, event: GoogleRemoteEvent) => Promise + updateEvent: (calendarId: string, eventId: string, event: GoogleRemoteEvent) => Promise + deleteEvent: (calendarId: string, eventId: string) => Promise +} + +export type FetchLike = typeof fetch + +/** + * The signed JWT assertion for the OAuth2 token exchange (RFC 7523). Exported + * for tests — `createGoogleCalendarClient` mints and exchanges it internally. + */ +export const buildServiceAccountAssertion = async ( + credentials: GoogleCalendarCredentials, + nowSeconds: number, +): Promise => { + const key = await importPKCS8(credentials.privateKey, 'RS256') + return new SignJWT({ scope: GOOGLE_CALENDAR_SCOPE }) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setIssuer(credentials.clientEmail) + .setAudience(GOOGLE_TOKEN_ENDPOINT) + .setIssuedAt(nowSeconds) + .setExpirationTime(nowSeconds + GOOGLE_TOKEN_TTL_SECONDS) + .sign(key) +} + +export const createGoogleCalendarClient = ( + credentials: GoogleCalendarCredentials, + fetchImpl: FetchLike = fetch, +): GoogleCalendarClient => { + let cachedToken: { value: string; expiresAtMs: number } | null = null + + const requestAccessToken = async (): Promise => { + const nowSeconds = Math.floor(Date.now() / 1000) + const assertion = await buildServiceAccountAssertion(credentials, nowSeconds) + + const response = await fetchImpl(GOOGLE_TOKEN_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion, + }).toString(), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + if (!response.ok) { + throw new GoogleCalendarApiError( + `Não foi possível autenticar no Google (HTTP ${response.status}).`, + response.status, + ) + } + + const body = (await response.json()) as { access_token?: string; expires_in?: number } + if (!body.access_token) { + throw new GoogleCalendarApiError('Resposta de autenticação do Google sem token.', 502) + } + + cachedToken = { + value: body.access_token, + expiresAtMs: Date.now() + (body.expires_in ?? GOOGLE_TOKEN_TTL_SECONDS) * 1000, + } + return cachedToken.value + } + + const getAccessToken = async (): Promise => { + if (cachedToken && cachedToken.expiresAtMs > Date.now() + TOKEN_REFRESH_LEAD_SECONDS * 1000) { + return cachedToken.value + } + return requestAccessToken() + } + + const apiFetch = async ( + url: string, + init: RequestInit = {}, + retried = false, + ): Promise => { + const token = await getAccessToken() + const response = await fetchImpl(url, { + ...init, + headers: { ...init.headers, Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + + // A 401 usually means the cached token expired server-side — re-mint once. + if (response.status === 401 && !retried) { + cachedToken = null + return apiFetch(url, init, true) + } + if (!response.ok) { + throw new GoogleCalendarApiError( + `Google Calendar API respondeu HTTP ${response.status}.`, + response.status, + ) + } + return response + } + + const eventsUrl = (calendarId: string, ...parts: string[]): string => + `${GOOGLE_CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events${parts + .map((part) => `/${encodeURIComponent(part)}`) + .join('')}` + + const listEvents: GoogleCalendarClient['listEvents'] = async (calendarId, range) => { + const events: GoogleRemoteEvent[] = [] + let pageToken: string | undefined + + do { + const params = new URLSearchParams({ + timeMin: range.timeMin, + timeMax: range.timeMax, + maxResults: String(MAX_LIST_PAGE_SIZE), + singleEvents: 'true', + }) + if (pageToken) params.set('pageToken', pageToken) + + const response = await apiFetch(`${eventsUrl(calendarId)}?${params.toString()}`) + const body = (await response.json()) as { + items?: GoogleRemoteEvent[] + nextPageToken?: string + } + events.push(...(body.items ?? [])) + pageToken = body.nextPageToken + } while (pageToken) + + return events + } + + const insertEvent: GoogleCalendarClient['insertEvent'] = async (calendarId, event) => { + try { + await apiFetch(eventsUrl(calendarId), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }) + } catch (error) { + // 409 = the deterministic id already exists (a concurrent pass inserted + // it between our list and this insert) — the idempotent end state, not + // a failure; the next pass converges on the content. + if (!(error instanceof GoogleCalendarApiError && error.status === 409)) throw error + } + } + + const updateEvent: GoogleCalendarClient['updateEvent'] = async (calendarId, eventId, event) => { + await apiFetch(eventsUrl(calendarId, eventId), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(event), + }) + } + + const deleteEvent: GoogleCalendarClient['deleteEvent'] = async (calendarId, eventId) => { + await apiFetch(eventsUrl(calendarId, eventId), { method: 'DELETE' }) + } + + return { listEvents, insertEvent, updateEvent, deleteEvent } +} diff --git a/src/utilities/googleCalendarSync.ts b/src/utilities/googleCalendarSync.ts new file mode 100644 index 00000000..17529d35 --- /dev/null +++ b/src/utilities/googleCalendarSync.ts @@ -0,0 +1,494 @@ +import 'server-only' + +import type { Activity, GoogleCalendarSync as GoogleCalendarSyncDoc } from '@/payload-types' +import type { Payload, PayloadRequest } from 'payload' + +import { + activityMunicipalityIdOf, + buildGoogleEventPayload, + decodeGoogleEventActivityId, + googleEventContentEquals, + googleEventIdForActivity, +} from '@/lib/googleCalendarEventMapping' +import { + buildActivityWindowRange, + buildActivityWindowWhereClauses, + loadMunicipalityNames, +} from '@/utilities/calendarFeed' +import { + createGoogleCalendarClient, + GoogleCalendarApiError, + type GoogleCalendarClient, + type GoogleCalendarCredentials, +} from '@/utilities/googleCalendarClient' + +/** + * C114 — the campaign→Google calendar reconciliation engine. + * + * One full pass per run (D3): load the staff-scope activities in the window, + * list the calendar's events in a slightly wider window, diff by the + * deterministic event id (`teqo` + base32hex(activityId)) and create/update/ + * delete — updates only when the content differs, so repeated passes converge + * without touching Google. Canceled and hard-deleted activities drop out of + * the wanted set, which deletes their Google event: Teqo keeps the SoT, + * Google keeps no "canceled" state visible to followers. + * + * Triggers (D4): activity afterChange/afterDelete (relevant fields only), + * agenda-page auto-retry and the manual "Sincronizar agora", plus the + * `googleCalendarSync` config afterChange (D7: calendarId change / re-enable). + * The engine NEVER throws into the caller — failures land in the state fields + * and the status derives to `paused`. + */ + +export const GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV = 'GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY' + +/** + * The list window scans further into the past than the push window so a + * canceled/deleted event older than the 90-day lookback is still found and + * deleted (a realistic case: canceling an activity that happened months ago). + */ +const SYNC_DELETE_LOOKBACK_DAYS = 730 +const SYNC_LIST_WINDOW_BUFFER_DAYS = 30 +/** + * The fields the Google event mirrors (title, schedule, location, tags, + * deputy flag, municipality). Task toggles, updates and result records are + * deliberately absent — those never trigger a pass. + */ +const SYNC_RELEVANT_ACTIVITY_FIELDS = [ + 'title', + 'status', + 'startAt', + 'endAt', + 'allDay', + 'locality', + 'municipality', + 'tags', + 'deputyPresent', +] as const + +export type GoogleCalendarSyncStatus = 'not-configured' | 'disabled' | 'synced' | 'paused' + +export type GoogleCalendarSyncView = { + status: GoogleCalendarSyncStatus + calendarId: string | null + lastSyncedAt: string | null + lastSuccessAt: string | null + lastErrorAt: string | null + lastError: string | null +} + +/** + * Pure derivation (never stored): the pill state is a function of the + * credential presence, the configured calendar and the last attempt outcomes. + * `paused` wins while the last attempt failed; any later success recovers. + */ +export const deriveGoogleCalendarSyncStatus = (input: { + hasCredential: boolean + calendarId: string | null | undefined + disabledAt?: string | null | undefined + lastSuccessAt?: string | null | undefined + lastErrorAt?: string | null | undefined +}): GoogleCalendarSyncStatus => { + if (!input.hasCredential || !input.calendarId) return 'not-configured' + if (input.disabledAt) return 'disabled' + if (input.lastErrorAt && (!input.lastSuccessAt || input.lastErrorAt > input.lastSuccessAt)) { + return 'paused' + } + return 'synced' +} + +/** + * Reads the service account credential from the environment (base64 of the + * Google Cloud JSON key). Fail-closed: malformed/absent → null, which + * disables the sync entirely without breaking anything else. + */ +export const readGoogleServiceAccountCredentials = (): GoogleCalendarCredentials | null => { + const raw = process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] + if (!raw) return null + + try { + const parsed = JSON.parse(Buffer.from(raw, 'base64').toString('utf8')) as Record< + string, + unknown + > + if ( + typeof parsed.client_email === 'string' && + typeof parsed.private_key === 'string' && + parsed.private_key.length > 0 + ) { + return { clientEmail: parsed.client_email, privateKey: parsed.private_key } + } + } catch { + // malformed credential — treated as absent (fail-closed, no sync attempts) + } + return null +} + +export const loadGoogleCalendarSyncConfig = async ( + payload: Payload, + req?: PayloadRequest, +): Promise => { + // Intentional admin bypass: config/state of the mirror is read by the + // engine and the agenda pill for any staff — access is enforced at the + // action layer, never per-read. + const result = await payload.find({ + collection: 'googleCalendarSync', + limit: 1, + pagination: false, + depth: 0, + overrideAccess: true, + req, + }) + return result.docs[0] ?? null +} + +const docToView = ( + doc: GoogleCalendarSyncDoc | null, + hasCredential: boolean, +): GoogleCalendarSyncView => ({ + status: deriveGoogleCalendarSyncStatus({ + hasCredential, + calendarId: doc?.calendarId ?? null, + disabledAt: doc?.disabledAt ?? null, + lastSuccessAt: doc?.lastSuccessAt ?? null, + lastErrorAt: doc?.lastErrorAt ?? null, + }), + calendarId: doc?.calendarId ?? null, + lastSyncedAt: doc?.lastSyncedAt ?? null, + lastSuccessAt: doc?.lastSuccessAt ?? null, + lastErrorAt: doc?.lastErrorAt ?? null, + lastError: doc?.lastError ?? null, +}) + +/** The read model the agenda UI renders — status derived, never stored. */ +export const readGoogleCalendarSyncView = async ( + payload: Payload, +): Promise => { + const [doc, credentials] = await Promise.all([ + loadGoogleCalendarSyncConfig(payload), + readGoogleServiceAccountCredentials(), + ]) + return docToView(doc, credentials !== null) +} + +/** + * The official calendar mirrors the FULL staff scope (espelho cheio): no + * advisor intersection — a coordinator-managed calendar, unlike per-feed + * creator scopes. Canceled activities are excluded from the PUSH set (their + * events get deleted); the window is the feed's own (shared contract). + */ +const loadSyncActivities = async ( + payload: Payload, + req?: PayloadRequest, +): Promise<{ activities: Activity[]; municipalityNames: Map }> => { + const { rangeStart, rangeEnd } = buildActivityWindowRange() + + const result = await payload.find({ + collection: 'activity', + depth: 0, + limit: 0, + pagination: false, + sort: 'startAt', + where: { and: buildActivityWindowWhereClauses(rangeStart, rangeEnd) }, + // Intentional admin bypass: the official mirror is the whole campaign + // agenda (espelho cheio) — the collection access already gates writers. + overrideAccess: true, + req, + }) + + const activities = result.docs + const municipalityIds = [ + ...new Set( + activities + .map((activity) => activityMunicipalityIdOf(activity.municipality)) + .filter((id): id is number => id !== undefined), + ), + ] + const municipalityNames = await loadMunicipalityNames(payload, municipalityIds) + + return { activities, municipalityNames } +} + +/** + * Ids of activities alive over the WIDER past window — the delete guard set. + * An event older than the push lookback whose activity is still alive (e.g. a + * past `realizado` commitment) must NOT be deleted; canceled/hard-deleted + * activities drop out of this set and their events are cleaned up wherever + * they are in the extended window. + */ +const loadAliveActivityIds = async ( + payload: Payload, + req: PayloadRequest | undefined, + timeMin: string, + timeMax: string, +): Promise> => { + const result = await payload.find({ + collection: 'activity', + depth: 0, + limit: 0, + pagination: false, + where: { + and: [ + { startAt: { less_than: timeMax } }, + { + or: [ + { endAt: { greater_than: timeMin } }, + { + and: [{ endAt: { exists: false } }, { startAt: { greater_than_equal: timeMin } }], + }, + ], + }, + { status: { not_equals: 'cancelado' } }, + ], + }, + // Intentional admin bypass: same espelho-cheio rationale as above. + overrideAccess: true, + req, + }) + return new Set(result.docs.map((activity) => activity.id)) +} + +/** + * The list window scans the extended past so canceled/deleted events older + * than the 90-day push lookback are still found and removed (canceling an + * activity that happened months ago is a realistic operation). + */ +const buildListWindow = ( + rangeStart: string, + rangeEnd: string, +): { timeMin: string; timeMax: string } => { + const bufferMs = SYNC_LIST_WINDOW_BUFFER_DAYS * 86_400_000 + const deleteLookbackMs = SYNC_DELETE_LOOKBACK_DAYS * 86_400_000 + const nowMs = Date.now() + return { + timeMin: new Date( + Math.min(new Date(rangeStart).getTime() - bufferMs, nowMs - deleteLookbackMs), + ).toISOString(), + timeMax: new Date(new Date(rangeEnd).getTime() + bufferMs).toISOString(), + } +} + +const deleteRemoteEvent = async ( + client: GoogleCalendarClient, + calendarId: string, + eventId: string, +): Promise => { + try { + await client.deleteEvent(calendarId, eventId) + } catch (error) { + // Already gone is the desired end state. + if (!(error instanceof GoogleCalendarApiError && error.status === 404)) throw error + } +} + +type SyncCounts = { created: number; updated: number; deleted: number } + +const runSyncPass = async ( + payload: Payload, + req: PayloadRequest | undefined, + client: GoogleCalendarClient, + calendarId: string, +): Promise => { + const { rangeStart, rangeEnd } = buildActivityWindowRange() + const { activities, municipalityNames } = await loadSyncActivities(payload, req) + const listWindow = buildListWindow(rangeStart, rangeEnd) + const remoteEvents = await client.listEvents(calendarId, listWindow) + const aliveActivityIds = await loadAliveActivityIds( + payload, + req, + listWindow.timeMin, + listWindow.timeMax, + ) + + const wantedById = new Map() + for (const activity of activities) { + wantedById.set(activity.id, activity) + } + + let created = 0 + let updated = 0 + let deleted = 0 + + const remoteEventIds = new Set() + + for (const event of remoteEvents) { + // Only OUR deterministic ids are reconciled; foreign events on the + // campaign calendar (e.g. added manually by the owner) stay untouched — + // the full decode, never a prefix match (a manual `teqo3` is foreign). + const activityId = event.id ? decodeGoogleEventActivityId(event.id) : null + if (activityId === null) continue + remoteEventIds.add(event.id!) + + const activity = wantedById.get(activityId) + if (!activity) { + // Not in the push window: alive past/future commitments keep their + // event; anything else (canceled, hard-deleted) leaves Google. + if (!aliveActivityIds.has(activityId)) { + await deleteRemoteEvent(client, calendarId, event.id!) + deleted += 1 + } + continue + } + + const municipalityId = activityMunicipalityIdOf(activity.municipality) + const municipalityName = municipalityId ? municipalityNames.get(municipalityId) : undefined + const googleEvent = buildGoogleEventPayload(activity, municipalityName) + + if (!googleEventContentEquals(event, googleEvent)) { + await client.updateEvent(calendarId, event.id!, googleEvent) + updated += 1 + } + } + + for (const [activityId, activity] of wantedById) { + const eventId = googleEventIdForActivity(activityId) + if (remoteEventIds.has(eventId)) continue + + const municipalityId = activityMunicipalityIdOf(activity.municipality) + const municipalityName = municipalityId ? municipalityNames.get(municipalityId) : undefined + await client.insertEvent(calendarId, buildGoogleEventPayload(activity, municipalityName)) + created += 1 + } + + return { created, updated, deleted } +} + +const recordSyncState = async ( + payload: Payload, + req: PayloadRequest | undefined, + patch: Partial< + Pick + >, +): Promise => { + const doc = await loadGoogleCalendarSyncConfig(payload, req) + if (!doc) return // nothing to record before the first activation + + await payload.update({ + collection: 'googleCalendarSync', + id: doc.id, + data: patch, + depth: 0, + // Intentional admin bypass: the state fields are system-only by design. + overrideAccess: true, + req, + }) +} + +export type CampaignCalendarSyncOutcome = SyncCounts & { + status: GoogleCalendarSyncStatus + at: string +} + +export type CampaignCalendarSyncOptions = { + reason: 'create' | 'update' | 'delete' | 'config-change' | 'manual' + client?: GoogleCalendarClient + req?: PayloadRequest +} + +export const runCampaignCalendarSync = async ( + payload: Payload, + options: CampaignCalendarSyncOptions, +): Promise => { + const credentials = readGoogleServiceAccountCredentials() + const config = await loadGoogleCalendarSyncConfig(payload, options.req) + const at = new Date().toISOString() + + if (!credentials || !config?.calendarId || config.disabledAt) { + return { + status: deriveGoogleCalendarSyncStatus({ + hasCredential: credentials !== null, + calendarId: config?.calendarId ?? null, + disabledAt: config?.disabledAt ?? null, + lastSuccessAt: config?.lastSuccessAt ?? null, + lastErrorAt: config?.lastErrorAt ?? null, + }), + created: 0, + updated: 0, + deleted: 0, + at, + } + } + + const client = options.client ?? createGoogleCalendarClient(credentials) + + try { + const counts = await runSyncPass(payload, options.req, client, config.calendarId) + await recordSyncState(payload, options.req, { lastSyncedAt: at, lastSuccessAt: at }) + return { status: 'synced', ...counts, at } + } catch (error) { + const message = + error instanceof Error ? error.message : 'Erro desconhecido ao sincronizar com o Google.' + await recordSyncState(payload, options.req, { + lastSyncedAt: at, + lastErrorAt: at, + lastError: message.slice(0, 500), + }) + return { status: 'paused', created: 0, updated: 0, deleted: 0, at } + } +} + +/** + * Normalizes a mirrored field for change detection. The afterChange `doc` + * carries populated relationships (municipality object) while `previousDoc` + * comes depth-0 (numeric id) — comparing raw values would flag EVERY update + * as changed and sync on every task toggle. + */ +const normalizeMirroredField = (doc: Activity | undefined, field: string): unknown => { + const raw = (doc as unknown as Record | undefined)?.[field] + if (field === 'municipality') { + return activityMunicipalityIdOf(raw as unknown as Activity['municipality']) ?? null + } + if (field === 'tags') { + return Array.isArray(raw) ? [...raw].sort() : raw + } + return raw +} + +/** + * Pure guard — the activity hook only runs a sync pass when a field the + * Google event mirrors changed (task toggles, updates and result records + * never trigger). Create/delete always sync. + */ +export const shouldSyncActivityOperation = ({ + operation, + doc, + previousDoc, +}: { + operation: 'create' | 'update' | 'delete' + doc: Activity + previousDoc?: Activity +}): boolean => { + if (operation !== 'update') return true + return SYNC_RELEVANT_ACTIVITY_FIELDS.some( + (field) => + JSON.stringify(normalizeMirroredField(doc, field)) !== + JSON.stringify(normalizeMirroredField(previousDoc, field)), + ) +} + +/** + * Pure guard — the config hook reconciles on calendarId change or re-enable + * (D7); state-only writes (the engine recording outcomes) never re-trigger, + * which is what breaks the would-be loop. + */ +export const shouldSyncConfigChange = ({ + operation, + doc, + previousDoc, +}: { + operation: 'create' | 'update' + doc: GoogleCalendarSyncDoc + previousDoc?: GoogleCalendarSyncDoc +}): boolean => { + if (operation !== 'update') return true + const calendarChanged = (doc.calendarId ?? '') !== (previousDoc?.calendarId ?? '') + const disabledCleared = Boolean(previousDoc?.disabledAt) && !doc.disabledAt + return calendarChanged || disabledCleared +} + +/** + * Activity hook (afterChange + afterDelete) and the `googleCalendarSync` + * config hook (D7 auto-resync) live in `googleCalendarSyncHooks.ts` — they + * delegate to the guards above and the engine, and are kept in a separate + * module so tests can mock the engine at the import boundary. + */ diff --git a/src/utilities/googleCalendarSyncHooks.ts b/src/utilities/googleCalendarSyncHooks.ts new file mode 100644 index 00000000..ec79ee6b --- /dev/null +++ b/src/utilities/googleCalendarSyncHooks.ts @@ -0,0 +1,67 @@ +import 'server-only' + +import type { Activity, GoogleCalendarSync as GoogleCalendarSyncDoc } from '@/payload-types' +import type { PayloadRequest } from 'payload' + +import { + runCampaignCalendarSync, + shouldSyncActivityOperation, + shouldSyncConfigChange, +} from '@/utilities/googleCalendarSync' + +/** + * C114 — the two Payload hooks that trigger the Google mirror (in a module of + * their own so tests can mock the engine at the import boundary). + * + * Activity hook (afterChange + afterDelete): delegates the decision to the + * pure guard — task toggles, updates and result records never trigger a pass. + * afterDelete hooks carry no `operation`; its absence means delete. + * Config hook (afterChange on `googleCalendarSync`): D7 auto-resync — a + * changed `calendarId` or a re-enable runs the full reconciliation against + * the new calendar; state-only writes never re-trigger (no loop). + * + * Both NEVER throw: the Teqo write paths are independent of Google + * (fail-closed at the trigger; engine failures land in the `paused` state). + */ +export const activityGoogleCalendarSyncHook = async ({ + doc, + previousDoc, + operation, + req, +}: { + doc: Activity + previousDoc?: Activity + operation?: 'create' | 'update' + req: PayloadRequest +}): Promise => { + try { + const resolvedOperation = operation ?? 'delete' + if (shouldSyncActivityOperation({ operation: resolvedOperation, doc, previousDoc })) { + await runCampaignCalendarSync(req.payload, { reason: resolvedOperation, req }) + } + } catch { + // recorded as paused inside the engine; the write path stays intact + } + return doc +} + +export const googleCalendarSyncConfigHook = async ({ + doc, + previousDoc, + operation, + req, +}: { + doc: GoogleCalendarSyncDoc + previousDoc?: GoogleCalendarSyncDoc + operation: 'create' | 'update' + req: PayloadRequest +}): Promise => { + try { + if (shouldSyncConfigChange({ operation, doc, previousDoc })) { + await runCampaignCalendarSync(req.payload, { reason: 'config-change', req }) + } + } catch { + // never throw into admin writes + } + return doc +} diff --git a/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts b/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts new file mode 100644 index 00000000..20fd5911 --- /dev/null +++ b/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts @@ -0,0 +1,33 @@ +import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js' + +/** + * C114 — Google Calendar mirror surface on the agenda. The e2e only pins the + * deterministic "não configurado" state (no credential env in the test + * runtime, no sync doc in the seeded DB): engine behavior (create/update/ + * delete/recover) is covered by the int tests with an in-memory calendar + * stub — this spec must never depend on the real Google API. + */ +test.describe('Agenda — sincronização Google (C114)', () => { + test('staff vê a pill "não configurado" e o diálogo explica o passo de operação', async ({ + campaign, + page, + }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator') + + await campaign.login(page, coordinator.email!, coordinator.password) + await page.goto(`${campaign.baseURL}/campanha/agenda`) + + await expect(campaignPageChrome(page, 'Agenda')).toBeVisible() + + const pill = page.getByRole('button', { name: 'Google: não configurado' }) + await expect(pill).toBeVisible({ timeout: 15_000 }) + await pill.click() + + const dialog = page.getByRole('dialog', { name: /Agenda da Campanha no Google/ }) + await expect(dialog).toBeVisible() + await expect(dialog.getByText('Ainda não configurado')).toBeVisible() + // O runbook de operação aparece (a ativação depende da conta Google da campanha). + await expect(dialog.getByText(/service account do Teqo/)).toBeVisible() + }) +}) diff --git a/tests/int/googleCalendarSync.int.spec.ts b/tests/int/googleCalendarSync.int.spec.ts new file mode 100644 index 00000000..055a2b6c --- /dev/null +++ b/tests/int/googleCalendarSync.int.spec.ts @@ -0,0 +1,334 @@ +// @vitest-environment node + +import { getPayload, type Payload } from 'payload' +import { afterEach, beforeAll, describe, expect, it } from 'vitest' + +import { googleEventIdForActivity } from '@/lib/googleCalendarEventMapping' +import config from '@/payload.config' +import type { GoogleCalendarClient, GoogleRemoteEvent } from '@/utilities/googleCalendarClient' +import { + GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV, + loadGoogleCalendarSyncConfig, + readGoogleCalendarSyncView, + runCampaignCalendarSync, +} from '@/utilities/googleCalendarSync' +import { hookFilledCreateData } from '@/utilities/hookFilledData' + +import { installCampaignFixtures } from '../helpers/campaignFixtures' + +let payload: Payload +const campaignFixtures = installCampaignFixtures({ + getPayload: () => payload, + setPayload: (nextPayload) => { + payload = nextPayload + }, +}) + +const FAKE_KEY = Buffer.from( + JSON.stringify({ + client_email: 'teqo-sa@projeto.iam.gserviceaccount.com', + private_key: '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n', + }), + 'utf8', +).toString('base64') + +const originalKey = process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] + +const withCredential = async (run: () => Promise): Promise => { + process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] = FAKE_KEY + try { + return await run() + } finally { + if (originalKey === undefined) { + delete process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] + } else { + process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] = originalKey + } + } +} + +/** In-memory Google calendar stub — list/insert/update/delete over an array. */ +const createStubClient = (store: GoogleRemoteEvent[] = []): GoogleCalendarClient => ({ + listEvents: async (_calendarId, range) => + store.filter((event) => { + const start = event.start?.dateTime ?? event.start?.date ?? '' + return start >= range.timeMin && start <= range.timeMax + }), + insertEvent: async (_calendarId, event) => { + store.push(event) + }, + updateEvent: async (_calendarId, eventId, event) => { + const index = store.findIndex((entry) => entry.id === eventId) + if (index >= 0) store[index] = event + }, + deleteEvent: async (_calendarId, eventId) => { + const index = store.findIndex((entry) => entry.id === eventId) + if (index >= 0) store.splice(index, 1) + }, +}) + +const calendarA = 'c_campanha_a@group.calendar.google.com' +const calendarB = 'c_campanha_b@group.calendar.google.com' + +describe('campaign Google calendar sync engine (C114)', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + afterEach(async () => { + // Teqo never depends on Google: the engine must never throw into callers. + // Cleanup happens per test; the env is restored by withCredential. + await payload.delete({ + collection: 'activity', + where: { title: { like: 'C114%' } }, + overrideAccess: true, + }) + await payload.delete({ + collection: 'googleCalendarSync', + where: { id: { exists: true } }, + overrideAccess: true, + }) + }) + + const createActivity = async (overrides: Record = {}) => { + const municipality = await campaignFixtures().getMunicipality() + return payload.create({ + collection: 'activity', + data: hookFilledCreateData<'activity'>({ + title: `C114 ${crypto.randomUUID().slice(0, 8)}`, + tags: ['Caminhada'], + status: 'confirmado', + startAt: new Date(Date.now() + 86_400_000).toISOString(), + municipality: municipality.id, + ...overrides, + }), + depth: 0, + overrideAccess: true, + }) + } + + const createConfig = async (calendarId: string) => + payload.create({ + collection: 'googleCalendarSync', + data: { calendarId }, + depth: 0, + overrideAccess: true, + }) + + it('creates the full mirror on the first pass (timed + all-day, municipality summary)', async () => { + const municipality = await campaignFixtures().getMunicipality() + const timed = await createActivity({ municipality: municipality.id, locality: 'Centro' }) + const allDay = await createActivity({ + municipality: municipality.id, + allDay: true, + startAt: '2026-08-10T03:00:00.000Z', + endAt: '2026-08-12T03:00:00.000Z', + }) + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + + const outcome = await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client }), + ) + + expect(outcome.status).toBe('synced') + expect(outcome.created).toBe(2) + expect(outcome.updated).toBe(0) + expect(outcome.deleted).toBe(0) + + const timedEvent = store.find((event) => event.id === googleEventIdForActivity(timed.id)) + expect(timedEvent?.summary).toBe(`[${municipality.name}] ${timed.title}`) + expect(timedEvent?.location).toBe('Centro') + expect(timedEvent?.start).toEqual({ dateTime: expect.stringMatching(/-03:00$/) }) + + const allDayEvent = store.find((event) => event.id === googleEventIdForActivity(allDay.id)) + expect(allDayEvent?.start).toEqual({ date: '2026-08-10' }) + expect(allDayEvent?.end).toEqual({ date: '2026-08-13' }) + + const doc = await loadGoogleCalendarSyncConfig(payload) + expect(doc?.lastSuccessAt).toBeTruthy() + expect(doc?.lastError).toBeNull() + }) + + it('converges: a second pass with no changes touches nothing', async () => { + await createActivity() + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + + const first = await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client }), + ) + expect(first.created).toBe(1) + + const second = await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client }), + ) + expect(second).toMatchObject({ created: 0, updated: 0, deleted: 0, status: 'synced' }) + }) + + it('updates only the drifted event and ignores foreign calendar events', async () => { + const activity = await createActivity() + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [ + { id: 'foreign-manual-event', summary: 'Mantido pela conta da campanha' }, + ] + const client = createStubClient(store) + await withCredential(() => runCampaignCalendarSync(payload, { reason: 'manual', client })) + + // Title is immutable after creation (canonical slug rule) — drift a + // mutable mirrored field instead (locality is in the mirror surface). + await payload.update({ + collection: 'activity', + id: activity.id, + data: { locality: 'Centro (atualizado)' }, + depth: 0, + overrideAccess: true, + }) + + const outcome = await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client }), + ) + expect(outcome.updated).toBe(1) + expect(outcome.deleted).toBe(0) + expect(store.find((event) => event.id === 'foreign-manual-event')).toBeTruthy() + expect( + store.find((event) => event.id === googleEventIdForActivity(activity.id))?.location, + ).toBe('Centro (atualizado)') + }) + + it('canceled activities leave Google: their events are deleted, none created', async () => { + const activity = await createActivity() + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + await withCredential(() => runCampaignCalendarSync(payload, { reason: 'manual', client })) + expect(store).toHaveLength(1) + + await payload.update({ + collection: 'activity', + id: activity.id, + data: { status: 'cancelado' }, + depth: 0, + overrideAccess: true, + }) + + const outcome = await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client }), + ) + expect(outcome.deleted).toBe(1) + expect(store).toHaveLength(0) + }) + + it('hard-deleted activities leave no ghost in Google', async () => { + const activity = await createActivity() + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + await withCredential(() => runCampaignCalendarSync(payload, { reason: 'manual', client })) + expect(store).toHaveLength(1) + + await payload.delete({ collection: 'activity', id: activity.id, overrideAccess: true }) + + const outcome = await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client }), + ) + expect(outcome.deleted).toBe(1) + expect(store).toHaveLength(0) + }) + + it('activities outside the window never reach Google', async () => { + await createActivity({ startAt: '2029-01-01T12:00:00.000Z' }) + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + + const outcome = await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client }), + ) + expect(outcome.created).toBe(0) + expect(store).toHaveLength(0) + }) + + it('failures land in paused and the next success recovers to synced', async () => { + await createActivity() + await createConfig(calendarA) + + const failingClient: GoogleCalendarClient = { + ...createStubClient(), + listEvents: async () => { + throw new Error('Google fora do ar (simulado)') + }, + } + const failed = await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client: failingClient }), + ) + expect(failed.status).toBe('paused') + + const doc = await loadGoogleCalendarSyncConfig(payload) + expect(doc?.lastErrorAt).toBeTruthy() + expect(doc?.lastError).toContain('Google fora do ar') + + // The pill reads with the credential present (same context the run had). + const failedView = await withCredential(() => readGoogleCalendarSyncView(payload)) + expect(failedView.status).toBe('paused') + + const store: GoogleRemoteEvent[] = [] + const recovered = await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client: createStubClient(store) }), + ) + expect(recovered.status).toBe('synced') + expect(recovered.created).toBe(1) + const recoveredView = await withCredential(() => readGoogleCalendarSyncView(payload)) + expect(recoveredView.status).toBe('synced') + }) + + it('without a credential or calendar the engine is a no-op (fail-closed)', async () => { + await createActivity() + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + + // No config doc, no env key. + const outcome = await runCampaignCalendarSync(payload, { reason: 'manual', client }) + expect(outcome.status).toBe('not-configured') + expect(store).toHaveLength(0) + + await createConfig(calendarA) + // Config present but env key absent (outside withCredential). + const withoutKey = await runCampaignCalendarSync(payload, { reason: 'manual', client }) + expect(withoutKey.status).toBe('not-configured') + expect(store).toHaveLength(0) + }) + + it('changing the calendarId reconciles into the new calendar (D7 engine side)', async () => { + await createActivity() + await createConfig(calendarA) + const storeA: GoogleRemoteEvent[] = [] + await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client: createStubClient(storeA) }), + ) + expect(storeA).toHaveLength(1) + + const config = await loadGoogleCalendarSyncConfig(payload) + await payload.update({ + collection: 'googleCalendarSync', + id: config!.id, + data: { calendarId: calendarB }, + depth: 0, + overrideAccess: true, + }) + + const storeB: GoogleRemoteEvent[] = [] + const outcome = await withCredential(() => + runCampaignCalendarSync(payload, { + reason: 'config-change', + client: createStubClient(storeB), + }), + ) + expect(outcome.created).toBe(1) + expect(storeB).toHaveLength(1) + // The abandoned calendar keeps its last mirror (documented, no retro-cleanup). + expect(storeA).toHaveLength(1) + }) +}) diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 34587e54..7c27af90 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -87,6 +87,9 @@ describe('banned campaign terminology', () => { 'src/lib/projectionSheetParse.ts', // Fixture entry deliberately exercises legacy visits written pre-rename. 'tests/unit/recentVisits.unit.spec.ts', + // English prose ("one row in practice") trips the accent-tolerant + // /pra[çc]/ — genuine code comment, not the retired term. + 'src/collections/GoogleCalendarSync.ts', ], }, { @@ -400,6 +403,13 @@ describe('src/utilities top-level is pinned', () => { 'campaignAccess.ts', 'campaignActionContext.ts', 'campaignAuditFields.ts', + // C114 — Google Calendar mirror: thin Calendar API v3 transport (jose+fetch). + 'googleCalendarClient.ts', + // C114 — Google Calendar mirror: reconciliation engine + hook guards. + 'googleCalendarSync.ts', + // C114 — Google Calendar mirror: activity/config hooks (separate module so + // the engine is mockable at the import boundary in tests). + 'googleCalendarSyncHooks.ts', 'campaignAuth.ts', 'campaignBiometricsPrompt.ts', 'campaignColumnVisibilityCookie.ts', diff --git a/tests/unit/googleCalendarClient.unit.spec.ts b/tests/unit/googleCalendarClient.unit.spec.ts new file mode 100644 index 00000000..e77858f6 --- /dev/null +++ b/tests/unit/googleCalendarClient.unit.spec.ts @@ -0,0 +1,185 @@ +// @vitest-environment node + +import { exportPKCS8, exportSPKI, generateKeyPair, importSPKI, jwtVerify } from 'jose' +import { beforeAll, describe, expect, it } from 'vitest' + +import { + buildServiceAccountAssertion, + createGoogleCalendarClient, + GOOGLE_CALENDAR_SCOPE, + GoogleCalendarApiError, + type FetchLike, + type GoogleCalendarCredentials, +} from '@/utilities/googleCalendarClient' + +describe('buildServiceAccountAssertion', () => { + let credentials: GoogleCalendarCredentials + let publicKeyPem: string + + beforeAll(async () => { + const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true }) + credentials = { + clientEmail: 'teqo-sa@projeto.iam.gserviceaccount.com', + privateKey: await exportPKCS8(privateKey), + } + publicKeyPem = await exportSPKI(publicKey) + }) + + it('signs an RS256 JWT with the service-account claims the token endpoint expects', async () => { + const assertion = await buildServiceAccountAssertion(credentials, 1_800_000_000) + const { payload } = await jwtVerify(assertion, await importSPKI(publicKeyPem, 'RS256')) + + expect(payload.iss).toBe(credentials.clientEmail) + expect(payload.aud).toBe('https://oauth2.googleapis.com/token') + expect(payload.scope).toBe(GOOGLE_CALENDAR_SCOPE) + expect(payload.iat).toBe(1_800_000_000) + expect((payload.exp as number) - (payload.iat as number)).toBe(3600) + }) +}) + +describe('createGoogleCalendarClient', () => { + let credentials: GoogleCalendarCredentials + + beforeAll(async () => { + // The token endpoint is stubbed, but the client still SIGNS the JWT + // assertion before posting — a real key keeps the tests honest. + const { privateKey } = await generateKeyPair('RS256', { extractable: true }) + credentials = { + clientEmail: 'teqo-sa@projeto.iam.gserviceaccount.com', + privateKey: await exportPKCS8(privateKey), + } + }) + + const calendarId = 'c_abc@group.calendar.google.com' + + /** Minimal fake transport: token endpoint + calendar REST in memory. */ + const stubTransport = (events: Array> = []) => { + let tokenRequests = 0 + const calls: Array<{ url: string; method: string; body?: string; auth?: string }> = [] + + const fetchImpl: FetchLike = (async (input: RequestInfo | URL, _init?: RequestInit) => { + const url = String(input) + const method = (_init?.method ?? 'GET').toUpperCase() + const body = typeof _init?.body === 'string' ? _init.body : undefined + const auth = (_init?.headers as Record | undefined)?.['Authorization'] + calls.push({ url, method, body, auth }) + + if (url === 'https://oauth2.googleapis.com/token') { + tokenRequests += 1 + return new Response( + JSON.stringify({ access_token: `token-${tokenRequests}`, expires_in: 3600 }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + + if (url.includes('/events') && method === 'POST') { + const event = JSON.parse(body ?? '{}') as Record + events.push(event) + return new Response(JSON.stringify(event), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + if (url.includes('/events') && method === 'PUT') { + const event = JSON.parse(body ?? '{}') as Record + const index = events.findIndex((e) => e.id === event.id) + if (index >= 0) events[index] = event + return new Response(JSON.stringify(event), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + if (url.includes('/events') && method === 'DELETE') { + const eventId = url.split('/').pop() + const index = events.findIndex((e) => e.id === eventId) + if (index >= 0) events.splice(index, 1) + return new Response(null, { status: 204 }) + } + if (url.includes('/events')) { + return new Response(JSON.stringify({ items: events }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + return new Response('not found', { status: 404 }) + }) as FetchLike + + return { fetchImpl, calls, events, getTokenRequests: () => tokenRequests } + } + + it('inserts with the deterministic id, bearer auth and JSON body', async () => { + const { fetchImpl, calls, events } = stubTransport() + const client = createGoogleCalendarClient(credentials, fetchImpl) + + await client.insertEvent(calendarId, { id: 'teqo1a', summary: 'S', description: 'D' }) + + expect(events).toHaveLength(1) + expect(events[0].id).toBe('teqo1a') + const insertCall = calls.find((call) => call.method === 'POST' && call.url.includes('/events')) + expect(insertCall?.url).toContain(`/calendars/${encodeURIComponent(calendarId)}/events`) + expect(insertCall?.auth).toBe('Bearer token-1') + }) + + it('lists with the window params and pagination', async () => { + const { fetchImpl, calls } = stubTransport() + const client = createGoogleCalendarClient(credentials, fetchImpl) + + await client.listEvents(calendarId, { + timeMin: '2026-01-01T00:00:00Z', + timeMax: '2026-12-31T00:00:00Z', + }) + + const listCall = calls.find((call) => call.method === 'GET') + expect(listCall?.url).toContain('timeMin=2026-01-01T00%3A00%3A00Z') + expect(listCall?.url).toContain('timeMax=2026-12-31T00%3A00%3A00Z') + expect(listCall?.url).toContain('maxResults=2500') + }) + + it('re-mints the token once on a 401 and retries the call', async () => { + let calendarCalls = 0 + const fetchImpl: FetchLike = (async (input: RequestInfo | URL, _init?: RequestInit) => { + const url = String(input) + if (url === 'https://oauth2.googleapis.com/token') { + return new Response(JSON.stringify({ access_token: 'fresh-token', expires_in: 3600 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + calendarCalls += 1 + if (calendarCalls === 1) { + return new Response('unauthorized', { status: 401 }) + } + return new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) as FetchLike + + const client = createGoogleCalendarClient(credentials, fetchImpl) + const events = await client.listEvents(calendarId, { + timeMin: '2026-01-01T00:00:00Z', + timeMax: '2026-12-31T00:00:00Z', + }) + + expect(events).toEqual([]) + expect(calendarCalls).toBe(2) + }) + + it('throws a typed error on a non-401 failure without leaking the body', async () => { + const fetchImpl: FetchLike = (async (input: RequestInfo | URL) => { + const url = String(input) + if (url === 'https://oauth2.googleapis.com/token') { + return new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 403 }) + } + return new Response('ok', { status: 200 }) + }) as FetchLike + + const client = createGoogleCalendarClient(credentials, fetchImpl) + await expect( + client.listEvents(calendarId, { timeMin: 'a', timeMax: 'b' }), + ).rejects.toBeInstanceOf(GoogleCalendarApiError) + }) +}) diff --git a/tests/unit/googleCalendarEventMapping.unit.spec.ts b/tests/unit/googleCalendarEventMapping.unit.spec.ts new file mode 100644 index 00000000..d633f7fa --- /dev/null +++ b/tests/unit/googleCalendarEventMapping.unit.spec.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest' + +import { + buildGoogleCalendarDescription, + buildGoogleEventPayload, + decodeGoogleEventActivityId, + googleEventContentEquals, + googleEventIdForActivity, + type GoogleRemoteEvent, +} from '@/lib/googleCalendarEventMapping' + +type MappingSource = Parameters[0] + +const baseActivity = (overrides: Partial = {}): MappingSource => + ({ + id: 42, + title: 'Caminhada no Cabula', + startAt: '2026-08-15T19:00:00.000Z', + endAt: '2026-08-15T22:00:00.000Z', + allDay: false, + locality: 'Centro', + tags: ['Caminhada'], + deputyPresent: true, + ...overrides, + }) as MappingSource + +describe('googleEventIdForActivity / decodeGoogleEventActivityId', () => { + it('encodes ids in base32hex under the teqo prefix (charset do Google)', () => { + expect(googleEventIdForActivity(0)).toBe('teqo0') + expect(googleEventIdForActivity(1)).toBe('teqo1') + expect(googleEventIdForActivity(31)).toBe('teqov') + expect(googleEventIdForActivity(32)).toBe('teqo10') + expect(googleEventIdForActivity(1000)).toBe('teqov8') + }) + + it('round-trips any positive id', () => { + for (const id of [1, 7, 42, 255, 1024, 123_456, 999_999_999]) { + const encoded = googleEventIdForActivity(id) + expect(encoded).toMatch(/^teqo[a-v0-9]+$/) + expect(decodeGoogleEventActivityId(encoded)).toBe(id) + } + }) + + it('rejects foreign and malformed ids', () => { + expect(decodeGoogleEventActivityId('meu-evento-42')).toBeNull() + expect(decodeGoogleEventActivityId('teqo')).toBeNull() + expect(decodeGoogleEventActivityId('teqoz')).toBeNull() + expect(decodeGoogleEventActivityId('teqoA1')).toBeNull() + }) + + it('rejects invalid activity ids', () => { + expect(() => googleEventIdForActivity(-1)).toThrow() + expect(() => googleEventIdForActivity(1.5)).toThrow() + }) +}) + +describe('buildGoogleEventPayload', () => { + it('maps a timed activity to RFC3339 dateTimes with the fixed -03:00 offset', () => { + const payload = buildGoogleEventPayload(baseActivity(), 'Salvador') + + expect(payload.id).toBe('teqo1a') + expect(payload.summary).toBe('[Salvador] Caminhada no Cabula') + expect(payload.start).toEqual({ dateTime: '2026-08-15T16:00:00-03:00' }) + expect(payload.end).toEqual({ dateTime: '2026-08-15T19:00:00-03:00' }) + expect(payload.location).toBe('Centro') + }) + + it('builds the description without unnecessary PII', () => { + const payload = buildGoogleEventPayload(baseActivity(), 'Salvador') + + expect(payload.description).toContain('Município: Salvador') + expect(payload.description).toContain('Local: Centro') + expect(payload.description).toContain('Tags: Caminhada') + expect(payload.description).toContain('Deputado presente') + }) + + it('synthesizes end = start + 1h when the activity has no endAt (Google exige end > start)', () => { + const payload = buildGoogleEventPayload( + baseActivity({ endAt: null, startAt: '2026-08-15T19:00:00.000Z' }), + 'Salvador', + ) + + expect(payload.start).toEqual({ dateTime: '2026-08-15T16:00:00-03:00' }) + expect(payload.end).toEqual({ dateTime: '2026-08-15T17:00:00-03:00' }) + }) + + it('maps all-day activities to civil dates with the exclusive end (C104 convention)', () => { + const payload = buildGoogleEventPayload( + baseActivity({ + allDay: true, + startAt: '2026-08-10T03:00:00.000Z', + endAt: '2026-08-12T03:00:00.000Z', + locality: '', + }), + 'Feira de Santana', + ) + + expect(payload.start).toEqual({ date: '2026-08-10' }) + expect(payload.end).toEqual({ date: '2026-08-13' }) + }) + + it('falls back to the municipality name as the location when locality is empty', () => { + const payload = buildGoogleEventPayload(baseActivity({ locality: '' }), 'Salvador') + expect(payload.location).toBe('Salvador') + }) + + it('omits location entirely when neither locality nor municipality name exist', () => { + const payload = buildGoogleEventPayload(baseActivity({ locality: '' })) + expect(payload.location).toBeUndefined() + expect(payload.summary).toBe('Caminhada no Cabula') + }) +}) + +describe('buildGoogleCalendarDescription', () => { + it('is empty when there is nothing to say', () => { + expect(buildGoogleCalendarDescription({ locality: '', tags: [], deputyPresent: false })).toBe( + '', + ) + }) +}) + +describe('googleEventContentEquals', () => { + const payload = buildGoogleEventPayload(baseActivity(), 'Salvador') + + it('treats the same instant under a different timezone echo as equal', () => { + const remote: GoogleRemoteEvent = { + id: payload.id, + summary: payload.summary, + description: payload.description, + location: payload.location, + // Calendar in America/Sao_Paulo echoes the same instant with its own offset. + start: { dateTime: '2026-08-15T16:00:00-03:00', timeZone: 'America/Sao_Paulo' }, + end: { dateTime: '2026-08-15T19:00:00-03:00', timeZone: 'America/Sao_Paulo' }, + } + expect(googleEventContentEquals(remote, payload)).toBe(true) + }) + + it('flags content drift', () => { + const remote: GoogleRemoteEvent = { ...payload, summary: '[Salvador] Outro título' } + expect(googleEventContentEquals(remote, payload)).toBe(false) + }) + + it('flags a moved instant', () => { + const remote: GoogleRemoteEvent = { + ...payload, + start: { dateTime: '2026-08-16T16:00:00-03:00' }, + end: { dateTime: '2026-08-16T19:00:00-03:00' }, + } + expect(googleEventContentEquals(remote, payload)).toBe(false) + }) + + it('compares all-day events by civil date', () => { + const allDayPayload = buildGoogleEventPayload( + baseActivity({ + allDay: true, + startAt: '2026-08-10T03:00:00.000Z', + endAt: '2026-08-12T03:00:00.000Z', + }), + 'Feira de Santana', + ) + const remote: GoogleRemoteEvent = { + id: allDayPayload.id, + summary: allDayPayload.summary, + description: allDayPayload.description, + location: allDayPayload.location, + start: { date: '2026-08-10' }, + end: { date: '2026-08-13' }, + } + expect(googleEventContentEquals(remote, allDayPayload)).toBe(true) + }) + + it('treats a missing remote description as empty (not drift)', () => { + const remote: GoogleRemoteEvent = { + id: payload.id, + summary: payload.summary, + start: payload.start, + end: payload.end, + } + expect( + googleEventContentEquals(remote, { ...payload, description: '', location: undefined }), + ).toBe(true) + }) +}) diff --git a/tests/unit/googleCalendarLink.unit.spec.ts b/tests/unit/googleCalendarLink.unit.spec.ts new file mode 100644 index 00000000..6acde48c --- /dev/null +++ b/tests/unit/googleCalendarLink.unit.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' + +import { + buildGoogleCalendarAddLink, + buildGoogleCalendarPublicIcalUrl, + buildGoogleCalendarWebcalUrl, +} from '@/lib/googleCalendarLink' + +describe('googleCalendarLink', () => { + const calendarId = 'c_abc123@group.calendar.google.com' + + it('builds the webcal subscription URL Apple/Outlook consume', () => { + expect(buildGoogleCalendarWebcalUrl(calendarId)).toBe( + 'webcal://calendar.google.com/calendar/ical/c_abc123%40group.calendar.google.com/public/basic.ics', + ) + }) + + it('builds the Google add-by-URL link with the webcal URL as cid', () => { + const link = buildGoogleCalendarAddLink(calendarId) + expect(link).toBe( + 'https://calendar.google.com/calendar/r?cid=webcal%3A%2F%2Fcalendar.google.com%2Fcalendar%2Fical%2Fc_abc123%2540group.calendar.google.com%2Fpublic%2Fbasic.ics', + ) + }) + + it('builds the public HTTPS iCal URL of the same calendar', () => { + expect(buildGoogleCalendarPublicIcalUrl(calendarId)).toBe( + 'https://calendar.google.com/calendar/ical/c_abc123%40group.calendar.google.com/public/basic.ics', + ) + }) +}) diff --git a/tests/unit/googleCalendarSync.unit.spec.ts b/tests/unit/googleCalendarSync.unit.spec.ts new file mode 100644 index 00000000..569786bf --- /dev/null +++ b/tests/unit/googleCalendarSync.unit.spec.ts @@ -0,0 +1,240 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import type { Activity, GoogleCalendarSync as GoogleCalendarSyncDoc } from '@/payload-types' + +import { + deriveGoogleCalendarSyncStatus, + GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV, + readGoogleServiceAccountCredentials, + shouldSyncActivityOperation, + shouldSyncConfigChange, +} from '@/utilities/googleCalendarSync' + +describe('deriveGoogleCalendarSyncStatus', () => { + const configured = { hasCredential: true, calendarId: 'c_abc@group.calendar.google.com' } + + it('is not-configured without credential or calendar id (fail-closed)', () => { + expect(deriveGoogleCalendarSyncStatus({ hasCredential: false, calendarId: null })).toBe( + 'not-configured', + ) + expect(deriveGoogleCalendarSyncStatus({ hasCredential: true, calendarId: null })).toBe( + 'not-configured', + ) + expect(deriveGoogleCalendarSyncStatus({ hasCredential: false, calendarId: 'x' })).toBe( + 'not-configured', + ) + }) + + it('is disabled while disabledAt is set', () => { + expect( + deriveGoogleCalendarSyncStatus({ ...configured, disabledAt: '2026-08-11T10:00:00.000Z' }), + ).toBe('disabled') + }) + + it('is paused while the last attempt failed', () => { + expect( + deriveGoogleCalendarSyncStatus({ + ...configured, + lastSuccessAt: '2026-08-11T09:00:00.000Z', + lastErrorAt: '2026-08-11T10:00:00.000Z', + }), + ).toBe('paused') + expect( + deriveGoogleCalendarSyncStatus({ + ...configured, + lastErrorAt: '2026-08-11T10:00:00.000Z', + }), + ).toBe('paused') + }) + + it('recovers to synced after the last success', () => { + expect( + deriveGoogleCalendarSyncStatus({ + ...configured, + lastSuccessAt: '2026-08-11T10:30:00.000Z', + lastErrorAt: '2026-08-11T10:00:00.000Z', + }), + ).toBe('synced') + expect(deriveGoogleCalendarSyncStatus(configured)).toBe('synced') + }) +}) + +describe('readGoogleServiceAccountCredentials', () => { + const originalKey = process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] + + afterEach(() => { + if (originalKey === undefined) { + delete process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] + } else { + process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] = originalKey + } + }) + + it('parses the base64 JSON key into credentials', () => { + const payload = { + client_email: 'teqo-sa@projeto.iam.gserviceaccount.com', + private_key: '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n', + } + process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] = Buffer.from( + JSON.stringify(payload), + 'utf8', + ).toString('base64') + + expect(readGoogleServiceAccountCredentials()).toEqual({ + clientEmail: payload.client_email, + privateKey: payload.private_key, + }) + }) + + it('returns null when the env is absent or malformed (fail-closed)', () => { + delete process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] + expect(readGoogleServiceAccountCredentials()).toBeNull() + + process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] = 'not-base64-json' + expect(readGoogleServiceAccountCredentials()).toBeNull() + + process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] = Buffer.from( + JSON.stringify({ client_email: 'x' }), + 'utf8', + ).toString('base64') + expect(readGoogleServiceAccountCredentials()).toBeNull() + }) +}) + +describe('shouldSyncActivityOperation', () => { + const activity = (overrides: Record = {}) => + ({ id: 1, title: 'Caminhada', status: 'confirmado', ...overrides }) as unknown as Activity + + it('always syncs on create and delete', () => { + expect(shouldSyncActivityOperation({ operation: 'create', doc: activity() })).toBe(true) + expect(shouldSyncActivityOperation({ operation: 'delete', doc: activity() })).toBe(true) + }) + + it('skips updates that only touch non-mirrored fields (tasks, derived state)', () => { + expect( + shouldSyncActivityOperation({ + operation: 'update', + doc: activity({ tasks: [{ title: 'Novo' }], taskDoneCount: 1 }), + previousDoc: activity({ tasks: [{ title: 'Antigo' }], taskDoneCount: 0 }), + }), + ).toBe(false) + }) + + it.each([ + 'title', + 'status', + 'startAt', + 'endAt', + 'allDay', + 'locality', + 'tags', + 'deputyPresent', + ] as const)('syncs when the mirrored field %s changed', (field) => { + expect( + shouldSyncActivityOperation({ + operation: 'update', + doc: activity({ [field]: 'changed' }), + previousDoc: activity({ [field]: 'original' }), + }), + ).toBe(true) + }) + + it('syncs when the municipality relationship changed', () => { + expect( + shouldSyncActivityOperation({ + operation: 'update', + doc: activity({ municipality: 2 }), + previousDoc: activity({ municipality: 1 }), + }), + ).toBe(true) + }) + + it('does NOT sync when only non-mirrored fields changed (tasks, description, updates, result)', () => { + expect( + shouldSyncActivityOperation({ + operation: 'update', + doc: activity({ description: 'novo texto', tasks: [{ title: 'Nova tarefa' }] }), + previousDoc: activity({ description: 'texto antigo', tasks: [{ title: 'Antiga' }] }), + }), + ).toBe(false) + }) + + it('normalizes populated vs depth-0 relationship shapes (afterChange doc vs previousDoc)', () => { + // The afterChange `doc` carries the populated municipality object; the + // `previousDoc` is loaded depth-0 (numeric id) — same id must NOT change. + expect( + shouldSyncActivityOperation({ + operation: 'update', + doc: activity({ municipality: { id: 7, name: 'Salvador' } }), + previousDoc: activity({ municipality: 7 }), + }), + ).toBe(false) + expect( + shouldSyncActivityOperation({ + operation: 'update', + doc: activity({ tags: ['B', 'A'] }), + previousDoc: activity({ tags: ['A', 'B'] }), + }), + ).toBe(false) + expect( + shouldSyncActivityOperation({ + operation: 'update', + doc: activity({ tags: ['A'] }), + previousDoc: activity({ tags: ['B'] }), + }), + ).toBe(true) + }) +}) + +describe('shouldSyncConfigChange', () => { + const config = (overrides: Record = {}) => + ({ + id: 1, + calendarId: 'c_a@group.calendar.google.com', + ...overrides, + }) as unknown as GoogleCalendarSyncDoc + + it('always syncs on create', () => { + expect(shouldSyncConfigChange({ operation: 'create', doc: config() })).toBe(true) + }) + + it('skips state-only writes (engine recording outcomes) — no re-entry loop', () => { + expect( + shouldSyncConfigChange({ + operation: 'update', + doc: config({ lastSyncedAt: '2026-08-11T10:00:00.000Z', lastSuccessAt: 'x' }), + previousDoc: config(), + }), + ).toBe(false) + }) + + it('skips disabling (no sync needed while off)', () => { + expect( + shouldSyncConfigChange({ + operation: 'update', + doc: config({ disabledAt: '2026-08-11T10:00:00.000Z' }), + previousDoc: config(), + }), + ).toBe(false) + }) + + it('syncs when the calendarId changed (reconcile into the new calendar)', () => { + expect( + shouldSyncConfigChange({ + operation: 'update', + doc: config({ calendarId: 'c_b@group.calendar.google.com' }), + previousDoc: config(), + }), + ).toBe(true) + }) + + it('syncs when re-enabled (disabledAt cleared)', () => { + expect( + shouldSyncConfigChange({ + operation: 'update', + doc: config(), + previousDoc: config({ disabledAt: '2026-08-11T10:00:00.000Z' }), + }), + ).toBe(true) + }) +}) diff --git a/tests/unit/googleCalendarSyncHooks.unit.spec.ts b/tests/unit/googleCalendarSyncHooks.unit.spec.ts new file mode 100644 index 00000000..55654d0b --- /dev/null +++ b/tests/unit/googleCalendarSyncHooks.unit.spec.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { Activity, GoogleCalendarSync as GoogleCalendarSyncDoc } from '@/payload-types' +import type { PayloadRequest } from 'payload' + +/** + * Hook → engine wiring (the guards themselves are unit-tested in + * `googleCalendarSync.unit.spec.ts`): the hooks must call the engine exactly + * when the guard says so, and NEVER throw into the write path. The hooks live + * in their own module (`googleCalendarSyncHooks.ts`), so mocking the engine + * at the import boundary reliably intercepts the wiring. + */ +vi.mock('@/utilities/googleCalendarSync', async (importOriginal) => { + const original = await importOriginal() + return { ...original, runCampaignCalendarSync: vi.fn() } +}) + +import { runCampaignCalendarSync } from '@/utilities/googleCalendarSync' +import { + activityGoogleCalendarSyncHook, + googleCalendarSyncConfigHook, +} from '@/utilities/googleCalendarSyncHooks' + +const mockedRun = vi.mocked(runCampaignCalendarSync) + +const fakeReq = { payload: {} } as unknown as PayloadRequest +const activity = (overrides: Record = {}) => + ({ id: 1, title: 'Caminhada', status: 'confirmado', ...overrides }) as unknown as Activity +const config = (overrides: Record = {}) => + ({ + id: 1, + calendarId: 'c_a@group.calendar.google.com', + ...overrides, + }) as unknown as GoogleCalendarSyncDoc + +const settledOutcome = { status: 'synced' as const, created: 0, updated: 0, deleted: 0, at: '' } + +describe('activityGoogleCalendarSyncHook wiring', () => { + beforeEach(() => { + mockedRun.mockReset() + mockedRun.mockResolvedValue(settledOutcome) + }) + + it('calls the engine on create, delete and relevant updates; skips task-only updates', async () => { + await activityGoogleCalendarSyncHook({ + doc: activity(), + operation: 'create', + req: fakeReq, + }) + expect(mockedRun).toHaveBeenCalledTimes(1) + + await activityGoogleCalendarSyncHook({ + doc: activity({ tasks: [{ title: 'Novo' }] }), + previousDoc: activity({ tasks: [{ title: 'Antigo' }] }), + operation: 'update', + req: fakeReq, + }) + expect(mockedRun).toHaveBeenCalledTimes(1) + + await activityGoogleCalendarSyncHook({ + doc: activity({ title: 'Caminhada (remarcada)' }), + previousDoc: activity(), + operation: 'update', + req: fakeReq, + }) + expect(mockedRun).toHaveBeenCalledTimes(2) + + // Delete: afterDelete hooks carry no `operation` — absence means delete. + await activityGoogleCalendarSyncHook({ + doc: activity(), + req: fakeReq, + }) + expect(mockedRun).toHaveBeenCalledTimes(3) + }) + + it('never throws into the activity write path', async () => { + mockedRun.mockRejectedValue(new Error('boom')) + await expect( + activityGoogleCalendarSyncHook({ + doc: activity(), + operation: 'create', + req: fakeReq, + }), + ).resolves.toBeDefined() + }) +}) + +describe('googleCalendarSyncConfigHook wiring', () => { + beforeEach(() => { + mockedRun.mockReset() + mockedRun.mockResolvedValue(settledOutcome) + }) + + it('calls the engine on create, calendarId change and re-enable; skips state writes and disable', async () => { + await googleCalendarSyncConfigHook({ + doc: config(), + operation: 'create', + req: fakeReq, + }) + expect(mockedRun).toHaveBeenCalledTimes(1) + + await googleCalendarSyncConfigHook({ + doc: config({ lastSyncedAt: '2026-08-11T10:00:00.000Z' }), + previousDoc: config(), + operation: 'update', + req: fakeReq, + }) + expect(mockedRun).toHaveBeenCalledTimes(1) + + await googleCalendarSyncConfigHook({ + doc: config({ disabledAt: '2026-08-11T10:00:00.000Z' }), + previousDoc: config(), + operation: 'update', + req: fakeReq, + }) + expect(mockedRun).toHaveBeenCalledTimes(1) + + await googleCalendarSyncConfigHook({ + doc: config({ calendarId: 'c_b@group.calendar.google.com' }), + previousDoc: config(), + operation: 'update', + req: fakeReq, + }) + expect(mockedRun).toHaveBeenCalledTimes(2) + + await googleCalendarSyncConfigHook({ + doc: config(), + previousDoc: config({ disabledAt: '2026-08-11T10:00:00.000Z' }), + operation: 'update', + req: fakeReq, + }) + expect(mockedRun).toHaveBeenCalledTimes(3) + }) + + it('never throws into admin writes', async () => { + mockedRun.mockRejectedValue(new Error('boom')) + await expect( + googleCalendarSyncConfigHook({ + doc: config({ calendarId: 'c_b@group.calendar.google.com' }), + previousDoc: config(), + operation: 'update', + req: fakeReq, + }), + ).resolves.toBeDefined() + }) +})