feat(C114): agenda da campanha espelhada no Google Calendar com notificação (push Teqo → Google)

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
This commit is contained in:
Francisco Solla 2026-08-11 12:30:15 -03:00
parent 69b9d4fe5b
commit 70070db8dc
34 changed files with 3209 additions and 30 deletions

View file

@ -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=

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-11):** **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-<id>`/`owned-consent-vacated-<id>`) — 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).

View file

@ -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<br/>campos relevantes mudaram?] -->|sim| S[runCampaignCalendarSync<br/>utilities/googleCalendarSync.ts]
P[agenda page view<br/>estado pausado → auto-retry] --> S
B["botão 'Sincronizar agora'"<br/>actions/googleCalendarSync.ts] --> S
S --> L[load activities janela 90d+365d<br/>espelho cheio do staff]
L --> G[Google Calendar API v3<br/>client com service account JWT<br/>jose + fetch]
G --> D[diff por id determinístico teqo_activity_&lt;id&gt;<br/>cria/atualiza/remove]
D --> W[grava collection googleCalendarSync<br/>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_<id>` (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_<id>`), `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=<encodeURIComponent(calendarId)>` + URL iCal pública `webcal://calendar.google.com/calendar/ical/<id>/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 `<AgendaGoogleSyncChrome>` 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`

View file

@ -0,0 +1,57 @@
# C122 — C114 follow-up: cobertura de testes dos estados do espelho Google
Status: rascunho
Atualizado em: 2026-08-11
Issue: #<pendente>
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).

View file

@ -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",

View file

@ -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: {}

View file

@ -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. */}
<AgendaGoogleSyncChrome
initialState={googleSyncState}
onSyncNow={async () => {
'use server'
return runGoogleCalendarSyncNow()
}}
onSetDisabled={async (disabled) => {
'use server'
return setGoogleCalendarSyncDisabled(disabled)
}}
/>
<CampaignListPendingBoundary>
<ActivityAgendaFilters

View file

@ -0,0 +1,96 @@
'use server'
import type { CampaignUser } from '@/payload-types'
import type { Payload } from 'payload'
import { buildGoogleCalendarAddLink } from '@/lib/googleCalendarLink'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import {
loadGoogleCalendarSyncConfig,
readGoogleCalendarSyncView,
runCampaignCalendarSync,
type GoogleCalendarSyncView,
} from '@/utilities/googleCalendarSync'
const GOOGLE_CALENDAR_SYNC_STAFF_ONLY_MESSAGE =
'Apenas a equipe da campanha pode gerenciar a sincronização com o Google.'
const GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE =
'Não foi possível atualizar o estado da sincronização com o Google.'
export type GoogleCalendarSyncActionResult = {
ok: boolean
message?: string
} & GoogleCalendarSyncView & { addLink: string | null }
const withLinks = (view: GoogleCalendarSyncView): Omit<GoogleCalendarSyncActionResult, 'ok'> => ({
...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<CampaignUser> =>
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<GoogleCalendarSyncActionResult> => {
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<GoogleCalendarSyncActionResult> => {
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<GoogleCalendarSyncActionResult> => {
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)
}
}

View file

@ -29,6 +29,7 @@ import {
eligibleCampaignStaffWhere,
} from '@/utilities/campaignAccess'
import { systemStampedActorField } from '@/utilities/campaignAuditFields'
import { activityGoogleCalendarSyncHook } from '@/utilities/googleCalendarSyncHooks'
const isActivityMutationShortcut = (context: Record<string, unknown> | 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: [
{

View file

@ -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,
},
},
],
}

View file

@ -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<GoogleCalendarSyncActionResult>
onSetDisabled: (disabled: boolean) => Promise<GoogleCalendarSyncActionResult>
}
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 (
<button
type="button"
className={`hidden shrink-0 items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring md:inline-flex ${copy.className}`}
aria-label={copy.label}
title={copy.label}
onClick={openSync}
>
<span className={`size-1.5 rounded-full ${copy.dot}`} aria-hidden />
{copy.label}
</button>
)
}, [state.status, openSync])
return (
<>
<SetCampaignHeaderAction id="google-calendar-sync">{pill}</SetCampaignHeaderAction>
<GoogleCalendarSyncDialog
open={open}
onOpenChange={setOpen}
state={state}
onSyncNow={handleSyncNow}
onSetDisabled={handleSetDisabled}
/>
</>
)
}

View file

