mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(campaign): C150 — calendário principal escolhido pelo candidato/coordenador + inscrição de staff no Google (Closes #935)
This commit is contained in:
parent
677dbbf0ea
commit
cc6d2abd04
18 changed files with 1574 additions and 56 deletions
145
docs/plans/c150-calendario-principal-e-inscricao-staff-impl.md
Normal file
145
docs/plans/c150-calendario-principal-e-inscricao-staff-impl.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# Impl: C150 — Agenda: calendário principal escolhido pelo candidato/coordenador + inscrição de staff no Google
|
||||
|
||||
Status: em execução
|
||||
Atualizado em: 2026-09-12
|
||||
Issue: #935
|
||||
Intenção: docs/plans/c150-calendario-principal-e-inscricao-staff.md
|
||||
Appetite restante: ~1,5–2 dias eng (herdado)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** na própria agenda (`/campanha/agenda`), o candidato/coordenador lista os calendários da conta Google conectada (C149) e escolhe o calendário principal da campanha — create/edit passam a espelhar nele; um staff adiciona a agenda ao Google pessoal com um clique, sem copiar link nem "Por URL". O recorte filtrado segue no feed ICS atual.
|
||||
- **O que NÃO negociar:** leader lockdown (nem vê as ações); Teqo segue SoT; um calendário principal só (nada de N espelhos); motor C114/C115 não muda de modelo; falha do Google não derruba a agenda (estado do sync visível); admin do Payload continua como escape hatch; o fluxo do recorte ICS (`CalendarFeedDialog`) fica intocado; OAuth por pessoa e backfill do calendário antigo são anti-goals.
|
||||
- **O que reavaliar (divergências registradas no plano):**
|
||||
- A "Direção no codebase" sugeria abrir `canSetGoogleCalendarSyncConfigField` a candidato/coordenador. **Rejeitado** (D2): a escrita passa por action de manager + utility system write com validação "id pertence à lista viva da conta conectada"; o campo segue admin-only.
|
||||
- O bullet de e2e do explorador propõe interceptar `oauth2.googleapis.com/token` e `www.googleapis.com/calendar/v3/...` com `page.route`. **Impossível** (D7): `page.route` só vê o tráfego do browser; a listagem roda no processo Next (server action). O caminho feliz lista/escolhe fica no int com stub client; o e2e cobre os contratos do browser.
|
||||
- O rascunho UI desenha o botão mobile full-width; o cluster do top bar não suporta isso — adaptado para ícone + `aria-label` (D6).
|
||||
- A cena de "confirmação da inscrição" do rascunho não é implementável (o Google não chama de volta); fora de escopo (não escopo de engenharia).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph UI["Agenda /campanha/agenda"]
|
||||
CHROME["AgendaGoogleSyncChrome<br/>(dono dos 2 diálogos)"]
|
||||
SYNC["GoogleCalendarSyncDialog<br/>card de conexão"]
|
||||
PICKER["GoogleCalendarPickerDialog"]
|
||||
ADD["Adicionar ao meu Google Calendar<br/>(âncora target=_blank no header)"]
|
||||
end
|
||||
CHROME --> SYNC
|
||||
SYNC -->|"manager + connected:<br/>Escolher/Trocar calendário"| PICKER
|
||||
PICKER -->|listGoogleCalendars| A1[server action]
|
||||
PICKER -->|chooseGoogleCalendar| A2[server action]
|
||||
A1 --> U1["listGoogleCalendarPickerOptions"]
|
||||
A2 --> U2["setCampaignPrimaryCalendarId"]
|
||||
U1 --> CL["GoogleCalendarClient.listCalendars<br/>GET /users/me/calendarList?minAccessRole=writer"]
|
||||
U2 --> CL
|
||||
U2 -->|"valida id na lista viva"| DB[("googleCalendarSync.calendarId<br/>system write overrideAccess")]
|
||||
DB -->|"config hook D7"| ENG["runCampaignCalendarSync<br/>re-concilia no novo calendário"]
|
||||
CHROME --> ADD
|
||||
ADD -->|href| GOOG["calendar.google.com/calendar/r?cid=calendarId"]
|
||||
U1 -. GoogleCalendarAuthError .-> ERR["recordGoogleCalendarOAuthError<br/>card deriva error/Reconectar"]
|
||||
U2 -. GoogleCalendarAuthError .-> ERR
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) listagem no client + actions de manager com validação viva + picker como diálogo próprio + literal `cid=<calendarId>` | B) abrir `canSetGoogleCalendarSyncConfigField` a `isCampaignUnrestricted` (hipótese da intenção) | C) armazenar nome/summary do calendário e fazer o picker a partir do banco.
|
||||
|
||||
**Recomendação:** A — mantém o motor e o modelo de dados intocados, concentra a nova escrita num utility com bypass e validação server-side (mesmo padrão de `recordGoogleCalendarOAuthConnection`), e entrega os dois outcomes com um componente de diálogo novo e zero migration. B abriria escrita crua de id arbitrário pela superfície REST do `campaignUser` sem a validação "pertence à conta conectada"; C exige migration e sofre drift quando o calendário é renomeado no Google.
|
||||
|
||||
**Rejeitadas:** B porque o enforcement de papel não substitui a validação de pertencimento (e o admin deve seguir funcionando como escape hatch); C porque a lista é viva e o "em uso" deriva de `state.calendarId` sem persistir nada.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
**Server**
|
||||
|
||||
- **`GoogleCalendarClient.listCalendars`** (`src/utilities/googleCalendarClient.ts`): método novo na superfície existente (mesmo `apiFetch`, mesmo cache de token, mesmo retry de 401, mesmo timeout de 15s). `GET {base}/users/me/calendarList` com `minAccessRole=writer`, `showHidden=true`, `maxResults=250`, `fields=items(id,summary,primary,deleted),nextPageToken` e loop de `pageToken`; filtra `deleted`; `summary` cai para o `id` quando ausente. Tipo novo `GoogleCalendarListEntry = { id: string; summary: string; primary: boolean }`. `CALENDAR_LIST_PAGE_SIZE = 250`.
|
||||
- **`listGoogleCalendarPickerOptions(payload, options?: { client?: GoogleCalendarClient })`** (`src/utilities/googleCalendarSync.ts`): carrega a row (`loadGoogleCalendarSyncConfig`), resolve `readGoogleCalendarAuth` e **exige `auth.kind === 'oauth'`**; cria o client (ou usa `options.client`) e devolve `{ ok: true; calendars } | { ok: false; reason: 'not-connected' | 'auth-error' | 'api-error'; message }`. Em `GoogleCalendarAuthError`, grava `recordGoogleCalendarOAuthError` e devolve `reason: 'auth-error'` com a mensagem do erro (já segura, pt-BR). `GoogleCalendarPickerOption = GoogleCalendarListEntry` (alias, sem twin de shape).
|
||||
- **`setCampaignPrimaryCalendarId(payload, calendarId, options?)`** (mesmo arquivo): reusa `listGoogleCalendarPickerOptions` (inclusive o record de auth error), exige que `calendarId` esteja na lista viva (`not-listed` quando não está), no-op quando já é o atual, e escreve com `payload.update({ collection: 'googleCalendarSync', id, data: { calendarId }, depth: 0, overrideAccess: true })` — o hook `googleCalendarSyncConfigHook` (D7) dispara `runCampaignCalendarSync` no novo calendário. Nunca cria row (a row conectada existe; sem row → `not-connected`).
|
||||
- **Actions** (`src/app/(campaign)/campanha/actions/googleCalendarSync.ts`, mesmo padrão manual try/catch — sem `runCampaignFormAction`/zod):
|
||||
- `listGoogleCalendars(): Promise<GoogleCalendarListActionResult>` — `loadConnectionManager` (candidate/coordinator) + utility; falha de papel devolve `{ ok: false, message: GOOGLE_CALENDAR_PRIMARY_MANAGER_ONLY_MESSAGE }`.
|
||||
- `chooseGoogleCalendar(calendarId: string): Promise<GoogleCalendarSyncActionResult>` — `loadConnectionManager`; valida `typeof calendarId === 'string'` com tamanho sano; utility; sucesso devolve `{ ok: true, canManageConnection: true, ...withLinks(await readGoogleCalendarSyncView(payload)) }` (mesmo shape das outras actions); falha devolve `failure(message, true)`.
|
||||
- Tipos/mensagens novos: `GoogleCalendarPickerOption`, `GoogleCalendarListActionResult = { ok: true; calendars } | { ok: false; message }`, `GOOGLE_CALENDAR_PRIMARY_MANAGER_ONLY_MESSAGE`, `GOOGLE_CALENDAR_PICKER_NOT_CONNECTED_MESSAGE`, `GOOGLE_CALENDAR_PICKER_NOT_LISTED_MESSAGE`, `GOOGLE_CALENDAR_PICKER_LIST_FAILED_MESSAGE`.
|
||||
- **`buildGoogleCalendarAddLink`** (`src/lib/googleCalendarLink.ts`): `cid=${encodeURIComponent(calendarId)}` (literal da intenção); `buildGoogleCalendarWebcalUrl` e `buildGoogleCalendarPublicIcalUrl` permanecem exportados/intocados (Apple/Outlook). Atualizar o doc-comment do módulo (o `cid` não é mais a URL webcal).
|
||||
- **Migration:** **nenhuma** — `payload-types.ts` intocado. Débito S3 (constraint singleton) adiado de novo (D3).
|
||||
|
||||
**UI (Impeccable B — encaixe na agenda; shell/dialog existentes)**
|
||||
|
||||
- **`GoogleCalendarPickerDialog.tsx`** (novo, `src/components/campaign/activity/`): Dialog desktop / Drawer mobile com o mesmo chrome do `GoogleCalendarSyncDialog` (header/scroll-body/footer). Props `{ open, onOpenChange, currentCalendarId, onListCalendars, onChooseCalendar }`. Estados `loading | error | empty | ready` com "Tentar de novo"; lista de opções com `summary`, marca "em uso" quando `id === currentCalendarId` e hint "principal da conta" quando `primary`; seleção local; rodapé "Cancelar" / "Escolher calendário" (desabilitado sem seleção, sem mudança ou busy).
|
||||
- **`GoogleCalendarSyncDialog.tsx`**: prop nova `onOpenPicker: () => void`; no bloco `connected` (manager) botão "Escolher calendário principal" (ou "Trocar calendário principal" quando `state.calendarId`); non-manager vê a copy "O calendário principal da campanha é escolhido por candidato ou coordenação." (substitui "escolhido na configuração do Painel"); o `content` de `status === 'not-configured' && connection === 'connected'` passa a apontar para o botão (manager) / informar a escolha do núcleo (non-manager); bloco manual: input vira `buildGoogleCalendarPublicIcalUrl(state.calendarId)` (label "URL pública do calendário (Por URL, Apple Calendar e Outlook)") + âncora one-click "Adicionar ao meu Google Calendar" (`state.addLink`); instruções "Por URL" passam a casar com o input.
|
||||
- **`AgendaGoogleSyncChrome.tsx`**: props novas `onListCalendars`, `onChooseCalendar`; estado `pickerOpen`; `openPicker` fecha o diálogo do espelho e abre o picker (overlays não empilham — ver D5), `onOpenChange(false)` do picker reabre o do espelho; `handleChooseCalendar` chama a action e `setState(result)` no sucesso; registra `SetCampaignHeaderAction id="google-calendar-add"` com a âncora one-click quando `state.addLink` existe (desktop com rótulo `hidden md:inline-flex`, mobile ícone `md:hidden` com `aria-label`/`title` "Adicionar ao meu Google Calendar"); pill e auto-retry intocados; FAB continua abrindo o diálogo do espelho.
|
||||
- **`agenda/page.tsx`**: injeta as duas actions novas no chrome (`listGoogleCalendars`, `chooseGoogleCalendar`) junto das 5 atuais.
|
||||
- **`scripts/lib/e2e-affected-manifest.mjs`**: adicionar `src/lib/googleCalendarLink.ts` e `src/app/(campaign)/campanha/actions/googleCalendarSync.ts` à entry C149 do domínio → `campaignAgendaGoogleSync`.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
Não se aplica como apresentação de dado do Teqo (nenhuma leitura nova; a intenção adiou a forma). O único dado apresentado é a lista viva da conta Google, com shape mínimo `{ id, summary, primary }` e "em uso" derivado de `state.calendarId` — sem persistência. Forma escolhida: lista de opções (radio-like) com o calendário atual marcado; **rejeitadas:** select/combobox (esconde o contexto do "em uso" e o estado vazio), armazenar `summary` (D3), lista cacheada (D1).
|
||||
|
||||
## Decisões de engenharia (decisão + por quê + rejeitadas; só as caras de reverter)
|
||||
|
||||
1. **Listagem como método novo do client, OAuth-only na borda (D1).** `listCalendars()` entra na superfície do `GoogleCalendarClient` (transporte fino) e a exigência de OAuth fica no utility (`auth.kind !== 'oauth'` → `not-connected`). Params `minAccessRole=writer` + `showHidden=true` + paginação + filtro `deleted`. **Opções:** A) método no client + enforcement no utility | B) método restrito ao branch OAuth do tipo union | C) listar via service account. **Recomendação: A** — o client já é agnóstico de auth (SA/OAuth) e a autorização é concern do domínio; B bifurca o tipo sem ganho; C não enxerga os calendários compartilhados (a `calendarList` do SA só contém os dele). **Rejeitadas:** `showHidden=false` (o default — o calendário criado pela operação pode estar desmarcado no Google e sumir do picker, justamente o que o usuário quer escolher); cache da lista/summary na row (migration + drift, D3); `syncToken` incremental (complexidade sem ganho para uma lista de dezenas de itens).
|
||||
2. **Escrita do `calendarId`: action de manager + utility system write com validação viva (D2).** `chooseGoogleCalendar` → `setCampaignPrimaryCalendarId` com `overrideAccess: true`, validando que o id está na lista viva (`minAccessRole=writer`) da conta conectada. **Opções:** A) action + utility com bypass | B) abrir `canSetGoogleCalendarSyncConfigField` a `isCampaignUnrestricted` (hipótese da intenção) | C) novo field access por papel. **Recomendação: A** — a superfície REST do `campaignUser` nunca escreve id arbitrário; a validação de pertencimento roda server-side e o write segue o padrão C149; o admin continua podendo configurar direto no Painel (escape hatch). **Rejeitadas:** B porque field access não valida valor nem roda o record de auth error (e abriria `calendarId` cru ao REST); C porque fragmenta o enforcement sem cobrir a validação. **Divergência da intenção registrada:** o outcome (escolher sem admin) é preservado; o caminho é outro.
|
||||
3. **Sem migration; lista viva (D3).** Nenhum campo novo; o picker marca "em uso" por `state.calendarId`. **Opções:** A) não armazenar | B) `calendarSummary` na row. **Recomendação: A** — zero schema e zero drift. **Rejeitadas:** B (migration cosmética + summary desatualizado quando o calendário é renomeado no Google). **S3 (constraint singleton):** adiado de novo; o C150 não cria rows (só atualiza a row conectada), então o risco de twins segue teórico — gatilho de revisitação: qualquer caminho novo que crie row ou twins observados em prod; sem coluna natural de singleton, a constraint exigiria migration hand-written.
|
||||
4. **Erro de auth na listagem grava o estado de conexão (D4).** `GoogleCalendarAuthError` → `recordGoogleCalendarOAuthError` + falha `reason: 'auth-error'`. **Opções:** A) gravar só `invalid_grant` | B) não gravar | C) gravar qualquer erro. **Recomendação: A** — mesma classificação do motor (C149 D4): `invalid_grant` é reconectável e o card deriva `error`/Reconectar; o picker é outro consumidor da mesma credencial. **Rejeitadas:** B (card diria "Conectado" enquanto o picker falha — estado desonesto); C (`invalid_client`/5xx não se consertam reconectando). Residual aceito: o badge do card atualiza no próximo read de view; a mensagem do picker já instrui "Reconecte a conta".
|
||||
5. **Picker como diálogo próprio; chrome dono dos dois; sem empilhar overlays (D5).** Novo `GoogleCalendarPickerDialog` (Dialog/Drawer, mesmo chrome), aberto a partir do card de conexão para manager com `connection === 'connected'`. O chrome fecha o diálogo do espelho ao abrir o picker e o reabre quando o picker fecha — um overlay por vez (Radix/vaul aninhados têm scroll-lock/focus frágeis no mobile). **Opções:** A) diálogo próprio com troca de overlay | B) lista expansível dentro do `GoogleCalendarSyncDialog` | C) rota/página nova. **Recomendação: A** — o picker tem ciclo próprio (loading/erro/vazio/retry) e o diálogo do espelho já é denso; o chrome já é dono do estado (padrão C148). **Rejeitadas:** B (duas máquinas de estado num modal cheio); C (cerimônia e rota nova sem URL pública a proteger); empilhar os dois overlays (risco de focus/scroll no mobile).
|
||||
6. **One-click com `cid=<calendarId>` + reconciliação do bloco manual (D6).** `buildGoogleCalendarAddLink` passa a `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(calendarId)}`; header ganha a âncora (desktop com rótulo, mobile ícone + `aria-label`); o input do diálogo passa a `buildGoogleCalendarPublicIcalUrl(calendarId)` (o "Por URL" e Apple/Outlook assinam a URL iCal pública) + âncora one-click dentro do diálogo; `CalendarFeedDialog` (recorte ICS) intocado. **Opções:** A) literal da intenção + input iCal público | B) manter webcal no `cid` | C) dois inputs. **Recomendação: A** — o fluxo de adicionar calendário do Google interpreta `cid` como id do calendário; as instruções "Por URL" atuais já estavam desalinhadas com o `cid` webcal (o input antigo não era a URL iCal pública); Apple/Outlook continuam servidos pela URL iCal. **Rejeitadas:** B (infiel ao literal); C (mais chrome que o menor ajuste); remover o bloco manual (quebraria Apple/Outlook e o "copie e envie"); um-clique para o recorte ICS (anti-goal da intenção).
|
||||
7. **E2E sem interceptar fetch do servidor (D7 — emenda ao bullet do explorador).** `page.route` intercepta só o tráfego do browser; a listagem roda na server action (processo Next). **Opções:** A) e2e cobre contratos do browser (href do one-click, ausência para advisor, ícone mobile, botão do manager) + caminho feliz lista/escolhe no int com stub | B) env de base URL do Google para teste | C) semear refresh token real e chamar o Google. **Recomendação: A** — determinístico e sem rede (mesma doutrina C114/C149); o int prova a validação viva e o D7 com `listEvents(novoId)`. **Rejeitadas:** B (superfície de produção — base URL do Google por env — existindo só por teste); C (flake de rede/credencial dummy no CI).
|
||||
8. **Seam de teste por injeção de client nas utilities (D8).** `listGoogleCalendarPickerOptions`/`setCampaignPrimaryCalendarId` aceitam `options.client?: GoogleCalendarClient` (precedente `runCampaignCalendarSync.options.client`); o int novo mocka `@/utilities/googleCalendarClient` (stub com `listCalendars`/`listEvents` gravando o `calendarId`) e `campaignActionContext`, serializado por `serializeGoogleCalendarSyncSpec()`. **Opções:** A) injeção de client | B) mock global de `fetch` | C) só o fake credential (falha no JWT). **Recomendação: A** — prova direta da rejeição de id fora da lista e da reconciliação no novo calendário; C não prova qual calendário foi usado. **Rejeitadas:** B (o action não injeta `fetchImpl`; mockar `fetch` global vaza para o resto do spec).
|
||||
9. **Manifest cobre o literal e as actions (D9).** Adicionar `src/lib/googleCalendarLink.ts` e `src/app/(campaign)/campanha/actions/googleCalendarSync.ts` à entry C149 → `campaignAgendaGoogleSync`. **Rejeitada:** confiar no smoke fallback (o literal do link e as actions não acordariam o spec da agenda).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer/servidor (~50%, ≈0,8–1 dia)**
|
||||
- `listCalendars` no client + unit em `tests/unit/googleCalendarClient.unit.spec.ts` (params `minAccessRole=writer`/`showHidden=true`, paginação por `nextPageToken`, filtro `deleted`, fallback do summary, retry de 401, `invalid_grant` → `GoogleCalendarAuthError`).
|
||||
- Utilities `listGoogleCalendarPickerOptions`/`setCampaignPrimaryCalendarId` + actions `listGoogleCalendars`/`chooseGoogleCalendar` + tipos/mensagens.
|
||||
- Int novo `tests/int/googleCalendarPrimaryCalendarAction.int.spec.ts` (mocks do client e do contexto + advisory lock): advisor recusado; lista para coordinator; `invalid_grant` grava `oauthErrorAt/oauthError`; id fora da lista rejeitado sem write; id listado escreve `calendarId` e o hook D7 reconcilia no novo (`listEvents` chamado com o novo id); no-op quando já é o atual.
|
||||
- Gate: `pnpm gate:fast`.
|
||||
2. **UI + links (~35%, ≈0,6 dia)**
|
||||
- `buildGoogleCalendarAddLink` literal + pins (`tests/unit/googleCalendarLink.unit.spec.ts`, e2e do input).
|
||||
- `GoogleCalendarPickerDialog` novo + `tests/unit/googleCalendarPickerDialog.unit.spec.tsx` (loading/erro/vazio/lista, "em uso", confirmar desabilitado sem mudança, escolha chama o callback).
|
||||
- `GoogleCalendarSyncDialog` (botão/copy/iCal público/âncora) + `tests/unit/googleCalendarSyncDialog.unit.spec.tsx`; `AgendaGoogleSyncChrome` (estado dos dois diálogos + header add) + `tests/unit/agendaGoogleSyncChrome.unit.spec.tsx`; `page.tsx` injeta as actions.
|
||||
- Gate: `pnpm gate:fast`.
|
||||
3. **E2E + manifest + gates (~15%, ≈0,3 dia)**
|
||||
- `tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts`: href do one-click com `cid=<calendarId>` e `target=_blank`; advisor sem o botão + copy; manager com "Trocar calendário principal"; mobile com o ícone (`aria-label`); input do diálogo com a URL iCal pública.
|
||||
- Manifest (D9). Gate: `pnpm gate:fast`; `pnpm test:e2e:affected` (deve incluir `campaignAgendaGoogleSync`); `pnpm push`.
|
||||
|
||||
**Cortes se o appetite estourar (nesta ordem):** (1) hint "principal da conta" e polish do picker; (2) âncora one-click dentro do diálogo (mantém header + input iCal); (3) asserção mobile no e2e (unit cobre o ícone); (4) `fields` no request da listagem. **Nunca cortar:** a validação do id na lista viva (D2), o record do auth error (D4) e o int do D7.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Backfill/limpeza do calendário abandonado:** trocar `calendarId` reusa a reconciliação D7 e o calendário antigo fica congelado (design C114 D7, provado em `tests/int/googleCalendarSync.int.spec.ts:396-418`); nenhum retro-cleanup.
|
||||
- **Limpar `calendarId` no disconnect:** C149 intocado; gatilho de revisitação — suporte observar "reconectei outra conta e o espelho pausou"; a UI do picker já lida (nenhum item "em uso") e o manager reescolhe.
|
||||
- **Estado de confirmação pós-one-click** ("Agenda adicionada"): o Google não chama de volta; a cena do rascunho não é implementável.
|
||||
- **One-click para o recorte ICS / feed externo:** anti-goal (cid infiel); `CalendarFeedDialog` fica como está.
|
||||
- **OAuth por pessoa, N calendários, calendário por assessor:** anti-goals C114/intenção.
|
||||
- **Cache da lista/summary, `syncToken` incremental:** D1/D3.
|
||||
- **Constraint singleton (S3):** adiada de novo (D3), com gatilho.
|
||||
- **Env de base URL do Google para teste:** D7.
|
||||
- **Mexer no motor C114/C115** (snapshot pino, canal push, janela, locks) e no `GoogleCalendarSyncDialog` além dos blocos descritos: fora.
|
||||
- **Rate limiting na listagem:** manager-only e aberta por gesto; sem superfície nova de abuso.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Listagem pode segurar o diálogo (token + N páginas, cada hop 15s):** o picker tem estado de loading e "Tentar de novo"; `REQUEST_TIMEOUT_MS` existente limita cada hop; ação de manager, sem caminho crítico da agenda.
|
||||
- **Calendário some entre a listagem e a escolha:** `setCampaignPrimaryCalendarId` re-lista e rejeita (`not-listed`); a mensagem pede atualizar a lista.
|
||||
- **`invalid_grant` no meio do fluxo:** a listagem grava o erro de conexão; o picker mostra a mensagem e o card deriva `error`/Reconectar no próximo read (residual documentado em D4).
|
||||
- **Crowding do header desktop** (pill + rótulo longo + import + IA + sino): rótulo com `shrink-0`/`whitespace-nowrap`; encurtar a copy ou esconder o rótulo em `md` (cheap polish) se a inspeção visual acusar.
|
||||
- **Overlays empilhados no mobile:** evitado por desenho (D5) — o picker substitui o diálogo do espelho enquanto aberto.
|
||||
- **E2E da agenda é serial e compartilha a row singleton:** os seeds novos usam `lastSuccessAt` futuro (imune a `lastErrorAt` de specs paralelas) e nunca disparam action contra o Google; sem refresh token real nos seeds que abrem o picker (o botão não é clicado no e2e).
|
||||
- **Sem rede no e2e:** o caminho feliz lista/escolhe é int com stub; a prova de produção fica no smoke manual pós-deploy (mesma doutrina C149).
|
||||
- **`buildGoogleCalendarPublicIcalUrl` passa a ser usado** (era export não consumido em `src`): sem impacto; knip já tolerava.
|
||||
- **`unmapped-risk` no CI:** as mudanças tocam `src/utilities/access`? Não — o access fica intocado (D2); o manifest entra no mesmo PR.
|
||||
|
||||
## Qualidade da decisão
|
||||
|
||||
**Auto-avaliação: 4/5** — todas as decisões caras (listagem, escrita do `calendarId`, schema, auth error, UI, literal do link, estratégia de teste) têm recomendação e rejeitadas ancoradas em achados concretos (union de auth C149, precedente de system write, hook D7, pins de teste, manifest); o appetite cabe (~1,7–1,9 dia) e o depth check reusa client/utility/action/dialog/hook existentes, criando só o componente de picker (ciclo de estados próprio). Perde 1 ponto porque o caminho feliz da listagem real (shape do Google, `showHidden`, paginação) só é provado por unit/int com stub — sem e2e de rede (D7) —, então a verificação contra o Google real fica no smoke pós-deploy.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto: candidato/coordenador escolhe o calendário principal na agenda; create/edit espelham nele; staff adiciona a agenda ao Google com um clique; recorte ICS intocado; admin do Payload segue escape hatch.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: `leader` sem agenda; escrita system com `overrideAccess` justificado e papel checado na action; identificadores em inglês, copy pt-BR; sem migration (nenhuma antiga editada).
|
||||
- [ ] `calendarId` só é escrito após validação de que o id está na lista viva da conta conectada (`minAccessRole=writer`); `canSetGoogleCalendarSyncConfigField` segue admin-only.
|
||||
- [ ] Trocar o calendário dispara a reconciliação D7 no novo calendário (int prova `listEvents(novoId)`); nenhum backfill/limpeza no calendário antigo.
|
||||
- [ ] `GoogleCalendarAuthError` na listagem/escolha grava `oauthErrorAt/oauthError` e o card deriva `error`/Reconectar; `invalid_client`/5xx não sujam a conexão.
|
||||
- [ ] `buildGoogleCalendarAddLink` = `cid=<calendarId URL-encoded>`; âncora de header (desktop com rótulo, mobile ícone + `aria-label`) visível só com `addLink`; diálogo mantém URL iCal pública para "Por URL"/Apple/Outlook.
|
||||
- [ ] Testes previstos verdes: unit do client/link/picker/sync dialog/chrome; int novo das actions (papel, id fora da lista, write + D7); e2e `campaignAgendaGoogleSync` estendido (href, advisor, manager, mobile).
|
||||
- [ ] `pnpm gate:fast` verde; `pnpm test:e2e:affected` inclui `campaignAgendaGoogleSync` (manifest cobre `googleCalendarLink.ts` e `actions/googleCalendarSync.ts`) e passa local; `pnpm push`.
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ import {
|
|||
revokeCalendarFeed,
|
||||
} from '@/app/(campaign)/campanha/actions/calendarFeed'
|
||||
import {
|
||||
chooseGoogleCalendar,
|
||||
disconnectGoogleCalendarOAuth,
|
||||
getGoogleCalendarSyncState,
|
||||
listGoogleCalendars,
|
||||
runGoogleCalendarSyncNow,
|
||||
setGoogleCalendarSyncDisabled,
|
||||
startGoogleCalendarOAuth,
|
||||
|
|
@ -112,6 +114,14 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
|
|||
'use server'
|
||||
return disconnectGoogleCalendarOAuth()
|
||||
}}
|
||||
onListCalendars={async () => {
|
||||
'use server'
|
||||
return listGoogleCalendars()
|
||||
}}
|
||||
onChooseCalendar={async (calendarId) => {
|
||||
'use server'
|
||||
return chooseGoogleCalendar(calendarId)
|
||||
}}
|
||||
/>
|
||||
|
||||
<CampaignListPendingBoundary>
|
||||
|
|
|
|||
|
|
@ -20,9 +20,12 @@ import {
|
|||
} from '@/utilities/googleCalendarOAuth'
|
||||
import {
|
||||
clearGoogleCalendarOAuthConnection,
|
||||
listGoogleCalendarPickerOptions,
|
||||
loadGoogleCalendarSyncConfig,
|
||||
readGoogleCalendarSyncView,
|
||||
runCampaignCalendarSync,
|
||||
setCampaignPrimaryCalendarId,
|
||||
type GoogleCalendarPickerOption,
|
||||
type GoogleCalendarSyncView,
|
||||
} from '@/utilities/googleCalendarSync'
|
||||
|
||||
|
|
@ -34,6 +37,9 @@ const GOOGLE_CALENDAR_OAUTH_MANAGER_ONLY_MESSAGE =
|
|||
'Apenas candidato ou coordenação pode conectar ou desconectar a conta Google da campanha.'
|
||||
const GOOGLE_CALENDAR_OAUTH_UNAVAILABLE_MESSAGE =
|
||||
'A conexão com o Google ainda não está configurada no servidor. Fale com a equipe técnica.'
|
||||
const GOOGLE_CALENDAR_PRIMARY_MANAGER_ONLY_MESSAGE =
|
||||
'Apenas candidato ou coordenação pode escolher o calendário principal da campanha.'
|
||||
const GOOGLE_CALENDAR_PRIMARY_INVALID_MESSAGE = 'Calendário inválido.'
|
||||
|
||||
export type GoogleCalendarSyncActionResult = {
|
||||
ok: boolean
|
||||
|
|
@ -42,6 +48,10 @@ export type GoogleCalendarSyncActionResult = {
|
|||
canManageConnection: boolean
|
||||
} & GoogleCalendarSyncView & { addLink: string | null }
|
||||
|
||||
export type GoogleCalendarListActionResult =
|
||||
| { ok: true; calendars: GoogleCalendarPickerOption[] }
|
||||
| { ok: false; message: string }
|
||||
|
||||
export type GoogleCalendarOAuthStartResult =
|
||||
| { ok: true; authorizeUrl: string }
|
||||
| { ok: false; message: string }
|
||||
|
|
@ -156,6 +166,57 @@ export const setGoogleCalendarSyncDisabled = async (
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C150 — lists the calendars of the OAuth-connected account for the primary
|
||||
* calendar picker. Candidate/coordinator only (the picker is a nucleus
|
||||
* decision); a Google failure maps to a safe message and never throws.
|
||||
*/
|
||||
export const listGoogleCalendars = async (): Promise<GoogleCalendarListActionResult> => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
|
||||
try {
|
||||
if (!(await loadConnectionManager(payload, actor))) {
|
||||
return { ok: false, message: GOOGLE_CALENDAR_PRIMARY_MANAGER_ONLY_MESSAGE }
|
||||
}
|
||||
const result = await listGoogleCalendarPickerOptions(payload)
|
||||
if (!result.ok) return { ok: false, message: result.message }
|
||||
return { ok: true, calendars: result.calendars }
|
||||
} catch (error) {
|
||||
console.error('listGoogleCalendars failed', error)
|
||||
return { ok: false, message: GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C150 — sets the campaign's primary calendar from the picker. The utility
|
||||
* validates the id against the connected account's live writable list and the
|
||||
* `afterChange` config hook (D7) reconciles the mirror into the new calendar.
|
||||
*/
|
||||
export const chooseGoogleCalendar = async (
|
||||
calendarId: string,
|
||||
): Promise<GoogleCalendarSyncActionResult> => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
|
||||
try {
|
||||
const currentActor = await loadConnectionManager(payload, actor)
|
||||
if (!currentActor) return failure(GOOGLE_CALENDAR_PRIMARY_MANAGER_ONLY_MESSAGE, false)
|
||||
if (typeof calendarId !== 'string' || calendarId.length === 0 || calendarId.length > 1024) {
|
||||
return failure(GOOGLE_CALENDAR_PRIMARY_INVALID_MESSAGE, true)
|
||||
}
|
||||
|
||||
const result = await setCampaignPrimaryCalendarId(payload, calendarId)
|
||||
if (!result.ok) return failure(result.message, true)
|
||||
return {
|
||||
ok: true,
|
||||
canManageConnection: true,
|
||||
...withLinks(await readGoogleCalendarSyncView(payload)),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('chooseGoogleCalendar failed', error)
|
||||
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE, canManageGoogleCalendarConnection(actor))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C149 — starts the OAuth handshake: validates the manager role, ensures the
|
||||
* singleton connection row exists, stores the signed single-use `state` cookie
|
||||
|
|
|
|||
|
|
@ -2,13 +2,18 @@
|
|||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { CalendarPlusIcon } from 'lucide-react'
|
||||
|
||||
import type {
|
||||
GoogleCalendarListActionResult,
|
||||
GoogleCalendarOAuthStartResult,
|
||||
GoogleCalendarSyncActionResult,
|
||||
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
||||
import { GoogleCalendarPickerDialog } from '@/components/campaign/activity/GoogleCalendarPickerDialog'
|
||||
import { GoogleCalendarSyncDialog } from '@/components/campaign/activity/GoogleCalendarSyncDialog'
|
||||
import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { useBridgedQuickAction } from '@/components/campaign/shell/CampaignQuickActionContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { GoogleCalendarSyncStatus } from '@/utilities/googleCalendarSync'
|
||||
|
||||
type AgendaGoogleSyncChromeProps = {
|
||||
|
|
@ -19,6 +24,10 @@ type AgendaGoogleSyncChromeProps = {
|
|||
onStartOAuth: () => Promise<GoogleCalendarOAuthStartResult>
|
||||
/** C149 — drops the OAuth connection from the Teqo. */
|
||||
onDisconnect: () => Promise<GoogleCalendarSyncActionResult>
|
||||
/** C150 — lists the connected account's writable calendars (picker). */
|
||||
onListCalendars: () => Promise<GoogleCalendarListActionResult>
|
||||
/** C150 — sets the campaign's primary calendar. */
|
||||
onChooseCalendar: (calendarId: string) => Promise<GoogleCalendarSyncActionResult>
|
||||
}
|
||||
|
||||
const PILL_COPY: Record<
|
||||
|
|
@ -47,11 +56,18 @@ const PILL_COPY: Record<
|
|||
},
|
||||
}
|
||||
|
||||
const ADD_TO_GOOGLE_LABEL = 'Adicionar ao meu Google Calendar'
|
||||
|
||||
/**
|
||||
* C114 — agenda page → app chrome bridge for the Google Calendar mirror
|
||||
* (same pattern as AgendaFeedChrome/C94): the status pill registers in the
|
||||
* desktop header cluster (after "Link de import"), the mobile FAB opens the
|
||||
* same dialog, and a paused mirror auto-retries once on mount.
|
||||
*
|
||||
* C150 — the chrome also owns the primary-calendar picker (one overlay at a
|
||||
* time: opening the picker closes the mirror dialog and closing it brings the
|
||||
* dialog back) and registers the one-click "add to my Google Calendar" link
|
||||
* for every staff member as soon as a primary calendar exists.
|
||||
*/
|
||||
export const AgendaGoogleSyncChrome = ({
|
||||
initialState,
|
||||
|
|
@ -59,8 +75,11 @@ export const AgendaGoogleSyncChrome = ({
|
|||
onSetDisabled,
|
||||
onStartOAuth,
|
||||
onDisconnect,
|
||||
onListCalendars,
|
||||
onChooseCalendar,
|
||||
}: AgendaGoogleSyncChromeProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [state, setState] = useState(initialState)
|
||||
const autoRetriedRef = useRef(false)
|
||||
|
||||
|
|
@ -99,6 +118,27 @@ export const AgendaGoogleSyncChrome = ({
|
|||
return result
|
||||
}, [onDisconnect])
|
||||
|
||||
// C150 — one overlay at a time: the picker replaces the mirror dialog while
|
||||
// open (nested Radix/vaul overlays have fragile focus/scroll on mobile).
|
||||
const openPicker = useCallback(() => {
|
||||
setOpen(false)
|
||||
setPickerOpen(true)
|
||||
}, [])
|
||||
|
||||
const handlePickerOpenChange = useCallback((next: boolean) => {
|
||||
setPickerOpen(next)
|
||||
if (!next) setOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleChooseCalendar = useCallback(
|
||||
async (calendarId: string) => {
|
||||
const result = await onChooseCalendar(calendarId)
|
||||
if (result.ok) setState(result)
|
||||
return result
|
||||
},
|
||||
[onChooseCalendar],
|
||||
)
|
||||
|
||||
const pill = useMemo(() => {
|
||||
const copy = PILL_COPY[state.status]
|
||||
return (
|
||||
|
|
@ -115,9 +155,41 @@ export const AgendaGoogleSyncChrome = ({
|
|||
)
|
||||
}, [state.status, openSync])
|
||||
|
||||
const addToGoogle = useMemo(() => {
|
||||
if (!state.addLink) return null
|
||||
return (
|
||||
<>
|
||||
<Button asChild size="sm" className="hidden shrink-0 whitespace-nowrap md:inline-flex">
|
||||
<a
|
||||
href={state.addLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={ADD_TO_GOOGLE_LABEL}
|
||||
>
|
||||
<CalendarPlusIcon className="mr-2 size-4" aria-hidden />
|
||||
{ADD_TO_GOOGLE_LABEL}
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 shrink-0 md:hidden"
|
||||
aria-label={ADD_TO_GOOGLE_LABEL}
|
||||
title={ADD_TO_GOOGLE_LABEL}
|
||||
>
|
||||
<a href={state.addLink} target="_blank" rel="noopener noreferrer">
|
||||
<CalendarPlusIcon className="size-5" aria-hidden />
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}, [state.addLink])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SetCampaignHeaderAction id="google-calendar-sync">{pill}</SetCampaignHeaderAction>
|
||||
<SetCampaignHeaderAction id="google-calendar-add">{addToGoogle}</SetCampaignHeaderAction>
|
||||
|
||||
<GoogleCalendarSyncDialog
|
||||
open={open}
|
||||
|
|
@ -127,6 +199,15 @@ export const AgendaGoogleSyncChrome = ({
|
|||
onSetDisabled={handleSetDisabled}
|
||||
onStartOAuth={onStartOAuth}
|
||||
onDisconnect={handleDisconnect}
|
||||
onOpenPicker={openPicker}
|
||||
/>
|
||||
|
||||
<GoogleCalendarPickerDialog
|
||||
open={pickerOpen}
|
||||
onOpenChange={handlePickerOpenChange}
|
||||
currentCalendarId={state.calendarId}
|
||||
onListCalendars={onListCalendars}
|
||||
onChooseCalendar={handleChooseCalendar}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
252
src/components/campaign/activity/GoogleCalendarPickerDialog.tsx
Normal file
252
src/components/campaign/activity/GoogleCalendarPickerDialog.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { RefreshCwIcon } from 'lucide-react'
|
||||
|
||||
import type {
|
||||
GoogleCalendarListActionResult,
|
||||
GoogleCalendarSyncActionResult,
|
||||
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@/components/ui/Drawer'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { GoogleCalendarPickerOption } from '@/utilities/googleCalendarSync'
|
||||
|
||||
type GoogleCalendarPickerDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** The calendar currently in use, marked and preselected. */
|
||||
currentCalendarId: string | null
|
||||
onListCalendars: () => Promise<GoogleCalendarListActionResult>
|
||||
onChooseCalendar: (calendarId: string) => Promise<GoogleCalendarSyncActionResult>
|
||||
}
|
||||
|
||||
type PickerListStatus = 'loading' | 'error' | 'ready'
|
||||
|
||||
const TITLE = 'Calendário principal da campanha'
|
||||
const DESCRIPTION = 'Criar e editar atividades espelham no calendário escolhido.'
|
||||
|
||||
/**
|
||||
* C150 — primary-calendar picker. The list is fetched live from the connected
|
||||
* Google account on every open (the account's calendars can change under us);
|
||||
* only candidate/coordination reach this dialog (the trigger is gated in the
|
||||
* sync dialog) and the server action re-validates both the role and the
|
||||
* calendar membership before writing.
|
||||
*/
|
||||
export const GoogleCalendarPickerDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentCalendarId,
|
||||
onListCalendars,
|
||||
onChooseCalendar,
|
||||
}: GoogleCalendarPickerDialogProps) => {
|
||||
const isMobile = useIsMobile()
|
||||
const [status, setStatus] = useState<PickerListStatus>('loading')
|
||||
const [calendars, setCalendars] = useState<GoogleCalendarPickerOption[]>([])
|
||||
const [listError, setListError] = useState<string | null>(null)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [chooseError, setChooseError] = useState<string | null>(null)
|
||||
const [isChoosing, setIsChoosing] = useState(false)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
setStatus('loading')
|
||||
setListError(null)
|
||||
setChooseError(null)
|
||||
void onListCalendars().then((result) => {
|
||||
if (cancelled) return
|
||||
if (result.ok) {
|
||||
setCalendars(result.calendars)
|
||||
setSelectedId(currentCalendarId)
|
||||
setStatus('ready')
|
||||
} else {
|
||||
setListError(result.message)
|
||||
setStatus('error')
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, attempt, onListCalendars, currentCalendarId])
|
||||
|
||||
const handleChoose = async () => {
|
||||
if (!selectedId || selectedId === currentCalendarId || isChoosing) return
|
||||
setIsChoosing(true)
|
||||
setChooseError(null)
|
||||
try {
|
||||
const result = await onChooseCalendar(selectedId)
|
||||
if (result.ok) {
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
setChooseError(result.message ?? 'Não foi possível escolher o calendário.')
|
||||
}
|
||||
} finally {
|
||||
setIsChoosing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const listContent =
|
||||
status === 'loading' ? (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground" aria-live="polite">
|
||||
<RefreshCwIcon className="h-4 w-4 animate-spin" aria-hidden />
|
||||
Carregando calendários...
|
||||
</p>
|
||||
) : status === 'error' ? (
|
||||
<div className="space-y-3">
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
{listError}
|
||||
</p>
|
||||
<Button type="button" variant="outline" onClick={() => setAttempt((value) => value + 1)}>
|
||||
Tentar de novo
|
||||
</Button>
|
||||
</div>
|
||||
) : calendars.length === 0 ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhum calendário com permissão de edição nesta conta Google.
|
||||
</p>
|
||||
<Button type="button" variant="outline" onClick={() => setAttempt((value) => value + 1)}>
|
||||
Atualizar lista
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ul
|
||||
role="radiogroup"
|
||||
aria-label="Calendários da conta Google conectada"
|
||||
className="space-y-2"
|
||||
>
|
||||
{calendars.map((calendar) => {
|
||||
const isSelected = selectedId === calendar.id
|
||||
const isCurrent = currentCalendarId === calendar.id
|
||||
return (
|
||||
<li key={calendar.id}>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
onClick={() => setSelectedId(calendar.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring',
|
||||
isSelected ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full border',
|
||||
isSelected ? 'border-primary' : 'border-muted-foreground',
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{isSelected ? <span className="h-2 w-2 rounded-full bg-primary" /> : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm">{calendar.summary}</span>
|
||||
{calendar.primary ? (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Calendário principal da conta
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isCurrent ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">em uso</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
|
||||
const chooseErrorNotice = chooseError ? (
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
{chooseError}
|
||||
</p>
|
||||
) : null
|
||||
|
||||
const actions =
|
||||
status === 'ready' && calendars.length > 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{chooseErrorNotice}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isChoosing}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleChoose()}
|
||||
disabled={!selectedId || selectedId === currentCalendarId || isChoosing}
|
||||
>
|
||||
{isChoosing ? 'Escolhendo...' : 'Escolher calendário'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange} showSwipeHandle>
|
||||
<DrawerContent className="max-h-[85vh]">
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle>{TITLE}</DrawerTitle>
|
||||
<DrawerDescription>{DESCRIPTION}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="overflow-y-auto px-4 pb-2">
|
||||
<div className="space-y-4">
|
||||
{listContent}
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
<DrawerFooter className="border-t">
|
||||
<DrawerCloseButton className="w-full">Fechar</DrawerCloseButton>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-lg sm:p-0">
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
|
||||
<DialogTitle>{TITLE}</DialogTitle>
|
||||
<DialogDescription>{DESCRIPTION}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div
|
||||
data-slot="dialog-scroll-body"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4"
|
||||
>
|
||||
{listContent}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div data-slot="dialog-footer" className="shrink-0 border-t px-6 py-4">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
import { buildGoogleCalendarPublicIcalUrl } from '@/lib/googleCalendarLink'
|
||||
import type { GoogleCalendarConnectionStatus } from '@/utilities/googleCalendarSync'
|
||||
|
||||
type GoogleCalendarSyncDialogProps = {
|
||||
|
|
@ -40,6 +41,8 @@ type GoogleCalendarSyncDialogProps = {
|
|||
onStartOAuth: () => Promise<GoogleCalendarOAuthStartResult>
|
||||
/** C149 — drops the OAuth connection from the Teqo. */
|
||||
onDisconnect: () => Promise<GoogleCalendarSyncActionResult>
|
||||
/** C150 — opens the primary-calendar picker (candidate/coordination only). */
|
||||
onOpenPicker: () => void
|
||||
}
|
||||
|
||||
const NOT_CONFIGURED_RUNBOOK: readonly string[] = [
|
||||
|
|
@ -85,6 +88,7 @@ export const GoogleCalendarSyncDialog = ({
|
|||
onSetDisabled,
|
||||
onStartOAuth,
|
||||
onDisconnect,
|
||||
onOpenPicker,
|
||||
}: GoogleCalendarSyncDialogProps) => {
|
||||
const isMobile = useIsMobile()
|
||||
const [isBusy, setIsBusy] = useState(false)
|
||||
|
|
@ -96,9 +100,13 @@ export const GoogleCalendarSyncDialog = ({
|
|||
setActionError(null)
|
||||
}
|
||||
|
||||
// C150 — the manual path ("Por URL", Apple Calendar, Outlook) consumes the
|
||||
// public iCal URL; the one-click button consumes `state.addLink`.
|
||||
const icalUrl = state.calendarId ? buildGoogleCalendarPublicIcalUrl(state.calendarId) : null
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!state.addLink) return
|
||||
await navigator.clipboard.writeText(state.addLink)
|
||||
if (!icalUrl) return
|
||||
await navigator.clipboard.writeText(icalUrl)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
|
@ -135,36 +143,51 @@ export const GoogleCalendarSyncDialog = ({
|
|||
|
||||
const handleDisconnect = () => runAction(onDisconnect, 'Não foi possível desconectar.')
|
||||
|
||||
const linkBlock = state.addLink ? (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="google-calendar-link">
|
||||
Link do calendário (copie e envie à equipe)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="google-calendar-link"
|
||||
value={state.addLink}
|
||||
readOnly
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copiar link"
|
||||
>
|
||||
{copyIcon}
|
||||
const linkBlock = state.calendarId ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Adicionar ao meu Google Calendar</p>
|
||||
<Button asChild className="w-full sm:w-auto">
|
||||
<a href={state.addLink ?? undefined} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLinkIcon className="mr-2 h-4 w-4" />
|
||||
Adicionar ao meu Google Calendar
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
{copied && (
|
||||
<p className="text-xs text-green-600" aria-live="polite">
|
||||
Link copiado!
|
||||
<p className="text-xs text-muted-foreground">
|
||||
O Google abre com o calendário da campanha pronto para assinar — vale para o computador e
|
||||
o celular.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Quem segue o calendário recebe aviso conforme as próprias configurações do Google.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="google-calendar-link">
|
||||
URL pública do calendário (Por URL, Apple Calendar e Outlook)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="google-calendar-link"
|
||||
value={icalUrl ?? ''}
|
||||
readOnly
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copiar link"
|
||||
>
|
||||
{copyIcon}
|
||||
</Button>
|
||||
</div>
|
||||
{copied && (
|
||||
<p className="text-xs text-green-600" aria-live="polite">
|
||||
Link copiado!
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Quem segue o calendário recebe aviso conforme as próprias configurações do Google.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
|
|
@ -330,6 +353,38 @@ export const GoogleCalendarSyncDialog = ({
|
|||
? `Conta Google conectada em ${formatBahiaDateTimeLabel(state.oauthConnectedAt)}.`
|
||||
: 'Conta Google conectada.'}
|
||||
</p>
|
||||
<div className="rounded-md border border-dashed border-border p-4">
|
||||
<p className="text-sm font-medium">Calendário principal da campanha</p>
|
||||
{state.canManageConnection ? (
|
||||
<>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{state.calendarId
|
||||
? 'O espelho escreve neste calendário:'
|
||||
: 'Nenhum calendário escolhido ainda — o espelho fica parado até a escolha.'}
|
||||
</p>
|
||||
{state.calendarId ? (
|
||||
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
|
||||
{state.calendarId}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-3"
|
||||
onClick={onOpenPicker}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{state.calendarId
|
||||
? 'Trocar calendário principal'
|
||||
: 'Escolher calendário principal'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
O calendário principal é escolhido por candidato ou coordenação.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{connectionActions}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Desconectar revoga o acesso do Teqo; depois, revogue o app nas configurações de
|
||||
|
|
@ -364,7 +419,9 @@ export const GoogleCalendarSyncDialog = ({
|
|||
content =
|
||||
state.connection === 'connected' ? (
|
||||
<p className="rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
||||
A conexão está ativa. O calendário da campanha é escolhido na configuração do Painel.
|
||||
{state.canManageConnection
|
||||
? 'Escolha o calendário principal para a agenda começar a espelhar no Google.'
|
||||
: 'O calendário principal da campanha é escolhido por candidato ou coordenação.'}
|
||||
</p>
|
||||
) : null
|
||||
} else if (state.status === 'disabled') {
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ const GOOGLE_CALENDAR_API_BASE = 'https://www.googleapis.com/calendar/v3'
|
|||
const GOOGLE_TOKEN_TTL_SECONDS = 3600
|
||||
const TOKEN_REFRESH_LEAD_SECONDS = 60
|
||||
const MAX_LIST_PAGE_SIZE = 2500
|
||||
/** calendarList caps `maxResults` at 250 (unlike events). */
|
||||
const CALENDAR_LIST_PAGE_SIZE = 250
|
||||
/**
|
||||
* Every outbound call carries a hard timeout so a hanging Google never holds
|
||||
* the activity write path (the afterChange hook awaits the sync) — Teqo's
|
||||
|
|
@ -76,7 +78,23 @@ export class GoogleCalendarAuthError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C150 — one entry of the connected account's calendar list, reduced to what
|
||||
* the picker renders. `summary` falls back to the id when Google omits it.
|
||||
*/
|
||||
export type GoogleCalendarListEntry = {
|
||||
id: string
|
||||
summary: string
|
||||
primary: boolean
|
||||
}
|
||||
|
||||
export type GoogleCalendarClient = {
|
||||
/**
|
||||
* C150 — the calendars the connected account can WRITE to (`minAccessRole=writer`),
|
||||
* for the campaign's primary-calendar picker. The mirror only works on a
|
||||
* calendar the account owns/edits, so read-only subscriptions never show up.
|
||||
*/
|
||||
listCalendars: () => Promise<GoogleCalendarListEntry[]>
|
||||
listEvents: (
|
||||
calendarId: string,
|
||||
range: { timeMin: string; timeMax: string },
|
||||
|
|
@ -261,6 +279,42 @@ export const createGoogleCalendarClient = (
|
|||
.map((part) => `/${encodeURIComponent(part)}`)
|
||||
.join('')}`
|
||||
|
||||
const listCalendars: GoogleCalendarClient['listCalendars'] = async () => {
|
||||
const calendars: GoogleCalendarListEntry[] = []
|
||||
let pageToken: string | undefined
|
||||
|
||||
do {
|
||||
const params = new URLSearchParams({
|
||||
minAccessRole: 'writer',
|
||||
// A calendar the operation hid in the Google sidebar is still a valid
|
||||
// mirror target — hiding is a UI preference, not a permission.
|
||||
showHidden: 'true',
|
||||
maxResults: String(CALENDAR_LIST_PAGE_SIZE),
|
||||
fields: 'items(id,summary,primary,deleted),nextPageToken',
|
||||
})
|
||||
if (pageToken) params.set('pageToken', pageToken)
|
||||
|
||||
const response = await apiFetch(`${GOOGLE_CALENDAR_API_BASE}/users/me/calendarList?${params}`)
|
||||
const body = (await response.json()) as {
|
||||
items?: Array<{ id?: unknown; summary?: unknown; primary?: unknown; deleted?: unknown }>
|
||||
nextPageToken?: string
|
||||
}
|
||||
for (const item of body.items ?? []) {
|
||||
// Deleted calendars linger in the list until Google purges them.
|
||||
if (typeof item.id !== 'string' || item.id.length === 0 || item.deleted === true) continue
|
||||
calendars.push({
|
||||
id: item.id,
|
||||
summary:
|
||||
typeof item.summary === 'string' && item.summary.length > 0 ? item.summary : item.id,
|
||||
primary: item.primary === true,
|
||||
})
|
||||
}
|
||||
pageToken = body.nextPageToken
|
||||
} while (pageToken)
|
||||
|
||||
return calendars
|
||||
}
|
||||
|
||||
const listEvents: GoogleCalendarClient['listEvents'] = async (calendarId, range) => {
|
||||
const events: GoogleRemoteEvent[] = []
|
||||
let pageToken: string | undefined
|
||||
|
|
@ -357,5 +411,13 @@ export const createGoogleCalendarClient = (
|
|||
}
|
||||
}
|
||||
|
||||
return { listEvents, insertEvent, updateEvent, deleteEvent, watchEvents, stopChannel }
|
||||
return {
|
||||
listCalendars,
|
||||
listEvents,
|
||||
insertEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
watchEvents,
|
||||
stopChannel,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
GoogleCalendarAuthError,
|
||||
type GoogleCalendarAuth,
|
||||
type GoogleCalendarClient,
|
||||
type GoogleCalendarListEntry,
|
||||
type GoogleCalendarServiceAccountCredentials,
|
||||
} from '@/utilities/googleCalendarClient'
|
||||
import {
|
||||
|
|
@ -896,6 +897,114 @@ export const clearGoogleCalendarOAuthConnection = async (payload: Payload): Prom
|
|||
})
|
||||
}
|
||||
|
||||
/** C150 — one calendar option of the primary-calendar picker. */
|
||||
export type GoogleCalendarPickerOption = GoogleCalendarListEntry
|
||||
|
||||
export type GoogleCalendarPickerListResult =
|
||||
| { ok: true; calendars: GoogleCalendarPickerOption[] }
|
||||
| { ok: false; reason: 'not-connected' | 'auth-error' | 'api-error'; message: string }
|
||||
|
||||
export type GoogleCalendarPrimaryCalendarResult =
|
||||
| { ok: true; calendarId: string; changed: boolean }
|
||||
| {
|
||||
ok: false
|
||||
reason: 'not-connected' | 'auth-error' | 'api-error' | 'not-listed'
|
||||
message: string
|
||||
}
|
||||
|
||||
const GOOGLE_CALENDAR_PICKER_NOT_CONNECTED_MESSAGE =
|
||||
'Conecte a conta Google da campanha para listar e escolher o calendário principal.'
|
||||
const GOOGLE_CALENDAR_PICKER_LIST_FAILED_MESSAGE =
|
||||
'Não foi possível listar os calendários do Google agora. Tente de novo.'
|
||||
const GOOGLE_CALENDAR_PICKER_NOT_LISTED_MESSAGE =
|
||||
'O calendário escolhido não está mais disponível na conta Google conectada. Atualize a lista e escolha de novo.'
|
||||
|
||||
/**
|
||||
* C150 — the picker lists the calendars of the OAuth-connected account. The
|
||||
* service account is deliberately refused: its own calendar list does not
|
||||
* contain the calendars shared with it, so it cannot back a picker (the
|
||||
* connection is the C149 hard dependency of this surface).
|
||||
*/
|
||||
const resolveGoogleCalendarPickerClient = (
|
||||
doc: GoogleCalendarSyncDoc | null,
|
||||
client?: GoogleCalendarClient,
|
||||
): { client: GoogleCalendarClient } | { reason: 'not-connected'; message: string } => {
|
||||
const auth = readGoogleCalendarAuth(doc)
|
||||
if (auth?.kind !== 'oauth') {
|
||||
return { reason: 'not-connected', message: GOOGLE_CALENDAR_PICKER_NOT_CONNECTED_MESSAGE }
|
||||
}
|
||||
return { client: client ?? createGoogleCalendarClient(auth) }
|
||||
}
|
||||
|
||||
/**
|
||||
* C150 — lists the calendars the connected account can WRITE to, for the
|
||||
* primary-calendar picker. A dead OAuth connection is recorded on the row
|
||||
* (same classification as the engine) so the card derives `error` and offers
|
||||
* Reconnect; every other failure is a transient API error.
|
||||
*/
|
||||
export const listGoogleCalendarPickerOptions = async (
|
||||
payload: Payload,
|
||||
options?: { client?: GoogleCalendarClient },
|
||||
): Promise<GoogleCalendarPickerListResult> => {
|
||||
const doc = await loadGoogleCalendarSyncConfig(payload)
|
||||
const resolved = resolveGoogleCalendarPickerClient(doc, options?.client)
|
||||
if ('reason' in resolved) return { ok: false, ...resolved }
|
||||
|
||||
try {
|
||||
return { ok: true, calendars: await resolved.client.listCalendars() }
|
||||
} catch (error) {
|
||||
if (error instanceof GoogleCalendarAuthError) {
|
||||
await recordGoogleCalendarOAuthError(payload, error.message)
|
||||
return { ok: false, reason: 'auth-error', message: error.message }
|
||||
}
|
||||
return { ok: false, reason: 'api-error', message: GOOGLE_CALENDAR_PICKER_LIST_FAILED_MESSAGE }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C150 — sets the campaign's primary calendar (`calendarId`), the one the
|
||||
* mirror writes to. The id is validated server-side against the connected
|
||||
* account's LIVE writable list before the write, so the campaign surface can
|
||||
* never point the mirror at an arbitrary string (the Payload admin keeps that
|
||||
* escape hatch through the field's admin-only access). The write uses the
|
||||
* admin bypass by design: the caller (campaign action) authorizes the actor
|
||||
* and this utility owns the value validation. The `afterChange` config hook
|
||||
* (D7) reconciles the mirror into the new calendar; the old one is left as-is
|
||||
* (design C114 D7 — no backfill, no retro-cleanup).
|
||||
*/
|
||||
export const setCampaignPrimaryCalendarId = async (
|
||||
payload: Payload,
|
||||
calendarId: string,
|
||||
options?: { client?: GoogleCalendarClient },
|
||||
): Promise<GoogleCalendarPrimaryCalendarResult> => {
|
||||
const doc = await loadGoogleCalendarSyncConfig(payload)
|
||||
if (!doc) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'not-connected',
|
||||
message: GOOGLE_CALENDAR_PICKER_NOT_CONNECTED_MESSAGE,
|
||||
}
|
||||
}
|
||||
if (doc.calendarId === calendarId) return { ok: true, calendarId, changed: false }
|
||||
|
||||
const listed = await listGoogleCalendarPickerOptions(payload, options)
|
||||
if (!listed.ok) return listed
|
||||
if (!listed.calendars.some((calendar) => calendar.id === calendarId)) {
|
||||
return { ok: false, reason: 'not-listed', message: GOOGLE_CALENDAR_PICKER_NOT_LISTED_MESSAGE }
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: 'googleCalendarSync',
|
||||
id: doc.id,
|
||||
data: { calendarId },
|
||||
depth: 0,
|
||||
// Intentional admin bypass: `calendarId` is admin-only at the field level;
|
||||
// the campaign path is authorized at the action and validated above.
|
||||
overrideAccess: true,
|
||||
})
|
||||
return { ok: true, calendarId, changed: true }
|
||||
}
|
||||
|
||||
/** The push-channel webhook address; the URL secret IS the credential. */
|
||||
const buildPushChannelAddress = (secret: string): string | null => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
270
tests/int/googleCalendarPrimaryCalendarAction.int.spec.ts
Normal file
270
tests/int/googleCalendarPrimaryCalendarAction.int.spec.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
// @vitest-environment node
|
||||
//
|
||||
// C150 — the primary-calendar picker actions. Session resolution is mocked
|
||||
// (`getCampaignActionContext`), like the sibling action specs; everything else
|
||||
// — Payload access, the config afterChange hook (D7) and the DB — runs for
|
||||
// real. The Google client factory is mocked so the listing/validation is
|
||||
// deterministic: the picker must refuse an id that is not in the connected
|
||||
// account's LIVE writable list, and choosing a listed one must write
|
||||
// `calendarId` and reconcile the mirror into it.
|
||||
|
||||
import { getPayload, type Payload } from 'payload'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/utilities/campaignActionContext', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@/utilities/campaignActionContext')>()
|
||||
return { ...original, getCampaignActionContext: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('@/utilities/googleCalendarClient', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@/utilities/googleCalendarClient')>()
|
||||
return { ...original, createGoogleCalendarClient: vi.fn() }
|
||||
})
|
||||
|
||||
import {
|
||||
chooseGoogleCalendar,
|
||||
listGoogleCalendars,
|
||||
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
||||
import config from '@/payload.config'
|
||||
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
|
||||
import {
|
||||
createGoogleCalendarClient,
|
||||
GoogleCalendarAuthError,
|
||||
type GoogleCalendarClient,
|
||||
type GoogleRemoteEvent,
|
||||
} from '@/utilities/googleCalendarClient'
|
||||
import {
|
||||
GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV,
|
||||
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV,
|
||||
} from '@/utilities/googleCalendarOAuth'
|
||||
import { loadGoogleCalendarSyncConfig } from '@/utilities/googleCalendarSync'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
import { serializeGoogleCalendarSyncSpec } from '../helpers/googleCalendarSyncLock'
|
||||
|
||||
serializeGoogleCalendarSyncSpec()
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const mockedGetContext = vi.mocked(getCampaignActionContext)
|
||||
const mockedCreateClient = vi.mocked(createGoogleCalendarClient)
|
||||
|
||||
const OAUTH_ENV_KEYS = [
|
||||
GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV,
|
||||
GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV,
|
||||
] as const
|
||||
const originalEnv = new Map(OAUTH_ENV_KEYS.map((key) => [key, process.env[key]]))
|
||||
|
||||
const setOAuthClientEnv = (): void => {
|
||||
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_ID_ENV] = 'test-client-id'
|
||||
process.env[GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET_ENV] = 'test-client-secret'
|
||||
}
|
||||
|
||||
const restoreEnv = (): void => {
|
||||
for (const key of OAUTH_ENV_KEYS) {
|
||||
const value = originalEnv.get(key)
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
const CALENDAR_A = 'c_campanha_a@group.calendar.google.com'
|
||||
const CALENDAR_B = 'c_campanha_b@group.calendar.google.com'
|
||||
|
||||
type StubClient = {
|
||||
client: GoogleCalendarClient
|
||||
listCalendars: ReturnType<typeof vi.fn>
|
||||
listEvents: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const createStubClient = (): StubClient => {
|
||||
const listCalendars = vi.fn(async () => [
|
||||
{ id: CALENDAR_A, summary: 'Agenda da Campanha', primary: false },
|
||||
{ id: CALENDAR_B, summary: 'Meu calendário', primary: true },
|
||||
])
|
||||
const listEvents = vi.fn(
|
||||
async (_calendarId: string, _range: { timeMin: string; timeMax: string }) =>
|
||||
[] as GoogleRemoteEvent[],
|
||||
)
|
||||
const client: GoogleCalendarClient = {
|
||||
listCalendars,
|
||||
listEvents,
|
||||
insertEvent: vi.fn(async () => {}),
|
||||
updateEvent: vi.fn(async () => {}),
|
||||
deleteEvent: vi.fn(async () => {}),
|
||||
watchEvents: vi.fn(async () => ({
|
||||
id: 'watch-1',
|
||||
resourceId: 'resource-1',
|
||||
expiration: null,
|
||||
})),
|
||||
stopChannel: vi.fn(async () => {}),
|
||||
}
|
||||
return { client, listCalendars, listEvents }
|
||||
}
|
||||
|
||||
describe('C150 — primary calendar picker actions', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
let ownedConfigId: number | null = null
|
||||
|
||||
afterEach(async () => {
|
||||
restoreEnv()
|
||||
if (ownedConfigId !== null) {
|
||||
// Scoped to OUR row: the sibling Google specs share the test DB and
|
||||
// their own config lifecycle must not be robbed by this file's cleanup.
|
||||
await payload.delete({
|
||||
collection: 'googleCalendarSync',
|
||||
id: ownedConfigId,
|
||||
overrideAccess: true,
|
||||
})
|
||||
ownedConfigId = null
|
||||
}
|
||||
mockedGetContext.mockReset()
|
||||
mockedCreateClient.mockReset()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
restoreEnv()
|
||||
})
|
||||
|
||||
const installActor = async (role: 'coordinator' | 'advisor'): Promise<void> => {
|
||||
const actor = await campaignFixtures().createCampaignUser(role)
|
||||
mockedGetContext.mockResolvedValue({ payload, actor })
|
||||
}
|
||||
|
||||
const createConnectedConfig = async (calendarId: string | null = null) => {
|
||||
const doc = await payload.create({
|
||||
collection: 'googleCalendarSync',
|
||||
data: {
|
||||
calendarId,
|
||||
oauthRefreshToken: 'c150-refresh-token',
|
||||
oauthConnectedAt: new Date().toISOString(),
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
ownedConfigId = doc.id
|
||||
return doc
|
||||
}
|
||||
|
||||
it('refuses an advisor on list and choose without touching the row', async () => {
|
||||
await installActor('advisor')
|
||||
await createConnectedConfig(CALENDAR_A)
|
||||
setOAuthClientEnv()
|
||||
|
||||
const list = await listGoogleCalendars()
|
||||
expect(list.ok).toBe(false)
|
||||
if (list.ok) throw new Error('list must be refused')
|
||||
expect(list.message).toContain('candidato ou coordenação')
|
||||
|
||||
const choose = await chooseGoogleCalendar(CALENDAR_B)
|
||||
expect(choose.ok).toBe(false)
|
||||
expect(choose.message).toContain('candidato ou coordenação')
|
||||
|
||||
const doc = await loadGoogleCalendarSyncConfig(payload)
|
||||
expect(doc?.calendarId).toBe(CALENDAR_A)
|
||||
expect(mockedCreateClient).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lists the writable calendars of the connected account for a coordinator', async () => {
|
||||
await installActor('coordinator')
|
||||
await createConnectedConfig()
|
||||
setOAuthClientEnv()
|
||||
const stub = createStubClient()
|
||||
mockedCreateClient.mockReturnValue(stub.client)
|
||||
|
||||
const result = await listGoogleCalendars()
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error('list must succeed')
|
||||
expect(result.calendars).toEqual([
|
||||
{ id: CALENDAR_A, summary: 'Agenda da Campanha', primary: false },
|
||||
{ id: CALENDAR_B, summary: 'Meu calendário', primary: true },
|
||||
])
|
||||
expect(mockedCreateClient).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('records a dead OAuth connection and surfaces it as an auth error', async () => {
|
||||
await installActor('coordinator')
|
||||
await createConnectedConfig()
|
||||
setOAuthClientEnv()
|
||||
const stub = createStubClient()
|
||||
stub.listCalendars.mockRejectedValue(
|
||||
new GoogleCalendarAuthError('A conexão com o Google expirou ou foi revogada. Reconecte.'),
|
||||
)
|
||||
mockedCreateClient.mockReturnValue(stub.client)
|
||||
|
||||
const result = await listGoogleCalendars()
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) throw new Error('list must fail')
|
||||
expect(result.message).toContain('Reconecte')
|
||||
const doc = await loadGoogleCalendarSyncConfig(payload)
|
||||
expect(doc?.oauthErrorAt).toBeTruthy()
|
||||
expect(doc?.oauthError).toContain('Reconecte')
|
||||
})
|
||||
|
||||
it('rejects an id outside the live writable list without writing', async () => {
|
||||
await installActor('coordinator')
|
||||
await createConnectedConfig(CALENDAR_A)
|
||||
setOAuthClientEnv()
|
||||
const stub = createStubClient()
|
||||
stub.listCalendars.mockResolvedValue([
|
||||
{ id: CALENDAR_A, summary: 'Agenda da Campanha', primary: false },
|
||||
])
|
||||
mockedCreateClient.mockReturnValue(stub.client)
|
||||
|
||||
const result = await chooseGoogleCalendar(CALENDAR_B)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.message).toContain('não está mais disponível')
|
||||
const doc = await loadGoogleCalendarSyncConfig(payload)
|
||||
expect(doc?.calendarId).toBe(CALENDAR_A)
|
||||
})
|
||||
|
||||
it('writes a listed calendar and reconciles the mirror into it (D7)', async () => {
|
||||
await installActor('coordinator')
|
||||
await createConnectedConfig(CALENDAR_A)
|
||||
setOAuthClientEnv()
|
||||
const stub = createStubClient()
|
||||
mockedCreateClient.mockReturnValue(stub.client)
|
||||
|
||||
const result = await chooseGoogleCalendar(CALENDAR_B)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.calendarId).toBe(CALENDAR_B)
|
||||
expect(result.addLink).toBe(
|
||||
`https://calendar.google.com/calendar/r?cid=${encodeURIComponent(CALENDAR_B)}`,
|
||||
)
|
||||
const doc = await loadGoogleCalendarSyncConfig(payload)
|
||||
expect(doc?.calendarId).toBe(CALENDAR_B)
|
||||
// The config afterChange hook (D7) ran a pass against the NEW calendar and
|
||||
// succeeded with the stub transport.
|
||||
expect(stub.listEvents).toHaveBeenCalled()
|
||||
expect(stub.listEvents.mock.calls[0][0]).toBe(CALENDAR_B)
|
||||
expect(doc?.lastSuccessAt).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the current calendar as a no-op without listing', async () => {
|
||||
await installActor('coordinator')
|
||||
await createConnectedConfig(CALENDAR_A)
|
||||
setOAuthClientEnv()
|
||||
const stub = createStubClient()
|
||||
mockedCreateClient.mockReturnValue(stub.client)
|
||||
|
||||
const result = await chooseGoogleCalendar(CALENDAR_A)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.calendarId).toBe(CALENDAR_A)
|
||||
expect(stub.listCalendars).not.toHaveBeenCalled()
|
||||
expect(stub.listEvents).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 ?? ''
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
|
|
@ -39,7 +39,10 @@ const stateFor = (status: GoogleCalendarSyncStatus): GoogleCalendarSyncActionRes
|
|||
lastError: status === 'paused' ? 'Google fora do ar (simulado)' : null,
|
||||
pushChannelExpiresAt: null,
|
||||
pushChannelError: null,
|
||||
addLink: status === 'not-configured' ? null : 'https://calendar.google.com/calendar/r?cid=webcal',
|
||||
addLink:
|
||||
status === 'not-configured'
|
||||
? null
|
||||
: 'https://calendar.google.com/calendar/r?cid=c_campanha%40group.calendar.google.com',
|
||||
})
|
||||
|
||||
const PILL_LABELS: Record<GoogleCalendarSyncStatus, string> = {
|
||||
|
|
@ -51,7 +54,12 @@ const PILL_LABELS: Record<GoogleCalendarSyncStatus, string> = {
|
|||
|
||||
const HeaderActionsProbe = () => {
|
||||
const actions = useCampaignHeaderActions()
|
||||
return <>{actions['google-calendar-sync'] ?? null}</>
|
||||
return (
|
||||
<>
|
||||
{actions['google-calendar-sync'] ?? null}
|
||||
{actions['google-calendar-add'] ?? null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const renderChrome = ({
|
||||
|
|
@ -59,9 +67,11 @@ const renderChrome = ({
|
|||
// Status-consistent default: a paused mount's auto-retry resolves back to
|
||||
// the same state, so the label assertions never race the retry's setState.
|
||||
onSyncNow = vi.fn(async () => stateFor(status)),
|
||||
onChooseCalendar,
|
||||
}: {
|
||||
status: GoogleCalendarSyncStatus
|
||||
onSyncNow?: () => Promise<GoogleCalendarSyncActionResult>
|
||||
onChooseCalendar?: (calendarId: string) => Promise<GoogleCalendarSyncActionResult>
|
||||
}) =>
|
||||
render(
|
||||
<CampaignPageChromeProvider role="coordinator">
|
||||
|
|
@ -77,6 +87,22 @@ const renderChrome = ({
|
|||
}),
|
||||
)}
|
||||
onDisconnect={vi.fn(async () => stateFor('not-configured'))}
|
||||
onListCalendars={vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
calendars: [
|
||||
{
|
||||
id: 'c_campanha@group.calendar.google.com',
|
||||
summary: 'Agenda da Campanha',
|
||||
primary: false,
|
||||
},
|
||||
{
|
||||
id: 'c_pessoal@group.calendar.google.com',
|
||||
summary: 'Meu calendário',
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
}))}
|
||||
onChooseCalendar={onChooseCalendar ?? vi.fn(async () => stateFor('synced'))}
|
||||
/>
|
||||
<HeaderActionsProbe />
|
||||
</CampaignQuickActionContextProvider>
|
||||
|
|
@ -154,4 +180,67 @@ describe('AgendaGoogleSyncChrome (S14)', () => {
|
|||
)
|
||||
expect(onSyncNow).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('registers the one-click add link when a primary calendar is set (C150)', () => {
|
||||
renderChrome({ status: 'synced' })
|
||||
|
||||
const links = screen.getAllByRole('link', { name: 'Adicionar ao meu Google Calendar' })
|
||||
expect(links).toHaveLength(2)
|
||||
for (const link of links) {
|
||||
expect(link.getAttribute('href')).toBe(
|
||||
'https://calendar.google.com/calendar/r?cid=c_campanha%40group.calendar.google.com',
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('hides the one-click add link without a primary calendar (C150)', () => {
|
||||
renderChrome({ status: 'not-configured' })
|
||||
|
||||
expect(screen.queryByRole('link', { name: 'Adicionar ao meu Google Calendar' })).toBeNull()
|
||||
})
|
||||
|
||||
it('opens the picker from the sync dialog and returns to it on close (C150)', async () => {
|
||||
renderChrome({ status: 'synced' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Google:/ }))
|
||||
const syncDialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
|
||||
fireEvent.click(within(syncDialog).getByRole('button', { name: 'Trocar calendário principal' }))
|
||||
|
||||
const picker = await screen.findByRole('dialog', {
|
||||
name: /Calendário principal da campanha/,
|
||||
})
|
||||
expect(within(picker).getByText('Agenda da Campanha')).toBeTruthy()
|
||||
expect(within(picker).getByText('em uso')).toBeTruthy()
|
||||
|
||||
fireEvent.click(within(picker).getByRole('button', { name: 'Cancelar' }))
|
||||
expect(screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('chooses a calendar and refreshes the one-click link (C150)', async () => {
|
||||
const chosen: GoogleCalendarSyncActionResult = {
|
||||
...stateFor('synced'),
|
||||
calendarId: 'c_pessoal@group.calendar.google.com',
|
||||
addLink: 'https://calendar.google.com/calendar/r?cid=c_pessoal%40group.calendar.google.com',
|
||||
}
|
||||
const onChooseCalendar = vi.fn(async () => chosen)
|
||||
renderChrome({ status: 'synced', onChooseCalendar })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Google:/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Trocar calendário principal' }))
|
||||
const picker = await screen.findByRole('dialog', {
|
||||
name: /Calendário principal da campanha/,
|
||||
})
|
||||
fireEvent.click(within(picker).getByRole('radio', { name: /Meu calendário/ }))
|
||||
fireEvent.click(within(picker).getByRole('button', { name: 'Escolher calendário' }))
|
||||
|
||||
await waitFor(() => expect(onChooseCalendar).toHaveBeenCalledTimes(1))
|
||||
expect(onChooseCalendar).toHaveBeenCalledWith('c_pessoal@group.calendar.google.com')
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole('link', { name: 'Adicionar ao meu Google Calendar' })[0]
|
||||
.getAttribute('href'),
|
||||
).toBe('https://calendar.google.com/calendar/r?cid=c_pessoal%40group.calendar.google.com'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -151,6 +151,69 @@ describe('createGoogleCalendarClient', () => {
|
|||
expect(listCall?.url).toContain('maxResults=2500')
|
||||
})
|
||||
|
||||
it('lists writable calendars with pagination, hiding deleted and falling back the summary (C150)', async () => {
|
||||
const pages: Record<string, { items: Array<Record<string, unknown>>; nextPageToken?: string }> =
|
||||
{
|
||||
'': {
|
||||
items: [
|
||||
{ id: 'primary-id', summary: 'Conta da campanha', primary: true, accessRole: 'owner' },
|
||||
{ id: 'campaign-id', summary: 'Agenda da Campanha', accessRole: 'writer' },
|
||||
{ id: 'no-summary-id' },
|
||||
{ id: 'deleted-id', summary: 'Antiga', deleted: true },
|
||||
],
|
||||
nextPageToken: 'page-2',
|
||||
},
|
||||
'page-2': {
|
||||
items: [{ id: 'second-page-id', summary: 'Segunda página' }],
|
||||
},
|
||||
}
|
||||
const calls: string[] = []
|
||||
const fetchImpl: FetchLike = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url === 'https://oauth2.googleapis.com/token') {
|
||||
return new Response(JSON.stringify({ access_token: 'token-1', expires_in: 3600 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
calls.push(url)
|
||||
const pageToken = new URL(url).searchParams.get('pageToken') ?? ''
|
||||
return new Response(JSON.stringify(pages[pageToken]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}) as FetchLike
|
||||
|
||||
const client = createGoogleCalendarClient(oauthAuth, fetchImpl)
|
||||
const calendars = await client.listCalendars()
|
||||
|
||||
expect(calendars).toEqual([
|
||||
{ id: 'primary-id', summary: 'Conta da campanha', primary: true },
|
||||
{ id: 'campaign-id', summary: 'Agenda da Campanha', primary: false },
|
||||
{ id: 'no-summary-id', summary: 'no-summary-id', primary: false },
|
||||
{ id: 'second-page-id', summary: 'Segunda página', primary: false },
|
||||
])
|
||||
expect(calls).toHaveLength(2)
|
||||
const first = new URL(calls[0])
|
||||
expect(first.pathname).toBe('/calendar/v3/users/me/calendarList')
|
||||
expect(first.searchParams.get('minAccessRole')).toBe('writer')
|
||||
expect(first.searchParams.get('showHidden')).toBe('true')
|
||||
expect(first.searchParams.get('maxResults')).toBe('250')
|
||||
expect(first.searchParams.get('fields')).toBe('items(id,summary,primary,deleted),nextPageToken')
|
||||
expect(new URL(calls[1]).searchParams.get('pageToken')).toBe('page-2')
|
||||
})
|
||||
|
||||
it('maps invalid_grant on the calendar list to GoogleCalendarAuthError (C150)', async () => {
|
||||
const fetchImpl: FetchLike = (async () =>
|
||||
new Response(JSON.stringify({ error: 'invalid_grant' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})) as FetchLike
|
||||
|
||||
const client = createGoogleCalendarClient(oauthAuth, fetchImpl)
|
||||
await expect(client.listCalendars()).rejects.toBeInstanceOf(GoogleCalendarAuthError)
|
||||
})
|
||||
|
||||
it('re-mints the token once on a 401 and retries the call', async () => {
|
||||
let calendarCalls = 0
|
||||
const fetchImpl: FetchLike = (async (input: RequestInfo | URL, _init?: RequestInit) => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
184
tests/unit/googleCalendarPickerDialog.unit.spec.tsx
Normal file
184
tests/unit/googleCalendarPickerDialog.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
GoogleCalendarListActionResult,
|
||||
GoogleCalendarSyncActionResult,
|
||||
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
||||
import { GoogleCalendarPickerDialog } from '@/components/campaign/activity/GoogleCalendarPickerDialog'
|
||||
|
||||
/**
|
||||
* C150 — the primary-calendar picker: live list states (loading/error/empty),
|
||||
* the "em uso" mark, the confirm gating and the choose callback. Desktop
|
||||
* layout (matchMedia false); the mobile drawer shares the same content.
|
||||
*/
|
||||
|
||||
const CALENDAR_A = 'c_campanha@group.calendar.google.com'
|
||||
const CALENDAR_B = 'c_pessoal@group.calendar.google.com'
|
||||
|
||||
const listResult: GoogleCalendarListActionResult = {
|
||||
ok: true,
|
||||
calendars: [
|
||||
{ id: CALENDAR_A, summary: 'Agenda da Campanha', primary: false },
|
||||
{ id: CALENDAR_B, summary: 'Meu calendário', primary: true },
|
||||
],
|
||||
}
|
||||
|
||||
const syncResult = (
|
||||
overrides: Partial<GoogleCalendarSyncActionResult> = {},
|
||||
): GoogleCalendarSyncActionResult => ({
|
||||
ok: true,
|
||||
canManageConnection: true,
|
||||
connection: 'connected',
|
||||
oauthAvailable: true,
|
||||
oauthConnectedAt: '2026-08-01T12:00:00.000Z',
|
||||
oauthError: null,
|
||||
status: 'synced',
|
||||
calendarId: CALENDAR_B,
|
||||
lastSyncedAt: null,
|
||||
lastSuccessAt: null,
|
||||
lastErrorAt: null,
|
||||
lastError: null,
|
||||
pushChannelExpiresAt: null,
|
||||
pushChannelError: null,
|
||||
addLink: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderPicker = (
|
||||
overrides: {
|
||||
currentCalendarId?: string | null
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onListCalendars?: () => Promise<GoogleCalendarListActionResult>
|
||||
onChooseCalendar?: (calendarId: string) => Promise<GoogleCalendarSyncActionResult>
|
||||
} = {},
|
||||
) =>
|
||||
render(
|
||||
<GoogleCalendarPickerDialog
|
||||
open
|
||||
onOpenChange={overrides.onOpenChange ?? vi.fn()}
|
||||
currentCalendarId={overrides.currentCalendarId ?? CALENDAR_A}
|
||||
onListCalendars={overrides.onListCalendars ?? vi.fn(async () => listResult)}
|
||||
onChooseCalendar={overrides.onChooseCalendar ?? vi.fn(async () => syncResult())}
|
||||
/>,
|
||||
)
|
||||
|
||||
const matchMediaMock = vi.fn()
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
Element.prototype.scrollIntoView = () => {}
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
matchMediaMock.mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}))
|
||||
vi.stubGlobal('matchMedia', matchMediaMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('GoogleCalendarPickerDialog (C150)', () => {
|
||||
it('shows the loading state while the live list is fetched', () => {
|
||||
renderPicker({
|
||||
onListCalendars: vi.fn((): Promise<GoogleCalendarListActionResult> => new Promise(() => {})),
|
||||
})
|
||||
|
||||
expect(screen.getByText('Carregando calendários...')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('lists the calendars, marks the current one and disables the no-op confirm', async () => {
|
||||
renderPicker()
|
||||
|
||||
const picker = await screen.findByRole('dialog', {
|
||||
name: /Calendário principal da campanha/,
|
||||
})
|
||||
const current = within(picker).getByRole('radio', { name: /Agenda da Campanha/ })
|
||||
expect(current.getAttribute('aria-checked')).toBe('true')
|
||||
expect(within(picker).getByText('em uso')).toBeTruthy()
|
||||
expect(within(picker).getByText('Calendário principal da conta')).toBeTruthy()
|
||||
|
||||
const confirm = within(picker).getByRole('button', { name: 'Escolher calendário' })
|
||||
expect(confirm.hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('chooses another calendar and closes on success', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
const onChooseCalendar = vi.fn(async () => syncResult())
|
||||
renderPicker({ onOpenChange, onChooseCalendar })
|
||||
|
||||
const picker = await screen.findByRole('dialog', {
|
||||
name: /Calendário principal da campanha/,
|
||||
})
|
||||
fireEvent.click(within(picker).getByRole('radio', { name: /Meu calendário/ }))
|
||||
fireEvent.click(within(picker).getByRole('button', { name: 'Escolher calendário' }))
|
||||
|
||||
await waitFor(() => expect(onChooseCalendar).toHaveBeenCalledWith(CALENDAR_B))
|
||||
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false))
|
||||
})
|
||||
|
||||
it('keeps the picker open and shows the failure message', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
const onChooseCalendar = vi.fn(async () =>
|
||||
syncResult({ ok: false, message: 'O calendário escolhido não está mais disponível.' }),
|
||||
)
|
||||
renderPicker({ onOpenChange, onChooseCalendar })
|
||||
|
||||
const picker = await screen.findByRole('dialog', {
|
||||
name: /Calendário principal da campanha/,
|
||||
})
|
||||
fireEvent.click(within(picker).getByRole('radio', { name: /Meu calendário/ }))
|
||||
fireEvent.click(within(picker).getByRole('button', { name: 'Escolher calendário' }))
|
||||
|
||||
expect(await within(picker).findByText(/não está mais disponível/)).toBeTruthy()
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers a retry when the list fails', async () => {
|
||||
const onListCalendars = vi
|
||||
.fn<() => Promise<GoogleCalendarListActionResult>>()
|
||||
.mockResolvedValueOnce({ ok: false, message: 'Não foi possível listar os calendários.' })
|
||||
.mockResolvedValueOnce(listResult)
|
||||
renderPicker({ onListCalendars })
|
||||
|
||||
expect(await screen.findByText('Não foi possível listar os calendários.')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tentar de novo' }))
|
||||
|
||||
expect(await screen.findByText('Agenda da Campanha')).toBeTruthy()
|
||||
expect(onListCalendars).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('shows the empty state when the account has no writable calendar', async () => {
|
||||
renderPicker({
|
||||
onListCalendars: vi.fn(
|
||||
async (): Promise<GoogleCalendarListActionResult> => ({ ok: true, calendars: [] }),
|
||||
),
|
||||
})
|
||||
|
||||
expect(
|
||||
await screen.findByText('Nenhum calendário com permissão de edição nesta conta Google.'),
|
||||
).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'Escolher calendário' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -22,7 +22,7 @@ const syncedState = (
|
|||
lastError: null,
|
||||
pushChannelExpiresAt: null,
|
||||
pushChannelError: null,
|
||||
addLink: 'https://calendar.google.com/calendar/r?cid=webcal%3A%2F%2F…',
|
||||
addLink: 'https://calendar.google.com/calendar/r?cid=c_campanha%40group.calendar.google.com',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
|
|
@ -54,6 +54,7 @@ const renderDialog = (
|
|||
{ ok: true; authorizeUrl: string } | { ok: false; message: string }
|
||||
>
|
||||
onDisconnect?: () => Promise<GoogleCalendarSyncActionResult>
|
||||
onOpenPicker?: () => void
|
||||
} = {},
|
||||
) =>
|
||||
render(
|
||||
|
|
@ -65,6 +66,7 @@ const renderDialog = (
|
|||
onSetDisabled={vi.fn()}
|
||||
onStartOAuth={overrides.onStartOAuth ?? vi.fn()}
|
||||
onDisconnect={overrides.onDisconnect ?? vi.fn()}
|
||||
onOpenPicker={overrides.onOpenPicker ?? vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
|
|
@ -197,6 +199,58 @@ describe('GoogleCalendarSyncDialog — card de conexão OAuth (C149)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('GoogleCalendarSyncDialog — calendário principal e one-click (C150)', () => {
|
||||
it('shows the current calendar and the picker trigger to managers', () => {
|
||||
renderDialog(syncedState())
|
||||
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
|
||||
expect(within(dialog).getByText('Calendário principal da campanha')).toBeTruthy()
|
||||
expect(within(dialog).getByText('c_campanha@group.calendar.google.com')).toBeTruthy()
|
||||
expect(within(dialog).getByRole('button', { name: 'Trocar calendário principal' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens the picker from the connection card', () => {
|
||||
const onOpenPicker = vi.fn()
|
||||
renderDialog(syncedState(), { onOpenPicker })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Trocar calendário principal' }))
|
||||
|
||||
expect(onOpenPicker).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('offers to choose when the connection is active but no calendar is set', () => {
|
||||
renderDialog(syncedState({ status: 'not-configured', calendarId: null, addLink: null }))
|
||||
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: 'Escolher calendário principal' }),
|
||||
).toBeTruthy()
|
||||
expect(within(dialog).getByText(/Escolha o calendário principal/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides the picker trigger from non-managers and names the nucleus', () => {
|
||||
renderDialog(syncedState({ canManageConnection: false }))
|
||||
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
|
||||
expect(within(dialog).queryByRole('button', { name: /calendário principal/ })).toBeNull()
|
||||
expect(within(dialog).getByText(/escolhido por candidato ou coordenação/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the one-click add link and the public iCal URL for the manual path', () => {
|
||||
renderDialog(syncedState())
|
||||
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
|
||||
const addLink = within(dialog).getByRole('link', {
|
||||
name: /Adicionar ao meu Google Calendar/,
|
||||
})
|
||||
expect(addLink.getAttribute('href')).toBe(
|
||||
'https://calendar.google.com/calendar/r?cid=c_campanha%40group.calendar.google.com',
|
||||
)
|
||||
const icalInput = within(dialog).getByLabelText(
|
||||
'URL pública do calendário (Por URL, Apple Calendar e Outlook)',
|
||||
) as HTMLInputElement
|
||||
expect(icalInput.value).toBe(
|
||||
'https://calendar.google.com/calendar/ical/c_campanha%40group.calendar.google.com/public/basic.ics',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ActivityUpdateFeed — atribuição do registro reverso (C115)', () => {
|
||||
const renderFeed = (updates: Parameters<typeof ActivityUpdateFeed>[0]['updates']) =>
|
||||
render(<ActivityUpdateFeed updates={updates} />)
|
||||
|
|
|
|||
Loading…
Reference in a new issue