diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index b8c2337a..0fffbf05 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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//canvases/plan--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/-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 `
` (ex. `mobile`, `desktop-vazio`) → `docs/plans/-ui-draft-.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). diff --git a/docs/plans/c115-followup-snapshot-race-e-hermeticidade-int.md b/docs/plans/c115-followup-snapshot-race-e-hermeticidade-int.md new file mode 100644 index 00000000..1da9fc46 --- /dev/null +++ b/docs/plans/c115-followup-snapshot-race-e-hermeticidade-int.md @@ -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: +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 diff --git a/docs/plans/c115-google-calendar-edicao-bidirecional-impl.md b/docs/plans/c115-google-calendar-edicao-bidirecional-impl.md new file mode 100644 index 00000000..c596f242 --- /dev/null +++ b/docs/plans/c115-google-calendar-edicao-bidirecional-impl.md @@ -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
usuário edita/remarca/cancela] -->|notificação push| W[webhook público
campanha/agenda/google-webhook/[secret]] + W -->|valida X-Goog-* + token
200 imediato| R[passada bidirecional
googleCalendarSync.ts] + A[trigger local:
hook activity / view agenda /
Sincronizar agora] --> R + R --> D[diff por id determinístico + relógio:
Google mais novo → aplica no Teqo
Teqo mais novo → re-asserta no Google
status cancelled → cancela atividade] + D --> T[(atividade atualizada
+ registro no updates feed)] + D --> C[ensurePushChannel
watch/renova/stop — best-effort] + C --> S[(estado do canal
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/` — **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 `[] ` 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é 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` diff --git a/src/app/(campaign)/campanha/actions/googleCalendarSync.ts b/src/app/(campaign)/campanha/actions/googleCalendarSync.ts index 1cee7d83..9e468fb1 100644 --- a/src/app/(campaign)/campanha/actions/googleCalendarSync.ts +++ b/src/app/(campaign)/campanha/actions/googleCalendarSync.ts @@ -36,6 +36,8 @@ const failure = (message: string): GoogleCalendarSyncActionResult => ({ lastSuccessAt: null, lastErrorAt: null, lastError: null, + pushChannelExpiresAt: null, + pushChannelError: null, addLink: null, }) diff --git a/src/app/(campaign)/campanha/agenda/google-webhook/[secret]/route.ts b/src/app/(campaign)/campanha/agenda/google-webhook/[secret]/route.ts new file mode 100644 index 00000000..98a000e1 --- /dev/null +++ b/src/app/(campaign)/campanha/agenda/google-webhook/[secret]/route.ts @@ -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 => 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 => { + 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 }) +} diff --git a/src/collections/Activity.ts b/src/collections/Activity.ts index 8812bfd2..375d8dec 100644 --- a/src/collections/Activity.ts +++ b/src/collections/Activity.ts @@ -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 | 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 }), ], } diff --git a/src/collections/GoogleCalendarSync.ts b/src/collections/GoogleCalendarSync.ts index b3ca252e..50157443 100644 --- a/src/collections/GoogleCalendarSync.ts +++ b/src/collections/GoogleCalendarSync.ts @@ -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, + }, + }, ], } diff --git a/src/components/campaign/activity/ActivityUpdateFeed.tsx b/src/components/campaign/activity/ActivityUpdateFeed.tsx index 9416b8ad..f2b631e0 100644 --- a/src/components/campaign/activity/ActivityUpdateFeed.tsx +++ b/src/components/campaign/activity/ActivityUpdateFeed.tsx @@ -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 }) => ( -
- - - {update.authorName ?? 'Autor removido'} -

- {update.createdAt ? formatBahiaDateTimeLabel(update.createdAt) : ''} -

-
- -

{update.body}

-
-
-
-) +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 ( +
+ + + + {update.authorName ?? (googleRecord ? 'Google Calendar' : 'Autor removido')} + +

+ {update.createdAt ? formatBahiaDateTimeLabel(update.createdAt) : ''} +

+
+ +

{update.body}

+
+
+
+ ) +} export const ActivityUpdateFeed = ({ updates }: { updates: ActivityUpdateViewModel[] }) => { if (!updates.length) { diff --git a/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx b/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx index fb5ad864..70748d39 100644 --- a/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx +++ b/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx @@ -152,6 +152,30 @@ export const GoogleCalendarSyncDialog = ({ ) + const reverseEditBlock = ( +
+

Edições pelo Google

+

+ 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. +

+ {state.pushChannelExpiresAt ? ( +

+ Notificações ativas até {formatBahiaDateTimeLabel(state.pushChannelExpiresAt)} — renovadas + automaticamente a cada sincronização. +

+ ) : null} + {state.pushChannelError ? ( +

+ Notificações de mudanças feitas no Google indisponíveis: {state.pushChannelError}. O Teqo + continua detectando as mudanças ao sincronizar. +

+ ) : null} +
+ ) + const syncButton = (