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

+ +
+ ) : ( +
    + {calendars.map((calendar) => { + const isSelected = selectedId === calendar.id + const isCurrent = currentCalendarId === calendar.id + return ( +
  • + +
  • + ) + })} +
+ ) + + const chooseErrorNotice = chooseError ? ( +

+ {chooseError} +

+ ) : null + + const actions = + status === 'ready' && calendars.length > 0 ? ( +
+ {chooseErrorNotice} +
+ + +
+
+ ) : null + + if (isMobile) { + return ( + + + + {TITLE} + {DESCRIPTION} + +
+
+ {listContent} + {actions} +
+
+ + Fechar + +
+
+ ) + } + + return ( + + + + {TITLE} + {DESCRIPTION} + +
+ {listContent} +
+ {actions ? ( +
+ {actions} +
+ ) : null} +
+
+ ) +} diff --git a/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx b/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx index 58a91b1c..07813c40 100644 --- a/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx +++ b/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx @@ -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 /** C149 — drops the OAuth connection from the Teqo. */ onDisconnect: () => Promise + /** 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 ? ( -
- -
- - -
- {copied && ( -

- Link copiado! +

+ O Google abre com o calendário da campanha pronto para assinar — vale para o computador e + o celular.

- )} -

- Quem segue o calendário recebe aviso conforme as próprias configurações do Google. -

+
+
+ +
+ + +
+ {copied && ( +

+ Link copiado! +

+ )} +

+ Quem segue o calendário recebe aviso conforme as próprias configurações do Google. +

+
) : null @@ -330,6 +353,38 @@ export const GoogleCalendarSyncDialog = ({ ? `Conta Google conectada em ${formatBahiaDateTimeLabel(state.oauthConnectedAt)}.` : 'Conta Google conectada.'}

+
+

Calendário principal da campanha

+ {state.canManageConnection ? ( + <> +

+ {state.calendarId + ? 'O espelho escreve neste calendário:' + : 'Nenhum calendário escolhido ainda — o espelho fica parado até a escolha.'} +

+ {state.calendarId ? ( +

+ {state.calendarId} +

+ ) : null} + + + ) : ( +

+ 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()