@ -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<GoogleCalendarSyncActionResult>
onSetDisabled: (disabled: boolean) => Promise<GoogleCalendarSyncActionResult>
}
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 = <CopyIcon className="h-4 w-4" />
/**
* 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<string | null>(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 ? (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="google-calendar-link">
Link do calendário (copie e envie à equipe)
</label>
<div className="flex gap-2">
<Input
id="google-calendar-link"
value={state.addLink}
readOnly
className="font-mono text-xs"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopy}
aria-label="Copiar link"
>
{copyIcon}
</Button>
</div>
{copied && (
<p className="text-xs text-green-600" aria-live="polite">
Link copiado!
</p>
)}
<p className="text-xs text-muted-foreground">
Quem segue o calendário recebe aviso conforme as próprias configurações do Google.
</p>
</div>
) : null
const instructions = (
<div className="rounded-lg bg-muted p-4 text-sm">
<p className="mb-2 font-medium">Como adicionar ao Google Calendar:</p>
<ol className="list-inside list-decimal space-y-1 text-muted-foreground">
<li>Abra o Google Calendar no computador</li>
<li>
No menu lateral, clique em {'"'}
{'+'}
{'"'} ao lado de {'"'}Outras agendas{'"'}
</li>
<li>
Selecione {'"'}Por URL{'"'}
</li>
<li>
Cole o link acima e clique em {'"'}Adicionar agenda{'"'}
</li>
</ol>
<p className="mt-2 text-muted-foreground">
Apple Calendar e Outlook assinam a URL iCal pública do mesmo calendário.
</p>
</div>
)
const syncButton = (
<Button type="button" onClick={handleSyncNow} disabled={isBusy}>
<RefreshCwIcon className={`mr-2 h-4 w-4 ${isBusy ? 'animate-spin' : ''}`} />
{isBusy ? 'Sincronizando...' : 'Sincronizar agora'}
</Button>
)
let body: React.ReactNode
if (state.status === 'not-configured') {
body = (
<div className="space-y-4">
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm">
<p className="mb-2 font-medium text-amber-900">Ainda não configurado</p>
<p className="text-amber-800">
O Teqo segue 100% funcional — a agenda do Teqo nunca depende do Google. Para ativar o
espelho, um administrador precisa concluir a configuração:
</p>
<ol className="mt-2 list-inside list-decimal space-y-1 text-amber-800">
{NOT_CONFIGURED_RUNBOOK.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</div>
</div>
)
} else if (state.status === 'disabled') {
body = (
<div className="space-y-4">
<div className="rounded-lg bg-muted p-4 text-sm text-muted-foreground">
A sincronização está desativada. A agenda do Teqo continua funcionando normalmente; nada é
enviado ao Google enquanto estiver desativada.
</div>
{actionError && <p className="text-sm text-red-600">{actionError}</p>}
<div className="flex justify-end">
<Button type="button" onClick={() => void handleSetDisabled(false)} disabled={isBusy}>
Reativar
</Button>
</div>
</div>
)
} else {
const paused = state.status === 'paused'
body = (
<div className="space-y-4">
{paused ? (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm">
<p className="mb-1 font-medium text-amber-900">Pausado — re-tentando</p>
<p className="text-amber-800">
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.
</p>
{state.lastErrorAt ? (
<p className="mt-2 text-xs text-amber-700">
Última tentativa: {formatBahiaDateTimeLabel(state.lastErrorAt)} —{' '}
{state.lastError ?? 'erro desconhecido'}
</p>
) : null}
</div>
) : (
<p className="rounded-lg bg-green-50 p-3 text-sm text-green-800">
Sincronizado — as mudanças da agenda já refletiram no Google.
{state.lastSuccessAt
? ` Última sincronização: ${formatBahiaDateTimeLabel(state.lastSuccessAt)}.`
: ''}
</p>
)}
{linkBlock}
{instructions}
{actionError && <p className="text-sm text-red-600">{actionError}</p>}
<div className="flex justify-end gap-2">
{paused ? (
<Button asChild variant="outline">
<a
href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLinkIcon className="mr-2 h-4 w-4" />
Abrir Google Calendar
</a>
</Button>
) : null}
{syncButton}
<Button
type="button"
variant="outline"
onClick={() => void handleSetDisabled(true)}
disabled={isBusy}
>
Desativar
</Button>
</div>
</div>
)
}
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 (
<Drawer open={open} onOpenChange={handleOpenChange} showSwipeHandle>
<DrawerContent className="max-h-[85vh]">
<DrawerHeader className="text-left">
<DrawerTitle>Agenda da Campanha no Google</DrawerTitle>
<DrawerDescription>{description}</DrawerDescription>
</DrawerHeader>
<div className="overflow-y-auto px-4 pb-2">{body}</div>
<DrawerFooter className="border-t">
<DrawerCloseButton className="w-full">Fechar</DrawerCloseButton>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Agenda da Campanha no Google</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{body}
</DialogContent>
</Dialog>
)
}

View file

@ -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<Activity, 'locality' | 'tags' | 'deputyPresent'>
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
}

View file

@ -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',

View file

@ -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 => ({})

View file

@ -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<Activity, 'locality' | 'tags' | 'deputyPresent'>,
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)

View file

@ -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`

View file

@ -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<void> {
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<void> {
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;
`)
}

View file

@ -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',
},
];

View file

@ -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<false> | MunicipalityUpdateSelect<true>;
activity: ActivitySelect<false> | ActivitySelect<true>;
calendarFeed: CalendarFeedSelect<false> | CalendarFeedSelect<true>;
googleCalendarSync: GoogleCalendarSyncSelect<false> | GoogleCalendarSyncSelect<true>;
electionTally: ElectionTallySelect<false> | ElectionTallySelect<true>;
electionCandidateVote: ElectionCandidateVoteSelect<false> | ElectionCandidateVoteSelect<true>;
electionCandidate: ElectionCandidateSelect<false> | ElectionCandidateSelect<true>;
@ -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<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "googleCalendarSync_select".
*/
export interface GoogleCalendarSyncSelect<T extends boolean = true> {
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'

View file

@ -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,

View file

@ -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

View file

@ -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<Activity[]> => {
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

View file

@ -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<GoogleRemoteEvent[]>
insertEvent: (calendarId: string, event: GoogleRemoteEvent) => Promise<void>
updateEvent: (calendarId: string, eventId: string, event: GoogleRemoteEvent) => Promise<void>
deleteEvent: (calendarId: string, eventId: string) => Promise<void>
}
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<string> => {
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<string> => {
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<string> => {
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<Response> => {
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 }
}

View file

@ -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<GoogleCalendarSyncDoc | null> => {
// 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<GoogleCalendarSyncView> => {
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<number, string> }> => {
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<Set<number>> => {
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<void> => {
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<SyncCounts> => {
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<number, Activity>()
for (const activity of activities) {
wantedById.set(activity.id, activity)
}
let created = 0
let updated = 0
let deleted = 0
const remoteEventIds = new Set<string>()
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<GoogleCalendarSyncDoc, 'lastSyncedAt' | 'lastSuccessAt' | 'lastErrorAt' | 'lastError'>
>,
): Promise<void> => {
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<CampaignCalendarSyncOutcome> => {
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<string, unknown> | 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.
*/

View file

@ -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<unknown> => {
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<unknown> => {
try {
if (shouldSyncConfigChange({ operation, doc, previousDoc })) {
await runCampaignCalendarSync(req.payload, { reason: 'config-change', req })
}
} catch {
// never throw into admin writes
}
return doc
}

View file

@ -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()
})
})

View file

@ -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 <T>(run: () => Promise<T>): Promise<T> => {
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<string, unknown> = {}) => {
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)
})
})

View file

@ -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',

View file

@ -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<Record<string, unknown>> = []) => {
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<string, string> | 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<string, unknown>
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<string, unknown>
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)
})
})

View file

@ -0,0 +1,183 @@
import { describe, expect, it } from 'vitest'
import {
buildGoogleCalendarDescription,
buildGoogleEventPayload,
decodeGoogleEventActivityId,
googleEventContentEquals,
googleEventIdForActivity,
type GoogleRemoteEvent,
} from '@/lib/googleCalendarEventMapping'
type MappingSource = Parameters<typeof buildGoogleEventPayload>[0]
const baseActivity = (overrides: Partial<MappingSource> = {}): 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)
})
})

View file

@ -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',
)
})
})

View file

@ -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<string, unknown> = {}) =>
({ 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<string, unknown> = {}) =>
({
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)
})
})

View file

@ -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<typeof import('@/utilities/googleCalendarSync')>()
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<string, unknown> = {}) =>
({ id: 1, title: 'Caminhada', status: 'confirmado', ...overrides }) as unknown as Activity
const config = (overrides: Record<string, unknown> = {}) =>
({
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()
})
})