diff --git a/docs/plans/c150-calendario-principal-e-inscricao-staff-impl.md b/docs/plans/c150-calendario-principal-e-inscricao-staff-impl.md
new file mode 100644
index 00000000..f6bd753c
--- /dev/null
+++ b/docs/plans/c150-calendario-principal-e-inscricao-staff-impl.md
@@ -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 (dono dos 2 diálogos)"]
+ SYNC["GoogleCalendarSyncDialog card de conexão"]
+ PICKER["GoogleCalendarPickerDialog"]
+ ADD["Adicionar ao meu Google Calendar (âncora target=_blank no header)"]
+ end
+ CHROME --> SYNC
+ SYNC -->|"manager + connected: 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 GET /users/me/calendarList?minAccessRole=writer"]
+ U2 --> CL
+ U2 -->|"valida id na lista viva"| DB[("googleCalendarSync.calendarId system write overrideAccess")]
+ DB -->|"config hook D7"| ENG["runCampaignCalendarSync re-concilia no novo calendário"]
+ CHROME --> ADD
+ ADD -->|href| GOOG["calendar.google.com/calendar/r?cid=calendarId"]
+ U1 -. GoogleCalendarAuthError .-> ERR["recordGoogleCalendarOAuthError 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=` | 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` — `loadConnectionManager` (candidate/coordinator) + utility; falha de papel devolve `{ ok: false, message: GOOGLE_CALENDAR_PRIMARY_MANAGER_ONLY_MESSAGE }`.
+ - `chooseGoogleCalendar(calendarId: string): Promise` — `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=` + 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=` 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=`; â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`.
diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs
index b34b2007..ec733e34 100644
--- a/scripts/lib/e2e-affected-manifest.mjs
+++ b/scripts/lib/e2e-affected-manifest.mjs
@@ -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'],
diff --git a/src/app/(campaign)/campanha/(app)/agenda/page.tsx b/src/app/(campaign)/campanha/(app)/agenda/page.tsx
index 4f180453..d4024aaf 100644
--- a/src/app/(campaign)/campanha/(app)/agenda/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/agenda/page.tsx
@@ -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)
+ }}
/>
diff --git a/src/app/(campaign)/campanha/actions/googleCalendarSync.ts b/src/app/(campaign)/campanha/actions/googleCalendarSync.ts
index 031adec2..542af63d 100644
--- a/src/app/(campaign)/campanha/actions/googleCalendarSync.ts
+++ b/src/app/(campaign)/campanha/actions/googleCalendarSync.ts
@@ -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 => {
+ 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 => {
+ 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
diff --git a/src/components/campaign/activity/AgendaGoogleSyncChrome.tsx b/src/components/campaign/activity/AgendaGoogleSyncChrome.tsx
index c2d44347..7da8c1e2 100644
--- a/src/components/campaign/activity/AgendaGoogleSyncChrome.tsx
+++ b/src/components/campaign/activity/AgendaGoogleSyncChrome.tsx
@@ -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
/** C149 — drops the OAuth connection from the Teqo. */
onDisconnect: () => Promise
+ /** C150 — lists the connected account's writable calendars (picker). */
+ onListCalendars: () => Promise
+ /** C150 — sets the campaign's primary calendar. */
+ onChooseCalendar: (calendarId: string) => Promise
}
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 (
+ <>
+
+
+ >
+ )
+ }, [state.addLink])
+
return (
<>
{pill}
+ {addToGoogle}
+
+
>
)
diff --git a/src/components/campaign/activity/GoogleCalendarPickerDialog.tsx b/src/components/campaign/activity/GoogleCalendarPickerDialog.tsx
new file mode 100644
index 00000000..89fae89f
--- /dev/null
+++ b/src/components/campaign/activity/GoogleCalendarPickerDialog.tsx
@@ -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
+ onChooseCalendar: (calendarId: string) => Promise
+}
+
+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('loading')
+ const [calendars, setCalendars] = useState([])
+ const [listError, setListError] = useState(null)
+ const [selectedId, setSelectedId] = useState(null)
+ const [chooseError, setChooseError] = useState(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' ? (
+
+
+ Carregando calendários...
+
+ ) : status === 'error' ? (
+
+
+ {listError}
+
+
+
+ ) : calendars.length === 0 ? (
+
+
+ Nenhum calendário com permissão de edição nesta conta Google.
+
+ O calendário principal é escolhido por candidato ou coordenação.
+
+ )}
+
{connectionActions}
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' ? (
- 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.'}
) : null
} else if (state.status === 'disabled') {
diff --git a/src/lib/googleCalendarLink.ts b/src/lib/googleCalendarLink.ts
index 4be4d901..7f7ff686 100644
--- a/src/lib/googleCalendarLink.ts
+++ b/src/lib/googleCalendarLink.ts
@@ -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`
diff --git a/src/utilities/googleCalendarClient.ts b/src/utilities/googleCalendarClient.ts
index f86e2314..65ac3d31 100644
--- a/src/utilities/googleCalendarClient.ts
+++ b/src/utilities/googleCalendarClient.ts
@@ -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
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,
+ }
}
diff --git a/src/utilities/googleCalendarSync.ts b/src/utilities/googleCalendarSync.ts
index 2d3ec38e..2614f640 100644
--- a/src/utilities/googleCalendarSync.ts
+++ b/src/utilities/googleCalendarSync.ts
@@ -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 => {
+ 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 => {
+ 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 {
diff --git a/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts b/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts
index eb4d00e4..a43edca9 100644
--- a/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts
+++ b/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts
@@ -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)', () => {
diff --git a/tests/int/googleCalendarPrimaryCalendarAction.int.spec.ts b/tests/int/googleCalendarPrimaryCalendarAction.int.spec.ts
new file mode 100644
index 00000000..c14083e6
--- /dev/null
+++ b/tests/int/googleCalendarPrimaryCalendarAction.int.spec.ts
@@ -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()
+ return { ...original, getCampaignActionContext: vi.fn() }
+})
+
+vi.mock('@/utilities/googleCalendarClient', async (importOriginal) => {
+ const original = await importOriginal()
+ 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
+ listEvents: ReturnType
+}
+
+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 => {
+ 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()
+ })
+})
diff --git a/tests/int/googleCalendarSync.int.spec.ts b/tests/int/googleCalendarSync.int.spec.ts
index ffa0ef05..5fbae5ea 100644
--- a/tests/int/googleCalendarSync.int.spec.ts
+++ b/tests/int/googleCalendarSync.int.spec.ts
@@ -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 ?? ''
diff --git a/tests/unit/agendaGoogleSyncChrome.unit.spec.tsx b/tests/unit/agendaGoogleSyncChrome.unit.spec.tsx
index d23ef679..ca04df12 100644
--- a/tests/unit/agendaGoogleSyncChrome.unit.spec.tsx
+++ b/tests/unit/agendaGoogleSyncChrome.unit.spec.tsx
@@ -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 = {
@@ -51,7 +54,12 @@ const PILL_LABELS: Record = {
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
+ onChooseCalendar?: (calendarId: string) => Promise
}) =>
render(
@@ -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'))}
/>
@@ -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'),
+ )
+ })
})
diff --git a/tests/unit/googleCalendarClient.unit.spec.ts b/tests/unit/googleCalendarClient.unit.spec.ts
index 232c08d8..14dc7c5c 100644
--- a/tests/unit/googleCalendarClient.unit.spec.ts
+++ b/tests/unit/googleCalendarClient.unit.spec.ts
@@ -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>; 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) => {
diff --git a/tests/unit/googleCalendarLink.unit.spec.ts b/tests/unit/googleCalendarLink.unit.spec.ts
index 6acde48c..89e4d9af 100644
--- a/tests/unit/googleCalendarLink.unit.spec.ts
+++ b/tests/unit/googleCalendarLink.unit.spec.ts
@@ -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',
)
})
diff --git a/tests/unit/googleCalendarPickerDialog.unit.spec.tsx b/tests/unit/googleCalendarPickerDialog.unit.spec.tsx
new file mode 100644
index 00000000..411bbab7
--- /dev/null
+++ b/tests/unit/googleCalendarPickerDialog.unit.spec.tsx
@@ -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 => ({
+ 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
+ onChooseCalendar?: (calendarId: string) => Promise
+ } = {},
+) =>
+ render(
+ 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 => 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>()
+ .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 => ({ 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()
+ })
+})
diff --git a/tests/unit/googleCalendarSyncDialog.unit.spec.tsx b/tests/unit/googleCalendarSyncDialog.unit.spec.tsx
index 95cc5d1c..2b9054ed 100644
--- a/tests/unit/googleCalendarSyncDialog.unit.spec.tsx
+++ b/tests/unit/googleCalendarSyncDialog.unit.spec.tsx
@@ -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
+ 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[0]['updates']) =>
render()