feat(campaign): C149 — botão Conectar com o Google (OAuth) substitui a configuração manual da service account (Closes #934)

This commit is contained in:
Francisco Solla 2026-09-12 03:11:22 -03:00
parent 581cb933a8
commit 534d5f1d02
34 changed files with 16735 additions and 146 deletions

View file

@ -65,3 +65,13 @@ DEEPINFRA_API_KEY=YOUR_DEEPINFRA_API_KEY_HERE
# collection. Absent/malformed → the agenda shows "não configurado" and the
# mirror stays off — the Teqo agenda never depends on this.
GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY=
# C149 — Google OAuth client pair for the "Conectar com o Google" button. Create
# a "Web application" OAuth client in the same GCP project, register the
# redirect URIs (`<public site>/campanha/agenda/google-oauth/callback`, plus the
# local dev origin) and publish the app to production (unverified is fine for
# internal use) so the refresh token does not expire in ~7 days. Absent → the
# dialog falls back to the service-account runbook and the mirror keeps working
# via `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY`.
GOOGLE_CALENDAR_OAUTH_CLIENT_ID=
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET=

View file

@ -0,0 +1 @@
**Botão "Conectar com o Google" na agenda substitui a configuração manual da service account (2026-09-12):** ativar o espelho Google Calendar (C114/C115) exigia cerimônia de engenharia — criar service account no GCP, baixar JSON, base64 numa env e compartilhar o calendário; agora candidato ou coordenação clica **Conectar com o Google** no diálogo do espelho e autoriza uma única concessão (Authorization Code + `access_type=offline` + `prompt=consent`) para o calendário da campanha. O refresh token é gravado na própria collection `googleCalendarSync` (5 campos `oauth*`, `oauthRefreshToken` com `read: false` — inalcançável por qualquer caminho de usuário, como o segredo do webhook); a migration `20260912_052226_add_google_calendar_oauth_connection` é aditiva/nullable e o schema continua com `push: false`. O motor resolve a credencial por `readGoogleCalendarAuth` (OAuth vence quando há token + client env; service account segue fallback intacto) e o client ganhou a união `GoogleCalendarAuth` (refresh_token grant) mantendo os 6 métodos e o retry de 401. A conexão tem exatamente três estados derivados (`conectado | erro | não configurado`): `invalid_grant` vira `error` com reconexão em um clique (`GoogleCalendarAuthError` → `oauthErrorPatchFor`, pinado em unit), e o sucesso limpa o erro — falha do Google nunca corrompe o Teqo. O handshake usa escopos mínimos (`calendar.events` + `calendar.calendarlist.readonly`, validados na concessão para o consent granular não conectar sem permissão de escrita), `state` HMAC-SHA256 single-use de 10min amarrado ao usuário (cookie `campaign-google-oauth-state`, padrão WebAuthn), callback GET fora do `(app)` com redirect sempre na URL pública (`getCampaignInviteBaseURL()` — nada de localhost atrás do túnel) e re-checagem do papel fresco. Desconectar limpa a conexão no Teqo e orienta revogar o app no Google. UI: card de conexão no `GoogleCalendarSyncDialog` (Conectar/Reconectar/Desconectar; advisor vê sem botões; leader lockdown intocado) com o runbook antigo só quando o client OAuth não está configurado no servidor. Duas envs novas (`GOOGLE_CALENDAR_OAUTH_CLIENT_ID`/`_SECRET`) documentadas no `.env.example` e no runbook `docs/ops/teqo-1313-deploy.md` (criar o OAuth client Web application, registrar os redirect URIs e publicar o app unverified para o token não expirar em 7 dias) — **pré-requisito de deploy: setá-las em `~/stack/teqo-1313.env`**. Verificação: unit full 2716 passed, int full 722 passed (os specs da família Google agora serializam por advisory lock — a collection é singleton e a corrida entre arquivos paralelos sumiu), e2e `campaignAgendaGoogleSync` 9/9 local incluindo o botão de consent, typecheck/lint/format/knip/cycles verdes. Débitos deferidos com gatilho no impl plan (`docs/plans/c149-conectar-google-oauth-impl.md`): extrair o cookie assinado compartilhado no 4º site, cifrar o refresh token em repouso se compliance exigir, e a constraint única do singleton para o C150.

View file

@ -175,6 +175,28 @@ automaticamente — **não serve push, não recebe PRs, é somente leitura**.
- Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` /
build-secrets; envs só no homeserver.
## C149 — Google OAuth da agenda (envs novas + GCP)
O botão **"Conectar com o Google"** da agenda usa um OAuth client do GCP. Sem
as envs, o diálogo cai no runbook antigo da service account — que segue como
fallback e não foi removida por esta entrega.
1. No GCP (mesmo projeto da service account), crie um OAuth client **Web
application** com os redirect URIs:
- `https://jorgesolla1313.com.br/campanha/agenda/google-oauth/callback`
- `http://localhost:<porta>/campanha/agenda/google-oauth/callback` (dev)
2. Publique o app em produção — **unverified** é aceito (uso interno). Em
"Testing" o refresh token expira em ~7 dias e o estado vira `erro` na UI.
3. Adicione ao `~/stack/teqo-1313.env` (homeserver, chmod 600):
`GOOGLE_CALENDAR_OAUTH_CLIENT_ID` e `GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET`.
4. Deploy normal (`workflow_dispatch` do `deploy.yml`). O callback monta o
redirect a partir de `NEXT_PUBLIC_SITE_URL` — precisa bater exatamente com
o URI registrado no passo 1.
Desconectar no Teqo apaga o refresh token daquele lado; para revogar o acesso
de fato, revogue o app em `myaccount.google.com/permissions` (a UI orienta o
passo).
## OPS79 — última migração da plataforma antiga → nova (vertical campanha)
Operação de dados executada em 2026-08-23. Ver assistência lógica completa:

View file

@ -0,0 +1,175 @@
# Impl: C149 — Google Calendar: botão Conectar Google (OAuth) substitui a configuração manual de service account
Status: em execução
Atualizado em: 2026-09-12
Issue: #934
Intenção: docs/plans/c149-conectar-google-oauth.md
Appetite restante: ~2 dias eng (herdado)
## Leitura da intenção
- **Outcome:** candidato/coordenador conecta a conta Google do calendário da campanha com um clique (consent), sem criar service account nem colar JSON; o estado da conexão é honesto (`conectado | erro | não configurado`), erro de token reconecta em um clique e o Teqo segue SoT.
- **Não negociar:** escopos mínimos literais (`calendar.events` + `calendar.calendarlist.readonly`); exatamente três estados, derivados, nunca armazenados; `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY` preservada como fallback (não renomear/remover); token sobrevive sem re-consentimento semanal; não é OAuth por pessoa (anti-goal C114); motor de reconciliação intocado; callback sempre na URL pública (`getCampaignInviteBaseURL()`); token nunca em log; armazenamento fail-closed (sem conexão → sem sync, sem crash); leader lockdown intocado.
- **Reavaliar:** app unverified exige publicar em produção no GCP para o refresh token não expirar em ~7 dias (decisão de ops, D14); armazenamento do refresh token em texto puro é débito consciente com gatilho de revisão (D1); advisor vê o card sem botões (assumido com produto); a escrita de `calendarId` continua admin-only e será aberta no C150.
## Abordagem recomendada
```mermaid
flowchart LR
subgraph UI["Agenda /campanha/agenda"]
CARD["GoogleCalendarSyncDialog<br/>card de conexão (3 estados)"]
end
CARD -->|startGoogleCalendarOAuth| ACT[server action]
ACT -->|state assinado HMAC| COOKIE[(cookie campaign<br/>TTL 10min, single-use)]
ACT -->|authorizeUrl| GOOG[accounts.google.com<br/>consent]
GOOG -->|code + state| CB[callback GET<br/>fora do app]
COOKIE -->|verifica state| CB
CB -->|troca code| TOK[oauth2 token endpoint]
TOK -->|refresh_token| DB[(googleCalendarSync<br/>oauthRefreshToken)]
CB -->|302 /campanha/agenda| CARD
SYNC[runCampaignCalendarSync] -->|readGoogleCalendarAuth| DB
SYNC -->|Bearer access token| GCAL[Google Calendar API]
SA[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY] -.fallback quando sem OAuth.-> SYNC
CARD -->|disconnectGoogleCalendarOAuth| ACT2[server action]
ACT2 -->|limpa conexão| DB
```
**Opções consideradas:** A) Authorization Code + rota de callback própria com state assinado em cookie | B) fluxo out-of-band (device/código colado) | C) manter service account e melhorar só o runbook.
**Recomendação:** A — reusa o precedente de cerimônia fora do `(app)` (WebAuthn) e o resolvedor de URL pública (`getCampaignInviteBaseURL`), entrega o consent de um clique e isola o callback para teste; B troca "um clique" por "copiar e colar código" e não tem precedente; C não entrega o outcome pedido pelo dono.
**Rejeitadas:** B porque adiciona atrito e uma superfície de erro nova; C porque é exatamente a cerimônia que o pedido manda remover.
### Componentes / mudanças
- **`src/collections/GoogleCalendarSync.ts`** — cinco campos novos (D1): `oauthRefreshToken` (text, `read: canReadGoogleCalendarSyncIdentityField`, `create/update: canSetGoogleCalendarSyncSystemField`), `oauthScope` (text), `oauthConnectedAt` (date), `oauthErrorAt` (date), `oauthError` (textarea); labels/admin `readOnly` em pt-BR; `admin.defaultColumns` inalterado.
- **`src/utilities/access/googleCalendarSync.ts`** — `canManageGoogleCalendarConnection(actor)` (delega a `isCampaignUnrestricted`); novos campos usam os helpers existentes (`canSetGoogleCalendarSyncSystemField`, `canReadGoogleCalendarSyncIdentityField`).
- **`src/migrations/<timestamp>_add_google_calendar_oauth_connection.{ts,json}` + `index.ts`** — migração aditiva (colunas nullable), gerada com `pnpm migrate:create add_google_calendar_oauth_connection`; `src/payload-types.ts` regenerado (`pnpm generate:types`).
- **`src/lib/googleCalendarOAuth.ts`** (novo, puro) — `GOOGLE_CALENDAR_OAUTH_SCOPES`, `GOOGLE_CALENDAR_OAUTH_CALLBACK_PATH`, `GOOGLE_CALENDAR_OAUTH_AUTHORIZE_ENDPOINT`, `buildGoogleCalendarOAuthAuthorizeUrl({ clientId, redirectUri, state })` com `access_type=offline`, `prompt=consent`, `response_type=code`.
- **`src/utilities/googleCalendarOAuth.ts`** (novo, server-only, sem importar `googleCalendarSync`) — `readGoogleCalendarOAuthCredentials()` (fail-closed), `buildGoogleCalendarOAuthRedirectUri()` (via `getCampaignInviteBaseURL()`), `storeGoogleCalendarOAuthState(state, userId)` / `readGoogleCalendarOAuthState(state)` / `clearGoogleCalendarOAuthState()` (HMAC `PAYLOAD_SECRET`, payload state+userId+exp, TTL 10min, httpOnly/lax, path `/campanha`, single-use), `exchangeGoogleCalendarOAuthCode(code, fetchImpl?)` → `{ refreshToken, scope }` ou erro tipado com mensagem segura (sem corpo/token).
- **`src/utilities/googleCalendarClient.ts`** — `GoogleCalendarAuth = { kind: 'service-account'; credentials } | { kind: 'oauth'; refreshToken; clientId; clientSecret }`; `createGoogleCalendarClient(auth, ...)` mantém os 6 métodos e o cache em memória; `requestAccessToken` ganha o branch `grant_type=refresh_token`; novo `GoogleCalendarAuthError` APENAS para `invalid_grant` (refresh token revogado/expirado — o caso reconectável; `invalid_client`/5xx seguem `GoogleCalendarApiError`, pois reconectar não conserta); `buildServiceAccountAssertion` preservado para testes.
- **`src/utilities/googleCalendarSync.ts`** — `readGoogleCalendarAuth(config)` (OAuth se refresh token + env; senão SA; senão `null`); `GoogleCalendarConnectionState = 'connected' | 'error' | 'not-configured'` + `deriveGoogleCalendarConnectionState`; `GoogleCalendarSyncView` ganha `connection`, `oauthAvailable`, `oauthConnectedAt`, `oauthError`; `loadGoogleCalendarSyncConfig` passa a "row com `calendarId` vence, senão qualquer row" (D11); helpers `recordGoogleCalendarOAuthConnection`, `recordGoogleCalendarOAuthError`, `clearGoogleCalendarOAuthConnection`; `runCampaignCalendarSync` resolve a auth pelo config, limpa `oauthError*` no sucesso e grava `oauthErrorAt/oauthError` no catch de `GoogleCalendarAuthError`.
- **`src/app/(campaign)/campanha/actions/googleCalendarSync.ts`** — `startGoogleCalendarOAuth()` (valida `reloadUnrestrictedActor`, exige env, cria o row singleton se ausente com admin bypass, grava state cookie, devolve `{ ok, authorizeUrl }`) e `disconnectGoogleCalendarOAuth()` (limpa token/scope/datas/erro; sem revoke remoto; sem pass de sync); `GoogleCalendarSyncActionResult` ganha `canManageConnection` e os campos de conexão; `failure()` atualizado.
- **`src/app/(campaign)/campanha/agenda/google-oauth/callback/route.ts`** (novo, fora do `(app)`, `dynamic = 'force-dynamic'`) — GET: lê `code/state/error`, verifica e queima o state cookie, re-checa papel fresco, troca o code, grava a conexão, roda pass de sync se houver `calendarId` e responde `NextResponse.redirect` 302 para `/campanha/agenda`; `access_denied` não grava erro; falha de troca grava `oauthError` e redireciona (sem query param).
- **`src/components/campaign/activity/GoogleCalendarSyncDialog.tsx`** — card de conexão por estado (badge, Conectar/Reconectar/Desconectar, nota do fallback SA, nota para não-manager, runbook admin só quando `!oauthAvailable`); props novas `onStartOAuth`, `onDisconnect`, `canManageConnection`.
- **`src/components/campaign/activity/AgendaGoogleSyncChrome.tsx`** — callbacks `onStartOAuth`/`onDisconnect` repassados; pill existente intocada.
- **`src/app/(campaign)/campanha/(app)/agenda/page.tsx`** — passa as server actions novas ao chrome.
- **`scripts/lib/e2e-affected-manifest.mjs`** — entry nova mapeando `src/utilities/googleCalendarSync.ts`, `googleCalendarSyncHooks.ts`, `googleCalendarClient.ts`, `googleCalendarOAuth.ts`, `src/lib/googleCalendarOAuth.ts`, `GoogleCalendarSyncDialog.tsx`, `AgendaGoogleSyncChrome.tsx` e `src/app/(campaign)/campanha/agenda/google-oauth` ao spec `campaignAgendaGoogleSync`.
- **`playwright.config.ts`** — `GOOGLE_CALENDAR_OAUTH_CLIENT_ID`/`_SECRET` dummy no `webServer.env` (botão visível no e2e).
- **`tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts`** — estados com OAuth: botão, URL de consent interceptada, conectado e erro.
- **`.env.example` + `docs/ops/teqo-1313-deploy.md`** — as duas envs e o runbook GCP (D14).
- **Testes:** `tests/unit/googleCalendarOAuth.unit.spec.ts` (novo), extensões em `googleCalendarClient.unit.spec.ts`, `googleCalendarSync.unit.spec.ts`, `googleCalendarSyncDialog.unit.spec.tsx`, `agendaGoogleSyncChrome.unit.spec.tsx`, `tests/int/googleCalendarSync.int.spec.ts`, `tests/int/googleCalendarSyncAction.int.spec.ts`; allowlist top-level de `tests/unit/codebaseConventions.unit.spec.ts` ganha `googleCalendarOAuth.ts`.
### Dados → forma
Estado da conexão é **derivado** (nunca armazenado), função de `oauthRefreshToken`, `oauthConnectedAt` e `oauthErrorAt`:
| refresh token | erro mais novo que a conexão | estado | UI |
| ------------- | ----------------------------------------------------------- | ---------------- | ------------------------------------------------------------------- |
| não | não | `not-configured` | "Não configurado" + Conectar (se `oauthAvailable`) ou runbook admin |
| sim ou não | sim (`oauthErrorAt > oauthConnectedAt`, inclui sem conexão) | `error` | "Erro na conexão" + Reconectar + Desconectar |
| sim | não | `connected` | "Conectado" + Desconectar |
`oauthAvailable` (env do client presente) é ortogonal ao estado e decide botão vs. runbook. `oauthScope`, `oauthConnectedAt` e `oauthError` são apenas informativos/derivados; o token nunca cruza a fronteira da view.
## Decisões de engenharia (decisão + por quê + rejeitadas; só as caras de reverter)
1. **Armazenamento na própria `googleCalendarSync` (D1):** campos novos na collection que já é dona do estado do espelho; sem collection twin. Texto puro com precedente (`SocialFeedSettings.instagramAccessToken`, `pushChannelSecret`), `read: false` no token e nunca logar. **Rejeitadas:** AES-GCM (sem precedente no repo, rotação de chave e o DB comprometido já é game over) — vira débito com gatilho de revisão; collection nova (twin).
2. **Credenciais do app OAuth em env (D2):** `GOOGLE_CALENDAR_OAUTH_CLIENT_ID`/`_SECRET`; redirect URI sempre derivado de `getCampaignInviteBaseURL()` + `/campanha/agenda/google-oauth/callback`. **Rejeitadas:** client no DB (não é per-campanha); override de redirect por env (risco de localhost vazar).
3. **Transporte por união de auth (D3):** `GoogleCalendarAuth` com SA (JWT assertion, comportamento atual) e OAuth (refresh_token grant) na mesma superfície de 6 métodos, mesmo cache e mesmo retry de 401; `GoogleCalendarAuthError` separa conexão quebrada (`invalid_grant`, reconectável) de erro de API/config (`invalid_client`, 5xx → pausa). **Rejeitadas:** segundo client OAuth paralelo; tratar todo erro de token como auth error (invalid_client viraria "reconecte" sem conserto).
4. **Resolução e limpeza no motor (D4):** `readGoogleCalendarAuth` prefere OAuth quando há refresh token + env, senão SA, senão `null`; sucesso limpa `oauthError*`, `GoogleCalendarAuthError` grava `oauthErrorAt/oauthError` além de `lastError`/paused. **Rejeitada:** fallback silencioso para SA com token quebrado (mascararia o erro e tornaria o estado `error` inalcançável com SA configurada).
5. **Estado derivado (D5):** `'connected' | 'error' | 'not-configured'` como função pura dos campos, nunca coluna nova. **Rejeitadas:** armazenar o estado (drift); quarto estado intermediário.
6. **Papéis (D6):** conectar/desconectar = `isCampaignUnrestricted` via `canManageGoogleCalendarConnection`, enforcement com `reloadUnrestrictedActor` na action; advisor vê o card sem botões; leader não vê a agenda. **Rejeitadas:** advisor conectar (credencial global); liberar por `canUpdateGoogleCalendarSync` (staff inteiro).
7. **Fluxo: action de start + callback GET com state em cookie (D7):** `startGoogleCalendarOAuth` devolve `authorizeUrl` e o client navega; callback valida state (HMAC, TTL 10min, single-use, bound ao userId), re-checa papel fresco, troca o code e responde 302. **Rejeitadas:** searchParam/hash para aviso pós-callback (a canonicalização de `resolveActivityAgendaUrl` come parâmetros desconhecidos); rota GET de start (menos testável e sem precedente); revogação remota no disconnect.
8. **Desconectar limpa só o Teqo (D8):** remove refresh token/scope/datas/erro com admin bypass e orienta revogar no Google; sem pass de sync automático (o próximo trigger cai no fallback SA, se houver). **Rejeitada:** chamar `/revoke` no Google (fora do literal da intenção).
9. **Módulos (D9):** puro em `src/lib/googleCalendarOAuth.ts`, server-only em `src/utilities/googleCalendarOAuth.ts` (sem importar `googleCalendarSync` para não criar ciclo), record/clear e resolução no `googleCalendarSync.ts`, client no `googleCalendarClient.ts`; registrar o novo top-level na allowlist. **Rejeitadas:** um módulo único misturando puro/server; lógica OAuth dentro do client.
10. **UI (D10):** card de conexão dentro do `GoogleCalendarSyncDialog`; pill existente e motor intocados; runbook admin só quando `!oauthAvailable`. **Rejeitadas:** diálogo novo; mexer na pill de status do espelho.
11. **Loader singleton (D11):** "row com `calendarId` vence; senão qualquer row" para o row da conexão não ficar escondido pelo filtro atual. **Rejeitada:** manter o filtro e criar row só no primeiro sync (a conexão ficaria invisível).
12. **Manifest e2e (D12):** entry do domínio Google Calendar → `campaignAgendaGoogleSync`, fechando o gap pré-existente. **Rejeitada:** confiar só no fallback de smoke (o spec não rodaria no PR).
13. **E2E determinístico (D13):** envs dummy no webServer; estados conectado/erro com `lastSuccessAt` futuro para não disparar auto-retry; consent assertado com `page.route('https://accounts.google.com/**')`. **Rejeitada:** consent real (rede/flake).
14. **Ops documentado, sem código (D14):** OAuth client "Web application" no GCP com redirect URIs (prod + dev), escopos, app **publicado em produção (unverified)** para o refresh token não expirar em 7 dias; envs em `~/stack/teqo-1313.env`; aviso de app não verificado aceito (decisão de produto A).
## Fases verificáveis
1. **Tracer/schema + server (~55%, ≈1,1 dia)**
- Campos + access + `pnpm migrate:create add_google_calendar_oauth_connection` + `pnpm migrate` + `pnpm generate:types`.
- `src/lib/googleCalendarOAuth.ts` e `src/utilities/googleCalendarOAuth.ts` com unit (URL, escopos, state TTL/single-use, env fail-closed, exchange sem token em mensagem).
- Auth union + `GoogleCalendarAuthError` no client com unit (SA inalterado, refresh grant, 401 re-mint).
- `readGoogleCalendarAuth`, derivação do estado, loader D11, record/clear, sucesso limpa erro, catch de `GoogleCalendarAuthError`; unit/int do sync atualizados.
- Actions start/disconnect + `canManageGoogleCalendarConnection`; int com mock de `campaignActionContext` (padrão de `googleCalendarSyncAction.int.spec.ts`).
- Allowlist top-level de `codebaseConventions` registrada. Gate: `pnpm gate:fast`.
2. **Callback + UI + e2e + docs (~35%, ≈0,7 dia)**
- Callback route (state, papel fresco, exchange, grava, pass se `calendarId`, 302).
- Dialog/chrome/page; unit de dialog/chrome; copy pt-BR dos três estados + orientação de revogar no Google.
- e2e: envs no `playwright.config.ts`, spec com botão/consent/conectado/erro, entry no manifest.
- `.env.example` + runbook em `docs/ops/teqo-1313-deploy.md`. Gate: `pnpm test:e2e:affected` (deve incluir `campaignAgendaGoogleSync`).
3. **Gates finais (~10%, ≈0,2 dia)**
- `pnpm gate:fast`; e2e afetado local (`pnpm test:e2e:affected`); `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- **Criptografar o refresh token no DB (AES-GCM/keyring):** débito registrado com gatilho (rotação de chave/requisito de compliance); não entra.
- **Chamar `/revoke` no Google ao desconectar:** só orientação na UI (literal da intenção).
- **Listar/escolher calendário:** C150; aqui só a conexão.
- **Verificação completa do app Google / auditoria:** ops, fora.
- **Aposentar/migrar a service account:** fora; segue fallback.
- **Watchdog de expiração do refresh token / cron:** fora; o erro aparece no próximo pass e a UI oferece Reconectar.
- **Módulo genérico de envs:** fora; `process.env` direto como no resto do repo.
- **Rate limiting nas rotas OAuth:** fora; state + TTL bastam para este escopo.
- **Motor de reconciliação, janela, locks, hooks C114/C122:** intocados.
- **Aviso pós-callback via searchParam/hash:** rejeitado; redirect simples para `/campanha/agenda`.
- **OAuth por pessoa / agenda pessoal:** anti-goal C114.
## Riscos e mitigação
- **Refresh token expira em ~7 dias (app unverified em testing):** D14 publica o app em produção (unverified) no GCP; se expirar, estado vira `error` e Reconectar resolve. Risco residual aceito (produto A).
- **Localhost vazando no redirect atrás do túnel:** redirect URI sempre de `getCampaignInviteBaseURL()` (prod exige HTTPS DNS); unit pin do path do callback; e2e asserta `redirect_uri` público.
- **CSRF/replay do callback:** state HMAC `PAYLOAD_SECRET` com TTL 10min, single-use (queimado antes da troca) e binding ao userId; papel re-checado fresco.
- **Token em log:** exchange não loga corpo; mensagens usam status/código seguros; `oauthRefreshToken` com `read: false`; unit garante ausência de token/code na mensagem de erro.
- **Falha do Google corromper dados:** Teqo SoT; `GoogleCalendarAuthError` só grava `oauthError*`/paused; int cobre `invalid_grant` sem apagar/duplicar eventos.
- **OAuth quebrado sem fallback SA:** decisão consciente (D4); a UI expõe erro + Reconectar e o runbook explica.
- **Upsert do row/corrida:** start cria o singleton com admin bypass; única escrita concorrente é o callback; sem lock novo.
- **Migration em prod:** aditiva/nullable, aplicada pelo deploy (`pnpm build` roda `payload migrate`); sem backfill; baseline intocada.
- **`unmapped-risk` no CI:** `src/utilities/access` já é mapeado; a entry nova do domínio Google entra no mesmo PR — falhar fechado é o comportamento desejado.
- **Auto-retry em erro de conexão:** uma tentativa por load da agenda (sem hammer); falha rápida no token endpoint.
## Qualidade da decisão
**Auto-avaliação: 4/5** — as decisões já vêm fechadas e ancoradas em achados concretos (rotas de cerimônia fora do `(app)`, cookie HMAC do WebAuthn, precedente de credencial no DB, canonicalização da agenda, gap do manifest), e o caminho crítico é testável sem tocar o Google. Perde 1 ponto por dependências fora do código: o refresh token de app unverified é uma promessa de ops (publicar em produção no GCP) e o e2e não exercita consent real, então a prova de produção fica no smoke manual pós-deploy; o armazenamento em texto puro é débito aceito com gatilho, não resolvido.
## Aceite de engenharia
- [ ] Botão "Conectar com o Google" no card da agenda para candidato/coordenador; advisor vê o card sem botões; leader sem agenda (lockdown intocado).
- [ ] Estados exatamente `conectado | erro | não configurado`, derivados de `oauthRefreshToken`/`oauthConnectedAt`/`oauthErrorAt`.
- [ ] Erro de token (`invalid_grant`) mostra Reconectar em um clique; `access_denied` não grava erro.
- [ ] Callback atrás do túnel usa a URL pública (`getCampaignInviteBaseURL()`); sem localhost em produção.
- [ ] Token nunca em log; `oauthRefreshToken` com `read: false`; exchange sem corpo em erro.
- [ ] Fail-closed: sem auth → `runCampaignCalendarSync` retorna cedo, sem crash e sem sync.
- [ ] Desconectar limpa a conexão no Teqo e orienta revogar no Google; sem pass de sync automático.
- [ ] Service account segue como fallback; `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY` não renomeada/removida.
- [ ] Escopos literais `calendar.events` + `calendar.calendarlist.readonly`; `access_type=offline` + `prompt=consent`.
- [ ] Migration nova aplicada local + `payload-types.ts` regenerado; nenhuma migration antiga editada.
- [ ] `pnpm gate:fast` verde; `pnpm test:e2e:affected` inclui `campaignAgendaGoogleSync` (manifest atualizado) e passa local.
- [ ] `.env.example` e runbook de ops (GCP, envs, app publicado unverified) documentados.
## Execução — desvios e decisões emergentes (2026-09-12)
- **Classificação do `invalid_grant` virou helper puro** (`oauthErrorPatchFor` em `googleCalendarSync.ts`, pinado em unit): o catch do motor é I/O-bound e o int de motor com row configurado colidia com os specs C114 paralelos. O comportamento é o mesmo; a prova ficou no unit.
- **Escopos concedidos são validados no exchange**: consent granular permite desmarcar `calendar.events`; sem a checagem o card diria "Conectado" e o sync falharia 403 sem caminho de reconexão.
- **Callback só grava `oauthError` quando o EXCHANGE falha** (papel recusado/env ausente não sujam o estado de uma conexão sadia; um erro transitório se auto-cura no próximo pass bem-sucedido, que limpa o campo).
- **Suíte int dos specs Google serializada por advisory lock** (`GOOGLE_CALENDAR_SYNC_LOCK_KEY` + `serializeSpecWithAdvisoryLock` em `tests/helpers/advisoryLock.ts`): a collection é singleton e o loader "row configurado vence" fazia specs paralelos lerem o doc um do outro. A serialização elimina a dependência de `retry` da família.
- **Specs novos não criam rows configurados** (só connection-only) e limpam tokens em rows compartilhados sem deletá-los; o pass do callback é mockado no spec de actions (o motor tem spec próprio).
- **`.env.example` + runbook C149 em `docs/ops/teqo-1313-deploy.md`** documentam o OAuth client, os redirect URIs e a publicação unverified.
## Débitos (triage do /simplify)
| ID | Achado | Score | Tipo | Destino |
| --- | ------------------------------------------------------------------- | ----- | ------------- | ------------------------------------------------------------- |
| S1 | Cookie HMAC — 3ª cópia (`googleCalendarOAuth` vs WebAuthn vs import) | 3 | defer_trigger | Defer: extrair `signedCampaignCookie` no 4º cookie assinado ou no próximo toque de um dos três |
| S2 | Refresh token OAuth em texto puro no DB | 3 | defer_trigger | Defer: cifrar em repouso quando compliance/counsel exigir (o campo já é `read: false` e o precedente do repo é texto puro) |
| S3 | Upsert do singleton sem constraint única | 2 | defer_trigger | Defer para **C150** (dono do `calendarId`): decidir constraint se twins virarem risco real |
| S4 | Helper de env dos testes duplicado em 3 specs | 2 | cheap_polish | Descartado (barato, local; os specs estão verdes) |
| S5 | `docToView` com 3 flags posicionais | 1 | cheap_polish | Descartado (cosmético) |
- **Já resolvido no simplify (não reabrir):** índice em `oauthConnectedAt` removido; `failure()` sem diagnóstico falso; copy do card ajustada; `Date.parse` na derivação; guarda do `parseAccessToken`; validação de escopo concedido; prefixo morto do manifest; callback grava erro só do exchange; `runAction` no dialog; classify puro `oauthErrorPatchFor`; serialização int.
- **Explicitamente fora:** criptografia do refresh token agora; revogação remota no Google (só orientação); rate limiting nas rotas OAuth; verificação completa do app Google; aposentadoria da service account.

View file

@ -228,6 +228,17 @@ export default defineConfig({
* e2e states stay deterministic (see `googleCalendarTestKey.ts`).
*/
[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV_NAME]: GOOGLE_CALENDAR_TEST_KEY,
/*
* C149 — dummy OAuth client pair so the agenda dialog offers the
* "Conectar com o Google" button (the view derives `oauthAvailable`).
* No spec seeds a refresh token on the shared sync row except with a
* far-future success, and the button test intercepts the navigation
* to Google — no request ever leaves the runner for oauth2.
*/
GOOGLE_CALENDAR_OAUTH_CLIENT_ID:
process.env.GOOGLE_CALENDAR_OAUTH_CLIENT_ID ?? 'e2e-client-id.apps.googleusercontent.com',
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET:
process.env.GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET ?? 'e2e-client-secret',
// Keep e2e artifacts outside `.next`: a concurrent development server
// owns that entire directory and may clear nested production bundles.
NEXT_DIST_DIR: process.env.NEXT_DIST_DIR ?? '.next-e2e',

View file

@ -101,6 +101,23 @@ export const E2E_AFFECTED_MANIFEST = [
],
specs: ['campaignActivity'],
},
{
// C149 — the Google Calendar OAuth connection surface. The engine/client
// utilities live top-level (not under `src/utilities/activity`), and the
// callback route sits OUTSIDE `(app)` — without this entry a diff to them
// would only wake the home smoke, leaving the mirror spec unrun.
prefixes: [
'src/utilities/googleCalendarSync.ts',
'src/utilities/googleCalendarSyncHooks.ts',
'src/utilities/googleCalendarClient.ts',
'src/utilities/googleCalendarOAuth.ts',
'src/lib/googleCalendarOAuth.ts',
'src/components/campaign/activity/AgendaGoogleSyncChrome',
'src/components/campaign/activity/GoogleCalendarSyncDialog',
'src/app/(campaign)/campanha/agenda/google-oauth',
],
specs: ['campaignAgendaGoogleSync'],
},
{
prefixes: [
`${CAMPAIGN_APP}/liderancas`,

View file

@ -8,9 +8,11 @@ import {
revokeCalendarFeed,
} from '@/app/(campaign)/campanha/actions/calendarFeed'
import {
disconnectGoogleCalendarOAuth,
getGoogleCalendarSyncState,
runGoogleCalendarSyncNow,
setGoogleCalendarSyncDisabled,
startGoogleCalendarOAuth,
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda'
import { ActivityAgendaFilters } from '@/components/campaign/activity/ActivityAgendaFilters'
@ -102,6 +104,14 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
'use server'
return setGoogleCalendarSyncDisabled(disabled)
}}
onStartOAuth={async () => {
'use server'
return startGoogleCalendarOAuth()
}}
onDisconnect={async () => {
'use server'
return disconnectGoogleCalendarOAuth()
}}
/>
<CampaignListPendingBoundary>

View file

@ -1,11 +1,25 @@
'use server'
import { randomBytes } from 'node:crypto'
import type { CampaignUser } from '@/payload-types'
import type { Payload } from 'payload'
import { buildGoogleCalendarAddLink } from '@/lib/googleCalendarLink'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import { buildGoogleCalendarOAuthAuthorizeUrl } from '@/lib/googleCalendarOAuth'
import { canManageGoogleCalendarConnection } from '@/utilities/access/googleCalendarSync'
import {
getCampaignActionContext,
reloadCampaignActor,
reloadStaffActor,
} from '@/utilities/campaignActionContext'
import {
buildGoogleCalendarOAuthRedirectUri,
readGoogleCalendarOAuthCredentials,
storeGoogleCalendarOAuthState,
} from '@/utilities/googleCalendarOAuth'
import {
clearGoogleCalendarOAuthConnection,
loadGoogleCalendarSyncConfig,
readGoogleCalendarSyncView,
runCampaignCalendarSync,
@ -16,21 +30,41 @@ const GOOGLE_CALENDAR_SYNC_STAFF_ONLY_MESSAGE =
'Apenas a equipe da campanha pode gerenciar a sincronização com o Google.'
const GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE =
'Não foi possível atualizar o estado da sincronização com o Google.'
const GOOGLE_CALENDAR_OAUTH_MANAGER_ONLY_MESSAGE =
'Apenas candidato ou coordenação pode conectar ou desconectar a conta Google da campanha.'
const GOOGLE_CALENDAR_OAUTH_UNAVAILABLE_MESSAGE =
'A conexão com o Google ainda não está configurada no servidor. Fale com a equipe técnica.'
export type GoogleCalendarSyncActionResult = {
ok: boolean
message?: string
/** C149 — the actor may connect/disconnect (candidate or coordinator). */
canManageConnection: boolean
} & GoogleCalendarSyncView & { addLink: string | null }
const withLinks = (view: GoogleCalendarSyncView): Omit<GoogleCalendarSyncActionResult, 'ok'> => ({
export type GoogleCalendarOAuthStartResult =
| { ok: true; authorizeUrl: string }
| { ok: false; message: string }
const withLinks = (
view: GoogleCalendarSyncView,
): Omit<GoogleCalendarSyncActionResult, 'ok' | 'canManageConnection'> => ({
...view,
addLink: view.calendarId ? buildGoogleCalendarAddLink(view.calendarId) : null,
})
const failure = (message: string): GoogleCalendarSyncActionResult => ({
const failure = (
message: string,
canManageConnection: boolean,
): GoogleCalendarSyncActionResult => ({
ok: false,
message,
canManageConnection,
status: 'not-configured',
connection: 'not-configured',
oauthAvailable: false,
oauthConnectedAt: null,
oauthError: null,
calendarId: null,
lastSyncedAt: null,
lastSuccessAt: null,
@ -44,16 +78,33 @@ const failure = (message: string): GoogleCalendarSyncActionResult => ({
const requireStaff = async (payload: Payload, actor: CampaignUser): Promise<CampaignUser> =>
reloadStaffActor(payload, actor, GOOGLE_CALENDAR_SYNC_STAFF_ONLY_MESSAGE)
/**
* C149 — reloads the actor and returns them only when they may manage the
* connection (candidate/coordinator). The role check runs on the fresh
* document; `null` means "refused", never "not loaded".
*/
const loadConnectionManager = async (
payload: Payload,
actor: CampaignUser,
): Promise<CampaignUser | null> => {
const currentActor = await reloadCampaignActor(payload, actor)
return canManageGoogleCalendarConnection(currentActor) ? currentActor : null
}
/** Read model for the agenda pill/dialog — status derived, never stored. */
export const getGoogleCalendarSyncState = async (): Promise<GoogleCalendarSyncActionResult> => {
const { payload, actor } = await getCampaignActionContext()
try {
await requireStaff(payload, actor)
return { ok: true, ...withLinks(await readGoogleCalendarSyncView(payload)) }
const currentActor = await requireStaff(payload, actor)
return {
ok: true,
canManageConnection: canManageGoogleCalendarConnection(currentActor),
...withLinks(await readGoogleCalendarSyncView(payload)),
}
} catch (error) {
console.error('getGoogleCalendarSyncState failed', error)
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE)
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE, canManageGoogleCalendarConnection(actor))
}
}
@ -62,12 +113,16 @@ export const runGoogleCalendarSyncNow = async (): Promise<GoogleCalendarSyncActi
const { payload, actor } = await getCampaignActionContext()
try {
await requireStaff(payload, actor)
const currentActor = await requireStaff(payload, actor)
await runCampaignCalendarSync(payload, { reason: 'manual' })
return { ok: true, ...withLinks(await readGoogleCalendarSyncView(payload)) }
return {
ok: true,
canManageConnection: canManageGoogleCalendarConnection(currentActor),
...withLinks(await readGoogleCalendarSyncView(payload)),
}
} catch (error) {
console.error('runGoogleCalendarSyncNow failed', error)
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE)
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE, canManageGoogleCalendarConnection(actor))
}
}
@ -90,9 +145,92 @@ export const setGoogleCalendarSyncDisabled = async (
overrideAccess: false,
})
}
return { ok: true, ...withLinks(await readGoogleCalendarSyncView(payload)) }
return {
ok: true,
canManageConnection: canManageGoogleCalendarConnection(currentActor),
...withLinks(await readGoogleCalendarSyncView(payload)),
}
} catch (error) {
console.error('setGoogleCalendarSyncDisabled failed', error)
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE)
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE, canManageGoogleCalendarConnection(actor))
}
}
/**
* C149 — starts the OAuth handshake: validates the manager role, ensures the
* singleton connection row exists, stores the signed single-use `state` cookie
* and returns the Google consent URL for the client to navigate to. The
* authorize URL is built from the canonical public origin (never the request),
* so a callback behind the Cloudflare tunnel cannot leak localhost.
*/
export const startGoogleCalendarOAuth = async (): Promise<GoogleCalendarOAuthStartResult> => {
const { payload, actor } = await getCampaignActionContext()
try {
if (!(await loadConnectionManager(payload, actor))) {
return { ok: false, message: GOOGLE_CALENDAR_OAUTH_MANAGER_ONLY_MESSAGE }
}
const credentials = readGoogleCalendarOAuthCredentials()
if (!credentials) {
return { ok: false, message: GOOGLE_CALENDAR_OAUTH_UNAVAILABLE_MESSAGE }
}
// Resolved BEFORE any write: a missing canonical origin throws here and
// must not leave a stray connection row behind.
const redirectUri = buildGoogleCalendarOAuthRedirectUri()
// The connection may exist before any calendar is chosen (C150 picks it).
// Payload's Local API without `user` needs the explicit admin bypass; the
// role check above is the authorization for this system write.
const doc = await loadGoogleCalendarSyncConfig(payload)
if (!doc) {
await payload.create({
collection: 'googleCalendarSync',
data: {},
depth: 0,
overrideAccess: true,
})
}
const state = randomBytes(32).toString('base64url')
await storeGoogleCalendarOAuthState(state, actor.id)
return {
ok: true,
authorizeUrl: buildGoogleCalendarOAuthAuthorizeUrl({
clientId: credentials.clientId,
redirectUri,
state,
}),
}
} catch (error) {
console.error('startGoogleCalendarOAuth failed', error)
return { ok: false, message: GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE }
}
}
/**
* C149 — disconnects the OAuth account from the Teqo: drops the refresh token
* and the connection state. The UI guides revoking the app access on the
* Google account; the service account, when configured, takes over on the next
* trigger.
*/
export const disconnectGoogleCalendarOAuth = async (): Promise<GoogleCalendarSyncActionResult> => {
const { payload, actor } = await getCampaignActionContext()
try {
const currentActor = await loadConnectionManager(payload, actor)
if (!currentActor) {
return failure(GOOGLE_CALENDAR_OAUTH_MANAGER_ONLY_MESSAGE, false)
}
await clearGoogleCalendarOAuthConnection(payload)
return {
ok: true,
canManageConnection: true,
...withLinks(await readGoogleCalendarSyncView(payload)),
}
} catch (error) {
console.error('disconnectGoogleCalendarOAuth failed', error)
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE, canManageGoogleCalendarConnection(actor))
}
}

View file

@ -0,0 +1,127 @@
import config from '@payload-config'
import { NextResponse, type NextRequest } from 'next/server'
import { getPayload } from 'payload'
import { canManageGoogleCalendarConnection } from '@/utilities/access/googleCalendarSync'
import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin'
import {
buildGoogleCalendarOAuthRedirectUri,
clearGoogleCalendarOAuthState,
exchangeGoogleCalendarOAuthCode,
GoogleCalendarOAuthError,
readGoogleCalendarOAuthCredentials,
readGoogleCalendarOAuthState,
} from '@/utilities/googleCalendarOAuth'
import {
loadGoogleCalendarSyncConfig,
recordGoogleCalendarOAuthConnection,
recordGoogleCalendarOAuthError,
runCampaignCalendarSync,
} from '@/utilities/googleCalendarSync'
/**
* C149 — the OAuth callback registered in the GCP client. Google returns the
* browser here after consent with `code + state` (or `error` when the user
* denies). It lives OUTSIDE `(app)` on purpose: it is a ceremony route like
* the WebAuthn ones, authenticated by the signed single-use state cookie and
* not by a page session.
*
* The exchange is the only step that matters — the refresh token lands on the
* singleton `googleCalendarSync` row and the derived connection state becomes
* `connected`; a failure records `oauthError` so the UI offers reconnect. The
* browser always ends on `/campanha/agenda` (no query param): the agenda page
* canonicalizes unknown params away, so the state itself is the feedback.
*/
export const dynamic = 'force-dynamic'
/** The code exchange plus (when a calendar is configured) a full sync pass. */
export const maxDuration = 60
const AGENDA_PATH = '/campanha/agenda'
const agendaRedirect = (request: NextRequest): NextResponse => {
try {
return NextResponse.redirect(new URL(AGENDA_PATH, getCampaignInviteBaseURL()))
} catch {
// Only reachable when NEXT_PUBLIC_SITE_URL is missing in production —
// still send the user back to the page instead of a dead end.
return NextResponse.redirect(new URL(AGENDA_PATH, request.nextUrl.origin))
}
}
export const GET = async (request: NextRequest): Promise<NextResponse> => {
const payload = await getPayload({ config })
const searchParams = request.nextUrl.searchParams
const providedState = searchParams.get('state') ?? ''
const errorParam = searchParams.get('error')
const code = searchParams.get('code')
try {
// `access_denied` is the user saying no — not an error to surface, and
// there is no code to exchange.
if (errorParam || !code) {
return agendaRedirect(request)
}
const state = await readGoogleCalendarOAuthState(providedState)
// Burn before the exchange: a captured callback cannot be replayed even
// if the exchange itself fails.
await clearGoogleCalendarOAuthState()
// Re-check the role against the FRESH document: the consent navigation
// may have outlived a downgrade. The signed state binds the actor; the
// role check is what authorizes the system write below. Intentional admin
// bypass on the reload (the fresh-role read is the trusted check).
const actor = await payload.findByID({
collection: 'campaignUser',
id: state.userId,
depth: 0,
overrideAccess: true,
})
if (!canManageGoogleCalendarConnection(actor)) {
throw new GoogleCalendarOAuthError(
'Apenas candidato ou coordenação pode conectar a conta Google da campanha.',
)
}
const credentials = readGoogleCalendarOAuthCredentials()
if (!credentials) {
throw new GoogleCalendarOAuthError('A conexão com o Google não está configurada no servidor.')
}
// Only an EXCHANGE failure flips the connection state to `error` — a
// refused role or a missing client env leaves the stored connection
// untouched (the engine clears a transient exchange error on the next
// successful pass with the previous token).
const tokens = await exchangeGoogleCalendarOAuthCode({
code,
redirectUri: buildGoogleCalendarOAuthRedirectUri(),
credentials,
}).catch(async (error: unknown) => {
if (error instanceof GoogleCalendarOAuthError) {
await recordGoogleCalendarOAuthError(payload, error.message).catch(() => {})
}
throw error
})
await recordGoogleCalendarOAuthConnection(payload, {
refreshToken: tokens.refreshToken,
scope: tokens.scope,
})
const doc = await loadGoogleCalendarSyncConfig(payload)
if (doc?.calendarId) {
await runCampaignCalendarSync(payload, { reason: 'config-change' })
}
return agendaRedirect(request)
} catch (error) {
const message =
error instanceof Error ? error.message : 'Erro desconhecido ao conectar com o Google.'
payload.logger.error(`[GoogleCalendarSync] Callback OAuth falhou: ${message}`)
return agendaRedirect(request)
} finally {
await clearGoogleCalendarOAuthState().catch(() => {})
}
}

View file

@ -18,6 +18,12 @@ import { googleCalendarSyncConfigHook } from '@/utilities/googleCalendarSyncHook
* account private key never lives here — it is `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY`
* in the environment. Changing `calendarId` (or re-enabling) re-runs the full
* reconciliation automatically via `googleCalendarSyncConfigHook` (D7).
*
* C149 — the same row also holds the OAuth connection (refresh token + state)
* created by the "Conectar com o Google" button. The refresh token is
* system-only and unreadable by every user path (`read: false`), exactly like
* the push-channel secret; the engine reads it with the admin bypass. The
* service account remains the fallback while no OAuth connection exists.
*/
export const GoogleCalendarSync: CollectionConfig = {
slug: 'googleCalendarSync',
@ -60,6 +66,64 @@ export const GoogleCalendarSync: CollectionConfig = {
'O calendarId do calendário "Agenda da Campanha" na conta Google da campanha (ex.: c_…@group.calendar.google.com). O calendário precisa estar público no link e a service account com permissão de edição.',
},
},
{
name: 'oauthRefreshToken',
type: 'text',
label: 'Token de atualização (OAuth)',
access: {
create: canSetGoogleCalendarSyncSystemField,
read: canReadGoogleCalendarSyncIdentityField,
update: canSetGoogleCalendarSyncSystemField,
},
admin: {
readOnly: true,
description:
'Credencial da conexão OAuth com a conta Google da campanha. Preenchida pelo callback do botão "Conectar com o Google"; nunca é exibida.',
},
},
{
name: 'oauthScope',
type: 'text',
label: 'Escopos concedidos (OAuth)',
access: {
create: canSetGoogleCalendarSyncSystemField,
update: canSetGoogleCalendarSyncSystemField,
},
admin: {
readOnly: true,
description: 'Escopos que o Google devolveu na conexão (diagnóstico).',
},
},
{
name: 'oauthConnectedAt',
type: 'date',
label: 'Conta Google conectada em',
access: {
create: canSetGoogleCalendarSyncSystemField,
update: canSetGoogleCalendarSyncSystemField,
},
admin: { readOnly: true },
},
{
name: 'oauthErrorAt',
type: 'date',
label: 'Último erro de conexão (OAuth)',
access: {
create: canSetGoogleCalendarSyncSystemField,
update: canSetGoogleCalendarSyncSystemField,
},
admin: { readOnly: true },
},
{
name: 'oauthError',
type: 'textarea',
label: 'Mensagem do último erro de conexão (OAuth)',
access: {
create: canSetGoogleCalendarSyncSystemField,
update: canSetGoogleCalendarSyncSystemField,
},
admin: { readOnly: true },
},
{
name: 'disabledAt',
type: 'date',

View file

@ -2,7 +2,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { GoogleCalendarSyncActionResult } from '@/app/(campaign)/campanha/actions/googleCalendarSync'
import type {
GoogleCalendarOAuthStartResult,
GoogleCalendarSyncActionResult,
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
import { GoogleCalendarSyncDialog } from '@/components/campaign/activity/GoogleCalendarSyncDialog'
import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext'
import { useBridgedQuickAction } from '@/components/campaign/shell/CampaignQuickActionContext'
@ -12,6 +15,10 @@ type AgendaGoogleSyncChromeProps = {
initialState: GoogleCalendarSyncActionResult
onSyncNow: () => Promise<GoogleCalendarSyncActionResult>
onSetDisabled: (disabled: boolean) => Promise<GoogleCalendarSyncActionResult>
/** C149 — starts the OAuth handshake; the dialog navigates to the consent URL. */
onStartOAuth: () => Promise<GoogleCalendarOAuthStartResult>
/** C149 — drops the OAuth connection from the Teqo. */
onDisconnect: () => Promise<GoogleCalendarSyncActionResult>
}
const PILL_COPY: Record<
@ -50,6 +57,8 @@ export const AgendaGoogleSyncChrome = ({
initialState,
onSyncNow,
onSetDisabled,
onStartOAuth,
onDisconnect,
}: AgendaGoogleSyncChromeProps) => {
const [open, setOpen] = useState(false)
const [state, setState] = useState(initialState)
@ -83,6 +92,13 @@ export const AgendaGoogleSyncChrome = ({
[onSetDisabled],
)
// C149 — the connection card follows the disconnect without a page reload.
const handleDisconnect = useCallback(async () => {
const result = await onDisconnect()
if (result.ok) setState(result)
return result
}, [onDisconnect])
const pill = useMemo(() => {
const copy = PILL_COPY[state.status]
return (
@ -109,6 +125,8 @@ export const AgendaGoogleSyncChrome = ({
state={state}
onSyncNow={handleSyncNow}
onSetDisabled={handleSetDisabled}
onStartOAuth={onStartOAuth}
onDisconnect={handleDisconnect}
/>
</>
)

View file

@ -4,7 +4,10 @@ import { useState } from 'react'
import { CopyIcon, ExternalLinkIcon, RefreshCwIcon } from 'lucide-react'
import type { GoogleCalendarSyncActionResult } from '@/app/(campaign)/campanha/actions/googleCalendarSync'
import type {
GoogleCalendarOAuthStartResult,
GoogleCalendarSyncActionResult,
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
import { Button } from '@/components/ui/button'
import {
Dialog,
@ -25,6 +28,7 @@ import {
import { Input } from '@/components/ui/input'
import { useIsMobile } from '@/hooks/use-mobile'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import type { GoogleCalendarConnectionStatus } from '@/utilities/googleCalendarSync'
type GoogleCalendarSyncDialogProps = {
open: boolean
@ -32,6 +36,10 @@ type GoogleCalendarSyncDialogProps = {
state: GoogleCalendarSyncActionResult
onSyncNow: () => Promise<GoogleCalendarSyncActionResult>
onSetDisabled: (disabled: boolean) => Promise<GoogleCalendarSyncActionResult>
/** C149 — starts the OAuth handshake; the caller navigates to the returned URL. */
onStartOAuth: () => Promise<GoogleCalendarOAuthStartResult>
/** C149 — drops the OAuth connection from the Teqo. */
onDisconnect: () => Promise<GoogleCalendarSyncActionResult>
}
const NOT_CONFIGURED_RUNBOOK: readonly string[] = [
@ -41,11 +49,32 @@ const NOT_CONFIGURED_RUNBOOK: readonly string[] = [
'Um administrador configura o ID do calendário no Painel e a chave da service account nas variáveis de ambiente.',
]
const CONNECTION_BADGE: Record<
GoogleCalendarConnectionStatus,
{ label: string; className: string }
> = {
connected: {
label: 'Conectado',
className: 'border-green-300 bg-green-50 text-green-700',
},
error: {
label: 'Erro',
className: 'border-amber-300 bg-amber-50 text-amber-700',
},
'not-configured': {
label: 'Não configurado',
className: 'border-border text-muted-foreground',
},
}
const copyIcon = <CopyIcon className="h-4 w-4" />
/**
* C114 — Google Calendar mirror dialog (estados da jornada do canvas:
* não configurado / sincronizado / pausado / desativado). Bottom sheet on
* C114/C115/C149 — Google Calendar mirror dialog. The OAuth connection card
* (C149) leads every state: `não configurado` offers the one-click connect
* (or the admin runbook when the server has no OAuth client), `conectado`
* offers reconnect/disconnect and `erro` offers the reconnect path. The
* mirror-state blocks (C114/C115) stay below, untouched. Bottom sheet on
* mobile, dialog on desktop — same chrome as the iCal feed dialog (C94).
*/
export const GoogleCalendarSyncDialog = ({
@ -54,6 +83,8 @@ export const GoogleCalendarSyncDialog = ({
state,
onSyncNow,
onSetDisabled,
onStartOAuth,
onDisconnect,
}: GoogleCalendarSyncDialogProps) => {
const isMobile = useIsMobile()
const [isBusy, setIsBusy] = useState(false)
@ -72,30 +103,38 @@ export const GoogleCalendarSyncDialog = ({
setTimeout(() => setCopied(false), 2000)
}
const handleSyncNow = async () => {
if (isBusy) return
/**
* Shared busy/error envelope: every card action maps a failed result to the
* same `actionError` line and never leaves the buttons stuck in busy.
*/
const runAction = async <T extends { ok: boolean; message?: string }>(
run: () => Promise<T>,
fallbackMessage: string,
): Promise<T | null> => {
if (isBusy) return null
setIsBusy(true)
setActionError(null)
try {
const result = await onSyncNow()
if (!result.ok) setActionError(result.message ?? 'Não foi possível sincronizar.')
const result = await run()
if (!result.ok) setActionError(result.message ?? fallbackMessage)
return result
} finally {
setIsBusy(false)
}
}
const handleSetDisabled = async (disabled: boolean) => {
if (isBusy) return
setIsBusy(true)
setActionError(null)
try {
const result = await onSetDisabled(disabled)
if (!result.ok) setActionError(result.message ?? 'Não foi possível atualizar.')
} finally {
setIsBusy(false)
}
const handleSyncNow = () => runAction(onSyncNow, 'Não foi possível sincronizar.')
const handleSetDisabled = (disabled: boolean) =>
runAction(() => onSetDisabled(disabled), 'Não foi possível atualizar.')
const handleStartOAuth = async () => {
const result = await runAction(onStartOAuth, 'Não foi possível iniciar a conexão.')
if (result?.ok) window.location.assign(result.authorizeUrl)
}
const handleDisconnect = () => runAction(onDisconnect, 'Não foi possível desconectar.')
const linkBlock = state.addLink ? (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="google-calendar-link">
@ -183,37 +222,150 @@ export const GoogleCalendarSyncDialog = ({
</Button>
)
let body: React.ReactNode
const connectionActions = state.canManageConnection ? (
<div className="flex flex-wrap items-center gap-2">
{state.connection === 'connected' ? (
<Button
type="button"
variant="outline"
onClick={() => void handleStartOAuth()}
disabled={isBusy}
>
Reconectar com o Google
</Button>
) : (
<Button
type="button"
onClick={() => void handleStartOAuth()}
disabled={isBusy || !state.oauthAvailable}
>
<ExternalLinkIcon className="mr-2 h-4 w-4" />
{state.connection === 'error' ? 'Reconectar com o Google' : 'Conectar com o Google'}
</Button>
)}
{state.connection !== 'not-configured' ? (
<Button
type="button"
variant="outline"
onClick={() => void handleDisconnect()}
disabled={isBusy}
>
Desconectar
</Button>
) : null}
</div>
) : (
<p className="text-xs text-muted-foreground">
Somente candidato ou coordenação pode conectar ou desconectar a conta Google da campanha.
</p>
)
if (state.status === 'not-configured') {
body = (
<div className="space-y-4">
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm">
<p className="mb-2 font-medium text-amber-900">Ainda não configurado</p>
<p className="text-amber-800">
O Teqo segue 100% funcional — a agenda do Teqo nunca depende do Google. Para ativar o
espelho, um administrador precisa concluir a configuração:
</p>
<ol className="mt-2 list-inside list-decimal space-y-1 text-amber-800">
{NOT_CONFIGURED_RUNBOOK.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</div>
const oauthUnavailableBlock =
state.connection === 'not-configured' && !state.oauthAvailable ? (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm">
<p className="mb-2 font-medium text-amber-900">Ainda não configurado</p>
<p className="text-amber-800">
O Teqo segue 100% funcional — a agenda do Teqo nunca depende do Google. Um administrador
pode concluir a configuração manual:
</p>
<ol className="mt-2 list-inside list-decimal space-y-1 text-amber-800">
{NOT_CONFIGURED_RUNBOOK.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</div>
)
} else if (state.status === 'disabled') {
body = (
) : null
const connectionBlock = (
<div className="rounded-lg border border-border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 className="text-sm font-semibold">Google Calendar</h3>
{state.connection === 'not-configured' ? (
<p className="mt-1 text-sm text-muted-foreground">
Conecte a conta Google do calendário da campanha para gerenciar o espelho por ela.
{state.status !== 'not-configured'
? ' O espelho segue ativo pela configuração manual (service account).'
: ''}
</p>
) : state.connection === 'connected' ? (
<p className="mt-1 text-sm text-muted-foreground">
O espelho usa a conta Google conectada para escrever os compromissos no calendário da
campanha.
</p>
) : (
<p className="mt-1 text-sm text-muted-foreground">
A conexão com o Google precisa ser refeita.
</p>
)}
</div>
<span
className={`self-start rounded-full border px-3 py-1 text-xs font-medium ${CONNECTION_BADGE[state.connection].className}`}
>
{CONNECTION_BADGE[state.connection].label}
</span>
</div>
{state.connection === 'not-configured' ? (
<div className="mt-4 space-y-3">
{oauthUnavailableBlock ?? (
<div className="rounded-md border border-dashed border-border p-4">
<p className="text-sm text-muted-foreground">
Você será levado à tela de consentimento do Google para autorizar o Teqo a gerenciar
o calendário da campanha.
</p>
<div className="mt-3">{connectionActions}</div>
</div>
)}
<p className="text-xs text-muted-foreground">
A configuração manual por service account continua disponível como fallback técnico.
</p>
</div>
) : null}
{state.connection === 'connected' ? (
<div className="mt-4 space-y-3">
<p className="text-sm text-muted-foreground">
{state.oauthConnectedAt
? `Conta Google conectada em ${formatBahiaDateTimeLabel(state.oauthConnectedAt)}.`
: 'Conta Google conectada.'}
</p>
{connectionActions}
<p className="text-xs text-muted-foreground">
Desconectar revoga o acesso do Teqo; depois, revogue o app nas configurações de
segurança da conta Google.
</p>
</div>
) : null}
{state.connection === 'error' ? (
<div className="mt-4 space-y-3">
<div className="rounded-md border border-amber-300 bg-amber-50 p-4 text-sm">
<p className="font-medium text-amber-900">Erro de conexão com o Google</p>
<p className="mt-1 text-amber-800">
{state.oauthError ??
'Não foi possível renovar o acesso. O Teqo continua sendo a fonte da verdade — nada foi perdido.'}
</p>
<p className="mt-2 text-xs text-amber-700">
Reconecte com a mesma conta Google do calendário da campanha. Se a conta mudou,
desconecte antes e conecte novamente.
</p>
</div>
{connectionActions}
</div>
) : null}
</div>
)
let statusBlock: React.ReactNode = null
if (state.status === 'disabled') {
statusBlock = (
<div className="space-y-4">
<div className="rounded-lg bg-muted p-4 text-sm text-muted-foreground">
A sincronização está desativada. A agenda do Teqo continua funcionando normalmente; nada é
enviado ao Google enquanto estiver desativada.
</div>
{actionError && (
<p role="alert" className="text-sm text-red-600">
{actionError}
</p>
)}
<div className="flex justify-end">
<Button type="button" onClick={() => void handleSetDisabled(false)} disabled={isBusy}>
Reativar
@ -221,9 +373,16 @@ export const GoogleCalendarSyncDialog = ({
</div>
</div>
)
} else if (state.status === 'not-configured') {
statusBlock =
state.connection === 'connected' ? (
<p className="rounded-lg bg-muted p-3 text-sm text-muted-foreground">
A conexão está ativa. O calendário da campanha é escolhido na configuração do Painel.
</p>
) : null
} else {
const paused = state.status === 'paused'
body = (
statusBlock = (
<div className="space-y-4">
{paused ? (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm">
@ -252,12 +411,6 @@ export const GoogleCalendarSyncDialog = ({
{instructions}
{reverseEditBlock}
{actionError && (
<p role="alert" className="text-sm text-red-600">
{actionError}
</p>
)}
<div className="flex justify-end gap-2">
{paused ? (
<Button asChild variant="outline">
@ -285,6 +438,18 @@ export const GoogleCalendarSyncDialog = ({
)
}
const body = (
<div className="space-y-4">
{connectionBlock}
{statusBlock}
{actionError && (
<p role="alert" className="text-sm text-red-600">
{actionError}
</p>
)}
</div>
)
const description =
'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.'

View file

@ -0,0 +1,48 @@
/**
* C149 — pure pieces of the campaign's Google OAuth handshake. The authorize
* URL is request-independent (client id + redirect uri + state are passed in),
* so it lives in `lib/` and is unit-tested without a server. Nothing here is
* secret: the client id is public in the consent URL and the state is
* generated per attempt by the caller.
*/
const GOOGLE_CALENDAR_OAUTH_AUTHORIZE_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth'
/** Where Google returns the browser after consent (also registered in the GCP client). */
export const GOOGLE_CALENDAR_OAUTH_CALLBACK_PATH = '/campanha/agenda/google-oauth/callback'
/**
* The minimum scopes (C149 literal): `calendar.events` is what the mirror
* needs to write, `calendar.calendarlist.readonly` is what C150's picker
* needs to list the account's calendars. Nothing beyond.
*/
export const GOOGLE_CALENDAR_OAUTH_SCOPES = [
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/calendar.calendarlist.readonly',
] as const
type GoogleCalendarOAuthAuthorizeUrlInput = {
clientId: string
redirectUri: string
state: string
}
export const buildGoogleCalendarOAuthAuthorizeUrl = ({
clientId,
redirectUri,
state,
}: GoogleCalendarOAuthAuthorizeUrlInput): string => {
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: GOOGLE_CALENDAR_OAUTH_SCOPES.join(' '),
// `offline` + `consent` are what make Google return a refresh token that
// survives without weekly re-consent (the hard product constraint); the
// explicit `prompt` also guarantees a fresh token on reconnect.
access_type: 'offline',
prompt: 'consent',
state,
})
return `${GOOGLE_CALENDAR_OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
/**
* C149 — `googleCalendarSync` gains the OAuth connection fields (C149). The
* generated migration also re-emitted the already-applied
* `supporter_import_batch.actor_id DROP NOT NULL` statement
* (applied by `20260824_010000_make_supporter_import_batch_actor_nullable`);
* it was removed by hand so the apply does not fail on a duplicate change —
* same drift handled in the C114 migration.
*/
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "google_calendar_sync" ADD COLUMN "oauth_refresh_token" varchar;
ALTER TABLE "google_calendar_sync" ADD COLUMN "oauth_scope" varchar;
ALTER TABLE "google_calendar_sync" ADD COLUMN "oauth_connected_at" timestamp(3) with time zone;
ALTER TABLE "google_calendar_sync" ADD COLUMN "oauth_error_at" timestamp(3) with time zone;
ALTER TABLE "google_calendar_sync" ADD COLUMN "oauth_error" varchar;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "google_calendar_sync" DROP COLUMN "oauth_refresh_token";
ALTER TABLE "google_calendar_sync" DROP COLUMN "oauth_scope";
ALTER TABLE "google_calendar_sync" DROP COLUMN "oauth_connected_at";
ALTER TABLE "google_calendar_sync" DROP COLUMN "oauth_error_at";
ALTER TABLE "google_calendar_sync" DROP COLUMN "oauth_error";`)
}

View file

@ -64,6 +64,7 @@ import * as migration_20260820_003426_add_instagram_sync_status from './20260820
import * as migration_20260823_161905_add_municipality_update_deliberation from './20260823_161905_add_municipality_update_deliberation';
import * as migration_20260824_000000_reconcile_allocation_decision_rationale_nullable from './20260824_000000_reconcile_allocation_decision_rationale_nullable';
import * as migration_20260824_010000_make_supporter_import_batch_actor_nullable from './20260824_010000_make_supporter_import_batch_actor_nullable';
import * as migration_20260912_052226_add_google_calendar_oauth_connection from './20260912_052226_add_google_calendar_oauth_connection';
export const migrations = [
{
@ -384,16 +385,21 @@ export const migrations = [
{
up: migration_20260823_161905_add_municipality_update_deliberation.up,
down: migration_20260823_161905_add_municipality_update_deliberation.down,
name: '20260823_161905_add_municipality_update_deliberation'
name: '20260823_161905_add_municipality_update_deliberation',
},
{
up: migration_20260824_000000_reconcile_allocation_decision_rationale_nullable.up,
down: migration_20260824_000000_reconcile_allocation_decision_rationale_nullable.down,
name: '20260824_000000_reconcile_allocation_decision_rationale_nullable'
name: '20260824_000000_reconcile_allocation_decision_rationale_nullable',
},
{
up: migration_20260824_010000_make_supporter_import_batch_actor_nullable.up,
down: migration_20260824_010000_make_supporter_import_batch_actor_nullable.down,
name: '20260824_010000_make_supporter_import_batch_actor_nullable'
name: '20260824_010000_make_supporter_import_batch_actor_nullable',
},
{
up: migration_20260912_052226_add_google_calendar_oauth_connection.up,
down: migration_20260912_052226_add_google_calendar_oauth_connection.down,
name: '20260912_052226_add_google_calendar_oauth_connection'
},
];

View file

@ -940,6 +940,17 @@ export interface GoogleCalendarSync {
* O calendarId do calendário "Agenda da Campanha" na conta Google da campanha (ex.: c_…@group.calendar.google.com). O calendário precisa estar público no link e a service account com permissão de edição.
*/
calendarId?: string | null;
/**
* Credencial da conexão OAuth com a conta Google da campanha. Preenchida pelo callback do botão "Conectar com o Google"; nunca é exibida.
*/
oauthRefreshToken?: string | null;
/**
* Escopos que o Google devolveu na conexão (diagnóstico).
*/
oauthScope?: string | null;
oauthConnectedAt?: string | null;
oauthErrorAt?: string | null;
oauthError?: string | null;
/**
* Enquanto preenchido, o Teqo não escreve no Google.
*/
@ -1959,6 +1970,11 @@ export interface CalendarFeedSelect<T extends boolean = true> {
*/
export interface GoogleCalendarSyncSelect<T extends boolean = true> {
calendarId?: T;
oauthRefreshToken?: T;
oauthScope?: T;
oauthConnectedAt?: T;
oauthErrorAt?: T;
oauthError?: T;
disabledAt?: T;
lastSyncedAt?: T;
lastSuccessAt?: T;

View file

@ -3,8 +3,10 @@ import type { Access, FieldAccess } from 'payload'
import {
getFreshCampaignUser,
isCampaignStaff,
isCampaignUnrestricted,
isPayloadAdmin,
payloadAdminOnly,
type CampaignActor,
} from '@/utilities/access/shared'
/**
@ -53,3 +55,13 @@ export const canSetGoogleCalendarSyncSystemField: FieldAccess = () => false
* hand out full push-forging authority to any staff member.
*/
export const canReadGoogleCalendarSyncIdentityField: FieldAccess = () => false
/**
* C149 — connecting/disconnecting the campaign's Google account is a core
* decision over a credential that belongs to the whole campaign: candidate or
* coordinator only (advisor reads the state but never holds the token; leader
* never reaches the agenda). Enforced at the action layer with the freshly
* reloaded actor — same pattern as the other campaign actions.
*/
export const canManageGoogleCalendarConnection = (user: CampaignActor): boolean =>
isCampaignUnrestricted(user)

View file

@ -5,8 +5,10 @@ import { importPKCS8, SignJWT } from 'jose'
import type { GoogleRemoteEvent } from '@/lib/googleCalendarEventMapping'
/**
* C114 — thin Calendar API v3 client authenticated as the campaign's service
* account (JWT assertion → OAuth2 token → REST). Only the four event endpoints
* C114 — thin Calendar API v3 client authenticated either as the campaign's
* service account (JWT assertion → OAuth2 token → REST) or — C149 — with the
* OAuth refresh token granted by the "Conectar com o Google" consent
* (refresh_token grant → access token → REST). Only the four event endpoints
* the reconciliation engine needs; the token is cached per runtime instance
* and re-minted on 401. The `fetch` implementation is injectable so tests can
* stub the transport without network.
@ -29,11 +31,27 @@ export const REQUEST_TIMEOUT_MS = 15_000
export type { GoogleRemoteEvent } from '@/lib/googleCalendarEventMapping'
export type GoogleCalendarCredentials = {
export type GoogleCalendarServiceAccountCredentials = {
clientEmail: string
privateKey: string
}
/** C149 — the connection created by the OAuth consent (refresh token + app pair). */
type GoogleCalendarOAuthCredentials = {
clientId: string
clientSecret: string
refreshToken: string
}
/**
* How the client authenticates. The engine resolves OAuth first (when the
* campaign connected an account) and falls back to the service account —
* `googleCalendarSync.readGoogleCalendarAuth`.
*/
export type GoogleCalendarAuth =
| { kind: 'service-account'; credentials: GoogleCalendarServiceAccountCredentials }
| { kind: 'oauth'; credentials: GoogleCalendarOAuthCredentials }
/** Typed transport failure — safe message, never echoes credentials or bodies. */
export class GoogleCalendarApiError extends Error {
readonly status: number
@ -45,6 +63,19 @@ export class GoogleCalendarApiError extends Error {
}
}
/**
* C149 — the OAuth connection is dead (`invalid_grant`: the refresh token was
* revoked or expired). Reconnectable by the user, unlike an API/config error:
* the engine records it as the connection `error` state that offers
* "Reconectar" in one click.
*/
export class GoogleCalendarAuthError extends Error {
constructor(message: string) {
super(message)
this.name = 'GoogleCalendarAuthError'
}
}
export type GoogleCalendarClient = {
listEvents: (
calendarId: string,
@ -76,7 +107,7 @@ export type FetchLike = typeof fetch
* for tests — `createGoogleCalendarClient` mints and exchanges it internally.
*/
export const buildServiceAccountAssertion = async (
credentials: GoogleCalendarCredentials,
credentials: GoogleCalendarServiceAccountCredentials,
nowSeconds: number,
): Promise<string> => {
const key = await importPKCS8(credentials.privateKey, 'RS256')
@ -89,8 +120,18 @@ export const buildServiceAccountAssertion = async (
.sign(key)
}
/** Reads the token endpoint's error code without ever surfacing the body. */
const readOAuthErrorCode = async (response: Response): Promise<string | null> => {
try {
const body = (await response.json()) as { error?: unknown }
return typeof body.error === 'string' ? body.error : null
} catch {
return null
}
}
export const createGoogleCalendarClient = (
credentials: GoogleCalendarCredentials,
auth: GoogleCalendarAuth,
fetchImpl: FetchLike = fetch,
hookSignal?: AbortSignal,
): GoogleCalendarClient => {
@ -101,7 +142,29 @@ export const createGoogleCalendarClient = (
return hookSignal ? AbortSignal.any([hookSignal, perHop]) : perHop
}
const requestAccessToken = async (): Promise<string> => {
const parseAccessToken = async (response: Response): Promise<string> => {
const body = (await response.json().catch(() => null)) as {
access_token?: unknown
expires_in?: unknown
} | null
const accessToken = typeof body?.access_token === 'string' ? body.access_token : ''
if (!accessToken) {
throw new GoogleCalendarApiError('Resposta de autenticação do Google sem token.', 502)
}
const expiresIn =
typeof body?.expires_in === 'number' && Number.isFinite(body.expires_in)
? body.expires_in
: GOOGLE_TOKEN_TTL_SECONDS
cachedToken = {
value: accessToken,
expiresAtMs: Date.now() + expiresIn * 1000,
}
return cachedToken.value
}
const requestServiceAccountAccessToken = async (
credentials: GoogleCalendarServiceAccountCredentials,
): Promise<string> => {
const nowSeconds = Math.floor(Date.now() / 1000)
const assertion = await buildServiceAccountAssertion(credentials, nowSeconds)
@ -120,19 +183,46 @@ export const createGoogleCalendarClient = (
response.status,
)
}
const body = (await response.json()) as { access_token?: string; expires_in?: number }
if (!body.access_token) {
throw new GoogleCalendarApiError('Resposta de autenticação do Google sem token.', 502)
}
cachedToken = {
value: body.access_token,
expiresAtMs: Date.now() + (body.expires_in ?? GOOGLE_TOKEN_TTL_SECONDS) * 1000,
}
return cachedToken.value
return parseAccessToken(response)
}
const requestOAuthAccessToken = async (
credentials: GoogleCalendarOAuthCredentials,
): Promise<string> => {
const response = await fetchImpl(GOOGLE_TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: credentials.refreshToken,
client_id: credentials.clientId,
client_secret: credentials.clientSecret,
}).toString(),
signal: requestSignal(),
})
if (!response.ok) {
// `invalid_grant` is the user-fixable case (token revoked/expired) and
// becomes the reconnectable auth error. `invalid_client` (our app
// credential is wrong) and transient failures stay API errors: telling
// the user to reconnect would not fix them.
if ((await readOAuthErrorCode(response)) === 'invalid_grant') {
throw new GoogleCalendarAuthError(
'A conexão com o Google expirou ou foi revogada. Reconecte a conta.',
)
}
throw new GoogleCalendarApiError(
`Não foi possível autenticar no Google (HTTP ${response.status}).`,
response.status,
)
}
return parseAccessToken(response)
}
const requestAccessToken = (): Promise<string> =>
auth.kind === 'oauth'
? requestOAuthAccessToken(auth.credentials)
: requestServiceAccountAccessToken(auth.credentials)
const getAccessToken = async (): Promise<string> => {
if (cachedToken && cachedToken.expiresAtMs > Date.now() + TOKEN_REFRESH_LEAD_SECONDS * 1000) {
return cachedToken.value

View file

@ -0,0 +1,212 @@
import 'server-only'
import { cookies } from 'next/headers'
import { createHmac, timingSafeEqual } from 'node:crypto'
import {
GOOGLE_CALENDAR_OAUTH_CALLBACK_PATH,
GOOGLE_CALENDAR_OAUTH_SCOPES,
} from '@/lib/googleCalendarOAuth'
import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin'
/**
* C149 — the server side of the campaign's Google OAuth handshake: the client
* credential pair (env), the public redirect URI, the signed single-use
* `state` cookie and the authorization-code exchange.
*
* No token ever crosses this module's messages: the exchange reads only
* `refresh_token`/`scope` and fails with a safe, fixed pt-BR message — the
* response body is never echoed and never logged.
*/
export const GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV = 'GOOGLE_CALENDAR_OAUTH_CLIENT_ID'
export const GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV = 'GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET'
const GOOGLE_TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'
const STATE_COOKIE_NAME = 'campaign-google-oauth-state'
const STATE_COOKIE_PATH = '/campanha'
const STATE_TTL_MS = 10 * 60 * 1000
const REQUEST_TIMEOUT_MS = 15_000
export type GoogleCalendarOAuthClientCredentials = {
clientId: string
clientSecret: string
}
/**
* Reads the OAuth client pair from the environment. Fail-closed: a partial or
* absent pair disables the connect button (the view exposes `oauthAvailable`)
* without breaking the service-account fallback. Never logs the values.
*/
export const readGoogleCalendarOAuthCredentials =
(): GoogleCalendarOAuthClientCredentials | null => {
const clientId = process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]?.trim()
const clientSecret = process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV]?.trim()
if (!clientId || !clientSecret) return null
return { clientId, clientSecret }
}
/**
* The redirect URI registered in the GCP OAuth client. Always the canonical
* public origin (production requires `NEXT_PUBLIC_SITE_URL`), never the
* request URL — so a callback behind the Cloudflare tunnel cannot leak
* localhost into the redirect.
*/
export const buildGoogleCalendarOAuthRedirectUri = (): string =>
`${getCampaignInviteBaseURL()}${GOOGLE_CALENDAR_OAUTH_CALLBACK_PATH}`
export class GoogleCalendarOAuthError extends Error {
constructor(message: string) {
super(message)
this.name = 'GoogleCalendarOAuthError'
}
}
const GOOGLE_CALENDAR_OAUTH_STATE_EXPIRED_MESSAGE =
'A conexão com o Google expirou. Tente conectar novamente.'
const getSecret = (): string => {
const secret = process.env.PAYLOAD_SECRET
if (!secret) throw new Error('PAYLOAD_SECRET não configurado para o state do OAuth.')
return secret
}
const signature = (state: string, userId: string, expiresAt: number): Buffer =>
createHmac('sha256', getSecret()).update(`${state}.${userId}.${expiresAt}`).digest()
const cookieOptions = (maxAge: number) => ({
httpOnly: true,
maxAge,
path: STATE_COOKIE_PATH,
sameSite: 'lax' as const,
secure: process.env.NODE_ENV === 'production',
})
/**
* Stores the signed, time-boxed `state` — the CSRF/replay guard of the
* handshake, bound to the campaign user that started it (precedent:
* `campaignWebAuthnChallenge`).
*/
export const storeGoogleCalendarOAuthState = async (
state: string,
userId: number,
): Promise<void> => {
const expiresAt = Date.now() + STATE_TTL_MS
const sig = signature(state, String(userId), expiresAt).toString('base64url')
const cookieStore = await cookies()
cookieStore.set(
STATE_COOKIE_NAME,
`${state}.${userId}.${expiresAt}.${sig}`,
cookieOptions(Math.ceil(STATE_TTL_MS / 1000)),
)
}
/**
* Verifies the cookie against the `state` Google echoed back and returns the
* user that started the flow. Anything wrong (missing, malformed, stale,
* forged, mismatched) is the same event from the user's side.
*/
export const readGoogleCalendarOAuthState = async (
providedState: string,
): Promise<{ userId: number }> => {
const cookieStore = await cookies()
const raw = cookieStore.get(STATE_COOKIE_NAME)?.value
const invalid = () => new GoogleCalendarOAuthError(GOOGLE_CALENDAR_OAUTH_STATE_EXPIRED_MESSAGE)
if (!raw) throw invalid()
const [state, userIdText, expiresAtText, sig, ...rest] = raw.split('.')
if (!state || !userIdText || !expiresAtText || !sig || rest.length > 0) throw invalid()
const expiresAt = Number(expiresAtText)
if (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now()) throw invalid()
const expected = signature(state, userIdText, expiresAt)
const provided = Buffer.from(sig, 'base64url')
if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) throw invalid()
if (state !== providedState) throw invalid()
const userId = Number(userIdText)
if (!Number.isSafeInteger(userId) || userId <= 0) throw invalid()
return { userId }
}
/** Burns the state so a captured callback cannot be replayed. */
export const clearGoogleCalendarOAuthState = async (): Promise<void> => {
const cookieStore = await cookies()
cookieStore.set(STATE_COOKIE_NAME, '', cookieOptions(0))
}
export type GoogleCalendarOAuthTokens = {
refreshToken: string
scope: string | null
}
/**
* Exchanges the authorization code for the long-lived refresh token. The
* response body is never echoed or logged: a non-OK answer collapses to a
* fixed safe message, and a body without `refresh_token` tells the user to
* remove the app access and try again (the case where Google does not reissue
* a token for an already-granted consent).
*/
export const exchangeGoogleCalendarOAuthCode = async ({
code,
redirectUri,
credentials,
fetchImpl = fetch,
}: {
code: string
redirectUri: string
credentials: GoogleCalendarOAuthClientCredentials
fetchImpl?: typeof fetch
}): Promise<GoogleCalendarOAuthTokens> => {
let response: Response
try {
response = await fetchImpl(GOOGLE_TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: credentials.clientId,
client_secret: credentials.clientSecret,
redirect_uri: redirectUri,
}).toString(),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
} catch {
throw new GoogleCalendarOAuthError('Não foi possível falar com o Google. Tente novamente.')
}
if (!response.ok) {
throw new GoogleCalendarOAuthError('O Google recusou a autorização. Tente conectar novamente.')
}
const body = (await response.json().catch(() => null)) as {
refresh_token?: unknown
scope?: unknown
} | null
const refreshToken = typeof body?.refresh_token === 'string' ? body.refresh_token : ''
if (!refreshToken) {
throw new GoogleCalendarOAuthError(
'O Google não devolveu um token de atualização. Remova o acesso do Teqo na sua conta Google e tente conectar de novo.',
)
}
const scope = typeof body?.scope === 'string' ? body.scope : null
// Granular consent lets the user uncheck a scope: without `calendar.events`
// the mirror would connect and then fail every pass with 403 — fail the
// handshake instead, which lands on the reconnect path.
if (scope) {
const granted = new Set(scope.split(' ').filter(Boolean))
if (GOOGLE_CALENDAR_OAUTH_SCOPES.some((required) => !granted.has(required))) {
throw new GoogleCalendarOAuthError(
'A autorização não inclui todas as permissões necessárias. Conecte novamente e aceite o acesso completo.',
)
}
}
return { refreshToken, scope }
}

View file

@ -32,9 +32,15 @@ import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin'
import {
createGoogleCalendarClient,
GoogleCalendarApiError,
GoogleCalendarAuthError,
type GoogleCalendarAuth,
type GoogleCalendarClient,
type GoogleCalendarCredentials,
type GoogleCalendarServiceAccountCredentials,
} from '@/utilities/googleCalendarClient'
import {
readGoogleCalendarOAuthCredentials,
type GoogleCalendarOAuthClientCredentials,
} from '@/utilities/googleCalendarOAuth'
/**
* C114+C115 — the campaign↔Google calendar reconciliation engine.
@ -57,6 +63,12 @@ import {
* 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.
*
* C149 — the credential is now resolved by `readGoogleCalendarAuth`: the OAuth
* connection (refresh token stored on the config row) wins when present and
* the service account remains the fallback. A dead OAuth connection
* (`GoogleCalendarAuthError`) additionally records `oauthError`, which derives
* the connection `error` state that offers reconnect in one click.
*/
export const GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV = 'GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY'
@ -111,8 +123,16 @@ const SYNC_RELEVANT_ACTIVITY_FIELDS = [
export type GoogleCalendarSyncStatus = 'not-configured' | 'disabled' | 'synced' | 'paused'
export type GoogleCalendarConnectionStatus = 'connected' | 'error' | 'not-configured'
export type GoogleCalendarSyncView = {
status: GoogleCalendarSyncStatus
/** C149 — state of the OAuth connection (derived, never stored). */
connection: GoogleCalendarConnectionStatus
/** C149 — the OAuth client env is present, so the connect button can work. */
oauthAvailable: boolean
oauthConnectedAt: string | null
oauthError: string | null
calendarId: string | null
lastSyncedAt: string | null
lastSuccessAt: string | null
@ -142,43 +162,92 @@ export const deriveGoogleCalendarSyncStatus = (input: {
return 'synced'
}
/**
* C149 — pure derivation of the connection state (the intent's literal
* `conectado | erro | não configurado`). An auth error newer than the last
* connection wins (including before any successful connection); a token
* without a newer error is connected; anything else is not configured. The
* state is never stored — reconnect clears the error fields and the state
* recovers by construction.
*/
export const deriveGoogleCalendarConnectionStatus = (input: {
hasRefreshToken: boolean
oauthConnectedAt?: string | null
oauthErrorAt?: string | null
}): GoogleCalendarConnectionStatus => {
// Timestamps are compared as instants, not strings: a value persisted with
// a UTC offset (admin edit, fixture) would invert a lexical comparison.
const errorAt = input.oauthErrorAt ? Date.parse(input.oauthErrorAt) : Number.NaN
const connectedAt = input.oauthConnectedAt ? Date.parse(input.oauthConnectedAt) : Number.NaN
if (!Number.isNaN(errorAt) && (Number.isNaN(connectedAt) || errorAt > connectedAt)) {
return 'error'
}
return input.hasRefreshToken ? 'connected' : 'not-configured'
}
/**
* Reads the service account credential from the environment (base64 of the
* Google Cloud JSON key). Fail-closed: malformed/absent → null, which
* disables the sync entirely without breaking anything else.
*/
export const readGoogleServiceAccountCredentials = (): GoogleCalendarCredentials | null => {
const raw = process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV]
if (!raw) {
// Note: this function is called without payload context, so we use console.warn
// In production, this warning will appear in server logs
console.warn(
`[GoogleCalendarSync] ${GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV} não está definido no ambiente.`,
)
export const readGoogleServiceAccountCredentials =
(): GoogleCalendarServiceAccountCredentials | null => {
const raw = process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV]
if (!raw) {
// Note: this function is called without payload context, so we use console.warn
// In production, this warning will appear in server logs
console.warn(
`[GoogleCalendarSync] ${GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV} não está definido no ambiente.`,
)
return null
}
try {
const parsed = JSON.parse(Buffer.from(raw, 'base64').toString('utf8')) as Record<
string,
unknown
>
if (
typeof parsed.client_email === 'string' &&
typeof parsed.private_key === 'string' &&
parsed.private_key.length > 0
) {
return { clientEmail: parsed.client_email, privateKey: parsed.private_key }
}
console.warn(
`[GoogleCalendarSync] ${GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV} está malformado: client_email ou private_key ausente/vazio.`,
)
} catch {
console.warn(
`[GoogleCalendarSync] ${GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV} não é base64 válido ou JSON inválido.`,
)
}
return null
}
try {
const parsed = JSON.parse(Buffer.from(raw, 'base64').toString('utf8')) as Record<
string,
unknown
>
if (
typeof parsed.client_email === 'string' &&
typeof parsed.private_key === 'string' &&
parsed.private_key.length > 0
) {
return { clientEmail: parsed.client_email, privateKey: parsed.private_key }
/**
* C149 — resolves the credential the engine uses. The OAuth connection (when
* a refresh token is stored AND the client env is present) wins over the
* service account, which stays as the technical fallback. `null` means no
* credential at all — the fail-closed state that skips the sync.
*/
export const readGoogleCalendarAuth = (
doc: GoogleCalendarSyncDoc | null,
oauthClient: GoogleCalendarOAuthClientCredentials | null = readGoogleCalendarOAuthCredentials(),
): GoogleCalendarAuth | null => {
if (doc?.oauthRefreshToken && oauthClient) {
return {
kind: 'oauth',
credentials: {
clientId: oauthClient.clientId,
clientSecret: oauthClient.clientSecret,
refreshToken: doc.oauthRefreshToken,
},
}
console.warn(
`[GoogleCalendarSync] ${GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV} está malformado: client_email ou private_key ausente/vazio.`,
)
} catch {
console.warn(
`[GoogleCalendarSync] ${GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV} não é base64 válido ou JSON inválido.`,
)
}
return null
const serviceAccount = readGoogleServiceAccountCredentials()
return serviceAccount ? { kind: 'service-account', credentials: serviceAccount } : null
}
export const loadGoogleCalendarSyncConfig = async (
@ -187,26 +256,31 @@ 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. 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.
// action layer, never per-read. The webhook route and the engine rely on
// this bypass for the channel identity fields, which no user path may read.
//
// C149 — single-row read with a preference: the configured row (calendarId
// present) wins, and when none is configured the connection-only row is
// returned (fresh setup: the OAuth connection exists before C150 picks the
// calendar). The collection holds one row operationally; legacy extra docs
// must never shadow the operational calendar — same intentional admin bypass.
const result = await payload.find({
collection: 'googleCalendarSync',
limit: 1,
limit: 0,
pagination: false,
depth: 0,
where: { calendarId: { exists: true } },
sort: 'createdAt',
overrideAccess: true,
req,
})
return result.docs[0] ?? null
const docs = result.docs
return docs.find((doc) => Boolean(doc.calendarId)) ?? docs[0] ?? null
}
const docToView = (
doc: GoogleCalendarSyncDoc | null,
hasCredential: boolean,
oauthAvailable: boolean,
): GoogleCalendarSyncView => ({
status: deriveGoogleCalendarSyncStatus({
hasCredential,
@ -215,6 +289,14 @@ const docToView = (
lastSuccessAt: doc?.lastSuccessAt ?? null,
lastErrorAt: doc?.lastErrorAt ?? null,
}),
connection: deriveGoogleCalendarConnectionStatus({
hasRefreshToken: Boolean(doc?.oauthRefreshToken),
oauthConnectedAt: doc?.oauthConnectedAt ?? null,
oauthErrorAt: doc?.oauthErrorAt ?? null,
}),
oauthAvailable,
oauthConnectedAt: doc?.oauthConnectedAt ?? null,
oauthError: doc?.oauthError ?? null,
calendarId: doc?.calendarId ?? null,
lastSyncedAt: doc?.lastSyncedAt ?? null,
lastSuccessAt: doc?.lastSuccessAt ?? null,
@ -228,11 +310,9 @@ const docToView = (
export const readGoogleCalendarSyncView = async (
payload: Payload,
): Promise<GoogleCalendarSyncView> => {
const [doc, credentials] = await Promise.all([
loadGoogleCalendarSyncConfig(payload),
readGoogleServiceAccountCredentials(),
])
return docToView(doc, credentials !== null)
const doc = await loadGoogleCalendarSyncConfig(payload)
const oauthClient = readGoogleCalendarOAuthCredentials()
return docToView(doc, readGoogleCalendarAuth(doc, oauthClient) !== null, oauthClient !== null)
}
/**
@ -638,6 +718,8 @@ type SyncStatePatch = Partial<
| 'lastSuccessAt'
| 'lastErrorAt'
| 'lastError'
| 'oauthErrorAt'
| 'oauthError'
| 'pushChannelId'
| 'pushChannelResourceId'
| 'pushChannelExpiresAt'
@ -720,6 +802,100 @@ export const recordGoogleCalendarSyncError = async (
await recordSyncState(payload, undefined, { lastErrorAt: at, lastError: message.slice(0, 500) })
}
/**
* C149 — persists the OAuth connection (the callback calls this after a
* successful code exchange). Creates the singleton connection row when the
* start action did not (fresh setup); clears every error field so the derived
* connection state recovers to `connected` by construction. The refresh token
* is system state, written with the admin bypass, and never returned here.
*/
export const recordGoogleCalendarOAuthConnection = async (
payload: Payload,
data: { refreshToken: string; scope: string | null },
req?: PayloadRequest,
): Promise<void> => {
const at = new Date().toISOString()
const patch = {
oauthRefreshToken: data.refreshToken,
oauthScope: data.scope,
oauthConnectedAt: at,
oauthErrorAt: null,
oauthError: null,
}
const doc = await loadGoogleCalendarSyncConfig(payload, req)
if (doc) {
await payload.update({
collection: 'googleCalendarSync',
id: doc.id,
data: patch,
depth: 0,
// Intentional admin bypass: the connection is system state, unreadable
// by user paths; the caller checks the actor before getting here.
overrideAccess: true,
req,
})
return
}
await payload.create({
collection: 'googleCalendarSync',
data: patch,
depth: 0,
// Intentional admin bypass: same rationale as above.
overrideAccess: true,
req,
})
}
/**
* C149 — records a failed handshake (code exchange) so the UI shows the
* connection `error` with the reconnect path. Never throws into the callback:
* the failure is already being surfaced by the redirect.
*/
export const recordGoogleCalendarOAuthError = async (
payload: Payload,
message: string,
): Promise<void> => {
const doc = await loadGoogleCalendarSyncConfig(payload)
if (!doc) return // nothing to record onto — the start action creates the row
const at = new Date().toISOString()
await payload.update({
collection: 'googleCalendarSync',
id: doc.id,
data: { oauthErrorAt: at, oauthError: message.slice(0, 500) },
depth: 0,
// Intentional admin bypass: connection state is system-only.
overrideAccess: true,
})
}
/**
* C149 — disconnect: drops the OAuth connection from the Teqo (the UI guides
* revoking the app access on the Google account). The service account, when
* configured, takes over on the next trigger — no sync pass is forced here.
*/
export const clearGoogleCalendarOAuthConnection = async (payload: Payload): Promise<void> => {
const doc = await loadGoogleCalendarSyncConfig(payload)
if (!doc) return
await payload.update({
collection: 'googleCalendarSync',
id: doc.id,
data: {
oauthRefreshToken: null,
oauthScope: null,
oauthConnectedAt: null,
oauthErrorAt: null,
oauthError: null,
},
depth: 0,
// Intentional admin bypass: the disconnect action checks the actor.
overrideAccess: true,
})
}
/** The push-channel webhook address; the URL secret IS the credential. */
const buildPushChannelAddress = (secret: string): string | null => {
try {
@ -827,6 +1003,20 @@ const ensureGoogleCalendarPushChannel = async (
}
}
/**
* C149 — pure classification for the engine's catch: only a dead OAuth
* connection (`GoogleCalendarAuthError`, i.e. `invalid_grant`) also flips the
* connection to `error`; every other failure stays `paused` without touching
* the connection. Exported for the unit pin (the engine catch is I/O-bound).
*/
export const oauthErrorPatchFor = (
error: unknown,
at: string,
): Pick<GoogleCalendarSyncDoc, 'oauthErrorAt' | 'oauthError'> | null =>
error instanceof GoogleCalendarAuthError
? { oauthErrorAt: at, oauthError: error.message.slice(0, 500) }
: null
export type CampaignCalendarSyncOutcome = SyncCounts & {
status: GoogleCalendarSyncStatus
at: string
@ -853,20 +1043,20 @@ export const runCampaignCalendarSync = async (
payload: Payload,
options: CampaignCalendarSyncOptions,
): Promise<CampaignCalendarSyncOutcome> => {
const credentials = readGoogleServiceAccountCredentials()
const config = await loadGoogleCalendarSyncConfig(payload, options.req)
const auth = readGoogleCalendarAuth(config)
const at = new Date().toISOString()
if (!credentials || !config?.calendarId || config.disabledAt) {
if (!auth || !config?.calendarId || config.disabledAt) {
const status = deriveGoogleCalendarSyncStatus({
hasCredential: credentials !== null,
hasCredential: auth !== null,
calendarId: config?.calendarId ?? null,
disabledAt: config?.disabledAt ?? null,
lastSuccessAt: config?.lastSuccessAt ?? null,
lastErrorAt: config?.lastErrorAt ?? null,
})
payload.logger.warn(
`[GoogleCalendarSync] Sincronização ignorada: credentialsPresent=${credentials !== null}, calendarId=${config?.calendarId ?? 'null'}, disabledAt=${config?.disabledAt ?? 'null'}, status=${status}`,
`[GoogleCalendarSync] Sincronização ignorada: credentialsPresent=${auth !== null}, calendarId=${config?.calendarId ?? 'null'}, disabledAt=${config?.disabledAt ?? 'null'}, status=${status}`,
)
return {
status,
@ -882,8 +1072,7 @@ export const runCampaignCalendarSync = async (
// the hook `signal` is not plumbed into that instance — the stub must be
// created with its own signal. Production hooks never inject a client, so
// the `??` branch covers the row-lock path.
const client =
options.client ?? createGoogleCalendarClient(credentials, undefined, options.signal)
const client = options.client ?? createGoogleCalendarClient(auth, undefined, options.signal)
try {
if (!options.skipChannelEnsure) {
@ -897,17 +1086,28 @@ export const runCampaignCalendarSync = async (
options.activityWhere,
config,
)
await recordSyncState(payload, options.req, { lastSyncedAt: at, lastSuccessAt: at })
// C149 — a successful pass also clears a previous OAuth error: the
// connection state recovers to `connected` without a manual reconnect.
await recordSyncState(payload, options.req, {
lastSyncedAt: at,
lastSuccessAt: at,
oauthErrorAt: null,
oauthError: null,
})
return { status: 'synced', ...counts, at }
} catch (error) {
const message =
error instanceof Error ? error.message : 'Erro desconhecido ao sincronizar com o Google.'
payload.logger.error(`[GoogleCalendarSync] Erro durante a sincronização: ${message}`)
await recordSyncState(payload, options.req, {
const patch: SyncStatePatch = {
lastSyncedAt: at,
lastErrorAt: at,
lastError: message.slice(0, 500),
})
}
// C149 — a dead OAuth connection (`invalid_grant`) additionally flips the
// derived connection state to `error`, which offers reconnect in one click.
Object.assign(patch, oauthErrorPatchFor(error, at) ?? {})
await recordSyncState(payload, options.req, patch)
return { status: 'paused', created: 0, updated: 0, deleted: 0, reverseEdits: 0, at }
}
}

View file

@ -49,7 +49,7 @@ const seedSyncConfig = (
})
test.describe('Agenda — sincronização Google (C114/C122)', () => {
test('staff vê a pill "não configurado" e o diálogo explica o passo de operação', async ({
test('staff vê a pill "não configurado" e o diálogo oferece conectar (C149)', async ({
campaign,
page,
}) => {
@ -67,9 +67,57 @@ test.describe('Agenda — sincronização Google (C114/C122)', () => {
const dialog = syncDialog(page)
await expect(dialog).toBeVisible()
await expect(dialog.getByText('Ainda não configurado')).toBeVisible()
// O runbook de operação aparece (a ativação depende da conta Google da campanha).
await expect(dialog.getByText(/service account do Teqo/)).toBeVisible()
// C149: sem conexão, o card oferece o botão de conectar (o client OAuth
// dummy está no webServer) e mantém a service account como fallback.
await expect(dialog.getByText('Não configurado')).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Conectar com o Google' })).toBeVisible()
await expect(dialog.getByText(/service account continua disponível/)).toBeVisible()
})
test('o botão Conectar leva ao consent do Google com os escopos mínimos (C149)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
// Row owned by the fixture: the start action UPDATES it (the connection
// row may exist before any calendar is chosen) instead of creating a row
// the cleanup would not track.
await fixtures.payload.create({
collection: 'googleCalendarSync',
data: {},
depth: 0,
overrideAccess: true,
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/agenda`)
// The click navigates the browser to accounts.google.com — intercept so
// the assertion observes the URL Google would receive, with no network.
await page.route('https://accounts.google.com/**', (route) =>
route.fulfill({ status: 200, contentType: 'text/html', body: '<title>consent</title>' }),
)
const pill = page.getByRole('button', { name: 'Google: não configurado' })
await expect(pill).toBeVisible({ timeout: 15_000 })
await pill.click()
const dialog = syncDialog(page)
await expect(dialog).toBeVisible()
await dialog.getByRole('button', { name: 'Conectar com o Google' }).click()
await expect.poll(() => page.url()).toContain('accounts.google.com/o/oauth2/v2/auth')
const url = new URL(page.url())
expect(url.searchParams.get('access_type')).toBe('offline')
expect(url.searchParams.get('prompt')).toBe('consent')
expect(url.searchParams.get('response_type')).toBe('code')
expect(url.searchParams.get('scope')).toBe(
'https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.calendarlist.readonly',
)
expect(url.searchParams.get('state')).toBeTruthy()
const redirectUri = new URL(url.searchParams.get('redirect_uri') as string)
expect(redirectUri.pathname).toBe('/campanha/agenda/google-oauth/callback')
})
test('estado disabled: pill, aviso de desativação e Reativar re-sincroniza (D7)', async ({
@ -197,7 +245,8 @@ test.describe('Agenda — sincronização Google (C114/C122)', () => {
const sheet = syncDialog(page)
await expect(sheet).toBeVisible({ timeout: 15_000 })
await expect(sheet.getByText('Ainda não configurado')).toBeVisible()
await expect(sheet.getByText('Não configurado')).toBeVisible()
await expect(sheet.getByRole('button', { name: 'Conectar com o Google' })).toBeVisible()
})
})

View file

@ -1,4 +1,5 @@
import { Client } from 'pg'
import { afterAll, beforeAll } from 'vitest'
import { assertTestDatabase } from './assertTestDatabase'
@ -12,6 +13,16 @@ import { assertTestDatabase } from './assertTestDatabase'
*/
export const ADMIN_LOGIN_LOCK_KEY = 727_001
/**
* C149 — `googleCalendarSync` is a SINGLETON row, and the int suite runs files
* in parallel against one database: several google specs create/pick/delete
* configured rows concurrently, and the loader's "configured row wins"
* preference then resolves to ANOTHER file's row (channel/count assertions
* read the wrong doc, snapshots land on a row being deleted). The lock
* serializes the whole google calendar family for the duration of each file.
*/
export const GOOGLE_CALENDAR_SYNC_LOCK_KEY = 727_002
/**
* Runs `fn` while holding a Postgres advisory lock.
*
@ -33,3 +44,26 @@ export const withAdvisoryLock = async <T>(key: number, fn: () => Promise<T>): Pr
await client.end().catch(() => {})
}
}
/**
* Holds the given advisory lock for the WHOLE spec file: `beforeAll` acquires
* on a dedicated connection and `afterAll` releases it. Call at the top level
* of a spec. A crashed worker drops its connection, releasing the lock.
*/
export const serializeSpecWithAdvisoryLock = (key: number): void => {
let client: Client | null = null
beforeAll(async () => {
assertTestDatabase(process.env.DATABASE_URL)
client = new Client({ connectionString: process.env.DATABASE_URL })
await client.connect()
await client.query('SELECT pg_advisory_lock($1)', [key])
}, 60_000)
afterAll(async () => {
if (!client) return
await client.query('SELECT pg_advisory_unlock($1)', [key]).catch(() => {})
await client.end().catch(() => {})
client = null
})
}

View file

@ -7,12 +7,18 @@ import config from '@/payload.config'
import { loadGoogleCalendarSyncConfig } from '@/utilities/googleCalendarSync'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import {
GOOGLE_CALENDAR_SYNC_LOCK_KEY,
serializeSpecWithAdvisoryLock,
} from '../helpers/advisoryLock'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import {
createSlowGoogleFetch,
withValidGoogleCalendarCredential,
} from '../helpers/googleCalendarTestKey'
serializeSpecWithAdvisoryLock(GOOGLE_CALENDAR_SYNC_LOCK_KEY)
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,

View file

@ -0,0 +1,169 @@
// @vitest-environment node
//
// C149 — the OAuth connection stored on the singleton `googleCalendarSync`
// row: recording, error state, disconnect, view/loader derivation. The int
// suite runs files in parallel against ONE shared database, so this file only
// creates CONNECTION-ONLY rows (no `calendarId`): the singleton loader prefers
// a configured row, which means these rows can never shadow the engine specs'
// configured row. Rows this file created are tracked and deleted; a token that
// landed on a shared row (the loader's preference) is cleared, never deleted.
import { getPayload, type Payload } from 'payload'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import config from '@/payload.config'
import {
GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV,
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV,
} from '@/utilities/googleCalendarOAuth'
import {
clearGoogleCalendarOAuthConnection,
loadGoogleCalendarSyncConfig,
readGoogleCalendarSyncView,
recordGoogleCalendarOAuthConnection,
recordGoogleCalendarOAuthError,
} from '@/utilities/googleCalendarSync'
import {
GOOGLE_CALENDAR_SYNC_LOCK_KEY,
serializeSpecWithAdvisoryLock,
} from '../helpers/advisoryLock'
serializeSpecWithAdvisoryLock(GOOGLE_CALENDAR_SYNC_LOCK_KEY)
let payload: Payload
/** Sets/restores the OAuth client pair around a callback — restored on failure too. */
const withGoogleCalendarOAuthClient = async <T>(run: () => Promise<T>): Promise<T> => {
const previous = {
id: process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV],
secret: process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV],
}
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV] = 'test-client-id'
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV] = 'test-client-secret'
try {
return await run()
} finally {
if (previous.id === undefined) delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]
else process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV] = previous.id
if (previous.secret === undefined) delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV]
else process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV] = previous.secret
}
}
describe('C149 — OAuth connection on the googleCalendarSync row', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
const ownedIds = new Set<number>()
afterEach(async () => {
// Clear connection fields this file wrote wherever they landed — the
// loader may have picked a row created by a sibling spec, and that row
// must not be deleted from under it.
const touched = await payload.find({
collection: 'googleCalendarSync',
where: { oauthRefreshToken: { like: 'c149-conn%' } },
depth: 0,
pagination: false,
overrideAccess: true,
})
for (const doc of touched.docs) {
if (ownedIds.has(doc.id)) continue
await payload.update({
collection: 'googleCalendarSync',
id: doc.id,
data: {
oauthRefreshToken: null,
oauthScope: null,
oauthConnectedAt: null,
oauthErrorAt: null,
oauthError: null,
},
depth: 0,
overrideAccess: true,
})
}
for (const id of ownedIds) {
await payload.delete({ collection: 'googleCalendarSync', id, overrideAccess: true })
}
ownedIds.clear()
})
const createConnectionRow = async (data: Record<string, unknown> = {}) => {
const doc = await payload.create({
collection: 'googleCalendarSync',
data,
depth: 0,
overrideAccess: true,
})
ownedIds.add(doc.id)
return doc
}
it('records the connection (create when no row exists) and clears previous errors', async () => {
await createConnectionRow({ oauthErrorAt: '2026-08-11T10:00:00.000Z', oauthError: 'antigo' })
await recordGoogleCalendarOAuthConnection(payload, {
refreshToken: 'c149-conn-create',
scope: 'scope-a scope-b',
})
const doc = await loadGoogleCalendarSyncConfig(payload)
expect(doc?.oauthRefreshToken).toBe('c149-conn-create')
expect(doc?.oauthScope).toBe('scope-a scope-b')
expect(doc?.oauthConnectedAt).toBeTruthy()
expect(doc?.oauthErrorAt).toBeNull()
expect(doc?.oauthError).toBeNull()
})
it('records the auth error and clears the whole connection on disconnect', async () => {
await createConnectionRow({ oauthRefreshToken: 'c149-conn-disconnect' })
await recordGoogleCalendarOAuthError(payload, 'A conexão com o Google expirou.')
let doc = await loadGoogleCalendarSyncConfig(payload)
expect(doc?.oauthError).toBe('A conexão com o Google expirou.')
expect(doc?.oauthErrorAt).toBeTruthy()
await clearGoogleCalendarOAuthConnection(payload)
doc = await loadGoogleCalendarSyncConfig(payload)
expect(doc?.oauthRefreshToken).toBeNull()
expect(doc?.oauthScope).toBeNull()
expect(doc?.oauthConnectedAt).toBeNull()
expect(doc?.oauthErrorAt).toBeNull()
expect(doc?.oauthError).toBeNull()
})
it('derives the connection view (connected → error) and oauthAvailable from the env', async () => {
await createConnectionRow({ oauthRefreshToken: 'c149-conn-view' })
await withGoogleCalendarOAuthClient(async () => {
const connected = await readGoogleCalendarSyncView(payload)
expect(connected.connection).toBe('connected')
expect(connected.oauthAvailable).toBe(true)
await recordGoogleCalendarOAuthError(payload, 'Token revogado.')
const broken = await readGoogleCalendarSyncView(payload)
expect(broken.connection).toBe('error')
expect(broken.oauthError).toBe('Token revogado.')
})
})
it('finds a connection-only row even without a calendarId (fresh setup)', async () => {
const row = await createConnectionRow({ oauthRefreshToken: 'c149-conn-loader' })
const loaded = await loadGoogleCalendarSyncConfig(payload)
// A configured sibling (parallel spec) legitimately wins the singleton
// preference; without one, the connection-only row is the row.
if (loaded?.calendarId) {
expect(loaded.id).not.toBe(row.id)
} else {
expect(loaded?.id).toBe(row.id)
}
expect(row.calendarId ?? null).toBeNull()
})
})

View file

@ -0,0 +1,293 @@
// @vitest-environment node
//
// C149 — the OAuth handshake boundary: the start action (role + state cookie
// + authorize URL) and the callback route (state validation, role re-check,
// code exchange, connection recording). `next/headers` is mocked with an
// in-memory cookie jar so the real signing/verification runs; `fetch` is
// stubbed at the token endpoint so no request leaves the process. Session
// resolution is mocked (the pattern of googleCalendarSyncAction.int.spec.ts);
// everything else — Payload access, the engine hooks, the DB — runs for real.
import { NextRequest } from 'next/server'
import { getPayload, type Payload } from 'payload'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import config from '@/payload.config'
const { cookieJar } = vi.hoisted(() => ({ cookieJar: new Map<string, string>() }))
vi.mock('next/headers', () => ({
cookies: async () => ({
get: (name: string) => (cookieJar.has(name) ? { name, value: cookieJar.get(name) } : undefined),
set: (name: string, value: string) => {
cookieJar.set(name, value)
},
}),
headers: async () => new Headers(),
}))
vi.mock('@/utilities/campaignActionContext', async (importOriginal) => {
const original = await importOriginal<typeof import('@/utilities/campaignActionContext')>()
return { ...original, getCampaignActionContext: vi.fn() }
})
// The callback's follow-up reconciliation is not this spec's subject (the
// engine has its own file) and, against the shared singleton, a full pass
// would write snapshots onto whichever row the loader picked — including a
// parallel spec's. Stub only that call; every other export stays real.
vi.mock('@/utilities/googleCalendarSync', async (importOriginal) => {
const original = await importOriginal<typeof import('@/utilities/googleCalendarSync')>()
return { ...original, runCampaignCalendarSync: vi.fn() }
})
import {
disconnectGoogleCalendarOAuth,
startGoogleCalendarOAuth,
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
import { GET as googleOAuthCallback } from '@/app/(campaign)/campanha/agenda/google-oauth/callback/route'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import {
GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV,
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV,
} from '@/utilities/googleCalendarOAuth'
import { recordGoogleCalendarOAuthConnection } from '@/utilities/googleCalendarSync'
import {
GOOGLE_CALENDAR_SYNC_LOCK_KEY,
serializeSpecWithAdvisoryLock,
} from '../helpers/advisoryLock'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
serializeSpecWithAdvisoryLock(GOOGLE_CALENDAR_SYNC_LOCK_KEY)
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const mockedGetContext = vi.mocked(getCampaignActionContext)
const OAUTH_ENV_KEYS = [
GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV,
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV,
] as const
const originalEnv = new Map(OAUTH_ENV_KEYS.map((key) => [key, process.env[key]]))
const setOAuthClientEnv = (): void => {
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV] = 'test-client-id'
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV] = 'test-client-secret'
}
const restoreEnv = (): void => {
for (const key of OAUTH_ENV_KEYS) {
const value = originalEnv.get(key)
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
}
/**
* Token endpoint + an empty calendar API, so the callback's follow-up pass
* completes without network regardless of which row the loader picked.
*/
const googleFetchStub = (refreshToken: string): typeof fetch =>
(async (input: RequestInfo | URL) => {
const url = String(input)
if (url === 'https://oauth2.googleapis.com/token') {
return new Response(
JSON.stringify({
access_token: 'access-token',
refresh_token: refreshToken,
scope:
'https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/calendar.calendarlist.readonly',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}
if (url.includes('/events/watch')) {
return new Response(
JSON.stringify({ id: 'watch-1', resourceId: 'resource-1', expiration: null }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}
if (url.includes('/channels/stop')) return new Response(null, { status: 204 })
if (url.includes('/calendar/v3/')) {
return new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
throw new Error(`Unexpected fetch to ${url}`)
}) as typeof fetch
const callbackRequest = (params: Record<string, string>): NextRequest =>
new NextRequest(
`http://localhost:3000/campanha/agenda/google-oauth/callback?${new URLSearchParams(params).toString()}`,
)
const rowsWithToken = (token: string) =>
payload.find({
collection: 'googleCalendarSync',
where: { oauthRefreshToken: { equals: token } },
depth: 0,
overrideAccess: true,
})
describe('C149 — OAuth actions and callback', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
afterEach(async () => {
restoreEnv()
cookieJar.clear()
vi.unstubAllGlobals()
vi.clearAllMocks()
// The connection may have landed on a parallel spec's row (the loader
// prefers a configured row): clear the fields, never delete that row.
const touched = await payload.find({
collection: 'googleCalendarSync',
where: { oauthRefreshToken: { like: 'c149-refresh%' } },
depth: 0,
pagination: false,
overrideAccess: true,
})
for (const doc of touched.docs) {
await payload.update({
collection: 'googleCalendarSync',
id: doc.id,
data: {
oauthRefreshToken: null,
oauthScope: null,
oauthConnectedAt: null,
oauthErrorAt: null,
oauthError: null,
},
depth: 0,
overrideAccess: true,
})
}
// Empty rows (no calendar, no token) are only produced by this file's
// start attempts — safe to delete.
const emptyRows = await payload.find({
collection: 'googleCalendarSync',
where: {
and: [{ calendarId: { exists: false } }, { oauthRefreshToken: { exists: false } }],
},
depth: 0,
pagination: false,
overrideAccess: true,
})
for (const doc of emptyRows.docs) {
await payload.delete({ collection: 'googleCalendarSync', id: doc.id, overrideAccess: true })
}
})
afterAll(() => {
restoreEnv()
})
it('start (coordinator) returns the consent URL and the callback records the connection', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
mockedGetContext.mockResolvedValue({ payload, actor: coordinator })
setOAuthClientEnv()
const start = await startGoogleCalendarOAuth()
expect(start.ok).toBe(true)
if (!start.ok) throw new Error('start must succeed')
const authorizeUrl = new URL(start.authorizeUrl)
expect(`${authorizeUrl.origin}${authorizeUrl.pathname}`).toBe(
'https://accounts.google.com/o/oauth2/v2/auth',
)
expect(authorizeUrl.searchParams.get('access_type')).toBe('offline')
expect(authorizeUrl.searchParams.get('prompt')).toBe('consent')
expect(authorizeUrl.searchParams.get('redirect_uri')).toMatch(
/\/campanha\/agenda\/google-oauth\/callback$/,
)
const state = authorizeUrl.searchParams.get('state')
expect(state).toBeTruthy()
expect(cookieJar.has('campaign-google-oauth-state')).toBe(true)
const refreshToken = 'c149-refresh-callback-success'
vi.stubGlobal('fetch', googleFetchStub(refreshToken))
const response = await googleOAuthCallback(
callbackRequest({ code: 'code-1', state: state as string }),
)
expect(response.status).toBe(307)
expect(response.headers.get('location')).toContain('/campanha/agenda')
const rows = await rowsWithToken(refreshToken)
expect(rows.docs).toHaveLength(1)
expect(rows.docs[0]?.oauthConnectedAt).toBeTruthy()
// The state cookie is single-use.
expect(cookieJar.get('campaign-google-oauth-state')).toBe('')
})
it('start refuses an advisor without writing anything', async () => {
const advisor = await campaignFixtures().createCampaignUser('advisor')
mockedGetContext.mockResolvedValue({ payload, actor: advisor })
setOAuthClientEnv()
const start = await startGoogleCalendarOAuth()
expect(start.ok).toBe(false)
if (start.ok) throw new Error('start must be refused')
expect(start.message).toContain('candidato ou coordenação')
expect(cookieJar.has('campaign-google-oauth-state')).toBe(false)
})
it('start fails closed when the OAuth client env is missing', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
mockedGetContext.mockResolvedValue({ payload, actor: coordinator })
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV]
const start = await startGoogleCalendarOAuth()
expect(start.ok).toBe(false)
if (start.ok) throw new Error('start must fail')
expect(start.message).toContain('não está configurada no servidor')
})
it('callback refuses an invalid state without recording anything', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
mockedGetContext.mockResolvedValue({ payload, actor: coordinator })
setOAuthClientEnv()
vi.stubGlobal('fetch', googleFetchStub('c149-refresh-should-not-exist'))
const response = await googleOAuthCallback(
callbackRequest({ code: 'code-1', state: 'forged-state' }),
)
expect(response.status).toBe(307)
expect((await rowsWithToken('c149-refresh-should-not-exist')).docs).toHaveLength(0)
})
it('callback treats access_denied as a silent redirect', async () => {
const response = await googleOAuthCallback(callbackRequest({ error: 'access_denied' }))
expect(response.status).toBe(307)
expect(response.headers.get('location')).toContain('/campanha/agenda')
})
it('disconnect clears the stored connection', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
mockedGetContext.mockResolvedValue({ payload, actor: coordinator })
setOAuthClientEnv()
const refreshToken = 'c149-refresh-disconnect'
await recordGoogleCalendarOAuthConnection(payload, { refreshToken, scope: null })
expect((await rowsWithToken(refreshToken)).docs).toHaveLength(1)
const result = await disconnectGoogleCalendarOAuth()
expect(result.ok).toBe(true)
expect((await rowsWithToken(refreshToken)).docs).toHaveLength(0)
})
})

View file

@ -37,9 +37,15 @@ import {
} from '@/utilities/googleCalendarSync'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import {
GOOGLE_CALENDAR_SYNC_LOCK_KEY,
serializeSpecWithAdvisoryLock,
} from '../helpers/advisoryLock'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { withGoogleCalendarTestCredential } from '../helpers/googleCalendarTestKey'
serializeSpecWithAdvisoryLock(GOOGLE_CALENDAR_SYNC_LOCK_KEY)
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,

View file

@ -10,6 +10,10 @@ import {
import config from '@/payload.config'
import { loadGoogleCalendarSyncConfig } from '@/utilities/googleCalendarSync'
import {
GOOGLE_CALENDAR_SYNC_LOCK_KEY,
serializeSpecWithAdvisoryLock,
} from '../helpers/advisoryLock'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { withGoogleCalendarTestCredential } from '../helpers/googleCalendarTestKey'
@ -30,6 +34,8 @@ vi.mock('@/utilities/campaignActionContext', async (importOriginal) => {
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
serializeSpecWithAdvisoryLock(GOOGLE_CALENDAR_SYNC_LOCK_KEY)
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,

View file

@ -1,7 +1,10 @@
import { cleanup, fireEvent, render, screen, waitFor } 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 type {
GoogleCalendarOAuthStartResult,
GoogleCalendarSyncActionResult,
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
import { AgendaGoogleSyncChrome } from '@/components/campaign/activity/AgendaGoogleSyncChrome'
import {
CampaignPageChromeProvider,
@ -23,6 +26,11 @@ import { stubMatchMedia } from '../helpers/matchMedia'
const stateFor = (status: GoogleCalendarSyncStatus): GoogleCalendarSyncActionResult => ({
ok: true,
canManageConnection: true,
connection: status === 'not-configured' ? 'not-configured' : 'connected',
oauthAvailable: true,
oauthConnectedAt: status === 'not-configured' ? null : '2026-08-01T10:00:00.000Z',
oauthError: null,
status,
calendarId: status === 'not-configured' ? null : 'c_campanha@group.calendar.google.com',
lastSyncedAt: status === 'synced' || status === 'paused' ? '2026-08-11T10:00:00.000Z' : null,
@ -62,6 +70,13 @@ const renderChrome = ({
initialState={stateFor(status)}
onSyncNow={onSyncNow}
onSetDisabled={vi.fn(async () => stateFor('disabled'))}
onStartOAuth={vi.fn(
async (): Promise<GoogleCalendarOAuthStartResult> => ({
ok: true,
authorizeUrl: 'https://accounts.google.com/',
}),
)}
onDisconnect={vi.fn(async () => stateFor('not-configured'))}
/>
<HeaderActionsProbe />
</CampaignQuickActionContextProvider>

View file

@ -417,6 +417,9 @@ describe('src/utilities top-level is pinned', () => {
// C114 — Google Calendar mirror: activity/config hooks (separate module so
// the engine is mockable at the import boundary in tests).
'googleCalendarSyncHooks.ts',
// C149 — Google Calendar OAuth: env client pair, signed single-use state
// cookie and the authorization-code exchange (server-only).
'googleCalendarOAuth.ts',
'campaignAuth.ts',
'campaignBiometricsPrompt.ts',
'campaignColumnVisibilityCookie.ts',

View file

@ -8,12 +8,14 @@ import {
createGoogleCalendarClient,
GOOGLE_CALENDAR_SCOPE,
GoogleCalendarApiError,
GoogleCalendarAuthError,
type FetchLike,
type GoogleCalendarCredentials,
type GoogleCalendarAuth,
type GoogleCalendarServiceAccountCredentials,
} from '@/utilities/googleCalendarClient'
describe('buildServiceAccountAssertion', () => {
let credentials: GoogleCalendarCredentials
let credentials: GoogleCalendarServiceAccountCredentials
let publicKeyPem: string
beforeAll(async () => {
@ -38,7 +40,8 @@ describe('buildServiceAccountAssertion', () => {
})
describe('createGoogleCalendarClient', () => {
let credentials: GoogleCalendarCredentials
let credentials: GoogleCalendarServiceAccountCredentials
let serviceAccountAuth: GoogleCalendarAuth
beforeAll(async () => {
// The token endpoint is stubbed, but the client still SIGNS the JWT
@ -48,8 +51,18 @@ describe('createGoogleCalendarClient', () => {
clientEmail: 'teqo-sa@projeto.iam.gserviceaccount.com',
privateKey: await exportPKCS8(privateKey),
}
serviceAccountAuth = { kind: 'service-account', credentials }
})
const oauthAuth: GoogleCalendarAuth = {
kind: 'oauth',
credentials: {
clientId: 'client-id.apps.googleusercontent.com',
clientSecret: 'client-secret',
refreshToken: 'refresh-token',
},
}
const calendarId = 'c_abc@group.calendar.google.com'
/** Minimal fake transport: token endpoint + calendar REST in memory. */
@ -112,7 +125,7 @@ describe('createGoogleCalendarClient', () => {
it('inserts with the deterministic id, bearer auth and JSON body', async () => {
const { fetchImpl, calls, events } = stubTransport()
const client = createGoogleCalendarClient(credentials, fetchImpl)
const client = createGoogleCalendarClient(serviceAccountAuth, fetchImpl)
await client.insertEvent(calendarId, { id: 'teqo1a', summary: 'S', description: 'D' })
@ -125,7 +138,7 @@ describe('createGoogleCalendarClient', () => {
it('lists with the window params and pagination', async () => {
const { fetchImpl, calls } = stubTransport()
const client = createGoogleCalendarClient(credentials, fetchImpl)
const client = createGoogleCalendarClient(serviceAccountAuth, fetchImpl)
await client.listEvents(calendarId, {
timeMin: '2026-01-01T00:00:00Z',
@ -158,7 +171,7 @@ describe('createGoogleCalendarClient', () => {
})
}) as FetchLike
const client = createGoogleCalendarClient(credentials, fetchImpl)
const client = createGoogleCalendarClient(serviceAccountAuth, fetchImpl)
const events = await client.listEvents(calendarId, {
timeMin: '2026-01-01T00:00:00Z',
timeMax: '2026-12-31T00:00:00Z',
@ -177,9 +190,79 @@ describe('createGoogleCalendarClient', () => {
return new Response('ok', { status: 200 })
}) as FetchLike
const client = createGoogleCalendarClient(credentials, fetchImpl)
const client = createGoogleCalendarClient(serviceAccountAuth, fetchImpl)
await expect(
client.listEvents(calendarId, { timeMin: 'a', timeMax: 'b' }),
).rejects.toBeInstanceOf(GoogleCalendarApiError)
})
it('mints an access token with the OAuth refresh grant (C149)', async () => {
const { fetchImpl, calls } = stubTransport()
const client = createGoogleCalendarClient(oauthAuth, fetchImpl)
await client.listEvents(calendarId, { timeMin: 'a', timeMax: 'b' })
const tokenCall = calls.find((call) => call.url === 'https://oauth2.googleapis.com/token')
const params = new URLSearchParams(tokenCall?.body)
expect(params.get('grant_type')).toBe('refresh_token')
expect(params.get('refresh_token')).toBe('refresh-token')
expect(params.get('client_id')).toBe('client-id.apps.googleusercontent.com')
expect(params.get('client_secret')).toBe('client-secret')
const listCall = calls.find((call) => call.method === 'GET')
expect(listCall?.auth).toBe('Bearer token-1')
})
it('maps invalid_grant to GoogleCalendarAuthError (reconnectable)', async () => {
const fetchImpl: FetchLike = (async () =>
new Response(JSON.stringify({ error: 'invalid_grant' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})) as FetchLike
const client = createGoogleCalendarClient(oauthAuth, fetchImpl)
await expect(
client.listEvents(calendarId, { timeMin: 'a', timeMax: 'b' }),
).rejects.toBeInstanceOf(GoogleCalendarAuthError)
})
it('keeps invalid_client as an API error (reconnect would not fix it)', async () => {
const fetchImpl: FetchLike = (async () =>
new Response(JSON.stringify({ error: 'invalid_client' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})) as FetchLike
const client = createGoogleCalendarClient(oauthAuth, fetchImpl)
await expect(
client.listEvents(calendarId, { timeMin: 'a', timeMax: 'b' }),
).rejects.toBeInstanceOf(GoogleCalendarApiError)
})
it('re-mints the OAuth token once on a 401 and retries the call', async () => {
let calendarCalls = 0
let tokenRequests = 0
const fetchImpl: FetchLike = (async (input: RequestInfo | URL) => {
const url = String(input)
if (url === 'https://oauth2.googleapis.com/token') {
tokenRequests += 1
return new Response(
JSON.stringify({ access_token: `oauth-token-${tokenRequests}`, expires_in: 3600 }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}
calendarCalls += 1
if (calendarCalls === 1) return new Response('unauthorized', { status: 401 })
return new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as FetchLike
const client = createGoogleCalendarClient(oauthAuth, fetchImpl)
const events = await client.listEvents(calendarId, { timeMin: 'a', timeMax: 'b' })
expect(events).toEqual([])
expect(calendarCalls).toBe(2)
expect(tokenRequests).toBe(2)
})
})

View file

@ -0,0 +1,195 @@
// @vitest-environment node
import { afterEach, describe, expect, it } from 'vitest'
import {
buildGoogleCalendarOAuthAuthorizeUrl,
GOOGLE_CALENDAR_OAUTH_CALLBACK_PATH,
GOOGLE_CALENDAR_OAUTH_SCOPES,
} from '@/lib/googleCalendarOAuth'
import {
exchangeGoogleCalendarOAuthCode,
GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV,
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV,
GoogleCalendarOAuthError,
readGoogleCalendarOAuthCredentials,
} from '@/utilities/googleCalendarOAuth'
const CREDENTIALS = {
clientId: 'client-id.apps.googleusercontent.com',
clientSecret: 'client-secret',
}
const REDIRECT_URI = `https://jorgesolla1313.com.br${GOOGLE_CALENDAR_OAUTH_CALLBACK_PATH}`
describe('buildGoogleCalendarOAuthAuthorizeUrl', () => {
it('pins the two literal minimum scopes (C149)', () => {
expect([...GOOGLE_CALENDAR_OAUTH_SCOPES]).toEqual([
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/calendar.calendarlist.readonly',
])
})
it('builds the consent URL with offline access, forced consent and the state', () => {
const url = new URL(
buildGoogleCalendarOAuthAuthorizeUrl({
clientId: CREDENTIALS.clientId,
redirectUri: REDIRECT_URI,
state: 'state-123',
}),
)
expect(`${url.origin}${url.pathname}`).toBe('https://accounts.google.com/o/oauth2/v2/auth')
expect(url.searchParams.get('client_id')).toBe(CREDENTIALS.clientId)
expect(url.searchParams.get('redirect_uri')).toBe(REDIRECT_URI)
expect(url.searchParams.get('response_type')).toBe('code')
expect(url.searchParams.get('scope')).toBe(GOOGLE_CALENDAR_OAUTH_SCOPES.join(' '))
expect(url.searchParams.get('access_type')).toBe('offline')
expect(url.searchParams.get('prompt')).toBe('consent')
expect(url.searchParams.get('state')).toBe('state-123')
expect(url.search).not.toContain('client_secret')
})
})
describe('readGoogleCalendarOAuthCredentials', () => {
const original = {
id: process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV],
secret: process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV],
}
afterEach(() => {
if (original.id === undefined) delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]
else process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV] = original.id
if (original.secret === undefined) delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV]
else process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV] = original.secret
})
it('fails closed (null) when either side is missing', () => {
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV]
expect(readGoogleCalendarOAuthCredentials()).toBeNull()
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV] = 'client-id'
expect(readGoogleCalendarOAuthCredentials()).toBeNull()
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV] = 'client-secret'
expect(readGoogleCalendarOAuthCredentials()).toBeNull()
})
it('returns the pair when both sides are set', () => {
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV] = 'client-id'
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV] = 'client-secret'
expect(readGoogleCalendarOAuthCredentials()).toEqual({
clientId: 'client-id',
clientSecret: 'client-secret',
})
})
})
describe('exchangeGoogleCalendarOAuthCode', () => {
it('posts the authorization code and returns the refresh token + scope', async () => {
const calls: Array<{ url: string; body?: string }> = []
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ url: String(input), body: typeof init?.body === 'string' ? init.body : '' })
return new Response(
JSON.stringify({
access_token: 'access-token',
refresh_token: 'refresh-token',
scope: GOOGLE_CALENDAR_OAUTH_SCOPES.join(' '),
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
}) as typeof fetch
const tokens = await exchangeGoogleCalendarOAuthCode({
code: 'code-123',
redirectUri: REDIRECT_URI,
credentials: CREDENTIALS,
fetchImpl,
})
expect(tokens).toEqual({
refreshToken: 'refresh-token',
scope: GOOGLE_CALENDAR_OAUTH_SCOPES.join(' '),
})
const params = new URLSearchParams(calls[0]?.body)
expect(calls[0]?.url).toBe('https://oauth2.googleapis.com/token')
expect(params.get('grant_type')).toBe('authorization_code')
expect(params.get('code')).toBe('code-123')
expect(params.get('redirect_uri')).toBe(REDIRECT_URI)
expect(params.get('client_id')).toBe(CREDENTIALS.clientId)
expect(params.get('client_secret')).toBe(CREDENTIALS.clientSecret)
})
it('maps a non-OK response to a safe error without echoing the body', async () => {
const fetchImpl = (async () =>
new Response(JSON.stringify({ error: 'invalid_grant', secret: 'leak-me' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})) as typeof fetch
const error = await exchangeGoogleCalendarOAuthCode({
code: 'code-123',
redirectUri: REDIRECT_URI,
credentials: CREDENTIALS,
fetchImpl,
}).catch((caught: unknown) => caught)
expect(error).toBeInstanceOf(GoogleCalendarOAuthError)
expect((error as Error).message).not.toContain('leak-me')
expect((error as Error).message).not.toContain('invalid_grant')
})
it('rejects a partial grant that misses a required scope', async () => {
const fetchImpl = (async () =>
new Response(
JSON.stringify({
access_token: 'access-token',
refresh_token: 'refresh-token',
scope: 'https://www.googleapis.com/auth/calendar.events',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)) as typeof fetch
await expect(
exchangeGoogleCalendarOAuthCode({
code: 'code-123',
redirectUri: REDIRECT_URI,
credentials: CREDENTIALS,
fetchImpl,
}),
).rejects.toBeInstanceOf(GoogleCalendarOAuthError)
})
it('fails safely when Google does not return a refresh token', async () => {
const fetchImpl = (async () =>
new Response(JSON.stringify({ access_token: 'access-token' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})) as typeof fetch
await expect(
exchangeGoogleCalendarOAuthCode({
code: 'code-123',
redirectUri: REDIRECT_URI,
credentials: CREDENTIALS,
fetchImpl,
}),
).rejects.toBeInstanceOf(GoogleCalendarOAuthError)
})
it('maps a network failure to a safe error', async () => {
const fetchImpl = (async () => {
throw new Error('socket hang up')
}) as typeof fetch
await expect(
exchangeGoogleCalendarOAuthCode({
code: 'code-123',
redirectUri: REDIRECT_URI,
credentials: CREDENTIALS,
fetchImpl,
}),
).rejects.toBeInstanceOf(GoogleCalendarOAuthError)
})
})

View file

@ -2,11 +2,22 @@ import { afterEach, describe, expect, it } from 'vitest'
import type { Activity, GoogleCalendarSync as GoogleCalendarSyncDoc } from '@/payload-types'
import { REQUEST_TIMEOUT_MS } from '@/utilities/googleCalendarClient'
import {
GoogleCalendarApiError,
GoogleCalendarAuthError,
REQUEST_TIMEOUT_MS,
} from '@/utilities/googleCalendarClient'
import {
GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV,
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV,
} from '@/utilities/googleCalendarOAuth'
import {
deriveGoogleCalendarConnectionStatus,
deriveGoogleCalendarSyncStatus,
GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV,
GOOGLE_CALENDAR_SYNC_HOOK_TIMEOUT_MS,
oauthErrorPatchFor,
readGoogleCalendarAuth,
readGoogleServiceAccountCredentials,
shouldSyncActivityOperation,
shouldSyncConfigChange,
@ -61,6 +72,71 @@ describe('deriveGoogleCalendarSyncStatus', () => {
})
})
describe('deriveGoogleCalendarConnectionStatus (C149)', () => {
it('is not-configured without a refresh token and without an error', () => {
expect(deriveGoogleCalendarConnectionStatus({ hasRefreshToken: false })).toBe('not-configured')
})
it('is connected with a refresh token and no newer error', () => {
expect(
deriveGoogleCalendarConnectionStatus({
hasRefreshToken: true,
oauthConnectedAt: '2026-08-11T10:00:00.000Z',
}),
).toBe('connected')
// An error recorded BEFORE the last connection is stale — connected wins.
expect(
deriveGoogleCalendarConnectionStatus({
hasRefreshToken: true,
oauthConnectedAt: '2026-08-11T12:00:00.000Z',
oauthErrorAt: '2026-08-11T10:00:00.000Z',
}),
).toBe('connected')
})
it('is error when the auth error is newer than the connection (or there is none)', () => {
expect(
deriveGoogleCalendarConnectionStatus({
hasRefreshToken: true,
oauthConnectedAt: '2026-08-11T10:00:00.000Z',
oauthErrorAt: '2026-08-11T12:00:00.000Z',
}),
).toBe('error')
// A failed handshake before the first connection has no connectedAt.
expect(
deriveGoogleCalendarConnectionStatus({
hasRefreshToken: false,
oauthErrorAt: '2026-08-11T12:00:00.000Z',
}),
).toBe('error')
})
})
describe('oauthErrorPatchFor (C149)', () => {
it('maps a dead OAuth connection to the connection error fields', () => {
expect(
oauthErrorPatchFor(
new GoogleCalendarAuthError('A conexão com o Google expirou. Reconecte a conta.'),
'2026-09-12T10:00:00.000Z',
),
).toEqual({
oauthErrorAt: '2026-09-12T10:00:00.000Z',
oauthError: 'A conexão com o Google expirou. Reconecte a conta.',
})
})
it('leaves every other failure out of the connection state', () => {
expect(oauthErrorPatchFor(new Error('HTTP 500'), 'at')).toBeNull()
expect(oauthErrorPatchFor(new GoogleCalendarApiError('HTTP 403', 403), 'at')).toBeNull()
expect(oauthErrorPatchFor('not-an-error', 'at')).toBeNull()
})
it('truncates the message to the stored 500-char limit', () => {
const patch = oauthErrorPatchFor(new GoogleCalendarAuthError('x'.repeat(900)), 'at')
expect(patch?.oauthError).toHaveLength(500)
})
})
describe('readGoogleServiceAccountCredentials', () => {
const originalKey = process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV]
@ -103,6 +179,86 @@ describe('readGoogleServiceAccountCredentials', () => {
})
})
describe('readGoogleCalendarAuth (C149)', () => {
const original = {
sa: process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV],
id: process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV],
secret: process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV],
}
afterEach(() => {
const restore = (key: string, value: string | undefined) => {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
restore(GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV, original.sa)
restore(GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV, original.id)
restore(GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV, original.secret)
})
const setServiceAccountEnv = () => {
process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] = Buffer.from(
JSON.stringify({
client_email: 'teqo-sa@projeto.iam.gserviceaccount.com',
private_key: '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n',
}),
'utf8',
).toString('base64')
}
const setOAuthClientEnv = () => {
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV] = 'client-id'
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV] = 'client-secret'
}
const doc = (overrides: Record<string, unknown> = {}) =>
({ id: 1, ...overrides }) as unknown as GoogleCalendarSyncDoc
it('prefers the OAuth connection over the service account', () => {
setServiceAccountEnv()
setOAuthClientEnv()
const auth = readGoogleCalendarAuth(doc({ oauthRefreshToken: 'refresh-token' }))
expect(auth).toEqual({
kind: 'oauth',
credentials: {
clientId: 'client-id',
clientSecret: 'client-secret',
refreshToken: 'refresh-token',
},
})
})
it('falls back to the service account without a refresh token', () => {
setServiceAccountEnv()
setOAuthClientEnv()
const auth = readGoogleCalendarAuth(doc())
expect(auth?.kind).toBe('service-account')
})
it('falls back to the service account when the OAuth client env is missing', () => {
setServiceAccountEnv()
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV]
expect(readGoogleCalendarAuth(doc({ oauthRefreshToken: 'refresh-token' }))?.kind).toBe(
'service-account',
)
})
it('is null (fail-closed) without any credential', () => {
delete process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV]
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV]
delete process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV]
expect(readGoogleCalendarAuth(doc({ oauthRefreshToken: 'refresh-token' }))).toBeNull()
expect(readGoogleCalendarAuth(null)).toBeNull()
})
})
describe('shouldSyncActivityOperation', () => {
const activity = (overrides: Record<string, unknown> = {}) =>
({ id: 1, title: 'Caminhada', status: 'confirmado', ...overrides }) as unknown as Activity
@ -239,6 +395,20 @@ describe('shouldSyncConfigChange', () => {
}),
).toBe(true)
})
it('skips OAuth-connection writes — the callback runs its own pass (C149)', () => {
expect(
shouldSyncConfigChange({
operation: 'update',
doc: config({
oauthRefreshToken: 'refresh-token',
oauthConnectedAt: 'x',
oauthError: null,
}),
previousDoc: config({ oauthRefreshToken: null }),
}),
).toBe(false)
})
})
describe('sync deadlines (C114-LOCK)', () => {

View file

@ -1,4 +1,4 @@
import { cleanup, render, screen, within } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor, 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'
@ -9,6 +9,11 @@ const syncedState = (
overrides: Partial<GoogleCalendarSyncActionResult> = {},
): GoogleCalendarSyncActionResult => ({
ok: true,
canManageConnection: true,
connection: 'connected',
oauthAvailable: true,
oauthConnectedAt: '2026-08-01T12:00:00.000Z',
oauthError: null,
status: 'synced',
calendarId: 'c_campanha@group.calendar.google.com',
lastSyncedAt: '2026-08-11T12:00:00.000Z',
@ -21,7 +26,15 @@ const syncedState = (
...overrides,
})
const renderDialog = (state: GoogleCalendarSyncActionResult) =>
const renderDialog = (
state: GoogleCalendarSyncActionResult,
overrides: {
onStartOAuth?: () => Promise<
{ ok: true; authorizeUrl: string } | { ok: false; message: string }
>
onDisconnect?: () => Promise<GoogleCalendarSyncActionResult>
} = {},
) =>
render(
<GoogleCalendarSyncDialog
open
@ -29,6 +42,8 @@ const renderDialog = (state: GoogleCalendarSyncActionResult) =>
state={state}
onSyncNow={vi.fn()}
onSetDisabled={vi.fn()}
onStartOAuth={overrides.onStartOAuth ?? vi.fn()}
onDisconnect={overrides.onDisconnect ?? vi.fn()}
/>,
)
@ -88,6 +103,11 @@ describe('GoogleCalendarSyncDialog — seção "Edições pelo Google" (C115)',
it('não mostra a seção no estado não configurado', () => {
renderDialog({
ok: true,
canManageConnection: true,
connection: 'not-configured',
oauthAvailable: true,
oauthConnectedAt: null,
oauthError: null,
status: 'not-configured',
calendarId: null,
lastSyncedAt: null,
@ -103,6 +123,72 @@ describe('GoogleCalendarSyncDialog — seção "Edições pelo Google" (C115)',
})
})
describe('GoogleCalendarSyncDialog — card de conexão OAuth (C149)', () => {
const notConfiguredState = (
overrides: Partial<GoogleCalendarSyncActionResult> = {},
): GoogleCalendarSyncActionResult => ({
ok: true,
canManageConnection: true,
connection: 'not-configured',
oauthAvailable: true,
oauthConnectedAt: null,
oauthError: null,
status: 'not-configured',
calendarId: null,
lastSyncedAt: null,
lastSuccessAt: null,
lastErrorAt: null,
lastError: null,
pushChannelExpiresAt: null,
pushChannelError: null,
addLink: null,
...overrides,
})
it('offers the connect button when the OAuth client is configured', () => {
renderDialog(notConfiguredState())
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
expect(within(dialog).getByText('Não configurado')).toBeTruthy()
expect(within(dialog).getByRole('button', { name: /Conectar com o Google/ })).toBeTruthy()
// The service account remains documented as the technical fallback.
expect(within(dialog).getByText(/service account continua disponível/)).toBeTruthy()
})
it('shows the handshake error with the one-click reconnect path', () => {
renderDialog(
syncedState({
connection: 'error',
oauthError: 'A conexão com o Google expirou ou foi revogada.',
}),
)
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
expect(within(dialog).getByText('Erro')).toBeTruthy()
expect(within(dialog).getByText(/expirou ou foi revogada/)).toBeTruthy()
expect(within(dialog).getByRole('button', { name: /Reconectar com o Google/ })).toBeTruthy()
})
it('disconnects from the card when connected', async () => {
const onDisconnect = vi.fn(async () => notConfiguredState())
renderDialog(syncedState(), { onDisconnect })
fireEvent.click(screen.getByRole('button', { name: 'Desconectar' }))
await waitFor(() => expect(onDisconnect).toHaveBeenCalledTimes(1))
})
it('hides the connect/disconnect actions from non-managers (advisor)', () => {
renderDialog(notConfiguredState({ canManageConnection: false }))
expect(screen.queryByRole('button', { name: /Conectar com o Google/ })).toBeNull()
expect(screen.getByText(/Somente candidato ou coordenação/)).toBeTruthy()
})
it('falls back to the admin runbook when the server has no OAuth client', () => {
renderDialog(notConfiguredState({ oauthAvailable: false }))
expect(screen.getByText(/service account do Teqo/)).toBeTruthy()
expect(screen.queryByRole('button', { name: /Conectar com o Google/ })).toBeNull()
})
})
describe('ActivityUpdateFeed — atribuição do registro reverso (C115)', () => {
const renderFeed = (updates: Parameters<typeof ActivityUpdateFeed>[0]['updates']) =>
render(<ActivityUpdateFeed updates={updates} />)