mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge pull request #701 from fsolla/C115-google-calendar-edicao-pela-agenda-pessoal-volta-para-t
C115 — Google Calendar: edição pela agenda pessoal volta para Teqo (bidirecional)
This commit is contained in:
commit
8fbf2c6018
28 changed files with 2330 additions and 58 deletions
|
|
@ -4,9 +4,9 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
---
|
||||
|
||||
**Recently resolved (2026-08-11):** **C126** (Issue #686) — os int specs do espelho Google (C114) deixam de flakear sob a suíte paralela: o motor ganhou **escopo injetável** `activityWhere?: Where` em `CampaignCalendarSyncOptions` (`src/utilities/googleCalendarSync.ts`) — appendado ao `and` do push set (`loadSyncActivities`) **e** do delete-guard set (`loadAliveActivityIds`); produção (hooks + action) omite o param → espelho cheio byte-identical. A race era mais ampla que o par da Issue: **5** specs criam atividades in-window no banco compartilhado (`campaignActivity`, `calendarFeed`, `homeSearchActivities`, `municipalityDossierData` + `campaignSuggestions` com `realizado` no lookback — o 5º que a Issue não nomeou), e `outcome.created` global contava todas; serialização não era construtível (vitest não serializa par-a-par; `fileParallelism:false` mataria o paralelismo da suíte) e "nunca relaxar a asserção" foi mantido à letra. O spec roda cada pass via helper `runSync(client, reason)` com `activityWhere: { title: { like: 'C114%' } }` (mesmo filtro do afterEach) e ganhou **dois pins**: (1) atividade estrangeira in-window **não** é espelhada; (2) o delete-guard honra o escopo (evento de atividade alheia viva é removido — espelho escopado é autoritativo no seu escopo). **Conflito com o C122 (#705, mergeado antes):** o C122 endereçou a C126 pela abordagem oposta (asserções globais **removidas** em favor de scoping por event-id, com delegação explícita do desfecho: "quem a detém decide") — a resolução do rebase mantém o contrato da Issue (contagens globais restauradas e determinísticas via escopo) e absorve a infra do C122 (credential compartilhada `googleCalendarTestKey`, cleanup de config por owned id, datas C127 derivadas do agora). **Contraprova:** fix 3/3 rodadas × 5 arquivos verdes (62/62); motor antigo re-flakeia 3–5 testes/rodada nas mesmas condições, e o pin 1 falha 100% — regressão viva. Gates: unit 2050, int 747, lint/typecheck/format/knip/cycles/build verdes. Sem migration/access/Consent/UI. Débito C127/#688 (datas hardcoded) já resolvido pelo C122 (#705). Impl [`c126-espelho-google-int-flake-isolamento-impl.md`](plans/c126-espelho-google-int-flake-isolamento-impl.md) (Issue = spec, sem intenção própria).
|
||||
**Recently resolved (2026-08-11):** **C115** (Issue #636) — **Google Calendar → Teqo**: quem tem permissão no calendário remarca/renomeia/cancela o compromisso direto no Google e a mudança volta para a atividade (bidirecional sobre o espelho C114). **Motor único bidirecional** no `runSyncPass` do C114 (estende o dono): para cada evento nosso — conteúdo difere? → **regra do relógio**: Google mais novo aplica **título + horário** (all-day incluído; prefixo `[Município] ` só é destripado quando bate com o município — desfazemos só a nossa prefixação; cap 160) com **registro auditável** no `updates` feed (`Google Calendar: …`, `author: null`, feed mostra "Google Calendar" como autor pelo prefixo) — senão o Teqo re-asserta. **Baseline do relógio = `activity.lastMirroredChangeAt`** (novo campo system-write, carimbado no beforeChange só quando um campo espelhado muda): task toggle/update/resultado bumpam `updatedAt` sem tocar o espelho — comparar contra `updatedAt` silenciaria uma edição pendente do Google. **Escrita reversa sem usuário** (`req.user` stripped): staff que dispara a passada não ganha autoria do registro nem passa pelo gate de re-agendamento do deputado. **Cancelamento:** `listEvents` com `showDeleted=true` — evento `status: 'cancelled'` (lixeira) com atividade `confirmado` → cancela **se o cancelamento for mais novo que o último carimbo** (senão é reopen do staff: o trashed é limpo e o evento recriado — nunca re-cancela); `realizado` é histórico protegido. **Remoção permanente** distinguida de "nunca criado" por snapshot `lastSeenEventIds` (JSONB `{calendarId, ids}`, gravado só em passada bem-sucedida e **excluindo ids que o próprio motor deletou** — senão o reopen virava re-cancelamento fantasma); troca de `calendarId` → snapshot stale → recria sem cancelar. **Webhook público** `POST /campanha/agenda/google-webhook/[secret]` (fora de `(app)`, precedente `ical/[secret]`, `maxDuration=60`, `force-dynamic`, allowlist do `campaignJsonMutationRoute` com razão): pre-filter barato (secret ≥32 + headers) ANTES do bootstrap do Payload + validação fail-closed em lib pura testada (`isGooglePushNotificationValid` — secret da URL + `X-Goog-Channel-ID` + `X-Goog-Resource-ID` + `X-Goog-Channel-Token`, constant-time, 404; `not_exists` → estado de erro; `sync` → ignora); ping sem payload → passada completa; 200 mesmo se a passada falhar (sem retry storm). **Canal lazy sem cron (Hobby):** `ensureGoogleCalendarPushChannel` em toda passada — watch com UUID novo, TTL 30d, **segredo rotaciona a cada criação** (URL vazada auto-cura na renovação), **novo canal persistido ANTES do stop do antigo** (falha do stop não orfana o novo); expira em <48h → watch com id novo + `channels.stop` do antigo; troca de calendário → re-watch; falha → `pushChannelError` (state), nunca pausa (o reverso roda em todo trigger local; canal só dá imediatismo — limitação de 30d de silêncio documentada). Campos novos na `googleCalendarSync` (system-write; **identity fields com `read: false`** — o conjunto de credenciais do webhook nunca sai por path de usuário; create admin-only + leitura prefere linha configurada); migration **hand-written** (mesmo motivo do C114). **Título editável pelo Google** exigiu `mutationKind: 'googleCalendarSync'` dedicado (título muda, slug canônico congelado) + **skip do hook de sync na própria escrita reversa** (sem passada aninhada). UI (Impeccable B): seção "Edições pelo Google" no dialog (copy + expiração do canal + erro). Testes: unit (reverse mapping 18, validação do webhook 7, dialog/feed render 6, allowlist), int (13 novos no motor com stub: Google novo aplica+converge, task toggle não silencia edição pendente, Teqo novo re-asserta, cancelled cancela+limpa, reopen pós-cancelamento não re-cancela, remoção permanente via snapshot, create falhou não cancela, troca de calendário não cancela+re-watch, all-day ida-e-volta, realizado protegido, description-only sem registro, ciclo do canal cria/renova/rotaciona/stop, falha de canal não pausa — asserções herméticas a atividades ambiente de arquivos irmãos no DB compartilhado), e2e (webhook 404 fail-closed + caminhos 200 validados: sync/not_exists/change). Suíte completa verde (unit 2064, int 749, e2e 5/5). Intenção [`c115-google-calendar-edicao-bidirecional.md`](plans/c115-google-calendar-edicao-bidirecional.md); impl [`c115-google-calendar-edicao-bidirecional-impl.md`](plans/c115-google-calendar-edicao-bidirecional-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-11):** **OPS41** (Issue #689) — o `AGENTS.md` de main carregava **marcadores de conflito commitados** no bullet "Per-worktree environments" (` <<<<<<< HEAD` indentado + lado HEAD pré-OPS33 com artefato `- # ` + fechamento corrompido `> > > > > > > 9e18bec8`; o `=======` foi removido no próprio commit). Origem: o commit OPS33 f0f5b73f entrou com o arquivo **já conflitado** (resolve do conflito OPS33×OPS28/OPS31 commitado com marcadores); OPS37 herdou o estado. Resolução **perda zero validada por diff**: manter o lado superset (OPS28 + OPS31 + OPS33 — claim determinístico, `--issue N`, prompt citado) e descartar o lado HEAD; −3 linhas no diff; scan whitespace-tolerant de todos os arquivos trackeados confirmou `AGENTS.md` como único afetado. **Guardrail anti-reincidência** (2ª vez que marcador chega a main): novo describe `no conflict markers in committed files` em `tests/unit/codebaseConventions.unit.spec.ts` — varre `git ls-files`, falha em `<<<<<<<`/`>>>>>>>` (tolerante a indentação) e na forma corrompida commitada `^\s*(> ){7}`; roda no CI via `pnpm test:unit`. Sem migration/access/Consent/UI/schema. Intenção: body da Issue (sem plano linkado); impl [`agents-md-sem-marcadores-conflito-impl.md`](plans/agents-md-sem-marcadores-conflito-impl.md).
|
||||
**Recently resolved (2026-08-11):** **C115** (Issue #636) — **Google Calendar → Teqo**: quem tem permissão no calendário remarca/renomeia/cancela o compromisso direto no Google e a mudança volta para a atividade (bidirecional sobre o espelho C114). **Motor único bidirecional** no `runSyncPass` do C114 (estende o dono): para cada evento nosso — conteúdo difere? → **regra do relógio**: Google mais novo aplica **título + horário** (all-day incluído; prefixo `[Município] ` só é destripado quando bate com o município — desfazemos só a nossa prefixação; cap 160) com **registro auditável** no `updates` feed (`Google Calendar: …`, `author: null`, feed mostra "Google Calendar" como autor pelo prefixo) — senão o Teqo re-asserta. **Baseline do relógio = `activity.lastMirroredChangeAt`** (novo campo system-write, carimbado no beforeChange só quando um campo espelhado muda): task toggle/update/resultado bumpam `updatedAt` sem tocar o espelho — comparar contra `updatedAt` silenciaria uma edição pendente do Google. **Escrita reversa sem usuário** (`req.user` stripped): staff que dispara a passada não ganha autoria do registro nem passa pelo gate de re-agendamento do deputado. **Cancelamento:** `listEvents` com `showDeleted=true` — evento `status: 'cancelled'` (lixeira) com atividade `confirmado` → cancela **se o cancelamento for mais novo que o último carimbo** (senão é reopen do staff: o trashed é limpo e o evento recriado — nunca re-cancela); `realizado` é histórico protegido. **Remoção permanente** distinguida de "nunca criado" por snapshot `lastSeenEventIds` (JSONB `{calendarId, ids}`, gravado só em passada bem-sucedida e **excluindo ids que o próprio motor deletou** — senão o reopen virava re-cancelamento fantasma); troca de `calendarId` → snapshot stale → recria sem cancelar. **Webhook público** `POST /campanha/agenda/google-webhook/[secret]` (fora de `(app)`, precedente `ical/[secret]`, `maxDuration=60`, `force-dynamic`, allowlist do `campaignJsonMutationRoute` com razão): pre-filter barato (secret ≥32 + headers) ANTES do bootstrap do Payload + validação fail-closed em lib pura testada (`isGooglePushNotificationValid` — secret da URL + `X-Goog-Channel-ID` + `X-Goog-Resource-ID` + `X-Goog-Channel-Token`, constant-time, 404; `not_exists` → estado de erro; `sync` → ignora); ping sem payload → passada completa; 200 mesmo se a passada falhar (sem retry storm). **Canal lazy sem cron (Hobby):** `ensureGoogleCalendarPushChannel` em toda passada — watch com UUID novo, TTL 30d, **segredo rotaciona a cada criação** (URL vazada auto-cura na renovação), **novo canal persistido ANTES do stop do antigo** (falha do stop não orfana o novo); expira em <48h → watch com id novo + `channels.stop` do antigo; troca de calendário → re-watch; falha → `pushChannelError` (state), nunca pausa (o reverso roda em todo trigger local; canal só dá imediatismo — limitação de 30d de silêncio documentada). Campos novos na `googleCalendarSync` (system-write; **identity fields com `read: false`** — o conjunto de credenciais do webhook nunca sai por path de usuário; create admin-only + leitura prefere linha configurada); migration **hand-written** (mesmo motivo do C114). **Título editável pelo Google** exigiu `mutationKind: 'googleCalendarSync'` dedicado (título muda, slug canônico congelado) + **skip do hook de sync na própria escrita reversa** (sem passada aninhada). UI (Impeccable B): seção "Edições pelo Google" no dialog (copy + expiração do canal + erro). Testes: unit (reverse mapping 18, validação do webhook 7, dialog/feed render 6, allowlist), int (13 novos no motor com stub: Google novo aplica+converge, task toggle não silencia edição pendente, Teqo novo re-asserta, cancelled cancela+limpa, reopen pós-cancelamento não re-cancela, remoção permanente via snapshot, create falhou não cancela, troca de calendário não cancela+re-watch, all-day ida-e-volta, realizado protegido, description-only sem registro, ciclo do canal cria/renova/rotaciona/stop, falha de canal não pausa — asserções herméticas a atividades ambiente de arquivos irmãos no DB compartilhado), e2e (webhook 404 fail-closed + caminhos 200 validados: sync/not_exists/change). Suíte completa verde (unit 2064, int 749, e2e 5/5). Intenção [`c115-google-calendar-edicao-bidirecional.md`](plans/c115-google-calendar-edicao-bidirecional.md); impl [`c115-google-calendar-edicao-bidirecional-impl.md`](plans/c115-google-calendar-edicao-bidirecional-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-11):** **Plan-issue — rascunho UI/UX do gate vira HTML+Tailwind commitado no repo (ad-hoc, decisão do usuário).** O artefato deixa de ser **Cursor canvas** (`~/.cursor/projects/<workspace>/canvases/plan-<id>-ui-draft.canvas.tsx`, fora do repo, só renderizava no IDE de quem planejou e apodrecia) e passa a ser **HTML + Tailwind v4** (`docs/plans/<slug>-ui-draft.html` — `@tailwindcss/browser@4` via jsdelivr, mesma major do app 4.1.x; rede só na hora de renderizar) **commitado com o plano** e **renderizado em PNG** por `pnpm ui-draft:render` (`scripts/render-ui-draft.mjs`, Chromium do Playwright via `@playwright/test`, fallback `load` se o CDN não settle — nunca toca banco/app): uma PNG por cena `<section data-shot="<rótulo>">` (ex. `mobile`, `desktop-vazio`) → `docs/plans/<slug>-ui-draft-<rótulo>.png`, ou full-page sem `data-shot`; PNGs **embutidos no plano** (visíveis no chat do gate E no PR — o aceite vira registro permanente; o HTML é a fonte iterável). **Contrato de fidelidade explícito em `ui-draft-html.md`** (substitui `ui-draft-canvas.md`, deletado; refs atualizadas em SKILL.md/frontmatter, intention-template.md — campo renomeado `Canvas UI:` → `Rascunho UI:` + secção de embed, skills-map.md, shaping.md): o rascunho DEVE ter layout real, espaçamentos/tamanhos na escala do app (`p-4`, `gap-3`, `h-11`, `text-sm`), breakpoints reais (390/1280) e estados críticos como cenas estáticas; é PROIBIDO ser funcional (zero JS de comportamento — cada estado é cena separada), usar código de app (`src/`), brand/polish (gradientes/emojis/sombras/logo/paleta do produto — cinza neutro + um acento), mock de dados realistas ou decisão de engenharia. Gate: iterar HTML → re-render → embutir até confirmação explícita. Verificado: render real do skeleton (classes v4 aplicadas — oklch zinc, rounded-lg 8px, text-lg 18px). Sem migration/access/Consent/UI de produto/schema. Sem issue — ad-hoc no worktree `work/5`.
|
||||
**Recently resolved (2026-08-11):** **OPS40** (Issue #682) — `pnpm dev` honra o `PORT` do `.env.local`: `next dev` resolve a porta **antes** de `@next/env` carregar env-files (commander `.env('PORT')` no `bin/next` — causa confirmada no fonte 15.4.11), então o `PORT=3100+slot` do provisionador de worktree era ignorado e todo dev server subia em 3000 (colisão entre worktrees paralelos; o e2e escapava porque o webServer injeta `PORT` no env real). **`pnpm dev`/`devsafe` agora passam por `scripts/dev.mjs`** (wrapper): importa o `guard-dev-db.mjs` (preflight fail-closed inline, mesma saída), `loadCliEnv()` (`.env.local` → `.env`, `override:false`) e entrega a porta ao `next dev` como **flag CLI `-p`** — a única alavanca que o repo controla. Precedência: env real do processo (webServer do Playwright manda) → `.env.local` (worktree) → `.env` → sem `-p` (default 3000 + `allowRetry` do Next intactos no main repo). Derivação pura unit-testada em `scripts/lib/cli.mjs` (`resolveDevPort` — null quando ausente, throw fail-closed em PORT inválida; `nextDevArgs`); spawn `node next/dist/bin/next` com `NODE_OPTIONS=--no-deprecation` (paridade cross-env), stdio inherit, forward SIGINT/SIGTERM/SIGHUP, propagação de exit code; extras (`--turbopack`) repassados após o `-p` derivado. Sem migration/access/Consent/UI. Verificação: boot manual no worktree → "Local: http://localhost:3140" (antes 3000), unit 8/8 novos, e2e dev-mode `campaignAuth` 3/3 (2 runs de prewarm estouraram o orçamento sob load ~50+ da máquina — flake de contenção fichado B17/B27/B32/B42, terceira run verde com cache quente). Nota fora de escopo: `AGENTS.md` em `main` carrega marcadores de conflito commitados (OPS33/OPS37) — área de main-mess da OPS38; o novo bullet do Database section foi adicionado **fora** da região conflitada. Intenção [`worktree-dev-port-nao-honrado.md`](plans/worktree-dev-port-nao-honrado.md); impl [`worktree-dev-port-nao-honrado-impl.md`](plans/worktree-dev-port-nao-honrado-impl.md).
|
||||
|
|
|
|||
103
docs/plans/c115-followup-snapshot-race-e-hermeticidade-int.md
Normal file
103
docs/plans/c115-followup-snapshot-race-e-hermeticidade-int.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# C115 follow-up — corrida do snapshot de eventos + hermeticidade da suíte int do motor
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: <preenchido pelo agent:register>
|
||||
Priority: P3
|
||||
Model: cursor-grok-4.5-high
|
||||
Appetite: ~0,5–1 dia eng
|
||||
|
||||
## Contexto
|
||||
|
||||
Débitos do gate de revisão do C115 (três revisores /simplify), mesclados num
|
||||
lote único por terem o mesmo pai (#636) e superfície contígua (motor de sync +
|
||||
suas specs). O C115 entregou asserções resilientes a atividades ambiente —
|
||||
estas fases atacam a raiz e a corrida remanescente, em ordem (lock/race antes
|
||||
de higiene de teste).
|
||||
|
||||
## F1 — Snapshot `lastSeenEventIds` com last-writer-wins pode ressuscitar remoção permanente (race)
|
||||
|
||||
**Achado:** `recordSyncState` recarrega e sobrescreve o snapshot; duas passadas
|
||||
concorrentes (webhook + hook de atividade em instâncias Vercel distintas) cada
|
||||
uma grava o snapshot da própria visão remota. Se a passada B (visão antiga,
|
||||
iniciada antes de A criar o evento X) grava por último, X cai do snapshot; o
|
||||
usuário então remove X permanentemente no Google e a passada seguinte trata
|
||||
como "nunca criado" → recria o evento — a remoção é desfeita e não converge
|
||||
para a intenção do usuário (as escritas de evento convergem por conteúdo —
|
||||
D3/C114 — mas este estado novo não).
|
||||
|
||||
**Fases propostas (a decidir no impl):**
|
||||
|
||||
- A) **compare-and-swap otimista**: gravar o snapshot só se o `lastSuccessAt`
|
||||
lido na passada ainda for o mesmo (re-leitura + condição na escrita) —
|
||||
barato, sem lock.
|
||||
- B) advisory lock por passada (precedentes `postgresTransactionLocks`) —
|
||||
mais forte, mas serializa passadas (aceitável: passadas são segundos).
|
||||
- C) aceitar e documentar (janela de minutos, rara) — rejeitada na triage:
|
||||
write path, piso de score.
|
||||
|
||||
**Testes:** int com stub — duas passadas sobrepostas (client com delay
|
||||
injetável) + remoção permanente após a corrida → a remoção NÃO é ressuscitada.
|
||||
|
||||
## F2 — Hermeticidade da suíte int contra atividades ambiente
|
||||
|
||||
**Achado:** o motor espelha a janela inteira (espelho cheio, por design) e
|
||||
arquivos irmãos (`homeSearchActivities`, `campaignSuggestions`, …) mantêm
|
||||
atividades na janela pelo arquivo inteiro (`fixtures.own` limpa no afterAll) —
|
||||
as passadas do `googleCalendarSync.int.spec.ts` absorvem esses eventos nas
|
||||
contagens globais. Mitigado na entrega por asserções resilientes
|
||||
(`ownEvents(...)`), mas a raiz permanece: qualquer outra spec nova sensível à
|
||||
janela morde de novo.
|
||||
|
||||
**Fases propostas (a decidir no impl):**
|
||||
|
||||
- A) cleanup por teste nos arquivos irmãos (afterEach com prefixo próprio —
|
||||
toca specs alheias, mínimo e cirúrgico).
|
||||
- B) helper compartilhado de "atividades da janela com prefixo" para specs que
|
||||
criam atividades de passagem.
|
||||
- C) documentar a convenção no header do `googleCalendarSync.int.spec.ts`.
|
||||
|
||||
**Testes:** suíte int completa ×N sem falhas de contagem.
|
||||
|
||||
## Já resolvido no simplify (não reabrir)
|
||||
|
||||
- Reopen re-cancelado (snapshot com ids deletados pelo motor → `removedEventIds`
|
||||
separado).
|
||||
- Ramo `cancelled` sem regra do relógio (reopen perdia).
|
||||
- Reverse write com `req.user` herdado (autoria + gate do deputado) → user
|
||||
stripped + hook skip da própria escrita.
|
||||
- Baseline do relógio `updatedAt` → `lastMirroredChangeAt` (campo + carimbo).
|
||||
- Canal órfão quando `stopChannel` falha após o watch → novo canal persistido
|
||||
antes do stop.
|
||||
- `startEndEquals` duplicado → `googleStartEndInstantEquals` exportado.
|
||||
- Linhas múltiplas do `googleCalendarSync` → create admin-only + leitura
|
||||
prefere linha configurada.
|
||||
- Bootstrap do Payload pago por scanner → pre-filter de secret/headers.
|
||||
- Staff lendo credenciais do webhook (latente) → `read: false` nos identity
|
||||
fields.
|
||||
- Rotação de segredo do canal por criação.
|
||||
- Cobertura: all-day reverso, caminhos 200 do webhook (e2e), dialog/feed
|
||||
(render tests), slug congelado, `defaultColumns` + copy do dialog.
|
||||
|
||||
## Explicitamente fora
|
||||
|
||||
- **Token do canal igual ao secret da URL** (P3 do revisor 2): mitigado — o
|
||||
fator independente é o par `channelId`+`resourceId` (DB-held, `read: false`);
|
||||
URL leak sozinho não forja entrega. Reavaliar só se um leak de URL acontecer
|
||||
em produção.
|
||||
- **Escopo do reverse = ACL do calendário** (qualquer editor do calendário pode
|
||||
cancelar qualquer `confirmado`): decisão de produto travada na intenção
|
||||
("permissões por calendário, não por evento, em v1") — o modelo é o do
|
||||
compartilhamento do Google. Documentar no runbook de ops como consequência
|
||||
assumida.
|
||||
- **Truncamento silencioso de título >160**: cap do formulário, by design.
|
||||
- **Evento órfão para atividade remarcada além do lookahead de 365d**:
|
||||
comportamento da janela herdado do C114, fora do aceite. Gatilho: quando a
|
||||
agenda oficial precisar de horizonte maior.
|
||||
- **Rate limit por IP no webhook**: o pre-filter barato rejeita scanner noise
|
||||
antes do Payload; Vercel absorve o resto.
|
||||
|
||||
## Referências
|
||||
|
||||
- Impl C115: `docs/plans/c115-google-calendar-edicao-bidirecional-impl.md`
|
||||
- Issue pai: #636
|
||||
145
docs/plans/c115-google-calendar-edicao-bidirecional-impl.md
Normal file
145
docs/plans/c115-google-calendar-edicao-bidirecional-impl.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# Impl: C115 — Google Calendar: edição pela agenda pessoal volta para Teqo (bidirecional)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #636
|
||||
Intenção: docs/plans/c115-google-calendar-edicao-bidirecional.md
|
||||
Appetite restante: ~2–2,5 dias eng (herdado; corte explícito: criação no Google fora de v1; apenas título/horário/cancelamento voltam)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** candidato (e autorizados) edita/remarca/cancela um compromisso da Agenda da Campanha no Google Calendar e a mudança volta para o Teqo — a atividade é atualizada, o registro fica auditável e o espelho propaga para quem segue (mecanismo do C114).
|
||||
- **O que NÃO negociar:** sem criação de eventos pelo Google em v1; conflito = regra automática (mais recente vence) com registro, nunca tela; acessos do Teqo valem para o que vem do Google (leader lockdown intocado); Teqo permanece SoT do domínio — o Google é superfície de edição (horário/título/cancelamento) e o Teqo reescreve o evento a partir do próprio estado; Teqo funciona sem Google.
|
||||
- **O que reavaliar:** a intenção aponta "webhook do Google + sync incremental" — confirmado, com duas correções de engenharia: (1) a notificação do Google NÃO diz qual evento mudou ("bare ping", doc oficial) — o "sync incremental" da intenção vira uma passada completa de reconciliação (o motor C114 já lista a janela inteira; não há custo marginal relevante); (2) o canal de push exige renovação periódica e **não há cron no Hobby** — renovação lazy em cada trigger do motor (aceite "se renova sozinho" = sem ação manual, com a limitação de silêncio de 30 dias documentada). A intenção assume "edição mais recente vence" — o forward do C114 hoje sobrescreve QUALQUER diferença de conteúdo; o motor precisa virar bidirecional com regra de relógio, senão o forward clobbera a edição do Google (contradição com o aceite).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
G[Google Calendar<br/>usuário edita/remarca/cancela] -->|notificação push| W[webhook público<br/>campanha/agenda/google-webhook/[secret]]
|
||||
W -->|valida X-Goog-* + token<br/>200 imediato| R[passada bidirecional<br/>googleCalendarSync.ts]
|
||||
A[trigger local:<br/>hook activity / view agenda /<br/>Sincronizar agora] --> R
|
||||
R --> D[diff por id determinístico + relógio:<br/>Google mais novo → aplica no Teqo<br/>Teqo mais novo → re-asserta no Google<br/>status cancelled → cancela atividade]
|
||||
D --> T[(atividade atualizada<br/>+ registro no updates feed)]
|
||||
D --> C[ensurePushChannel<br/>watch/renova/stop — best-effort]
|
||||
C --> S[(estado do canal<br/>googleCalendarSync)]
|
||||
```
|
||||
|
||||
**Opções consideradas:** D1–D8 abaixo.
|
||||
**Recomendação:** um **motor único bidirecional** (regra de relógio decide a direção por evento), **webhook público com validação fail-closed** (URL secret + `X-Goog-Channel-ID` + `X-Goog-Resource-ID` + `X-Goog-Channel-Token`, comparação constant-time), **canal de push lazy** (ensure em toda passada; TTL 30 dias; renovação = watch com UUID novo + `channels.stop` do antigo), **cancelamento detectado por `showDeleted=true`** (status `cancelled` = usuário cancelou → cancela a atividade) **+ snapshot `lastSeenEventIds`** (remoção permanente ≠ nunca criado), **registro no `updates` feed** da atividade com prefixo "Google Calendar:" (sem schema novo de updates).
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
**D1 — Detecção Google → Teqo.**
|
||||
Opções: A) **push channel** (`events.watch`, webhook `web_hook`) + passada completa ao receber | B) polling com cron | C) `syncToken` incremental.
|
||||
Recomendação: **A** — latência de segundos (aceite "minutos"), sem cron no Hobby; a notificação é um ping sem payload ("nothing about the change itself", doc oficial), então o handler roda a mesma passada completa do motor — que já lista a janela inteira (D3 C114), sem custo marginal real (centenas de eventos).
|
||||
Rejeitadas: B (sem cron no Hobby; cadência de horas não entrega o aceite); C (syncToken expira em ~30d, adiciona estado e não economiza nada — a passada já é full-list; o ping → passada completa é o fluxo documentado do Google).
|
||||
|
||||
**D2 — Um motor bidirecional (não dois caminhos).**
|
||||
Opções: A) **regra de relógio dentro do `runSyncPass` atual**: para cada evento nosso, conteúdo difere? → quem é mais novo aplica (Google → update na atividade; Teqo → update no evento) | B) passada reversa separada rodando antes do forward | C) só forward + reverter edições do Google no próximo ciclo.
|
||||
Recomendação: **A** — um único caminho de código (uma superfície de teste), e é o único jeito de a regra "mais recente vence" valer em TODOS os triggers: sem A, o forward (disparado por qualquer escrita no Teqo) sobrescreveria a edição do Google antes de ela ser aplicada. "Edit the owner": o dono da reconciliação é o motor C114 — estende, não cria irmão.
|
||||
Rejeitadas: B (duas passadas = chamadas duplicadas + orquestração; a decisão de direção É o relógio, um único diff resolve); C (viola o aceite — a edição do Google seria revertida na passada seguinte).
|
||||
|
||||
**D3 — Regra de conflito (relógio).**
|
||||
Opções: A) **vence o mais novo**: `remote.updated > activity.updatedAt + tolerância (2s)` → aplica edição do Google; senão Teqo re-asserta (forward) | B) Teqo sempre vence | C) pausa o evento.
|
||||
Recomendação: **A** — decisão travada da intenção; content-compare roda ANTES do relógio (passadas convergem sem tocar em nada, D3 C114); a tolerância de 2s absorve skew NTP entre Google e o banco (ambos NTP); quando NOSSA escrita gera notificação, conteúdo é igual → no-op (sem loop). O relógio só decide quando há diferença real de conteúdo.
|
||||
Rejeitadas: B (trai o sentido de "editar pelo Google"); C (vira a tela de conflito cortada na intenção).
|
||||
|
||||
**D4 — Cancelamento pelo Google.**
|
||||
Opções: A) **`showDeleted=true` no list + snapshot `lastSeenEventIds`**: evento `status: 'cancelled'` (lixeira do Google) com atividade `confirmado` → cancela a atividade; evento sumiu por completo com id no snapshot da passada anterior → usuário removeu permanentemente → cancela; id fora do snapshot → nunca criado (calendário novo) → cria | B) todo "sumiu" = cancelar | C) todo "sumiu" = recriar.
|
||||
Recomendação: **A** — a lixeira é o sinal direto de cancelamento (o UI padrão do Google trashes, não remove); a remoção permanente só se distingue de "nunca criado" com o snapshot (a passada anterior viu o id). Snapshot = JSONB `lastSeenEventIds` na collection de sync (estado transitório de reconciliação, NÃO duplicação do mapeamento — o id determinístico D2/C114 continua sendo a identidade). Só grava em passada BEM-SUCEDIDA: um create que falhou não entra no snapshot → próxima passada cria (não cancela). Cancelação só quando `activity.status === 'confirmado'` (compromissos vivos): `realizado` é histórico protegido (no-op; o evento trashed fica invisível na lixeira do usuário). Após cancelar, o forward da passada seguinte apaga o trashed permanentemente (convergência; o C114 já exclui eventos de atividades não vivas).
|
||||
Rejeitadas: B (quebraria a primeira sincronização: calendário novo → todas as atividades seriam canceladas); C (ressuscita remoção deliberada do usuário e é a semântica atual do C114 que o C115 precisa substituir).
|
||||
|
||||
**D5 — Canal de push: criação e renovação lazy (sem cron).**
|
||||
Opções: A) **ensure na passada do motor**: sem canal → `events.watch` (UUID novo, `token` = segredo da URL, TTL 30 dias via `params.ttl`); expira em <48h → watch com **UUID novo** (doc oficial: "you must use a unique value for the id property of the new channel") + `channels.stop` do antigo; falha → `pushChannelError` (state), **nunca** `paused` | B) cron de renovação | C) renovar com o mesmo id.
|
||||
Recomendação: **A** — todo trigger existente do C114 (hook de activity, view da agenda, "Sincronizar agora", config-change) vira também renovador; TTL máximo (~30d, limite interno do Google) minimiza renovações; a falha do canal NUNCA pausa o sync (o reverso continua funcionando pelos triggers locais — toda passada é bidirecional, o canal só dá imediatismo). Address = `getCampaignInviteBaseURL()` + `/campanha/agenda/google-webhook/<secret>` — **C98 lesson**: `NEXT_PUBLIC_SITE_URL` obrigatória em prod (sem ela, ensure falha com `pushChannelError` legível, não quebra nada).
|
||||
Rejeitadas: B (sem cron no Hobby); C (doc oficial exige id único por canal; re-watch com mesmo id tem comportamento indefinido).
|
||||
Limitação documentada no runbook: com 30 dias de silêncio total (nenhuma escrita no Teqo, nenhuma view, nenhuma notificação) o canal expira e o reverso passa a depender do próximo trigger local — dentro do "renova sozinho" (nunca ação manual), com a lacuna de silêncio declarada.
|
||||
|
||||
**D6 — Webhook público (superfície nova, fail-closed).**
|
||||
Opções: A) **rota `POST /campanha/agenda/google-webhook/[secret]/route.ts` fora de `(app)`** (precedente `agenda/ical/[secret]`): segredo na URL (≥32 chars) + `X-Goog-Channel-ID` == `pushChannelId` + `X-Goog-Resource-ID` == `pushChannelResourceId` + `X-Goog-Channel-Token` == token — todas comparações constant-time; falha → 404 (precedente iCal: não revela existência) | B) assinatura de corpo | C) rodar a passada em background pós-response.
|
||||
Recomendação: **A** — 200 imediato após validação; passada inline (`export const maxDuration = 60` — Hobby permite até 60s; uma passada típica é 1 list + N updates, segundos); erros da passada NÃO viram não-200 (o Google re-tentaria e replicaria trabalho; o auto-retry local já cobre); `X-Goog-Resource-State: sync` (notificação de criação de canal) → ignora (passada já roda nos triggers). `not_exists` (recurso apagado) → estado de erro no sync ("calendário não existe mais").
|
||||
Rejeitadas: B (o corpo do push do Calendar é vazio por contrato — a autenticidade É o token + ids, que o Google documenta para isso); C (serverless congela após o response; trabalho perdido + Google re-tenta — pior que inline).
|
||||
|
||||
**D7 — Estado do canal e snapshot (collection `googleCalendarSync` + migration).**
|
||||
Opções: A) **campos novos na collection existente** (system-write via `canSetGoogleCalendarSyncSystemField`): `pushChannelId`, `pushChannelResourceId`, `pushChannelExpiresAt`, `pushChannelSecret`, `pushChannelError` + `lastSeenEventIds` (json) | B) collection separada de canais | C) tudo em env.
|
||||
Recomendação: **A** — "edit the owner": a collection É o recurso de sync (C114 D5); estado do canal vive junto do estado do sync; `pushChannelSecret` segue o precedente `calendarFeed.secretSlug` (credencial de link em DB, não em env — env exigiria redeploy para rotacionar); o config hook (`shouldSyncConfigChange`) só olha `calendarId`/`disabledAt` → escritas de estado nunca re-disparam (guard existente, sem loop). `calendarId` trocado → canal antigo é stopado na ensure seguinte e o snapshot é re-derivado por passada (o novo calendário lista vazio → cria tudo de novo — D7 C114 preservado).
|
||||
Rejeitadas: B (mais uma collection/access para um estado de 5 campos); C (calendarId/channel rotacionariam por redeploy).
|
||||
|
||||
**D8 — Registro da edição ("com registro").**
|
||||
Opções: A) **entrada no `updates` feed da atividade** com body prefixado `Google Calendar: …` e `author: null`; o card mostra "Google Calendar" como autor quando o body tem o prefixo (fallback do `ActivityUpdateFeed`, mudança contida) | B) log no `googleCalendarSync` | C) notification do Teqo.
|
||||
Recomendação: **A** — o registro é visível no contexto da atividade (a equipe vê "remarcada de X para Y pelo Google Calendar" na aba Atualizações), sem schema novo (o array `updates` e a derivação do `deriveActivityFields` já existem; `author: null` passa pelo passthrough); a notificação Teqo fica de fora (o aceite manda "mesmo mecanismo do C114" = aviso do próprio Google aos seguidores).
|
||||
Rejeitadas: B (invisível para a equipe no contexto do compromisso); C (novo tipo de notification para um caso que o feed já conta).
|
||||
|
||||
**D9 — Campos reversos editáveis (v1).**
|
||||
Opções: A) **título + horário**: `summary` → `title` (strip de `[<nome do município>] ` apenas quando o prefixo bate com o município da atividade — desfazemos só a nossa própria prefixação; resto vira título inteiro); `start`/`end` do evento → `startAt`/`endAt`/`allDay` (presença de `date` vs `dateTime` decide all-day; converte com `activityAllDay`); somente atividades `status: 'confirmado'` | B) incluir location/description | C) permitir em qualquer status.
|
||||
Recomendação: **A** — o aceite lista data/hora/título/cancelamento; locality/description/location/tags são campos do Teqo (a regra estrutural da intenção: Google é espelho para o resto — o forward re-asserta). `confirmado`-only protege `realizado` (histórico) e `cancelado` (Teqo SoT). A validação local (`validateActivitySchedule`) roda na aplicação: valor remoto malformado → o update falha → o Teqo mantém o estado e o forward re-asserta (fail-safe embutido).
|
||||
Rejeitadas: B (descrição é rica em campos do Teqo; parse de volta = fragile e fora do aceite); C (editar realizado via Google corrompe histórico; cancelado no Teqo já é gerenciado).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/lib/googleCalendarReverseEdit.ts`** (novo, puro, unit-testável): `googleTitleFromSummary(summary, municipalityName)` (strip condicional do prefixo), `googleScheduleToActivityFields(event)` → `{ startAt, endAt, allDay }` ou `null` (malformado), `buildGoogleReverseUpdateBody(kind, antes→depois)` (pt-BR, prefixo `Google Calendar: `), `GOOGLE_REVERSE_EDIT_BODY_PREFIX` (compartilhado com o feed UI), `googleEditIsNewer(remoteUpdated, activityUpdatedAt, toleranceMs)` (regra do relógio pura).
|
||||
- **`src/lib/googleCalendarEventMapping.ts`** (editar): `GoogleRemoteEvent` ganha `status?: string` e `updated?: string` (o list passa a devolver lixeira).
|
||||
- **`src/utilities/googleCalendarClient.ts`** (editar): `listEvents` passa `showDeleted: true` (sem mudança de assinatura — o stub não é afetado); novos métodos `watchEvents(calendarId, { id, address, token, ttlSeconds })` → `{ id, resourceId, expiration }` e `stopChannel(id, resourceId)` (`POST /channels/stop`); `GoogleCalendarClient` (e stub de teste) crescem junto.
|
||||
- **`src/utilities/googleCalendarSync.ts`** (editar — dono do motor): `runSyncPass` vira **bidirecional** (D2/D3/D4): por evento nosso — `status cancelled` → cancela atividade (`confirmado`); conteúdo difere → relógio decide (reverse via `payload.update` com `updates` + body prefixado, `overrideAccess: true` — sem usuário, precedente dos hooks; ou forward `updateEvent`); conteúdo igual → no-op; atividade querida sem evento → snapshot decide (cria vs cancela); snapshot `lastSeenEventIds` gravado no fim de cada passada bem-sucedida; `ensureGoogleCalendarPushChannel(payload, config, client)` (D5) chamado no início (best-effort; falha → `pushChannelError`, nunca `paused`); estados `not_exists` → `lastError` explicativo. `deriveGoogleCalendarSyncStatus` ganha a leitura do canal só para a UI (não muda a matriz — canal nunca pausa).
|
||||
- **`src/collections/GoogleCalendarSync.ts`** (editar): campos novos D7 (system-write, `admin.readOnly`); `pushChannelExpiresAt` aparece nos defaultColumns.
|
||||
- **Migration:** `migrate:create add_google_calendar_push_channel` (6 colunas: 4 text + 1 date + 1 json).
|
||||
- **Rota pública** `src/app/(campaign)/campanha/agenda/google-webhook/[secret]/route.ts` (novo): `POST` (e `GET` → 404) fora de `(app)`, precedente `agenda/ical/[secret]`; validação D6; `export const dynamic = 'force-dynamic'`, `maxDuration = 60`; responde 200 e roda a passada; `not_exists` → estado de erro.
|
||||
- **`src/components/campaign/activity/ActivityUpdateFeed.tsx`** (editar): fallback de autor — body com prefixo → "Google Calendar"; senão mantém "Autor removido".
|
||||
- **`src/components/campaign/activity/GoogleCalendarSyncDialog.tsx`** (editar, Impeccable B — shape→craft→critique→polish): quando `synced`, seção "Edições pelo Google" com copy (candidato/autores editam no Google e volta para o Teqo) + estado do canal (ativa até <data> quando o canal existe; mensagem de `pushChannelError` quando presente). Nada novo de ação — o toggle/retry existentes cobrem.
|
||||
- **Server actions** (sem mudança): `runGoogleCalendarSyncNow` e a view da agenda já chamam o motor — agora bidirecional, o mesmo caminho traz o reverso.
|
||||
- **`docs/CHANGELOG-AGENTS.md`** (editar): UMA entrada curta C115.
|
||||
- **Access / Consent:** sem chave de Consent (dados internos da campanha, precedente C114); sem PII nova; o webhook é fail-closed (nunca processa sem canal/token válidos); a escrita reversa respeita os guards de hook do Activity (o guard de leader não se aplica — sem `req.user`); `overrideAccess` apenas onde o C114 já usa (engine), nunca em paths de usuário.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
N/A — não há apresentação de dados nova no Teqo; o único dado novo visível é o registro no `updates` feed (forma já existente) e o estado do canal no dialog (linha de texto derivada, sem forma nova).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / schema+server** (dia 1): migration + collection fields + `generate:types`; client (`showDeleted`, `watchEvents`, `stopChannel`, `status`/`updated` no tipo); `googleCalendarReverseEdit` + unit tests; motor bidirecional + ensure de canal + int tests com stub (edição do Google mais nova → aplica; Teqo mais nova → re-asserta; `cancelled` → cancela; remoção permanente via snapshot → cancela; calendário novo → cria tudo; create falhou → cria na próxima, não cancela; canal: cria/renova/stop/falha não pausa; loop de re-entrada: passada após reverse é no-op); rota webhook + validação (unit: headers falsos → 404; token errado → 404); `migrate` local + `migrate:status`.
|
||||
2. **UI** (dia 1–2): dialog (seção reverso + estado do canal), fallback do feed; e2e: webhook com segredo inválido → 404 (determinístico, sem Google), dialog mostra a seção quando sincronizado; 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)
|
||||
|
||||
- **Verificação de domínio do callback no Google Cloud** — se o Google exigir (alguns serviços pedem domínio verificado no console), é ops one-time (pt.jorgesolla.com.br); não bloqueia o código (falha → `pushChannelError` legível).
|
||||
- **`syncToken` incremental** — D1; a passada full-list já cobre.
|
||||
- **Participantes/attendees** — corte herdado do C114 (P1).
|
||||
- **Criação pelo Google** — anti-goal da intenção (sem município não entra).
|
||||
- **Recorrências/todos-os-dias complexos** — mapa de campos do espelho (C114), fora do aceite.
|
||||
- **Notificação Teqo (sino) para edições reversas** — o feed é o registro; aviso aos seguidores é do Google.
|
||||
- **Editar locality/description/location pelo Google** — D9; o forward re-asserta.
|
||||
- **Lock distribuído da passada** — corridas convergem por conteúdo (herdado D3 C114); canal duplicado em renovação = notificações duplicadas, idempotentes.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Semântica do push/`showDeleted` desconhecida na prática** — verificar no doc oficial durante o impl (source-driven-development); os int tests com stub travam NOSSO contrato, não o do Google.
|
||||
- **Loop de re-entrada** (reverse aplica → hook roda passada) — conteúdo igual após aplicar → no-op; termina na segunda passada; verificado em int test.
|
||||
- **Skew de relógio** Google × banco — tolerância 2s (D3); ambos NTP.
|
||||
- **Canal morto sem trigger (30d de silêncio)** — limitação documentada no runbook; todo trigger local renova; a falha fica visível no dialog (`pushChannelError`/expiração).
|
||||
- **maxDuration no webhook** — passada típica em segundos; `maxDuration = 60`; em pico o Google re-tenta (não-200) e o auto-retry local cobre.
|
||||
- **Valores remotos malformados** — `validateActivitySchedule` rejeita → Teqo mantém estado e re-asserta (fail-safe).
|
||||
- **Ressurreição por restore do Google** — usuário restaura evento cancelado → atividade já cancelada no Teqo → forward apaga de novo (Teqo SoT, documentado).
|
||||
- **E2E com Google real** — webhook e2e só cobre fail-closed sem canal (404); motor testado em int com stub (precedente C114).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto: edição de título/horário e cancelamento feitos no Google refletem na atividade do Teqo (com registro no feed) e o espelho propaga; criação não existe em v1; conflito = relógio, sem tela; leader lockdown intocado (sem superfície e sem escrita reversa para líder — `confirmado`-only, engine sem usuário); Teqo funciona sem Google (canal é best-effort; falha → estado, nunca quebra escrita).
|
||||
- [ ] Regra de conflito documentada e testada: mais recente vence com tolerância; Teqo re-asserta campos estruturais; cancelamento só para `confirmado`.
|
||||
- [ ] Webhook fail-closed: rota pública valida secret + channel id + resource id + token em tempo constante; sem canal configurado → 404; nunca processa corpo não validado.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: identificadores em inglês, copy pt-BR; escrita multi-coleção onde houver usa transação (aqui a escrita reversa é 1 doc — sem transação necessária, sem `req.transactionID`); sem PII nova no Google; segredo de URL em DB segue precedente `calendarFeed`; migration commitada; sem migration retroativa.
|
||||
- [ ] Testes de domínio previstos: unit (reverse edit mapping, relógio, prefixo do summary, validação do webhook), int (motor bidirecional com stub: todas as direções de D3/D4/D5 + re-entrada + snapshot), e2e (webhook 404 fail-closed, dialog).
|
||||
- [ ] Self-score decision-quality: 5/5 (decisões caras com rejeitadas; cabe no appetite; rabbit holes nomeados; depth check reusa motor C114, client, collection, ical route, `activityAllDay`, shells do dialog; intenção preservada).
|
||||
|
||||
## Decisões em aberto (produto — para o gate humano)
|
||||
|
||||
- Nenhuma nova: as questões abertas da intenção (B: candidato + coordenador editam; A: mais recente vence; A: estrutural não propaga) foram assumidas na própria intenção. Confirmação do gate = impl segue com elas.
|
||||
|
||||
## Referências
|
||||
|
||||
- Intenção: `docs/plans/c115-google-calendar-edicao-bidirecional.md` (gate de produto)
|
||||
- Base obrigatória: impl C114 `docs/plans/c114-google-calendar-push-agenda-oficial-impl.md` (D1–D7 herdados) + código em `src/utilities/googleCalendarSync.ts` (494 linhas), `googleCalendarClient.ts`, `googleCalendarSyncHooks.ts`, `src/collections/GoogleCalendarSync.ts`
|
||||
- Precedentes: `src/app/(campaign)/campanha/agenda/ical/[secret]/route.ts` (rota pública com segredo), `src/lib/activityAllDay.ts` (conversões all-day), `src/utilities/campaignInviteOrigin.ts` (`getCampaignInviteBaseURL` — C98), `src/collections/Activity.ts` (guards + updates), `ActivityUpdateFeed.tsx`/`GoogleCalendarSyncDialog.tsx`
|
||||
- Docs oficiais consultados: [Events: watch](https://developers.google.com/workspace/calendar/api/v3/reference/events/watch), [Push notifications](https://developers.google.com/workspace/calendar/api/guides/push) (headers, token, renovação com id único, TTL default 604800s)
|
||||
- Testes existentes: `tests/int/googleCalendarSync.int.spec.ts`, `tests/unit/googleCalendarSync*.spec.ts`, `tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts`
|
||||
|
|
@ -36,6 +36,8 @@ const failure = (message: string): GoogleCalendarSyncActionResult => ({
|
|||
lastSuccessAt: null,
|
||||
lastErrorAt: null,
|
||||
lastError: null,
|
||||
pushChannelExpiresAt: null,
|
||||
pushChannelError: null,
|
||||
addLink: null,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
import config from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { isGooglePushNotificationValid } from '@/lib/googleCalendarPushNotification'
|
||||
import {
|
||||
loadGoogleCalendarSyncConfig,
|
||||
recordGoogleCalendarSyncError,
|
||||
runCampaignCalendarSync,
|
||||
} from '@/utilities/googleCalendarSync'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ secret: string }>
|
||||
}
|
||||
|
||||
// C115: the push channel is validated against the live config on every
|
||||
// delivery — an explicit marker per repo convention (dynamic campaign routes
|
||||
// declare it) so a cached handler can never accept a stale channel.
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/** Google re-delivers failed notifications — a lean pass needs the extra headroom. */
|
||||
export const maxDuration = 60
|
||||
|
||||
const notFoundResponse = () =>
|
||||
new Response('Não encontrado.', {
|
||||
status: 404,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
})
|
||||
|
||||
export const GET = async (): Promise<Response> => notFoundResponse()
|
||||
|
||||
/**
|
||||
* C115 — Google Calendar push notifications (events.watch). Google POSTs a
|
||||
* bare "something changed" ping with channel headers and NO event data — the
|
||||
* handler validates the origin (URL secret + channel id + resource id +
|
||||
* channel token, all constant-time) and then runs the same bidirectional
|
||||
* reconciliation every other trigger runs. A valid ping answers 200 even if
|
||||
* the pass fails (the failure lands in the `paused` state and the local
|
||||
* auto-retry paths recover) — retries would only replicate work.
|
||||
*/
|
||||
export const POST = async (request: Request, context: RouteContext): Promise<Response> => {
|
||||
const { secret } = await context.params
|
||||
|
||||
// Cheap pre-filter BEFORE Payload bootstrap: every valid delivery carries
|
||||
// the three channel headers and a ≥32-char URL secret — scanner noise is
|
||||
// rejected without paying for a Payload init + DB read per hit. The full
|
||||
// constant-time validation below still runs (defense in depth).
|
||||
if (
|
||||
secret.length < 32 ||
|
||||
!request.headers.get('x-goog-channel-id') ||
|
||||
!request.headers.get('x-goog-resource-id') ||
|
||||
!request.headers.get('x-goog-channel-token')
|
||||
) {
|
||||
return notFoundResponse()
|
||||
}
|
||||
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
// Intentional admin bypass: the endpoint is unauthenticated by design
|
||||
// (Google delivers by URL); the secret + channel identity are the
|
||||
// credential, validated below — fail-closed.
|
||||
const doc = await loadGoogleCalendarSyncConfig(payload)
|
||||
if (
|
||||
!isGooglePushNotificationValid({
|
||||
secret,
|
||||
channelId: request.headers.get('x-goog-channel-id'),
|
||||
resourceId: request.headers.get('x-goog-resource-id'),
|
||||
channelToken: request.headers.get('x-goog-channel-token'),
|
||||
config: {
|
||||
pushChannelSecret: doc?.pushChannelSecret ?? null,
|
||||
pushChannelId: doc?.pushChannelId ?? null,
|
||||
pushChannelResourceId: doc?.pushChannelResourceId ?? null,
|
||||
},
|
||||
})
|
||||
) {
|
||||
return notFoundResponse()
|
||||
}
|
||||
|
||||
const resourceState = request.headers.get('x-goog-resource-state')
|
||||
|
||||
// `sync` is the channel-creation ping — nothing to reconcile yet.
|
||||
// `not_exists` means the watched calendar itself is gone — record it so
|
||||
// the staff sees why the sync stopped, instead of a silent no-op.
|
||||
if (resourceState === 'not_exists') {
|
||||
await recordGoogleCalendarSyncError(
|
||||
payload,
|
||||
'O calendário configurado não existe mais no Google.',
|
||||
)
|
||||
return new Response('OK', { status: 200 })
|
||||
}
|
||||
if (resourceState === 'sync') {
|
||||
return new Response('OK', { status: 200 })
|
||||
}
|
||||
|
||||
await runCampaignCalendarSync(payload, { reason: 'webhook' })
|
||||
return new Response('OK', { status: 200 })
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import type {
|
|||
} from 'payload'
|
||||
import { APIError } from 'payload'
|
||||
|
||||
import type { Activity as ActivityRecord } from '@/payload-types'
|
||||
|
||||
import { allDayRangeValid } from '@/lib/activityAllDay'
|
||||
import { relationshipId } from '@/lib/relationship'
|
||||
import {
|
||||
|
|
@ -29,6 +31,7 @@ import {
|
|||
eligibleCampaignStaffWhere,
|
||||
} from '@/utilities/campaignAccess'
|
||||
import { systemStampedActorField } from '@/utilities/campaignAuditFields'
|
||||
import { shouldSyncActivityOperation } from '@/utilities/googleCalendarSync'
|
||||
import { activityGoogleCalendarSyncHook } from '@/utilities/googleCalendarSyncHooks'
|
||||
|
||||
const isActivityMutationShortcut = (context: Record<string, unknown> | undefined) =>
|
||||
|
|
@ -72,12 +75,20 @@ const setCanonicalActivitySlug: CollectionBeforeValidateHook = ({
|
|||
}) => {
|
||||
if (isActivityMutationShortcut(context)) return data
|
||||
if (!data) return data
|
||||
const isGoogleCalendarWrite = context?.mutationKind === 'googleCalendarSync'
|
||||
const title = trimmedText(data.title ?? originalDoc?.title)
|
||||
const slug = slugify(title)
|
||||
if (!slug) {
|
||||
throw new APIError('Informe um título com letras ou números.', 400)
|
||||
}
|
||||
if (operation === 'update' && data.title !== undefined && title !== originalDoc?.title) {
|
||||
// C115 — the Google direction edits titles by product decision (D9); the
|
||||
// canonical slug stays frozen so public URLs never break.
|
||||
if (
|
||||
operation === 'update' &&
|
||||
!isGoogleCalendarWrite &&
|
||||
data.title !== undefined &&
|
||||
title !== originalDoc?.title
|
||||
) {
|
||||
throw new APIError('O título da atividade não pode ser alterado após a criação.', 409)
|
||||
}
|
||||
data.title = title
|
||||
|
|
@ -368,6 +379,20 @@ const deriveActivityFields: CollectionBeforeChangeHook = ({
|
|||
}
|
||||
}
|
||||
|
||||
// C115 — the clock rule baseline: stamped only when a mirrored field
|
||||
// actually changed (task toggles/updates/result records never stamp, so a
|
||||
// pending newer Google edit is not silenced by an unrelated `updatedAt`).
|
||||
if (
|
||||
operation === 'create' ||
|
||||
shouldSyncActivityOperation({
|
||||
operation: 'update',
|
||||
doc: { ...originalDoc, ...data } as ActivityRecord,
|
||||
previousDoc: originalDoc,
|
||||
})
|
||||
) {
|
||||
data.lastMirroredChangeAt = new Date().toISOString()
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
|
|
@ -711,6 +736,22 @@ export const Activity: CollectionConfig = {
|
|||
update: canSetActivitySystemField,
|
||||
},
|
||||
},
|
||||
{
|
||||
// C115 — last time a field the Google mirror reflects changed in the
|
||||
// Teqo. The conflict clock rule compares the Google event's `updated`
|
||||
// against this, not `updatedAt` (task toggles, updates and result
|
||||
// records bump `updatedAt` without ever reaching the mirror).
|
||||
name: 'lastMirroredChangeAt',
|
||||
type: 'date',
|
||||
label: 'Última mudança espelhada',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
access: {
|
||||
create: canSetActivitySystemField,
|
||||
update: canSetActivitySystemField,
|
||||
},
|
||||
},
|
||||
systemStampedActorField({ label: 'Criada por', setAccess: canSetActivitySystemField }),
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
canCreateGoogleCalendarSync,
|
||||
canDeleteGoogleCalendarSync,
|
||||
canReadGoogleCalendarSync,
|
||||
canReadGoogleCalendarSyncIdentityField,
|
||||
canSetGoogleCalendarSyncConfigField,
|
||||
canSetGoogleCalendarSyncDisabled,
|
||||
canSetGoogleCalendarSyncSystemField,
|
||||
|
|
@ -27,7 +28,13 @@ export const GoogleCalendarSync: CollectionConfig = {
|
|||
admin: {
|
||||
group: 'Campanha',
|
||||
useAsTitle: 'calendarId',
|
||||
defaultColumns: ['calendarId', 'lastSyncedAt', 'lastSuccessAt', 'lastError'],
|
||||
defaultColumns: [
|
||||
'calendarId',
|
||||
'lastSyncedAt',
|
||||
'lastSuccessAt',
|
||||
'lastError',
|
||||
'pushChannelExpiresAt',
|
||||
],
|
||||
},
|
||||
access: {
|
||||
create: canCreateGoogleCalendarSync,
|
||||
|
|
@ -107,5 +114,68 @@ export const GoogleCalendarSync: CollectionConfig = {
|
|||
update: canSetGoogleCalendarSyncSystemField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'pushChannelId',
|
||||
type: 'text',
|
||||
label: 'ID do canal de notificação (Google)',
|
||||
admin: { readOnly: true },
|
||||
access: {
|
||||
create: canSetGoogleCalendarSyncSystemField,
|
||||
read: canReadGoogleCalendarSyncIdentityField,
|
||||
update: canSetGoogleCalendarSyncSystemField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'pushChannelResourceId',
|
||||
type: 'text',
|
||||
label: 'Resource ID do canal (Google)',
|
||||
admin: { readOnly: true },
|
||||
access: {
|
||||
create: canSetGoogleCalendarSyncSystemField,
|
||||
read: canReadGoogleCalendarSyncIdentityField,
|
||||
update: canSetGoogleCalendarSyncSystemField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'pushChannelExpiresAt',
|
||||
type: 'date',
|
||||
label: 'Canal de notificação expira em',
|
||||
admin: { readOnly: true },
|
||||
access: {
|
||||
create: canSetGoogleCalendarSyncSystemField,
|
||||
update: canSetGoogleCalendarSyncSystemField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'pushChannelSecret',
|
||||
type: 'text',
|
||||
label: 'Segredo do webhook (URL)',
|
||||
admin: { readOnly: true },
|
||||
access: {
|
||||
create: canSetGoogleCalendarSyncSystemField,
|
||||
read: canReadGoogleCalendarSyncIdentityField,
|
||||
update: canSetGoogleCalendarSyncSystemField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'pushChannelError',
|
||||
type: 'textarea',
|
||||
label: 'Erro do canal de notificação',
|
||||
admin: { readOnly: true },
|
||||
access: {
|
||||
create: canSetGoogleCalendarSyncSystemField,
|
||||
update: canSetGoogleCalendarSyncSystemField,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'lastSeenEventIds',
|
||||
type: 'json',
|
||||
label: 'Eventos vistos na última sincronização',
|
||||
admin: { readOnly: true },
|
||||
access: {
|
||||
create: canSetGoogleCalendarSyncSystemField,
|
||||
update: canSetGoogleCalendarSyncSystemField,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,23 +3,31 @@ import { MessageSquareTextIcon } from 'lucide-react'
|
|||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/Empty'
|
||||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
import { GOOGLE_REVERSE_EDIT_BODY_PREFIX } from '@/lib/googleCalendarReverseEdit'
|
||||
import type { ActivityUpdateViewModel } from '@/utilities/activityViewModels'
|
||||
|
||||
const UpdateCard = ({ update }: { update: ActivityUpdateViewModel }) => (
|
||||
<article>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{update.authorName ?? 'Autor removido'}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{update.createdAt ? formatBahiaDateTimeLabel(update.createdAt) : ''}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-wrap">{update.body}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</article>
|
||||
)
|
||||
const UpdateCard = ({ update }: { update: ActivityUpdateViewModel }) => {
|
||||
// C115 — reverse-direction records carry no campaign author; the body
|
||||
// prefix attributes the entry to Google Calendar.
|
||||
const googleRecord = update.body.startsWith(GOOGLE_REVERSE_EDIT_BODY_PREFIX)
|
||||
return (
|
||||
<article>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{update.authorName ?? (googleRecord ? 'Google Calendar' : 'Autor removido')}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{update.createdAt ? formatBahiaDateTimeLabel(update.createdAt) : ''}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-wrap">{update.body}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export const ActivityUpdateFeed = ({ updates }: { updates: ActivityUpdateViewModel[] }) => {
|
||||
if (!updates.length) {
|
||||
|
|
|
|||
|
|
@ -152,6 +152,30 @@ export const GoogleCalendarSyncDialog = ({
|
|||
</div>
|
||||
)
|
||||
|
||||
const reverseEditBlock = (
|
||||
<div className="rounded-lg bg-muted p-4 text-sm">
|
||||
<p className="mb-1 font-medium">Edições pelo Google</p>
|
||||
<p className="text-muted-foreground">
|
||||
Quem tem permissão de edição no calendário pode remarcar, renomear ou cancelar o compromisso
|
||||
direto no Google — a mudança volta para a atividade do Teqo automaticamente e fica
|
||||
registrada nas atualizações. Título e horário seguem editáveis por lá; os demais campos só
|
||||
mudam pelo Teqo.
|
||||
</p>
|
||||
{state.pushChannelExpiresAt ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Notificações ativas até {formatBahiaDateTimeLabel(state.pushChannelExpiresAt)} — renovadas
|
||||
automaticamente a cada sincronização.
|
||||
</p>
|
||||
) : null}
|
||||
{state.pushChannelError ? (
|
||||
<p className="mt-2 text-xs text-amber-700">
|
||||
Notificações de mudanças feitas no Google indisponíveis: {state.pushChannelError}. O Teqo
|
||||
continua detectando as mudanças ao sincronizar.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
const syncButton = (
|
||||
<Button type="button" onClick={handleSyncNow} disabled={isBusy}>
|
||||
<RefreshCwIcon className={`mr-2 h-4 w-4 ${isBusy ? 'animate-spin' : ''}`} />
|
||||
|
|
@ -222,6 +246,7 @@ export const GoogleCalendarSyncDialog = ({
|
|||
|
||||
{linkBlock}
|
||||
{instructions}
|
||||
{reverseEditBlock}
|
||||
|
||||
{actionError && <p className="text-sm text-red-600">{actionError}</p>}
|
||||
|
||||
|
|
@ -253,7 +278,7 @@ export const GoogleCalendarSyncDialog = ({
|
|||
}
|
||||
|
||||
const description =
|
||||
'Compromissos do Teqo refletem no calendário Google compartilhado em minutos. Quem segue recebe aviso conforme as próprias configurações.'
|
||||
'Compromissos do Teqo refletem no calendário Google compartilhado em minutos, e edições feitas nele voltam para o Teqo. Quem segue recebe aviso conforme as próprias configurações.'
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) resetTransientState()
|
||||
|
|
|
|||
|
|
@ -154,10 +154,15 @@ export type GoogleRemoteEvent = {
|
|||
summary?: string
|
||||
description?: string
|
||||
location?: string
|
||||
status?: string
|
||||
updated?: string
|
||||
start?: { dateTime?: string; date?: string; timeZone?: string }
|
||||
end?: { dateTime?: string; date?: string; timeZone?: string }
|
||||
}
|
||||
|
||||
/** Google's own event lifecycle status — `cancelled` is the trash signal. */
|
||||
export const GOOGLE_EVENT_STATUS_CANCELLED = 'cancelled'
|
||||
|
||||
const startEndInstantEquals = (
|
||||
remote: { dateTime?: string; date?: string } | undefined,
|
||||
wanted: GoogleCalendarEventPayload['start'],
|
||||
|
|
@ -173,6 +178,12 @@ const startEndInstantEquals = (
|
|||
return remote.date === wanted.date
|
||||
}
|
||||
|
||||
/**
|
||||
* Same instant comparison, exported for the engine's editable-fields check
|
||||
* (C115 reverse direction) — single implementation of the compare.
|
||||
*/
|
||||
export { startEndInstantEquals as googleStartEndInstantEquals }
|
||||
|
||||
/**
|
||||
* True when the remote event already carries exactly what we would write —
|
||||
* the decision rule that makes repeated reconciliation passes converge
|
||||
|
|
|
|||
42
src/lib/googleCalendarPushNotification.ts
Normal file
42
src/lib/googleCalendarPushNotification.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* C115 — fail-closed validation of Google Calendar push deliveries (the
|
||||
* webhook route's security contract). Google echoes the channel identity on
|
||||
* every notification: the URL secret, the channel id, the resource id and
|
||||
* the channel token must ALL match the stored channel — compared in constant
|
||||
* time, because the endpoint is a public surface. Any mismatch rejects.
|
||||
*/
|
||||
import { timingSafeEqual } from 'node:crypto'
|
||||
|
||||
type GooglePushChannelConfig = {
|
||||
pushChannelSecret: string | null
|
||||
pushChannelId: string | null
|
||||
pushChannelResourceId: string | null
|
||||
}
|
||||
|
||||
export type GooglePushNotificationInput = {
|
||||
secret: string
|
||||
channelId: string | null
|
||||
resourceId: string | null
|
||||
channelToken: string | null
|
||||
config: GooglePushChannelConfig
|
||||
}
|
||||
|
||||
const safeEqual = (a: string, b: string): boolean => {
|
||||
const aBuffer = Buffer.from(a)
|
||||
const bBuffer = Buffer.from(b)
|
||||
return aBuffer.length === bBuffer.length && timingSafeEqual(aBuffer, bBuffer)
|
||||
}
|
||||
|
||||
export const isGooglePushNotificationValid = (input: GooglePushNotificationInput): boolean => {
|
||||
const { secret, channelId, resourceId, channelToken, config } = input
|
||||
if (secret.length < 32) return false
|
||||
if (!config.pushChannelSecret || !config.pushChannelId || !config.pushChannelResourceId) {
|
||||
return false
|
||||
}
|
||||
if (!safeEqual(secret, config.pushChannelSecret)) return false
|
||||
if (!channelId || !resourceId || !channelToken) return false
|
||||
if (!safeEqual(channelId, config.pushChannelId)) return false
|
||||
if (!safeEqual(resourceId, config.pushChannelResourceId)) return false
|
||||
if (!safeEqual(channelToken, config.pushChannelSecret)) return false
|
||||
return true
|
||||
}
|
||||
150
src/lib/googleCalendarReverseEdit.ts
Normal file
150
src/lib/googleCalendarReverseEdit.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* C115 — pure mapping from a Google Calendar event edit back to the campaign
|
||||
* activity fields: the reverse of `googleCalendarEventMapping`. The Google
|
||||
* event is never authoritative — the engine applies these values to the
|
||||
* activity only when the clock rule says the Google edit is newer, and every
|
||||
* other field (municipality, tags, locality, description…) stays Teqo-owned.
|
||||
*
|
||||
* This module is pure and client-safe; only the engine touches the network.
|
||||
*/
|
||||
import {
|
||||
allDayEndInstantFromExclusive,
|
||||
allDayStartInstant,
|
||||
formatAllDayRangeLabel,
|
||||
isCivilDate,
|
||||
} from '@/lib/activityAllDay'
|
||||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
import type { GoogleRemoteEvent } from '@/lib/googleCalendarEventMapping'
|
||||
|
||||
/** Body prefix that marks an activity-update entry as written by the reverse direction. */
|
||||
export const GOOGLE_REVERSE_EDIT_BODY_PREFIX = 'Google Calendar:'
|
||||
|
||||
/** The v1 editable fields (título/horário/cancelamento) — nothing else propagates. */
|
||||
export type GoogleReverseActivityEdit = {
|
||||
title?: string
|
||||
startAt?: string | null
|
||||
endAt?: string | null
|
||||
allDay?: boolean | null
|
||||
}
|
||||
|
||||
/** Matches the `title` field cap in the Activity collection — the same bound the Teqo form enforces. */
|
||||
const ACTIVITY_TITLE_MAX_LENGTH = 160
|
||||
|
||||
/**
|
||||
* D3 — the conflict clock rule: the Google edit wins only when its `updated`
|
||||
* instant is strictly newer than the activity's last Teqo write, with a small
|
||||
* tolerance for NTP skew between Google and the database (both are
|
||||
* NTP-synced). Content equality is checked BEFORE this rule, so repeated
|
||||
* passes converge without ever reaching it.
|
||||
*/
|
||||
export const googleEditIsNewer = (
|
||||
remoteUpdated: string | undefined,
|
||||
activityUpdatedAt: string | undefined,
|
||||
toleranceMs = 2_000,
|
||||
): boolean => {
|
||||
if (!remoteUpdated || !activityUpdatedAt) return false
|
||||
const remoteMs = Date.parse(remoteUpdated)
|
||||
const localMs = Date.parse(activityUpdatedAt)
|
||||
if (Number.isNaN(remoteMs) || Number.isNaN(localMs)) return false
|
||||
return remoteMs > localMs + toleranceMs
|
||||
}
|
||||
|
||||
/**
|
||||
* The summary carries a `[Município] ` prefix we wrote; the user edits what
|
||||
* follows it. Only OUR OWN prefix is stripped (matched against the activity's
|
||||
* municipality name); anything else is the user's title verbatim — and the
|
||||
* forward direction re-prefixes it consistently. A summary that is ONLY the
|
||||
* prefix is structural tampering → null → the Teqo re-asserts.
|
||||
*/
|
||||
export const googleTitleFromSummary = (
|
||||
summary: string | undefined,
|
||||
municipalityName?: string,
|
||||
): string | null => {
|
||||
const raw = summary?.trim()
|
||||
if (!raw) return null
|
||||
const stripped =
|
||||
municipalityName && raw.startsWith(`[${municipalityName}]`)
|
||||
? raw.slice(municipalityName.length + 2).trim()
|
||||
: raw
|
||||
if (!stripped) return null
|
||||
return stripped.length > ACTIVITY_TITLE_MAX_LENGTH
|
||||
? stripped.slice(0, ACTIVITY_TITLE_MAX_LENGTH)
|
||||
: stripped
|
||||
}
|
||||
|
||||
const parseInstant = (value: string | undefined): string | null => {
|
||||
if (!value) return null
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Event start/end → activity schedule. `date` (all-day) maps through the
|
||||
* `activityAllDay` conventions; `dateTime` (timed) maps by absolute instant.
|
||||
* Malformed values (missing end, exclusive end not after start, unparseable)
|
||||
* → null: the Teqo keeps its state and the forward direction re-asserts
|
||||
* (fail-safe — a bad Google value never corrupts the activity).
|
||||
*/
|
||||
export const googleScheduleToActivityFields = (
|
||||
event: Pick<GoogleRemoteEvent, 'start' | 'end'>,
|
||||
): Pick<GoogleReverseActivityEdit, 'startAt' | 'endAt' | 'allDay'> | null => {
|
||||
const start = event.start
|
||||
if (!start) return null
|
||||
|
||||
if (start.date) {
|
||||
if (!isCivilDate(start.date)) return null
|
||||
const startAt = allDayStartInstant(start.date)
|
||||
let endAt = startAt
|
||||
if (event.end?.date) {
|
||||
if (!isCivilDate(event.end.date) || event.end.date <= start.date) return null
|
||||
endAt = allDayEndInstantFromExclusive(event.end.date)
|
||||
}
|
||||
return { startAt, endAt, allDay: true }
|
||||
}
|
||||
|
||||
if (start.dateTime) {
|
||||
const startAt = parseInstant(start.dateTime)
|
||||
if (!startAt || !event.end?.dateTime) return null
|
||||
const endAt = parseInstant(event.end.dateTime)
|
||||
if (!endAt) return null
|
||||
return { startAt, endAt, allDay: false }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const scheduleLabelOf = (
|
||||
schedule: Pick<GoogleReverseActivityEdit, 'startAt' | 'endAt' | 'allDay'>,
|
||||
): string | null => {
|
||||
const { startAt, endAt, allDay } = schedule
|
||||
if (!startAt) return null
|
||||
if (allDay) return formatAllDayRangeLabel(startAt, endAt ?? null)
|
||||
const start = formatBahiaDateTimeLabel(startAt)
|
||||
if (endAt && endAt !== startAt) return `${start} — ${formatBahiaDateTimeLabel(endAt)}`
|
||||
return start
|
||||
}
|
||||
|
||||
/**
|
||||
* The audit entry ("com registro"): a concise pt-BR line describing what the
|
||||
* Google edit changed, prefixed for the feed to attribute it to Google.
|
||||
*/
|
||||
export const buildGoogleReverseUpdateBody = (
|
||||
previous: Pick<GoogleReverseActivityEdit, 'title' | 'startAt' | 'endAt' | 'allDay'>,
|
||||
next: Pick<GoogleReverseActivityEdit, 'title' | 'startAt' | 'endAt' | 'allDay'>,
|
||||
): string => {
|
||||
const parts: string[] = []
|
||||
if (next.title !== undefined && next.title !== previous.title) {
|
||||
parts.push(`título alterado para "${next.title}"`)
|
||||
}
|
||||
const previousSchedule = scheduleLabelOf(previous)
|
||||
const nextSchedule = scheduleLabelOf(next)
|
||||
if (nextSchedule && nextSchedule !== previousSchedule) {
|
||||
parts.push(`remarcada — antes ${previousSchedule}, agora ${nextSchedule}`)
|
||||
}
|
||||
if (parts.length === 0) return `${GOOGLE_REVERSE_EDIT_BODY_PREFIX} compromisso atualizado`
|
||||
return `${GOOGLE_REVERSE_EDIT_BODY_PREFIX} ${parts.join('; ')}`
|
||||
}
|
||||
|
||||
/** The cancellation record — the reverse direction reuses it for the updates feed. */
|
||||
export const buildGoogleReverseCancelBody = (): string =>
|
||||
`${GOOGLE_REVERSE_EDIT_BODY_PREFIX} cancelada`
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
/**
|
||||
* C115 — push-channel state for the Google→Teqo direction (edits made in the
|
||||
* Google Calendar come back to the campaign activities). Hand-written like its
|
||||
* C114 sibling `20260811_133332_add_google_calendar_sync`: `migrate:create`
|
||||
* has no snapshot baseline for the table (the sibling is a hand-written
|
||||
* migration without a `.json`), so the generator would re-emit the whole
|
||||
* `CREATE TABLE` and fail with "relation already exists" on apply.
|
||||
*/
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "google_calendar_sync" ADD COLUMN IF NOT EXISTS "push_channel_id" varchar;
|
||||
ALTER TABLE "google_calendar_sync" ADD COLUMN IF NOT EXISTS "push_channel_resource_id" varchar;
|
||||
ALTER TABLE "google_calendar_sync" ADD COLUMN IF NOT EXISTS "push_channel_expires_at" timestamp(3) with time zone;
|
||||
ALTER TABLE "google_calendar_sync" ADD COLUMN IF NOT EXISTS "push_channel_secret" varchar;
|
||||
ALTER TABLE "google_calendar_sync" ADD COLUMN IF NOT EXISTS "push_channel_error" varchar;
|
||||
ALTER TABLE "google_calendar_sync" ADD COLUMN IF NOT EXISTS "last_seen_event_ids" jsonb;
|
||||
`)
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "google_calendar_sync" DROP COLUMN IF EXISTS "last_seen_event_ids";
|
||||
ALTER TABLE "google_calendar_sync" DROP COLUMN IF EXISTS "push_channel_error";
|
||||
ALTER TABLE "google_calendar_sync" DROP COLUMN IF EXISTS "push_channel_secret";
|
||||
ALTER TABLE "google_calendar_sync" DROP COLUMN IF EXISTS "push_channel_expires_at";
|
||||
ALTER TABLE "google_calendar_sync" DROP COLUMN IF EXISTS "push_channel_resource_id";
|
||||
ALTER TABLE "google_calendar_sync" DROP COLUMN IF EXISTS "push_channel_id";
|
||||
`)
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
/**
|
||||
* C115 — `activity.lastMirroredChangeAt`: the last time a field the Google
|
||||
* mirror reflects changed in the Teqo. The conflict clock rule (D3) compares
|
||||
* the Google event's `updated` against THIS instant instead of `updatedAt`,
|
||||
* which non-mirrored writes (task toggles, updates feed, result records)
|
||||
* bump without ever reaching the mirror. Hand-written for the same reason as
|
||||
* its C114/C115 siblings: the migration generator has no snapshot baseline
|
||||
* for `google_calendar_sync` and would re-emit its whole CREATE TABLE.
|
||||
*/
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "activity" ADD COLUMN IF NOT EXISTS "last_mirrored_change_at" timestamp(3) with time zone;
|
||||
`)
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "activity" DROP COLUMN IF EXISTS "last_mirrored_change_at";
|
||||
`)
|
||||
}
|
||||
|
|
@ -51,6 +51,8 @@ import * as migration_20260811_111828_add_contact_phones_array from './20260811_
|
|||
import * as migration_20260811_133332_add_google_calendar_sync from './20260811_133332_add_google_calendar_sync';
|
||||
import * as migration_20260811_155116_add_lembranca_support_status from './20260811_155116_add_lembranca_support_status';
|
||||
import * as migration_20260811_183016_drop_activity_task_due from './20260811_183016_drop_activity_task_due';
|
||||
import * as migration_20260811_205157_add_google_calendar_push_channel from './20260811_205157_add_google_calendar_push_channel';
|
||||
import * as migration_20260811_213648_add_activity_last_mirrored_change_at from './20260811_213648_add_activity_last_mirrored_change_at';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -316,6 +318,16 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260811_183016_drop_activity_task_due.up,
|
||||
down: migration_20260811_183016_drop_activity_task_due.down,
|
||||
name: '20260811_183016_drop_activity_task_due'
|
||||
name: '20260811_183016_drop_activity_task_due',
|
||||
},
|
||||
{
|
||||
up: migration_20260811_205157_add_google_calendar_push_channel.up,
|
||||
down: migration_20260811_205157_add_google_calendar_push_channel.down,
|
||||
name: '20260811_205157_add_google_calendar_push_channel',
|
||||
},
|
||||
{
|
||||
up: migration_20260811_213648_add_activity_last_mirrored_change_at.up,
|
||||
down: migration_20260811_213648_add_activity_last_mirrored_change_at.down,
|
||||
name: '20260811_213648_add_activity_last_mirrored_change_at',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -802,6 +802,7 @@ export interface Activity {
|
|||
resultMedia?: (number | Media)[] | null;
|
||||
resultRecordedBy?: (number | null) | CampaignUser;
|
||||
resultRecordedAt?: string | null;
|
||||
lastMirroredChangeAt?: string | null;
|
||||
createdBy?: (number | null) | CampaignUser;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
|
|
@ -909,6 +910,20 @@ export interface GoogleCalendarSync {
|
|||
lastSuccessAt?: string | null;
|
||||
lastErrorAt?: string | null;
|
||||
lastError?: string | null;
|
||||
pushChannelId?: string | null;
|
||||
pushChannelResourceId?: string | null;
|
||||
pushChannelExpiresAt?: string | null;
|
||||
pushChannelSecret?: string | null;
|
||||
pushChannelError?: string | null;
|
||||
lastSeenEventIds?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
@ -1864,6 +1879,7 @@ export interface ActivitySelect<T extends boolean = true> {
|
|||
resultMedia?: T;
|
||||
resultRecordedBy?: T;
|
||||
resultRecordedAt?: T;
|
||||
lastMirroredChangeAt?: T;
|
||||
createdBy?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
|
|
@ -1894,6 +1910,12 @@ export interface GoogleCalendarSyncSelect<T extends boolean = true> {
|
|||
lastSuccessAt?: T;
|
||||
lastErrorAt?: T;
|
||||
lastError?: T;
|
||||
pushChannelId?: T;
|
||||
pushChannelResourceId?: T;
|
||||
pushChannelExpiresAt?: T;
|
||||
pushChannelSecret?: T;
|
||||
pushChannelError?: T;
|
||||
lastSeenEventIds?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,15 @@ import {
|
|||
} 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.
|
||||
* C114+C115 — 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` and creates the row (the single-row invariant is enforced at
|
||||
* the read: the configured row wins); the system state fields are written
|
||||
* solely by the sync engine through an admin bypass — never by a user path.
|
||||
* The push-channel identity fields (id/resource/secret — the webhook
|
||||
* credential set) are unreadable to any user path, admin included: the
|
||||
* engine and the webhook read them with the admin bypass.
|
||||
*/
|
||||
|
||||
export const canReadGoogleCalendarSync: Access = async ({ req }) => {
|
||||
|
|
@ -20,10 +24,7 @@ export const canReadGoogleCalendarSync: Access = async ({ req }) => {
|
|||
return isCampaignStaff(await getFreshCampaignUser(req))
|
||||
}
|
||||
|
||||
export const canCreateGoogleCalendarSync: Access = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
return isCampaignStaff(await getFreshCampaignUser(req))
|
||||
}
|
||||
export const canCreateGoogleCalendarSync: Access = payloadAdminOnly
|
||||
|
||||
export const canUpdateGoogleCalendarSync: Access = async ({ req }) => {
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
|
|
@ -44,3 +45,11 @@ export const canSetGoogleCalendarSyncDisabled: FieldAccess = async ({ req }) =>
|
|||
|
||||
/** System state fields — only the sync engine writes them (overrideAccess). */
|
||||
export const canSetGoogleCalendarSyncSystemField: FieldAccess = () => false
|
||||
|
||||
/**
|
||||
* C115 — the webhook credential set never leaves the engine: field-level
|
||||
* `read` denies every user path (the engine/webhook read with the admin
|
||||
* bypass). A future server action reading the config row would otherwise
|
||||
* hand out full push-forging authority to any staff member.
|
||||
*/
|
||||
export const canReadGoogleCalendarSyncIdentityField: FieldAccess = () => false
|
||||
|
|
|
|||
|
|
@ -52,6 +52,20 @@ export type GoogleCalendarClient = {
|
|||
insertEvent: (calendarId: string, event: GoogleRemoteEvent) => Promise<void>
|
||||
updateEvent: (calendarId: string, eventId: string, event: GoogleRemoteEvent) => Promise<void>
|
||||
deleteEvent: (calendarId: string, eventId: string) => Promise<void>
|
||||
/** C115 — push channel for the Google→Teqo direction (events.watch). */
|
||||
watchEvents: (
|
||||
calendarId: string,
|
||||
channel: { id: string; address: string; token: string; ttlSeconds: number },
|
||||
) => Promise<GoogleWatchChannel>
|
||||
/** C115 — stops a push channel (channels.stop); used on renewal/calendar change. */
|
||||
stopChannel: (channel: { id: string; resourceId: string }) => Promise<void>
|
||||
}
|
||||
|
||||
/** The `api#channel` the watch response returns. */
|
||||
type GoogleWatchChannel = {
|
||||
id: string
|
||||
resourceId: string
|
||||
expiration: number | null
|
||||
}
|
||||
|
||||
export type FetchLike = typeof fetch
|
||||
|
|
@ -160,6 +174,10 @@ export const createGoogleCalendarClient = (
|
|||
timeMax: range.timeMax,
|
||||
maxResults: String(MAX_LIST_PAGE_SIZE),
|
||||
singleEvents: 'true',
|
||||
// C115 — cancelled (trashed) events are the "user cancelled this
|
||||
// commitment" signal the reverse direction acts on; without it a
|
||||
// deletion in Google would be invisible to the reconciliation.
|
||||
showDeleted: 'true',
|
||||
})
|
||||
if (pageToken) params.set('pageToken', pageToken)
|
||||
|
||||
|
|
@ -202,5 +220,45 @@ export const createGoogleCalendarClient = (
|
|||
await apiFetch(eventsUrl(calendarId, eventId), { method: 'DELETE' })
|
||||
}
|
||||
|
||||
return { listEvents, insertEvent, updateEvent, deleteEvent }
|
||||
const watchEvents: GoogleCalendarClient['watchEvents'] = async (calendarId, channel) => {
|
||||
const response = await apiFetch(`${eventsUrl(calendarId)}/watch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: channel.id,
|
||||
type: 'web_hook',
|
||||
address: channel.address,
|
||||
token: channel.token,
|
||||
params: { ttl: String(channel.ttlSeconds) },
|
||||
}),
|
||||
})
|
||||
const body = (await response.json()) as {
|
||||
id?: string
|
||||
resourceId?: string
|
||||
expiration?: number | null
|
||||
}
|
||||
if (!body.id || !body.resourceId) {
|
||||
throw new GoogleCalendarApiError('O Google não devolveu um canal de notificação válido.', 502)
|
||||
}
|
||||
return {
|
||||
id: body.id,
|
||||
resourceId: body.resourceId,
|
||||
expiration: body.expiration ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
const stopChannel: GoogleCalendarClient['stopChannel'] = async ({ id, resourceId }) => {
|
||||
try {
|
||||
await apiFetch(`${GOOGLE_CALENDAR_API_BASE}/channels/stop`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, resourceId }),
|
||||
})
|
||||
} catch (error) {
|
||||
// Already stopped / unknown channel is the desired end state (404).
|
||||
if (!(error instanceof GoogleCalendarApiError && error.status === 404)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
return { listEvents, insertEvent, updateEvent, deleteEvent, watchEvents, stopChannel }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import 'server-only'
|
||||
|
||||
import { randomBytes, randomUUID } from 'node:crypto'
|
||||
|
||||
import type { Activity, GoogleCalendarSync as GoogleCalendarSyncDoc } from '@/payload-types'
|
||||
import type { Payload, PayloadRequest, Where } from 'payload'
|
||||
|
||||
|
|
@ -7,14 +9,26 @@ import {
|
|||
activityMunicipalityIdOf,
|
||||
buildGoogleEventPayload,
|
||||
decodeGoogleEventActivityId,
|
||||
GOOGLE_EVENT_STATUS_CANCELLED,
|
||||
googleEventContentEquals,
|
||||
googleEventIdForActivity,
|
||||
googleStartEndInstantEquals,
|
||||
type GoogleRemoteEvent,
|
||||
} from '@/lib/googleCalendarEventMapping'
|
||||
import {
|
||||
buildGoogleReverseCancelBody,
|
||||
buildGoogleReverseUpdateBody,
|
||||
googleEditIsNewer,
|
||||
googleScheduleToActivityFields,
|
||||
googleTitleFromSummary,
|
||||
type GoogleReverseActivityEdit,
|
||||
} from '@/lib/googleCalendarReverseEdit'
|
||||
import {
|
||||
buildActivityWindowRange,
|
||||
buildActivityWindowWhereClauses,
|
||||
loadMunicipalityNames,
|
||||
} from '@/utilities/calendarFeed'
|
||||
import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin'
|
||||
import {
|
||||
createGoogleCalendarClient,
|
||||
GoogleCalendarApiError,
|
||||
|
|
@ -23,25 +37,42 @@ import {
|
|||
} from '@/utilities/googleCalendarClient'
|
||||
|
||||
/**
|
||||
* C114 — the campaign→Google calendar reconciliation engine.
|
||||
* C114+C115 — 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.
|
||||
* list the calendar's events in a slightly wider window (incl. cancelled),
|
||||
* diff by the deterministic event id (`teqo` + base32hex(activityId)) and
|
||||
* reconcile BOTH directions: the Teqo re-asserts its state (create/update/
|
||||
* delete — updates only when the content differs, so repeated passes
|
||||
* converge without touching Google), and a Google edit that is NEWER by the
|
||||
* clock rule is applied back to the activity (title/schedule/cancellation —
|
||||
* D2/D3/D4). 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`.
|
||||
* agenda-page auto-retry and the manual "Sincronizar agora", the
|
||||
* `googleCalendarSync` config afterChange (D7: calendarId change / re-enable)
|
||||
* and — C115 — the public webhook receiving a push notification. Every
|
||||
* trigger also lazily ensures the push channel (D5). The engine NEVER throws
|
||||
* into the caller — failures land in the state fields and the status derives
|
||||
* to `paused`; the channel itself is best-effort and never pauses the mirror.
|
||||
*/
|
||||
|
||||
export const GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV = 'GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY'
|
||||
|
||||
/**
|
||||
* C115 — public push-channel surface. The webhook address embeds the channel
|
||||
* secret (same link-as-credential model as the iCal feed); Google echoes the
|
||||
* token (the same secret) on every notification, so the route validates URL
|
||||
* secret + channel id + resource id + token before acting.
|
||||
*/
|
||||
const GOOGLE_CALENDAR_WEBHOOK_PATH = '/campanha/agenda/google-webhook/'
|
||||
/** Google's internal TTL cap (~30 days per the events/watch docs). */
|
||||
const PUSH_CHANNEL_TTL_SECONDS = 30 * 24 * 60 * 60
|
||||
/** Renewal lead — a channel is replaced 48h before it would expire. */
|
||||
const PUSH_CHANNEL_RENEW_LEAD_MS = 48 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
@ -75,6 +106,8 @@ export type GoogleCalendarSyncView = {
|
|||
lastSuccessAt: string | null
|
||||
lastErrorAt: string | null
|
||||
lastError: string | null
|
||||
pushChannelExpiresAt: string | null
|
||||
pushChannelError: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -130,12 +163,17 @@ export const loadGoogleCalendarSyncConfig = async (
|
|||
): 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.
|
||||
// action layer, never per-read. The configured row wins: a stray empty
|
||||
// row (staff can no longer create one, but legacy rows may exist) must
|
||||
// never shadow the operational calendar (single-row invariant). The
|
||||
// webhook route and the engine rely on this bypass for the channel
|
||||
// identity fields, which no user path may read.
|
||||
const result = await payload.find({
|
||||
collection: 'googleCalendarSync',
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
depth: 0,
|
||||
where: { calendarId: { exists: true } },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
|
@ -158,6 +196,8 @@ const docToView = (
|
|||
lastSuccessAt: doc?.lastSuccessAt ?? null,
|
||||
lastErrorAt: doc?.lastErrorAt ?? null,
|
||||
lastError: doc?.lastError ?? null,
|
||||
pushChannelExpiresAt: doc?.pushChannelExpiresAt ?? null,
|
||||
pushChannelError: doc?.pushChannelError ?? null,
|
||||
})
|
||||
|
||||
/** The read model the agenda UI renders — status derived, never stored. */
|
||||
|
|
@ -291,14 +331,128 @@ const deleteRemoteEvent = async (
|
|||
}
|
||||
}
|
||||
|
||||
type SyncCounts = { created: number; updated: number; deleted: number }
|
||||
type SyncCounts = { created: number; updated: number; deleted: number; reverseEdits: number }
|
||||
|
||||
/**
|
||||
* C115 — the reconciliation's last-seen snapshot (JSONB on the sync row).
|
||||
* `calendarId` pins WHICH calendar the ids were seen on: after a calendar
|
||||
* switch the snapshot is stale and treated as empty (fresh mirror), and the
|
||||
* push channel is re-created for the new calendar (see the channel ensure).
|
||||
* Only written on SUCCESSFUL passes — a failed event creation is never
|
||||
* "seen", so the next pass creates instead of cancelling.
|
||||
*/
|
||||
type GoogleCalendarSyncSnapshot = { calendarId: string; ids: string[] }
|
||||
|
||||
const parseLastSeenSnapshot = (raw: unknown): GoogleCalendarSyncSnapshot | null => {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const candidate = raw as { calendarId?: unknown; ids?: unknown }
|
||||
if (typeof candidate.calendarId !== 'string') return null
|
||||
if (!Array.isArray(candidate.ids)) return null
|
||||
const ids = candidate.ids.filter((id): id is string => typeof id === 'string')
|
||||
return { calendarId: candidate.calendarId, ids }
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the audit record ("com registro", D8) to the activity's updates
|
||||
* feed and applies the reverse values. System write with an admin bypass —
|
||||
* the authorized Google editor acts through the calendar, never as a
|
||||
* campaign user; the leader lockdown guard does not apply (no `req.user`).
|
||||
* The caller's user is STRIPPED on purpose: a pass triggered by a staff save
|
||||
* must not attribute the record to that staff member nor trip the
|
||||
* deputy-present reschedule gate.
|
||||
* `validateActivitySchedule` runs on the values (malformed → the update
|
||||
* throws → the pass fails → `paused`, and the next pass re-asserts Teqo).
|
||||
*/
|
||||
const applyGoogleReverseActivityPatch = async (
|
||||
payload: Payload,
|
||||
req: PayloadRequest | undefined,
|
||||
activity: Activity,
|
||||
data: {
|
||||
title?: string
|
||||
startAt?: string | null
|
||||
endAt?: string | null
|
||||
allDay?: boolean | null
|
||||
status?: 'cancelado'
|
||||
},
|
||||
body: string,
|
||||
): Promise<void> => {
|
||||
await payload.update({
|
||||
collection: 'activity',
|
||||
id: activity.id,
|
||||
data: {
|
||||
...data,
|
||||
updates: [...(activity.updates ?? []), { body, author: null }],
|
||||
},
|
||||
depth: 0,
|
||||
// Intentional admin bypass (the Google editor acts through the calendar,
|
||||
// never as a campaign user) + the dedicated mutation context that allows
|
||||
// the title edit without breaking the canonical-slug contract.
|
||||
overrideAccess: true,
|
||||
context: { mutationKind: 'googleCalendarSync' },
|
||||
req: req ? { ...req, user: undefined } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const cancelActivityFromGoogle = async (
|
||||
payload: Payload,
|
||||
req: PayloadRequest | undefined,
|
||||
activity: Activity,
|
||||
): Promise<void> => {
|
||||
await applyGoogleReverseActivityPatch(
|
||||
payload,
|
||||
req,
|
||||
activity,
|
||||
{ status: 'cancelado' },
|
||||
buildGoogleReverseCancelBody(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The editable v1 fields (D9): title + schedule. `null` per field means the
|
||||
* remote value is absent/structural (e.g. a summary that is only the
|
||||
* `[Município] ` prefix) — the Teqo re-asserts those through the forward
|
||||
* direction on a later pass.
|
||||
*/
|
||||
const googleReverseEditOf = (
|
||||
event: GoogleRemoteEvent,
|
||||
municipalityName?: string,
|
||||
): GoogleReverseActivityEdit | null => {
|
||||
const title = googleTitleFromSummary(event.summary, municipalityName)
|
||||
const schedule = googleScheduleToActivityFields(event)
|
||||
if (title === null && !schedule) return null
|
||||
return { ...(title !== null ? { title } : {}), ...(schedule ?? {}) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The reverse decision uses the EDITABLE fields only (summary + schedule):
|
||||
* a description/location change in Google stays Teqo-owned and is re-asserted
|
||||
* by the forward direction — it must not trigger a reverse edit record.
|
||||
*/
|
||||
const googleEditableContentEquals = (
|
||||
remote: GoogleRemoteEvent,
|
||||
payload: ReturnType<typeof buildGoogleEventPayload>,
|
||||
): boolean =>
|
||||
(remote.summary ?? '') === payload.summary &&
|
||||
googleStartEndInstantEquals(remote.start, payload.start) &&
|
||||
googleStartEndInstantEquals(remote.end, payload.end)
|
||||
|
||||
/**
|
||||
* C115 — one full pass, now BIDIRECTIONAL (D2): for every event of ours the
|
||||
* diff decides the direction by the clock rule (D3) — Google wins when its
|
||||
* `updated` is newer, the Teqo re-asserts otherwise; a `cancelled` event is
|
||||
* the user's cancellation (D4); a previously-seen event that vanished is the
|
||||
* user's permanent removal. Content equality is checked first, so repeated
|
||||
* passes converge without touching Google (C114 D3 preserved).
|
||||
* `activityWhere` narrows the mirrored set (C126 — test isolation); the
|
||||
* production callers omit it and keep the full staff scope.
|
||||
*/
|
||||
const runSyncPass = async (
|
||||
payload: Payload,
|
||||
req: PayloadRequest | undefined,
|
||||
client: GoogleCalendarClient,
|
||||
calendarId: string,
|
||||
activityWhere?: Where,
|
||||
activityWhere: Where | undefined,
|
||||
config: GoogleCalendarSyncDoc,
|
||||
): Promise<SyncCounts> => {
|
||||
const { rangeStart, rangeEnd } = buildActivityWindowRange()
|
||||
const { activities, municipalityNames } = await loadSyncActivities(payload, req, activityWhere)
|
||||
|
|
@ -312,6 +466,10 @@ const runSyncPass = async (
|
|||
activityWhere,
|
||||
)
|
||||
|
||||
const snapshot = parseLastSeenSnapshot(config.lastSeenEventIds)
|
||||
const lastSeenIds =
|
||||
snapshot?.calendarId === calendarId ? new Set(snapshot.ids) : new Set<string>()
|
||||
|
||||
const wantedById = new Map<number, Activity>()
|
||||
for (const activity of activities) {
|
||||
wantedById.set(activity.id, activity)
|
||||
|
|
@ -320,8 +478,15 @@ const runSyncPass = async (
|
|||
let created = 0
|
||||
let updated = 0
|
||||
let deleted = 0
|
||||
let reverseEdits = 0
|
||||
|
||||
// Ids PRESENT in this pass's remote list (the second loop's "exists" check)
|
||||
// — never removed mid-pass. Ids the engine DELETES are tracked separately so
|
||||
// the last-seen snapshot excludes them: a trashed event cleaned here must
|
||||
// not look "previously seen and gone" to the next pass (which would cancel
|
||||
// the activity it was cleaned for — the reopen-after-cancel rule).
|
||||
const remoteEventIds = new Set<string>()
|
||||
const removedEventIds = new Set<string>()
|
||||
|
||||
for (const event of remoteEvents) {
|
||||
// Only OUR deterministic ids are reconciled; foreign events on the
|
||||
|
|
@ -332,11 +497,37 @@ const runSyncPass = async (
|
|||
remoteEventIds.add(event.id!)
|
||||
|
||||
const activity = wantedById.get(activityId)
|
||||
|
||||
// C115 — a `cancelled` event is the user's cancellation in Google
|
||||
// (trash). A live `confirmado` commitment is cancelled in the Teqo only
|
||||
// when the cancel is NEWER than the activity's last mirrored change —
|
||||
// otherwise the Teqo changed it after the cancel (a reopen): the trashed
|
||||
// event is cleaned instead, and the next pass re-creates it. History
|
||||
// (`realizado`) stays untouched; anything dead leaves Google.
|
||||
if (event.status === GOOGLE_EVENT_STATUS_CANCELLED) {
|
||||
if (activity?.status === 'confirmado') {
|
||||
if (googleEditIsNewer(event.updated, activity.lastMirroredChangeAt ?? activity.updatedAt)) {
|
||||
await cancelActivityFromGoogle(payload, req, activity)
|
||||
reverseEdits += 1
|
||||
} else {
|
||||
await deleteRemoteEvent(client, calendarId, event.id!)
|
||||
removedEventIds.add(event.id!)
|
||||
deleted += 1
|
||||
}
|
||||
} else if (!activity && !aliveActivityIds.has(activityId)) {
|
||||
await deleteRemoteEvent(client, calendarId, event.id!)
|
||||
removedEventIds.add(event.id!)
|
||||
deleted += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
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!)
|
||||
removedEventIds.add(event.id!)
|
||||
deleted += 1
|
||||
}
|
||||
continue
|
||||
|
|
@ -346,31 +537,83 @@ const runSyncPass = async (
|
|||
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
|
||||
if (googleEventContentEquals(event, googleEvent)) continue
|
||||
|
||||
// C115 — the clock rule: a NEWER Google edit of an editable field wins;
|
||||
// anything else (Teqo newer, structural fields, `realizado` history) is
|
||||
// re-asserted by the forward direction. The baseline is the last MIRRORED
|
||||
// change (`lastMirroredChangeAt`), not `updatedAt` — task toggles and
|
||||
// updates-feed records bump `updatedAt` without ever reaching the mirror.
|
||||
if (
|
||||
activity.status === 'confirmado' &&
|
||||
!googleEditableContentEquals(event, googleEvent) &&
|
||||
googleEditIsNewer(event.updated, activity.lastMirroredChangeAt ?? activity.updatedAt)
|
||||
) {
|
||||
const reverseEdit = googleReverseEditOf(event, municipalityName)
|
||||
if (reverseEdit) {
|
||||
const body = buildGoogleReverseUpdateBody(activity, reverseEdit)
|
||||
await applyGoogleReverseActivityPatch(payload, req, activity, reverseEdit, body)
|
||||
reverseEdits += 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
await client.updateEvent(calendarId, event.id!, googleEvent)
|
||||
updated += 1
|
||||
}
|
||||
|
||||
for (const [activityId, activity] of wantedById) {
|
||||
const eventId = googleEventIdForActivity(activityId)
|
||||
if (remoteEventIds.has(eventId)) continue
|
||||
|
||||
// C115 — previously seen and now gone: the user permanently removed the
|
||||
// event (trash still shows as `cancelled`, handled above). Never seen →
|
||||
// a fresh mirror (first activation or calendar switch) → create.
|
||||
if (activity.status === 'confirmado' && lastSeenIds.has(eventId)) {
|
||||
await cancelActivityFromGoogle(payload, req, activity)
|
||||
reverseEdits += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const municipalityId = activityMunicipalityIdOf(activity.municipality)
|
||||
const municipalityName = municipalityId ? municipalityNames.get(municipalityId) : undefined
|
||||
await client.insertEvent(calendarId, buildGoogleEventPayload(activity, municipalityName))
|
||||
// The created event NOW exists — the last-seen snapshot must include it,
|
||||
// or the next pass would treat it as never-created and rebuild it.
|
||||
remoteEventIds.add(eventId)
|
||||
created += 1
|
||||
}
|
||||
|
||||
return { created, updated, deleted }
|
||||
await recordSyncState(payload, req, {
|
||||
lastSeenEventIds: {
|
||||
calendarId,
|
||||
ids: [...remoteEventIds].filter((id) => !removedEventIds.has(id)),
|
||||
},
|
||||
})
|
||||
|
||||
return { created, updated, deleted, reverseEdits }
|
||||
}
|
||||
|
||||
type SyncStatePatch = Partial<
|
||||
Pick<
|
||||
GoogleCalendarSyncDoc,
|
||||
| 'lastSyncedAt'
|
||||
| 'lastSuccessAt'
|
||||
| 'lastErrorAt'
|
||||
| 'lastError'
|
||||
| 'lastSeenEventIds'
|
||||
| 'pushChannelId'
|
||||
| 'pushChannelResourceId'
|
||||
| 'pushChannelExpiresAt'
|
||||
| 'pushChannelSecret'
|
||||
| 'pushChannelError'
|
||||
>
|
||||
>
|
||||
|
||||
const recordSyncState = async (
|
||||
payload: Payload,
|
||||
req: PayloadRequest | undefined,
|
||||
patch: Partial<
|
||||
Pick<GoogleCalendarSyncDoc, 'lastSyncedAt' | 'lastSuccessAt' | 'lastErrorAt' | 'lastError'>
|
||||
>,
|
||||
patch: SyncStatePatch,
|
||||
): Promise<void> => {
|
||||
const doc = await loadGoogleCalendarSyncConfig(payload, req)
|
||||
if (!doc) return // nothing to record before the first activation
|
||||
|
|
@ -386,15 +629,126 @@ const recordSyncState = async (
|
|||
})
|
||||
}
|
||||
|
||||
/** The webhook route uses this when Google reports the watched calendar is gone. */
|
||||
export const recordGoogleCalendarSyncError = async (
|
||||
payload: Payload,
|
||||
message: string,
|
||||
): Promise<void> => {
|
||||
const at = new Date().toISOString()
|
||||
await recordSyncState(payload, undefined, { lastErrorAt: at, lastError: message.slice(0, 500) })
|
||||
}
|
||||
|
||||
/** The push-channel webhook address; the URL secret IS the credential. */
|
||||
const buildPushChannelAddress = (secret: string): string | null => {
|
||||
try {
|
||||
return `${getCampaignInviteBaseURL()}${GOOGLE_CALENDAR_WEBHOOK_PATH}${secret}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const generateChannelSecret = (): string => randomBytes(32).toString('base64url')
|
||||
|
||||
/**
|
||||
* C115 — D5: keeps a push channel alive on the configured calendar. Lazily
|
||||
* ensures (create / renew / re-create after a calendar switch) on EVERY pass,
|
||||
* so the channel renews itself through the existing triggers — no cron.
|
||||
* Best-effort by contract: a channel failure is recorded in
|
||||
* `pushChannelError` and NEVER pauses the mirror — the reverse direction also
|
||||
* runs on every local trigger, the channel only buys immediacy.
|
||||
*/
|
||||
const ensureGoogleCalendarPushChannel = async (
|
||||
payload: Payload,
|
||||
req: PayloadRequest | undefined,
|
||||
client: GoogleCalendarClient,
|
||||
config: GoogleCalendarSyncDoc,
|
||||
): Promise<void> => {
|
||||
const calendarId = config.calendarId
|
||||
if (!calendarId) return
|
||||
|
||||
// Fail fast when the site URL is missing (C98 lesson) — the real address is
|
||||
// built inside the try with the fresh secret.
|
||||
if (!buildPushChannelAddress('probe')) {
|
||||
await recordSyncState(payload, req, {
|
||||
pushChannelError: 'NEXT_PUBLIC_SITE_URL não configurada — impossível registrar o webhook.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const snapshot = parseLastSeenSnapshot(config.lastSeenEventIds)
|
||||
const channelWatchesCurrentCalendar = snapshot === null || snapshot.calendarId === calendarId
|
||||
const expiringSoon =
|
||||
config.pushChannelExpiresAt &&
|
||||
new Date(config.pushChannelExpiresAt).getTime() < Date.now() + PUSH_CHANNEL_RENEW_LEAD_MS
|
||||
const healthy = Boolean(config.pushChannelId && config.pushChannelResourceId) && !expiringSoon
|
||||
|
||||
if (healthy && channelWatchesCurrentCalendar) {
|
||||
if (config.pushChannelError) {
|
||||
await recordSyncState(payload, req, { pushChannelError: null })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// A FRESH secret per channel creation (rotation): a leaked webhook URL
|
||||
// self-heals on the next renewal, and the old channel — stopped below —
|
||||
// dies with its URL.
|
||||
const secret = generateChannelSecret()
|
||||
const address = buildPushChannelAddress(secret)
|
||||
if (!address) {
|
||||
await recordSyncState(payload, req, {
|
||||
pushChannelError: 'NEXT_PUBLIC_SITE_URL não configurada — impossível registrar o webhook.',
|
||||
})
|
||||
return
|
||||
}
|
||||
const channel = await client.watchEvents(calendarId, {
|
||||
id: randomUUID(),
|
||||
address,
|
||||
token: secret,
|
||||
ttlSeconds: PUSH_CHANNEL_TTL_SECONDS,
|
||||
})
|
||||
|
||||
// Persist the NEW channel BEFORE stopping the old one: if the stop fails
|
||||
// the replacement is already recorded (no orphaned live channel, no
|
||||
// duplicate re-watch on the next pass) — the catch below only records
|
||||
// the error.
|
||||
await recordSyncState(payload, req, {
|
||||
pushChannelId: channel.id,
|
||||
pushChannelResourceId: channel.resourceId,
|
||||
pushChannelExpiresAt: channel.expiration
|
||||
? new Date(channel.expiration).toISOString()
|
||||
: new Date(Date.now() + PUSH_CHANNEL_TTL_SECONDS * 1000).toISOString(),
|
||||
pushChannelSecret: secret,
|
||||
pushChannelError: null,
|
||||
})
|
||||
|
||||
// Best-effort: the old channel is stopped once its replacement is live
|
||||
// (Google allows an overlap period; duplicated notifications are
|
||||
// idempotent). A failure here never loses the new channel.
|
||||
if (config.pushChannelId && config.pushChannelResourceId) {
|
||||
await client.stopChannel({
|
||||
id: config.pushChannelId,
|
||||
resourceId: config.pushChannelResourceId,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Erro desconhecido ao registrar o webhook do Google.'
|
||||
await recordSyncState(payload, req, { pushChannelError: message.slice(0, 500) })
|
||||
}
|
||||
}
|
||||
|
||||
export type CampaignCalendarSyncOutcome = SyncCounts & {
|
||||
status: GoogleCalendarSyncStatus
|
||||
at: string
|
||||
}
|
||||
|
||||
export type CampaignCalendarSyncOptions = {
|
||||
reason: 'create' | 'update' | 'delete' | 'config-change' | 'manual'
|
||||
reason: 'create' | 'update' | 'delete' | 'config-change' | 'manual' | 'webhook'
|
||||
client?: GoogleCalendarClient
|
||||
req?: PayloadRequest
|
||||
/** Int tests stub the transport; the channel ensure needs it too — off in tests that don't want it. */
|
||||
skipChannelEnsure?: boolean
|
||||
/**
|
||||
* Narrows the mirrored activity set (push + delete-guard). Production
|
||||
* callers omit it — the official mirror is the full staff scope. Exists
|
||||
|
|
@ -424,6 +778,7 @@ export const runCampaignCalendarSync = async (
|
|||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
reverseEdits: 0,
|
||||
at,
|
||||
}
|
||||
}
|
||||
|
|
@ -431,12 +786,16 @@ export const runCampaignCalendarSync = async (
|
|||
const client = options.client ?? createGoogleCalendarClient(credentials)
|
||||
|
||||
try {
|
||||
if (!options.skipChannelEnsure) {
|
||||
await ensureGoogleCalendarPushChannel(payload, options.req, client, config)
|
||||
}
|
||||
const counts = await runSyncPass(
|
||||
payload,
|
||||
options.req,
|
||||
client,
|
||||
config.calendarId,
|
||||
options.activityWhere,
|
||||
config,
|
||||
)
|
||||
await recordSyncState(payload, options.req, { lastSyncedAt: at, lastSuccessAt: at })
|
||||
return { status: 'synced', ...counts, at }
|
||||
|
|
@ -448,7 +807,7 @@ export const runCampaignCalendarSync = async (
|
|||
lastErrorAt: at,
|
||||
lastError: message.slice(0, 500),
|
||||
})
|
||||
return { status: 'paused', created: 0, updated: 0, deleted: 0, at }
|
||||
return { status: 'paused', created: 0, updated: 0, deleted: 0, reverseEdits: 0, at }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ export const activityGoogleCalendarSyncHook = async ({
|
|||
req: PayloadRequest
|
||||
}): Promise<unknown> => {
|
||||
try {
|
||||
// C115 — the reverse direction's own write must not re-enter the engine:
|
||||
// the pass that applied the Google edit is still running; a nested pass
|
||||
// would only re-list and converge (content equality) at double cost.
|
||||
if (req.context?.mutationKind === 'googleCalendarSync') return doc
|
||||
const resolvedOperation = operation ?? 'delete'
|
||||
if (shouldSyncActivityOperation({ operation: resolvedOperation, doc, previousDoc })) {
|
||||
await runCampaignCalendarSync(req.payload, { reason: resolvedOperation, req })
|
||||
|
|
|
|||
|
|
@ -185,10 +185,14 @@ test.describe('Agenda — sincronização Google (C114/C122)', () => {
|
|||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/agenda`)
|
||||
|
||||
await expect(campaignPageChrome(page, 'Agenda')).toBeVisible()
|
||||
// C101: no mobile o título da top bar vira o rótulo do período assim que
|
||||
// o calendário settle ("12 Agosto") — a asserção de página carregada é
|
||||
// o próprio FAB, o sujeito do teste.
|
||||
const fab = page.getByRole('button', { name: 'Ações rápidas' })
|
||||
await expect(fab).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// Sem doc seedado + chave fake presente → estado not-configured.
|
||||
await page.getByRole('button', { name: 'Ações rápidas' }).click()
|
||||
await fab.click()
|
||||
await page.getByRole('button', { name: 'Agenda da Campanha' }).click()
|
||||
|
||||
const sheet = syncDialog(page)
|
||||
|
|
@ -196,4 +200,163 @@ test.describe('Agenda — sincronização Google (C114/C122)', () => {
|
|||
await expect(sheet.getByText('Ainda não configurado')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('webhook público (C115)', () => {
|
||||
test('falha fechado sem canal configurado', async ({ campaign, request }) => {
|
||||
const baseURL = campaign.baseURL
|
||||
const wrongSecret = 'a'.repeat(32)
|
||||
const validSecret = 'b'.repeat(32)
|
||||
|
||||
// Unknown URL secret → 404, com ou sem headers de canal.
|
||||
const unknown = await request.post(
|
||||
`${baseURL}/campanha/agenda/google-webhook/${wrongSecret}`,
|
||||
{
|
||||
headers: {
|
||||
'x-goog-channel-id': 'channel-x',
|
||||
'x-goog-resource-id': 'resource-x',
|
||||
'x-goog-channel-token': validSecret,
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(unknown.status()).toBe(404)
|
||||
|
||||
// Short secret (below the 32-char contract) → 404.
|
||||
const short = await request.post(`${baseURL}/campanha/agenda/google-webhook/short`)
|
||||
expect(short.status()).toBe(404)
|
||||
|
||||
// GET is not a delivery method → 404.
|
||||
const get = await request.get(`${baseURL}/campanha/agenda/google-webhook/${validSecret}`)
|
||||
expect(get.status()).toBe(404)
|
||||
})
|
||||
|
||||
test('aceita uma entrega válida e trata os estados do recurso', async ({
|
||||
campaign,
|
||||
request,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const secret = 'c'.repeat(32)
|
||||
const channelId = 'channel-e2e-c115'
|
||||
const resourceId = 'resource-e2e-c115'
|
||||
|
||||
await seedSyncConfig(fixtures, {
|
||||
pushChannelId: channelId,
|
||||
pushChannelResourceId: resourceId,
|
||||
pushChannelSecret: secret,
|
||||
})
|
||||
|
||||
const url = `${campaign.baseURL}/campanha/agenda/google-webhook/${secret}`
|
||||
const headers = {
|
||||
'x-goog-channel-id': channelId,
|
||||
'x-goog-resource-id': resourceId,
|
||||
'x-goog-channel-token': secret,
|
||||
}
|
||||
|
||||
try {
|
||||
// `sync` — channel-creation ping: acknowledged, nothing recorded.
|
||||
const syncPing = await request.post(url, {
|
||||
headers: { ...headers, 'x-goog-resource-state': 'sync' },
|
||||
})
|
||||
expect(syncPing.status()).toBe(200)
|
||||
|
||||
// A valid change ping: acknowledged and the reconciliation runs — the
|
||||
// fake credential makes the pass fail fast locally, but the 200
|
||||
// contract is the point: Google must not retry a delivery whose pass
|
||||
// failed (the local auto-retry paths recover).
|
||||
const changePing = await request.post(url, {
|
||||
headers: { ...headers, 'x-goog-resource-state': 'exists' },
|
||||
})
|
||||
expect(changePing.status()).toBe(200)
|
||||
|
||||
// `not_exists` — the watched calendar is gone: still 200 (no retry
|
||||
// storm), and the staff-visible error state records WHY.
|
||||
const gonePing = await request.post(url, {
|
||||
headers: { ...headers, 'x-goog-resource-state': 'not_exists' },
|
||||
})
|
||||
expect(gonePing.status()).toBe(200)
|
||||
|
||||
const doc = await fixtures.payload.find({
|
||||
collection: 'googleCalendarSync',
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(doc.docs[0]?.lastError).toContain('não existe mais')
|
||||
} finally {
|
||||
await fixtures.payload.delete({
|
||||
collection: 'googleCalendarSync',
|
||||
where: { pushChannelId: { equals: channelId } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('o webhook aceita uma entrega válida e trata os estados do recurso (C115)', async ({
|
||||
campaign,
|
||||
request,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const secret = 'c'.repeat(32)
|
||||
const channelId = 'channel-e2e'
|
||||
const resourceId = 'resource-e2e'
|
||||
|
||||
await fixtures.payload.create({
|
||||
collection: 'googleCalendarSync',
|
||||
data: {
|
||||
calendarId: 'c_campanha_e2e@group.calendar.google.com',
|
||||
pushChannelId: channelId,
|
||||
pushChannelResourceId: resourceId,
|
||||
pushChannelSecret: secret,
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const url = `${campaign.baseURL}/campanha/agenda/google-webhook/${secret}`
|
||||
const headers = {
|
||||
'x-goog-channel-id': channelId,
|
||||
'x-goog-resource-id': resourceId,
|
||||
'x-goog-channel-token': secret,
|
||||
}
|
||||
|
||||
try {
|
||||
// `sync` — channel-creation ping: acknowledged, nothing recorded.
|
||||
const syncPing = await request.post(url, {
|
||||
headers: { ...headers, 'x-goog-resource-state': 'sync' },
|
||||
})
|
||||
expect(syncPing.status()).toBe(200)
|
||||
|
||||
// A valid change ping: acknowledged and the reconciliation runs (no
|
||||
// credential env in the test runtime → the engine no-ops as
|
||||
// `not-configured`; the 200 contract is what matters — Google must not
|
||||
// retry a delivery whose pass failed).
|
||||
const changePing = await request.post(url, {
|
||||
headers: { ...headers, 'x-goog-resource-state': 'exists' },
|
||||
})
|
||||
expect(changePing.status()).toBe(200)
|
||||
|
||||
// `not_exists` — the watched calendar is gone: still 200 (no retry
|
||||
// storm), and the staff-visible error state records WHY.
|
||||
const gonePing = await request.post(url, {
|
||||
headers: { ...headers, 'x-goog-resource-state': 'not_exists' },
|
||||
})
|
||||
expect(gonePing.status()).toBe(200)
|
||||
|
||||
const doc = await fixtures.payload.find({
|
||||
collection: 'googleCalendarSync',
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(doc.docs[0]?.lastError).toContain('não existe mais')
|
||||
} finally {
|
||||
await fixtures.payload.delete({
|
||||
collection: 'googleCalendarSync',
|
||||
where: { pushChannelId: { equals: channelId } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@
|
|||
import { getPayload, type Payload } from 'payload'
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { allDayCivilDateOf, allDayExclusiveEndDate } from '@/lib/activityAllDay'
|
||||
import {
|
||||
allDayCivilDateOf,
|
||||
allDayEndInstantFromExclusive,
|
||||
allDayExclusiveEndDate,
|
||||
allDayStartInstant,
|
||||
} from '@/lib/activityAllDay'
|
||||
import { googleEventIdForActivity } from '@/lib/googleCalendarEventMapping'
|
||||
import config from '@/payload.config'
|
||||
import type { GoogleCalendarClient, GoogleRemoteEvent } from '@/utilities/googleCalendarClient'
|
||||
|
|
@ -44,6 +49,12 @@ const createStubClient = (store: GoogleRemoteEvent[] = []): GoogleCalendarClient
|
|||
const index = store.findIndex((entry) => entry.id === eventId)
|
||||
if (index >= 0) store.splice(index, 1)
|
||||
},
|
||||
watchEvents: async () => ({
|
||||
id: 'watch-' + crypto.randomUUID(),
|
||||
resourceId: 'resource-' + crypto.randomUUID(),
|
||||
expiration: Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||
}),
|
||||
stopChannel: async () => {},
|
||||
})
|
||||
|
||||
const calendarA = 'c_campanha_a@group.calendar.google.com'
|
||||
|
|
@ -385,4 +396,412 @@ describe('campaign Google calendar sync engine (C114)', () => {
|
|||
// The abandoned calendar keeps its last mirror (documented, no retro-cleanup).
|
||||
expect(storeA).toHaveLength(1)
|
||||
})
|
||||
|
||||
describe('bidirectional reconciliation (C115)', () => {
|
||||
/** UTC ISO → the `-03:00` dateTime shape the Calendar API echoes. */
|
||||
const formatBahiaDateTime = (iso: string): string =>
|
||||
`${new Date(new Date(iso).getTime() - 3 * 3_600_000).toISOString().slice(0, 19)}-03:00`
|
||||
|
||||
const replaceEvent = (
|
||||
store: GoogleRemoteEvent[],
|
||||
eventId: string,
|
||||
patch: Partial<GoogleRemoteEvent>,
|
||||
) => {
|
||||
const index = store.findIndex((entry) => entry.id === eventId)
|
||||
store[index] = { ...store[index], ...patch }
|
||||
}
|
||||
|
||||
const reloadActivity = async (id: number) =>
|
||||
payload.findByID({
|
||||
collection: 'activity',
|
||||
id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
it('a newer Google edit applies title and schedule back with an audit record, then converges', async () => {
|
||||
const municipality = await campaignFixtures().getMunicipality()
|
||||
const activity = await createActivity({ municipality: municipality.id })
|
||||
await createConfig(calendarA)
|
||||
const store: GoogleRemoteEvent[] = []
|
||||
const client = createStubClient(store)
|
||||
await runSync(client)
|
||||
|
||||
// The renamed title keeps the `C114 ` cleanup prefix so the file's
|
||||
// afterEach can still reach this activity.
|
||||
const renamedTitle = `C114 ${crypto.randomUUID().slice(0, 8)} (renomeada no Google)`
|
||||
const eventId = googleEventIdForActivity(activity.id)
|
||||
const originalStart = new Date(store.find((entry) => entry.id === eventId)!.start!.dateTime!)
|
||||
const newStart = new Date(originalStart.getTime() + 3_600_000)
|
||||
replaceEvent(store, eventId, {
|
||||
summary: `[${municipality.name}] ${renamedTitle}`,
|
||||
start: { dateTime: formatBahiaDateTime(newStart.toISOString()) },
|
||||
end: {
|
||||
dateTime: formatBahiaDateTime(new Date(newStart.getTime() + 3_600_000).toISOString()),
|
||||
},
|
||||
updated: new Date(Date.now() + 60_000).toISOString(),
|
||||
})
|
||||
|
||||
const outcome = await runSync(client)
|
||||
expect(outcome.reverseEdits).toBe(1)
|
||||
expect(outcome.updated).toBe(0)
|
||||
|
||||
const reloaded = await reloadActivity(activity.id)
|
||||
expect(reloaded.title).toBe(renamedTitle)
|
||||
expect(reloaded.slug).toBe(activity.slug)
|
||||
expect(new Date(reloaded.startAt!).getTime()).toBe(newStart.getTime())
|
||||
const record = (reloaded.updates ?? []).at(-1)
|
||||
expect(record?.body).toContain('Google Calendar:')
|
||||
expect(record?.author).toBeNull()
|
||||
|
||||
// Converges: the next pass touches nothing (no loop).
|
||||
const again = await runSync(client)
|
||||
expect(again).toMatchObject({ updated: 0, deleted: 0, reverseEdits: 0 })
|
||||
expect(store).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('an older Google edit loses the clock rule — the Teqo re-asserts', async () => {
|
||||
const municipality = await campaignFixtures().getMunicipality()
|
||||
const activity = await createActivity({ municipality: municipality.id })
|
||||
await createConfig(calendarA)
|
||||
const store: GoogleRemoteEvent[] = []
|
||||
const client = createStubClient(store)
|
||||
await runSync(client)
|
||||
|
||||
replaceEvent(store, googleEventIdForActivity(activity.id), {
|
||||
summary: `[${municipality.name}] Editado antes do Teqo`,
|
||||
updated: new Date(Date.now() - 60_000).toISOString(),
|
||||
})
|
||||
|
||||
const outcome = await runSync(client)
|
||||
expect(outcome.updated).toBe(1)
|
||||
expect(outcome.reverseEdits).toBe(0)
|
||||
|
||||
const reloaded = await reloadActivity(activity.id)
|
||||
expect(reloaded.title).not.toBe('Editado antes do Teqo')
|
||||
expect(
|
||||
store.find((entry) => entry.id === googleEventIdForActivity(activity.id))?.summary,
|
||||
).toBe(`[${municipality.name}] ${activity.title}`)
|
||||
})
|
||||
|
||||
it('a cancelled event in Google cancels the confirmado activity, then the trash is cleaned', async () => {
|
||||
const activity = await createActivity()
|
||||
await createConfig(calendarA)
|
||||
const store: GoogleRemoteEvent[] = []
|
||||
const client = createStubClient(store)
|
||||
await runSync(client)
|
||||
|
||||
// The cancel carries Google's own `updated` clock (newer than the
|
||||
// activity's last mirrored change) — otherwise the clock rule would
|
||||
// treat it as a stale cancel and clean the trash instead.
|
||||
replaceEvent(store, googleEventIdForActivity(activity.id), {
|
||||
status: 'cancelled',
|
||||
updated: new Date(Date.now() + 60_000).toISOString(),
|
||||
})
|
||||
|
||||
const outcome = await runSync(client)
|
||||
expect(outcome.reverseEdits).toBe(1)
|
||||
const reloaded = await reloadActivity(activity.id)
|
||||
expect(reloaded.status).toBe('cancelado')
|
||||
expect((reloaded.updates ?? []).at(-1)?.body).toContain('Google Calendar: cancelada')
|
||||
|
||||
// The trashed event leaves Google on the next pass (Teqo SoT).
|
||||
const cleanup = await runSync(client)
|
||||
expect(cleanup.deleted).toBe(1)
|
||||
expect(store).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a permanently removed event cancels the activity (snapshot rule)', async () => {
|
||||
const activity = await createActivity()
|
||||
await createConfig(calendarA)
|
||||
const store: GoogleRemoteEvent[] = []
|
||||
const client = createStubClient(store)
|
||||
await runSync(client)
|
||||
expect(store).toHaveLength(1)
|
||||
|
||||
store.splice(0, 1)
|
||||
|
||||
const outcome = await runSync(client)
|
||||
expect(outcome.reverseEdits).toBe(1)
|
||||
expect((await reloadActivity(activity.id)).status).toBe('cancelado')
|
||||
})
|
||||
|
||||
it('a failed creation is never "seen" — the next pass creates instead of cancelling', async () => {
|
||||
const activity = await createActivity()
|
||||
await createConfig(calendarA)
|
||||
const store: GoogleRemoteEvent[] = []
|
||||
const flakyClient: GoogleCalendarClient = {
|
||||
...createStubClient(store),
|
||||
insertEvent: async () => {
|
||||
throw new Error('falha simulada na criação')
|
||||
},
|
||||
}
|
||||
|
||||
const failed = await runSync(flakyClient)
|
||||
expect(failed.status).toBe('paused')
|
||||
|
||||
const recovered = await runSync(createStubClient(store))
|
||||
expect(recovered.created).toBe(1)
|
||||
expect(recovered.reverseEdits).toBe(0)
|
||||
expect((await reloadActivity(activity.id)).status).toBe('confirmado')
|
||||
})
|
||||
|
||||
it('switching calendars re-creates the mirror without cancelling, and re-watches the channel', async () => {
|
||||
const activity = await createActivity()
|
||||
await createConfig(calendarA)
|
||||
const storeA: GoogleRemoteEvent[] = []
|
||||
const watched: string[] = []
|
||||
const stopped: Array<{ id: string; resourceId: string }> = []
|
||||
const client: GoogleCalendarClient = {
|
||||
...createStubClient(storeA),
|
||||
watchEvents: async (calendarId) => {
|
||||
watched.push(calendarId)
|
||||
return {
|
||||
id: 'watch-' + watched.length,
|
||||
resourceId: 'resource-' + watched.length,
|
||||
expiration: Date.now() + 30 * 86_400_000,
|
||||
}
|
||||
},
|
||||
stopChannel: async (channel) => {
|
||||
stopped.push(channel)
|
||||
},
|
||||
}
|
||||
await runSync(client)
|
||||
expect(watched).toEqual([calendarA])
|
||||
expect(storeA).toHaveLength(1)
|
||||
|
||||
const config = await loadGoogleCalendarSyncConfig(payload)
|
||||
await payload.update({
|
||||
collection: 'googleCalendarSync',
|
||||
id: config!.id,
|
||||
data: { calendarId: calendarB },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
storeA.length = 0
|
||||
|
||||
const outcome = await runSync(client, 'config-change')
|
||||
expect(outcome.created).toBe(1)
|
||||
expect(outcome.reverseEdits).toBe(0)
|
||||
expect(storeA).toHaveLength(1)
|
||||
expect((await reloadActivity(activity.id)).status).toBe('confirmado')
|
||||
// Channel re-created for the new calendar; the old one stopped.
|
||||
expect(watched).toEqual([calendarA, calendarB])
|
||||
expect(stopped).toEqual([{ id: 'watch-1', resourceId: 'resource-1' }])
|
||||
})
|
||||
|
||||
it('a newer Google edit never touches realizado history', async () => {
|
||||
const municipality = await campaignFixtures().getMunicipality()
|
||||
const activity = await createActivity({ municipality: municipality.id, status: 'realizado' })
|
||||
await createConfig(calendarA)
|
||||
const store: GoogleRemoteEvent[] = []
|
||||
const client = createStubClient(store)
|
||||
await runSync(client)
|
||||
|
||||
replaceEvent(store, googleEventIdForActivity(activity.id), {
|
||||
summary: `[${municipality.name}] Editado depois`,
|
||||
updated: new Date(Date.now() + 60_000).toISOString(),
|
||||
})
|
||||
|
||||
const outcome = await runSync(client)
|
||||
expect(outcome.updated).toBe(1)
|
||||
expect(outcome.reverseEdits).toBe(0)
|
||||
expect((await reloadActivity(activity.id)).status).toBe('realizado')
|
||||
})
|
||||
|
||||
it('a description-only drift is Teqo-owned: forward re-asserts without a reverse record', async () => {
|
||||
const activity = await createActivity()
|
||||
await createConfig(calendarA)
|
||||
const store: GoogleRemoteEvent[] = []
|
||||
const client = createStubClient(store)
|
||||
await runSync(client)
|
||||
|
||||
replaceEvent(store, googleEventIdForActivity(activity.id), {
|
||||
description: 'descrição adulterada no Google',
|
||||
})
|
||||
|
||||
const outcome = await runSync(client)
|
||||
expect(outcome.updated).toBe(1)
|
||||
expect(outcome.reverseEdits).toBe(0)
|
||||
expect((await reloadActivity(activity.id)).updates ?? []).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ensures a push channel on the first pass and renews before expiry with a fresh id', async () => {
|
||||
await createActivity()
|
||||
await createConfig(calendarA)
|
||||
const store: GoogleRemoteEvent[] = []
|
||||
const watched: Array<{
|
||||
calendarId: string
|
||||
channel: { id: string; token: string; address: string }
|
||||
}> = []
|
||||
const stopped: Array<{ id: string; resourceId: string }> = []
|
||||
const client: GoogleCalendarClient = {
|
||||
...createStubClient(store),
|
||||
watchEvents: async (calendarId, channel) => {
|
||||
watched.push({ calendarId, channel })
|
||||
return {
|
||||
id: 'watch-' + watched.length,
|
||||
resourceId: 'resource-' + watched.length,
|
||||
expiration: Date.now() + 30 * 86_400_000,
|
||||
}
|
||||
},
|
||||
stopChannel: async (channel) => {
|
||||
stopped.push(channel)
|
||||
},
|
||||
}
|
||||
await runSync(client)
|
||||
|
||||
let doc = await loadGoogleCalendarSyncConfig(payload)
|
||||
expect(watched).toHaveLength(1)
|
||||
expect(watched[0].channel.address).toContain('/campanha/agenda/google-webhook/')
|
||||
expect(watched[0].channel.token).toBe(doc?.pushChannelSecret)
|
||||
expect(doc?.pushChannelId).toBe('watch-1')
|
||||
expect(doc?.pushChannelSecret).toHaveLength(43)
|
||||
expect(doc?.pushChannelError).toBeNull()
|
||||
|
||||
// Expiring soon → renewal with a NEW unique id + stop of the old
|
||||
// channel; the URL secret ROTATES per channel (a leaked URL self-heals
|
||||
// on the next renewal).
|
||||
await payload.update({
|
||||
collection: 'googleCalendarSync',
|
||||
id: doc!.id,
|
||||
data: { pushChannelExpiresAt: new Date(Date.now() + 60_000).toISOString() },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
await runSync(client)
|
||||
|
||||
doc = await loadGoogleCalendarSyncConfig(payload)
|
||||
expect(watched).toHaveLength(2)
|
||||
expect(stopped).toEqual([{ id: 'watch-1', resourceId: 'resource-1' }])
|
||||
expect(doc?.pushChannelId).toBe('watch-2')
|
||||
expect(doc?.pushChannelSecret).toBe(watched[1].channel.token)
|
||||
expect(doc?.pushChannelSecret).not.toBe(watched[0].channel.token)
|
||||
})
|
||||
|
||||
it('a channel failure is recorded but never pauses the mirror', async () => {
|
||||
await createActivity()
|
||||
await createConfig(calendarA)
|
||||
const store: GoogleRemoteEvent[] = []
|
||||
const client: GoogleCalendarClient = {
|
||||
...createStubClient(store),
|
||||
watchEvents: async () => {
|
||||
throw new Error('watch indisponível (simulado)')
|
||||
},
|
||||
}
|
||||
|
||||
const outcome = await runSync(client)
|
||||
expect(outcome.status).toBe('synced')
|
||||
expect(outcome.created).toBe(1)
|
||||
|
||||
const doc = await loadGoogleCalendarSyncConfig(payload)
|
||||
expect(doc?.pushChannelError).toContain('watch indisponível')
|
||||
expect(doc?.pushChannelId).toBeNull()
|
||||
})
|
||||
|
||||
it('a newer all-day Google edit round-trips through the reverse path', async () => {
|
||||
const municipality = await campaignFixtures().getMunicipality()
|
||||
const activity = await createActivity({ municipality: municipality.id })
|
||||
await createConfig(calendarA)
|
||||
const store: GoogleRemoteEvent[] = []
|
||||
const client = createStubClient(store)
|
||||
await runSync(client)
|
||||
|
||||
// C127: dates derived from now — hardcoded instants would leave the
|
||||
// sync window and break the assertion deterministically after ~90 days.
|
||||
// The expected instants come from the SAME lib the engine maps through
|
||||
// (`activityAllDay`) — no manual timezone arithmetic in the test.
|
||||
const startCivil = allDayCivilDateOf(new Date(Date.now() + 2 * 86_400_000).toISOString())
|
||||
const endExclusive = allDayCivilDateOf(new Date(Date.now() + 4 * 86_400_000).toISOString())
|
||||
const expectedStartAt = allDayStartInstant(startCivil)
|
||||
const expectedEndAt = allDayEndInstantFromExclusive(endExclusive)
|
||||
|
||||
replaceEvent(store, googleEventIdForActivity(activity.id), {
|
||||
start: { date: startCivil },
|
||||
end: { date: endExclusive },
|
||||
updated: new Date(Date.now() + 60_000).toISOString(),
|
||||
})
|
||||
|
||||
const outcome = await runSync(client)
|
||||
expect(outcome.reverseEdits).toBe(1)
|
||||
|
||||
const reloaded = await reloadActivity(activity.id)
|
||||
expect(reloaded.allDay).toBe(true)
|
||||
expect(reloaded.startAt).toBe(expectedStartAt)
|
||||
expect(reloaded.endAt).toBe(expectedEndAt)
|
||||
expect((reloaded.updates ?? []).at(-1)?.body).toContain('remarcada')
|
||||
|
||||
// Converges — the forward does not flip allDay back.
|
||||
const again = await runSync(client)
|
||||
expect(again).toMatchObject({ updated: 0, deleted: 0, reverseEdits: 0 })
|
||||
expect(store).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a pending newer Google edit survives a task toggle (clock baseline = lastMirroredChangeAt)', async () => {
|
||||
const municipality = await campaignFixtures().getMunicipality()
|
||||
const activity = await createActivity({ municipality: municipality.id })
|
||||
await createConfig(calendarA)
|
||||
const store: GoogleRemoteEvent[] = []
|
||||
const client = createStubClient(store)
|
||||
await runSync(client)
|
||||
|
||||
// Google edit lands (newer than the last mirrored change)…
|
||||
const googleTitle = `C114 ${crypto.randomUUID().slice(0, 8)} (título do Google)`
|
||||
replaceEvent(store, googleEventIdForActivity(activity.id), {
|
||||
summary: `[${municipality.name}] ${googleTitle}`,
|
||||
updated: new Date(Date.now() + 60_000).toISOString(),
|
||||
})
|
||||
// …and a task toggle bumps `updatedAt` BEFORE the pass runs (it never
|
||||
// reaches the mirror — no pass, no stamp).
|
||||
await payload.update({
|
||||
collection: 'activity',
|
||||
id: activity.id,
|
||||
data: { tasks: [{ title: 'Convidar a rádio', done: true }] },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const outcome = await runSync(client)
|
||||
expect(outcome.reverseEdits).toBe(1)
|
||||
expect((await reloadActivity(activity.id)).title).toBe(googleTitle)
|
||||
})
|
||||
|
||||
it('a staff reopen after a Google cancel is not re-cancelled', async () => {
|
||||
const activity = await createActivity()
|
||||
await createConfig(calendarA)
|
||||
const store: GoogleRemoteEvent[] = []
|
||||
const client = createStubClient(store)
|
||||
await runSync(client)
|
||||
|
||||
// 1) user cancels in Google — the cancel beats the 2s clock tolerance
|
||||
// (anchored on the last mirrored change), so the activity is cancelled.
|
||||
const stampedAt = (await reloadActivity(activity.id)).lastMirroredChangeAt!
|
||||
replaceEvent(store, googleEventIdForActivity(activity.id), {
|
||||
status: 'cancelled',
|
||||
updated: new Date(new Date(stampedAt).getTime() + 5_000).toISOString(),
|
||||
})
|
||||
let outcome = await runSync(client)
|
||||
expect(outcome.reverseEdits).toBe(1)
|
||||
expect((await reloadActivity(activity.id)).status).toBe('cancelado')
|
||||
|
||||
// 2) staff reopens the activity AFTER the cancel instant — the next
|
||||
// pass must NOT re-cancel: the trashed event is cleaned instead.
|
||||
await new Promise((resolve) => setTimeout(resolve, 3_500))
|
||||
await payload.update({
|
||||
collection: 'activity',
|
||||
id: activity.id,
|
||||
data: { status: 'confirmado' },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
outcome = await runSync(client)
|
||||
expect(outcome.reverseEdits).toBe(0)
|
||||
expect(outcome.deleted).toBe(1)
|
||||
|
||||
// 3) …and a fresh event is created for the reopened commitment.
|
||||
outcome = await runSync(client)
|
||||
expect(outcome.created).toBe(1)
|
||||
expect((await reloadActivity(activity.id)).status).toBe('confirmado')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ const stateFor = (status: GoogleCalendarSyncStatus): GoogleCalendarSyncActionRes
|
|||
lastSuccessAt: status === 'synced' ? '2026-08-11T10:00:00.000Z' : null,
|
||||
lastErrorAt: status === 'paused' ? '2026-08-11T10:05:00.000Z' : null,
|
||||
lastError: status === 'paused' ? 'Google fora do ar (simulado)' : null,
|
||||
pushChannelExpiresAt: null,
|
||||
pushChannelError: null,
|
||||
addLink: status === 'not-configured' ? null : 'https://calendar.google.com/calendar/r?cid=webcal',
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -216,6 +216,10 @@ describe('campaign JSON mutation route convention', () => {
|
|||
'src/app/(campaign)/campanha/api/ai-transcribe/route.ts',
|
||||
'multipart STT endpoint (audio FormData, not JSON) — sibling of ai-chat, cookie-authenticated via campaign-token + explicit same-origin check',
|
||||
],
|
||||
[
|
||||
'src/app/(campaign)/campanha/agenda/google-webhook/[secret]/route.ts',
|
||||
'Google Calendar push delivery (empty body, not JSON) — public by design, authenticated by URL secret + X-Goog channel headers (never a campaign cookie)',
|
||||
],
|
||||
])
|
||||
|
||||
it('builds every POST route under src/app with campaignJsonMutationRoute', () => {
|
||||
|
|
|
|||
135
tests/unit/googleCalendarPushNotification.unit.spec.ts
Normal file
135
tests/unit/googleCalendarPushNotification.unit.spec.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isGooglePushNotificationValid } from '@/lib/googleCalendarPushNotification'
|
||||
|
||||
const SECRET = '0123456789abcdef0123456789abcdef'
|
||||
const CHANNEL_ID = 'channel-1'
|
||||
const RESOURCE_ID = 'resource-1'
|
||||
|
||||
const config = {
|
||||
pushChannelSecret: SECRET,
|
||||
pushChannelId: CHANNEL_ID,
|
||||
pushChannelResourceId: RESOURCE_ID,
|
||||
}
|
||||
|
||||
describe('isGooglePushNotificationValid (C115 webhook fail-closed)', () => {
|
||||
it('accepts a delivery that matches every credential', () => {
|
||||
expect(
|
||||
isGooglePushNotificationValid({
|
||||
secret: SECRET,
|
||||
channelId: CHANNEL_ID,
|
||||
resourceId: RESOURCE_ID,
|
||||
channelToken: SECRET,
|
||||
config,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a wrong URL secret', () => {
|
||||
expect(
|
||||
isGooglePushNotificationValid({
|
||||
secret: 'x'.repeat(32),
|
||||
channelId: CHANNEL_ID,
|
||||
resourceId: RESOURCE_ID,
|
||||
channelToken: SECRET,
|
||||
config,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a short URL secret', () => {
|
||||
expect(
|
||||
isGooglePushNotificationValid({
|
||||
secret: 'short',
|
||||
channelId: CHANNEL_ID,
|
||||
resourceId: RESOURCE_ID,
|
||||
channelToken: SECRET,
|
||||
config,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing channel headers', () => {
|
||||
expect(
|
||||
isGooglePushNotificationValid({
|
||||
secret: SECRET,
|
||||
channelId: null,
|
||||
resourceId: RESOURCE_ID,
|
||||
channelToken: SECRET,
|
||||
config,
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isGooglePushNotificationValid({
|
||||
secret: SECRET,
|
||||
channelId: CHANNEL_ID,
|
||||
resourceId: null,
|
||||
channelToken: SECRET,
|
||||
config,
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isGooglePushNotificationValid({
|
||||
secret: SECRET,
|
||||
channelId: CHANNEL_ID,
|
||||
resourceId: RESOURCE_ID,
|
||||
channelToken: null,
|
||||
config,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a spoofed channel id / resource id / token', () => {
|
||||
expect(
|
||||
isGooglePushNotificationValid({
|
||||
secret: SECRET,
|
||||
channelId: 'attacker-channel',
|
||||
resourceId: RESOURCE_ID,
|
||||
channelToken: SECRET,
|
||||
config,
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isGooglePushNotificationValid({
|
||||
secret: SECRET,
|
||||
channelId: CHANNEL_ID,
|
||||
resourceId: 'attacker-resource',
|
||||
channelToken: SECRET,
|
||||
config,
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isGooglePushNotificationValid({
|
||||
secret: SECRET,
|
||||
channelId: CHANNEL_ID,
|
||||
resourceId: RESOURCE_ID,
|
||||
channelToken: 'attacker-token',
|
||||
config,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects when no channel was ever configured', () => {
|
||||
expect(
|
||||
isGooglePushNotificationValid({
|
||||
secret: SECRET,
|
||||
channelId: CHANNEL_ID,
|
||||
resourceId: RESOURCE_ID,
|
||||
channelToken: SECRET,
|
||||
config: { pushChannelSecret: null, pushChannelId: null, pushChannelResourceId: null },
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a token that is a different valid length (constant-time does not leak)', () => {
|
||||
expect(
|
||||
isGooglePushNotificationValid({
|
||||
secret: SECRET,
|
||||
channelId: CHANNEL_ID,
|
||||
resourceId: RESOURCE_ID,
|
||||
channelToken: 'y'.repeat(32),
|
||||
config,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
184
tests/unit/googleCalendarReverseEdit.unit.spec.ts
Normal file
184
tests/unit/googleCalendarReverseEdit.unit.spec.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildGoogleReverseCancelBody,
|
||||
buildGoogleReverseUpdateBody,
|
||||
GOOGLE_REVERSE_EDIT_BODY_PREFIX,
|
||||
googleEditIsNewer,
|
||||
googleScheduleToActivityFields,
|
||||
googleTitleFromSummary,
|
||||
} from '@/lib/googleCalendarReverseEdit'
|
||||
|
||||
const MUNICIPALITY = 'Salvador'
|
||||
|
||||
describe('googleEditIsNewer (C115 clock rule)', () => {
|
||||
it('wins only when the Google updated instant is newer than the tolerance', () => {
|
||||
const local = '2026-08-11T12:00:00.000Z'
|
||||
expect(googleEditIsNewer('2026-08-11T12:00:02.500Z', local)).toBe(true)
|
||||
expect(googleEditIsNewer('2026-08-11T12:00:01.500Z', local)).toBe(false)
|
||||
expect(googleEditIsNewer('2026-08-11T12:00:00.000Z', local)).toBe(false)
|
||||
expect(googleEditIsNewer('2026-08-10T12:00:00.000Z', local)).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed on missing or unparseable timestamps', () => {
|
||||
expect(googleEditIsNewer(undefined, '2026-08-11T12:00:00.000Z')).toBe(false)
|
||||
expect(googleEditIsNewer('not-a-date', '2026-08-11T12:00:00.000Z')).toBe(false)
|
||||
expect(googleEditIsNewer('2026-08-11T12:00:01.000Z', undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('googleTitleFromSummary (C115 prefix strip)', () => {
|
||||
it('strips only our own [Município] prefix', () => {
|
||||
expect(googleTitleFromSummary('[Salvador] Caminhada no Centro', MUNICIPALITY)).toBe(
|
||||
'Caminhada no Centro',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a summary without our prefix verbatim (user-owned title)', () => {
|
||||
expect(googleTitleFromSummary('Reunião com o prefeito', MUNICIPALITY)).toBe(
|
||||
'Reunião com o prefeito',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a foreign prefix verbatim — it is user text, not ours', () => {
|
||||
expect(googleTitleFromSummary('[URGENTE] Reunião', MUNICIPALITY)).toBe('[URGENTE] Reunião')
|
||||
})
|
||||
|
||||
it('a summary that is only the prefix is structural tampering → null', () => {
|
||||
expect(googleTitleFromSummary('[Salvador]', MUNICIPALITY)).toBeNull()
|
||||
expect(googleTitleFromSummary('[Salvador] ', MUNICIPALITY)).toBeNull()
|
||||
})
|
||||
|
||||
it('empty summaries are null', () => {
|
||||
expect(googleTitleFromSummary(undefined, MUNICIPALITY)).toBeNull()
|
||||
expect(googleTitleFromSummary(' ', MUNICIPALITY)).toBeNull()
|
||||
})
|
||||
|
||||
it('caps the title at the Activity field limit (160)', () => {
|
||||
const long = 'x'.repeat(200)
|
||||
expect(googleTitleFromSummary(long, MUNICIPALITY)).toHaveLength(160)
|
||||
})
|
||||
})
|
||||
|
||||
describe('googleScheduleToActivityFields (C115 reverse schedule)', () => {
|
||||
it('maps a timed event by absolute instant', () => {
|
||||
const fields = googleScheduleToActivityFields({
|
||||
start: { dateTime: '2026-08-14T10:00:00-03:00' },
|
||||
end: { dateTime: '2026-08-14T12:30:00-03:00' },
|
||||
})
|
||||
expect(fields).toEqual({
|
||||
startAt: '2026-08-14T13:00:00.000Z',
|
||||
endAt: '2026-08-14T15:30:00.000Z',
|
||||
allDay: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('maps a single-day all-day event to day-boundary instants', () => {
|
||||
const fields = googleScheduleToActivityFields({
|
||||
start: { date: '2026-08-14' },
|
||||
end: { date: '2026-08-15' },
|
||||
})
|
||||
expect(fields).toEqual({
|
||||
startAt: '2026-08-14T03:00:00.000Z',
|
||||
endAt: '2026-08-14T03:00:00.000Z',
|
||||
allDay: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('maps a multi-day all-day range (exclusive end → inclusive last day)', () => {
|
||||
const fields = googleScheduleToActivityFields({
|
||||
start: { date: '2026-08-14' },
|
||||
end: { date: '2026-08-17' },
|
||||
})
|
||||
expect(fields).toEqual({
|
||||
startAt: '2026-08-14T03:00:00.000Z',
|
||||
endAt: '2026-08-16T03:00:00.000Z',
|
||||
allDay: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed on malformed values — the Teqo keeps its state', () => {
|
||||
expect(googleScheduleToActivityFields({ start: undefined, end: undefined })).toBeNull()
|
||||
expect(googleScheduleToActivityFields({ start: { dateTime: 'x' }, end: {} })).toBeNull()
|
||||
expect(
|
||||
googleScheduleToActivityFields({ start: { dateTime: '2026-08-14T10:00:00-03:00' } }),
|
||||
).toBeNull()
|
||||
expect(
|
||||
googleScheduleToActivityFields({
|
||||
start: { date: '2026-13-99' },
|
||||
end: { date: '2026-08-15' },
|
||||
}),
|
||||
).toBeNull()
|
||||
expect(
|
||||
googleScheduleToActivityFields({
|
||||
start: { date: '2026-08-15' },
|
||||
end: { date: '2026-08-15' },
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildGoogleReverseUpdateBody (C115 audit record)', () => {
|
||||
const previous = {
|
||||
title: 'Caminhada',
|
||||
startAt: '2026-08-14T13:00:00.000Z',
|
||||
endAt: '2026-08-14T14:00:00.000Z',
|
||||
allDay: false,
|
||||
}
|
||||
|
||||
it('records a title change with the Google prefix', () => {
|
||||
const body = buildGoogleReverseUpdateBody(previous, { ...previous, title: 'Caminhada 2' })
|
||||
expect(body).toBe(`${GOOGLE_REVERSE_EDIT_BODY_PREFIX} título alterado para "Caminhada 2"`)
|
||||
})
|
||||
|
||||
it('records a reschedule with before → after labels', () => {
|
||||
const body = buildGoogleReverseUpdateBody(previous, {
|
||||
...previous,
|
||||
startAt: '2026-08-15T16:00:00.000Z',
|
||||
endAt: '2026-08-15T17:00:00.000Z',
|
||||
})
|
||||
expect(body).toBe(
|
||||
`${GOOGLE_REVERSE_EDIT_BODY_PREFIX} remarcada — antes 14/08/2026 às 10:00 — 14/08/2026 às 11:00, agora 15/08/2026 às 13:00 — 15/08/2026 às 14:00`,
|
||||
)
|
||||
})
|
||||
|
||||
it('records an all-day range change', () => {
|
||||
const body = buildGoogleReverseUpdateBody(
|
||||
{
|
||||
title: 'X',
|
||||
startAt: '2026-08-14T03:00:00.000Z',
|
||||
endAt: '2026-08-14T03:00:00.000Z',
|
||||
allDay: true,
|
||||
},
|
||||
{
|
||||
title: 'X',
|
||||
startAt: '2026-08-20T03:00:00.000Z',
|
||||
endAt: '2026-08-22T03:00:00.000Z',
|
||||
allDay: true,
|
||||
},
|
||||
)
|
||||
expect(body).toBe(
|
||||
`${GOOGLE_REVERSE_EDIT_BODY_PREFIX} remarcada — antes 14/08/2026, agora 20/08/2026 a 22/08/2026`,
|
||||
)
|
||||
})
|
||||
|
||||
it('combines title and schedule changes', () => {
|
||||
const body = buildGoogleReverseUpdateBody(previous, {
|
||||
title: 'Novo',
|
||||
startAt: '2026-08-15T16:00:00.000Z',
|
||||
endAt: null,
|
||||
allDay: false,
|
||||
})
|
||||
expect(body).toContain('título alterado para "Novo"')
|
||||
expect(body).toContain('remarcada')
|
||||
})
|
||||
|
||||
it('falls back to a generic line when nothing changed', () => {
|
||||
const body = buildGoogleReverseUpdateBody(previous, previous)
|
||||
expect(body).toBe(`${GOOGLE_REVERSE_EDIT_BODY_PREFIX} compromisso atualizado`)
|
||||
})
|
||||
|
||||
it('the cancellation record carries the same prefix', () => {
|
||||
expect(buildGoogleReverseCancelBody()).toBe(`${GOOGLE_REVERSE_EDIT_BODY_PREFIX} cancelada`)
|
||||
})
|
||||
})
|
||||
145
tests/unit/googleCalendarSyncDialog.unit.spec.tsx
Normal file
145
tests/unit/googleCalendarSyncDialog.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { cleanup, render, screen, within } from '@testing-library/react'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { GoogleCalendarSyncActionResult } from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
||||
import { ActivityUpdateFeed } from '@/components/campaign/activity/ActivityUpdateFeed'
|
||||
import { GoogleCalendarSyncDialog } from '@/components/campaign/activity/GoogleCalendarSyncDialog'
|
||||
|
||||
const syncedState = (
|
||||
overrides: Partial<GoogleCalendarSyncActionResult> = {},
|
||||
): GoogleCalendarSyncActionResult => ({
|
||||
ok: true,
|
||||
status: 'synced',
|
||||
calendarId: 'c_campanha@group.calendar.google.com',
|
||||
lastSyncedAt: '2026-08-11T12:00:00.000Z',
|
||||
lastSuccessAt: '2026-08-11T12:00:00.000Z',
|
||||
lastErrorAt: null,
|
||||
lastError: null,
|
||||
pushChannelExpiresAt: null,
|
||||
pushChannelError: null,
|
||||
addLink: 'https://calendar.google.com/calendar/r?cid=webcal%3A%2F%2F…',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderDialog = (state: GoogleCalendarSyncActionResult) =>
|
||||
render(
|
||||
<GoogleCalendarSyncDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
state={state}
|
||||
onSyncNow={vi.fn()}
|
||||
onSetDisabled={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const matchMediaMock = vi.fn()
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
Element.prototype.scrollIntoView = () => {}
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
matchMediaMock.mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}))
|
||||
vi.stubGlobal('matchMedia', matchMediaMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('GoogleCalendarSyncDialog — seção "Edições pelo Google" (C115)', () => {
|
||||
it('mostra a data de expiração do canal quando ativo', () => {
|
||||
renderDialog(syncedState({ pushChannelExpiresAt: '2026-09-10T12:00:00.000Z' }))
|
||||
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
|
||||
expect(within(dialog).getByText(/Edições pelo Google/)).toBeTruthy()
|
||||
expect(within(dialog).getByText(/Notificações ativas até/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('mostra o erro do canal quando a notificação falhou, sem prometer renovação', () => {
|
||||
renderDialog(syncedState({ pushChannelError: 'NEXT_PUBLIC_SITE_URL não configurada' }))
|
||||
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
|
||||
expect(
|
||||
within(dialog).getByText(/Notificações de mudanças feitas no Google indisponíveis/),
|
||||
).toBeTruthy()
|
||||
expect(within(dialog).queryByText(/Notificações ativas até/)).toBeNull()
|
||||
})
|
||||
|
||||
it('não mostra a seção no estado não configurado', () => {
|
||||
renderDialog({
|
||||
ok: true,
|
||||
status: 'not-configured',
|
||||
calendarId: null,
|
||||
lastSyncedAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastErrorAt: null,
|
||||
lastError: null,
|
||||
pushChannelExpiresAt: null,
|
||||
pushChannelError: null,
|
||||
addLink: null,
|
||||
})
|
||||
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
|
||||
expect(within(dialog).queryByText(/Edições pelo Google/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ActivityUpdateFeed — atribuição do registro reverso (C115)', () => {
|
||||
const renderFeed = (updates: Parameters<typeof ActivityUpdateFeed>[0]['updates']) =>
|
||||
render(<ActivityUpdateFeed updates={updates} />)
|
||||
|
||||
it('atribui "Google Calendar" ao registro reverso sem autor', () => {
|
||||
renderFeed([
|
||||
{
|
||||
id: '1',
|
||||
body: 'Google Calendar: remarcada — antes 14/08 às 10:00, agora 14/08 às 16:00',
|
||||
authorName: null,
|
||||
createdAt: '2026-08-11T12:00:00.000Z',
|
||||
},
|
||||
])
|
||||
expect(screen.getByText('Google Calendar')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('mantém "Autor removido" para entradas sem autor fora do prefixo', () => {
|
||||
renderFeed([
|
||||
{
|
||||
id: '2',
|
||||
body: 'Atualização de um autor que saiu da campanha',
|
||||
authorName: null,
|
||||
createdAt: '2026-08-11T12:00:00.000Z',
|
||||
},
|
||||
])
|
||||
expect(screen.getByText('Autor removido')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('mantém o nome do autor quando presente (o prefixo não rouba a atribuição)', () => {
|
||||
renderFeed([
|
||||
{
|
||||
id: '3',
|
||||
body: 'Google Calendar: título alterado',
|
||||
authorName: 'Ana Coordenadora',
|
||||
createdAt: '2026-08-11T12:00:00.000Z',
|
||||
},
|
||||
])
|
||||
expect(screen.getByText('Ana Coordenadora')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
@ -33,7 +33,14 @@ const config = (overrides: Record<string, unknown> = {}) =>
|
|||
...overrides,
|
||||
}) as unknown as GoogleCalendarSyncDoc
|
||||
|
||||
const settledOutcome = { status: 'synced' as const, created: 0, updated: 0, deleted: 0, at: '' }
|
||||
const settledOutcome = {
|
||||
status: 'synced' as const,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
reverseEdits: 0,
|
||||
at: '',
|
||||
}
|
||||
|
||||
describe('activityGoogleCalendarSyncHook wiring', () => {
|
||||
beforeEach(() => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue