feat(campaign): C150 — calendário principal escolhido pelo candidato/coordenador + inscrição de staff no Google (Closes #935)

This commit is contained in:
Francisco Solla 2026-09-12 20:29:37 -03:00
parent 677dbbf0ea
commit cc6d2abd04
18 changed files with 1574 additions and 56 deletions

View file

@ -0,0 +1,145 @@
# Impl: C150 — Agenda: calendário principal escolhido pelo candidato/coordenador + inscrição de staff no Google
Status: em execução
Atualizado em: 2026-09-12
Issue: #935
Intenção: docs/plans/c150-calendario-principal-e-inscricao-staff.md
Appetite restante: ~1,5–2 dias eng (herdado)
## Leitura da intenção
- **Outcome:** na própria agenda (`/campanha/agenda`), o candidato/coordenador lista os calendários da conta Google conectada (C149) e escolhe o calendário principal da campanha — create/edit passam a espelhar nele; um staff adiciona a agenda ao Google pessoal com um clique, sem copiar link nem "Por URL". O recorte filtrado segue no feed ICS atual.
- **O que NÃO negociar:** leader lockdown (nem vê as ações); Teqo segue SoT; um calendário principal só (nada de N espelhos); motor C114/C115 não muda de modelo; falha do Google não derruba a agenda (estado do sync visível); admin do Payload continua como escape hatch; o fluxo do recorte ICS (`CalendarFeedDialog`) fica intocado; OAuth por pessoa e backfill do calendário antigo são anti-goals.
- **O que reavaliar (divergências registradas no plano):**
- A "Direção no codebase" sugeria abrir `canSetGoogleCalendarSyncConfigField` a candidato/coordenador. **Rejeitado** (D2): a escrita passa por action de manager + utility system write com validação "id pertence à lista viva da conta conectada"; o campo segue admin-only.
- O bullet de e2e do explorador propõe interceptar `oauth2.googleapis.com/token` e `www.googleapis.com/calendar/v3/...` com `page.route`. **Impossível** (D7): `page.route` só vê o tráfego do browser; a listagem roda no processo Next (server action). O caminho feliz lista/escolhe fica no int com stub client; o e2e cobre os contratos do browser.
- O rascunho UI desenha o botão mobile full-width; o cluster do top bar não suporta isso — adaptado para ícone + `aria-label` (D6).
- A cena de "confirmação da inscrição" do rascunho não é implementável (o Google não chama de volta); fora de escopo (não escopo de engenharia).
## Abordagem recomendada
```mermaid
flowchart LR
subgraph UI["Agenda /campanha/agenda"]
CHROME["AgendaGoogleSyncChrome<br/>(dono dos 2 diálogos)"]
SYNC["GoogleCalendarSyncDialog<br/>card de conexão"]
PICKER["GoogleCalendarPickerDialog"]
ADD["Adicionar ao meu Google Calendar<br/>(âncora target=_blank no header)"]
end
CHROME --> SYNC
SYNC -->|"manager + connected:<br/>Escolher/Trocar calendário"| PICKER
PICKER -->|listGoogleCalendars| A1[server action]
PICKER -->|chooseGoogleCalendar| A2[server action]
A1 --> U1["listGoogleCalendarPickerOptions"]
A2 --> U2["setCampaignPrimaryCalendarId"]
U1 --> CL["GoogleCalendarClient.listCalendars<br/>GET /users/me/calendarList?minAccessRole=writer"]
U2 --> CL
U2 -->|"valida id na lista viva"| DB[("googleCalendarSync.calendarId<br/>system write overrideAccess")]
DB -->|"config hook D7"| ENG["runCampaignCalendarSync<br/>re-concilia no novo calendário"]
CHROME --> ADD
ADD -->|href| GOOG["calendar.google.com/calendar/r?cid=calendarId"]
U1 -. GoogleCalendarAuthError .-> ERR["recordGoogleCalendarOAuthError<br/>card deriva error/Reconectar"]
U2 -. GoogleCalendarAuthError .-> ERR
```
**Opções consideradas:** A) listagem no client + actions de manager com validação viva + picker como diálogo próprio + literal `cid=<calendarId>` | B) abrir `canSetGoogleCalendarSyncConfigField` a `isCampaignUnrestricted` (hipótese da intenção) | C) armazenar nome/summary do calendário e fazer o picker a partir do banco.
**Recomendação:** A — mantém o motor e o modelo de dados intocados, concentra a nova escrita num utility com bypass e validação server-side (mesmo padrão de `recordGoogleCalendarOAuthConnection`), e entrega os dois outcomes com um componente de diálogo novo e zero migration. B abriria escrita crua de id arbitrário pela superfície REST do `campaignUser` sem a validação "pertence à conta conectada"; C exige migration e sofre drift quando o calendário é renomeado no Google.
**Rejeitadas:** B porque o enforcement de papel não substitui a validação de pertencimento (e o admin deve seguir funcionando como escape hatch); C porque a lista é viva e o "em uso" deriva de `state.calendarId` sem persistir nada.
### Componentes / mudanças
**Server**
- **`GoogleCalendarClient.listCalendars`** (`src/utilities/googleCalendarClient.ts`): método novo na superfície existente (mesmo `apiFetch`, mesmo cache de token, mesmo retry de 401, mesmo timeout de 15s). `GET {base}/users/me/calendarList` com `minAccessRole=writer`, `showHidden=true`, `maxResults=250`, `fields=items(id,summary,primary,deleted),nextPageToken` e loop de `pageToken`; filtra `deleted`; `summary` cai para o `id` quando ausente. Tipo novo `GoogleCalendarListEntry = { id: string; summary: string; primary: boolean }`. `CALENDAR_LIST_PAGE_SIZE = 250`.
- **`listGoogleCalendarPickerOptions(payload, options?: { client?: GoogleCalendarClient })`** (`src/utilities/googleCalendarSync.ts`): carrega a row (`loadGoogleCalendarSyncConfig`), resolve `readGoogleCalendarAuth` e **exige `auth.kind === 'oauth'`**; cria o client (ou usa `options.client`) e devolve `{ ok: true; calendars } | { ok: false; reason: 'not-connected' | 'auth-error' | 'api-error'; message }`. Em `GoogleCalendarAuthError`, grava `recordGoogleCalendarOAuthError` e devolve `reason: 'auth-error'` com a mensagem do erro (já segura, pt-BR). `GoogleCalendarPickerOption = GoogleCalendarListEntry` (alias, sem twin de shape).
- **`setCampaignPrimaryCalendarId(payload, calendarId, options?)`** (mesmo arquivo): reusa `listGoogleCalendarPickerOptions` (inclusive o record de auth error), exige que `calendarId` esteja na lista viva (`not-listed` quando não está), no-op quando já é o atual, e escreve com `payload.update({ collection: 'googleCalendarSync', id, data: { calendarId }, depth: 0, overrideAccess: true })` — o hook `googleCalendarSyncConfigHook` (D7) dispara `runCampaignCalendarSync` no novo calendário. Nunca cria row (a row conectada existe; sem row → `not-connected`).
- **Actions** (`src/app/(campaign)/campanha/actions/googleCalendarSync.ts`, mesmo padrão manual try/catch — sem `runCampaignFormAction`/zod):
- `listGoogleCalendars(): Promise<GoogleCalendarListActionResult>` — `loadConnectionManager` (candidate/coordinator) + utility; falha de papel devolve `{ ok: false, message: GOOGLE_CALENDAR_PRIMARY_MANAGER_ONLY_MESSAGE }`.
- `chooseGoogleCalendar(calendarId: string): Promise<GoogleCalendarSyncActionResult>` — `loadConnectionManager`; valida `typeof calendarId === 'string'` com tamanho sano; utility; sucesso devolve `{ ok: true, canManageConnection: true, ...withLinks(await readGoogleCalendarSyncView(payload)) }` (mesmo shape das outras actions); falha devolve `failure(message, true)`.
- Tipos/mensagens novos: `GoogleCalendarPickerOption`, `GoogleCalendarListActionResult = { ok: true; calendars } | { ok: false; message }`, `GOOGLE_CALENDAR_PRIMARY_MANAGER_ONLY_MESSAGE`, `GOOGLE_CALENDAR_PICKER_NOT_CONNECTED_MESSAGE`, `GOOGLE_CALENDAR_PICKER_NOT_LISTED_MESSAGE`, `GOOGLE_CALENDAR_PICKER_LIST_FAILED_MESSAGE`.
- **`buildGoogleCalendarAddLink`** (`src/lib/googleCalendarLink.ts`): `cid=${encodeURIComponent(calendarId)}` (literal da intenção); `buildGoogleCalendarWebcalUrl` e `buildGoogleCalendarPublicIcalUrl` permanecem exportados/intocados (Apple/Outlook). Atualizar o doc-comment do módulo (o `cid` não é mais a URL webcal).
- **Migration:** **nenhuma** — `payload-types.ts` intocado. Débito S3 (constraint singleton) adiado de novo (D3).
**UI (Impeccable B — encaixe na agenda; shell/dialog existentes)**
- **`GoogleCalendarPickerDialog.tsx`** (novo, `src/components/campaign/activity/`): Dialog desktop / Drawer mobile com o mesmo chrome do `GoogleCalendarSyncDialog` (header/scroll-body/footer). Props `{ open, onOpenChange, currentCalendarId, onListCalendars, onChooseCalendar }`. Estados `loading | error | empty | ready` com "Tentar de novo"; lista de opções com `summary`, marca "em uso" quando `id === currentCalendarId` e hint "principal da conta" quando `primary`; seleção local; rodapé "Cancelar" / "Escolher calendário" (desabilitado sem seleção, sem mudança ou busy).
- **`GoogleCalendarSyncDialog.tsx`**: prop nova `onOpenPicker: () => void`; no bloco `connected` (manager) botão "Escolher calendário principal" (ou "Trocar calendário principal" quando `state.calendarId`); non-manager vê a copy "O calendário principal da campanha é escolhido por candidato ou coordenação." (substitui "escolhido na configuração do Painel"); o `content` de `status === 'not-configured' && connection === 'connected'` passa a apontar para o botão (manager) / informar a escolha do núcleo (non-manager); bloco manual: input vira `buildGoogleCalendarPublicIcalUrl(state.calendarId)` (label "URL pública do calendário (Por URL, Apple Calendar e Outlook)") + âncora one-click "Adicionar ao meu Google Calendar" (`state.addLink`); instruções "Por URL" passam a casar com o input.
- **`AgendaGoogleSyncChrome.tsx`**: props novas `onListCalendars`, `onChooseCalendar`; estado `pickerOpen`; `openPicker` fecha o diálogo do espelho e abre o picker (overlays não empilham — ver D5), `onOpenChange(false)` do picker reabre o do espelho; `handleChooseCalendar` chama a action e `setState(result)` no sucesso; registra `SetCampaignHeaderAction id="google-calendar-add"` com a âncora one-click quando `state.addLink` existe (desktop com rótulo `hidden md:inline-flex`, mobile ícone `md:hidden` com `aria-label`/`title` "Adicionar ao meu Google Calendar"); pill e auto-retry intocados; FAB continua abrindo o diálogo do espelho.
- **`agenda/page.tsx`**: injeta as duas actions novas no chrome (`listGoogleCalendars`, `chooseGoogleCalendar`) junto das 5 atuais.
- **`scripts/lib/e2e-affected-manifest.mjs`**: adicionar `src/lib/googleCalendarLink.ts` e `src/app/(campaign)/campanha/actions/googleCalendarSync.ts` à entry C149 do domínio → `campaignAgendaGoogleSync`.
### Dados → forma
Não se aplica como apresentação de dado do Teqo (nenhuma leitura nova; a intenção adiou a forma). O único dado apresentado é a lista viva da conta Google, com shape mínimo `{ id, summary, primary }` e "em uso" derivado de `state.calendarId` — sem persistência. Forma escolhida: lista de opções (radio-like) com o calendário atual marcado; **rejeitadas:** select/combobox (esconde o contexto do "em uso" e o estado vazio), armazenar `summary` (D3), lista cacheada (D1).
## Decisões de engenharia (decisão + por quê + rejeitadas; só as caras de reverter)
1. **Listagem como método novo do client, OAuth-only na borda (D1).** `listCalendars()` entra na superfície do `GoogleCalendarClient` (transporte fino) e a exigência de OAuth fica no utility (`auth.kind !== 'oauth'` → `not-connected`). Params `minAccessRole=writer` + `showHidden=true` + paginação + filtro `deleted`. **Opções:** A) método no client + enforcement no utility | B) método restrito ao branch OAuth do tipo union | C) listar via service account. **Recomendação: A** — o client já é agnóstico de auth (SA/OAuth) e a autorização é concern do domínio; B bifurca o tipo sem ganho; C não enxerga os calendários compartilhados (a `calendarList` do SA só contém os dele). **Rejeitadas:** `showHidden=false` (o default — o calendário criado pela operação pode estar desmarcado no Google e sumir do picker, justamente o que o usuário quer escolher); cache da lista/summary na row (migration + drift, D3); `syncToken` incremental (complexidade sem ganho para uma lista de dezenas de itens).
2. **Escrita do `calendarId`: action de manager + utility system write com validação viva (D2).** `chooseGoogleCalendar` → `setCampaignPrimaryCalendarId` com `overrideAccess: true`, validando que o id está na lista viva (`minAccessRole=writer`) da conta conectada. **Opções:** A) action + utility com bypass | B) abrir `canSetGoogleCalendarSyncConfigField` a `isCampaignUnrestricted` (hipótese da intenção) | C) novo field access por papel. **Recomendação: A** — a superfície REST do `campaignUser` nunca escreve id arbitrário; a validação de pertencimento roda server-side e o write segue o padrão C149; o admin continua podendo configurar direto no Painel (escape hatch). **Rejeitadas:** B porque field access não valida valor nem roda o record de auth error (e abriria `calendarId` cru ao REST); C porque fragmenta o enforcement sem cobrir a validação. **Divergência da intenção registrada:** o outcome (escolher sem admin) é preservado; o caminho é outro.
3. **Sem migration; lista viva (D3).** Nenhum campo novo; o picker marca "em uso" por `state.calendarId`. **Opções:** A) não armazenar | B) `calendarSummary` na row. **Recomendação: A** — zero schema e zero drift. **Rejeitadas:** B (migration cosmética + summary desatualizado quando o calendário é renomeado no Google). **S3 (constraint singleton):** adiado de novo; o C150 não cria rows (só atualiza a row conectada), então o risco de twins segue teórico — gatilho de revisitação: qualquer caminho novo que crie row ou twins observados em prod; sem coluna natural de singleton, a constraint exigiria migration hand-written.
4. **Erro de auth na listagem grava o estado de conexão (D4).** `GoogleCalendarAuthError` → `recordGoogleCalendarOAuthError` + falha `reason: 'auth-error'`. **Opções:** A) gravar só `invalid_grant` | B) não gravar | C) gravar qualquer erro. **Recomendação: A** — mesma classificação do motor (C149 D4): `invalid_grant` é reconectável e o card deriva `error`/Reconectar; o picker é outro consumidor da mesma credencial. **Rejeitadas:** B (card diria "Conectado" enquanto o picker falha — estado desonesto); C (`invalid_client`/5xx não se consertam reconectando). Residual aceito: o badge do card atualiza no próximo read de view; a mensagem do picker já instrui "Reconecte a conta".
5. **Picker como diálogo próprio; chrome dono dos dois; sem empilhar overlays (D5).** Novo `GoogleCalendarPickerDialog` (Dialog/Drawer, mesmo chrome), aberto a partir do card de conexão para manager com `connection === 'connected'`. O chrome fecha o diálogo do espelho ao abrir o picker e o reabre quando o picker fecha — um overlay por vez (Radix/vaul aninhados têm scroll-lock/focus frágeis no mobile). **Opções:** A) diálogo próprio com troca de overlay | B) lista expansível dentro do `GoogleCalendarSyncDialog` | C) rota/página nova. **Recomendação: A** — o picker tem ciclo próprio (loading/erro/vazio/retry) e o diálogo do espelho já é denso; o chrome já é dono do estado (padrão C148). **Rejeitadas:** B (duas máquinas de estado num modal cheio); C (cerimônia e rota nova sem URL pública a proteger); empilhar os dois overlays (risco de focus/scroll no mobile).
6. **One-click com `cid=<calendarId>` + reconciliação do bloco manual (D6).** `buildGoogleCalendarAddLink` passa a `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(calendarId)}`; header ganha a âncora (desktop com rótulo, mobile ícone + `aria-label`); o input do diálogo passa a `buildGoogleCalendarPublicIcalUrl(calendarId)` (o "Por URL" e Apple/Outlook assinam a URL iCal pública) + âncora one-click dentro do diálogo; `CalendarFeedDialog` (recorte ICS) intocado. **Opções:** A) literal da intenção + input iCal público | B) manter webcal no `cid` | C) dois inputs. **Recomendação: A** — o fluxo de adicionar calendário do Google interpreta `cid` como id do calendário; as instruções "Por URL" atuais já estavam desalinhadas com o `cid` webcal (o input antigo não era a URL iCal pública); Apple/Outlook continuam servidos pela URL iCal. **Rejeitadas:** B (infiel ao literal); C (mais chrome que o menor ajuste); remover o bloco manual (quebraria Apple/Outlook e o "copie e envie"); um-clique para o recorte ICS (anti-goal da intenção).
7. **E2E sem interceptar fetch do servidor (D7 — emenda ao bullet do explorador).** `page.route` intercepta só o tráfego do browser; a listagem roda na server action (processo Next). **Opções:** A) e2e cobre contratos do browser (href do one-click, ausência para advisor, ícone mobile, botão do manager) + caminho feliz lista/escolhe no int com stub | B) env de base URL do Google para teste | C) semear refresh token real e chamar o Google. **Recomendação: A** — determinístico e sem rede (mesma doutrina C114/C149); o int prova a validação viva e o D7 com `listEvents(novoId)`. **Rejeitadas:** B (superfície de produção — base URL do Google por env — existindo só por teste); C (flake de rede/credencial dummy no CI).
8. **Seam de teste por injeção de client nas utilities (D8).** `listGoogleCalendarPickerOptions`/`setCampaignPrimaryCalendarId` aceitam `options.client?: GoogleCalendarClient` (precedente `runCampaignCalendarSync.options.client`); o int novo mocka `@/utilities/googleCalendarClient` (stub com `listCalendars`/`listEvents` gravando o `calendarId`) e `campaignActionContext`, serializado por `serializeGoogleCalendarSyncSpec()`. **Opções:** A) injeção de client | B) mock global de `fetch` | C) só o fake credential (falha no JWT). **Recomendação: A** — prova direta da rejeição de id fora da lista e da reconciliação no novo calendário; C não prova qual calendário foi usado. **Rejeitadas:** B (o action não injeta `fetchImpl`; mockar `fetch` global vaza para o resto do spec).
9. **Manifest cobre o literal e as actions (D9).** Adicionar `src/lib/googleCalendarLink.ts` e `src/app/(campaign)/campanha/actions/googleCalendarSync.ts` à entry C149 → `campaignAgendaGoogleSync`. **Rejeitada:** confiar no smoke fallback (o literal do link e as actions não acordariam o spec da agenda).
## Fases verificáveis
1. **Tracer/servidor (~50%, ≈0,8–1 dia)**
- `listCalendars` no client + unit em `tests/unit/googleCalendarClient.unit.spec.ts` (params `minAccessRole=writer`/`showHidden=true`, paginação por `nextPageToken`, filtro `deleted`, fallback do summary, retry de 401, `invalid_grant` → `GoogleCalendarAuthError`).
- Utilities `listGoogleCalendarPickerOptions`/`setCampaignPrimaryCalendarId` + actions `listGoogleCalendars`/`chooseGoogleCalendar` + tipos/mensagens.
- Int novo `tests/int/googleCalendarPrimaryCalendarAction.int.spec.ts` (mocks do client e do contexto + advisory lock): advisor recusado; lista para coordinator; `invalid_grant` grava `oauthErrorAt/oauthError`; id fora da lista rejeitado sem write; id listado escreve `calendarId` e o hook D7 reconcilia no novo (`listEvents` chamado com o novo id); no-op quando já é o atual.
- Gate: `pnpm gate:fast`.
2. **UI + links (~35%, ≈0,6 dia)**
- `buildGoogleCalendarAddLink` literal + pins (`tests/unit/googleCalendarLink.unit.spec.ts`, e2e do input).
- `GoogleCalendarPickerDialog` novo + `tests/unit/googleCalendarPickerDialog.unit.spec.tsx` (loading/erro/vazio/lista, "em uso", confirmar desabilitado sem mudança, escolha chama o callback).
- `GoogleCalendarSyncDialog` (botão/copy/iCal público/âncora) + `tests/unit/googleCalendarSyncDialog.unit.spec.tsx`; `AgendaGoogleSyncChrome` (estado dos dois diálogos + header add) + `tests/unit/agendaGoogleSyncChrome.unit.spec.tsx`; `page.tsx` injeta as actions.
- Gate: `pnpm gate:fast`.
3. **E2E + manifest + gates (~15%, ≈0,3 dia)**
- `tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts`: href do one-click com `cid=<calendarId>` e `target=_blank`; advisor sem o botão + copy; manager com "Trocar calendário principal"; mobile com o ícone (`aria-label`); input do diálogo com a URL iCal pública.
- Manifest (D9). Gate: `pnpm gate:fast`; `pnpm test:e2e:affected` (deve incluir `campaignAgendaGoogleSync`); `pnpm push`.
**Cortes se o appetite estourar (nesta ordem):** (1) hint "principal da conta" e polish do picker; (2) âncora one-click dentro do diálogo (mantém header + input iCal); (3) asserção mobile no e2e (unit cobre o ícone); (4) `fields` no request da listagem. **Nunca cortar:** a validação do id na lista viva (D2), o record do auth error (D4) e o int do D7.
## Rabbit holes / Não escopo (engenharia)
- **Backfill/limpeza do calendário abandonado:** trocar `calendarId` reusa a reconciliação D7 e o calendário antigo fica congelado (design C114 D7, provado em `tests/int/googleCalendarSync.int.spec.ts:396-418`); nenhum retro-cleanup.
- **Limpar `calendarId` no disconnect:** C149 intocado; gatilho de revisitação — suporte observar "reconectei outra conta e o espelho pausou"; a UI do picker já lida (nenhum item "em uso") e o manager reescolhe.
- **Estado de confirmação pós-one-click** ("Agenda adicionada"): o Google não chama de volta; a cena do rascunho não é implementável.
- **One-click para o recorte ICS / feed externo:** anti-goal (cid infiel); `CalendarFeedDialog` fica como está.
- **OAuth por pessoa, N calendários, calendário por assessor:** anti-goals C114/intenção.
- **Cache da lista/summary, `syncToken` incremental:** D1/D3.
- **Constraint singleton (S3):** adiada de novo (D3), com gatilho.
- **Env de base URL do Google para teste:** D7.
- **Mexer no motor C114/C115** (snapshot pino, canal push, janela, locks) e no `GoogleCalendarSyncDialog` além dos blocos descritos: fora.
- **Rate limiting na listagem:** manager-only e aberta por gesto; sem superfície nova de abuso.
## Riscos e mitigação
- **Listagem pode segurar o diálogo (token + N páginas, cada hop 15s):** o picker tem estado de loading e "Tentar de novo"; `REQUEST_TIMEOUT_MS` existente limita cada hop; ação de manager, sem caminho crítico da agenda.
- **Calendário some entre a listagem e a escolha:** `setCampaignPrimaryCalendarId` re-lista e rejeita (`not-listed`); a mensagem pede atualizar a lista.
- **`invalid_grant` no meio do fluxo:** a listagem grava o erro de conexão; o picker mostra a mensagem e o card deriva `error`/Reconectar no próximo read (residual documentado em D4).
- **Crowding do header desktop** (pill + rótulo longo + import + IA + sino): rótulo com `shrink-0`/`whitespace-nowrap`; encurtar a copy ou esconder o rótulo em `md` (cheap polish) se a inspeção visual acusar.
- **Overlays empilhados no mobile:** evitado por desenho (D5) — o picker substitui o diálogo do espelho enquanto aberto.
- **E2E da agenda é serial e compartilha a row singleton:** os seeds novos usam `lastSuccessAt` futuro (imune a `lastErrorAt` de specs paralelas) e nunca disparam action contra o Google; sem refresh token real nos seeds que abrem o picker (o botão não é clicado no e2e).
- **Sem rede no e2e:** o caminho feliz lista/escolhe é int com stub; a prova de produção fica no smoke manual pós-deploy (mesma doutrina C149).
- **`buildGoogleCalendarPublicIcalUrl` passa a ser usado** (era export não consumido em `src`): sem impacto; knip já tolerava.
- **`unmapped-risk` no CI:** as mudanças tocam `src/utilities/access`? Não — o access fica intocado (D2); o manifest entra no mesmo PR.
## Qualidade da decisão
**Auto-avaliação: 4/5** — todas as decisões caras (listagem, escrita do `calendarId`, schema, auth error, UI, literal do link, estratégia de teste) têm recomendação e rejeitadas ancoradas em achados concretos (union de auth C149, precedente de system write, hook D7, pins de teste, manifest); o appetite cabe (~1,7–1,9 dia) e o depth check reusa client/utility/action/dialog/hook existentes, criando só o componente de picker (ciclo de estados próprio). Perde 1 ponto porque o caminho feliz da listagem real (shape do Google, `showHidden`, paginação) só é provado por unit/int com stub — sem e2e de rede (D7) —, então a verificação contra o Google real fica no smoke pós-deploy.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: candidato/coordenador escolhe o calendário principal na agenda; create/edit espelham nele; staff adiciona a agenda ao Google com um clique; recorte ICS intocado; admin do Payload segue escape hatch.
- [ ] Invariantes AGENTS/engineering-standards: `leader` sem agenda; escrita system com `overrideAccess` justificado e papel checado na action; identificadores em inglês, copy pt-BR; sem migration (nenhuma antiga editada).
- [ ] `calendarId` só é escrito após validação de que o id está na lista viva da conta conectada (`minAccessRole=writer`); `canSetGoogleCalendarSyncConfigField` segue admin-only.
- [ ] Trocar o calendário dispara a reconciliação D7 no novo calendário (int prova `listEvents(novoId)`); nenhum backfill/limpeza no calendário antigo.
- [ ] `GoogleCalendarAuthError` na listagem/escolha grava `oauthErrorAt/oauthError` e o card deriva `error`/Reconectar; `invalid_client`/5xx não sujam a conexão.
- [ ] `buildGoogleCalendarAddLink` = `cid=<calendarId URL-encoded>`; âncora de header (desktop com rótulo, mobile ícone + `aria-label`) visível só com `addLink`; diálogo mantém URL iCal pública para "Por URL"/Apple/Outlook.
- [ ] Testes previstos verdes: unit do client/link/picker/sync dialog/chrome; int novo das actions (papel, id fora da lista, write + D7); e2e `campaignAgendaGoogleSync` estendido (href, advisor, manager, mobile).
- [ ] `pnpm gate:fast` verde; `pnpm test:e2e:affected` inclui `campaignAgendaGoogleSync` (manifest cobre `googleCalendarLink.ts` e `actions/googleCalendarSync.ts`) e passa local; `pnpm push`.

View file

@ -116,14 +116,19 @@ export const E2E_AFFECTED_MANIFEST = [
// 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.
// C150 — the one-click link builder and the campaign actions (picker +
// choose) join the same domain entry.
prefixes: [
'src/utilities/googleCalendarSync.ts',
'src/utilities/googleCalendarSyncHooks.ts',
'src/utilities/googleCalendarClient.ts',
'src/utilities/googleCalendarOAuth.ts',
'src/lib/googleCalendarOAuth.ts',
'src/lib/googleCalendarLink.ts',
'src/components/campaign/activity/AgendaGoogleSyncChrome',
'src/components/campaign/activity/GoogleCalendarSyncDialog',
'src/components/campaign/activity/GoogleCalendarPickerDialog',
'src/app/(campaign)/campanha/actions/googleCalendarSync.ts',
'src/app/(campaign)/campanha/agenda/google-oauth',
],
specs: ['campaignAgendaGoogleSync'],

View file

@ -8,8 +8,10 @@ import {
revokeCalendarFeed,
} from '@/app/(campaign)/campanha/actions/calendarFeed'
import {
chooseGoogleCalendar,
disconnectGoogleCalendarOAuth,
getGoogleCalendarSyncState,
listGoogleCalendars,
runGoogleCalendarSyncNow,
setGoogleCalendarSyncDisabled,
startGoogleCalendarOAuth,
@ -112,6 +114,14 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
'use server'
return disconnectGoogleCalendarOAuth()
}}
onListCalendars={async () => {
'use server'
return listGoogleCalendars()
}}
onChooseCalendar={async (calendarId) => {
'use server'
return chooseGoogleCalendar(calendarId)
}}
/>
<CampaignListPendingBoundary>

View file

@ -20,9 +20,12 @@ import {
} from '@/utilities/googleCalendarOAuth'
import {
clearGoogleCalendarOAuthConnection,
listGoogleCalendarPickerOptions,
loadGoogleCalendarSyncConfig,
readGoogleCalendarSyncView,
runCampaignCalendarSync,
setCampaignPrimaryCalendarId,
type GoogleCalendarPickerOption,
type GoogleCalendarSyncView,
} from '@/utilities/googleCalendarSync'
@ -34,6 +37,9 @@ 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.'
const GOOGLE_CALENDAR_PRIMARY_MANAGER_ONLY_MESSAGE =
'Apenas candidato ou coordenação pode escolher o calendário principal da campanha.'
const GOOGLE_CALENDAR_PRIMARY_INVALID_MESSAGE = 'Calendário inválido.'
export type GoogleCalendarSyncActionResult = {
ok: boolean
@ -42,6 +48,10 @@ export type GoogleCalendarSyncActionResult = {
canManageConnection: boolean
} & GoogleCalendarSyncView & { addLink: string | null }
export type GoogleCalendarListActionResult =
| { ok: true; calendars: GoogleCalendarPickerOption[] }
| { ok: false; message: string }
export type GoogleCalendarOAuthStartResult =
| { ok: true; authorizeUrl: string }
| { ok: false; message: string }
@ -156,6 +166,57 @@ export const setGoogleCalendarSyncDisabled = async (
}
}
/**
* C150 — lists the calendars of the OAuth-connected account for the primary
* calendar picker. Candidate/coordinator only (the picker is a nucleus
* decision); a Google failure maps to a safe message and never throws.
*/
export const listGoogleCalendars = async (): Promise<GoogleCalendarListActionResult> => {
const { payload, actor } = await getCampaignActionContext()
try {
if (!(await loadConnectionManager(payload, actor))) {
return { ok: false, message: GOOGLE_CALENDAR_PRIMARY_MANAGER_ONLY_MESSAGE }
}
const result = await listGoogleCalendarPickerOptions(payload)
if (!result.ok) return { ok: false, message: result.message }
return { ok: true, calendars: result.calendars }
} catch (error) {
console.error('listGoogleCalendars failed', error)
return { ok: false, message: GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE }
}
}
/**
* C150 — sets the campaign's primary calendar from the picker. The utility
* validates the id against the connected account's live writable list and the
* `afterChange` config hook (D7) reconciles the mirror into the new calendar.
*/
export const chooseGoogleCalendar = async (
calendarId: string,
): Promise<GoogleCalendarSyncActionResult> => {
const { payload, actor } = await getCampaignActionContext()
try {
const currentActor = await loadConnectionManager(payload, actor)
if (!currentActor) return failure(GOOGLE_CALENDAR_PRIMARY_MANAGER_ONLY_MESSAGE, false)
if (typeof calendarId !== 'string' || calendarId.length === 0 || calendarId.length > 1024) {
return failure(GOOGLE_CALENDAR_PRIMARY_INVALID_MESSAGE, true)
}
const result = await setCampaignPrimaryCalendarId(payload, calendarId)
if (!result.ok) return failure(result.message, true)
return {
ok: true,
canManageConnection: true,
...withLinks(await readGoogleCalendarSyncView(payload)),
}
} catch (error) {
console.error('chooseGoogleCalendar failed', error)
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

View file

@ -2,13 +2,18 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { CalendarPlusIcon } from 'lucide-react'
import type {
GoogleCalendarListActionResult,
GoogleCalendarOAuthStartResult,
GoogleCalendarSyncActionResult,
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
import { GoogleCalendarPickerDialog } from '@/components/campaign/activity/GoogleCalendarPickerDialog'
import { GoogleCalendarSyncDialog } from '@/components/campaign/activity/GoogleCalendarSyncDialog'
import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext'
import { useBridgedQuickAction } from '@/components/campaign/shell/CampaignQuickActionContext'
import { Button } from '@/components/ui/button'
import type { GoogleCalendarSyncStatus } from '@/utilities/googleCalendarSync'
type AgendaGoogleSyncChromeProps = {
@ -19,6 +24,10 @@ type AgendaGoogleSyncChromeProps = {
onStartOAuth: () => Promise<GoogleCalendarOAuthStartResult>
/** C149 — drops the OAuth connection from the Teqo. */
onDisconnect: () => Promise<GoogleCalendarSyncActionResult>
/** C150 — lists the connected account's writable calendars (picker). */
onListCalendars: () => Promise<GoogleCalendarListActionResult>
/** C150 — sets the campaign's primary calendar. */
onChooseCalendar: (calendarId: string) => Promise<GoogleCalendarSyncActionResult>
}
const PILL_COPY: Record<
@ -47,11 +56,18 @@ const PILL_COPY: Record<
},
}
const ADD_TO_GOOGLE_LABEL = 'Adicionar ao meu Google Calendar'
/**
* C114 — agenda page → app chrome bridge for the Google Calendar mirror
* (same pattern as AgendaFeedChrome/C94): the status pill registers in the
* desktop header cluster (after "Link de import"), the mobile FAB opens the
* same dialog, and a paused mirror auto-retries once on mount.
*
* C150 — the chrome also owns the primary-calendar picker (one overlay at a
* time: opening the picker closes the mirror dialog and closing it brings the
* dialog back) and registers the one-click "add to my Google Calendar" link
* for every staff member as soon as a primary calendar exists.
*/
export const AgendaGoogleSyncChrome = ({
initialState,
@ -59,8 +75,11 @@ export const AgendaGoogleSyncChrome = ({
onSetDisabled,
onStartOAuth,
onDisconnect,
onListCalendars,
onChooseCalendar,
}: AgendaGoogleSyncChromeProps) => {
const [open, setOpen] = useState(false)
const [pickerOpen, setPickerOpen] = useState(false)
const [state, setState] = useState(initialState)
const autoRetriedRef = useRef(false)
@ -99,6 +118,27 @@ export const AgendaGoogleSyncChrome = ({
return result
}, [onDisconnect])
// C150 — one overlay at a time: the picker replaces the mirror dialog while
// open (nested Radix/vaul overlays have fragile focus/scroll on mobile).
const openPicker = useCallback(() => {
setOpen(false)
setPickerOpen(true)
}, [])
const handlePickerOpenChange = useCallback((next: boolean) => {
setPickerOpen(next)
if (!next) setOpen(true)
}, [])
const handleChooseCalendar = useCallback(
async (calendarId: string) => {
const result = await onChooseCalendar(calendarId)
if (result.ok) setState(result)
return result
},
[onChooseCalendar],
)
const pill = useMemo(() => {
const copy = PILL_COPY[state.status]
return (
@ -115,9 +155,41 @@ export const AgendaGoogleSyncChrome = ({
)
}, [state.status, openSync])
const addToGoogle = useMemo(() => {
if (!state.addLink) return null
return (
<>
<Button asChild size="sm" className="hidden shrink-0 whitespace-nowrap md:inline-flex">
<a
href={state.addLink}
target="_blank"
rel="noopener noreferrer"
aria-label={ADD_TO_GOOGLE_LABEL}
>
<CalendarPlusIcon className="mr-2 size-4" aria-hidden />
{ADD_TO_GOOGLE_LABEL}
</a>
</Button>
<Button
asChild
variant="ghost"
size="icon"
className="size-11 shrink-0 md:hidden"
aria-label={ADD_TO_GOOGLE_LABEL}
title={ADD_TO_GOOGLE_LABEL}
>
<a href={state.addLink} target="_blank" rel="noopener noreferrer">
<CalendarPlusIcon className="size-5" aria-hidden />
</a>
</Button>
</>
)
}, [state.addLink])
return (
<>
<SetCampaignHeaderAction id="google-calendar-sync">{pill}</SetCampaignHeaderAction>
<SetCampaignHeaderAction id="google-calendar-add">{addToGoogle}</SetCampaignHeaderAction>
<GoogleCalendarSyncDialog
open={open}
@ -127,6 +199,15 @@ export const AgendaGoogleSyncChrome = ({
onSetDisabled={handleSetDisabled}
onStartOAuth={onStartOAuth}
onDisconnect={handleDisconnect}
onOpenPicker={openPicker}
/>
<GoogleCalendarPickerDialog
open={pickerOpen}
onOpenChange={handlePickerOpenChange}
currentCalendarId={state.calendarId}
onListCalendars={onListCalendars}
onChooseCalendar={handleChooseCalendar}
/>
</>
)

View file

@ -0,0 +1,252 @@
'use client'
import { useEffect, useState } from 'react'
import { RefreshCwIcon } from 'lucide-react'
import type {
GoogleCalendarListActionResult,
GoogleCalendarSyncActionResult,
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Drawer,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/Drawer'
import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils'
import type { GoogleCalendarPickerOption } from '@/utilities/googleCalendarSync'
type GoogleCalendarPickerDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
/** The calendar currently in use, marked and preselected. */
currentCalendarId: string | null
onListCalendars: () => Promise<GoogleCalendarListActionResult>
onChooseCalendar: (calendarId: string) => Promise<GoogleCalendarSyncActionResult>
}
type PickerListStatus = 'loading' | 'error' | 'ready'
const TITLE = 'Calendário principal da campanha'
const DESCRIPTION = 'Criar e editar atividades espelham no calendário escolhido.'
/**
* C150 — primary-calendar picker. The list is fetched live from the connected
* Google account on every open (the account's calendars can change under us);
* only candidate/coordination reach this dialog (the trigger is gated in the
* sync dialog) and the server action re-validates both the role and the
* calendar membership before writing.
*/
export const GoogleCalendarPickerDialog = ({
open,
onOpenChange,
currentCalendarId,
onListCalendars,
onChooseCalendar,
}: GoogleCalendarPickerDialogProps) => {
const isMobile = useIsMobile()
const [status, setStatus] = useState<PickerListStatus>('loading')
const [calendars, setCalendars] = useState<GoogleCalendarPickerOption[]>([])
const [listError, setListError] = useState<string | null>(null)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [chooseError, setChooseError] = useState<string | null>(null)
const [isChoosing, setIsChoosing] = useState(false)
const [attempt, setAttempt] = useState(0)
useEffect(() => {
if (!open) return
let cancelled = false
setStatus('loading')
setListError(null)
setChooseError(null)
void onListCalendars().then((result) => {
if (cancelled) return
if (result.ok) {
setCalendars(result.calendars)
setSelectedId(currentCalendarId)
setStatus('ready')
} else {
setListError(result.message)
setStatus('error')
}
})
return () => {
cancelled = true
}
}, [open, attempt, onListCalendars, currentCalendarId])
const handleChoose = async () => {
if (!selectedId || selectedId === currentCalendarId || isChoosing) return
setIsChoosing(true)
setChooseError(null)
try {
const result = await onChooseCalendar(selectedId)
if (result.ok) {
onOpenChange(false)
} else {
setChooseError(result.message ?? 'Não foi possível escolher o calendário.')
}
} finally {
setIsChoosing(false)
}
}
const listContent =
status === 'loading' ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground" aria-live="polite">
<RefreshCwIcon className="h-4 w-4 animate-spin" aria-hidden />
Carregando calendários...
</p>
) : status === 'error' ? (
<div className="space-y-3">
<p role="alert" className="text-sm text-red-600">
{listError}
</p>
<Button type="button" variant="outline" onClick={() => setAttempt((value) => value + 1)}>
Tentar de novo
</Button>
</div>
) : calendars.length === 0 ? (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Nenhum calendário com permissão de edição nesta conta Google.
</p>
<Button type="button" variant="outline" onClick={() => setAttempt((value) => value + 1)}>
Atualizar lista
</Button>
</div>
) : (
<ul
role="radiogroup"
aria-label="Calendários da conta Google conectada"
className="space-y-2"
>
{calendars.map((calendar) => {
const isSelected = selectedId === calendar.id
const isCurrent = currentCalendarId === calendar.id
return (
<li key={calendar.id}>
<button
type="button"
role="radio"
aria-checked={isSelected}
onClick={() => setSelectedId(calendar.id)}
className={cn(
'flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring',
isSelected ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted',
)}
>
<span
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full border',
isSelected ? 'border-primary' : 'border-muted-foreground',
)}
aria-hidden
>
{isSelected ? <span className="h-2 w-2 rounded-full bg-primary" /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{calendar.summary}</span>
{calendar.primary ? (
<span className="block text-xs text-muted-foreground">
Calendário principal da conta
</span>
) : null}
</span>
{isCurrent ? (
<span className="shrink-0 text-xs text-muted-foreground">em uso</span>
) : null}
</button>
</li>
)
})}
</ul>
)
const chooseErrorNotice = chooseError ? (
<p role="alert" className="text-sm text-red-600">
{chooseError}
</p>
) : null
const actions =
status === 'ready' && calendars.length > 0 ? (
<div className="flex flex-col gap-3">
{chooseErrorNotice}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isChoosing}
>
Cancelar
</Button>
<Button
type="button"
onClick={() => void handleChoose()}
disabled={!selectedId || selectedId === currentCalendarId || isChoosing}
>
{isChoosing ? 'Escolhendo...' : 'Escolher calendário'}
</Button>
</div>
</div>
) : null
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange} showSwipeHandle>
<DrawerContent className="max-h-[85vh]">
<DrawerHeader className="text-left">
<DrawerTitle>{TITLE}</DrawerTitle>
<DrawerDescription>{DESCRIPTION}</DrawerDescription>
</DrawerHeader>
<div className="overflow-y-auto px-4 pb-2">
<div className="space-y-4">
{listContent}
{actions}
</div>
</div>
<DrawerFooter className="border-t">
<DrawerCloseButton className="w-full">Fechar</DrawerCloseButton>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-lg sm:p-0">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
<DialogTitle>{TITLE}</DialogTitle>
<DialogDescription>{DESCRIPTION}</DialogDescription>
</DialogHeader>
<div
data-slot="dialog-scroll-body"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4"
>
{listContent}
</div>
{actions ? (
<div data-slot="dialog-footer" className="shrink-0 border-t px-6 py-4">
{actions}
</div>
) : null}
</DialogContent>
</Dialog>
)
}

View file

@ -28,6 +28,7 @@ import {
import { Input } from '@/components/ui/input'
import { useIsMobile } from '@/hooks/use-mobile'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import { buildGoogleCalendarPublicIcalUrl } from '@/lib/googleCalendarLink'
import type { GoogleCalendarConnectionStatus } from '@/utilities/googleCalendarSync'
type GoogleCalendarSyncDialogProps = {
@ -40,6 +41,8 @@ type GoogleCalendarSyncDialogProps = {
onStartOAuth: () => Promise<GoogleCalendarOAuthStartResult>
/** C149 — drops the OAuth connection from the Teqo. */
onDisconnect: () => Promise<GoogleCalendarSyncActionResult>
/** C150 — opens the primary-calendar picker (candidate/coordination only). */
onOpenPicker: () => void
}
const NOT_CONFIGURED_RUNBOOK: readonly string[] = [
@ -85,6 +88,7 @@ export const GoogleCalendarSyncDialog = ({
onSetDisabled,
onStartOAuth,
onDisconnect,
onOpenPicker,
}: GoogleCalendarSyncDialogProps) => {
const isMobile = useIsMobile()
const [isBusy, setIsBusy] = useState(false)
@ -96,9 +100,13 @@ export const GoogleCalendarSyncDialog = ({
setActionError(null)
}
// C150 — the manual path ("Por URL", Apple Calendar, Outlook) consumes the
// public iCal URL; the one-click button consumes `state.addLink`.
const icalUrl = state.calendarId ? buildGoogleCalendarPublicIcalUrl(state.calendarId) : null
const handleCopy = async () => {
if (!state.addLink) return
await navigator.clipboard.writeText(state.addLink)
if (!icalUrl) return
await navigator.clipboard.writeText(icalUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
@ -135,36 +143,51 @@ export const GoogleCalendarSyncDialog = ({
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">
Link do calendário (copie e envie à equipe)
</label>
<div className="flex gap-2">
<Input
id="google-calendar-link"
value={state.addLink}
readOnly
className="font-mono text-xs"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopy}
aria-label="Copiar link"
>
{copyIcon}
const linkBlock = state.calendarId ? (
<div className="space-y-3">
<div className="space-y-2">
<p className="text-sm font-medium">Adicionar ao meu Google Calendar</p>
<Button asChild className="w-full sm:w-auto">
<a href={state.addLink ?? undefined} target="_blank" rel="noopener noreferrer">
<ExternalLinkIcon className="mr-2 h-4 w-4" />
Adicionar ao meu Google Calendar
</a>
</Button>
</div>
{copied && (
<p className="text-xs text-green-600" aria-live="polite">
Link copiado!
<p className="text-xs text-muted-foreground">
O Google abre com o calendário da campanha pronto para assinar — vale para o computador e
o celular.
</p>
)}
<p className="text-xs text-muted-foreground">
Quem segue o calendário recebe aviso conforme as próprias configurações do Google.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="google-calendar-link">
URL pública do calendário (Por URL, Apple Calendar e Outlook)
</label>
<div className="flex gap-2">
<Input
id="google-calendar-link"
value={icalUrl ?? ''}
readOnly
className="font-mono text-xs"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopy}
aria-label="Copiar link"
>
{copyIcon}
</Button>
</div>
{copied && (
<p className="text-xs text-green-600" aria-live="polite">
Link copiado!
</p>
)}
<p className="text-xs text-muted-foreground">
Quem segue o calendário recebe aviso conforme as próprias configurações do Google.
</p>
</div>
</div>
) : null
@ -330,6 +353,38 @@ export const GoogleCalendarSyncDialog = ({
? `Conta Google conectada em ${formatBahiaDateTimeLabel(state.oauthConnectedAt)}.`
: 'Conta Google conectada.'}
</p>
<div className="rounded-md border border-dashed border-border p-4">
<p className="text-sm font-medium">Calendário principal da campanha</p>
{state.canManageConnection ? (
<>
<p className="mt-1 text-sm text-muted-foreground">
{state.calendarId
? 'O espelho escreve neste calendário:'
: 'Nenhum calendário escolhido ainda — o espelho fica parado até a escolha.'}
</p>
{state.calendarId ? (
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
{state.calendarId}
</p>
) : null}
<Button
type="button"
variant="outline"
className="mt-3"
onClick={onOpenPicker}
disabled={isBusy}
>
{state.calendarId
? 'Trocar calendário principal'
: 'Escolher calendário principal'}
</Button>
</>
) : (
<p className="mt-1 text-sm text-muted-foreground">
O calendário principal é escolhido por candidato ou coordenação.
</p>
)}
</div>
{connectionActions}
<p className="text-xs text-muted-foreground">
Desconectar revoga o acesso do Teqo; depois, revogue o app nas configurações de
@ -364,7 +419,9 @@ export const GoogleCalendarSyncDialog = ({
content =
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.
{state.canManageConnection
? 'Escolha o calendário principal para a agenda começar a espelhar no Google.'
: 'O calendário principal da campanha é escolhido por candidato ou coordenação.'}
</p>
) : null
} else if (state.status === 'disabled') {

View file

@ -1,11 +1,13 @@
/**
* C114 — public "add this calendar" links for the campaign's shared Google
* calendar. The calendar itself is created in the campaign's Google account
* (never by a service account — Google's recommendation) and shared publicly
* by link (ops runbook); these URLs are what the team receives:
* C114/C150 — public "add this calendar" links for the campaign's shared
* Google calendar. The calendar itself is created in the campaign's Google
* account (never by a service account — Google's recommendation) and shared
* publicly by link (ops runbook); these URLs are what the team receives:
*
* - Google Calendar: the `cid` webcal URL (the established add-by-URL flow the
* iCal feed dialog already uses, `calendar/r/settings/addbyurl`).
* - Google Calendar: the `cid` calendarId (C150) — Google opens the calendar
* ready to subscribe, no "add by URL" step. The previous `cid` webcal URL
* was the manual flow; the raw webcal/public iCal URL stays for the other
* apps.
* - Apple Calendar / Outlook: subscribe straight to the webcal URL the Google
* calendar exposes for public calendars.
*
@ -18,11 +20,10 @@ const GOOGLE_CALENDAR_ICAL_BASE = 'https://calendar.google.com/calendar/ical'
export const buildGoogleCalendarWebcalUrl = (calendarId: string): string =>
`webcal://calendar.google.com/calendar/ical/${encodeURIComponent(calendarId)}/public/basic.ics`
export const buildGoogleCalendarAddLink = (calendarId: string): string => {
const webcal = buildGoogleCalendarWebcalUrl(calendarId)
return `${GOOGLE_CALENDAR_ADD_BASE}?cid=${encodeURIComponent(webcal)}`
}
/** C150 — the one-click add link: `cid` carries the calendar id itself. */
export const buildGoogleCalendarAddLink = (calendarId: string): string =>
`${GOOGLE_CALENDAR_ADD_BASE}?cid=${encodeURIComponent(calendarId)}`
/** The public iCal URL of the shared calendar — same `cid` target, plain HTTPS. */
/** The public iCal URL of the shared calendar — the manual "add by URL" target. */
export const buildGoogleCalendarPublicIcalUrl = (calendarId: string): string =>
`${GOOGLE_CALENDAR_ICAL_BASE}/${encodeURIComponent(calendarId)}/public/basic.ics`

View file

@ -21,6 +21,8 @@ const GOOGLE_CALENDAR_API_BASE = 'https://www.googleapis.com/calendar/v3'
const GOOGLE_TOKEN_TTL_SECONDS = 3600
const TOKEN_REFRESH_LEAD_SECONDS = 60
const MAX_LIST_PAGE_SIZE = 2500
/** calendarList caps `maxResults` at 250 (unlike events). */
const CALENDAR_LIST_PAGE_SIZE = 250
/**
* Every outbound call carries a hard timeout so a hanging Google never holds
* the activity write path (the afterChange hook awaits the sync) — Teqo's
@ -76,7 +78,23 @@ export class GoogleCalendarAuthError extends Error {
}
}
/**
* C150 — one entry of the connected account's calendar list, reduced to what
* the picker renders. `summary` falls back to the id when Google omits it.
*/
export type GoogleCalendarListEntry = {
id: string
summary: string
primary: boolean
}
export type GoogleCalendarClient = {
/**
* C150 — the calendars the connected account can WRITE to (`minAccessRole=writer`),
* for the campaign's primary-calendar picker. The mirror only works on a
* calendar the account owns/edits, so read-only subscriptions never show up.
*/
listCalendars: () => Promise<GoogleCalendarListEntry[]>
listEvents: (
calendarId: string,
range: { timeMin: string; timeMax: string },
@ -261,6 +279,42 @@ export const createGoogleCalendarClient = (
.map((part) => `/${encodeURIComponent(part)}`)
.join('')}`
const listCalendars: GoogleCalendarClient['listCalendars'] = async () => {
const calendars: GoogleCalendarListEntry[] = []
let pageToken: string | undefined
do {
const params = new URLSearchParams({
minAccessRole: 'writer',
// A calendar the operation hid in the Google sidebar is still a valid
// mirror target — hiding is a UI preference, not a permission.
showHidden: 'true',
maxResults: String(CALENDAR_LIST_PAGE_SIZE),
fields: 'items(id,summary,primary,deleted),nextPageToken',
})
if (pageToken) params.set('pageToken', pageToken)
const response = await apiFetch(`${GOOGLE_CALENDAR_API_BASE}/users/me/calendarList?${params}`)
const body = (await response.json()) as {
items?: Array<{ id?: unknown; summary?: unknown; primary?: unknown; deleted?: unknown }>
nextPageToken?: string
}
for (const item of body.items ?? []) {
// Deleted calendars linger in the list until Google purges them.
if (typeof item.id !== 'string' || item.id.length === 0 || item.deleted === true) continue
calendars.push({
id: item.id,
summary:
typeof item.summary === 'string' && item.summary.length > 0 ? item.summary : item.id,
primary: item.primary === true,
})
}
pageToken = body.nextPageToken
} while (pageToken)
return calendars
}
const listEvents: GoogleCalendarClient['listEvents'] = async (calendarId, range) => {
const events: GoogleRemoteEvent[] = []
let pageToken: string | undefined
@ -357,5 +411,13 @@ export const createGoogleCalendarClient = (
}
}
return { listEvents, insertEvent, updateEvent, deleteEvent, watchEvents, stopChannel }
return {
listCalendars,
listEvents,
insertEvent,
updateEvent,
deleteEvent,
watchEvents,
stopChannel,
}
}

View file

@ -35,6 +35,7 @@ import {
GoogleCalendarAuthError,
type GoogleCalendarAuth,
type GoogleCalendarClient,
type GoogleCalendarListEntry,
type GoogleCalendarServiceAccountCredentials,
} from '@/utilities/googleCalendarClient'
import {
@ -896,6 +897,114 @@ export const clearGoogleCalendarOAuthConnection = async (payload: Payload): Prom
})
}
/** C150 — one calendar option of the primary-calendar picker. */
export type GoogleCalendarPickerOption = GoogleCalendarListEntry
export type GoogleCalendarPickerListResult =
| { ok: true; calendars: GoogleCalendarPickerOption[] }
| { ok: false; reason: 'not-connected' | 'auth-error' | 'api-error'; message: string }
export type GoogleCalendarPrimaryCalendarResult =
| { ok: true; calendarId: string; changed: boolean }
| {
ok: false
reason: 'not-connected' | 'auth-error' | 'api-error' | 'not-listed'
message: string
}
const GOOGLE_CALENDAR_PICKER_NOT_CONNECTED_MESSAGE =
'Conecte a conta Google da campanha para listar e escolher o calendário principal.'
const GOOGLE_CALENDAR_PICKER_LIST_FAILED_MESSAGE =
'Não foi possível listar os calendários do Google agora. Tente de novo.'
const GOOGLE_CALENDAR_PICKER_NOT_LISTED_MESSAGE =
'O calendário escolhido não está mais disponível na conta Google conectada. Atualize a lista e escolha de novo.'
/**
* C150 — the picker lists the calendars of the OAuth-connected account. The
* service account is deliberately refused: its own calendar list does not
* contain the calendars shared with it, so it cannot back a picker (the
* connection is the C149 hard dependency of this surface).
*/
const resolveGoogleCalendarPickerClient = (
doc: GoogleCalendarSyncDoc | null,
client?: GoogleCalendarClient,
): { client: GoogleCalendarClient } | { reason: 'not-connected'; message: string } => {
const auth = readGoogleCalendarAuth(doc)
if (auth?.kind !== 'oauth') {
return { reason: 'not-connected', message: GOOGLE_CALENDAR_PICKER_NOT_CONNECTED_MESSAGE }
}
return { client: client ?? createGoogleCalendarClient(auth) }
}
/**
* C150 — lists the calendars the connected account can WRITE to, for the
* primary-calendar picker. A dead OAuth connection is recorded on the row
* (same classification as the engine) so the card derives `error` and offers
* Reconnect; every other failure is a transient API error.
*/
export const listGoogleCalendarPickerOptions = async (
payload: Payload,
options?: { client?: GoogleCalendarClient },
): Promise<GoogleCalendarPickerListResult> => {
const doc = await loadGoogleCalendarSyncConfig(payload)
const resolved = resolveGoogleCalendarPickerClient(doc, options?.client)
if ('reason' in resolved) return { ok: false, ...resolved }
try {
return { ok: true, calendars: await resolved.client.listCalendars() }
} catch (error) {
if (error instanceof GoogleCalendarAuthError) {
await recordGoogleCalendarOAuthError(payload, error.message)
return { ok: false, reason: 'auth-error', message: error.message }
}
return { ok: false, reason: 'api-error', message: GOOGLE_CALENDAR_PICKER_LIST_FAILED_MESSAGE }
}
}
/**
* C150 — sets the campaign's primary calendar (`calendarId`), the one the
* mirror writes to. The id is validated server-side against the connected
* account's LIVE writable list before the write, so the campaign surface can
* never point the mirror at an arbitrary string (the Payload admin keeps that
* escape hatch through the field's admin-only access). The write uses the
* admin bypass by design: the caller (campaign action) authorizes the actor
* and this utility owns the value validation. The `afterChange` config hook
* (D7) reconciles the mirror into the new calendar; the old one is left as-is
* (design C114 D7 — no backfill, no retro-cleanup).
*/
export const setCampaignPrimaryCalendarId = async (
payload: Payload,
calendarId: string,
options?: { client?: GoogleCalendarClient },
): Promise<GoogleCalendarPrimaryCalendarResult> => {
const doc = await loadGoogleCalendarSyncConfig(payload)
if (!doc) {
return {
ok: false,
reason: 'not-connected',
message: GOOGLE_CALENDAR_PICKER_NOT_CONNECTED_MESSAGE,
}
}
if (doc.calendarId === calendarId) return { ok: true, calendarId, changed: false }
const listed = await listGoogleCalendarPickerOptions(payload, options)
if (!listed.ok) return listed
if (!listed.calendars.some((calendar) => calendar.id === calendarId)) {
return { ok: false, reason: 'not-listed', message: GOOGLE_CALENDAR_PICKER_NOT_LISTED_MESSAGE }
}
await payload.update({
collection: 'googleCalendarSync',
id: doc.id,
data: { calendarId },
depth: 0,
// Intentional admin bypass: `calendarId` is admin-only at the field level;
// the campaign path is authorized at the action and validated above.
overrideAccess: true,
})
return { ok: true, calendarId, changed: true }
}
/** The push-channel webhook address; the URL secret IS the credential. */
const buildPushChannelAddress = (secret: string): string | null => {
try {

View file

@ -183,7 +183,7 @@ test.describe('Agenda — sincronização Google (C114/C122)', () => {
)
})
test('estado synced: pill, link do calendário com copiar e instruções', async ({
test('estado synced: pill, link do calendário, seletor principal e one-click (C150)', async ({
campaign,
page,
}) => {
@ -191,14 +191,26 @@ test.describe('Agenda — sincronização Google (C114/C122)', () => {
const coordinator = await fixtures.createCampaignUser('coordinator')
// lastSuccessAt no futuro: imune a lastErrorAt gravado por hooks de specs
// paralelas (a derivação só volta a synced enquanto erro não for mais novo).
// O refresh token seedado dá o estado `connected` (o client OAuth dummy
// está no webServer) — necessário para o seletor de calendário principal.
await seedSyncConfig(fixtures, {
lastSyncedAt: FAR_FUTURE,
lastSuccessAt: FAR_FUTURE,
oauthRefreshToken: 'c150-e2e-refresh',
oauthConnectedAt: '2026-08-01T10:00:00.000Z',
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/agenda`)
// C150 — o one-click do header aparece para staff quando há calendário.
const headerAdd = page.locator('a[aria-label="Adicionar ao meu Google Calendar"]:visible')
await expect(headerAdd).toBeVisible({ timeout: 15_000 })
await expect(headerAdd).toHaveAttribute(
'href',
`https://calendar.google.com/calendar/r?cid=${encodeURIComponent(CALENDAR_ID)}`,
)
const pill = page.getByRole('button', { name: 'Google: sincronizado' })
await expect(pill).toBeVisible({ timeout: 15_000 })
await pill.click()
@ -208,12 +220,20 @@ test.describe('Agenda — sincronização Google (C114/C122)', () => {
await expect(
dialog.getByText(/Sincronizado — as mudanças da agenda já refletiram/),
).toBeVisible()
// O link de adição vem do calendarId seedado (webcal → cid) + copiar + instruções.
const linkInput = dialog.getByLabel('Link do calendário (copie e envie à equipe)')
await expect(linkInput).toHaveValue(
`https://calendar.google.com/calendar/r?cid=${encodeURIComponent(
`webcal://calendar.google.com/calendar/ical/${encodeURIComponent(CALENDAR_ID)}/public/basic.ics`,
)}`,
// C150 — o seletor do calendário principal (manager + conexão ativa).
await expect(dialog.getByRole('button', { name: 'Trocar calendário principal' })).toBeVisible()
// O one-click no diálogo e a URL iCal pública para o caminho manual.
await expect(
dialog.getByRole('link', { name: 'Adicionar ao meu Google Calendar' }),
).toHaveAttribute(
'href',
`https://calendar.google.com/calendar/r?cid=${encodeURIComponent(CALENDAR_ID)}`,
)
const icalInput = dialog.getByLabel(
'URL pública do calendário (Por URL, Apple Calendar e Outlook)',
)
await expect(icalInput).toHaveValue(
`https://calendar.google.com/calendar/ical/${encodeURIComponent(CALENDAR_ID)}/public/basic.ics`,
)
await expect(dialog.getByRole('button', { name: 'Copiar link' })).toBeVisible()
await expect(dialog.getByText(/Como adicionar ao Google Calendar:/)).toBeVisible()
@ -235,6 +255,35 @@ test.describe('Agenda — sincronização Google (C114/C122)', () => {
await expect(dialog.getByRole('button', { name: 'Sincronizar agora' })).toBeVisible()
})
test('advisor vê o one-click mas não o seletor de calendário (C150)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const advisor = await fixtures.createCampaignUser('advisor')
await seedSyncConfig(fixtures, {
lastSyncedAt: FAR_FUTURE,
lastSuccessAt: FAR_FUTURE,
oauthRefreshToken: 'c150-e2e-refresh-advisor',
oauthConnectedAt: '2026-08-01T10:00:00.000Z',
})
await campaign.login(page, advisor.email!, advisor.password)
await page.goto(`${campaign.baseURL}/campanha/agenda`)
const headerAdd = page.locator('a[aria-label="Adicionar ao meu Google Calendar"]:visible')
await expect(headerAdd).toBeVisible({ timeout: 15_000 })
const pill = page.getByRole('button', { name: 'Google: sincronizado' })
await expect(pill).toBeVisible({ timeout: 15_000 })
await pill.click()
const dialog = syncDialog(page)
await expect(dialog).toBeVisible()
await expect(dialog.getByRole('button', { name: /calendário principal/ })).toHaveCount(0)
await expect(dialog.getByText(/escolhido por candidato ou coordenação/)).toBeVisible()
})
test.describe('mobile (FAB)', () => {
test.use({ viewport: { width: 390, height: 844 } })
@ -263,6 +312,30 @@ test.describe('Agenda — sincronização Google (C114/C122)', () => {
await expect(sheet.getByText('Não configurado')).toBeVisible()
await expect(sheet.getByRole('button', { name: 'Conectar com o Google' })).toBeVisible()
})
test('o one-click do header aparece no mobile com calendário principal (C150)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
await seedSyncConfig(fixtures, {
lastSyncedAt: FAR_FUTURE,
lastSuccessAt: FAR_FUTURE,
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/agenda`)
// O cluster do top bar não suporta botão full-width (adaptação do
// rascunho): no mobile o one-click é o ícone com aria-label.
const addLink = page.locator('a[aria-label="Adicionar ao meu Google Calendar"]:visible')
await expect(addLink).toBeVisible({ timeout: 15_000 })
await expect(addLink).toHaveAttribute(
'href',
`https://calendar.google.com/calendar/r?cid=${encodeURIComponent(CALENDAR_ID)}`,
)
})
})
test.describe('webhook público (C115)', () => {

View file

@ -0,0 +1,270 @@
// @vitest-environment node
//
// C150 — the primary-calendar picker actions. Session resolution is mocked
// (`getCampaignActionContext`), like the sibling action specs; everything else
// — Payload access, the config afterChange hook (D7) and the DB — runs for
// real. The Google client factory is mocked so the listing/validation is
// deterministic: the picker must refuse an id that is not in the connected
// account's LIVE writable list, and choosing a listed one must write
// `calendarId` and reconcile the mirror into it.
import { getPayload, type Payload } from 'payload'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
vi.mock('@/utilities/campaignActionContext', async (importOriginal) => {
const original = await importOriginal<typeof import('@/utilities/campaignActionContext')>()
return { ...original, getCampaignActionContext: vi.fn() }
})
vi.mock('@/utilities/googleCalendarClient', async (importOriginal) => {
const original = await importOriginal<typeof import('@/utilities/googleCalendarClient')>()
return { ...original, createGoogleCalendarClient: vi.fn() }
})
import {
chooseGoogleCalendar,
listGoogleCalendars,
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
import config from '@/payload.config'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import {
createGoogleCalendarClient,
GoogleCalendarAuthError,
type GoogleCalendarClient,
type GoogleRemoteEvent,
} from '@/utilities/googleCalendarClient'
import {
GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV,
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV,
} from '@/utilities/googleCalendarOAuth'
import { loadGoogleCalendarSyncConfig } from '@/utilities/googleCalendarSync'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { serializeGoogleCalendarSyncSpec } from '../helpers/googleCalendarSyncLock'
serializeGoogleCalendarSyncSpec()
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const mockedGetContext = vi.mocked(getCampaignActionContext)
const mockedCreateClient = vi.mocked(createGoogleCalendarClient)
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
}
}
const CALENDAR_A = 'c_campanha_a@group.calendar.google.com'
const CALENDAR_B = 'c_campanha_b@group.calendar.google.com'
type StubClient = {
client: GoogleCalendarClient
listCalendars: ReturnType<typeof vi.fn>
listEvents: ReturnType<typeof vi.fn>
}
const createStubClient = (): StubClient => {
const listCalendars = vi.fn(async () => [
{ id: CALENDAR_A, summary: 'Agenda da Campanha', primary: false },
{ id: CALENDAR_B, summary: 'Meu calendário', primary: true },
])
const listEvents = vi.fn(
async (_calendarId: string, _range: { timeMin: string; timeMax: string }) =>
[] as GoogleRemoteEvent[],
)
const client: GoogleCalendarClient = {
listCalendars,
listEvents,
insertEvent: vi.fn(async () => {}),
updateEvent: vi.fn(async () => {}),
deleteEvent: vi.fn(async () => {}),
watchEvents: vi.fn(async () => ({
id: 'watch-1',
resourceId: 'resource-1',
expiration: null,
})),
stopChannel: vi.fn(async () => {}),
}
return { client, listCalendars, listEvents }
}
describe('C150 — primary calendar picker actions', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
let ownedConfigId: number | null = null
afterEach(async () => {
restoreEnv()
if (ownedConfigId !== null) {
// Scoped to OUR row: the sibling Google specs share the test DB and
// their own config lifecycle must not be robbed by this file's cleanup.
await payload.delete({
collection: 'googleCalendarSync',
id: ownedConfigId,
overrideAccess: true,
})
ownedConfigId = null
}
mockedGetContext.mockReset()
mockedCreateClient.mockReset()
})
afterAll(() => {
restoreEnv()
})
const installActor = async (role: 'coordinator' | 'advisor'): Promise<void> => {
const actor = await campaignFixtures().createCampaignUser(role)
mockedGetContext.mockResolvedValue({ payload, actor })
}
const createConnectedConfig = async (calendarId: string | null = null) => {
const doc = await payload.create({
collection: 'googleCalendarSync',
data: {
calendarId,
oauthRefreshToken: 'c150-refresh-token',
oauthConnectedAt: new Date().toISOString(),
},
depth: 0,
overrideAccess: true,
})
ownedConfigId = doc.id
return doc
}
it('refuses an advisor on list and choose without touching the row', async () => {
await installActor('advisor')
await createConnectedConfig(CALENDAR_A)
setOAuthClientEnv()
const list = await listGoogleCalendars()
expect(list.ok).toBe(false)
if (list.ok) throw new Error('list must be refused')
expect(list.message).toContain('candidato ou coordenação')
const choose = await chooseGoogleCalendar(CALENDAR_B)
expect(choose.ok).toBe(false)
expect(choose.message).toContain('candidato ou coordenação')
const doc = await loadGoogleCalendarSyncConfig(payload)
expect(doc?.calendarId).toBe(CALENDAR_A)
expect(mockedCreateClient).not.toHaveBeenCalled()
})
it('lists the writable calendars of the connected account for a coordinator', async () => {
await installActor('coordinator')
await createConnectedConfig()
setOAuthClientEnv()
const stub = createStubClient()
mockedCreateClient.mockReturnValue(stub.client)
const result = await listGoogleCalendars()
expect(result.ok).toBe(true)
if (!result.ok) throw new Error('list must succeed')
expect(result.calendars).toEqual([
{ id: CALENDAR_A, summary: 'Agenda da Campanha', primary: false },
{ id: CALENDAR_B, summary: 'Meu calendário', primary: true },
])
expect(mockedCreateClient).toHaveBeenCalledTimes(1)
})
it('records a dead OAuth connection and surfaces it as an auth error', async () => {
await installActor('coordinator')
await createConnectedConfig()
setOAuthClientEnv()
const stub = createStubClient()
stub.listCalendars.mockRejectedValue(
new GoogleCalendarAuthError('A conexão com o Google expirou ou foi revogada. Reconecte.'),
)
mockedCreateClient.mockReturnValue(stub.client)
const result = await listGoogleCalendars()
expect(result.ok).toBe(false)
if (result.ok) throw new Error('list must fail')
expect(result.message).toContain('Reconecte')
const doc = await loadGoogleCalendarSyncConfig(payload)
expect(doc?.oauthErrorAt).toBeTruthy()
expect(doc?.oauthError).toContain('Reconecte')
})
it('rejects an id outside the live writable list without writing', async () => {
await installActor('coordinator')
await createConnectedConfig(CALENDAR_A)
setOAuthClientEnv()
const stub = createStubClient()
stub.listCalendars.mockResolvedValue([
{ id: CALENDAR_A, summary: 'Agenda da Campanha', primary: false },
])
mockedCreateClient.mockReturnValue(stub.client)
const result = await chooseGoogleCalendar(CALENDAR_B)
expect(result.ok).toBe(false)
expect(result.message).toContain('não está mais disponível')
const doc = await loadGoogleCalendarSyncConfig(payload)
expect(doc?.calendarId).toBe(CALENDAR_A)
})
it('writes a listed calendar and reconciles the mirror into it (D7)', async () => {
await installActor('coordinator')
await createConnectedConfig(CALENDAR_A)
setOAuthClientEnv()
const stub = createStubClient()
mockedCreateClient.mockReturnValue(stub.client)
const result = await chooseGoogleCalendar(CALENDAR_B)
expect(result.ok).toBe(true)
expect(result.calendarId).toBe(CALENDAR_B)
expect(result.addLink).toBe(
`https://calendar.google.com/calendar/r?cid=${encodeURIComponent(CALENDAR_B)}`,
)
const doc = await loadGoogleCalendarSyncConfig(payload)
expect(doc?.calendarId).toBe(CALENDAR_B)
// The config afterChange hook (D7) ran a pass against the NEW calendar and
// succeeded with the stub transport.
expect(stub.listEvents).toHaveBeenCalled()
expect(stub.listEvents.mock.calls[0][0]).toBe(CALENDAR_B)
expect(doc?.lastSuccessAt).toBeTruthy()
})
it('keeps the current calendar as a no-op without listing', async () => {
await installActor('coordinator')
await createConnectedConfig(CALENDAR_A)
setOAuthClientEnv()
const stub = createStubClient()
mockedCreateClient.mockReturnValue(stub.client)
const result = await chooseGoogleCalendar(CALENDAR_A)
expect(result.ok).toBe(true)
expect(result.calendarId).toBe(CALENDAR_A)
expect(stub.listCalendars).not.toHaveBeenCalled()
expect(stub.listEvents).not.toHaveBeenCalled()
})
})

View file

@ -53,6 +53,8 @@ const campaignFixtures = installCampaignFixtures({
/** In-memory Google calendar stub — list/insert/update/delete over an array. */
const createStubClient = (store: GoogleRemoteEvent[] = []): GoogleCalendarClient => ({
// C150 — the picker lists calendars; the engine never calls this.
listCalendars: async () => [],
listEvents: async (_calendarId, range) =>
store.filter((event) => {
const start = event.start?.dateTime ?? event.start?.date ?? ''

View file

@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } 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 {
@ -39,7 +39,10 @@ const stateFor = (status: GoogleCalendarSyncStatus): GoogleCalendarSyncActionRes
lastError: status === 'paused' ? 'Google fora do ar (simulado)' : null,
pushChannelExpiresAt: null,
pushChannelError: null,
addLink: status === 'not-configured' ? null : 'https://calendar.google.com/calendar/r?cid=webcal',
addLink:
status === 'not-configured'
? null
: 'https://calendar.google.com/calendar/r?cid=c_campanha%40group.calendar.google.com',
})
const PILL_LABELS: Record<GoogleCalendarSyncStatus, string> = {
@ -51,7 +54,12 @@ const PILL_LABELS: Record<GoogleCalendarSyncStatus, string> = {
const HeaderActionsProbe = () => {
const actions = useCampaignHeaderActions()
return <>{actions['google-calendar-sync'] ?? null}</>
return (
<>
{actions['google-calendar-sync'] ?? null}
{actions['google-calendar-add'] ?? null}
</>
)
}
const renderChrome = ({
@ -59,9 +67,11 @@ const renderChrome = ({
// Status-consistent default: a paused mount's auto-retry resolves back to
// the same state, so the label assertions never race the retry's setState.
onSyncNow = vi.fn(async () => stateFor(status)),
onChooseCalendar,
}: {
status: GoogleCalendarSyncStatus
onSyncNow?: () => Promise<GoogleCalendarSyncActionResult>
onChooseCalendar?: (calendarId: string) => Promise<GoogleCalendarSyncActionResult>
}) =>
render(
<CampaignPageChromeProvider role="coordinator">
@ -77,6 +87,22 @@ const renderChrome = ({
}),
)}
onDisconnect={vi.fn(async () => stateFor('not-configured'))}
onListCalendars={vi.fn(async () => ({
ok: true as const,
calendars: [
{
id: 'c_campanha@group.calendar.google.com',
summary: 'Agenda da Campanha',
primary: false,
},
{
id: 'c_pessoal@group.calendar.google.com',
summary: 'Meu calendário',
primary: true,
},
],
}))}
onChooseCalendar={onChooseCalendar ?? vi.fn(async () => stateFor('synced'))}
/>
<HeaderActionsProbe />
</CampaignQuickActionContextProvider>
@ -154,4 +180,67 @@ describe('AgendaGoogleSyncChrome (S14)', () => {
)
expect(onSyncNow).toHaveBeenCalledTimes(1)
})
it('registers the one-click add link when a primary calendar is set (C150)', () => {
renderChrome({ status: 'synced' })
const links = screen.getAllByRole('link', { name: 'Adicionar ao meu Google Calendar' })
expect(links).toHaveLength(2)
for (const link of links) {
expect(link.getAttribute('href')).toBe(
'https://calendar.google.com/calendar/r?cid=c_campanha%40group.calendar.google.com',
)
}
})
it('hides the one-click add link without a primary calendar (C150)', () => {
renderChrome({ status: 'not-configured' })
expect(screen.queryByRole('link', { name: 'Adicionar ao meu Google Calendar' })).toBeNull()
})
it('opens the picker from the sync dialog and returns to it on close (C150)', async () => {
renderChrome({ status: 'synced' })
fireEvent.click(screen.getByRole('button', { name: /^Google:/ }))
const syncDialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
fireEvent.click(within(syncDialog).getByRole('button', { name: 'Trocar calendário principal' }))
const picker = await screen.findByRole('dialog', {
name: /Calendário principal da campanha/,
})
expect(within(picker).getByText('Agenda da Campanha')).toBeTruthy()
expect(within(picker).getByText('em uso')).toBeTruthy()
fireEvent.click(within(picker).getByRole('button', { name: 'Cancelar' }))
expect(screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })).toBeTruthy()
})
it('chooses a calendar and refreshes the one-click link (C150)', async () => {
const chosen: GoogleCalendarSyncActionResult = {
...stateFor('synced'),
calendarId: 'c_pessoal@group.calendar.google.com',
addLink: 'https://calendar.google.com/calendar/r?cid=c_pessoal%40group.calendar.google.com',
}
const onChooseCalendar = vi.fn(async () => chosen)
renderChrome({ status: 'synced', onChooseCalendar })
fireEvent.click(screen.getByRole('button', { name: /^Google:/ }))
fireEvent.click(screen.getByRole('button', { name: 'Trocar calendário principal' }))
const picker = await screen.findByRole('dialog', {
name: /Calendário principal da campanha/,
})
fireEvent.click(within(picker).getByRole('radio', { name: /Meu calendário/ }))
fireEvent.click(within(picker).getByRole('button', { name: 'Escolher calendário' }))
await waitFor(() => expect(onChooseCalendar).toHaveBeenCalledTimes(1))
expect(onChooseCalendar).toHaveBeenCalledWith('c_pessoal@group.calendar.google.com')
await waitFor(() =>
expect(
screen
.getAllByRole('link', { name: 'Adicionar ao meu Google Calendar' })[0]
.getAttribute('href'),
).toBe('https://calendar.google.com/calendar/r?cid=c_pessoal%40group.calendar.google.com'),
)
})
})

View file

@ -151,6 +151,69 @@ describe('createGoogleCalendarClient', () => {
expect(listCall?.url).toContain('maxResults=2500')
})
it('lists writable calendars with pagination, hiding deleted and falling back the summary (C150)', async () => {
const pages: Record<string, { items: Array<Record<string, unknown>>; nextPageToken?: string }> =
{
'': {
items: [
{ id: 'primary-id', summary: 'Conta da campanha', primary: true, accessRole: 'owner' },
{ id: 'campaign-id', summary: 'Agenda da Campanha', accessRole: 'writer' },
{ id: 'no-summary-id' },
{ id: 'deleted-id', summary: 'Antiga', deleted: true },
],
nextPageToken: 'page-2',
},
'page-2': {
items: [{ id: 'second-page-id', summary: 'Segunda página' }],
},
}
const calls: string[] = []
const fetchImpl: FetchLike = (async (input: RequestInfo | URL) => {
const url = String(input)
if (url === 'https://oauth2.googleapis.com/token') {
return new Response(JSON.stringify({ access_token: 'token-1', expires_in: 3600 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
calls.push(url)
const pageToken = new URL(url).searchParams.get('pageToken') ?? ''
return new Response(JSON.stringify(pages[pageToken]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as FetchLike
const client = createGoogleCalendarClient(oauthAuth, fetchImpl)
const calendars = await client.listCalendars()
expect(calendars).toEqual([
{ id: 'primary-id', summary: 'Conta da campanha', primary: true },
{ id: 'campaign-id', summary: 'Agenda da Campanha', primary: false },
{ id: 'no-summary-id', summary: 'no-summary-id', primary: false },
{ id: 'second-page-id', summary: 'Segunda página', primary: false },
])
expect(calls).toHaveLength(2)
const first = new URL(calls[0])
expect(first.pathname).toBe('/calendar/v3/users/me/calendarList')
expect(first.searchParams.get('minAccessRole')).toBe('writer')
expect(first.searchParams.get('showHidden')).toBe('true')
expect(first.searchParams.get('maxResults')).toBe('250')
expect(first.searchParams.get('fields')).toBe('items(id,summary,primary,deleted),nextPageToken')
expect(new URL(calls[1]).searchParams.get('pageToken')).toBe('page-2')
})
it('maps invalid_grant on the calendar list to GoogleCalendarAuthError (C150)', 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.listCalendars()).rejects.toBeInstanceOf(GoogleCalendarAuthError)
})
it('re-mints the token once on a 401 and retries the call', async () => {
let calendarCalls = 0
const fetchImpl: FetchLike = (async (input: RequestInfo | URL, _init?: RequestInit) => {

View file

@ -15,10 +15,10 @@ describe('googleCalendarLink', () => {
)
})
it('builds the Google add-by-URL link with the webcal URL as cid', () => {
it('builds the one-click Google add link with the calendarId as cid (C150)', () => {
const link = buildGoogleCalendarAddLink(calendarId)
expect(link).toBe(
'https://calendar.google.com/calendar/r?cid=webcal%3A%2F%2Fcalendar.google.com%2Fcalendar%2Fical%2Fc_abc123%2540group.calendar.google.com%2Fpublic%2Fbasic.ics',
'https://calendar.google.com/calendar/r?cid=c_abc123%40group.calendar.google.com',
)
})

View file

@ -0,0 +1,184 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
GoogleCalendarListActionResult,
GoogleCalendarSyncActionResult,
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
import { GoogleCalendarPickerDialog } from '@/components/campaign/activity/GoogleCalendarPickerDialog'
/**
* C150 — the primary-calendar picker: live list states (loading/error/empty),
* the "em uso" mark, the confirm gating and the choose callback. Desktop
* layout (matchMedia false); the mobile drawer shares the same content.
*/
const CALENDAR_A = 'c_campanha@group.calendar.google.com'
const CALENDAR_B = 'c_pessoal@group.calendar.google.com'
const listResult: GoogleCalendarListActionResult = {
ok: true,
calendars: [
{ id: CALENDAR_A, summary: 'Agenda da Campanha', primary: false },
{ id: CALENDAR_B, summary: 'Meu calendário', primary: true },
],
}
const syncResult = (
overrides: Partial<GoogleCalendarSyncActionResult> = {},
): GoogleCalendarSyncActionResult => ({
ok: true,
canManageConnection: true,
connection: 'connected',
oauthAvailable: true,
oauthConnectedAt: '2026-08-01T12:00:00.000Z',
oauthError: null,
status: 'synced',
calendarId: CALENDAR_B,
lastSyncedAt: null,
lastSuccessAt: null,
lastErrorAt: null,
lastError: null,
pushChannelExpiresAt: null,
pushChannelError: null,
addLink: null,
...overrides,
})
const renderPicker = (
overrides: {
currentCalendarId?: string | null
onOpenChange?: (open: boolean) => void
onListCalendars?: () => Promise<GoogleCalendarListActionResult>
onChooseCalendar?: (calendarId: string) => Promise<GoogleCalendarSyncActionResult>
} = {},
) =>
render(
<GoogleCalendarPickerDialog
open
onOpenChange={overrides.onOpenChange ?? vi.fn()}
currentCalendarId={overrides.currentCalendarId ?? CALENDAR_A}
onListCalendars={overrides.onListCalendars ?? vi.fn(async () => listResult)}
onChooseCalendar={overrides.onChooseCalendar ?? vi.fn(async () => syncResult())}
/>,
)
const matchMediaMock = vi.fn()
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
Element.prototype.scrollIntoView = () => {}
})
beforeEach(() => {
matchMediaMock.mockImplementation((query: string) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
vi.stubGlobal('matchMedia', matchMediaMock)
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
afterAll(() => {
vi.unstubAllGlobals()
})
describe('GoogleCalendarPickerDialog (C150)', () => {
it('shows the loading state while the live list is fetched', () => {
renderPicker({
onListCalendars: vi.fn((): Promise<GoogleCalendarListActionResult> => new Promise(() => {})),
})
expect(screen.getByText('Carregando calendários...')).toBeTruthy()
})
it('lists the calendars, marks the current one and disables the no-op confirm', async () => {
renderPicker()
const picker = await screen.findByRole('dialog', {
name: /Calendário principal da campanha/,
})
const current = within(picker).getByRole('radio', { name: /Agenda da Campanha/ })
expect(current.getAttribute('aria-checked')).toBe('true')
expect(within(picker).getByText('em uso')).toBeTruthy()
expect(within(picker).getByText('Calendário principal da conta')).toBeTruthy()
const confirm = within(picker).getByRole('button', { name: 'Escolher calendário' })
expect(confirm.hasAttribute('disabled')).toBe(true)
})
it('chooses another calendar and closes on success', async () => {
const onOpenChange = vi.fn()
const onChooseCalendar = vi.fn(async () => syncResult())
renderPicker({ onOpenChange, onChooseCalendar })
const picker = await screen.findByRole('dialog', {
name: /Calendário principal da campanha/,
})
fireEvent.click(within(picker).getByRole('radio', { name: /Meu calendário/ }))
fireEvent.click(within(picker).getByRole('button', { name: 'Escolher calendário' }))
await waitFor(() => expect(onChooseCalendar).toHaveBeenCalledWith(CALENDAR_B))
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false))
})
it('keeps the picker open and shows the failure message', async () => {
const onOpenChange = vi.fn()
const onChooseCalendar = vi.fn(async () =>
syncResult({ ok: false, message: 'O calendário escolhido não está mais disponível.' }),
)
renderPicker({ onOpenChange, onChooseCalendar })
const picker = await screen.findByRole('dialog', {
name: /Calendário principal da campanha/,
})
fireEvent.click(within(picker).getByRole('radio', { name: /Meu calendário/ }))
fireEvent.click(within(picker).getByRole('button', { name: 'Escolher calendário' }))
expect(await within(picker).findByText(/não está mais disponível/)).toBeTruthy()
expect(onOpenChange).not.toHaveBeenCalled()
})
it('offers a retry when the list fails', async () => {
const onListCalendars = vi
.fn<() => Promise<GoogleCalendarListActionResult>>()
.mockResolvedValueOnce({ ok: false, message: 'Não foi possível listar os calendários.' })
.mockResolvedValueOnce(listResult)
renderPicker({ onListCalendars })
expect(await screen.findByText('Não foi possível listar os calendários.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Tentar de novo' }))
expect(await screen.findByText('Agenda da Campanha')).toBeTruthy()
expect(onListCalendars).toHaveBeenCalledTimes(2)
})
it('shows the empty state when the account has no writable calendar', async () => {
renderPicker({
onListCalendars: vi.fn(
async (): Promise<GoogleCalendarListActionResult> => ({ ok: true, calendars: [] }),
),
})
expect(
await screen.findByText('Nenhum calendário com permissão de edição nesta conta Google.'),
).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Escolher calendário' })).toBeNull()
})
})

View file

@ -22,7 +22,7 @@ const syncedState = (
lastError: null,
pushChannelExpiresAt: null,
pushChannelError: null,
addLink: 'https://calendar.google.com/calendar/r?cid=webcal%3A%2F%2F…',
addLink: 'https://calendar.google.com/calendar/r?cid=c_campanha%40group.calendar.google.com',
...overrides,
})
@ -54,6 +54,7 @@ const renderDialog = (
{ ok: true; authorizeUrl: string } | { ok: false; message: string }
>
onDisconnect?: () => Promise<GoogleCalendarSyncActionResult>
onOpenPicker?: () => void
} = {},
) =>
render(
@ -65,6 +66,7 @@ const renderDialog = (
onSetDisabled={vi.fn()}
onStartOAuth={overrides.onStartOAuth ?? vi.fn()}
onDisconnect={overrides.onDisconnect ?? vi.fn()}
onOpenPicker={overrides.onOpenPicker ?? vi.fn()}
/>,
)
@ -197,6 +199,58 @@ describe('GoogleCalendarSyncDialog — card de conexão OAuth (C149)', () => {
})
})
describe('GoogleCalendarSyncDialog — calendário principal e one-click (C150)', () => {
it('shows the current calendar and the picker trigger to managers', () => {
renderDialog(syncedState())
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
expect(within(dialog).getByText('Calendário principal da campanha')).toBeTruthy()
expect(within(dialog).getByText('c_campanha@group.calendar.google.com')).toBeTruthy()
expect(within(dialog).getByRole('button', { name: 'Trocar calendário principal' })).toBeTruthy()
})
it('opens the picker from the connection card', () => {
const onOpenPicker = vi.fn()
renderDialog(syncedState(), { onOpenPicker })
fireEvent.click(screen.getByRole('button', { name: 'Trocar calendário principal' }))
expect(onOpenPicker).toHaveBeenCalledTimes(1)
})
it('offers to choose when the connection is active but no calendar is set', () => {
renderDialog(syncedState({ status: 'not-configured', calendarId: null, addLink: null }))
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
expect(
within(dialog).getByRole('button', { name: 'Escolher calendário principal' }),
).toBeTruthy()
expect(within(dialog).getByText(/Escolha o calendário principal/)).toBeTruthy()
})
it('hides the picker trigger from non-managers and names the nucleus', () => {
renderDialog(syncedState({ canManageConnection: false }))
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
expect(within(dialog).queryByRole('button', { name: /calendário principal/ })).toBeNull()
expect(within(dialog).getByText(/escolhido por candidato ou coordenação/)).toBeTruthy()
})
it('renders the one-click add link and the public iCal URL for the manual path', () => {
renderDialog(syncedState())
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
const addLink = within(dialog).getByRole('link', {
name: /Adicionar ao meu Google Calendar/,
})
expect(addLink.getAttribute('href')).toBe(
'https://calendar.google.com/calendar/r?cid=c_campanha%40group.calendar.google.com',
)
const icalInput = within(dialog).getByLabelText(
'URL pública do calendário (Por URL, Apple Calendar e Outlook)',
) as HTMLInputElement
expect(icalInput.value).toBe(
'https://calendar.google.com/calendar/ical/c_campanha%40group.calendar.google.com/public/basic.ics',
)
})
})
describe('ActivityUpdateFeed — atribuição do registro reverso (C115)', () => {
const renderFeed = (updates: Parameters<typeof ActivityUpdateFeed>[0]['updates']) =>
render(<ActivityUpdateFeed updates={updates} />)