merge: main into B197+ (CHANGELOG)

This commit is contained in:
Francisco Solla 2026-08-11 14:52:51 -03:00
commit 6e5a293781
127 changed files with 19161 additions and 504 deletions

View file

@ -124,7 +124,10 @@ Mostre ao usuário **antes** de editar docs:
Inclua baldes **já_resolvido**, **descartar** e **defer** com uma linha de racional/gatilho cada (transparência > silêncio).
**Pare e confirme** — exceto em **modo autônomo** (`agent-work-issue` / pool): aí aplique a seção Modo autônomo abaixo e pule o AskQuestion. Só avance ao Passo 6 com aprovação explícita (humano) ou com as regras do modo autônomo. Sem isso = não cria Issue nem toca `docs/plans/`.
**Pare e confirme** — exceto em **modo autônomo** (`work-issue` pós-plano /
`agent-work-issue` / pool): aí aplique a seção Modo autônomo abaixo e pule o
AskQuestion. Só avance ao Passo 6 com aprovação explícita (humano) ou com as
regras do modo autônomo. Sem isso = não cria Issue nem toca `docs/plans/`.
## Passo 6 — Registrar via `agent:register` / `agent:file-miss`
@ -143,14 +146,21 @@ Não implemente as fases aqui. Execução é via `work-issue` / `agent-work-issu
**Próximo no fluxo de entrega:** após a triage (confirmada ou modo autônomo), o fechamento segue o Passo 6 de `agent-work-issue` / `work-issue` (`pnpm push` → PR `--base main` com `Closes #N` → auto-merge → CI).
## Modo autônomo (`agent-work-issue` / pool)
## Modo autônomo (`work-issue` pós-plano / `agent-work-issue` / pool)
Quando não há humano no gate (Passo 5):
1. Faça colheita, dedup, score e tipo como de costume.
2. **Registre** só itens `expensive_lock` com score ≥4 (Issue nova + `depends` no pai se preciso; plano curto se score ≥3 eng).
3. Todo o resto → **defer** (gatilho no `*-impl.md` / Explicitamente fora) ou **descartar** — não abra AskQuestion.
4. Resuma no comentário/fechamento o que registrou vs deferiu.
2. **`work-issue`** (humano presente só no plano): decida o destino pela
triage completa (Passos 3–4) — registre o que a triage manda (score ≥3
registrar, expensive_lock com piso 4–5), absorva em plano existente,
defira com gatilho ou descarte o resto — **sem abrir AskQuestion**.
3. **Pool (`agent-work-issue`):** registre só itens `expensive_lock` com
score ≥4 (Issue nova + `depends` no pai se preciso; plano curto se
score ≥3 eng).
4. Todo o resto → **defer** (gatilho no `*-impl.md` / Explicitamente fora)
ou **descartar** — não abra AskQuestion.
5. Resuma no comentário/fechamento o que registrou vs deferiu vs descartou.
## Anti-padrões (baseline)
@ -162,7 +172,7 @@ Quando não há humano no gate (Passo 5):
| "Junto DRY e UX num FD+" | Quebra o precedente FD+/FD2; tipos separados |
| "DRY com 1 call site vira escala-dry" | defer_trigger + gatilho; não registrar epic YAGNI |
| "Não li o plano existente" | Grep primeiro; absorver > duplicar |
| "Registro sem perguntar" | Passo 5 é gate; docs de produto não são scratchpad |
| "Registro sem perguntar" | Passo 5 é gate (humano-gated); em modo autônomo o agente decide e registra |
| "Corto access/LGPD do lote por tempo" | expensive_lock nunca é cortável por appetite |
## Resumo ao usuário

View file

@ -1,13 +1,13 @@
---
name: work-issue
description: >-
Executa uma Issue já claimada de ponta a ponta com supervisão humana:
o contexto da sessão identifica a Issue (sem claim), Plan mode com plano
de implementação, pausa para confirmação humana, depois execução →
/simplify → capture-review-debts com gate → PR Ready em main com
auto-merge. Use quando o usuário pedir /work-issue, quiser supervisionar
a abordagem de engenharia, ou trabalhar uma Issue com gate humano antes
do código.
Executa uma Issue já claimada de ponta a ponta com supervisão humana no
plano: o contexto da sessão identifica a Issue (sem claim), Plan mode com
plano de implementação, pausa para confirmação humana, depois execução →
/simplify → capture-review-debts autônomo (o agente decide o que registrar
e o que descartar) → PR Ready em main com auto-merge. Use quando o usuário
pedir /work-issue, quiser supervisionar a abordagem de engenharia, ou
trabalhar uma Issue com gate humano antes do código.
disable-model-invocation: true
---
@ -36,7 +36,7 @@ pular a pausa do impl plan; Draft / sem auto-merge.
- [ ] 1. Contexto: Issue do prompt/`$ARGUMENTS` da sessão (ausente → UMA pergunta com validação; nunca claim)
- [ ] 2. Abrir o plano de intenção do body da Issue (`Plano: docs/plans/...`; sem link → body é spec)
- [ ] 3. Plan mode → escrever `docs/plans/<slug>-impl.md` → **PARAR e confirmar com o humano**
- [ ] 4. Após “ok”: Passo 4 (execução → /simplify → débitos com gate → PR, via `execution-pipeline.md`)
- [ ] 4. Após “ok”: Passo 4 (execução → /simplify → débitos autônomos → PR, via `execution-pipeline.md`)
```
## Passo 1 — Contexto da sessão
@ -96,9 +96,11 @@ fechar em main), com os deltas do ator humano:
- **Branch:** `<Code>-<slug>` do worktree — nunca crie branch nova na sessão.
- **UI:** shape → craft → critique → polish.
- **`capture-review-debts`:** **com gate humano** — colha e pontue como a
skill manda, apresente ao humano antes de registrar (Issues novas com
`depends` no pai se necessário).
- **`capture-review-debts`:** **autônomo** — colha, dedupe, pontue e **decida
você mesmo o destino** (registrar/absorver/deferir/descartar) pela triage
da própria skill, **sem pausa para o humano**; registre via
`agent:register` / `agent:file-miss` (Issues novas com `depends` no pai se
necessário) e resuma o que registrou vs descartou no fechamento.
## Resumo final

View file

@ -40,5 +40,5 @@ entrega.
| Ator | Branch | UI | `capture-review-debts` | Cloud |
| ---- | ------ | --- | ---------------------- | ----- |
| **Humano** (`work-issue`) | `<Code>-<slug>` (worktree; nunca crie branch nova na sessão) | shape → craft → critique → polish | **com gate humano** — colha, pontue e apresente antes de registrar | n/a (máquina do humano) |
| **Humano** (`work-issue`) | `<Code>-<slug>` (worktree; nunca crie branch nova na sessão) | shape → craft → critique → polish | **autônomo** — decide o destino dos achados (registrar/absorver/deferir/descartar) pela triage da skill; sem pausa para o humano | n/a (máquina do humano) |
| **Pool** (`agent-work-issue`) | `agent/<id>-<slug>` (worktrees Cursor podem já ter criado) | shape → craft → critique → polish (harden/optimize só sob gatilho) | **autônomo** — só `expensive_lock` com score ≥4 (Issues novas com `depends`); score ≤3 / cheap_polish / defer_trigger → defer no `*-impl.md` ou descarte | `ManagePullRequest` com `draft: false`; Prep Cloud no Passo 0 |

View file

@ -44,3 +44,11 @@ DEEPSEEK_API_KEY=YOUR_DEEPSEEK_API_KEY_HERE
# Deep Infra API key for the Sollinha voice transcription (STT, Whisper large-v3)
# — cheapest speech-to-text around ($0.00045/audio min). Get one at https://deepinfra.com
DEEPINFRA_API_KEY=YOUR_DEEPINFRA_API_KEY_HERE
# C114 — Google Calendar mirror (Agenda da Campanha): base64 of the Google Cloud
# service-account JSON key (cat key.json | base64 -w0). The service account must
# be granted "make changes to events" on the campaign's public calendar; the
# calendar ID itself is configured by an admin in the `googleCalendarSync`
# collection. Absent/malformed → the agenda shows "não configurado" and the
# mirror stays off — the Teqo agenda never depends on this.
GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY=

View file

@ -6,13 +6,19 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**Recently resolved (2026-08-11):** **B197+** (Issue #642) — o menu do picker de colunas de assessores nasce da **mesma fonte** que os `<th>` do `AdvisorsTable` (fim do espelho): o módulo client declara `advisorTableColumns` (id/label/mandatory + `headerClassName` + `visuallyHiddenLabel` para o `sr-only` de Ações) e exporta `advisorPickerColumns = toCampaignColumnPickerColumns(...)` (precedente `supporterPickerColumns`); o header renderiza por `resolveVisibleColumns(advisorTableColumns, hiddenColumnIds)` — mesmo helper B17 das outras listas — e `emailHidden` deriva do mesmo `visibleColumns`. **Descoberta do /simplify incorporada:** o corpo só condiciona a coluna `email` (células à mão + exceção do draft), então `phone`/`municipalities`/`actions` são `mandatory: true` — esconder um deles (cookie velho do toggle inerte do B197) derrubaria o `<th>` sobre um corpo de 5 células; os checkboxes viram "sempre visível" (honestos). `assessores/page.tsx` consome a fonte exportada e perdeu o array espelho + imports órfãos. Comportamento da tabela idêntico (ids/labels/ordem, exceção do draft intacta); e2e `campaignColumnPicker` ganhou pin dos mandatory. Sem migration/access/Consent/UI/cache. Observações de ambiente (não da mudança): o e2e do picker falha no harness dev sob carga desta máquina (painel do popover não monta; falha idêntica na base e em lideranças — intocado; conjunto falho deriva com a carga; verificado funcional em Chromium real); warning React de key pré-existente no assessores (`CampaignListOmnibox` — base também). Intenção [`escala-dry-pos-b197.md`](plans/escala-dry-pos-b197.md); impl [`escala-dry-pos-b197-impl.md`](plans/escala-dry-pos-b197-impl.md).
**Recently resolved (2026-08-11):** **C114** (Issue #635) — Agenda da Campanha espelhada no Google Calendar com push Teqo→Google (API oficial, service account). **Motor:** reconciliação completa por passada (`runCampaignCalendarSync`) com **id determinístico de evento** (`teqo` + base32hex do id — charset do Google) → upsert idempotente sem estado por evento; janela 90d passado/365d futuro (precedente do feed iCal), espelho cheio do escopo do staff (sem intersecção de advisor — agenda oficial), cancelado/deletado no Teqo **apaga o evento no Google** (sem estado cancelado visível a quem segue); conteúdo comparado por instante absoluto (imune a fuso do calendário) → passadas repetidas não tocam o Google; eventos estrangeiros do calendário nunca são tocados (decode completo — um `teqo3` manual não é nosso). **Triggers (sem cron — Hobby):** hook `afterChange`/`afterDelete` na `activity` (só campos espelhados, com normalização de shapes populados vs depth-0; tarefas não disparam; **nunca lança** — Teqo independe do Google; timeout 15s por request), auto-retry na view da agenda quando `paused` (uma vez por carregamento), botão "Sincronizar agora", e **auto-resync por config** (D7): trocar `calendarId`/reativar na collection `googleCalendarSync` re-conciliar sozinho (guard por comparação de campo quebra o loop de re-entrada); scan de delete com lookback de 730d (cancelado antigo não vira fantasma; atividade viva fora da janela mantém o evento). **Estado:** collection `googleCalendarSync` (uma linha; `calendarId` admin-only, `disabledAt` staff, timestamps system-write; **status derivado** `not-configured | disabled | synced | paused` em lib pura) + **chave da service account só em env** (`GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY`, base64 do JSON do GCP — sem credencial no DB); sem env → `not-configured` com runbook de ops (criar calendário na conta da campanha, público no link, SA editor, calendarId no Painel). **Transporte:** `jose` (novo dep) + fetch → Calendar API v3 REST (token JWT assertion, cache em módulo, 401 re-minta uma vez, 409 de insert idempotente; `end` obrigatório → atividade sem `endAt` vira 1h). **UI (Impeccable B, padrão C94):** pill no header da agenda (sincronizado/pausado/não configurado/desativado) + FAB mobile + diálogo com os 4 estados do canvas (link público de adição `calendar/r?cid=webcal…` + instruções + sync agora + desativar; falha de action visível). **Sem participantes no v1** (decisão de produto do gate: aviso segue as configs de quem segue; service account exigiria domain-wide delegation — segue para o C115/Google→Teqo). DRY com o feed: janela/where/nomes de município/description compartilhados (`calendarFeed.ts` como dono da janela). Migration hand-written (o gerador re-emitia no-ops pré-existentes de `campaign_user.contact_id`). Testes: unit (mapping/derivação/guards com normalização/client com fetch fake/hook wiring via `vi.mock`), int (engine com stub in-memory: cria/converge/atualiza só drift/cancela deleta/delete limpa fantasma/falha→pausado→recupera/troca de calendarId), e2e (pill não-configurado + runbook, sem dependência externa). Débito de cobertura (estados disabled/paused/synced + ação) registrado como #666 (depends #635). Intenção [`c114-google-calendar-push-agenda-oficial.md`](plans/c114-google-calendar-push-agenda-oficial.md); impl [`c114-google-calendar-push-agenda-oficial-impl.md`](plans/c114-google-calendar-push-agenda-oficial-impl.md).
**Recently resolved (2026-08-11):** **D10** (Issue #617) — auto-cura de leftovers de consent/supporter + fim do shared-write latente no lease de consent (pós-D9). **F1 (self-heal):** `purgeOrphanedConsentRenames` (3 nomes explícitos de renames temporários — `temporarily-renamed-consent`, `chave-renomeada`, `chave-temporariamente-ausente`) roda sob lease **exclusivo**, em **autocommit** (delete na transação do lease seguraria row lock e a operation do próprio fixture self-deadlockaria ao reusar a key purgada — observado na primeira implementação), nos 3 pontos que tocam a key canônica (`withMutableConsentFixture` antes do snapshot, setup do `withMissingInviteConsentFixture`, `ensureLeasedConsent`) — uma run abortada no meio de um rename de teste não envenena mais o run seguinte (UNIQUE `consent_key_idx` auto-sustentado). **F1b:** `purgeMunicipalityResidue` também deleta os **contatos** que supporters residue órfãos deixaram (reference-check em leadership/signature/subscription/supporter pós-delete — mesmo critério do `removeSupporterData`, reimplementado no helper). **F2 (refutação por medição + design final):** a hipótese "shared-writer = ABBA latente" caiu sob carga — exclusivo-durante-a-operation → fila de 17 waiters e cascata de 15s (6 workers); operation sem lease → FK `consent_id` (cleanup de fixture apagava linha canônica adotada); o ABBA não é construtível hoje (escritas disjuntas por teste; Onda0 em ordem fixa de 3 keys). As flakes reais do baseline (3/4 runs) eram: consumidores observando a ausência commitada da janela missing-consent (janela D9 era **shared**), down-test da Onda0 deletando as 4 keys **não-atomicamente**, e provision re-textando no meio de asserções de preview. **Fix:** janela do `withMissingInviteConsentFixture` → **exclusiva** (bloqueia consumidores shared durante a ausência; fila limitada ~200–500ms — a medição D9 do "15s tight" era o regime ONDA0-exclusivo, inexistente hoje); down-test da Onda0 **atômico** (delete+count+restore numa transação — FK insert espera o commit via speculative insertion); provision da Onda0 com leases **exclusivos** aninhados (ordem fixa CI→SR→VI); consumidores continuam shared. **Review /simplify (2 reviewers):** P1-1 — os renames vivos de `campaignInvite`/`campaignFixtures` migraram para chaves privadas (`consent-vacated-<id>`/`owned-consent-vacated-<id>`) — nenhum teste vivo produz os 3 nomes do purge; comentários "Onda0 shared" corrigidos; `withExclusiveTestDatabaseLease` colapsado (era cópia do privado); rollback no down-test quando a sessão falta; cleanup no finally nos testes F1 (auto-curáveis — um run falho do próprio teste não polui). Testes novos: contrato da janela exclusiva (waiter SHARED bloqueado), purge em ensure/setup, self-heal do canônico (repro do run abortado, 2 fixtures), F1b. **Contraprova:** 7 arquivos da key × 6 workers → **12/12 verdes** (baseline 1/4; designs intermediários: cascata 15s / FK); suíte int completa 83 arquivos/701 testes; unit 1938; e2e paridade com baseline (falhas pré-existentes de chat IA/webauthn/mic reproduzem no código original). Sem migration/access/Consent/`src/`. Intenção [`escala-dry-pos-d9.md`](plans/escala-dry-pos-d9.md); impl [`escala-dry-pos-d9-impl.md`](plans/escala-dry-pos-d9-impl.md).
**Recently resolved (2026-08-11):** **C113** (Issue #631) — feed iCal da agenda **vivo**: eventos criados depois da criação do link aparecem no mesmo link. **Spike diagnóstico primeiro** (e2e novo com o aceite de dois `GET`s via fixture — roda em dev E no build de produção): o teste **passou no modo prod ANTES do fix**, provando que o Next 15 **não** congela route handlers GET (docs: "not cached by default" desde 15.0; build compila a rota como `route.js` dinâmica, sem `.body`) — **o freeze é o contrato de resposta**: `Cache-Control: public, max-age=3600` sem validadores permitia a caches compartilhados (CDN da Vercel, infra do Google, proxies) reter o snapshot do primeiro fetch. **Fix (mínimo):** `export const dynamic = 'force-dynamic'` na rota `ical/[secret]` (explícito, convenção do repo) + novo `buildICalFeedResponse` em `src/utilities/calendarFeed.ts` (helper puro): `Cache-Control: public, no-cache` (armazenar só com revalidação obrigatória — fecha também a janela de 1h servindo feed revogado), `ETag` forte (sha256 do corpo, hash sempre muda com conteúdo), `Last-Modified` informacional, `X-PUBLISHED-TTL: PT1H` (RFC 7986) e **304 só por `If-None-Match`** — nunca por `If-Modified-Since`, porque exclusão de atividade não move `Last-Modified` (linha some) e um 304 por data esconderia "evento fantasma". **Testes:** unit do helper (12/12 — headers, ETag estável/mutável, 304 sem corpo, `*`/lista/weak, ETag velho → 200, IMS nunca 304); int na rota real com payload (`route.dynamic === 'force-dynamic'` + A → GET → B → GET ambos → edição de horário → DTSTART novo → cancelamento → some → GET condicional → 304, 18/18); e2e do aceite completo no spec existente `campaignAgendaFeed` (dev 5/5, prod build 1/1). Aceite: edição/cancelamento refletem, revogação/escopo do criador fail-closed intactos, sem migration/schema/access/UI/Consent. Verificação pós-deploy pendente no elo CDN externo (link real de produção). Intenção [`c113-feed-ical-agenda-nao-atualiza.md`](plans/c113-feed-ical-agenda-nao-atualiza.md); impl [`c113-feed-ical-agenda-nao-atualiza-impl.md`](plans/c113-feed-ical-agenda-nao-atualiza-impl.md).
**Recently resolved (2026-08-10):** **OPS36** (Issue #601) — journeys com **sessão compartilhada** nos 3 arquivos e2e pesados (`campaignHomeActions` 17, `campaignMunicipalities` 20, `campaignBottomNav` 9 testes): o overhead repetido por teste **amortizado de verdade**, não o que a intenção supunha. **Premissa da intenção corrigida por medição:** `getPayload` NÃO é pago por teste — Payload 3.82 memoiza a instância em `global._payload` keyed por `options.key` (medido: 364 ms na 1ª chamada do worker, 0 ms nas seguintes). O custo real era **o login de browser + a criação de usuário por teste** (bcrypt): login isolado medido em **3.6 s (quente) / 30.3 s (1º hit)** vs **112–134 ms** do `payload.login` de seed — ~32×. **Mecanismo:** `beforeAll` por arquivo cria UM coordinator + UM leader (instância `CampaignE2EOwnership` própria, cleanup em `afterAll`) e minta o token via `payload.login` (a MESMA via do login real — session row + JWT que o app valida); `beforeEach` injeta o cookie `campaign-token` (path `/campanha`, httpOnly, sameSite lax) no context fresh de cada teste — helpers `mintCampaignSession`/`seedCampaignSession`/`createCampaignOwnership` no fixture. **Divergências declaradas da intenção:** (1) **sem `mode: 'serial'`** — serial existia para reusar o contexto do browser; com seed por cookie o reuso é desnecessário e o serial skipa N+1..k em falha (perde diagnóstico — anti-goal "mega-teste" da própria intenção); (2) storageState rejeitado (file race entre workers + `test.use` em tempo de definição); (3) **login UI sai dos 46 testes** — caminho permanece coberto por `campaignAuth.e2e.spec.ts` + journey mista t9 (advisor→leader, mantém browser login); advisors **nunca** compartilhados (OPS29 exige portfólio vazio; B176 atribui portfólio). **Flake classe exposta e endurecida:** sem o warm-up incidental do login, clicks de estado client-side (drawer/sheet/overlay) passaram a correr contra a hidratação — a classe B13/B17 da casa; 7 interações ganharam o padrão `toPass` (B70 add-liderança, Mais drawer, B42 trend/C109, B193 scenario, FAB×3) e 7 lookups do strip de ações/omnibox ganharam `.first()` (duplicado transitório RSC do shell — classe `.nth(0)` já documentada), sem enfraquecer asserção. **Medição antes/depois:** baseline 29/49 passou em 10.0 m a load ~55 (20 falhas = classe de carga/restart de Postgres por outro worktree — OPS37; A/B no código original: B97 falha identicamente na mesma carga, e o conjunto falhante casa o do baseline); com o seed, runs limpas: homeActions 18/19 (2.6 m), interações endurecidas 8/10 (falhas residuais = render lento sob load, classe pré-existente comprovada). Sem migration/access/Consent/UI. Intenção [`e2e-journeys-seriais-arquivos-pesados.md`](plans/e2e-journeys-seriais-arquivos-pesados.md); impl [`e2e-journeys-seriais-arquivos-pesados-impl.md`](plans/e2e-journeys-seriais-arquivos-pesados-impl.md).
**Recently resolved (2026-08-10):** **B197** (Issue #627) — coluna de e-mail oculta por padrão nas cinco listas de pessoas do `/campanha` (lideranças, dobradinhas, pessoas, apoiadores, assessores), religável pelo seletor de colunas B17 (cookie `campaign_columns`, default vale só para quem nunca tocou no seletor — escolha salva vence). Defaults novos em `DEFAULT_HIDDEN_COLUMN_IDS` (`email` nas 5) e `assessores` entrou em `CAMPAIGN_LIST_IDS` (primeira lista sem `CampaignTable` no mecanismo). **Pessoas (C100 reaberto):** o e-mail saiu da célula "Contato" (fica só telefone, `—` sem contato) e virou coluna própria oculta — cards mobile intocados (anti-goal). **Apoiadores:** coluna "E-mail" nova no `SupporterList` (dado já existia via `contact`; `SupporterListItemViewModel` ganhou `email`), nascendo oculta. **Assessores:** o encaixe — picker B17 na lista (trailing no omnibox, `advisorPickerColumns` espelhando os `<th>`) e `AdvisorsTable` (client) passou a receber `columnVisibility` com `<th>`/`<td>` condicionais; **exceção única: a linha-draft de criação mantém o input de e-mail sempre** (campo obrigatório no create). Sem migration/access/Consent/cache. Testes: unit do mecanismo (parser passa a guardar `assessores`; defaults + stored-wins) e e2e novo no `campaignColumnPicker` (default oculto, religar, persistência no reload, draft com input). Intenção [`ocultar-email-por-padrao-listas.md`](plans/ocultar-email-por-padrao-listas.md); impl [`ocultar-email-por-padrao-listas-impl.md`](plans/ocultar-email-por-padrao-listas-impl.md).
**Recently resolved (2026-08-10):** **OPS35+** (Issue #616) — restaurado o pin de browser que o OPS35 documentou como perdido: a **visibilidade CSS real** das colunas de rede (Assessor / Liderança / Dobradinha) e dos sortables de `/campanha/territorios` a 2200px. Spec novo mínimo `tests/e2e/campaignTerritoriesColumns.e2e.spec.ts` (projeto `campaign`, corpo exato do teste 3 do spec deletado — `getByRole('columnheader')` visível por rung container-query `@min-[Nrem]/territory-list`, Cobertura `count 0`); divisão de trabalho com o HTTP preservada (presença + classes de rung continuam no `campaignTerritoriesHttp`). Manifesto `e2e-affected`: entrada territórios agora mapeia os 2 specs **e** o arquivo dono das rungs `TerritoryListColumns.tsx` (uma mudança de rung acorda o pin; o seletor faz união, specs de municípios continuam acordando). Prova de não-vazio por mutação (rung `advisor` → `@min-[200rem]` fez o spec falhar em "Assessor"; revertido). Sem migration/access/Consent/UI/CSS. Intenção [`restaurar-pin-visibilidade-colunas-territorios.md`](plans/restaurar-pin-visibilidade-colunas-territorios.md); impl [`restaurar-pin-visibilidade-colunas-territorios-impl.md`](plans/restaurar-pin-visibilidade-colunas-territorios-impl.md).
**Recently resolved (2026-08-10):** **Push /campanha — opt-in automático + fix do prompt nativo (ad-hoc).** O `requestPermission` do card de perfil rodava **dentro de `startTransition(async …)`** — qualquer fronteira assíncrona entre o clique e a chamada derruba a user activation e o Chrome **auto-nega silenciosamente** (resolve `'denied'` sem mostrar o prompt): o teste real em device deu exatamente "Permissão de notificação negada". Fix: os guards sync ficam no handler e **`Notification.requestPermission()` passa a ser chamado sincronamente no clique** (antes de qualquer `await`/transição); mensagens distintas para `denied` (permissão bloqueada — reativar nas configurações do site; um `denied` não pode ser re-perguntado por nenhuma API) vs `default` (prompt não exibido). **Opt-in automático** (pedido do usuário): novo `CampaignPushOptInToast` no shell `(app)` (padrão `InstallPwaToast`/`BiometricEnrollmentToast`), montado só para staff (leader não recebe push — sino idêntico), exibido ~2s após o login quando `Notification.permission === 'default'` (nunca `denied`), sem inscrição ativa no device, consentimento `campanha-notificacoes-push` publicado e VAPID configurado; iOS só com app instalado (standalone); "Aceitar e ativar" roda a mesma cerimônia com o requestPermission síncrono no clique do action; dismiss fica salvo em `localStorage` (chave `campaign-push-opt-in-dismissed`); ativação/desativação/leitura de inscrição extraídas para `src/utilities/campaignPushClient.ts` (compartilhado com o card — don't twin; registrado no pin de top-level com razão). **Diagnóstico de prod (read-only, `db:check:push-chain`):** consentimento `campanha-notificacoes-push` **já publicado** (PASS — nada a criar), envs VAPID presentes, SW versionado, 77 eventos no sino — **único elo quebrado: 0 inscrições push** (o erro de permissão impedia o cadastro). Sem migration/access/Consent/schema. Sem issue — entregue via ad-hoc no worktree `work/3`.
**Recently resolved (2026-08-10):** **B197** (Issue #627) — coluna de e-mail oculta por padrão nas cinco listas de pessoas do `/campanha` (lideranças, dobradinhas, pessoas, apoiadores, assessores), religável pelo seletor de colunas B17 (cookie `campaign_columns`, default vale só para quem nunca tocou no seletor — escolha salva vence). Defaults novos em `DEFAULT_HIDDEN_COLUMN_IDS` (`email` nas 5) e `assessores` entrou em `CAMPAIGN_LIST_IDS` (primeira lista sem `CampaignTable` no mecanismo). **Pessoas (C100 reaberto):** o e-mail saiu da célula "Contato" (fica só telefone, `—` sem contato) e virou coluna própria oculta — cards mobile intocados (anti-goal). **Apoiadores:** coluna "E-mail" nova no `SupporterList` (dado já existia via `contact`; `SupporterListItemViewModel` ganhou `email`), nascendo oculta. **Assessores:** o encaixe — picker B17 na lista (trailing no omnibox, `advisorPickerColumns` espelhando os `<th>`) e `AdvisorsTable` (client) passou a receber `columnVisibility` com `<th>`/`<td>` condicionais; **exceção única: a linha-draft de criação mantém o input de e-mail sempre** (campo obrigatório no create). Sem migration/access/Consent/cache. Testes: unit do mecanismo (parser passa a guardar `assessores`; defaults + stored-wins) e e2e novo no `campaignColumnPicker` (default oculto, religar, persistência no reload, draft com input). Intenção [`ocultar-email-por-padrao-listas.md`](plans/ocultar-email-por-padrao-listas.md); impl [`ocultar-email-por-padrao-listas-impl.md`](plans/ocultar-email-por-padrao-listas-impl.md).
**Recently resolved (2026-08-10):** **Push /campanha — opt-in automático + fix do prompt nativo (ad-hoc).** O `requestPermission` do card de perfil rodava **dentro de `startTransition(async …)`** — qualquer fronteira assíncrona entre o clique e a chamada derruba a user activation e o Chrome **auto-nega silenciosamente** (resolve `'denied'` sem mostrar o prompt): o teste real em device deu exatamente "Permissão de notificação negada". Fix: os guards sync ficam no handler e **`Notification.requestPermission()` passa a ser chamado sincronamente no clique** (antes de qualquer `await`/transição); mensagens distintas para `denied` (permissão bloqueada — reativar nas configurações do site; um `denied` não pode ser re-perguntado por nenhuma API) vs `default` (prompt não exibido). **Opt-in automático** (pedido do usuário): novo `CampaignPushOptInToast` no shell `(app)` (padrão `InstallPwaToast`/`BiometricEnrollmentToast`), montado só para staff (leader não recebe push — sino idêntico), exibido ~2s após o login quando `Notification.permission === 'default'` (nunca `denied`), sem inscrição ativa no device, consentimento `campanha-notificacoes-push` publicado e VAPID configurado; iOS só com app instalado (standalone); "Aceitar e ativar" roda a mesma cerimônia com o requestPermission síncrono no clique do action; dismiss fica salvo em `localStorage` (chave `campaign-push-opt-in-dismissed`); ativação/desativação/leitura de inscrição extraídas para `src/utilities/campaignPushClient.ts` (compartilhado com o card — don't twin; registrado no pin de top-level com razão). **Diagnóstico de prod (read-only, `db:check:push-chain`):** consentimento `campanha-notificacoes-push` **já publicado** (PASS — nada a criar), envs VAPID presentes, SW versionado, 77 eventos no sino — **único elo quebrado: 0 inscrições push** (o erro de permissão impedia o cadastro). Sem migration/access/Consent/schema. Sem issue — entregue via ad-hoc no worktree `work/3`.

View file

@ -0,0 +1,83 @@
# C123 — Agenda: modal central como única superfície de criação/edição de atividade
Status: rascunho
Atualizado em: 2026-08-11
Issue: #667
Priority: P1
Model: composer-2.5
Impeccable: C — fluxo de criação/edição inline redesenhado na agenda (popover → modal central; formulário completo some)
Canvas UI: /home/fsolla/.cursor/projects/home-fsolla-Code-teqo/canvases/plan-c123-ui-draft.canvas.tsx
Appetite: ~1–1,5 dia eng; um outcome verificável
Responsável: —
## Intenção
A criação/edição de compromisso hoje pede **dois passos**: o popover de criação inline (estilo Google Calendar, C91) e depois o link "Mais detalhes" para preencher o resto no formulário completo — que vive em páginas próprias (`/campanha/atividades/nova` e `/campanha/atividades/[slug]/editar`). A mesa quer **uma** superfície só: o overlay da agenda (modal central no desktop, drawer no mobile) com **todas as configurações** — criação E edição ali mesmo, sem páginas de formulário. O popover atual também acumulou defeitos visíveis: o campo "Local" aparece **duplicado** na mesma tela (mesmo input renderizado duas vezes), e o seletor de dia/hora (calendário + seletores de hora/minuto empilhados) exige **rolar** dentro do popover para encontrar o seletor de horário — que fica escondido abaixo do calendário e ninguém descobre.
## Persona e fluxo
- **Persona / contexto:** staff na mesa (coordenador/assessor) no `/campanha/agenda`, criando ou ajustando um compromisso no slot certo do calendário — à pressa, entre uma ligação e outra.
- **Job principal:** lançar o compromisso completo no calendário **sem sair da agenda e sem navegar** para outra página.
- **Fluxo desejado (criar):** clica num slot → overlay abre já com o dia/hora do slot → preenche **todas** as configurações ali mesmo → Salvar → overlay fecha, evento aparece na agenda, sem reload.
- **Fluxo desejado (editar):** clica num evento da agenda → overlay abre com os valores do compromisso → ajusta → Salvar → evento atualizado no calendário. O detalhe `/campanha/atividades/[slug]` continua existindo, alcançável por link no próprio overlay (e o botão "Editar" do detalhe abre o mesmo overlay).
- **Anti-goals de produto:** não vira planilha nem redesenho do calendário em si; a página de detalhe `/campanha/atividades/[slug]` permanece como leitura/registro de resultado (não é formulário completo); recorrência não entra.
### Esboço de fluxo (C/D)
```text
[criar: clique no slot | editar: clique no evento]
→ [modal central (desktop) / drawer (mobile) com TODAS as configurações, dados pré-preenchidos]
→ [Início: data + hora separados | Término: data + hora separados]
→ [Salvar] → [overlay fecha, refetch da janela, calendário atualizado]
```
## Objetivo e aceite
- Clicar num slot abre um **modal central** (desktop), não mais um popover ancorado ao ponto de clique.
- O overlay expõe **todas as configurações** do compromisso — todas as seções do formulário completo de hoje (básicas, data e horário, onde, pessoas e organizações, tarefas, demandas).
- Clicar num evento abre o **overlay de edição** (mesmo modal/drawer, valores preenchidos) — o clique não navega mais para o detalhe; o detalhe fica acessível por link no overlay.
- **As páginas `/campanha/atividades/nova` e `/campanha/atividades/[slug]/editar` saem sem redirecionamento** — o overlay vira a única superfície de criação/edição (o "Mais detalhes" morre junto); links antigos para essas rotas dão 404.
- O campo **"Local" aparece uma única vez** no overlay (remover o bloco duplicado).
- Início e Término têm **dois seletores separados**: um só de **data** e um só de **horário**, ambos visíveis **sem rolar** — nada de horário escondido abaixo do calendário.
- **Mobile:** continua o bottom sheet (C103) — mesma superfície única de criação/edição — com **duas linhas empilhadas** (Início em cima, Término embaixo), cada uma com o seletor de **dia** e o seletor de **hora lado a lado na mesma linha**: `[dia de início][hora de início]` / `[dia de término][hora de término]`.
- Salvar não navega; refetch da janela visível.
- Guardrails existentes intactos: contrato civil `YYYY-MM-DDTHH:mm` de parse/validação, validação início ≤ término, modo "Todo o dia" (só data), prefill do slot, permissões staff, título imutável na edição.
## Dados (intenção)
- **Vou apresentar dados?** Não — são campos de entrada de data/hora, sem decisão de apresentação.
- **Decisões desbloqueadas:** nenhuma de dados; forma fica para o plano de implementação.
## Direção no codebase (hipótese)
- **Áreas prováveis:** `src/components/campaign/activity/ActivityInlineCreate.tsx` (vira o overlay único de criar/editar — popover vira modal; remover o bloco duplicado de Local — hoje dois `<Field>` idênticos; "Mais detalhes"/`buildActivityCreateHref` morrem), `src/components/campaign/activity/ActivityDateTimeField.tsx` (dividir o picker em seletor de data + seletor de hora — provavelmente dois controles lado a lado mantendo o contrato civil), `ActivityForm.tsx` (conteúdo do overlay — a forma em página morre), páginas `/campanha/atividades/nova` e `/[slug]/editar` (remover junto com `formActions`/prefill correspondentes; a página de detalhe segue e seu botão de editar passa a abrir o overlay), `src/utilities/activityUi.ts` (href de prefill), `src/app/(campaign)/campanha/actions/activity.ts` (ações de criar/editar se unificam no overlay).
- **Precedente a olhar:** plans C91 (`agenda-criar-evento-inline.md`), C103 (`agenda-mobile-form-criacao-usavel.md`), C97 (seletor de dia/hora shadcn), C104 (todo o dia); testes `tests/unit/activityInlineCreate.unit.spec.tsx` e `tests/e2e/campaignActivity.e2e.spec.ts` usam "Mais detalhes" e as páginas de formulário — serão atualizados pelo executor.
- **Risco de acoplamento:** leader lockdown não toca criação/edição staff (o toggle de tarefa no detalhe segue como está); o contrato de valor civil `YYYY-MM-DDTHH:mm` é compartilhado com a validação do submit e com o modo todo-o-dia — não quebrar; o detalhe perde o botão de editar em página — ele passa a abrir o overlay; e2e das rotas removidas morrem junto (sem redirecionamento — 404).
## Dependências
- **C124** (tarefas sem prazo): **dura** — o overlay inclui a seção de tarefas e deve nascer já com o modelo simplificado.
## Fora de escopo
- Redesenho da página de detalhe `/campanha/atividades/[slug]` (leitura/registro de resultado permanece como está).
- Recorrência; mudanças no FullCalendar ou nos filtros/feed iCal; drag-and-drop de remarcação (intocado).
## Rabbit holes de produto
- **"Overlay vira mini-CRM".** Se alguém "só completar" o desejo de ter tudo no overlay, ele vira o formulário completo empilhado sem hierarquia. **Corte:** seções agrupadas como no formulário atual, overlay rolável internamente com rodapé fixo (Cancelar/Salvar).
- **"Unificar com o sheet mobile".** O bottom sheet (C103) é o padrão mobile aceito; não redesenhar o mobile além dos seletores divididos. **Corte:** mobile = mesmo conteúdo, mesma anatomia de sheet.
## Questões em aberto (produto)
- **Como a edição é acessada?** **Opções:** A) clique no evento abre o overlay de edição (estilo Google Calendar), e o botão "Editar" do detalhe abre o mesmo overlay; B) só o botão "Editar" do detalhe abre o overlay; C) clique no evento abre o overlay e o detalhe vira acessível por link no próprio overlay. **Recomendação:** A. _(decidido no gate — clique no evento abre o overlay de edição; o detalhe continua alcançável por link no próprio overlay; o botão "Editar" do detalhe abre o mesmo overlay)_
- **Rotas `/campanha/atividades/nova` e `/[slug]/editar`:** redirecionar ou 404? **Recomendação:** redirecionar. _(decidido no gate — remover sem redirecionar, 404 para links antigos)_
## Referências
- GitHub Issue #667 (C123)
- Plans C91 (`agenda-criar-evento-inline.md`), C103, C104, C105, C124 (dependência)
- Canvas UI (gate): `/home/fsolla/.cursor/projects/home-fsolla-Code-teqo/canvases/plan-c123-ui-draft.canvas.tsx`
- `src/components/campaign/activity/ActivityInlineCreate.tsx` · `ActivityDateTimeField.tsx` · `ActivityForm.tsx` · `ActivityTaskFields.tsx`
- `src/app/(campaign)/campanha/atividades/nova/page.tsx` · `[slug]/editar/page.tsx` · `[slug]/page.tsx`
- `tests/unit/activityInlineCreate.unit.spec.tsx` · `tests/e2e/campaignActivity.e2e.spec.ts`

View file

@ -0,0 +1,69 @@
# C124 — Atividades: tarefa só com texto, responsável e concluída (sem data/hora)
Status: rascunho
Atualizado em: 2026-08-11
Issue: #668
Priority: P2
Model: composer-2.5
Impeccable: B — encaixe nas superfícies existentes (campos de tarefa do formulário + checklist do detalhe)
Canvas UI: /home/fsolla/.cursor/projects/home-fsolla-Code-teqo/canvases/plan-c124-ui-draft.canvas.tsx
Appetite: ~0,5–1 dia eng; um outcome verificável
Responsável: —
## Intenção
O modelo de tarefas da atividade carrega um **prazo** (data/hora) que a mesa não usa. No dia a dia, tarefa é "o que fazer, com quem, e já foi feito?" — o prazo só polui: um input `datetime-local` apertado na linha da tarefa no formulário e um "Prazo: …" no checklist do detalhe, sem desbloquear nenhuma decisão (não há alerta nem filtro por prazo). O usuário pediu para tirar data/hora das tarefas: fica só **texto, responsável e concluída**.
## Persona e fluxo
- **Persona / contexto:** staff criando/editando atividade no formulário completo (`/nova` e `/editar`); liderança marcando tarefas como concluídas no detalhe da atividade (toggle, como hoje).
- **Job principal:** cadastrar e acompanhar tarefas da atividade sem atrito — digitar o que fazer, apontar o responsável, marcar feito.
- **Fluxo desejado:** no formulário, cada tarefa = título + responsável (pessoa) + checkbox "concluída"; no detalhe, o checklist mostra título + "Resp: …" e o toggle de concluída.
- **Anti-goals de produto:** prazo não migra para outro campo da atividade nem vira notificação/relatório; nada de segunda ficha de pessoa.
## Objetivo e aceite
- Criar/editar tarefa tem **exatamente** três controles: título (texto), responsável (pessoa) e "concluída" (checkbox) — o campo de data/hora sai da UI.
- O checklist do detalhe mostra só título + responsável (sem "Prazo: …").
- O **schema** da tarefa não expõe mais prazo: dados existentes com prazo são **descartados** pela migração (verificar no impl que nenhum consumidor usa o campo antes de dropar).
- Guardrail de liderança intacto: liderança só pode marcar "concluída" (não altera título/responsável, não adiciona/remove tarefa) — a comparação de campos do guardrail perde a referência ao prazo.
- `doneAt` (derivado, invisível, "concluída em") permanece — auditoria de quando foi marcada.
## Dados (intenção)
- **Vou apresentar dados?** Não — simplificação de um modelo de entrada; o contador de tarefas concluídas (taskDoneCount) segue como está.
- **Decisões desbloqueadas:** nenhuma de apresentação.
## Direção no codebase (hipótese)
- **Áreas prováveis:** `src/collections/Activity.ts` (campo `due` do array `tasks` sai do schema; `doneAt` permanece; guard de liderança em `beforeValidate` perde a comparação de `due`), `src/components/campaign/activity/ActivityTaskFields.tsx` (remove o input de prazo e o serializador), `ActivityTaskChecklist.tsx` (remove a linha de prazo), `src/utilities/activityFormData.ts`, `src/lib/schemas/activity.ts`, `src/utilities/activityViewModels.ts` (parsing/validação/view model), migration nova (drop da coluna de prazo do array de tarefas — história de migrations intacta), testes unit/e2e que exercitam tarefas.
- **Precedente a olhar:** migration de adição do campo (`20260718_222832_add_action_plan`) e remodel de schema recente de atividade (`20260808_184113_remodel_activity_responsible`, `20260810_010844_add_activity_all_day`).
- **Risco de acoplamento:** o guardrail de liderança (Activity.ts, `beforeValidate`) compara tarefa a tarefa — mudar o shape exige tocar essa comparação no mesmo item, senão liderança quebra ou perde o fail-closed.
## Dependências
- Nenhuma dura para si. **Ela é dependida pela C123** (modal central da agenda): o overlay único de criação/edição inclui a seção de tarefas e nasce já com o modelo simplificado — a C123 roda depois desta.
## Fora de escopo
- Prazo em demandas/atividades (não criar campo equivalente em outro lugar).
- Notificações/alertas por prazo de tarefa.
- Editar `doneAt` ou o contador `taskDoneCount`.
## Rabbit holes de produto
- **"Prazo volta na listagem".** Alguém pode querer mostrar o prazo na lista de atividades. **Corte:** o campo sai do modelo; se a mesa pedir prazo de novo, é um item novo com intenção própria.
- **"Migrar prazo para outro lugar".** **Corte:** nada consome o prazo hoje (a verificação no impl fecha isso); migração de conteúdo é trabalho morto.
## Questões em aberto (produto)
- **`doneAt` ("concluída em") permanece no modelo?** **Opções:** A) sim, como campo derivado invisível (auditoria sem custo de UI); B) remove junto. **Recomendação:** A — custo zero, mantém rastreabilidade de quando cada tarefa foi marcada. _(assumido — validar com produto)_
- **Prazo com valor preenchido em produção: descartar ou preservar?** **Opções:** A) descartar (nenhum consumidor; dados de campanha internos); B) preservar em coluna morta. **Recomendação:** A — limpeza real, sem coluna fantasma. _(decidido no gate — descartar e remover)_
## Referências
- GitHub Issue #668 (C124)
- Plans C91/C103 (formulário de criação — o overlay da C123 inclui tarefas), C14 (modelo de atividades)
- Canvas UI (gate): `/home/fsolla/.cursor/projects/home-fsolla-Code-teqo/canvases/plan-c124-ui-draft.canvas.tsx`
- `src/collections/Activity.ts` · `src/components/campaign/activity/ActivityTaskFields.tsx` · `ActivityTaskChecklist.tsx`
- `src/utilities/activityFormData.ts` · `src/lib/schemas/activity.ts` · `src/utilities/activityViewModels.ts`

View file

@ -0,0 +1,135 @@
# Impl: C114 — Google Calendar: agenda da campanha espelhada com notificação (push Teqo → Google)
Status: aprovado
Atualizado em: 2026-08-11
Issue: #635
Intenção: docs/plans/c114-google-calendar-push-agenda-oficial.md
Appetite restante: ~2 dias eng (herdado; corte explícito: sem participantes no v1 — ver decisão aberta P1)
## Leitura da intenção
- **Outcome:** com um comando em `/campanha/agenda`, a campanha ativa um calendário Google compartilhado ("Agenda da Campanha"); criar/remarcar/cancelar atividades em Teqo reflete no Google em minutos (push via API oficial); quem segue recebe aviso conforme as próprias configurações; Teqo segue SoT e funciona sem Google (estado do sync visível, re-tenta sem ação manual); leader lockdown intocado.
- **O que NÃO negociar:** direção única Teqo → Google (C115 é o bidirecional); modelo = calendário compartilhado (nunca OAuth por pessoa); Teqo SoT — credencial inválida/API fora não pode quebrar o Teqo; espelho cheio do escopo do staff (recortes continuam no iCal); sem PII desnecessária no evento; líder não vê nem ativa.
- **O que reavaliar:** a "hipótese" aponta `src/utilities/calendarFeed.ts` como precedente — confirmado (janela de eventos, formatação de summary/description, nomes de município reusáveis). O que a intenção NÃO diz e a engenharia precisa decidir: **como disparar o push** (não há cron no Vercel — verificação: `vercel.json` não tem `crons`; Hobby limita cron), **onde viver o estado do sync** e **como mapear atividade→evento sem estado duplicado**.
## Abordagem recomendada
```mermaid
flowchart LR
A[afterChange/afterDelete hook em activity<br/>campos relevantes mudaram?] -->|sim| S[runCampaignCalendarSync<br/>utilities/googleCalendarSync.ts]
P[agenda page view<br/>estado pausado → auto-retry] --> S
B["botão 'Sincronizar agora'"<br/>actions/googleCalendarSync.ts] --> S
S --> L[load activities janela 90d+365d<br/>espelho cheio do staff]
L --> G[Google Calendar API v3<br/>client com service account JWT<br/>jose + fetch]
G --> D[diff por id determinístico teqo_activity_&lt;id&gt;<br/>cria/atualiza/remove]
D --> W[grava collection googleCalendarSync<br/>lastSyncedAt/lastSuccessAt/lastError*]
W --> U[UI: pill sincronizado/pausado/não configurado]
```
**Opções consideradas:** ver decisões D1–D6 abaixo.
**Recomendação:** push **orientado a evento** (hook no `activity`) com **reconciliação completa por passada** e **id determinístico de evento** — sem cron, sem fila, sem collection join e sem campo novo em `activity`. Configuração e estado do sync numa **collection `googleCalendarSync`** (credencial em env; **auto-resync quando a configuração muda**). UI no padrão do link de import (C94): pill no header desktop + quick action mobile + dialog.
### Decisões de engenharia
**D1 — Transporte para a API do Google.**
Opções: A) `googleapis` + `google-auth-library` (SDK oficial) | B) `jose` (assina JWT RS256) + `fetch` nativo → Calendar API v3 REST | C) JWT com `node:crypto` na mão.
Recomendação: **B** — só precisamos de token (JWT service account) + 4 endpoints REST (`events.list/insert/update/delete`); SDK oficial é ~40MB de máquina para 1 chamada; C reimplementa crypto de JWT (risco de segurança). `jose` é dependência pequena e padrão (Payload usa a mesma família).
Rejeitadas: A (pesado demais para a superfície real); C (implementar JWT à mão viola "nunca implemente crypto").
**D2 — Mapeamento atividade → evento Google.**
Opções: A) collection join `activityCalendarEvent` (activityId→googleEventId) | B) campos `googleEventId`/hash no `activity` | C) **id determinístico** `teqo_activity_<id>` (charset do Google permite alfanumérico + `_`).
Recomendação: **C** — upsert idempotente sem estado persistido por evento: `events.get`/`list` → 404 cria, 200 compara conteúdo e atualiza só se mudou, cancelado/deletado remove. Elimina collection nova, migration de tabela e o loop de re-entrada do hook (escrever campo no doc re-dispara afterChange). "Edit the owner": o dono do evento no Google É a atividade; o id determinístico é só a identidade externa.
Rejeitadas: A (estado duplicado desnecessário; mais uma collection/access para administrar); B (re-entrada do hook — toda escrita de `googleEventId` re-dispara afterChange; precisa guard frágil; e leader update guard já compara snapshots de campos de staff).
**D3 — Motor de sync.**
Opções: A) upsert incremental por evento (só o evento tocado) no hook | B) **reconciliação completa por passada**: lista janela no Google + diff contra atividades do Teqo + cria/atualiza/remove | C) dois caminhos (incremental no hook + reconciliação periódica).
Recomendação: **B** — um único caminho de código (uma superfície de teste), auto-curativo (remove fantasmas de eventos deletados no Teqo, reconstrói evento apagado à mão no Google — Teqo é SoT), e a comparação de conteúdo (summary/description/start/end) faz passadas repetidas convergirem sem tocar no Google quando nada mudou. Custo: 1 chamada `list` por passada (~100ms) — irrelevante na escala (centenas de eventos).
Rejeitadas: A (não limpa fantasmas; edição manual no Google não é corrigida; dois estados de falha distintos); C (dois caminhos para manter/testar sem ganho real).
**D4 — Trigger do push ("em minutos" sem cron).**
Opções: A) cron Vercel (`vercel.json` crons) | B) **hook afterChange/afterDelete em `activity` (campos relevantes) + auto-retry na view da agenda quando pausado + botão "Sincronizar agora"** | C) Google push channels (webhook reverso).
Recomendação: **B** — o hook dá latência de segundos (o aceite pede "minutos"); a view da agenda + o botão cobrem o "re-tenta sem ação manual" (a própria equipe abre a agenda quando opera); escrever em Teqo continua 100% local. Sem cron (Hobby limita a 2/dia), sem fila, sem endpoint público novo.
Rejeitadas: A (limite de Hobby; cadência de horas não entrega o aceite); C (endpoint público + desafios de renovação de channel — infra que este item não precisa).
Nota: corridas convergem — a comparação de conteúdo torna uma segunda passada simultânea um no-op; sem advisory lock distribuído para estado cosmético (o `pg_advisory_xact_lock` existente é transacional e soltaria no commit, inútil para chamadas HTTP pós-commit).
**D5 — Configuração e estado do sync (collection, não global).**
Opções: A) **collection `googleCalendarSync`** (uma linha de fato; precedente de infra `supporterImportBatch`/`calendarFeed`) | B) global `googleCalendarSync` (precedente `campaignGoals`) | C) tudo em env.
Recomendação: **A** — pedido do gate e casa com o precedente de recurso de sync com credencial revogável (`calendarFeed`); o `afterChange` da própria collection dispara o **auto-resync** quando a configuração muda (D7); access por collection explícito (read staff, update staff campo a campo: `calendarId` admin-only, `disabledAt` staff, estado system-write/staff-read). Singleton de fato garantido pelo fluxo (engine faz find/create na primeira ativação) — sem unicidade DB (a linha é única por construção da ação de ativação).
Rejeitadas: B (auto-resync também funcionaria num global, mas o precedente de recurso de sync é collection e o access fica mais legível; `campaignGoals` é configuração pura sem estado rastreado); C (calendarId em env exigiria redeploy para rotacionar; sem rastreabilidade de `lastError` na UI).
**D6 — Credencial.**
Opções: A) **chave privada da service account em env** (`GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY`, base64 do JSON do GCP) + `calendarId` na collection (admin-set) | B) JSON inteiro da service account na collection (campo admin) | C) OAuth por pessoa.
Recomendação: **A** — segredo em env (precedente `REVALIDATE_SECRET`/Blob), identidade operacional (calendarId) na collection: staff desativa/reativa sem tocar em env; admin ajusta calendarId sem redeploy; key nunca vaza em log/DB. O runbook de ops (criar calendário na conta da campanha, compartilhar "público no link", dar permissão de escrita à service account, pôr calendarId) é o caminho da questão aberta de produto Q1 (dono da conta).
Rejeitadas: B (segredo em DB — precedente do repo é env; revogação via env é mais segura); C (anti-goal explícito da intenção).
**D7 — Auto-resync ao mudar a configuração.**
Opções: A) **afterChange na collection `googleCalendarSync`**: quando `calendarId` (ou `disabledAt`) mudou de fato (comparação `originalDoc` vs `doc`), dispara `runCampaignCalendarSync` — reconciliação completa contra o novo calendário | B) só manual ("Sincronizar agora" / view da agenda) | C) migração completa entre calendários (deletar no antigo + criar no novo).
Recomendação: **A** — o id determinístico + diff por conteúdo tornam a passada idempotente: trocar o `calendarId` recria o espelho inteiro no novo calendário na hora, sem ação manual. Guard de re-entrada: as escritas de estado (`lastSyncedAt`/`lastSuccessAt`/`lastError*`) não alteram `calendarId`/`disabledAt`, então não re-disparam o afterChange. O calendário antigo fica com o último espelho congelado (sem limpeza retroativa — documentado no runbook).
Rejeitadas: B (o aceite "re-tenta sem ação manual" se estende à reconfiguração); C (varrer e deletar N eventos de um calendário que a campanha abandonou é valor marginal — o calendarId troca só em migração de conta).
### Componentes / mudanças
- **`src/lib/googleCalendarEventMapping.ts`** (novo, puro, unit-testável): `googleEventIdForActivity` (`teqo_activity_<id>`), `buildGoogleEventPayload(activity, municipalityName)` (summary `[Município] Título`; description sem PII — Município/Local/Tags/Deputado presente, mesmo espírito do iCal; `start`/`end` timed com offset America/Bahia −03:00 ou `date` all-day via `allDayCivilDateOf`/`allDayExclusiveEndDate` de `src/lib/activityAllDay.ts`; **end ausente → start + 1h** — pendente de verificação no doc oficial da API), `googleEventContentEquals(remote, payload)`.
- **`src/utilities/googleCalendarClient.ts`** (novo, `server-only`): JWT service account com `jose` (`SignJWT` RS256, scope `https://www.googleapis.com/auth/calendar`, aud `https://oauth2.googleapis.com/token`), POST token (cache em módulo, invalida em 401 e re-tenta uma vez), `listEvents` (paginação), `insertEvent`, `updateEvent`, `deleteEvent`; erros tipados (`GoogleCalendarApiError`) sem credencial em log.
- **`src/utilities/googleCalendarSync.ts`** (novo, `server-only`): `readGoogleSyncConfig`, `deriveGoogleSyncStatus` (matriz pura `not-configured | disabled | synced | paused` — key presente? calendarId? disabledAt? lastErrorAt > lastSuccessAt?), `runCampaignCalendarSync(payload, client)` — reconciliação completa (carrega atividades da janela 90d passado/365d futuro com `overrideAccess` — espelho cheio do staff, sem intersecção de escopo de advisor: é a agenda oficial da campanha; reusa o padrão de `loadFeedActivities`/`loadMunicipalityNames` de `calendarFeed.ts`), grava `lastSyncedAt` + `lastSuccessAt` ou `lastErrorAt`/`lastError`.
- **`src/collections/GoogleCalendarSync.ts`** (novo): slug `googleCalendarSync`, group `Campanha` (precedente de infra `supporterImportBatch`); campos `calendarId` (admin-write), `disabledAt` (staff-settable via action), `lastSyncedAt`/`lastSuccessAt`/`lastErrorAt`/`lastError` (system-write, staff-read para a pill); access: read staff, update staff campo a campo (field access); **afterChange com auto-resync (D7)**: `calendarId`/`disabledAt` mudaram de fato → dispara `runCampaignCalendarSync`; escritas de estado não re-disparam (guard por comparação de campo).
- **`src/collections/Activity.ts`** (editar): afterChange ganha `syncActivityToGoogleCalendar` (dynamic import — mesmo padrão do `notifyActivityNeedsAttention`; guard: só dispara quando campos relevantes mudaram — `title, status, startAt, endAt, allDay, locality, municipality, tags, deputyPresent, description` — compara `originalDoc` vs `doc`, tarefas não disparam; try/catch **nunca lança** — Teqo nunca depende do Google) e afterDelete idem (reconciliação remove o fantasma).
- **Server actions `src/app/(campaign)/campanha/actions/googleCalendarSync.ts`** (novo): `getGoogleCalendarSyncState` (status + link público quando configurado), `runGoogleCalendarSyncNow` (retry manual/automático, retorna estado fresco), `setGoogleCalendarSyncDisabled` (toggle staff); padrão `getCampaignActionContext` + `reloadStaffActor` de `actions/calendarFeed.ts`; líder nunca chega (layout `(app)` + gate staff da página).
- **`src/lib/googleCalendarLink.ts`** (novo, puro): link público de adição `https://calendar.google.com/calendar/r?cid=<encodeURIComponent(calendarId)>` + URL iCal pública `webcal://calendar.google.com/calendar/ical/<id>/public/basic.ics` (Apple/Outlook) — formato a verificar no doc oficial no impl.
- **UI** (Impeccable B — shape→craft→critique→polish; reusa shell `Dialog`/`Drawer`/`useIsMobile`/`SetCampaignHeaderAction`/`useCampaignQuickActionContext` de C94): `AgendaGoogleSyncChrome.tsx` (pill no header desktop junto do link de import + quick action mobile; recebe estado inicial do servidor; auto-retry quando `paused` e atualiza via action + `revalidatePath`) e `GoogleCalendarSyncDialog.tsx` (estados do canvas: não configurado → instruções de ops; sincronizado → link + copiar + instruções + "Sincronizar agora" + "Desativar"; pausado → último erro + re-tentar; desativado → reativar). Copy pt-BR conforme canvas.
- **`src/app/(campaign)/campanha/(app)/agenda/page.tsx`** (editar): carrega estado do sync (staff) e monta `<AgendaGoogleSyncChrome>` ao lado do `AgendaFeedChrome` (ordem do cluster C95: [Semana][Link de import][Google][Notificações][IA] — verificar colisão de `id` dos `SetCampaignHeaderAction`).
- **`.env.example`** (editar): `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY` documentada.
- **Migration:** `migrate:create add_google_calendar_sync` (tabela da collection nova — D5; sem collection join — D2).
- **Access / Consent:** sem chave de Consent (dados internos da campanha, precedente `calendarFeed`); sem PII nova — emails de campanha não vão para o Google no v1 (corte de participantes). Fail-closed: sem env key o sync não dispara (estado `not-configured`), o hook é no-op.
- **Docs:** entrada única em `docs/CHANGELOG-AGENTS.md`; runbook de ops (criação do calendário na conta da campanha + compartilhamento + permissão da service account + envs) — na AGENTS.md ou no plano? Decisão barata: na entrada do CHANGELOG + nota no impl.
### Dados → forma (se aplicável)
N/A — não há apresentação de dados no Teqo (o evento é do Google Calendar; decisão de apresentação do Google). Único dado novo visível: a pill de estado do sync (derivada, matriz unit-testada).
## Fases verificáveis
1. **Tracer / schema+server** (dia 1): `pnpm add jose`; collection `googleCalendarSync` + migration + `generate:types` + `generate:importmap`; `googleCalendarEventMapping` + unit tests; `googleCalendarClient`; `googleCalendarSync` + int tests com stub in-memory do cliente (incl. **auto-resync ao trocar `calendarId`** e guard de re-entrada do afterChange); hook no `activity` + guard; actions; `migrate` local + `migrate:status`.
2. **UI** (dia 1–2): chrome + dialog + wiring na página; e2e do estado `not-configured` (sem env no teste → determinístico, sem dependência externa) + diálogo; Impeccable shape→craft.
3. **Gates** (dia 2): `pnpm gate:fast`; `pnpm format:check`; `pnpm exec knip`; `pnpm check:cycles`; `pnpm test` (unit+int); `pnpm test:e2e`; `pnpm build`; Aikido nos arquivos novos; `pnpm push` com CI green.
## Rabbit holes / Não escopo (engenharia)
- **Watch channels do Google (push reverso para nós)** — exige webhook público e renovação; o hook do Teqo já dá latência de segundos.
- **Fila/worker/cron próprio** — Hobby; hook + lazy retry cobre o aceite.
- **Um calendário por filtro/recorte** — iCal cobre (intenção).
- **Participantes/attendees (aviso garantido ao núcleo)** — corte v1: quem segue recebe conforme as configs do próprio Google (é o que o pedido assume); reabre como follow-up se a mesa quiser (decisão aberta P1).
- **Sincronizar histórico completo** — janela 90d+365d (precedente do feed).
- **Migrar eventos antigos para o Google** — fora de escopo.
- **OAuth por pessoa** — anti-goal da intenção.
- **Lock distribuído para a passada** — corridas convergem por comparação de conteúdo; D3.
## Riscos e mitigação
- **Semântica da API Google desconhecida** (charset do id de evento, `end` obrigatório para timed, formato do link `cid`): verificar no doc oficial durante o impl (source-driven-development); os int tests com stub travam NOSSO contrato de mapeamento, não o do Google.
- **E2E com dependência externa (Google real)** — flakiness em CI: e2e só cobre `not-configured` (env ausente); motor testado em int com stub.
- **Latência no hook** — guard de campos relevantes (tarefa não sincroniza), try/catch total, escrita nunca bloqueada.
- **Segredo vazando em log** — key só em env; client tipa erros sem corpo da credencial; Aikido nos arquivos novos.
- **Env ausente em produção (C98 lesson)** — a pill mostra `não configurado` com runbook em vez de quebrar; `NEXT_PUBLIC_SITE_URL` já é conhecida; sem env nova obrigatória para o app funcionar (sync desligado é estado válido).
- **Re-escrita de edição manual feita no Google** — direção intencional (Teqo SoT); documentar no runbook.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: push em minutos via hook; estado visível; re-tenta sem ação manual (view + botão + próximas escritas); Teqo funciona sem Google; leader lockdown intocado (gate staff da página + sem superfície para líder).
- [ ] Reconfiguração auto-sincronizada: mudar `calendarId`/`disabledAt` dispara reconciliação contra o novo calendário sem ação manual (D7), com guard de re-entrada do afterChange.
- [ ] Invariantes AGENTS/engineering-standards: identificadores em inglês, copy pt-BR; server actions com `getCampaignActionContext`; sem PII nova; segredo em env; sem migration retroativa.
- [ ] Testes de domínio previstos: unit (mapping, derivação de status, link), int (engine com stub: cria/atualiza só quando muda/cancela remove/fantasma limpo/falha→pausado→recupera/auto-resync de config; guard do hook), e2e (pill não-configurado + diálogo).
- [ ] Self-score decision-quality: 5/5 (decisões caras com rejeitadas; cabe no appetite; rabbit holes nomeados; depth check reusa `calendarFeed.ts`/`activityAllDay.ts`/shells C94; intenção preservada).
## Decisões em aberto (produto — para o gate humano)
- **P1 — Participantes (núcleo) no v1?** Recomendação: **não** (quem segue recebe conforme as próprias configs; é o que o pedido assume; evita e-mail PII da campanha no Google e spam de convite a cada remarcação). Reabre como follow-up.
- **Q1 — Dono da conta do calendário:** assumido A (conta Google da campanha; service account só escreve). Requer do humano: confirmação de que existe a conta e o `calendarId` (ops).
- **Q2 — Visibilidade:** assumido A (público no link — a equipe é mista Google+Apple; conteúdo já é a agenda oficial sem PII).
## Referências
- Intenção: `docs/plans/c114-google-calendar-push-agenda-oficial.md` (gate de produto)
- Canvas: `plan-c114-ui-draft.canvas.tsx` (4 estados da jornada + pill)
- Precedentes: `src/utilities/calendarFeed.ts`, `src/collections/CalendarFeed.ts`, `src/app/(campaign)/campanha/actions/calendarFeed.ts`, `src/components/campaign/activity/CalendarFeedDialog.tsx` + `AgendaFeedChrome.tsx` (C94), `src/collections/Activity.ts` (hooks), `src/collections/SupporterImportBatch.ts` (infra admin-hidden), `src/lib/activityAllDay.ts`, `src/utilities/postgresTransactionLocks.ts` (lido; decidido não usar)
- Testes existentes: `tests/e2e/campaignAgendaFeed.e2e.spec.ts`, `tests/unit/calendarFeed.unit.spec.ts`, `tests/int/calendarFeed.int.spec.ts`

View file

@ -0,0 +1,57 @@
# C122 — C114 follow-up: cobertura de testes dos estados do espelho Google
Status: rascunho
Atualizado em: 2026-08-11
Issue: #<pendente>
Priority: P3
Model: composer-2.5
Impeccable: B — superfícies existentes (pill + diálogo da agenda); sem fluxo novo
Appetite: ~0,5 dia eng — fill-in de cobertura, sem mudança de comportamento
## Intenção
O C114 entregou o espelho Teqo→Google com três estados derivados na UI
(sincronizado / pausado / não configurado / desativado), mas a cobertura de
testes parou no estado `not-configured` (e2e) e no engine (int com stub). O
que o /simplify deixou de fora, em um lote:
- **S14 — teste de componente dos chromes:** o auto-retry do
`AgendaGoogleSyncChrome` (uma tentativa por mount quando `paused`) e o
rótulo da pill por status não têm nenhum teste; o repo tem precedente
jsdom+RTL (`wizardExpectedVotesStep.unit.spec.tsx`).
- **S17 — e2e dos outros estados:** `disabled` (Reativar), `paused` (erro +
link "Abrir Google Calendar") e `synced` (link block + copiar + instruções)
nunca exercitados; o FAB mobile ("Agenda da Campanha") também não tem pin.
- **S18 — int da action de desativar:** `setGoogleCalendarSyncDisabled`
(grava `disabledAt`, re-enable limpa e o hook da config re-sincroniza — D7)
sem teste.
## Aceite
- Unit (RTL): pill renderiza o rótulo/estado correto por status; auto-retry
dispara UMA vez quando `paused` e nenhuma quando `synced`/`not-configured`.
- E2E: dialog nos estados disabled/paused/synced (seed do doc
`googleCalendarSync` + env key fake — estados determinísticos, sem rede) e
FAB mobile abrindo o sheet.
- Int: disable → `disabledAt` gravado; re-enable → limpo e o
`googleCalendarSyncConfigHook` dispara a reconciliação (spy/observável via
stub) — sem tocar na rede.
## Direção no codebase
- `src/components/campaign/activity/AgendaGoogleSyncChrome.tsx` +
`GoogleCalendarSyncDialog.tsx` (alvos do teste de componente).
- `src/app/(campaign)/campanha/actions/googleCalendarSync.ts` (alvo do int).
- `tests/unit/`, `tests/int/`, `tests/e2e/` — precedentes:
`wizardExpectedVotesStep.unit.spec.tsx`, `googleCalendarSync.int.spec.ts`,
`campaignAgendaGoogleSync.e2e.spec.ts`.
## Fora de escopo
- Serialização de passes concorrentes (advisory lock no estado) — defer:
self-healing (o próximo pass recobre), gatilho: reclamação de pill preso em
`paused` ou o C115.
- Teste dos estados no modo mobile do pill na linha de filtros (decisão C114:
FAB é a superfície mobile).
- Refactor do chrome de instruções addbyurl (2 call sites) — defer, gatilho:
3ª superfície de calendário (C115).

View file

@ -0,0 +1,73 @@
# Impl: E2E — flake do `getByLabel('Atividade relacionada')` (strict mode) sob carga
Status: aprovado
Atualizado em: 2026-08-11
Issue: #641 (D12)
Intenção: docs/plans/e2e-combobox-label-strict-mode-flake.md
Appetite restante: herdado (P3, kind: defect, sem nova superfície)
## Leitura da intenção
- **Outcome:** `campaignRegisterDemand` verde em run completo e isolado; sem mudança de DOM/labels no estado fechado que afete outros specs.
- **O que NÃO negociar:** copy visível e comportamento da combobox intactos; nenhuma regressão de a11y do dialog.
- **O que reavaliar:** a hipótese "o conteúdo do CommandDialog fica sempre montado" da intenção **não se sustenta** — ver caracterização abaixo.
## Caracterização (completada na sessão, browser real + código)
1. **Estado estável (fechado): exatamente 1 match.** O trigger (`aria-label="Atividade relacionada: Nenhuma atividade"`) é o único nó. O `CommandDialog` usa `DialogPortal` **sem** `forceMount` (src/components/ui/dialog.tsx): o conteúdo (incl. `CommandInput` com `aria-label={label}`) só monta com `open` ou na animação de saída (~200ms, `duration-200`).
2. **Dialog aberto: 2 matches** — trigger + `CommandInput` (`role="combobox"`, `aria-label="Atividade relacionada"`). Medido ao vivo.
3. **O sr-only `DialogHeader` (título + descrição) fica montado permanentemente** — é filho direto de `Dialog.Root` (render inline, fora da porta). O snapshot pós-falha do D11 ("título + descrição no DOM com o dialog fechado") é o **estado normal da página**, não evidência de dialog montado.
4. O teste que flakava (linha 38) **nunca abre o dialog** — logo o segundo match não pode ser o `CommandInput` por interação do próprio teste.
5. A intenção registra o segundo match como **"ambos botões"** com o mesmo `aria-label` do trigger → duplicação **transitória de instância** (2 comboboxes idênticos no DOM por um instante), a classe já tratada em cd469857 (2026-08-09): "transient RSC-pending duplication … strict-mode flake on loaded machines" → fix estabelecido no repo: `.first()` nos asserts.
6. Navegação dura (`page.goto`) medida ao vivo: 0→1 sem janela de 2 matches na máquina local; a duplicação é dependente de carga (máquina do pool, compiles de 6–27s).
## Abordagem recomendada
```mermaid
flowchart LR
A[Flake strict-mode 2 matches] --> B{2º match é...}
B -->|"CommandInput do dialog"| C[Descartado: teste nunca abre o dialog]
B -->|"2º trigger idêntico"| D[Duplicação transitória de instância<br/>classe cd469857]
D --> E["Fix: .first() no assert<br/>precedente do repo"]
```
**Opções consideradas:** A (spec `.first()`) | B (spec `getByRole('button', …)`) | C (componente: remover label do `CommandInput` / `aria-hidden` no fechado)
**Recomendação:** A — `.first()` nos asserts de `getByLabel('Atividade relacionada')` dos dois specs que usam o label (`campaignRegisterDemand.e2e.spec.ts:38` e `campaignActivity.e2e.spec.ts:52`), com comentário citando o precedente cd469857.
**Rejeitadas:**
- **B** — `getByRole('button', { name: /Atividade relacionada:/ })` resolve a duplicação trigger+input (input é `role=combobox`), mas **não** resolve o caso reportado ("ambos botões" = duplicação de instância): 2 triggers de mesmo `aria-label` → mesma strict violation.
- **C** — o portal **não está montado no fechado** (medido): remover `aria-label` do `CommandInput` não muda o estado estável e degradaria a a11y do input de busca (o dialog precisa de label próprio); `aria-hidden` no fechado não tem alvo (nada montado). Não atinge a duplicação transitória, que é artefato de render do framework (Next/React), não do componente.
- (descartada na análise) "esperar o dialog abrir/fechar": o teste não interage com o dialog.
### Componentes / mudanças
- **`tests/e2e/campaignRegisterDemand.e2e.spec.ts:38`**: `page.getByLabel(CAMPAIGN_DEMAND_ACTIVITY_LABEL)` → `.first()`. Semântica preservada: o teste quer provar que o campo está presente/visível; qualquer uma das instâncias transitórias é o mesmo trigger.
- **`tests/e2e/campaignActivity.e2e.spec.ts:52`**: idem (mesmo label, mesma classe de flake — página da atividade também monta `AsyncSearchCombobox` via `DemandFields`).
- **Migration:** sem migration. **Access/Consent:** não toca. **UI/componente:** nenhuma mudança (aceite "sem mudança de DOM/labels").
- Comentário nos dois asserts no estilo cd469857 (classe + motivo, não reescrever o histórico).
## Fases verificáveis
1. **Baseline (reprodução)** — run completo do e2e em dev (em andamento, máquina com load real 11–22): registrar se o flake reproduz localmente. O D11 já provou pré-existência em main via stash (3/4 runs falharam no pool); a reprodução local é bônus, não gate.
2. **Fix** — `.first()` nos 2 asserts.
3. **Gates** — `campaignRegisterDemand` isolado (dev) + `campaignActivity` isolado; run completo do e2e verde; `pnpm gate:fast` (tsc, lint, format, unit+int) e `pnpm check:cycles`; knip.
## Rabbit holes / Não escopo (engenharia)
- Não investigar o mecanismo exato da duplicação transitória do Next/React (bug upstream, dependente de carga; o repo já decidiu a resposta pragmática em cd469857).
- Não mexer no `AsyncSearchCombobox`/`CommandDialog` (a11y do dialog é correta; portal não monta no fechado).
- Não trocar `getByLabel` por `getByRole` nos outros ~100 usos do arquivo (fora do escopo do label em questão).
## Riscos e mitigação
- **Flake não reproduz na máquina local** (condições do pool diferentes) → mitigação: validação = mecanismo caracterizado ao vivo (estado estável 1 match; 2 matches só em instância duplicada/open) + fix idêntico ao precedente aprovado cd469857 + runs completos verdes pós-fix (1+).
- **`.first()` mascarar regressão real** (ex.: 2 instâncias ESTÁVEIS) → mitigação: assert continua verificando visibilidade do campo; se um dia houver duplicação estável, os outros asserts do spec (contagem 0/1 em linhas vizinhas) pegam.
- **Aceite "grep getByLabel em e2e"** → verificado: os únicos usos do label `Atividade relacionada` são os 2 pontos alterados; nenhum outro spec depende do estado fechado da combobox.
## Aceite de engenharia
- [ ] Aceite da intenção coberto (spec verde isolado e em run completo; DOM/labels fechados intactos)
- [ ] Invariantes AGENTS/engineering-standards (sem migration/access/transação; identificadores em inglês; precedente cd469857)
- [ ] Testes: run completo e2e dev verde + specs alterados isolados; gates completos no fechamento

View file

@ -0,0 +1,112 @@
# Impl: Auto-cura de leftovers de consent/supporter + ABBA do shared-write do lease de consent (pós-D9)
Status: em execução (F1 + F2 implementados; review /simplify aplicado; gates verdes; PR pendente)
Atualizado em: 2026-08-11
Issue: #617
Intenção: docs/plans/escala-dry-pos-d9.md
Appetite restante: herdado (só testes/helpers; zero `src/`; zero migration)
## Leitura da intenção
- **Outcome:** (F1) uma run abortada no meio de um fixture de rename/cleanup nunca mais envenena o run seguinte — renames órfãos de consent e contatos/supporters órfãos são reconhecidos e removidos deterministicamente; (F2) o ABBA latente (lease shared de consent + operation escritora) deixa de existir — cascata de timeouts não reproduz sob 6 workers + carga.
- **O que NÃO negociar:** mecanismo de lease (advisory lock) é canônico; orçamento de 15s do vitest fora; 4ª key da Onda0 sem lease fora; nada de `src/` nem migration.
- **O que reavaliar:** a hipótese "lista vs padrão vs re-snapshot por id" da F1 (o re-snapshot é impossível — ver decisões); o alvo exato da F2 (compartilhado entre `withLeasedConsent`/`withInviteConsent`/janela do `withMissingInviteConsentFixture`/`campaignSupporter:95` — o contrato de leitura/escrita cobre todos).
## Caracterização (auditoria feita no código)
**F1 — mecanismo exato da poluição auto-sustentada.** Os testes renomeiam a linha canônica para `chave-renomeada` (campaignInvite.int.spec.ts:1615), `chave-temporariamente-ausente` (campaignInvite.int.spec.ts:1679), `temporarily-renamed-consent` (testDatabaseLease.int.spec.ts:311) e `temporarily-unkeyed-<id>` (campaignFixtures.int.spec.ts:254), sempre dentro do lease **exclusivo** do fixture (renomeia + restaura no mesmo holder). Se o run morre entre o commit do rename e o restore, a linha órfã persiste com a key temporária e a key canônica fica ausente. No run seguinte o fixture snapshotta "ausente", cria linha nova, e o teste renomeia para a MESMA key temporária → UNIQUE `consent_key_idx` (`Valor deve ser único`) → poluição auto-sustentada. Garantia de segurança do purge: qualquer renomeador vivo está segurando o lease exclusivo da key — quem faz o purge também o segura, então nenhuma linha temporária viva pode existir durante o purge (renomeadores em fila só agem após o release, e partem do snapshot pós-purge).
**F2 — ciclo exato do ABBA.** `withLeasedConsent` (e `withInviteConsent`) segura o lease **shared** durante a `operation()`. Operations escritoras (redeem autofill/login → `UPDATE campaignInvite.usedAt`, `UPDATE leadership`, `UPDATE contact`, `CREATE campaignUser`; `createSupporterRecord`/`createLeaderSupporterRecord` → upsert `contact` + `CREATE supporter`) tomam row locks de invite/contato. Dois escritores em spec files diferentes seguram shared (compatível — zero serialização advisory) e intercalam writes sobre linhas sobrepostas → ABBA de row locks. Também: escritor shared + holder exclusivo (`withMutableConsentFixture`, que escreve invites no corpo do teste) → o exclusivo espera o shared enquanto seu corpo segura row locks que o shared-writer quer. Auditoria de ordem canônica e FIFO: a aquisição é sempre "consent-lease primeiro, row locks depois" — o ciclo existe porque DOIS holders seguram locks em ordens opostas (consent↔row); com escritores exclusivos e leitores shared (que não escrevem → não seguram row locks), nenhum ciclo é construtível.
## Abordagem recomendada
```mermaid
flowchart LR
F1[F1: purge de renames órfãos] --> H1[purgeOrphanedConsentRenames<br/>lista explícita + padrão temporarily-unkeyed-%]
H1 --> S1[chamado sob o lease exclusivo em<br/>withMutableConsentFixture / withMissingInviteConsentFixture / ensureLeasedConsent]
F1b[F1b: contatos órfãos de residue] --> H2[purgeMunicipalityResidue deleta<br/>contatos sem outras referências]
F2[F2: contrato read/write do withLeasedConsent] --> H3[opção mode:'write' → lease EXCLUSIVO]
H3 --> S2[call sites escritores migram;<br/>leitores continuam shared]
H3 --> S3[janela do withMissingInviteConsentFixture<br/>permanece shared + invariante documentado]
```
**Opções consideradas:** A (mode `'write'` explícito por call site, default read) | B (exclusivo default, opt-out read) | C (escritas fora do escopo do lease shared) | D (janela do missing-consent exclusiva) | E (purge por lista explícita + padrão) | F (re-snapshot por id) | G (purge global de consent não-canônico)
**Recomendação:**
- **F1 = E**: purge determinístico por lista explícita (`temporarily-renamed-consent`, `chave-renomeada`, `chave-temporariamente-ausente`) + `key LIKE 'temporarily-unkeyed-%'`, executado sob o lease exclusivo **antes do snapshot** nos três pontos que tocam a key canônica. — porque a lista cobre os 4 nomes reais hoje, o padrão cobre a família `temporarily-unkeyed-<id>` sem risco (nenhuma key estável legítima casa com o padrão) e um único DELETE indexado é barato.
- **F2 = A**: `withLeasedConsent` ganha `{ mode: 'write' }` (lease exclusivo para o ensure + operation num único holder; sem loop de retry — ninguém pode deletar a linha sob exclusivo); default continua `'read'` (shared). Call sites que escrevem migram explicitamente. — porque escritores exclusivos serializam entre si (fim da intercalação de row locks), leitores shared não escrevem → não entram em ciclo, e a medição D9 (janela exclusiva estourou o budget de 15s) não se repete: os escritores de invite são operations curtas (~100–300 ms), não provisions multi-segundo.
- **F2 janela missing-consent = shared, preservada (não D)**: a janela shared bloqueia os exclusivos (ensure/fixtures) sem parar leitores shared — a medição D9 foi explícita ("15s tight under load com janela exclusiva"). A operation da janela escreve, mas é a ÚNICA shared-writer da key hoje (1 arquivo por key: campaignSupporter:95 / testDatabaseLease com keys privadas) e o holder do window não espera por ninguém (exclusivos que esperam a janela não seguram row locks — adquirem após o wait) → ciclo não construtível; vira invariante documentado + comentário.
**Rejeitadas:** B porque serializaria os LEITORES atrás dos escritores (a fila que estourou o budget na medição D9 — leitores são muitos: `testDatabaseLease`, `campaignFixtures`, previews de `campaignInviteUi`); C porque quebra a garantia "a linha canônica está estável durante a operation" (writers sem lease veriam a ausência commitada de uma janela missing-consent → flake de fail-closed); D porque a medição D9 documentou o budget estourando sob carga com janela exclusiva; F porque o snapshot id morre com o run abortado (impossível re-snapshot por id — só o nome temporário sobrevive); G porque apagaria keys legítimas de fixtures (`fixtures.createConsent` gera keys runID-scoped aleatórias que coexistem).
## Revisão da F2 por medição (2026-08-11 — mudança material pós-gate)
O plano aprovado assumia a hipótese da intenção ("shared-writer = ABBA latente") e propunha o contrato `mode: 'write'` → lease **exclusivo** durante a operation. A medição sob carga (7 arquivos da key, 6 workers) **refutou a forma e o mecanismo**:
1. **Exclusivo durante a operation = cascata.** Fila de 17 waiters advisory + timeouts de 15s em cascata (campaignInvite/campaignInviteUi/testDatabaseLease) — o hold do exclusivo por ~30 testes serializa a key inteira. **Descartado medido.**
2. **Operation sem lease = FK violation.** `withMutableConsentFixture` com snapshot ausente recria a linha canônica; o cleanup do fixture a deleta; um consumidor sem lease que a adotou (redeem gravando `leadership.consent_id`) → `supporter/leadership_consent_id_consent_id_fk`. **Descartado medido.**
3. **O ABBA shared↔row não é construtível hoje:** os consumidores escrevem linhas disjuntas por teste; Onda0 é shared desde D9; nenhum ciclo advisory↔row existe. A cascata D9 era o ONDA0 **exclusivo** (multi-segundo) + fila — regime que já não existe.
4. **As flakes residuais reais (baseline medido: 3/4 runs com 1 falha) eram:** (a) consumidores observando a ausência commitada da janela do missing-consent (janela shared D9 não bloqueia consumidores shared) → 'Consentimento ainda não configurado' em redeems; (b) o down-test da Onda0 deletando as 4 keys estáveis **não-atomicamente** (delete autocommit + restore separado) → FK `consent_id`; (c) o provision da Onda0 reescrevendo textos no meio de asserções de preview (mismatch de `requiresConsent`/`consentData`).
**Design final (medido 8/8 verdes sob 6 workers; baseline 1/4):**
- **Consumidores ficam SHARED** (D9) — `withLeasedConsent`/`withInviteConsent` inalterados no modo; o contrato `mode` foi **descartado** (call sites revertidos).
- **Janela do `withMissingInviteConsentFixture` vira EXCLUSIVA** (era shared desde D9): nenhum consumidor observa a ausência commitada — é a mudança que mata a classe (a). O custo é fila limitada (~200–500ms) — a medição D9 do "15s tight" era sob o regime ONDA0-exclusivo, que não existe mais.
- **Down-test da Onda0 atômico** (delete + count + restore numa transação) — mata a classe (b).
- **Provision da Onda0 EXCLUSIVO** (~500ms, fila limitada) — mata a classe (c).
- **Purge F1 sem o padrão `temporarily-unkeyed-%`**: o padrão só era produzido pelo teste `campaignFixtures` ('deletes the stable invite consent'), que agora usa chave privada `owned-consent-vacated-<id>` — a linha canônica renomeada com key no padrão do purge podia ser apagada por purge de outro arquivo no meio da operation (falha NotFound observada). O purge cobre os 3 nomes explícitos.
- **Testes F1 auto-curáveis**: plantas de órfãos sob lease exclusivo + purge no início + cleanup no finally (uma falha do próprio teste não deixa poluição para o run seguinte).
### Componentes / mudanças (final)
- **`purgeOrphanedConsentRenames`** (`tests/helpers/testDatabaseLease.ts`): DELETE `WHERE key IN (3 nomes)` via `execute`; autocommit (nunca dentro da transação do lease — delete não-commitado seguraria row lock e a operation do próprio fixture self-deadlockaria ao reusar a key purgada). Chamado sob o lease exclusivo por: (1) `withMutableConsentFixture` antes do snapshot; (2) `withMissingInviteConsentFixture` no setup antes do snapshot; (3) `ensureLeasedConsent` dentro da janela exclusiva antes do find.
- **`withMissingInviteConsentFixture`** (`tests/helpers/testDatabaseLease.ts`): `serializeWindow: true` (default) → janela **exclusiva**; `false` inalterado (teste de ensures concorrentes em key privada).
- **`withExclusiveTestDatabaseLease`** (`tests/helpers/testDatabaseLease.ts`): wrapper novo exportado (mirror do shared).
- **`onda0Provision.int.spec.ts`**: provision com leases exclusivos; down-test com delete+assert+restore numa transação (`beginTransaction` + session db + commit/rollback).
- **`campaignFixtures.ts`** (`purgeMunicipalityResidue`): além do purge de residue existente, deleta os **contatos** que os supporters residue órfãos deixaram (referência-check em leadership/signature/subscription/supporter — mesmo critério do `removeSupporterData`, reimplementado no helper).
- **`campaignFixtures.int.spec.ts`** (teste 'deletes the stable invite consent...'): chave de vacância privada `owned-consent-vacated-<id>` (fora do padrão do purge).
- **Testes novos** (`testDatabaseLease.int.spec.ts`): contrato da janela exclusiva (waiter SHARED bloqueado durante a janela — estende o teste D9); purge em ensure; purge no setup do missing; self-heal do canônico (repro do run abortado, 2 fixtures); **F1b** (`campaignFixtures.int.spec.ts`): purge de contatos órfãos preservando referenciados.
- **Migration:** sem migration (só testes/helpers).
- **Access / Consent:** nenhuma mudança em runtime; chaves e fail-closed intactos.
- **UI:** nenhuma.
## Fases verificáveis (executadas)
1. **F2 contrato** — janela exclusiva + contrato testado (waiter SHARED bloqueado durante a janela); consumidores shared preservados.
2. **F1 self-heal** — purge nos 3 pontos; repros: órfão plantado (sob lease), auto-cura no run seguinte (fixture 2×), sem UNIQUE; testes auto-curáveis (cleanup no finally).
3. **F1b residue** — purge de contatos sem referências; repro: plant + purge direto, preservação de referenciado.
4. **Gates + carga** — contraprova: 7 arquivos da key (`campaignInvite`, `campaignInviteUi`, `campaignFixtures`, `testDatabaseLease`, `collectionAccessLockdown`, `campaignSupporter`, `onda0Provision`) com workers=6 → **8/8 runs verdes** (baseline original: 1/4; designs intermediários: cascata de 15s / FK violations); suíte int completa 83 arquivos/701 testes verdes; gates abaixo em andamento.
## Rabbit holes / Não escopo (engenharia)
- Mecanismo de lease/advisory lock (canônico, fora — só o modo de aquisição da janela muda).
- Orçamento de 15s do vitest (gatilho: recorrência com pool ativo).
- 4ª key da Onda0 sem lease (gatilho: primeiro escritor do push key).
- Atacar a poluição residual de contatos legados (inertes; fora — o F1b só impede acúmulo novo).
- Re-trabalhar a spec Onda0 ou a Onda0 em si.
- Gap setup→janela do missing-consent (ausência commitada por ~ms entre o commit do setup e a aquisição da janela; consumidor que insira com FK nesse gap ainda pode falhar — classe residual rara, mesma ordem da aceita na D9; gatilho de revisita: recorrência sob carga).
## Revisão /simplify (2 reviewers independentes, 2026-08-11) — fixes aplicados
- **P1-1 (ambos reviewers, obrigatório):** o purge deleta os 3 nomes globalmente sob a lease do chamador; os renomeadores vivos de `campaignInvite.int.spec.ts` (linhas 1615/1679) produziam `chave-renomeada`/`chave-temporariamente-ausente` sob a lease invite — um purge sob outra lease key (supporter/privada) podia apagar a linha renomeada viva (a classe exata que a D10 mata). **Fix aplicado:** os dois renames migraram para chave privada `consent-vacated-<id>` (mesmo padrão do `campaignFixtures`); agora NENHUM teste vivo produz os 3 nomes — o único produtor restante é a planta do self-heal (sob a lease invite, imediatamente antes do fixture curador). Invariante documentada no helper.
- **P2-1:** comentários do diff diziam "Onda0 holds shared, never exclusive" — falsos pós-diff (provision é exclusivo). Reescritos.
- **P2-2:** `withExclusiveTestDatabaseLease` era cópia verbatim do privado — colapsado em um único exportado (usado também internamente).
- **P2-3:** teste 'purges orphaned renames during the missing-consent fixture setup' sem cleanup no finally — adicionado.
- **P2 (reviewer 2):** down-test da Onda0 lançava sem rollback quando a sessão da transação faltava — rollback adicionado antes do throw.
- **Débitos registrados (não-fix):** fault `beforeRestoreLeaseAcquire` vestigial com janela exclusiva (só tem efeito com `serializeWindow: false` — documentar/remover); exposição transitória do gap do self-heal (ms, mesma classe residual aceita); `privacy_policy.published=false` persiste após o down-test (comportamento D9, inalterado); micro-otimização dos selects do reference-check; callback `execute` do purge podia receber `payload` direto.
## Riscos e mitigação (medidos)
- **Fila nova na janela exclusiva / provision exclusivo** (consumidores esperam ~200–500ms): medido 8/8 verdes sob 6 workers — sem timeouts.
- **F1 purge apagar linha viva:** impossível por construção (renomeador vivo segura o exclusivo; purge roda sob o exclusivo); testes plantam órfãos sob lease e limpam no finally.
- **Down-test atômico:** consumers com FK no meio do delete+restore transacional esperam o commit (speculative insertion) e progridem — classe FK eliminada (medido).
- **F1b deletar contato referenciado:** o filtro de outras-referências roda antes do delete; teste cobre ambos os lados (deleta só órfão; preserva referenciado).
## Aceite de engenharia
- [x] Run abortado (rename órfão plantado) não envenena o run seguinte (F1 — repros + suíte)
- [x] Nenhum consumidor observa a ausência commitada da janela missing-consent (janela exclusiva + contrato testado)
- [x] Cascata de timeouts não reproduz sob 6 workers ×12 (F2 — repro 12/12 vs baseline 1/4)
- [x] Review /simplify: P1-1 e P2s aplicados; débitos registrados
- [x] Invariantes AGENTS/engineering-standards; só testes/helpers; sem migration; zero `src/` (tsc, lint, knip, cycles, format, unit 1938, int 701, e2e paridade com baseline)

View file

@ -0,0 +1,71 @@
# Impl: Múltiplos telefones por pessoa
Status: aprovado
Atualizado em: 2026-08-11
Issue: #626
Intenção: docs/plans/multiplos-telefones-por-pessoa.md
Appetite restante: ~1–2 dias eng (intenção); sem cortes de aceite
## Leitura da intenção
- **Outcome:** toda pessoa (liderança, apoiador, dobradinha, assessor) tem N telefones na ficha; listas mostram 1 (o principal); ficha mostra todos; WhatsApp/convite/import usam o principal; busca por telefone acha por qualquer número; guardrail C111 (números compartilhados entre pessoas) preservado.
- **O que NÃO negociar:** ordem = prioridade sem flag explícita; sem rótulos/tipos de telefone; import CSV com uma coluna; login da liderança continua o `username` (não muda); dedupe dentro da MESMA ficha bloqueia salvar; **UI pública intocada** — formulários de petição/WhatsApp (e o componente `PhoneInput`) não mudam; a mudança de dados entra só no mapeamento server-side das ações públicas.
- **O que reavaliar:** a hipótese da intenção listava `src/lib/phone.ts`, view models e import — ok; mas **assessor** tem camada extra (o telefone mora em `campaignUser.phone`, espelhado na ficha) e **`Contact.phone` é lido pelo site público** (petição/WhatsApp) e por export — a intenção não citava esses dois.
## Abordagem recomendada
```mermaid
flowchart LR
C[Contact] -->|array field| P[phones: {value}[]]
P -->|primary| H[primaryPhoneOf]
H --> VM[view models / listas / wa.me / convite]
W[formulários criação/edição] -->|PhonesFieldEditor| P
S[busca/import/findOrCreate] -->|where phones.value| P
```
**Opções consideradas:** A | B | C
**Recomendação:** A — array field Payload `phones` com subcampo `value` na `Contact`, removendo `phone` (fonte única). Payload onde com `'phones.value': { equals/contains }` resolve busca/dedupe com JOIN nativo, `_order` preserva ordem = prioridade, e o admin UI ganha a edição de graça.
**Rejeitadas:**
- B — `jsonb phones: string[]` (custom/JSON): perde query/index Payload; `supporterListSqlFilters` e `supporterImportBulk` teriam que viver de SQL cru e a busca Payload (supporter filters, pickers) ficaria sem where nativo. Pior contrato para o caso de uso mais comum.
- C — collection join `contactPhone`: entidade demais para um campo sem rótulos/tipos; clareza zero de "principal" (teria `_order` escondido do mesmo jeito); admin group extra.
- D — manter `phone` + `phones` desnormalizados: duas fontes de verdade sincronizando em N write paths; rejeitado por invariante (uma fonte).
### Componentes / mudanças
- **`phones`** (`src/collections/Contact.ts`): array field `phones` (label "Telefones", subcampo `value` text 11 dígitos, `required: false`); remove o campo `phone`. `beforeValidate` da collection normaliza cada item (`normalizeBrazilianPhone` / regex 11 dígitos), filtra vazios, rejeita inválido com `BRAZILIAN_PHONE_INVALID_MESSAGE` e rejeita **duplicado dentro da ficha** (nova copy: `BRAZILIAN_PHONE_DUPLICATE_MESSAGE` = "Telefone repetido na ficha.").
- **Migration** `add_contact_phones_array`: tabela gerada `contact_phones` (\_parent_id, \_order, value) + backfill manual `INSERT ... SELECT id, phone, 0 FROM contact WHERE phone IS NOT NULL` + `DROP COLUMN contact.phone` (derruba `contact_phone_idx` junto) + índice manual `contact_phones (value)` (Payload não indexa subcampo de array; o equals/in da busca precisa).
- **`primaryPhoneOf`** (`src/lib/phone.ts`): `phones[0]?.value ?? null` — o único tradutor array→principal. Todos os readers de lista/ficha passam por ele (view models `leadershipData`, `stateDeputyData`, `peopleData`, `supporterViewModels`, `leaderContactsPageData`, `campaignInvitePageData`, `activityViewModels`, home search, `MunicipalityDossier`, `ContactCombobox`, `wizardLeadershipContract`).
- **`setContactPhones`** (nova utility `src/utilities/contactPhones.ts`): read-modify-write transacional do array (usada pelas actions de edição inline "seta principal preservando o resto" e pelos editors de ficha); `normalizeContactPhones` (pure, compartilhada com o hook e com o zod).
- **`PhonesFieldEditor`** (novo componente `src/components/campaign/shared/PhonesFieldEditor.tsx`): inputs mascarados repetíveis (reusa `FormattedInput` + `formatBrazilianPhoneInput`), remover por linha, "+ Adicionar telefone", setas ↑/↓ de reordenação (ordem = prioridade; sem arrasto — anti-goal). Serializa `phones` repetido no FormData (padrão `repeatedRelationshipFormValues`; novo `repeatedPhoneFormValues` em `src/lib/formData.ts`).
- **Schemas** (`src/lib/schemas/`): **`contactSchema` permanece com `phone`** (só o público usa — petição/WhatsApp); as ações públicas mapeiam `phone → phones: [{ value }]` ao gravar a `Contact` (UI e zod públicos intocados). Nos schemas de campanha: `leadershipCreateSchema`/`wizard`/`supporter`/`leaderSupporter`/`invite` trocam `phone` por `phones` (array, min 1 nos fluxos que exigiam); `contactFieldUpdateSchema`: branch `field: 'phone'` passa a significar "escrever principal preservando o resto" e novo branch `field: 'phones'` (array completo) para os editors de ficha.
- **Access / Consent:** nenhum (campo interno de `Contact`; sem Consent novo).
- **UI:** Impeccable B — formulários de criação/edição + ficha + listas existentes; shape→craft→critique→polish no editor compartilhado; shells e células inline existentes (`CampaignInlineEditableCell`) permanecem para as listas (mostram o principal, editam o principal).
## Fases verificáveis
1. **Tracer schema+server** — campo array + hook + migration (backfill+drop+índice) + `primaryPhoneOf` + `findOrCreateContactByPhone` via `phones.value` + view models trocando para o principal + ações públicas mapeando `phone → phones[0]` (sem tocar UI/zod públicos) + `signatureExport` + seed/fixtures. **Spike aqui:** confirmar semântica do `where: { 'phones.value': { equals/contains } }` em Payload 3.82 (int spec) — se falhar, fallback é JOIN cru nos poucos sites Payload-where.
2. **Write paths** — zod/schemas das 5 superfícies + actions (leadership create/wizard/inline, supporter create/tombstone LGPD, stateDeputy inline, leaderSupporter, convite redemption, sync `CampaignUser` (append se faltar), import CSV preview+bulk) + where de busca (`supporterListFilters`, `supporterListSqlFilters`, pickers de atividade, combobox).
3. **UI** — `PhonesFieldEditor` + forms de criação (LeadershipForm, SupporterForm, LeaderContactForm, WizardLeadershipForm) + fichas (LeadershipContactSection, StateDeputyContactSection, "Dados de contato" do apoiador com editor novo, assessor ganha seção "Telefones" na ficha) + células inline inalteradas na lista.
4. **Gates** — unit (primaryPhoneOf, normalize/dedupe, schemas, formData) + int (multi-telefone nas 5 superfícies, dedupe bloqueia, principal preservado no inline, import, convite, busca por qualquer número, tombstone substitui TODOS os telefones, sync append) + `pnpm gate:fast` + `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Não adicionar busca por telefone nas listas que hoje só buscam por nome (lideranças, dobradinhas, pessoas, assessores) — o aceite é "a busca por telefone encontra por qualquer número" onde busca por telefone existe (apoiadores, pickers); adicionar nas outras é item futuro se a mesa pedir. **Gatilho de revisitação:** mesa perguntar "por que não acho a liderança pelo número na lista de lideranças".
- Sem cap de quantidade de telefones; sem flag "principal"; sem arrasto (setas são suficientes para trocar prioridade).
- `campaignUser.phone` permanece campo único da conta (canal de contato/login); não vira array.
- Sem espelho ficha→conta (conta continua espelhando UNICAMENTE via sync existente conta→ficha, agora append).
## Riscos e mitigação
- **Payload onde em subcampo de array** (`'phones.value'`): spike na Fase 1 com int spec; fallback JOIN cru documentado nos poucos sites.
- **Migration destrutiva em prod (PII real):** backfill roda ANTES do drop no mesmo migration; `migrate:status` local + revisão do SQL no checklist de deploy.
- **Churn de testes** (muitos specs leem `contact.phone`): helper de fixture `createContact` continua aceitando `phone` e mapeia internamente para `phones` — maioria dos specs int fica intocada; asserts com `where: { phone }` viram `where: { 'phones.value': … }`.
- **Race do sync conta→ficha (append)**: sem transação garantida no hook; se o append colidir com edição concorrente, o dedupe fail-closed rejeita o save da conta (raro, mensagem clara).
- **Inline edit de lista** apagar principal: "limpar o campo" remove o index 0 (o resto sobe); nunca zera os demais.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (N telefones, lista=principal, ficha=todos, wa.me/convite/import=principal, busca por qualquer, C111 preservado, dedupe interno)
- [ ] Invariantes AGENTS/engineering-standards (fonte única em `Contact`; transação+req nas escritas multi-collection; copy pt-BR; identificadores inglês)
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam — acima

View file

@ -102,6 +102,7 @@
"cross-env": "^7.0.3",
"dotenv": "16.4.7",
"graphql": "^16.8.1",
"jose": "^6.2.8",
"leaflet": "^1.9.4",
"lucide-react": "^0.570.0",
"next": "15.4.11",

View file

@ -80,6 +80,9 @@ importers:
graphql:
specifier: ^16.8.1
version: 16.12.0
jose:
specifier: ^6.2.8
version: 6.2.8
leaflet:
specifier: ^1.9.4
version: 1.9.4
@ -5509,8 +5512,8 @@ packages:
jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
jose@6.2.1:
resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
jose@6.2.8:
resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==}
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
@ -9094,7 +9097,7 @@ snapshots:
express: 5.2.1
express-rate-limit: 8.3.1(express@5.2.1)
hono: 4.12.7
jose: 6.2.1
jose: 6.2.8
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
raw-body: 3.0.2
@ -13133,7 +13136,7 @@ snapshots:
jose@5.10.0: {}
jose@6.2.1: {}
jose@6.2.8: {}
joycon@3.1.1: {}

View file

@ -199,8 +199,12 @@ try {
for (const entry of MINIMAL_LEADERSHIPS) {
const contact = await upsert(
'contact',
{ phone: { equals: entry.contactPhone } },
{ name: entry.contactName, phone: entry.contactPhone, state: 'BA' },
{ 'phones.value': { equals: entry.contactPhone } },
{
name: entry.contactName,
phones: [{ value: entry.contactPhone }],
state: 'BA',
},
`contact ${entry.contactPhone}`,
)
const consent = consentIdByKey.get(entry.consentKey)
@ -226,8 +230,12 @@ try {
for (const entry of MINIMAL_SUPPORTERS) {
const contact = await upsert(
'contact',
{ phone: { equals: entry.contactPhone } },
{ name: entry.contactName, phone: entry.contactPhone, state: 'BA' },
{ 'phones.value': { equals: entry.contactPhone } },
{
name: entry.contactName,
phones: [{ value: entry.contactPhone }],
state: 'BA',
},
`contact ${entry.contactPhone}`,
)
const municipalityID = await municipalityIdBySlug(entry.municipalitySlug)

View file

@ -5,7 +5,12 @@ import {
createLeadershipWizard,
updateLeadershipWizard,
} from '@/app/(campaign)/campanha/actions/leadership'
import { optionalFormText, requiredFormText, requiredRelationshipFormValue } from '@/lib/formData'
import {
optionalFormText,
repeatedPhoneFormValues,
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
import {
LEADERSHIP_DUPLICATE_MESSAGE,
LEADERSHIP_INVALID_CONTACT_MESSAGE,
@ -37,7 +42,7 @@ const parseSupportStatus = (formData: FormData) => {
const wizardFieldsFromForm = (formData: FormData) => ({
name: requiredFormText(formData, 'name'),
phone: requiredFormText(formData, 'phone'),
phones: repeatedPhoneFormValues(formData, 'phones'),
email: optionalFormText(formData, 'email'),
exclusive: parseExclusive(formData),
supportStatus: parseSupportStatus(formData),

View file

@ -7,9 +7,15 @@ import {
listCalendarFeeds,
revokeCalendarFeed,
} from '@/app/(campaign)/campanha/actions/calendarFeed'
import {
getGoogleCalendarSyncState,
runGoogleCalendarSyncNow,
setGoogleCalendarSyncDisabled,
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda'
import { ActivityAgendaFilters } from '@/components/campaign/activity/ActivityAgendaFilters'
import { AgendaFeedChrome } from '@/components/campaign/activity/AgendaFeedChrome'
import { AgendaGoogleSyncChrome } from '@/components/campaign/activity/AgendaGoogleSyncChrome'
import { AgendaViewChrome } from '@/components/campaign/activity/AgendaViewChrome'
import {
CampaignListPendingBoundary,
@ -41,10 +47,11 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
requireCampaignPageActor({ gate: 'staff' }),
getPayload({ config }),
])
const [municipalityOptions, knownTags, feeds] = await Promise.all([
const [municipalityOptions, knownTags, feeds, googleSyncState] = await Promise.all([
loadMunicipalityOptions(payload, user),
loadAccessibleActivityTags(payload, user),
listCalendarFeeds().catch(() => []),
getGoogleCalendarSyncState(),
])
const state = restrictActivityAgendaState(
resolvedUrl.state,
@ -79,6 +86,18 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
return revokeCalendarFeed(feedId)
}}
/>
{/* C114: pill Google no header (após o link de import) + FAB mobile. */}
<AgendaGoogleSyncChrome
initialState={googleSyncState}
onSyncNow={async () => {
'use server'
return runGoogleCalendarSyncNow()
}}
onSetDisabled={async (disabled) => {
'use server'
return setGoogleCalendarSyncDisabled(disabled)
}}
/>
<CampaignListPendingBoundary>
<ActivityAgendaFilters

View file

@ -10,9 +10,15 @@ import { redirect } from 'next/navigation'
import {
removeSupporterData,
setSupporterVoteIntention,
updateSupporterContact,
} from '@/app/(campaign)/campanha/actions/supporter'
import { SUPPORTER_VOTE_INTENTION_CONSENT_MISSING_MESSAGE } from '@/lib/campaignConsentKeys'
import { checkboxFormValue, requiredRelationshipFormValue } from '@/lib/formData'
import {
checkboxFormValue,
repeatedPhoneFormValues,
requiredRelationshipFormValue,
} from '@/lib/formData'
import { BRAZILIAN_PHONE_DUPLICATE_MESSAGE, BRAZILIAN_PHONE_INVALID_MESSAGE } from '@/lib/phone'
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
import {
SUPPORTER_STAFF_MESSAGE,
@ -21,6 +27,8 @@ import {
} from '@/lib/schemas/supporter'
import {
mapCampaignFormActionError,
runCampaignFormAction,
type CampaignFormActionState,
type CampaignFormErrorState,
} from '@/utilities/campaignFormActionError'
import { firstFormActionMessage } from '@/utilities/campaignFormFields'
@ -41,6 +49,12 @@ const safeVoteIntentionMessages = [
SUPPORTER_STAFF_MESSAGE,
] as const
const safeContactMessages = [
SUPPORTER_STAFF_MESSAGE,
BRAZILIAN_PHONE_INVALID_MESSAGE,
BRAZILIAN_PHONE_DUPLICATE_MESSAGE,
] as const
const toMessageOnlyState = (mapped: CampaignFormErrorState<unknown>): { message?: string } => {
const message = firstFormActionMessage(mapped)
return message ? { message } : {}
@ -98,3 +112,20 @@ export const removeSupporterDataFormAction = async (
)
}
}
export const updateSupporterContactFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await updateSupporterContact({
id: requiredRelationshipFormValue(formData, 'supporterId'),
field: 'phones',
phones: repeatedPhoneFormValues(formData, 'phones'),
})
return { message: 'Telefones atualizados.' }
},
safeMessages: safeContactMessages,
genericMessage: 'Não foi possível salvar os telefones.',
})

View file

@ -4,8 +4,12 @@ import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { setSupporterVoteIntentionFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
import {
setSupporterVoteIntentionFormAction,
updateSupporterContactFormAction,
} from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
import { ConsentText } from '@/components/campaign/shared/ConsentText'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { RemoveSupporterDataButton } from '@/components/campaign/supporter/RemoveSupporterDataButton'
import { SupporterShareKit } from '@/components/campaign/supporter/SupporterShareKit'
@ -112,7 +116,15 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag
<CardTitle className="text-base">Dados de contato</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<p className="tabular-nums">{supporter.phoneDisplay || '—'}</p>
<PhonesFieldEditor
defaultValues={supporter.phones}
label="Celulares"
saveAction={updateSupporterContactFormAction}
recordId={supporter.id}
recordIdField="supporterId"
saveLabel="Salvar"
cancelLabel="Cancelar"
/>
{supporter.email ? <p>{supporter.email}</p> : null}
<p>
{supporter.municipalityName ? (

View file

@ -7,8 +7,12 @@ import {
SUPPORTER_REGISTRATION_CONSENT_MISSING_MESSAGE,
SUPPORTER_VOTE_INTENTION_CONSENT_MISSING_MESSAGE,
} from '@/lib/campaignConsentKeys'
import { checkboxFormValue, nullableRelationshipFormValue, optionalFormText } from '@/lib/formData'
import { sanitizeBrazilianPhoneInput } from '@/lib/phone'
import {
checkboxFormValue,
nullableRelationshipFormValue,
optionalFormText,
repeatedPhoneFormValues,
} from '@/lib/formData'
import {
SUPPORTER_DUPLICATE_MESSAGE,
SUPPORTER_STAFF_MESSAGE,
@ -28,7 +32,7 @@ export type SupporterFormState = {
type SupporterFormValues = {
name?: string
phone?: string
phones?: string[]
email?: string
city?: string
municipality?: string
@ -50,7 +54,7 @@ export const createSupporterFormAction = async (
// Parsed before the ladder (non-throwing readers) so failures echo them back.
const values: SupporterFormValues = {
name: optionalFormText(formData, 'name'),
phone: optionalFormText(formData, 'phone'),
phones: repeatedPhoneFormValues(formData, 'phones'),
email: optionalFormText(formData, 'email'),
city: optionalFormText(formData, 'city'),
municipality: optionalFormText(formData, 'municipality'),
@ -59,13 +63,12 @@ export const createSupporterFormAction = async (
return runCampaignFormAction({
execute: async () => {
const phone = sanitizeBrazilianPhoneInput(values.phone ?? '')
const voteIntention = values.voteIntention?.trim() || undefined
const municipality = nullableRelationshipFormValue(formData, 'municipality')
const input = supporterCreateSchema.parse({
name: values.name ?? '',
phone,
phones: values.phones ?? [],
email: values.email,
city: values.city,
...(municipality ? { municipality } : {}),

View file

@ -6,6 +6,7 @@ import { getPayload } from 'payload'
import { AdvisorDebouncedTextCell } from '@/components/campaign/advisor/AdvisorDebouncedTextCell'
import { AdvisorPasswordResetButton } from '@/components/campaign/advisor/AdvisorPasswordResetButton'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
@ -14,7 +15,11 @@ import { formatBrazilianPhoneInput } from '@/lib/phone'
import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor'
import { loadAdvisorDetail } from '@/utilities/advisorData'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { sendAdvisorPasswordResetFormAction, updateAdvisorProfileFormAction } from '../formActions'
import {
sendAdvisorPasswordResetFormAction,
updateAdvisorContactFormAction,
updateAdvisorProfileFormAction,
} from '../formActions'
export async function generateMetadata({ params }: AdvisorDetailPageProps) {
const { id: rawId } = await params
@ -115,6 +120,29 @@ export default async function AdvisorDetailPage({ params }: AdvisorDetailPagePro
)}
</section>
<section
aria-labelledby="advisor-contact-title"
className="flex flex-col gap-2 rounded-xl border p-4"
>
<h2 id="advisor-contact-title" className="text-base font-medium">
Telefones da pessoa
</h2>
<p className="text-sm text-muted-foreground">
Todos os números da ficha — o primeiro é o principal (listas, WhatsApp e convites).
</p>
{advisor.contactID !== null ? (
<PhonesFieldEditor
defaultValues={advisor.fichaPhones}
label="Telefones"
saveAction={updateAdvisorContactFormAction}
recordId={advisor.contactID}
recordIdField="contactId"
/>
) : (
<p className="text-sm text-muted-foreground">Ficha de contato ainda não vinculada.</p>
)}
</section>
<section aria-labelledby="advisor-password-title" className="flex flex-col gap-3">
<h2 id="advisor-password-title" className="text-base font-medium">
Acesso

View file

@ -4,10 +4,12 @@ import {
createAdvisor,
sendAdvisorPasswordReset,
setAdvisorMunicipalitiesBatch,
updateAdvisorContactFicha,
updateAdvisorProfile,
} from '@/app/(campaign)/campanha/actions/advisor'
import {
nullableFormText,
repeatedPhoneFormValues,
repeatedRelationshipFormValues,
requiredFormBoolean,
requiredFormText,
@ -88,6 +90,26 @@ export const sendAdvisorPasswordResetFormAction = async (
genericMessage: 'Não foi possível enviar o link de redefinição de senha.',
})
/**
* C112 — the advisor's ficha (Contact) phone list. The account keeps its own
* single channel phone ("Conta"); this editor targets the person's ficha.
*/
export const updateAdvisorContactFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await updateAdvisorContactFicha({
contactId: requiredRelationshipFormValue(formData, 'contactId'),
phones: repeatedPhoneFormValues(formData, 'phones'),
})
return { message: 'Telefones atualizados.' }
},
safeMessages: advisorSafeMessages,
genericMessage: 'Não foi possível salvar os telefones.',
})
export const createAdvisorFormAction = async (
_state: CampaignFormActionState,
formData: FormData,

View file

@ -5,6 +5,7 @@ import { getPayload } from 'payload'
import type { ContactComboboxOption } from '@/components/campaign/shared/ContactCombobox'
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
import { primaryPhoneOf } from '@/lib/phone'
import {
searchActivityResponsibleOptions,
type ActivityResponsibleSearchOption,
@ -28,18 +29,22 @@ export const searchActivityContactOptions = async (
const result = await payload.find({
collection: 'contact',
where: {
or: [{ name: { contains: trimmed } }, { phone: { contains: digits || trimmed } }],
or: [{ name: { contains: trimmed } }, { 'phones.value': { contains: digits || trimmed } }],
},
depth: 0,
limit: CONTACT_OPTION_LIMIT,
page: 1,
sort: 'name',
select: { name: true, phone: true },
select: { name: true, phones: { value: true } },
user,
overrideAccess: false,
})
return result.docs.map(({ id, name, phone }) => ({ id, name, phone: phone ?? null }))
return result.docs.map(({ id, name, phones }) => ({
id,
name,
phone: primaryPhoneOf(phones),
}))
}
export const searchActivityResponsibleOptionsAction = async (

View file

@ -69,7 +69,7 @@ export default async function StateDeputyDetailPage({ params }: StateDeputyDetai
stateDeputyId={stateDeputy.id}
name={stateDeputy.name}
email={stateDeputy.email}
phone={stateDeputy.phone}
phones={stateDeputy.phones}
/>
{stateDeputy.notes ? (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{stateDeputy.notes}</p>

View file

@ -9,6 +9,7 @@ import {
} from '@/app/(campaign)/campanha/actions/stateDeputy'
import {
nullableFormText,
repeatedPhoneFormValues,
repeatedRelationshipFormValues,
requiredFormBoolean,
requiredFormText,
@ -69,6 +70,12 @@ export const updateStateDeputyContactFormAction = async (
field: 'phone',
phone: nullableFormText(formData, 'phone'),
})
} else if (field === 'phones') {
await updateStateDeputyContact({
id,
field: 'phones',
phones: repeatedPhoneFormValues(formData, 'phones'),
})
} else {
throw new Error(STATE_DEPUTY_INVALID_FIELD_MESSAGE)
}

View file

@ -97,7 +97,7 @@ export default async function LeadershipDetailPage({ params }: LeadershipDetailP
leadershipId={leadership.id}
name={leadership.name}
email={leadership.email}
phone={leadership.phone}
phones={leadership.phones}
/>
{leadership.stateDeputies.length > 0 ? (
<StateDeputyChips deputies={leadership.stateDeputies} />

View file

@ -8,6 +8,7 @@ import {
} from '@/app/(campaign)/campanha/actions/leadership'
import {
nullableFormText,
repeatedPhoneFormValues,
repeatedRelationshipFormValues,
requiredFormBoolean,
requiredFormText,
@ -65,6 +66,12 @@ export const updateLeadershipContactFormAction = async (
field: 'phone',
phone: nullableFormText(formData, 'phone'),
})
} else if (field === 'phones') {
await updateLeadershipContact({
id,
field: 'phones',
phones: repeatedPhoneFormValues(formData, 'phones'),
})
} else {
throw new Error(INVALID_FIELD_MESSAGE)
}

View file

@ -2,7 +2,12 @@
import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/liderancas/leadershipStaffEditMessages'
import { createLeadership } from '@/app/(campaign)/campanha/actions/leadership'
import { optionalFormText, repeatedRelationshipFormValues, requiredFormText } from '@/lib/formData'
import {
optionalFormText,
repeatedPhoneFormValues,
repeatedRelationshipFormValues,
requiredFormText,
} from '@/lib/formData'
import {
LEADERSHIP_DUPLICATE_MESSAGE,
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
@ -33,7 +38,7 @@ export const createLeadershipFormAction = async (
return createLeadership({
name: requiredFormText(formData, 'name'),
phone: requiredFormText(formData, 'phone'),
phones: repeatedPhoneFormValues(formData, 'phones'),
email: optionalFormText(formData, 'email'),
municipalities: repeatedRelationshipFormValues(formData, 'municipalities'),
organizations: repeatedRelationshipFormValues(formData, 'organizations'),

View file

@ -23,6 +23,7 @@ import {
type AdvisorPasswordResetInput,
type AdvisorProfileUpdateInput,
} from '@/lib/schemas/advisor'
import { contactPhonesSchema } from '@/lib/schemas/contact'
import type { CampaignUser } from '@/payload-types'
import {
getCampaignActionContext,
@ -163,6 +164,29 @@ export const updateAdvisorProfile = async (input: AdvisorProfileUpdateInput) =>
return updated
}
/**
* C112 — write the advisor's ficha (Contact) phone list. The advisor page is
* unrestricted-staff gated; the ficha is the person's record, distinct from
* the account's single channel phone.
*/
export const updateAdvisorContactFicha = async (input: { contactId: number; phones: string[] }) => {
const { payload, actor } = await getCampaignActionContext()
await reloadUnrestrictedActor(payload, actor, ADVISOR_UNRESTRICTED_MESSAGE)
// Same zod gate as the other ficha editors: valid mobiles, no duplicates.
const parsed = contactPhonesSchema.parse(input.phones)
await payload.update({
collection: 'contact',
id: input.contactId,
data: { phones: parsed.map((value) => ({ value })) },
depth: 0,
// Intentional admin bypass: the unrestricted route gate above established
// staff scope; the ficha is the advisor's own normalized Contact.
overrideAccess: true,
})
revalidatePath('/campanha/assessores', 'page')
}
export const setAdvisorMunicipalitiesBatchRecord = async (
payload: Payload,
actor: CampaignUser,

View file

@ -0,0 +1,96 @@
'use server'
import type { CampaignUser } from '@/payload-types'
import type { Payload } from 'payload'
import { buildGoogleCalendarAddLink } from '@/lib/googleCalendarLink'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import {
loadGoogleCalendarSyncConfig,
readGoogleCalendarSyncView,
runCampaignCalendarSync,
type GoogleCalendarSyncView,
} from '@/utilities/googleCalendarSync'
const GOOGLE_CALENDAR_SYNC_STAFF_ONLY_MESSAGE =
'Apenas a equipe da campanha pode gerenciar a sincronização com o Google.'
const GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE =
'Não foi possível atualizar o estado da sincronização com o Google.'
export type GoogleCalendarSyncActionResult = {
ok: boolean
message?: string
} & GoogleCalendarSyncView & { addLink: string | null }
const withLinks = (view: GoogleCalendarSyncView): Omit<GoogleCalendarSyncActionResult, 'ok'> => ({
...view,
addLink: view.calendarId ? buildGoogleCalendarAddLink(view.calendarId) : null,
})
const failure = (message: string): GoogleCalendarSyncActionResult => ({
ok: false,
message,
status: 'not-configured',
calendarId: null,
lastSyncedAt: null,
lastSuccessAt: null,
lastErrorAt: null,
lastError: null,
addLink: null,
})
const requireStaff = async (payload: Payload, actor: CampaignUser): Promise<CampaignUser> =>
reloadStaffActor(payload, actor, GOOGLE_CALENDAR_SYNC_STAFF_ONLY_MESSAGE)
/** Read model for the agenda pill/dialog — status derived, never stored. */
export const getGoogleCalendarSyncState = async (): Promise<GoogleCalendarSyncActionResult> => {
const { payload, actor } = await getCampaignActionContext()
try {
await requireStaff(payload, actor)
return { ok: true, ...withLinks(await readGoogleCalendarSyncView(payload)) }
} catch (error) {
console.error('getGoogleCalendarSyncState failed', error)
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE)
}
}
/** Manual retry / auto-retry (agenda page view when paused). */
export const runGoogleCalendarSyncNow = async (): Promise<GoogleCalendarSyncActionResult> => {
const { payload, actor } = await getCampaignActionContext()
try {
await requireStaff(payload, actor)
await runCampaignCalendarSync(payload, { reason: 'manual' })
return { ok: true, ...withLinks(await readGoogleCalendarSyncView(payload)) }
} catch (error) {
console.error('runGoogleCalendarSyncNow failed', error)
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE)
}
}
/** Staff pauses/resumes the mirror — the afterChange hook reconciles on resume. */
export const setGoogleCalendarSyncDisabled = async (
disabled: boolean,
): Promise<GoogleCalendarSyncActionResult> => {
const { payload, actor } = await getCampaignActionContext()
try {
const currentActor = await requireStaff(payload, actor)
const doc = await loadGoogleCalendarSyncConfig(payload)
if (doc) {
await payload.update({
collection: 'googleCalendarSync',
id: doc.id,
data: { disabledAt: disabled ? new Date().toISOString() : null },
depth: 0,
user: currentActor,
overrideAccess: false,
})
}
return { ok: true, ...withLinks(await readGoogleCalendarSyncView(payload)) }
} catch (error) {
console.error('setGoogleCalendarSyncDisabled failed', error)
return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE)
}
}

View file

@ -4,8 +4,12 @@ import { revalidatePath } from 'next/cache'
import type { Payload } from 'payload'
import { SUPPORTER_REGISTRATION_CONSENT_MISSING_MESSAGE } from '@/lib/campaignConsentKeys'
import { checkboxFormValue, nullableRelationshipFormValue, optionalFormText } from '@/lib/formData'
import { sanitizeBrazilianPhoneInput } from '@/lib/phone'
import {
checkboxFormValue,
nullableRelationshipFormValue,
optionalFormText,
repeatedPhoneFormValues,
} from '@/lib/formData'
import {
LEADER_SUPPORTER_ONLY_MESSAGE,
leaderSupporterCreateSchema,
@ -32,7 +36,7 @@ export type LeaderSupporterFormState = {
type LeaderSupporterFormValues = {
name?: string
phone?: string
phones?: string[]
city?: string
municipality?: string
}
@ -89,7 +93,7 @@ export const createLeaderSupporterRecord = async (
const { contactID, reused } = await findOrCreateContactByPhone({
payload,
req,
phone: data.phone,
phones: data.phones,
name: data.name,
city: data.city,
})
@ -133,18 +137,17 @@ export const createLeaderSupporterFormAction = async (
): Promise<LeaderSupporterFormState> => {
const values: LeaderSupporterFormValues = {
name: optionalFormText(formData, 'name'),
phone: optionalFormText(formData, 'phone'),
phones: repeatedPhoneFormValues(formData, 'phones'),
city: optionalFormText(formData, 'city'),
municipality: optionalFormText(formData, 'municipality'),
}
return runCampaignFormAction({
execute: async () => {
const phone = sanitizeBrazilianPhoneInput(values.phone ?? '')
const municipality = nullableRelationshipFormValue(formData, 'municipality')
const input = leaderSupporterCreateSchema.parse({
name: values.name ?? '',
phone,
phones: values.phones ?? [],
city: values.city,
municipality,
consentAccepted: checkboxFormValue(formData, 'consentAccepted') ? true : undefined,

View file

@ -6,6 +6,7 @@ import type { Payload } from 'payload'
import { nextLeadershipAdvisorIdsAfterMembership } from '@/lib/leadershipAdvisorMembership'
import { nextMunicipalityIdsAfterLeadershipMembership } from '@/lib/leadershipMunicipalityMembership'
import { nextStateDeputyIdsAfterMembership } from '@/lib/leadershipStateDeputyMembership'
import { reorderWithPrimaryPhone } from '@/lib/phone'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import {
LEADERSHIP_ADVISORS_UNRESTRICTED_MESSAGE,
@ -81,8 +82,8 @@ const isUniqueLeadershipConflict = (error: unknown): boolean => {
}
type LeadershipCreateData = ReturnType<typeof leadershipCreateSchema.parse>
type ValidatedLeadershipCreateData = Omit<LeadershipCreateData, 'phone'> & {
phone?: string | null
type ValidatedLeadershipCreateData = Omit<LeadershipCreateData, 'phones'> & {
phones?: string[]
}
const createValidatedLeadershipRecord = async (
@ -118,7 +119,7 @@ const createValidatedLeadershipRecord = async (
const { contactID, reused } = await findOrCreateContactByPhone({
payload,
req,
phone: data.phone ?? null,
phones: data.phones ?? [],
name: data.name,
email: data.email,
city: city ?? undefined,
@ -254,18 +255,25 @@ export const updateLeadershipContactRecord = async (
throw new Error(LEADERSHIP_INVALID_CONTACT_MESSAGE)
}
const contactData: Partial<Pick<Contact, 'name' | 'phone' | 'email'>> = {}
const contactData: Partial<Pick<Contact, 'name' | 'email'>> & {
phones?: { value: string }[]
} = {}
if (data.field === 'name') {
contactData.name = data.name
} else if (data.field === 'email') {
contactData.email = data.email ?? null
} else if (data.field === 'phone') {
if (data.phone) {
contactData.phone = data.phone
} else {
contactData.phone = null
}
// Inline cell edit = set the PRIMARY phone, keeping the rest of the
// list untouched: the new number goes first (an earlier occurrence
// moves, never duplicates) and clearing removes the primary.
const contact = current.contact
contactData.phones = reorderWithPrimaryPhone(
typeof contact === 'object' && contact !== null ? contact.phones : null,
data.phone,
).map((value) => ({ value }))
} else if (data.field === 'phones') {
contactData.phones = data.phones.map((value) => ({ value }))
}
// bypass: contact write is staff-scoped via leadership access check above.
@ -336,7 +344,7 @@ const updateLeadershipWizardRecord = async (
id: contactID,
data: {
name: data.name,
phone: data.phone,
phones: data.phones.map((value) => ({ value })),
email: data.email,
},
depth: 0,
@ -370,7 +378,7 @@ const createLeadershipWizardRecord = async (
const data = leadershipWizardCreateSchema.parse(input)
return createValidatedLeadershipRecord(payload, actor, {
name: data.name,
phone: data.phone,
phones: data.phones,
email: data.email,
municipalities: [data.municipalityId],
exclusive: data.exclusive,
@ -415,7 +423,7 @@ export const createMunicipalityLeadershipRecord = async (
const data = municipalityLeadershipCreateSchema.parse(input)
const leadership = await createValidatedLeadershipRecord(payload, actor, {
name: data.name,
phone: null,
phones: [],
municipalities: [data.municipalityId],
email: undefined,
exclusive: true,

View file

@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache'
import type { Payload } from 'payload'
import { nextStateDeputyIdsAfterMunicipalityMembership } from '@/lib/municipalityStateDeputyMembership'
import { reorderWithPrimaryPhone } from '@/lib/phone'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import { contactFieldUpdateSchema, type ContactFieldUpdateInput } from '@/lib/schemas/contact'
import {
@ -69,7 +70,7 @@ const createStateDeputyWithContact = async (
data: {
name: data.name,
email: null,
phone: null,
phones: [],
state: 'BA' as Contact['state'],
city: null,
},
@ -165,7 +166,7 @@ export const updateStateDeputyContactRecord = async (
const current = await payload.findByID({
collection: 'stateDeputy',
id: data.id,
depth: 0,
depth: 1,
select: { contact: true },
user: currentActor,
overrideAccess: false,
@ -175,18 +176,24 @@ export const updateStateDeputyContactRecord = async (
const contactID = relationshipId(current.contact)
if (contactID === null) throw new Error(STATE_DEPUTY_INVALID_CONTACT_MESSAGE)
const contactData: Partial<Pick<Contact, 'name' | 'phone' | 'email'>> = {}
const contactData: Partial<Pick<Contact, 'name' | 'email'>> & {
phones?: { value: string }[]
} = {}
if (data.field === 'name') {
await assertStateDeputyNameAvailable(payload, req, data.name, data.id)
contactData.name = data.name
} else if (data.field === 'email') {
contactData.email = data.email ?? null
} else if (data.field === 'phone') {
if (data.phone) {
contactData.phone = data.phone
} else {
contactData.phone = null
}
// Inline cell edit = set the PRIMARY phone, keeping the rest of the
// list untouched (see the leadership sibling).
const contact = current.contact
contactData.phones = reorderWithPrimaryPhone(
typeof contact === 'object' && contact !== null ? contact.phones : null,
data.phone,
).map((value) => ({ value }))
} else if (data.field === 'phones') {
contactData.phones = data.phones.map((value) => ({ value }))
}
// Bypass: the StateDeputy row access above established staff scope.

View file

@ -1,8 +1,10 @@
'use server'
import { revalidatePath } from 'next/cache'
import type { Payload } from 'payload'
import { requireRelationshipId } from '@/lib/relationship'
import { relationshipId, requireRelationshipId } from '@/lib/relationship'
import { contactFieldUpdateSchema, type ContactFieldUpdateInput } from '@/lib/schemas/contact'
import {
SUPPORTER_DUPLICATE_MESSAGE,
SUPPORTER_STAFF_MESSAGE,
@ -37,6 +39,8 @@ const getFreshStaffActor = (
req?: PayloadTransactionRequest,
): Promise<CampaignUser> => reloadStaffActor(payload, actor, SUPPORTER_STAFF_MESSAGE, req)
const INVALID_FIELD_MESSAGE = 'Campo inválido.'
const assertMunicipalityManagement = async (
payload: Payload,
actor: CampaignUser,
@ -92,7 +96,7 @@ const createValidatedSupporter = async (payload: Payload, actor: CampaignUser, i
const { contactID, reused } = await findOrCreateContactByPhone({
payload,
req,
phone: data.phone,
phones: data.phones,
name: data.name,
email: data.email,
city: data.city,
@ -183,6 +187,54 @@ export const setSupporterVoteIntention = async (input: unknown) => {
return setSupporterVoteIntentionRecord(payload, actor, input)
}
/**
* C112 — the supporter ficha's phone list editor. Staff only (same supporter
* access gate as every supporter write); only the `phones` branch of the
* shared per-field schema is honored.
*/
export const updateSupporterContactRecord = async (
payload: Payload,
actor: CampaignUser,
input: ContactFieldUpdateInput,
) => {
const data = contactFieldUpdateSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await getFreshStaffActor(payload, actor, req)
const supporter = await assertCanManageSupporter(payload, currentActor, data.id, req)
const contactID = relationshipId(supporter.contact)
if (contactID === null) throw new Error(SUPPORTER_STAFF_MESSAGE)
if (data.field !== 'phones') {
throw new Error(INVALID_FIELD_MESSAGE)
}
// bypass: the supporter row access above established staff scope.
await payload.update({
collection: 'contact',
id: contactID,
data: { phones: data.phones.map((value) => ({ value })) },
depth: 0,
overrideAccess: true,
req,
})
return supporter
},
{ beginFailureMessage: 'Não foi possível atualizar o contato do apoiador.' },
)
}
export const updateSupporterContact = async (input: ContactFieldUpdateInput) => {
const { payload, actor } = await getCampaignActionContext()
const supporter = await updateSupporterContactRecord(payload, actor, input)
revalidatePath(`/campanha/apoiadores/${supporter.id}`, 'page')
return supporter
}
// ---------------------------------------------------------------------------
// CSV import pipeline — implementation lives in ./supporterImport. A top-level
// `'use server'` module cannot re-export values (only async functions defined
@ -275,7 +327,9 @@ const anonymizeContact = async (
data: {
name: 'Titular removido',
email: null,
phone: tombstonePhone,
// The tombstone REPLACES every number — the remaining phones are the
// person's PII and must not survive the anonymization (C112).
phones: [{ value: tombstonePhone }],
gender: null,
city: null,
postalCode: null,

View file

@ -224,28 +224,35 @@ export const previewSupporterImportText = async (
if (candidatePhones.length > 0) {
const contacts = await payload.find({
collection: 'contact',
where: { phone: { in: candidatePhones } },
where: { 'phones.value': { in: candidatePhones } },
depth: 0,
// No LIMIT: a phone can now legitimately match many fichas (C111), so a
// finite limit would silently miss a shared phone past the cutoff and
// the preview would promise rows the confirm then aborts.
limit: 0,
pagination: false,
select: { phone: true },
select: { phones: { value: true } },
overrideAccess: true,
})
// C111 — the phone is not unique: a CSV phone resolving to two or more
// fichas cannot be matched to a person, so every row with that phone is
// flagged for manual resolution (never a silent "last one wins").
// flagged for manual resolution (never a silent "last one wins"). Keyed by
// the CSV phone ITSELF, not the ficha's primary: the JOIN matched any
// position, so a number carried as secondary by two fichas must be shared
// here — exactly what the confirm's fail-closed check will abort on.
const candidatePhoneSet = new Set(candidatePhones)
const contactByPhone = new Map<string, number>()
const sharedPhones = new Set<string>()
for (const doc of contacts.docs) {
if (!doc.phone) continue
if (contactByPhone.has(doc.phone)) {
sharedPhones.add(doc.phone)
continue
for (const entry of doc.phones ?? []) {
const phone = entry.value
if (!phone || !candidatePhoneSet.has(phone)) continue
if (contactByPhone.has(phone)) {
sharedPhones.add(phone)
continue
}
contactByPhone.set(phone, doc.id)
}
contactByPhone.set(doc.phone, doc.id)
}
const contactIDs = [...contactByPhone.values()]

View file

@ -37,9 +37,15 @@ export const submitPetitionSignature = async ({
}
try {
const { phone, ...contactFields } = data
const contact = await payload.create({
collection: 'contact',
data,
data: {
...contactFields,
// The public forms keep a single phone input; the ficha stores the
// phones array with that number as primary (C112).
phones: phone ? [{ value: phone }] : [],
},
req: { transactionID },
})

View file

@ -28,9 +28,15 @@ export const submitWhatsapp = async (input: WhatsAppFormInput) => {
}
try {
const { phone, ...contactFields } = contactInput
const contact = await payload.create({
collection: 'contact',
data: contactInput,
data: {
...contactFields,
// The public forms keep a single phone input; the ficha stores the
// phones array with that number as primary (C112).
phones: phone ? [{ value: phone }] : [],
},
req: { transactionID },
})

View file

@ -29,6 +29,7 @@ import {
eligibleCampaignStaffWhere,
} from '@/utilities/campaignAccess'
import { systemStampedActorField } from '@/utilities/campaignAuditFields'
import { activityGoogleCalendarSyncHook } from '@/utilities/googleCalendarSyncHooks'
const isActivityMutationShortcut = (context: Record<string, unknown> | undefined) =>
context?.mutationKind === 'taskToggle' || context?.mutationKind === 'appendUpdate'
@ -404,7 +405,11 @@ export const Activity: CollectionConfig = {
await notifyActivityNeedsAttention(req, doc)
return doc
},
// C114: pushes the official calendar mirror to Google (best-effort —
// never throws into the write path; failures land in `paused`).
activityGoogleCalendarSyncHook,
],
afterDelete: [activityGoogleCalendarSyncHook],
},
fields: [
{

View file

@ -146,7 +146,7 @@ const resolveContactForAccount = async ({
const { contactID } = await findOrCreateContactByPhone({
payload,
req,
phone: typeof phone === 'string' && phone.length > 0 ? phone : null,
phones: typeof phone === 'string' && phone.length > 0 ? [phone] : [],
name,
email: email && !isPlanilhaPlaceholderEmail(email) ? email : null,
})
@ -205,7 +205,11 @@ const ensureCampaignUserContactIdentity: CollectionBeforeChangeHook<CampaignUser
const linkedContactID = relationshipId(originalDoc?.contact)
if (linkedContactID !== null) {
const contactData: { name?: string; email?: string | null; phone?: string } = {}
const contactData: {
name?: string
email?: string | null
phones?: { value: string }[]
} = {}
if (data.name !== undefined && data.name !== originalDoc?.name) {
contactData.name = data.name
@ -216,14 +220,32 @@ const ensureCampaignUserContactIdentity: CollectionBeforeChangeHook<CampaignUser
}
}
// Clearing the account's phone never clears the ficha's: the ficha's
// phone is the person's dedupe key across every join (supporter,
// phones are the person's dedupe keys across every join (supporter,
// leadership, dobradinha), and the account edit is not a ficha edit.
// A changed account phone is APPENDED when the ficha lacks it (never
// reordering the mesa's priority, never removing the previous number);
// creation still seeds it as the primary via find-or-create.
if (
typeof data.phone === 'string' &&
data.phone.length > 0 &&
data.phone !== originalDoc?.phone
) {
contactData.phone = data.phone
const contact = await req.payload.findByID({
collection: 'contact',
id: linkedContactID,
depth: 0,
select: { phones: { value: true } },
// Intentional admin bypass: same account-scoped policy as the ficha
// write below — the read only derives the append target.
overrideAccess: true,
req,
})
const existingPhones = (contact.phones ?? [])
.map((entry) => entry.value)
.filter((value): value is string => Boolean(value))
if (!existingPhones.includes(data.phone)) {
contactData.phones = [...existingPhones, data.phone].map((value) => ({ value }))
}
}
if (Object.keys(contactData).length === 0) return data

View file

@ -1,11 +1,63 @@
import { CitiesByState } from '@/lib/cities'
import { BRAZILIAN_PHONE_INVALID_MESSAGE, normalizeBrazilianPhone } from '@/lib/phone'
import {
BRAZILIAN_PHONE_DUPLICATE_MESSAGE,
BRAZILIAN_PHONE_INVALID_MESSAGE,
normalizeBrazilianPhone,
} from '@/lib/phone'
import { trimmedText } from '@/lib/text'
import { canManageContacts, canReadContacts } from '@/utilities/campaignAccess'
import { assertStateDeputyNameAvailable } from '@/utilities/stateDeputy/nameInvariant'
import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload'
import { APIError } from 'payload'
type ContactPhoneValue = { value: string }
/**
* Normalizes the ficha's phone list: each entry is a valid Brazilian mobile
* (11 digits, `^[1-9]{2}9\d{8}$`), empty entries are dropped, and the same
* number cannot be stored twice in the same ficha (the person already has it;
* sharing a number BETWEEN fichas stays allowed — C111). `undefined` (admin
* untouched) passes through; `null`/`''` collapse to an empty list.
*/
const normalizeContactPhones = (data: unknown): ContactPhoneValue[] => {
const phones = Array.isArray(data) ? data : []
const seen = new Set<string>()
const normalized: ContactPhoneValue[] = []
for (const entry of phones) {
if (entry === null || entry === undefined) continue
const raw =
typeof entry === 'object' && 'value' in (entry as Record<string, unknown>)
? (entry as Record<string, unknown>).value
: entry
if (raw === null || raw === undefined || raw === '') continue
const input = String(raw)
let value: string | null
if (/^\d{11}$/.test(input)) {
if (/^[1-9]{2}9\d{8}$/.test(input)) {
value = input
} else {
throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400)
}
} else {
value = normalizeBrazilianPhone(input)
if (!value) {
throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400)
}
}
if (seen.has(value)) {
throw new APIError(BRAZILIAN_PHONE_DUPLICATE_MESSAGE, 400)
}
seen.add(value)
normalized.push({ value })
}
return normalized
}
const enforceStateDeputyName: CollectionBeforeChangeHook = async ({
data,
operation,
@ -57,27 +109,14 @@ export const Contact: CollectionConfig = {
hooks: {
beforeValidate: [
({ data }) => {
if (!data || data.phone === undefined) return data
if (!data || data.phones === undefined) return data
if (data.phone === null || data.phone === '') {
data.phone = null
if (data.phones === null || data.phones === '') {
data.phones = []
return data
}
const input = String(data.phone)
if (/^\d{11}$/.test(input)) {
if (!/^[1-9]{2}9\d{8}$/.test(input)) {
throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400)
}
return data
}
const phone = normalizeBrazilianPhone(input)
if (!phone) {
throw new APIError(BRAZILIAN_PHONE_INVALID_MESSAGE, 400)
}
data.phone = phone
data.phones = normalizeContactPhones(data.phones)
return data
},
],
@ -99,15 +138,22 @@ export const Contact: CollectionConfig = {
required: false,
},
{
name: 'phone',
type: 'text',
label: 'Celular',
minLength: 11,
maxLength: 11,
name: 'phones',
type: 'array',
label: 'Telefones',
// Optional at the collection level so name-only records (e.g. leaderships imported
// from the projection sheet) can exist; UI flows still require it via zod schemas.
required: false,
index: true,
fields: [
{
name: 'value',
type: 'text',
label: 'Celular',
minLength: 11,
maxLength: 11,
index: true,
},
],
},
{
name: 'gender',

View file

@ -0,0 +1,111 @@
import type { CollectionConfig } from 'payload'
import {
canCreateGoogleCalendarSync,
canDeleteGoogleCalendarSync,
canReadGoogleCalendarSync,
canSetGoogleCalendarSyncConfigField,
canSetGoogleCalendarSyncDisabled,
canSetGoogleCalendarSyncSystemField,
canUpdateGoogleCalendarSync,
} from '@/utilities/access/googleCalendarSync'
import { googleCalendarSyncConfigHook } from '@/utilities/googleCalendarSyncHooks'
/**
* C114 — configuration and state of the campaign→Google calendar mirror
* (one row in practice, created/edited by Payload admin in ops). The service
* account private key never lives here — it is `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY`
* in the environment. Changing `calendarId` (or re-enabling) re-runs the full
* reconciliation automatically via `googleCalendarSyncConfigHook` (D7).
*/
export const GoogleCalendarSync: CollectionConfig = {
slug: 'googleCalendarSync',
labels: {
singular: 'Sincronização Google Agenda',
plural: 'Sincronização Google Agenda',
},
admin: {
group: 'Campanha',
useAsTitle: 'calendarId',
defaultColumns: ['calendarId', 'lastSyncedAt', 'lastSuccessAt', 'lastError'],
},
access: {
create: canCreateGoogleCalendarSync,
read: canReadGoogleCalendarSync,
update: canUpdateGoogleCalendarSync,
delete: canDeleteGoogleCalendarSync,
},
hooks: {
afterChange: [googleCalendarSyncConfigHook],
},
fields: [
{
name: 'calendarId',
type: 'text',
label: 'ID do calendário (Google)',
index: true,
access: {
create: canSetGoogleCalendarSyncConfigField,
update: canSetGoogleCalendarSyncConfigField,
},
admin: {
description:
'O calendarId do calendário "Agenda da Campanha" na conta Google da campanha (ex.: c_…@group.calendar.google.com). O calendário precisa estar público no link e a service account com permissão de edição.',
},
},
{
name: 'disabledAt',
type: 'date',
label: 'Desativado em',
index: true,
access: {
create: canSetGoogleCalendarSyncDisabled,
update: canSetGoogleCalendarSyncDisabled,
},
admin: {
readOnly: true,
description: 'Enquanto preenchido, o Teqo não escreve no Google.',
},
},
{
name: 'lastSyncedAt',
type: 'date',
label: 'Última sincronização',
admin: { readOnly: true },
access: {
create: canSetGoogleCalendarSyncSystemField,
update: canSetGoogleCalendarSyncSystemField,
},
},
{
name: 'lastSuccessAt',
type: 'date',
label: 'Última sincronização com sucesso',
admin: { readOnly: true },
access: {
create: canSetGoogleCalendarSyncSystemField,
update: canSetGoogleCalendarSyncSystemField,
},
},
{
name: 'lastErrorAt',
type: 'date',
label: 'Último erro',
admin: { readOnly: true },
access: {
create: canSetGoogleCalendarSyncSystemField,
update: canSetGoogleCalendarSyncSystemField,
},
},
{
name: 'lastError',
type: 'textarea',
label: 'Mensagem do último erro',
admin: { readOnly: true },
access: {
create: canSetGoogleCalendarSyncSystemField,
update: canSetGoogleCalendarSyncSystemField,
},
},
],
}

View file

@ -0,0 +1,121 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { GoogleCalendarSyncActionResult } from '@/app/(campaign)/campanha/actions/googleCalendarSync'
import { GoogleCalendarSyncDialog } from '@/components/campaign/activity/GoogleCalendarSyncDialog'
import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext'
import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext'
import type { GoogleCalendarSyncStatus } from '@/utilities/googleCalendarSync'
type AgendaGoogleSyncChromeProps = {
initialState: GoogleCalendarSyncActionResult
onSyncNow: () => Promise<GoogleCalendarSyncActionResult>
onSetDisabled: (disabled: boolean) => Promise<GoogleCalendarSyncActionResult>
}
const PILL_COPY: Record<
GoogleCalendarSyncStatus,
{ label: string; dot: string; className: string }
> = {
synced: {
label: 'Google: sincronizado',
dot: 'bg-green-500',
className: 'text-green-700',
},
paused: {
label: 'Google: pausado — re-tentando',
dot: 'bg-amber-500',
className: 'text-amber-700',
},
'not-configured': {
label: 'Google: não configurado',
dot: 'bg-muted-foreground',
className: 'text-muted-foreground',
},
disabled: {
label: 'Google: desativado',
dot: 'bg-muted-foreground',
className: 'text-muted-foreground',
},
}
/**
* 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.
*/
export const AgendaGoogleSyncChrome = ({
initialState,
onSyncNow,
onSetDisabled,
}: AgendaGoogleSyncChromeProps) => {
const { setContext } = useCampaignQuickActionContext()
const [open, setOpen] = useState(false)
const [state, setState] = useState(initialState)
const autoRetriedRef = useRef(false)
const openSync = useCallback(() => setOpen(true), [])
useEffect(() => {
setContext((current) => ({ ...current, openGoogleCalendarSync: openSync }))
return () => {
setContext((current) => ({ ...current, openGoogleCalendarSync: undefined }))
}
}, [setContext, openSync])
// "Re-tenta sem ação manual": a paused mirror tries once when the agenda
// page loads (the staff is operating — the natural retry moment).
const handleSyncNow = useCallback(async () => {
const result = await onSyncNow()
if (result.ok) setState(result)
return result
}, [onSyncNow])
useEffect(() => {
if (state.status === 'paused' && !autoRetriedRef.current) {
autoRetriedRef.current = true
void handleSyncNow()
}
}, [state.status, handleSyncNow])
const handleSetDisabled = useCallback(
async (disabled: boolean) => {
const result = await onSetDisabled(disabled)
if (result.ok) setState(result)
return result
},
[onSetDisabled],
)
const pill = useMemo(() => {
const copy = PILL_COPY[state.status]
return (
<button
type="button"
className={`hidden shrink-0 items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring md:inline-flex ${copy.className}`}
aria-label={copy.label}
title={copy.label}
onClick={openSync}
>
<span className={`size-1.5 rounded-full ${copy.dot}`} aria-hidden />
{copy.label}
</button>
)
}, [state.status, openSync])
return (
<>
<SetCampaignHeaderAction id="google-calendar-sync">{pill}</SetCampaignHeaderAction>
<GoogleCalendarSyncDialog
open={open}
onOpenChange={setOpen}
state={state}
onSyncNow={handleSyncNow}
onSetDisabled={handleSetDisabled}
/>
</>
)
}

View file

@ -0,0 +1,291 @@
'use client'
import { useState } from 'react'
import { CopyIcon, ExternalLinkIcon, RefreshCwIcon } from 'lucide-react'
import type { 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 { Input } from '@/components/ui/input'
import { useIsMobile } from '@/hooks/use-mobile'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
type GoogleCalendarSyncDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
state: GoogleCalendarSyncActionResult
onSyncNow: () => Promise<GoogleCalendarSyncActionResult>
onSetDisabled: (disabled: boolean) => Promise<GoogleCalendarSyncActionResult>
}
const NOT_CONFIGURED_RUNBOOK: readonly string[] = [
'Crie o calendário "Agenda da Campanha" na conta Google da campanha.',
'No Google, compartilhe o calendário como público (qualquer pessoa com o link pode ver).',
'Dê permissão de edição ("fazer alterações em eventos") à service account do Teqo.',
'Um administrador configura o ID do calendário no Painel e a chave da service account nas variáveis de ambiente.',
]
const copyIcon = <CopyIcon className="h-4 w-4" />
/**
* C114 — Google Calendar mirror dialog (estados da jornada do canvas:
* não configurado / sincronizado / pausado / desativado). Bottom sheet on
* mobile, dialog on desktop — same chrome as the iCal feed dialog (C94).
*/
export const GoogleCalendarSyncDialog = ({
open,
onOpenChange,
state,
onSyncNow,
onSetDisabled,
}: GoogleCalendarSyncDialogProps) => {
const isMobile = useIsMobile()
const [isBusy, setIsBusy] = useState(false)
const [copied, setCopied] = useState(false)
const [actionError, setActionError] = useState<string | null>(null)
const resetTransientState = () => {
setCopied(false)
setActionError(null)
}
const handleCopy = async () => {
if (!state.addLink) return
await navigator.clipboard.writeText(state.addLink)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleSyncNow = async () => {
if (isBusy) return
setIsBusy(true)
setActionError(null)
try {
const result = await onSyncNow()
if (!result.ok) setActionError(result.message ?? 'Não foi possível sincronizar.')
} finally {
setIsBusy(false)
}
}
const handleSetDisabled = async (disabled: boolean) => {
if (isBusy) return
setIsBusy(true)
setActionError(null)
try {
const result = await onSetDisabled(disabled)
if (!result.ok) setActionError(result.message ?? 'Não foi possível atualizar.')
} finally {
setIsBusy(false)
}
}
const 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}
</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>
) : null
const instructions = (
<div className="rounded-lg bg-muted p-4 text-sm">
<p className="mb-2 font-medium">Como adicionar ao Google Calendar:</p>
<ol className="list-inside list-decimal space-y-1 text-muted-foreground">
<li>Abra o Google Calendar no computador</li>
<li>
No menu lateral, clique em {'"'}
{'+'}
{'"'} ao lado de {'"'}Outras agendas{'"'}
</li>
<li>
Selecione {'"'}Por URL{'"'}
</li>
<li>
Cole o link acima e clique em {'"'}Adicionar agenda{'"'}
</li>
</ol>
<p className="mt-2 text-muted-foreground">
Apple Calendar e Outlook assinam a URL iCal pública do mesmo calendário.
</p>
</div>
)
const syncButton = (
<Button type="button" onClick={handleSyncNow} disabled={isBusy}>
<RefreshCwIcon className={`mr-2 h-4 w-4 ${isBusy ? 'animate-spin' : ''}`} />
{isBusy ? 'Sincronizando...' : 'Sincronizar agora'}
</Button>
)
let body: React.ReactNode
if (state.status === 'not-configured') {
body = (
<div className="space-y-4">
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm">
<p className="mb-2 font-medium text-amber-900">Ainda não configurado</p>
<p className="text-amber-800">
O Teqo segue 100% funcional — a agenda do Teqo nunca depende do Google. Para ativar o
espelho, um administrador precisa concluir a configuração:
</p>
<ol className="mt-2 list-inside list-decimal space-y-1 text-amber-800">
{NOT_CONFIGURED_RUNBOOK.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</div>
</div>
)
} else if (state.status === 'disabled') {
body = (
<div className="space-y-4">
<div className="rounded-lg bg-muted p-4 text-sm text-muted-foreground">
A sincronização está desativada. A agenda do Teqo continua funcionando normalmente; nada é
enviado ao Google enquanto estiver desativada.
</div>
{actionError && <p className="text-sm text-red-600">{actionError}</p>}
<div className="flex justify-end">
<Button type="button" onClick={() => void handleSetDisabled(false)} disabled={isBusy}>
Reativar
</Button>
</div>
</div>
)
} else {
const paused = state.status === 'paused'
body = (
<div className="space-y-4">
{paused ? (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm">
<p className="mb-1 font-medium text-amber-900">Pausado — re-tentando</p>
<p className="text-amber-800">
A última tentativa de sincronização falhou. O Teqo continua funcionando; o espelho é
tentado de novo automaticamente ao salvar uma atividade ou ao abrir esta página.
</p>
{state.lastErrorAt ? (
<p className="mt-2 text-xs text-amber-700">
Última tentativa: {formatBahiaDateTimeLabel(state.lastErrorAt)} —{' '}
{state.lastError ?? 'erro desconhecido'}
</p>
) : null}
</div>
) : (
<p className="rounded-lg bg-green-50 p-3 text-sm text-green-800">
Sincronizado — as mudanças da agenda já refletiram no Google.
{state.lastSuccessAt
? ` Última sincronização: ${formatBahiaDateTimeLabel(state.lastSuccessAt)}.`
: ''}
</p>
)}
{linkBlock}
{instructions}
{actionError && <p className="text-sm text-red-600">{actionError}</p>}
<div className="flex justify-end gap-2">
{paused ? (
<Button asChild variant="outline">
<a
href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLinkIcon className="mr-2 h-4 w-4" />
Abrir Google Calendar
</a>
</Button>
) : null}
{syncButton}
<Button
type="button"
variant="outline"
onClick={() => void handleSetDisabled(true)}
disabled={isBusy}
>
Desativar
</Button>
</div>
</div>
)
}
const description =
'Compromissos do Teqo refletem no calendário Google compartilhado em minutos. Quem segue recebe aviso conforme as próprias configurações.'
const handleOpenChange = (next: boolean) => {
if (!next) resetTransientState()
onOpenChange(next)
}
if (isMobile) {
return (
<Drawer open={open} onOpenChange={handleOpenChange} showSwipeHandle>
<DrawerContent className="max-h-[85vh]">
<DrawerHeader className="text-left">
<DrawerTitle>Agenda da Campanha no Google</DrawerTitle>
<DrawerDescription>{description}</DrawerDescription>
</DrawerHeader>
<div className="overflow-y-auto px-4 pb-2">{body}</div>
<DrawerFooter className="border-t">
<DrawerCloseButton className="w-full">Fechar</DrawerCloseButton>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Agenda da Campanha no Google</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{body}
</DialogContent>
</Dialog>
)
}

View file

@ -8,10 +8,10 @@ import {
type LeaderSupporterFormState,
} from '@/app/(campaign)/campanha/actions/leaderSupporter'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
import { FormattedInput } from '@/components/FormattedInput'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
@ -26,7 +26,6 @@ import {
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone'
import { errorProps as buildErrorProps, fieldError } from '@/utilities/campaignFormFields'
import { municipalityComboboxOptions } from '@/utilities/territory/territoryComboboxOptions'
@ -55,7 +54,6 @@ export const LeaderContactForm = ({
useCampaignFormSuccessToast(state)
const phoneField = errorProps(state.fieldErrors, 'phone')
const nameField = errorProps(state.fieldErrors, 'name')
const cityField = errorProps(state.fieldErrors, 'city')
const municipalityField = errorProps(state.fieldErrors, 'municipality')
@ -88,33 +86,19 @@ export const LeaderContactForm = ({
) : null}
<FieldGroup>
<Field data-invalid={phoneField.invalid}>
<FieldLabel htmlFor="leader-contact-phone">Celular *</FieldLabel>
<FormattedInput
id="leader-contact-phone"
name="phone"
required
inputMode="tel"
autoComplete="tel"
autoFocus
defaultValue={values?.phone ? formatBrazilianPhoneInput(values.phone) : ''}
format={formatBrazilianPhoneInput}
sanitize={sanitizeBrazilianPhoneInput}
aria-invalid={phoneField.invalid}
aria-describedby={phoneField.describedBy}
className="min-h-11 rounded-[6px]"
/>
{phoneField.error ? (
<FieldError id="leader-contact-phone-error">{phoneField.error}</FieldError>
) : null}
</Field>
<PhonesFieldEditor
name="phones"
minRows={1}
label="Celulares (o primeiro é o principal) *"
defaultValues={values?.phones ?? []}
error={state.fieldErrors?.phones}
/>
<Field data-invalid={nameField.invalid}>
<FieldLabel htmlFor="leader-contact-name">Nome *</FieldLabel>
<Input
id="leader-contact-name"
name="name"
required
minLength={2}
defaultValue={values?.name}
autoComplete="name"
@ -148,7 +132,6 @@ export const LeaderContactForm = ({
<NativeSelect
id="leader-contact-municipality"
name="municipality"
required
defaultValue={values?.municipality ?? ''}
className="w-full **:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-[6px]"
>
@ -193,7 +176,6 @@ export const LeaderContactForm = ({
id="leader-contact-consent"
name="consentAccepted"
value="true"
required
aria-invalid={Boolean(consentError)}
aria-describedby={consentError ? 'leader-contact-consent-error' : undefined}
/>

View file

@ -2,19 +2,21 @@
import { updateLeadershipContactFormAction } from '@/app/(campaign)/campanha/(app)/liderancas/formActions'
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
type LeadershipContactSectionProps = {
leadershipId: number
name: string
email: string | null
phone: string | null
/** Every number of the ficha, order = priority (C112) — primary first. */
phones: string[]
}
export const LeadershipContactSection = ({
leadershipId,
name,
email,
phone,
phones,
}: LeadershipContactSectionProps) => (
<section aria-labelledby="leadership-contact-title" className="flex flex-col gap-3">
<h2 id="leadership-contact-title" className="text-base font-medium">
@ -50,16 +52,16 @@ export const LeadershipContactSection = ({
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-sm text-muted-foreground">Celular</dt>
<dt className="text-sm text-muted-foreground">
Celulares <span className="font-normal">(o primeiro é o principal)</span>
</dt>
<dd>
<CampaignInlineEditableCell
<PhonesFieldEditor
defaultValues={phones}
label="Celulares"
saveAction={updateLeadershipContactFormAction}
recordId={leadershipId}
recordIdField="leadershipId"
field="phone"
value={phone}
label="Celular"
formAction={updateLeadershipContactFormAction}
readBehavior="copy"
/>
</dd>
</div>

View file

@ -3,6 +3,7 @@
import { useActionState } from 'react'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
import {
RelationMultiSelect,
type RelationOption,
@ -46,7 +47,6 @@ export const LeadershipForm = ({
<Input
id="leadership-name"
name="name"
required
minLength={2}
maxLength={120}
className="min-h-11"
@ -56,20 +56,12 @@ export const LeadershipForm = ({
) : null}
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="leadership-phone">Celular (com DDD)</FieldLabel>
<Input
id="leadership-phone"
name="phone"
type="tel"
required
autoComplete="tel-national"
className="min-h-11"
/>
{fieldError(state.fieldErrors, 'phone') ? (
<FieldError>{fieldError(state.fieldErrors, 'phone')}</FieldError>
) : null}
</Field>
<PhonesFieldEditor
name="phones"
minRows={1}
label="Celulares (o primeiro é o principal)"
error={fieldError(state.fieldErrors, 'phones')}
/>
<Field>
<FieldLabel htmlFor="leadership-email">E-mail (opcional)</FieldLabel>
<Input id="leadership-email" name="email" type="email" className="min-h-11" />

View file

@ -7,6 +7,7 @@ import {
updateLeadershipWizardFormAction,
} from '@/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
@ -66,7 +67,6 @@ export const WizardLeadershipForm = ({
ref={nameRef}
id="wizard-leadership-name"
name="name"
required
minLength={2}
maxLength={120}
defaultValue={leadership?.name ?? ''}
@ -78,21 +78,13 @@ export const WizardLeadershipForm = ({
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="wizard-leadership-phone">Celular</FieldLabel>
<Input
id="wizard-leadership-phone"
name="phone"
type="tel"
required
autoComplete="tel-national"
defaultValue={leadership?.phone ?? ''}
className="min-h-11"
/>
{fieldError(state.fieldErrors, 'phone') ? (
<FieldError>{fieldError(state.fieldErrors, 'phone')}</FieldError>
) : null}
</Field>
<PhonesFieldEditor
name="phones"
minRows={1}
label="Celulares (o primeiro é o principal)"
defaultValues={leadership?.phones ?? []}
error={fieldError(state.fieldErrors, 'phones')}
/>
<Field>
<FieldLabel htmlFor="wizard-leadership-email">E-mail</FieldLabel>
<Input

View file

@ -0,0 +1,285 @@
'use client'
import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { FormattedInput } from '@/components/FormattedInput'
import { Button } from '@/components/ui/button'
import { FieldError, FieldLabel } from '@/components/ui/field'
import { Spinner } from '@/components/ui/Spinner'
import { campaignInlineInputClassName } from '@/lib/campaignInlineInput'
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
type PhoneRow = { key: string; value: string }
let rowKey = 0
const nextRowKey = (): string => `phone-${++rowKey}`
const EMPTY_ROW_KEY = 'phone-empty'
const displayValue = (value: string): string => formatBrazilianPhoneInput(value)
/**
* The repeatable phone list editor (C112): one masked input per number, order
* = priority (first = primary), with remove and ↑/↓ reorder — no labels, no
* drag (product cut). In a create/edit form it renders plain inputs named
* `phones` for the form action to collect; with `saveAction` it becomes the
* ficha's inline editor (Salvar/Cancelar + refresh on success).
*/
export const PhonesFieldEditor = ({
name = 'phones',
defaultValues = [],
minRows = 0,
label = 'Celulares',
error,
saveAction,
recordId,
recordIdField,
saveLabel = 'Salvar',
cancelLabel = 'Cancelar',
className,
}: {
name?: string
defaultValues?: string[]
/** Rows below this count cannot be removed (create flows that require a phone). */
minRows?: number
label?: string
error?: string | string[] | undefined
/** Inline mode: submits the full list through the per-surface contact update action. */
saveAction?: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
recordId?: number
recordIdField?: string
saveLabel?: string
cancelLabel?: string
className?: string
}) => {
const router = useRouter()
const [rows, setRows] = useState<PhoneRow[]>(() => {
const initial = defaultValues.map((value) => ({ key: nextRowKey(), value }))
while (initial.length < minRows) initial.push({ key: nextRowKey(), value: '' })
return initial
})
const [isPending, setIsPending] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [isEditing, setIsEditing] = useState(!saveAction)
const rowsRef = useRef(rows)
rowsRef.current = rows
const appliedDefaultsRef = useRef<string[]>(defaultValues)
useEffect(() => {
if (!saveAction) return
// Apply the server values only when they actually changed (a post-save
// refresh or an unrelated refresh of the page must not drop what the
// user is typing).
if (defaultValues.join('|') === appliedDefaultsRef.current.join('|')) return
appliedDefaultsRef.current = defaultValues
setRows((current) => {
const existing = new Map(current.map((row) => [row.value, row]))
const next = defaultValues.map((value) => existing.get(value) ?? { key: nextRowKey(), value })
return next.length > 0 || current.length === 0 ? next : current
})
}, [defaultValues, saveAction])
const updateRow = (key: string, value: string): void => {
setRows((current) => current.map((row) => (row.key === key ? { ...row, value } : row)))
setSaveError(null)
}
const addRow = (): void => {
setRows((current) => [...current, { key: nextRowKey(), value: '' }])
setSaveError(null)
}
const removeRow = (key: string): void => {
setRows((current) =>
current.length > minRows ? current.filter((row) => row.key !== key) : current,
)
setSaveError(null)
}
const moveRow = (index: number, direction: -1 | 1): void => {
setRows((current) => {
const target = index + direction
if (target < 0 || target >= current.length) return current
const next = [...current]
;[next[index], next[target]] = [next[target]!, next[index]!]
return next
})
setSaveError(null)
}
const errorId = 'phones-field-error'
const hasError = saveError !== null || (Array.isArray(error) ? error.length > 0 : Boolean(error))
const save = async (): Promise<void> => {
if (!saveAction || recordId === undefined) return
const formData = new FormData()
if (recordIdField) formData.set(recordIdField, String(recordId))
formData.set('field', 'phones')
for (const row of rowsRef.current) {
formData.append(name, row.value)
}
setIsPending(true)
setSaveError(null)
try {
const result = await saveAction({}, formData)
setIsPending(false)
if (result.status === 'success') {
setIsEditing(false)
router.refresh()
return
}
const message =
result.fieldErrors?.phones?.[0] ??
result.fieldErrors?.[name]?.[0] ??
result.fieldErrors?.form?.[0] ??
result.message
const refusal = message ?? 'Não foi possível salvar.'
setSaveError(refusal)
toast.error(refusal)
} catch {
setIsPending(false)
const message = 'Não foi possível salvar. Tente novamente.'
setSaveError(message)
toast.error(message)
}
}
const rowInputs = rows.map((row, index) => (
<div key={row.key} className="flex items-center gap-1.5">
<FormattedInput
name={name}
inputMode="tel"
autoComplete="tel"
aria-label={`Celular ${index + 1}`}
aria-invalid={hasError}
aria-describedby={hasError ? `${errorId}` : undefined}
value={displayValue(row.value)}
format={formatBrazilianPhoneInput}
sanitize={sanitizeBrazilianPhoneInput}
className={campaignInlineInputClassName}
onChange={(event) => updateRow(row.key, event.currentTarget.value)}
/>
{rows.length > 1 ? (
<>
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 shrink-0"
aria-label="Mover para cima (torna principal antes dos demais)"
disabled={index === 0}
onClick={() => moveRow(index, -1)}
>
<ArrowUpIcon className="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 shrink-0"
aria-label="Mover para baixo"
disabled={index === rows.length - 1}
onClick={() => moveRow(index, 1)}
>
<ArrowDownIcon className="size-4" aria-hidden="true" />
</Button>
</>
) : null}
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 shrink-0"
aria-label={`Remover celular ${index + 1}`}
disabled={rows.length <= minRows}
onClick={() => removeRow(row.key)}
>
<Trash2Icon className="size-4" aria-hidden="true" />
</Button>
</div>
))
const addButton = (
<Button
type="button"
variant="ghost"
className="h-9 w-fit shrink-0 justify-start px-2 text-sm text-muted-foreground"
aria-label="Adicionar telefone"
onClick={addRow}
>
<PlusIcon className="size-4" aria-hidden="true" />
Adicionar telefone
</Button>
)
if (saveAction && !isEditing) {
return (
<div className={className}>
<ul className="flex flex-col gap-1.5" aria-label={label}>
{(rows.length ? rows : [{ key: EMPTY_ROW_KEY, value: '' }]).map((row, index) => (
<li key={row.key} className="flex items-center gap-1.5 text-sm tabular-nums">
<span className="w-6 text-right text-muted-foreground">{index + 1}.</span>
<span className="min-w-40">
{row.value ? formatBrazilianPhoneInput(row.value) : '—'}
</span>
</li>
))}
</ul>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-1 size-11"
aria-label={`Editar ${label.toLowerCase()}`}
onClick={() => {
setIsEditing(true)
setSaveError(null)
}}
>
<span className="sr-only">Editar</span>
<PlusIcon className="size-4" aria-hidden="true" />
</Button>
</div>
)
}
return (
<div className={className}>
<FieldLabel>{label}</FieldLabel>
<div className="flex flex-col gap-2">
{rowInputs}
{addButton}
</div>
{hasError ? (
<FieldError id={errorId}>
{saveError ?? (Array.isArray(error) ? error[0] : error)}
</FieldError>
) : null}
{saveAction ? (
<div className="mt-2 flex items-center gap-2">
<Button type="button" disabled={isPending} onClick={() => void save()}>
{saveLabel}
</Button>
<Button
type="button"
variant="ghost"
disabled={isPending}
onClick={() => {
setIsEditing(false)
setSaveError(null)
}}
>
{cancelLabel}
</Button>
{isPending ? <Spinner className="size-4" aria-label="Salvando telefones" /> : null}
</div>
) : null}
</div>
)
}

View file

@ -2,19 +2,21 @@
import { updateStateDeputyContactFormAction } from '@/app/(campaign)/campanha/(app)/dobradinhas/formActions'
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
type StateDeputyContactSectionProps = {
stateDeputyId: number
name: string
email: string | null
phone: string | null
/** Every number of the ficha, order = priority (C112) — primary first. */
phones: string[]
}
export const StateDeputyContactSection = ({
stateDeputyId,
name,
email,
phone,
phones,
}: StateDeputyContactSectionProps) => (
<section aria-labelledby="state-deputy-contact-title" className="flex flex-col gap-3">
<h2 id="state-deputy-contact-title" className="text-base font-medium">
@ -31,6 +33,7 @@ export const StateDeputyContactSection = ({
value={name}
label="Nome"
formAction={updateStateDeputyContactFormAction}
href={`/campanha/dobradinhas/${stateDeputyId}`}
/>
</dd>
</div>
@ -44,19 +47,21 @@ export const StateDeputyContactSection = ({
value={email}
label="E-mail"
formAction={updateStateDeputyContactFormAction}
readBehavior="copy"
/>
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-sm text-muted-foreground">Telefone</dt>
<dt className="text-sm text-muted-foreground">
Telefones <span className="font-normal">(o primeiro é o principal)</span>
</dt>
<dd>
<CampaignInlineEditableCell
<PhonesFieldEditor
defaultValues={phones}
label="Telefones"
saveAction={updateStateDeputyContactFormAction}
recordId={stateDeputyId}
recordIdField="stateDeputyId"
field="phone"
value={phone}
label="Telefone"
formAction={updateStateDeputyContactFormAction}
/>
</dd>
</div>

View file

@ -5,10 +5,10 @@ import { useActionState, useState } from 'react'
import type { SupporterFormState } from '@/app/(campaign)/campanha/(app)/apoiadores/novo/formActions'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
import { FormattedInput } from '@/components/FormattedInput'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
@ -24,7 +24,6 @@ import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone'
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
import { errorProps as buildErrorProps, fieldError } from '@/utilities/campaignFormFields'
import { supporterVoteIntentionLabels } from '@/utilities/supporter/supporterUi'
@ -118,7 +117,6 @@ export const SupporterForm = ({
})
const nameField = errorProps(state.fieldErrors, 'name')
const phoneField = errorProps(state.fieldErrors, 'phone')
const emailField = errorProps(state.fieldErrors, 'email')
const cityField = errorProps(state.fieldErrors, 'city')
const municipalityField = errorProps(state.fieldErrors, 'municipality')
@ -140,7 +138,6 @@ export const SupporterForm = ({
<Input
id="supporter-name"
name="name"
required
minLength={2}
defaultValue={values?.name}
autoComplete="name"
@ -153,25 +150,12 @@ export const SupporterForm = ({
) : null}
</Field>
<Field data-invalid={phoneField.invalid}>
<FieldLabel htmlFor="supporter-phone">Celular *</FieldLabel>
<FormattedInput
id="supporter-phone"
name="phone"
required
inputMode="tel"
autoComplete="tel"
defaultValue={values?.phone ? formatBrazilianPhoneInput(values.phone) : ''}
format={formatBrazilianPhoneInput}
sanitize={sanitizeBrazilianPhoneInput}
aria-invalid={phoneField.invalid}
aria-describedby={phoneField.describedBy}
className="min-h-11 rounded-[6px]"
/>
{phoneField.error ? (
<FieldError id="supporter-phone-error">{phoneField.error}</FieldError>
) : null}
</Field>
<PhonesFieldEditor
name="phones"
minRows={1}
label="Celulares (o primeiro é o principal)"
error={fieldError(state.fieldErrors, 'phones')}
/>
<Field data-invalid={emailField.invalid}>
<FieldLabel htmlFor="supporter-email">E-mail</FieldLabel>

View file

@ -0,0 +1,21 @@
/**
* Shared description parts for campaign activities — the iCal feed and the
* C114 Google mirror render the same facts (municipality, locality, tags,
* deputy flag) with their own consumers' separators/escapes. No PII: no
* leadership names, phones or emails.
*/
import type { Activity } from '@/payload-types'
export type ActivityDescriptionSource = Pick<Activity, 'locality' | 'tags' | 'deputyPresent'>
export const buildActivityDescriptionParts = (
activity: ActivityDescriptionSource,
municipalityName?: string,
): string[] => {
const parts: string[] = []
if (municipalityName) parts.push(`Município: ${municipalityName}`)
if (activity.locality) parts.push(`Local: ${activity.locality}`)
if (activity.tags?.length) parts.push(`Tags: ${activity.tags.join(', ')}`)
if (activity.deputyPresent) parts.push('Deputado presente')
return parts
}

View file

@ -1,4 +1,12 @@
import { Calendar, ClipboardList, ListChecks, MapPinned, Pencil, Plus } from 'lucide-react'
import {
Calendar,
CalendarCog,
ClipboardList,
ListChecks,
MapPinned,
Pencil,
Plus,
} from 'lucide-react'
import {
CAMPAIGN_WIZARD_ACTION_SLUGS,
@ -42,6 +50,17 @@ const listQuickActions = (context: CampaignQuickActionContext): readonly Campaig
},
]
: []),
...(context.openGoogleCalendarSync
? [
{
id: 'google-calendar-mirror',
label: 'Agenda da Campanha',
icon: CalendarCog,
description: 'Espelhar a agenda oficial no Google Calendar da campanha',
onAction: context.openGoogleCalendarSync,
},
]
: []),
{
id: 'plan-tour',
label: 'Planejar giro',

View file

@ -16,6 +16,8 @@ export type CampaignQuickActionContext = {
* crossed server→client.
*/
openCalendarFeed?: () => void
/** C114 — opens the Google Calendar mirror dialog (same bridge as above). */
openGoogleCalendarSync?: () => void
}
export const emptyCampaignQuickActionContext = (): CampaignQuickActionContext => ({})

View file

@ -176,6 +176,24 @@ export const repeatedFormTexts = (formData: FormData, field: string): string[] =
return values
}
/**
* The ficha's phone list from the repeatable editor: order is preserved (=
* priority, first = primary) and empty values are dropped. Duplicates are NOT
* dropped here — the zod `contactPhonesSchema` refine reports them so the
* form can show the "Telefone repetido" refusal instead of silently merging.
*/
export const repeatedPhoneFormValues = (formData: FormData, field: string): string[] => {
const values: string[] = []
for (const value of formData.getAll(field)) {
if (typeof value !== 'string') {
throw new FormDataBoundaryError(field, 'Arquivos não são aceitos neste campo.')
}
const trimmed = value.trim()
if (trimmed) values.push(trimmed)
}
return values
}
export const checkboxFormValue = (formData: FormData, field: string): boolean => {
const entry = formEntry(formData, field)
if (!entry.present) return false

View file

@ -0,0 +1,189 @@
/**
* C114 — pure mapping between campaign activities and Google Calendar events.
*
* The Google event id is DETERMINISTIC (`teqo` + base32hex of the activity
* id): Google ids only allow base32hex characters (lowercase a–v, digits 0–9)
* — no underscores, no uppercase — and the deterministic id makes the whole
* reconciliation stateless (upsert by id, content-compare to decide updates).
* Teqo stays the single source of truth; canceled activities are excluded by
* the engine (their Google event is deleted), never rendered in Google.
*
* This module is pure and client-safe; only the engine/client in
* `src/utilities/googleCalendarSync.ts` touches the network.
*/
import type { Activity } from '@/payload-types'
import { allDayCivilDateOf, allDayExclusiveEndDate } from '@/lib/activityAllDay'
import { buildActivityDescriptionParts } from '@/lib/activityDescription'
const GOOGLE_EVENT_ID_PREFIX = 'teqo'
/** RFC 2938 base32hex alphabet (0–9, a–v) — the only charset Google event ids accept. */
const BASE32HEX_ALPHABET = '0123456789abcdefghijklmnopqrstuv'
/**
* Brazil standard time is fixed UTC−03:00 (no DST since 2019), so the offset
* is a constant — the same invariant `campaignTime` builds on.
*/
const BAHIA_FIXED_OFFSET_HOURS = -3
const BAHIA_FIXED_OFFSET_SUFFIX = '-03:00'
/**
* Google requires `end` strictly after `start` on timed events; an activity
* without `endAt` (a point-in-time commitment) becomes a 1-hour event — the
* iCal feed mirrors this as DTEND = DTSTART, which Google cannot represent.
*/
const DEFAULT_TIMED_EVENT_DURATION_MINUTES = 60
export const googleEventIdForActivity = (activityId: number): string => {
if (!Number.isInteger(activityId) || activityId < 0) {
throw new Error('ID de atividade inválido para evento do Google.')
}
let remainder = activityId
let encoded = ''
do {
encoded = BASE32HEX_ALPHABET[remainder % 32] + encoded
remainder = Math.floor(remainder / 32)
} while (remainder > 0)
return `${GOOGLE_EVENT_ID_PREFIX}${encoded}`
}
/** Inverse of `googleEventIdForActivity` — null for foreign/invalid ids. */
export const decodeGoogleEventActivityId = (eventId: string): number | null => {
if (!eventId.startsWith(GOOGLE_EVENT_ID_PREFIX)) return null
const encoded = eventId.slice(GOOGLE_EVENT_ID_PREFIX.length)
if (encoded.length === 0) return null
let value = 0
for (const char of encoded) {
const digit = BASE32HEX_ALPHABET.indexOf(char)
if (digit === -1) return null
value = value * 32 + digit
if (!Number.isSafeInteger(value)) return null
}
return value
}
/**
* UTC ISO instant → RFC 3339 dateTime in Bahia wall time with the fixed
* −03:00 offset (what the Calendar API expects; the timeZone field stays
* unnecessary because the offset is explicit).
*/
const formatBahiaOffsetDateTime = (iso: string): string => {
const shifted = new Date(new Date(iso).getTime() + BAHIA_FIXED_OFFSET_HOURS * 3_600_000)
const parts = shifted.toISOString().slice(0, 19).split('T')
const [date, time] = parts
const [year, month, day] = date.split('-')
const [hour, minute, second] = time.split(':')
return `${year}-${month}-${day}T${hour}:${minute}:${second}${BAHIA_FIXED_OFFSET_SUFFIX}`
}
const addMinutes = (iso: string, minutes: number): string =>
new Date(new Date(iso).getTime() + minutes * 60_000).toISOString()
export const activityMunicipalityIdOf = (
municipality: Activity['municipality'],
): number | undefined => (typeof municipality === 'number' ? municipality : municipality?.id)
export type GoogleCalendarEventPayload = {
id: string
summary: string
description: string
location?: string
start: { dateTime: string } | { date: string }
end: { dateTime: string } | { date: string }
}
/**
* Event description without unnecessary PII: municipality, locality, tags and
* the deputy flag — the same parts the iCal feed renders, joined with real
* newlines for Google. No leadership names, no phones, no emails.
*/
export const buildGoogleCalendarDescription = (
activity: Pick<Activity, 'locality' | 'tags' | 'deputyPresent'>,
municipalityName?: string,
): string => buildActivityDescriptionParts(activity, municipalityName).join('\n')
export const buildGoogleEventPayload = (
activity: Pick<
Activity,
'id' | 'title' | 'startAt' | 'endAt' | 'allDay' | 'locality' | 'tags' | 'deputyPresent'
>,
municipalityName?: string,
): GoogleCalendarEventPayload => {
// C14 guarantees startAt beyond draft; a null start has no Google shape —
// fail loudly in the mapping (the engine never feeds one: the window query
// only returns dated activities).
if (!activity.startAt) {
throw new Error('Atividade sem data de início não pode virar evento do Google.')
}
const summary = municipalityName ? `[${municipalityName}] ${activity.title}` : activity.title
const description = buildGoogleCalendarDescription(activity, municipalityName)
const allDay = Boolean(activity.allDay)
const start = allDay
? { date: allDayCivilDateOf(activity.startAt) }
: { dateTime: formatBahiaOffsetDateTime(activity.startAt) }
const end = allDay
? { date: allDayExclusiveEndDate(activity.endAt ?? activity.startAt) }
: {
dateTime: formatBahiaOffsetDateTime(
activity.endAt ?? addMinutes(activity.startAt, DEFAULT_TIMED_EVENT_DURATION_MINUTES),
),
}
const locality = activity.locality?.trim()
const location = locality || municipalityName || undefined
return {
id: googleEventIdForActivity(activity.id),
summary,
description,
...(location ? { location } : {}),
start,
end,
}
}
/** The remote shape the Calendar API echoes on list/insert — a narrow view of it. */
export type GoogleRemoteEvent = {
id?: string
summary?: string
description?: string
location?: string
start?: { dateTime?: string; date?: string; timeZone?: string }
end?: { dateTime?: string; date?: string; timeZone?: string }
}
const startEndInstantEquals = (
remote: { dateTime?: string; date?: string } | undefined,
wanted: GoogleCalendarEventPayload['start'],
): boolean => {
if (!remote) return false
if ('dateTime' in wanted) {
// Compare absolute instants: the calendar's own timezone may echo the
// dateTime with a different offset — the instant is what we control.
const remoteMs = Date.parse(remote.dateTime ?? '')
const wantedMs = Date.parse(wanted.dateTime)
return !Number.isNaN(remoteMs) && !Number.isNaN(wantedMs) && remoteMs === wantedMs
}
return remote.date === wanted.date
}
/**
* True when the remote event already carries exactly what we would write —
* the decision rule that makes repeated reconciliation passes converge
* without touching Google (no update churn, no modified-date spam).
*/
export const googleEventContentEquals = (
remote: GoogleRemoteEvent,
payload: GoogleCalendarEventPayload,
): boolean =>
(remote.summary ?? '') === payload.summary &&
(remote.description ?? '') === payload.description &&
(remote.location ?? '') === (payload.location ?? '') &&
startEndInstantEquals(remote.start, payload.start) &&
startEndInstantEquals(remote.end, payload.end)

View file

@ -0,0 +1,28 @@
/**
* 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:
*
* - Google Calendar: the `cid` webcal URL (the established add-by-URL flow the
* iCal feed dialog already uses, `calendar/r/settings/addbyurl`).
* - Apple Calendar / Outlook: subscribe straight to the webcal URL the Google
* calendar exposes for public calendars.
*
* Both require the calendar to be public in the owner's sharing settings —
* the link IS the credential, same model as the iCal feed secret.
*/
const GOOGLE_CALENDAR_ADD_BASE = 'https://calendar.google.com/calendar/r'
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)}`
}
/** The public iCal URL of the shared calendar — same `cid` target, plain HTTPS. */
export const buildGoogleCalendarPublicIcalUrl = (calendarId: string): string =>
`${GOOGLE_CALENDAR_ICAL_BASE}/${encodeURIComponent(calendarId)}/public/basic.ics`

View file

@ -4,6 +4,9 @@ const BRAZILIAN_MOBILE_PHONE_LENGTH = 11
/** One refusal copy for the URL builder, the zod primitive and the Contact hook — rewording in one layer only would give the same refusal two phrasings. */
export const BRAZILIAN_PHONE_INVALID_MESSAGE = 'Celular brasileiro inválido.'
/** A ficha may not hold the same number twice (the person already has it); sharing a number BETWEEN fichas stays allowed (C111). */
export const BRAZILIAN_PHONE_DUPLICATE_MESSAGE = 'Telefone repetido na ficha.'
export const sanitizeBrazilianPhoneInput = (value: string): string => {
const digits = value.replace(/\D/g, '')
const domesticDigits =
@ -61,3 +64,35 @@ export const whatsAppHrefForPhone = (phone: string | null): string | null => {
if (!phone || !normalizeBrazilianPhone(phone)) return null
return buildWhatsAppUrl(phone)
}
/**
* The person's primary phone from the `Contact.phones` array: order is
* priority (first = primary), so the row/list/WhatsApp/convite readers all go
* through this single translation and never touch the array shape.
*/
export const primaryPhoneOf = (
phones: ReadonlyArray<{ value?: string | null }> | null | undefined,
): string | null => phones?.[0]?.value || null
/** Every number of the ficha in priority order, empties dropped. */
export const phoneValuesOf = (
phones: ReadonlyArray<{ value?: string | null }> | null | undefined,
): string[] =>
(phones ?? []).map((entry) => entry.value).filter((value): value is string => Boolean(value))
/**
* Edit the primary phone preserving the rest: the next primary goes first
* (any earlier occurrence removed — the number the person already had moves,
* never duplicates), and an empty `primary` removes the current first number
* so the rest shifts up.
*/
export const reorderWithPrimaryPhone = (
phones: ReadonlyArray<{ value?: string | null }> | null | undefined,
primary: string | null,
): string[] => {
const current = (phones ?? [])
.map((entry) => entry.value)
.filter((value): value is string => Boolean(value))
const rest = current.filter((phone) => phone !== primary)
return primary ? [primary, ...rest] : current.slice(1)
}

View file

@ -1,4 +1,5 @@
import { CitiesByState } from '@/lib/cities'
import { BRAZILIAN_PHONE_DUPLICATE_MESSAGE } from '@/lib/phone'
import { z } from 'zod'
import {
@ -44,6 +45,20 @@ const nullableContactPhone = contactPhoneInput.transform((value) =>
value === '' || value === undefined ? null : value,
)
/**
* The ficha's phone list: valid mobiles, never the same number twice within
* the SAME person (C112 — the mesa blocks it at the form); sharing a number
* BETWEEN persons stays free (C111). Order = priority (first = primary).
*/
export const contactPhonesSchema = z.array(brazilianMobile).superRefine((phones, context) => {
if (new Set(phones).size !== phones.length) {
context.addIssue({
code: 'custom',
message: BRAZILIAN_PHONE_DUPLICATE_MESSAGE,
})
}
})
/** Per-field Contact write shared by campaign person joins. */
export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
z.object({
@ -61,6 +76,11 @@ export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
field: z.literal('phone'),
phone: nullableContactPhone,
}),
z.object({
id: positiveRelationshipId,
field: z.literal('phones'),
phones: contactPhonesSchema,
}),
])
export type ContactFieldUpdateInput = z.input<typeof contactFieldUpdateSchema>

View file

@ -1,8 +1,7 @@
import { z } from 'zod'
import { contactFieldUpdateSchema } from '@/lib/schemas/contact'
import { contactFieldUpdateSchema, contactPhonesSchema } from '@/lib/schemas/contact'
import {
brazilianMobile,
optionalPersistedEmail,
positiveRelationshipId,
trimmedNullableText,
@ -91,7 +90,7 @@ export const leadershipCreateSchema = z.object({
organizations: organizationsArraySchema.optional(),
stateDeputies: stateDeputiesArraySchema.optional(),
name: z.string().trim().min(2).max(120),
phone: brazilianMobile,
phones: contactPhonesSchema.min(1, 'Informe ao menos um celular.'),
email: optionalPersistedEmail,
gender: z.enum(leadershipGenders).optional(),
exclusive: z.boolean().default(true),
@ -113,7 +112,7 @@ export type LeadershipInternalUpdateInput = z.input<typeof leadershipInternalUpd
const leadershipWizardFieldsSchema = z.object({
name: z.string().trim().min(2).max(120),
phone: brazilianMobile,
phones: contactPhonesSchema.min(1, 'Informe ao menos um celular.'),
email: optionalPersistedEmail,
exclusive: z.boolean().default(true),
supportStatus: z.enum(leadershipSupportStatuses).default('a_abordar'),

View file

@ -1,8 +1,8 @@
import { z } from 'zod'
import { resolveMunicipalityName } from '@/lib/municipalityNameAliases'
import { contactPhonesSchema } from '@/lib/schemas/contact'
import {
brazilianMobile,
optionalPersistedEmail,
positiveRelationshipId,
trimmedNullableText,
@ -70,7 +70,7 @@ const optionalBahiaCity = z
export const supporterCreateSchema = z
.object({
name: z.string().trim().min(2).max(120),
phone: brazilianMobile,
phones: contactPhonesSchema.min(1, 'Informe ao menos um celular.'),
email: optionalPersistedEmail,
city: optionalBahiaCity,
municipality: positiveRelationshipId.optional(),
@ -108,7 +108,7 @@ export const supporterImportConfirmSchema = z.object({
export const leaderSupporterCreateSchema = z.object({
name: z.string().trim().min(2).max(120),
phone: brazilianMobile,
phones: contactPhonesSchema.min(1, 'Informe ao menos um celular.'),
city: optionalBahiaCity,
municipality: positiveRelationshipId,
consentAccepted: z.literal(true, {

View file

@ -4,6 +4,8 @@ import type { SupportStatus } from '@/lib/schemas/leadership'
export type WizardLeadershipTileViewModel = {
id: number
name: string
/** Every number of the ficha, order = priority (C112). */
phones: string[]
phone: string | null
email: string | null
supportStatus: SupportStatus | null

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
// Hand-edited on top of the generated statements: the generator re-emitted
// `campaign_user.contact_id` / `leadership_rels.campaign_user_id` because the
// 20260809_204728 snapshot (.json) does not reflect its hand-written SQL —
// those columns already exist in every environment (C99/C100). Kept here: the
// `contact_phones` join table (order = priority, index on `value`), a backfill
// of the existing single `phone` into `phones[0]` BEFORE the column drops, and
// the drop of `contact.phone` (+ its index). Production holds real PII: the
// backfill is lossless, the drop happens only after it.
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE "contact_phones" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"value" varchar
);
ALTER TABLE "contact_phones" ADD CONSTRAINT "contact_phones_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."contact"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "contact_phones_order_idx" ON "contact_phones" USING btree ("_order");
CREATE INDEX "contact_phones_parent_id_idx" ON "contact_phones" USING btree ("_parent_id");
CREATE INDEX "contact_phones_value_idx" ON "contact_phones" USING btree ("value");
INSERT INTO "contact_phones" ("id", "_parent_id", "_order", "value")
SELECT gen_random_uuid()::text, "id", 0, "phone" FROM "contact" WHERE "phone" IS NOT NULL;
DROP INDEX "contact_phone_idx";
ALTER TABLE "contact" DROP COLUMN "phone";`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
// Rollback is lossy BY DESIGN: only the primary (`_order` 0) is restored to
// `contact.phone` — every appended number is discarded. Rollbacks are
// best-effort here (C111-era policy); the forward path is lossless.
await db.execute(sql`
ALTER TABLE "contact" ADD COLUMN "phone" varchar;
UPDATE "contact" SET "phone" = "contact_phones"."value"
FROM "contact_phones" WHERE "contact_phones"."_parent_id" = "contact"."id" AND "contact_phones"."_order" = 0;
CREATE INDEX "contact_phone_idx" ON "contact" USING btree ("phone");
ALTER TABLE "contact_phones" DISABLE ROW LEVEL SECURITY;
DROP TABLE "contact_phones" CASCADE;`)
}

View file

@ -0,0 +1,43 @@
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
/**
* C114 — `googleCalendarSync` collection: the configuration/state row of the
* campaign→Google calendar mirror. Hand-written because `migrate:create`
* re-emitted pre-existing `campaign_user.contact_id` /
* `leadership_rels.campaign_user_id` statements (already applied by
* `20260809_204728_add_campaign_user_contact_and_leadership_advisors` — those
* would fail with "column already exists" on apply; kept out deliberately).
*/
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE "google_calendar_sync" (
"id" serial PRIMARY KEY NOT NULL,
"calendar_id" varchar,
"disabled_at" timestamp(3) with time zone,
"last_synced_at" timestamp(3) with time zone,
"last_success_at" timestamp(3) with time zone,
"last_error_at" timestamp(3) with time zone,
"last_error" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE INDEX "google_calendar_sync_calendar_id_idx" ON "google_calendar_sync" USING btree ("calendar_id");
CREATE INDEX "google_calendar_sync_disabled_at_idx" ON "google_calendar_sync" USING btree ("disabled_at");
CREATE INDEX "google_calendar_sync_updated_at_idx" ON "google_calendar_sync" USING btree ("updated_at");
CREATE INDEX "google_calendar_sync_created_at_idx" ON "google_calendar_sync" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "google_calendar_sync_id" integer;
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_google_calendar_sync_fk" FOREIGN KEY ("google_calendar_sync_id") REFERENCES "public"."google_calendar_sync"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_google_calendar_sync_id_idx" ON "payload_locked_documents_rels" USING btree ("google_calendar_sync_id");
`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_google_calendar_sync_fk";
DROP INDEX "payload_locked_documents_rels_google_calendar_sync_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "google_calendar_sync_id";
DROP TABLE "google_calendar_sync" CASCADE;
`)
}

View file

@ -47,6 +47,8 @@ import * as migration_20260808_134911_add_calendar_feed from './20260808_134911_
import * as migration_20260808_184113_remodel_activity_responsible from './20260808_184113_remodel_activity_responsible';
import * as migration_20260809_204728_add_campaign_user_contact_and_leadership_advisors from './20260809_204728_add_campaign_user_contact_and_leadership_advisors';
import * as migration_20260810_010844_add_activity_all_day from './20260810_010844_add_activity_all_day';
import * as migration_20260811_111828_add_contact_phones_array from './20260811_111828_add_contact_phones_array';
import * as migration_20260811_133332_add_google_calendar_sync from './20260811_133332_add_google_calendar_sync';
export const migrations = [
{
@ -294,4 +296,14 @@ export const migrations = [
down: migration_20260810_010844_add_activity_all_day.down,
name: '20260810_010844_add_activity_all_day',
},
{
up: migration_20260811_111828_add_contact_phones_array.up,
down: migration_20260811_111828_add_contact_phones_array.down,
name: '20260811_111828_add_contact_phones_array',
},
{
up: migration_20260811_133332_add_google_calendar_sync.up,
down: migration_20260811_133332_add_google_calendar_sync.down,
name: '20260811_133332_add_google_calendar_sync',
},
];

View file

@ -87,6 +87,7 @@ export interface Config {
municipalityUpdate: MunicipalityUpdate;
activity: Activity;
calendarFeed: CalendarFeed;
googleCalendarSync: GoogleCalendarSync;
electionTally: ElectionTally;
electionCandidateVote: ElectionCandidateVote;
electionCandidate: ElectionCandidate;
@ -127,6 +128,7 @@ export interface Config {
municipalityUpdate: MunicipalityUpdateSelect<false> | MunicipalityUpdateSelect<true>;
activity: ActivitySelect<false> | ActivitySelect<true>;
calendarFeed: CalendarFeedSelect<false> | CalendarFeedSelect<true>;
googleCalendarSync: GoogleCalendarSyncSelect<false> | GoogleCalendarSyncSelect<true>;
electionTally: ElectionTallySelect<false> | ElectionTallySelect<true>;
electionCandidateVote: ElectionCandidateVoteSelect<false> | ElectionCandidateVoteSelect<true>;
electionCandidate: ElectionCandidateSelect<false> | ElectionCandidateSelect<true>;
@ -298,7 +300,12 @@ export interface Contact {
id: number;
name: string;
email?: string | null;
phone?: string | null;
phones?:
| {
value?: string | null;
id?: string | null;
}[]
| null;
gender?: ('feminino' | 'masculino' | 'outro' | 'nao_informado') | null;
state:
| 'AC'
@ -885,6 +892,27 @@ export interface CalendarFeed {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "googleCalendarSync".
*/
export interface GoogleCalendarSync {
id: number;
/**
* O calendarId do calendário "Agenda da Campanha" na conta Google da campanha (ex.: c_…@group.calendar.google.com). O calendário precisa estar público no link e a service account com permissão de edição.
*/
calendarId?: string | null;
/**
* Enquanto preenchido, o Teqo não escreve no Google.
*/
disabledAt?: string | null;
lastSyncedAt?: string | null;
lastSuccessAt?: string | null;
lastErrorAt?: string | null;
lastError?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* Totais oficiais TSE por município e zona (dado público).
*
@ -1354,6 +1382,10 @@ export interface PayloadLockedDocument {
relationTo: 'calendarFeed';
value: number | CalendarFeed;
} | null)
| ({
relationTo: 'googleCalendarSync';
value: number | GoogleCalendarSync;
} | null)
| ({
relationTo: 'electionTally';
value: number | ElectionTally;
@ -1853,6 +1885,20 @@ export interface CalendarFeedSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "googleCalendarSync_select".
*/
export interface GoogleCalendarSyncSelect<T extends boolean = true> {
calendarId?: T;
disabledAt?: T;
lastSyncedAt?: T;
lastSuccessAt?: T;
lastErrorAt?: T;
lastError?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "electionTally_select".
@ -1977,7 +2023,12 @@ export interface PetitionSelect<T extends boolean = true> {
export interface ContactSelect<T extends boolean = true> {
name?: T;
email?: T;
phone?: T;
phones?:
| T
| {
value?: T;
id?: T;
};
gender?: T;
state?: T;
city?: T;
@ -2413,6 +2464,7 @@ export interface TaskCreateCollectionExport {
| 'municipalityUpdate'
| 'activity'
| 'calendarFeed'
| 'googleCalendarSync'
| 'electionTally'
| 'electionCandidateVote'
| 'electionCandidate'

View file

@ -22,6 +22,7 @@ import { Contact } from './collections/Contact'
import { ElectionCandidate } from './collections/ElectionCandidate'
import { ElectionCandidateVote } from './collections/ElectionCandidateVote'
import { ElectionTally } from './collections/ElectionTally'
import { GoogleCalendarSync } from './collections/GoogleCalendarSync'
import { Leadership } from './collections/Leadership'
import { Media } from './collections/Media'
import { Municipality } from './collections/Municipality'
@ -107,6 +108,7 @@ export default buildConfig({
MunicipalityUpdate,
Activity,
CalendarFeed,
GoogleCalendarSync,
ElectionTally,
ElectionCandidateVote,
ElectionCandidate,

View file

@ -0,0 +1,46 @@
import type { Access, FieldAccess } from 'payload'
import {
getFreshCampaignUser,
isCampaignStaff,
isPayloadAdmin,
payloadAdminOnly,
} from '@/utilities/access/shared'
/**
* C114 — access for the `googleCalendarSync` configuration/state collection.
* Staff (coordinator/advisor/candidate) read the sync state (pill) and toggle
* `disabledAt`; only Payload admin sets the operational `calendarId`; the
* system state fields are written solely by the sync engine through an
* admin bypass — never by a user path.
*/
export const canReadGoogleCalendarSync: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignStaff(await getFreshCampaignUser(req))
}
export const canCreateGoogleCalendarSync: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignStaff(await getFreshCampaignUser(req))
}
export const canUpdateGoogleCalendarSync: Access = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignStaff(await getFreshCampaignUser(req))
}
export const canDeleteGoogleCalendarSync: Access = payloadAdminOnly
/** `calendarId` — operational identity, admin-only (env-level trust). */
export const canSetGoogleCalendarSyncConfigField: FieldAccess = ({ req }) =>
isPayloadAdmin(req.user)
/** `disabledAt` — staff may pause/resume the sync from the agenda. */
export const canSetGoogleCalendarSyncDisabled: FieldAccess = async ({ req }) => {
if (isPayloadAdmin(req.user)) return true
return isCampaignStaff(await getFreshCampaignUser(req))
}
/** System state fields — only the sync engine writes them (overrideAccess). */
export const canSetGoogleCalendarSyncSystemField: FieldAccess = () => false

View file

@ -42,7 +42,7 @@ export const searchActivityLeadershipOptions = async (
{
or: [
{ 'contact.name': { contains: trimmed } },
{ 'contact.phone': { contains: digits || trimmed } },
{ 'contact.phones.value': { contains: digits || trimmed } },
],
},
],

View file

@ -96,7 +96,7 @@ const searchStateDeputyResponsibles = async (
where: {
or: [
{ 'contact.name': { contains: trimmed } },
{ 'contact.phone': { contains: digits || trimmed } },
{ 'contact.phones.value': { contains: digits || trimmed } },
],
},
depth: 1,

View file

@ -1,5 +1,6 @@
import { formatAllDayRangeLabel } from '@/lib/activityAllDay'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import { primaryPhoneOf } from '@/lib/phone'
import { isPopulatedRelationship, populatedContactName, relationshipId } from '@/lib/relationship'
import {
activityResponsibleTypeLabels,
@ -287,7 +288,7 @@ export const toActivityFormViewModel = (activity: Activity): ActivityFormViewMod
? {
id: task.responsible.id,
name: task.responsible.name,
phone: task.responsible.phone ?? null,
phone: primaryPhoneOf(task.responsible.phones),
}
: null,
due: task.due ?? null,

View file

@ -2,6 +2,9 @@ import 'server-only'
import type { Payload } from 'payload'
import type { Contact } from '@/payload-types'
import { phoneValuesOf } from '@/lib/phone'
import { relationshipId } from '@/lib/relationship'
import {
advisorListHrefForPage,
@ -35,6 +38,10 @@ export type AdvisorRowViewModel = AdvisorAccountViewModel & {
/** The detail page renders real names and links, so it keeps the labels. */
export type AdvisorDetailViewModel = AdvisorAccountViewModel & {
/** The advisor's normalized ficha (C99) — target of the ficha phones editor. */
contactID: number | null
/** Every number of the ficha, order = priority (C112) — primary first. */
fichaPhones: string[]
municipalities: AdvisorMunicipalityViewModel[]
}
@ -222,6 +229,7 @@ export const loadAdvisorDetail = async (
email?: string | null
phone?: string | null
role: string
contact?: number | Contact | null
}
try {
// Intentional admin bypass — same rationale as `loadAdvisorListPageData`.
@ -229,7 +237,7 @@ export const loadAdvisorDetail = async (
collection: 'campaignUser',
id: advisorId,
depth: 0,
select: { name: true, email: true, phone: true, role: true },
select: { name: true, email: true, phone: true, role: true, contact: true },
overrideAccess: true,
})
} catch {
@ -238,12 +246,30 @@ export const loadAdvisorDetail = async (
if (advisor.role !== 'advisor') return null
const contactID = typeof advisor.contact === 'number' ? advisor.contact : null
const fichaPhones =
contactID === null
? []
: await payload
.findByID({
collection: 'contact',
id: contactID,
depth: 0,
select: { phones: { value: true } },
// Intentional admin bypass: same staff-scoped read rationale.
overrideAccess: true,
})
.then((contact) => phoneValuesOf(contact.phones))
.catch(() => [])
const municipalitiesByAdvisor = await municipalitiesByAdvisorIds(payload, [advisor.id])
return {
id: advisor.id,
name: advisor.name,
email: advisor.email ?? null,
phone: advisor.phone ?? null,
contactID,
fichaPhones,
municipalities: municipalitiesByAdvisor.get(advisor.id) ?? [],
}
}

View file

@ -3,6 +3,7 @@ import 'server-only'
import { createHash } from 'node:crypto'
import { allDayCivilDateOf, allDayExclusiveEndDate } from '@/lib/activityAllDay'
import { buildActivityDescriptionParts } from '@/lib/activityDescription'
import type { Activity, CalendarFeed } from '@/payload-types'
import { advisorMunicipalityScopeWhere } from '@/utilities/access/shared'
import type { Payload, Where } from 'payload'
@ -24,15 +25,9 @@ const escapeICalText = (value: string): string =>
const formatICalDate = (isoString: string): string =>
isoString.replace(/[-:]/g, '').replace(/\.\d{3}/, '')
const buildActivityDescription = (activity: Activity, municipalityName?: string): string => {
const parts: string[] = []
if (municipalityName) parts.push(`Município: ${municipalityName}`)
if (activity.locality) parts.push(`Local: ${activity.locality}`)
if (activity.tags?.length) parts.push(`Tags: ${activity.tags.join(', ')}`)
if (activity.deputyPresent) parts.push('Deputado presente')
if (activity.status === 'cancelado') parts.push('CANCELADO')
return parts.join('\\n')
}
const buildActivityDescription = (activity: Activity, municipalityName?: string): string =>
// iCal escapes newlines as literal `\n` (backslash + n).
buildActivityDescriptionParts(activity, municipalityName).join('\\n')
export const generateICalFeed = (
activities: Activity[],
@ -144,7 +139,8 @@ export const buildICalFeedResponse = (
})
}
const buildFeedDateRange = (): { rangeStart: string; rangeEnd: string } => {
/** The shared activity window (past lookback + future lookahead) of the feed and the Google mirror. */
export const buildActivityWindowRange = (): { rangeStart: string; rangeEnd: string } => {
const now = new Date()
const rangeStart = new Date(now)
rangeStart.setDate(rangeStart.getDate() - FEED_LOOKBACK_DAYS)
@ -157,24 +153,32 @@ const buildFeedDateRange = (): { rangeStart: string; rangeEnd: string } => {
}
}
/**
* The activity window (past lookback + future lookahead) and its base where
* clauses — shared by the iCal feed and the C114 Google mirror so both
* surfaces keep the SAME horizon by definition. The feed adds its per-feed
* filters (municipality/deputy/tag + advisor scope) on top.
*/
export const buildActivityWindowWhereClauses = (rangeStart: string, rangeEnd: string): Where[] => [
{ startAt: { less_than: rangeEnd } },
{
or: [
{ endAt: { greater_than: rangeStart } },
{
and: [{ endAt: { exists: false } }, { startAt: { greater_than_equal: rangeStart } }],
},
],
},
{ status: { not_equals: 'cancelado' } },
]
const buildFeedWhere = (
feed: CalendarFeed,
rangeStart: string,
rangeEnd: string,
accessibleMunicipalityIds: number[] | null,
): Where => {
const filters: Where[] = [
{ startAt: { less_than: rangeEnd } },
{
or: [
{ endAt: { greater_than: rangeStart } },
{
and: [{ endAt: { exists: false } }, { startAt: { greater_than_equal: rangeStart } }],
},
],
},
{ status: { not_equals: 'cancelado' } },
]
const filters: Where[] = buildActivityWindowWhereClauses(rangeStart, rangeEnd)
if (feed.filterMunicipality) {
const municipalityId =
@ -208,7 +212,7 @@ export const loadFeedActivities = async (
feed: CalendarFeed,
accessibleMunicipalityIds: number[] | null,
): Promise<Activity[]> => {
const { rangeStart, rangeEnd } = buildFeedDateRange()
const { rangeStart, rangeEnd } = buildActivityWindowRange()
const where = buildFeedWhere(feed, rangeStart, rangeEnd, accessibleMunicipalityIds)
// Intentional admin bypass: access is already enforced at the feed level

View file

@ -3,6 +3,7 @@ import 'server-only'
import type { Payload } from 'payload'
import { CREATE_CAMPAIGN_INVITE_MISSING_CONSENT_MESSAGE } from '@/lib/campaignInviteClient'
import { primaryPhoneOf } from '@/lib/phone'
import { requireRelationshipId } from '@/lib/relationship'
import { campaignInviteCreateSchema, type CampaignInviteCreateInput } from '@/lib/schemas/invite'
import type { CampaignUser } from '@/payload-types'
@ -78,14 +79,15 @@ export const createCampaignInviteForActor = async (
overrideAccess: true,
req,
})
if (!contact.phone) {
const primaryPhone = primaryPhoneOf(contact.phones)
if (!primaryPhone) {
throw new Error('Cadastre o celular da liderança antes de gerar o convite.')
}
const inviteUrl = `${inviteBaseURL}/campanha/convite/${generated.token}`
return {
inviteUrl,
whatsappUrl: buildCampaignInviteWhatsAppLink({
phone: contact.phone,
phone: primaryPhone,
recipientName: contact.name,
senderName: currentActor.name,
inviteUrl,

View file

@ -2,6 +2,7 @@ import 'server-only'
import type { Payload } from 'payload'
import { primaryPhoneOf } from '@/lib/phone'
import { relationshipId } from '@/lib/relationship'
import type { Consent } from '@/payload-types'
import { getLeadershipConsent } from '@/utilities/campaignConsent'
@ -73,7 +74,7 @@ export const getCampaignInvitePageData = async (
depth: 0,
select: {
name: true,
phone: true,
phones: { value: true },
email: true,
gender: true,
},
@ -89,7 +90,7 @@ export const getCampaignInvitePageData = async (
kind: invite.kind,
profile: {
name: contact.name,
phone: contact.phone ?? '',
phone: primaryPhoneOf(contact.phones) ?? '',
email: contact.email ?? null,
gender: contact.gender ?? null,
},

View file

@ -2,6 +2,7 @@ import 'server-only'
import type { Payload } from 'payload'
import { primaryPhoneOf, reorderWithPrimaryPhone } from '@/lib/phone'
import { relationshipId, requireRelationshipId } from '@/lib/relationship'
import {
campaignInviteAutofillSchema,
@ -9,7 +10,7 @@ import {
type CampaignInviteAutofillInput,
type CampaignInviteLoginInput,
} from '@/lib/schemas/invite'
import type { CampaignUser } from '@/payload-types'
import type { CampaignUser, Contact } from '@/payload-types'
import { requireLeadershipConsent, resolveInviteConsent } from '@/utilities/campaignConsent'
import {
acquireCampaignInviteAccountLocks,
@ -32,9 +33,12 @@ type ProfileInput = {
gender?: 'feminino' | 'masculino' | 'outro' | 'nao_informado' | null
}
const profileContactData = (data: ProfileInput) => ({
const profileContactData = (data: ProfileInput, originalPhones: Contact['phones']) => ({
name: data.name,
phone: data.phone,
// The typed phone becomes the PRIMARY of the anchored ficha; every number
// the ficha already had is kept (an earlier occurrence of the typed phone
// moves, never duplicates) — the person's other numbers survive the invite.
phones: reorderWithPrimaryPhone(originalPhones, data.phone).map((value) => ({ value })),
...(data.email !== undefined ? { email: data.email } : {}),
...(data.gender !== undefined ? { gender: data.gender } : {}),
})
@ -123,7 +127,9 @@ export const redeemCampaignInviteAutofillRecord = async (
payload,
req,
contactPhoneLockKeys(
[originalContact.phone, data.phone].filter((phone): phone is string => Boolean(phone)),
[primaryPhoneOf(originalContact.phones), data.phone].filter((phone): phone is string =>
Boolean(phone),
),
),
)
// C111 — the phone is a contact channel, not a unique person identity:
@ -132,7 +138,7 @@ export const redeemCampaignInviteAutofillRecord = async (
await payload.update({
collection: 'contact',
id: contactID,
data: profileContactData(data),
data: profileContactData(data, originalContact.phones),
depth: 0,
overrideAccess: true,
req,
@ -194,7 +200,9 @@ export const redeemCampaignInviteLoginRecord = async (
let account = await findReusableLeadershipAccount(payload, req, leadership)
await acquireCampaignInviteAccountLocks(payload, req, [
...contactPhoneLockKeys(
[originalContact.phone, data.phone].filter((phone): phone is string => Boolean(phone)),
[primaryPhoneOf(originalContact.phones), data.phone].filter((phone): phone is string =>
Boolean(phone),
),
),
...(account?.username ? [`account-username:${account.username}`] : []),
`account-username:${data.phone}`,
@ -224,7 +232,7 @@ export const redeemCampaignInviteLoginRecord = async (
await payload.update({
collection: 'contact',
id: originalContact.id,
data: profileContactData(data),
data: profileContactData(data, originalContact.phones),
depth: 0,
overrideAccess: true,
req,

View file

@ -10,16 +10,19 @@ type ContactIdentityRequest = PayloadTransactionRequest | PayloadRequest
/**
* Find-or-create the normalized `Contact` ficha for a person, inside the
* caller's transaction. With a phone, the advisory lock is acquired HERE and
* an existing ficha with that phone is reused — but only when the phone is
* unambiguous (exactly one ficha): that is the phone-matching dedupe C6/C99
* keeps for re-imports and re-registration of the SAME person. When the phone
* already belongs to TWO OR MORE fichas there is no person the phone can
* identify — C111 — so a fresh ficha is created (the identity of the person
* being created is the ficha just written; never a guess among the existing
* ones). Without a phone, a fresh name-only ficha is created. Every "cria ou
* vincula a ficha" path shares one owner for the "BA default + shared-phone"
* policy.
* caller's transaction. With phones, the advisory locks are acquired HERE for
* every typed number and an existing ficha that already carries the PRIMARY
* (first) phone is reused — but only when that phone is unambiguous (exactly
* one ficha): that is the phone-matching dedupe C6/C99 keeps for re-imports
* and re-registration of the SAME person. When the primary phone already
* belongs to TWO OR MORE fichas there is no person the phone can identify —
* C111 — so a fresh ficha is created (the identity of the person being
* created is the ficha just written; never a guess among the existing ones).
* The remaining typed numbers follow the ficha wherever it lands: they fill
* the fresh ficha, or are appended (only the ones it lacks, at the END — a
* re-registration never reorders the mesa's priority). Without phones, a
* fresh name-only ficha is created. Every "cria ou vincula a ficha" path
* shares one owner for the "BA default + shared-phone" policy.
*
* Callers must already be inside an active Payload transaction (the advisory
* lock requires it) and authorized to own the write (staff creation flows run
@ -29,7 +32,7 @@ type ContactIdentityRequest = PayloadTransactionRequest | PayloadRequest
export const findOrCreateContactByPhone = async ({
payload,
req,
phone,
phones,
name,
email,
city,
@ -37,18 +40,22 @@ export const findOrCreateContactByPhone = async ({
}: {
payload: Payload
req: ContactIdentityRequest
phone: string | null
phones: string[]
name: string
email?: string | null
city?: string | null
gender?: Contact['gender'] | null
}): Promise<{ contactID: number; reused: boolean }> => {
if (phone) {
await acquireContactPhoneLocks(payload, req, [phone])
const primary = phones[0] ?? null
if (primary) {
await acquireContactPhoneLocks(payload, req, phones)
const contacts = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
// Matches ANY of the ficha's phones — the primary typed here is the
// dedupe key, but it may sit at any position of the existing ficha.
where: { 'phones.value': { equals: primary } },
depth: 0,
limit: 2,
pagination: false,
@ -59,7 +66,24 @@ export const findOrCreateContactByPhone = async ({
})
if (contacts.totalDocs === 1) {
return { contactID: contacts.docs[0]!.id, reused: true }
const existing = contacts.docs[0]!
const existingPhones = (existing.phones ?? [])
.map((entry) => entry.value)
.filter((value): value is string => Boolean(value))
const missing = phones.filter((phone) => !existingPhones.includes(phone))
if (missing.length > 0) {
await payload.update({
collection: 'contact',
id: existing.id,
data: {
phones: [...existingPhones, ...missing].map((value) => ({ value })),
},
depth: 0,
overrideAccess: true,
req,
})
}
return { contactID: existing.id, reused: true }
}
}
@ -67,7 +91,7 @@ export const findOrCreateContactByPhone = async ({
collection: 'contact',
data: {
name,
phone,
phones: phones.map((value) => ({ value })),
email: email ?? null,
state: 'BA' as Contact['state'],
city: city ?? null,

View file

@ -0,0 +1,206 @@
import 'server-only'
import { importPKCS8, SignJWT } from 'jose'
import type { GoogleRemoteEvent } from '@/lib/googleCalendarEventMapping'
/**
* C114 — thin Calendar API v3 client authenticated as the campaign's service
* account (JWT assertion → OAuth2 token → REST). Only the four event endpoints
* the reconciliation engine needs; the token is cached per runtime instance
* and re-minted on 401. The `fetch` implementation is injectable so tests can
* stub the transport without network.
*/
export const GOOGLE_CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar'
const GOOGLE_TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'
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
/**
* 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
* availability cannot depend on Google's.
*/
const REQUEST_TIMEOUT_MS = 15_000
export type { GoogleRemoteEvent } from '@/lib/googleCalendarEventMapping'
export type GoogleCalendarCredentials = {
clientEmail: string
privateKey: string
}
/** Typed transport failure — safe message, never echoes credentials or bodies. */
export class GoogleCalendarApiError extends Error {
readonly status: number
constructor(message: string, status: number) {
super(message)
this.name = 'GoogleCalendarApiError'
this.status = status
}
}
export type GoogleCalendarClient = {
listEvents: (
calendarId: string,
range: { timeMin: string; timeMax: string },
) => Promise<GoogleRemoteEvent[]>
insertEvent: (calendarId: string, event: GoogleRemoteEvent) => Promise<void>
updateEvent: (calendarId: string, eventId: string, event: GoogleRemoteEvent) => Promise<void>
deleteEvent: (calendarId: string, eventId: string) => Promise<void>
}
export type FetchLike = typeof fetch
/**
* The signed JWT assertion for the OAuth2 token exchange (RFC 7523). Exported
* for tests — `createGoogleCalendarClient` mints and exchanges it internally.
*/
export const buildServiceAccountAssertion = async (
credentials: GoogleCalendarCredentials,
nowSeconds: number,
): Promise<string> => {
const key = await importPKCS8(credentials.privateKey, 'RS256')
return new SignJWT({ scope: GOOGLE_CALENDAR_SCOPE })
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
.setIssuer(credentials.clientEmail)
.setAudience(GOOGLE_TOKEN_ENDPOINT)
.setIssuedAt(nowSeconds)
.setExpirationTime(nowSeconds + GOOGLE_TOKEN_TTL_SECONDS)
.sign(key)
}
export const createGoogleCalendarClient = (
credentials: GoogleCalendarCredentials,
fetchImpl: FetchLike = fetch,
): GoogleCalendarClient => {
let cachedToken: { value: string; expiresAtMs: number } | null = null
const requestAccessToken = async (): Promise<string> => {
const nowSeconds = Math.floor(Date.now() / 1000)
const assertion = await buildServiceAccountAssertion(credentials, nowSeconds)
const response = await fetchImpl(GOOGLE_TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion,
}).toString(),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
if (!response.ok) {
throw new GoogleCalendarApiError(
`Não foi possível autenticar no Google (HTTP ${response.status}).`,
response.status,
)
}
const body = (await response.json()) as { access_token?: string; expires_in?: number }
if (!body.access_token) {
throw new GoogleCalendarApiError('Resposta de autenticação do Google sem token.', 502)
}
cachedToken = {
value: body.access_token,
expiresAtMs: Date.now() + (body.expires_in ?? GOOGLE_TOKEN_TTL_SECONDS) * 1000,
}
return cachedToken.value
}
const getAccessToken = async (): Promise<string> => {
if (cachedToken && cachedToken.expiresAtMs > Date.now() + TOKEN_REFRESH_LEAD_SECONDS * 1000) {
return cachedToken.value
}
return requestAccessToken()
}
const apiFetch = async (
url: string,
init: RequestInit = {},
retried = false,
): Promise<Response> => {
const token = await getAccessToken()
const response = await fetchImpl(url, {
...init,
headers: { ...init.headers, Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
})
// A 401 usually means the cached token expired server-side — re-mint once.
if (response.status === 401 && !retried) {
cachedToken = null
return apiFetch(url, init, true)
}
if (!response.ok) {
throw new GoogleCalendarApiError(
`Google Calendar API respondeu HTTP ${response.status}.`,
response.status,
)
}
return response
}
const eventsUrl = (calendarId: string, ...parts: string[]): string =>
`${GOOGLE_CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events${parts
.map((part) => `/${encodeURIComponent(part)}`)
.join('')}`
const listEvents: GoogleCalendarClient['listEvents'] = async (calendarId, range) => {
const events: GoogleRemoteEvent[] = []
let pageToken: string | undefined
do {
const params = new URLSearchParams({
timeMin: range.timeMin,
timeMax: range.timeMax,
maxResults: String(MAX_LIST_PAGE_SIZE),
singleEvents: 'true',
})
if (pageToken) params.set('pageToken', pageToken)
const response = await apiFetch(`${eventsUrl(calendarId)}?${params.toString()}`)
const body = (await response.json()) as {
items?: GoogleRemoteEvent[]
nextPageToken?: string
}
events.push(...(body.items ?? []))
pageToken = body.nextPageToken
} while (pageToken)
return events
}
const insertEvent: GoogleCalendarClient['insertEvent'] = async (calendarId, event) => {
try {
await apiFetch(eventsUrl(calendarId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event),
})
} catch (error) {
// 409 = the deterministic id already exists (a concurrent pass inserted
// it between our list and this insert) — the idempotent end state, not
// a failure; the next pass converges on the content.
if (!(error instanceof GoogleCalendarApiError && error.status === 409)) throw error
}
}
const updateEvent: GoogleCalendarClient['updateEvent'] = async (calendarId, eventId, event) => {
await apiFetch(eventsUrl(calendarId, eventId), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event),
})
}
const deleteEvent: GoogleCalendarClient['deleteEvent'] = async (calendarId, eventId) => {
await apiFetch(eventsUrl(calendarId, eventId), { method: 'DELETE' })
}
return { listEvents, insertEvent, updateEvent, deleteEvent }
}

View file

@ -0,0 +1,494 @@
import 'server-only'
import type { Activity, GoogleCalendarSync as GoogleCalendarSyncDoc } from '@/payload-types'
import type { Payload, PayloadRequest } from 'payload'
import {
activityMunicipalityIdOf,
buildGoogleEventPayload,
decodeGoogleEventActivityId,
googleEventContentEquals,
googleEventIdForActivity,
} from '@/lib/googleCalendarEventMapping'
import {
buildActivityWindowRange,
buildActivityWindowWhereClauses,
loadMunicipalityNames,
} from '@/utilities/calendarFeed'
import {
createGoogleCalendarClient,
GoogleCalendarApiError,
type GoogleCalendarClient,
type GoogleCalendarCredentials,
} from '@/utilities/googleCalendarClient'
/**
* C114 — the campaign→Google calendar reconciliation engine.
*
* One full pass per run (D3): load the staff-scope activities in the window,
* list the calendar's events in a slightly wider window, diff by the
* deterministic event id (`teqo` + base32hex(activityId)) and create/update/
* delete — updates only when the content differs, so repeated passes converge
* without touching Google. Canceled and hard-deleted activities drop out of
* the wanted set, which deletes their Google event: Teqo keeps the SoT,
* Google keeps no "canceled" state visible to followers.
*
* Triggers (D4): activity afterChange/afterDelete (relevant fields only),
* agenda-page auto-retry and the manual "Sincronizar agora", plus the
* `googleCalendarSync` config afterChange (D7: calendarId change / re-enable).
* The engine NEVER throws into the caller — failures land in the state fields
* and the status derives to `paused`.
*/
export const GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV = 'GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY'
/**
* The list window scans further into the past than the push window so a
* canceled/deleted event older than the 90-day lookback is still found and
* deleted (a realistic case: canceling an activity that happened months ago).
*/
const SYNC_DELETE_LOOKBACK_DAYS = 730
const SYNC_LIST_WINDOW_BUFFER_DAYS = 30
/**
* The fields the Google event mirrors (title, schedule, location, tags,
* deputy flag, municipality). Task toggles, updates and result records are
* deliberately absent — those never trigger a pass.
*/
const SYNC_RELEVANT_ACTIVITY_FIELDS = [
'title',
'status',
'startAt',
'endAt',
'allDay',
'locality',
'municipality',
'tags',
'deputyPresent',
] as const
export type GoogleCalendarSyncStatus = 'not-configured' | 'disabled' | 'synced' | 'paused'
export type GoogleCalendarSyncView = {
status: GoogleCalendarSyncStatus
calendarId: string | null
lastSyncedAt: string | null
lastSuccessAt: string | null
lastErrorAt: string | null
lastError: string | null
}
/**
* Pure derivation (never stored): the pill state is a function of the
* credential presence, the configured calendar and the last attempt outcomes.
* `paused` wins while the last attempt failed; any later success recovers.
*/
export const deriveGoogleCalendarSyncStatus = (input: {
hasCredential: boolean
calendarId: string | null | undefined
disabledAt?: string | null | undefined
lastSuccessAt?: string | null | undefined
lastErrorAt?: string | null | undefined
}): GoogleCalendarSyncStatus => {
if (!input.hasCredential || !input.calendarId) return 'not-configured'
if (input.disabledAt) return 'disabled'
if (input.lastErrorAt && (!input.lastSuccessAt || input.lastErrorAt > input.lastSuccessAt)) {
return 'paused'
}
return 'synced'
}
/**
* Reads the service account credential from the environment (base64 of the
* Google Cloud JSON key). Fail-closed: malformed/absent → null, which
* disables the sync entirely without breaking anything else.
*/
export const readGoogleServiceAccountCredentials = (): GoogleCalendarCredentials | null => {
const raw = process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV]
if (!raw) return null
try {
const parsed = JSON.parse(Buffer.from(raw, 'base64').toString('utf8')) as Record<
string,
unknown
>
if (
typeof parsed.client_email === 'string' &&
typeof parsed.private_key === 'string' &&
parsed.private_key.length > 0
) {
return { clientEmail: parsed.client_email, privateKey: parsed.private_key }
}
} catch {
// malformed credential — treated as absent (fail-closed, no sync attempts)
}
return null
}
export const loadGoogleCalendarSyncConfig = async (
payload: Payload,
req?: PayloadRequest,
): Promise<GoogleCalendarSyncDoc | null> => {
// Intentional admin bypass: config/state of the mirror is read by the
// engine and the agenda pill for any staff — access is enforced at the
// action layer, never per-read.
const result = await payload.find({
collection: 'googleCalendarSync',
limit: 1,
pagination: false,
depth: 0,
overrideAccess: true,
req,
})
return result.docs[0] ?? null
}
const docToView = (
doc: GoogleCalendarSyncDoc | null,
hasCredential: boolean,
): GoogleCalendarSyncView => ({
status: deriveGoogleCalendarSyncStatus({
hasCredential,
calendarId: doc?.calendarId ?? null,
disabledAt: doc?.disabledAt ?? null,
lastSuccessAt: doc?.lastSuccessAt ?? null,
lastErrorAt: doc?.lastErrorAt ?? null,
}),
calendarId: doc?.calendarId ?? null,
lastSyncedAt: doc?.lastSyncedAt ?? null,
lastSuccessAt: doc?.lastSuccessAt ?? null,
lastErrorAt: doc?.lastErrorAt ?? null,
lastError: doc?.lastError ?? null,
})
/** The read model the agenda UI renders — status derived, never stored. */
export const readGoogleCalendarSyncView = async (
payload: Payload,
): Promise<GoogleCalendarSyncView> => {
const [doc, credentials] = await Promise.all([
loadGoogleCalendarSyncConfig(payload),
readGoogleServiceAccountCredentials(),
])
return docToView(doc, credentials !== null)
}
/**
* The official calendar mirrors the FULL staff scope (espelho cheio): no
* advisor intersection — a coordinator-managed calendar, unlike per-feed
* creator scopes. Canceled activities are excluded from the PUSH set (their
* events get deleted); the window is the feed's own (shared contract).
*/
const loadSyncActivities = async (
payload: Payload,
req?: PayloadRequest,
): Promise<{ activities: Activity[]; municipalityNames: Map<number, string> }> => {
const { rangeStart, rangeEnd } = buildActivityWindowRange()
const result = await payload.find({
collection: 'activity',
depth: 0,
limit: 0,
pagination: false,
sort: 'startAt',
where: { and: buildActivityWindowWhereClauses(rangeStart, rangeEnd) },
// Intentional admin bypass: the official mirror is the whole campaign
// agenda (espelho cheio) — the collection access already gates writers.
overrideAccess: true,
req,
})
const activities = result.docs
const municipalityIds = [
...new Set(
activities
.map((activity) => activityMunicipalityIdOf(activity.municipality))
.filter((id): id is number => id !== undefined),
),
]
const municipalityNames = await loadMunicipalityNames(payload, municipalityIds)
return { activities, municipalityNames }
}
/**
* Ids of activities alive over the WIDER past window — the delete guard set.
* An event older than the push lookback whose activity is still alive (e.g. a
* past `realizado` commitment) must NOT be deleted; canceled/hard-deleted
* activities drop out of this set and their events are cleaned up wherever
* they are in the extended window.
*/
const loadAliveActivityIds = async (
payload: Payload,
req: PayloadRequest | undefined,
timeMin: string,
timeMax: string,
): Promise<Set<number>> => {
const result = await payload.find({
collection: 'activity',
depth: 0,
limit: 0,
pagination: false,
where: {
and: [
{ startAt: { less_than: timeMax } },
{
or: [
{ endAt: { greater_than: timeMin } },
{
and: [{ endAt: { exists: false } }, { startAt: { greater_than_equal: timeMin } }],
},
],
},
{ status: { not_equals: 'cancelado' } },
],
},
// Intentional admin bypass: same espelho-cheio rationale as above.
overrideAccess: true,
req,
})
return new Set(result.docs.map((activity) => activity.id))
}
/**
* The list window scans the extended past so canceled/deleted events older
* than the 90-day push lookback are still found and removed (canceling an
* activity that happened months ago is a realistic operation).
*/
const buildListWindow = (
rangeStart: string,
rangeEnd: string,
): { timeMin: string; timeMax: string } => {
const bufferMs = SYNC_LIST_WINDOW_BUFFER_DAYS * 86_400_000
const deleteLookbackMs = SYNC_DELETE_LOOKBACK_DAYS * 86_400_000
const nowMs = Date.now()
return {
timeMin: new Date(
Math.min(new Date(rangeStart).getTime() - bufferMs, nowMs - deleteLookbackMs),
).toISOString(),
timeMax: new Date(new Date(rangeEnd).getTime() + bufferMs).toISOString(),
}
}
const deleteRemoteEvent = async (
client: GoogleCalendarClient,
calendarId: string,
eventId: string,
): Promise<void> => {
try {
await client.deleteEvent(calendarId, eventId)
} catch (error) {
// Already gone is the desired end state.
if (!(error instanceof GoogleCalendarApiError && error.status === 404)) throw error
}
}
type SyncCounts = { created: number; updated: number; deleted: number }
const runSyncPass = async (
payload: Payload,
req: PayloadRequest | undefined,
client: GoogleCalendarClient,
calendarId: string,
): Promise<SyncCounts> => {
const { rangeStart, rangeEnd } = buildActivityWindowRange()
const { activities, municipalityNames } = await loadSyncActivities(payload, req)
const listWindow = buildListWindow(rangeStart, rangeEnd)
const remoteEvents = await client.listEvents(calendarId, listWindow)
const aliveActivityIds = await loadAliveActivityIds(
payload,
req,
listWindow.timeMin,
listWindow.timeMax,
)
const wantedById = new Map<number, Activity>()
for (const activity of activities) {
wantedById.set(activity.id, activity)
}
let created = 0
let updated = 0
let deleted = 0
const remoteEventIds = new Set<string>()
for (const event of remoteEvents) {
// Only OUR deterministic ids are reconciled; foreign events on the
// campaign calendar (e.g. added manually by the owner) stay untouched —
// the full decode, never a prefix match (a manual `teqo3` is foreign).
const activityId = event.id ? decodeGoogleEventActivityId(event.id) : null
if (activityId === null) continue
remoteEventIds.add(event.id!)
const activity = wantedById.get(activityId)
if (!activity) {
// Not in the push window: alive past/future commitments keep their
// event; anything else (canceled, hard-deleted) leaves Google.
if (!aliveActivityIds.has(activityId)) {
await deleteRemoteEvent(client, calendarId, event.id!)
deleted += 1
}
continue
}
const municipalityId = activityMunicipalityIdOf(activity.municipality)
const municipalityName = municipalityId ? municipalityNames.get(municipalityId) : undefined
const googleEvent = buildGoogleEventPayload(activity, municipalityName)
if (!googleEventContentEquals(event, googleEvent)) {
await client.updateEvent(calendarId, event.id!, googleEvent)
updated += 1
}
}
for (const [activityId, activity] of wantedById) {
const eventId = googleEventIdForActivity(activityId)
if (remoteEventIds.has(eventId)) continue
const municipalityId = activityMunicipalityIdOf(activity.municipality)
const municipalityName = municipalityId ? municipalityNames.get(municipalityId) : undefined
await client.insertEvent(calendarId, buildGoogleEventPayload(activity, municipalityName))
created += 1
}
return { created, updated, deleted }
}
const recordSyncState = async (
payload: Payload,
req: PayloadRequest | undefined,
patch: Partial<
Pick<GoogleCalendarSyncDoc, 'lastSyncedAt' | 'lastSuccessAt' | 'lastErrorAt' | 'lastError'>
>,
): Promise<void> => {
const doc = await loadGoogleCalendarSyncConfig(payload, req)
if (!doc) return // nothing to record before the first activation
await payload.update({
collection: 'googleCalendarSync',
id: doc.id,
data: patch,
depth: 0,
// Intentional admin bypass: the state fields are system-only by design.
overrideAccess: true,
req,
})
}
export type CampaignCalendarSyncOutcome = SyncCounts & {
status: GoogleCalendarSyncStatus
at: string
}
export type CampaignCalendarSyncOptions = {
reason: 'create' | 'update' | 'delete' | 'config-change' | 'manual'
client?: GoogleCalendarClient
req?: PayloadRequest
}
export const runCampaignCalendarSync = async (
payload: Payload,
options: CampaignCalendarSyncOptions,
): Promise<CampaignCalendarSyncOutcome> => {
const credentials = readGoogleServiceAccountCredentials()
const config = await loadGoogleCalendarSyncConfig(payload, options.req)
const at = new Date().toISOString()
if (!credentials || !config?.calendarId || config.disabledAt) {
return {
status: deriveGoogleCalendarSyncStatus({
hasCredential: credentials !== null,
calendarId: config?.calendarId ?? null,
disabledAt: config?.disabledAt ?? null,
lastSuccessAt: config?.lastSuccessAt ?? null,
lastErrorAt: config?.lastErrorAt ?? null,
}),
created: 0,
updated: 0,
deleted: 0,
at,
}
}
const client = options.client ?? createGoogleCalendarClient(credentials)
try {
const counts = await runSyncPass(payload, options.req, client, config.calendarId)
await recordSyncState(payload, options.req, { lastSyncedAt: at, lastSuccessAt: at })
return { status: 'synced', ...counts, at }
} catch (error) {
const message =
error instanceof Error ? error.message : 'Erro desconhecido ao sincronizar com o Google.'
await recordSyncState(payload, options.req, {
lastSyncedAt: at,
lastErrorAt: at,
lastError: message.slice(0, 500),
})
return { status: 'paused', created: 0, updated: 0, deleted: 0, at }
}
}
/**
* Normalizes a mirrored field for change detection. The afterChange `doc`
* carries populated relationships (municipality object) while `previousDoc`
* comes depth-0 (numeric id) — comparing raw values would flag EVERY update
* as changed and sync on every task toggle.
*/
const normalizeMirroredField = (doc: Activity | undefined, field: string): unknown => {
const raw = (doc as unknown as Record<string, unknown> | undefined)?.[field]
if (field === 'municipality') {
return activityMunicipalityIdOf(raw as unknown as Activity['municipality']) ?? null
}
if (field === 'tags') {
return Array.isArray(raw) ? [...raw].sort() : raw
}
return raw
}
/**
* Pure guard — the activity hook only runs a sync pass when a field the
* Google event mirrors changed (task toggles, updates and result records
* never trigger). Create/delete always sync.
*/
export const shouldSyncActivityOperation = ({
operation,
doc,
previousDoc,
}: {
operation: 'create' | 'update' | 'delete'
doc: Activity
previousDoc?: Activity
}): boolean => {
if (operation !== 'update') return true
return SYNC_RELEVANT_ACTIVITY_FIELDS.some(
(field) =>
JSON.stringify(normalizeMirroredField(doc, field)) !==
JSON.stringify(normalizeMirroredField(previousDoc, field)),
)
}
/**
* Pure guard — the config hook reconciles on calendarId change or re-enable
* (D7); state-only writes (the engine recording outcomes) never re-trigger,
* which is what breaks the would-be loop.
*/
export const shouldSyncConfigChange = ({
operation,
doc,
previousDoc,
}: {
operation: 'create' | 'update'
doc: GoogleCalendarSyncDoc
previousDoc?: GoogleCalendarSyncDoc
}): boolean => {
if (operation !== 'update') return true
const calendarChanged = (doc.calendarId ?? '') !== (previousDoc?.calendarId ?? '')
const disabledCleared = Boolean(previousDoc?.disabledAt) && !doc.disabledAt
return calendarChanged || disabledCleared
}
/**
* Activity hook (afterChange + afterDelete) and the `googleCalendarSync`
* config hook (D7 auto-resync) live in `googleCalendarSyncHooks.ts` — they
* delegate to the guards above and the engine, and are kept in a separate
* module so tests can mock the engine at the import boundary.
*/

View file

@ -0,0 +1,67 @@
import 'server-only'
import type { Activity, GoogleCalendarSync as GoogleCalendarSyncDoc } from '@/payload-types'
import type { PayloadRequest } from 'payload'
import {
runCampaignCalendarSync,
shouldSyncActivityOperation,
shouldSyncConfigChange,
} from '@/utilities/googleCalendarSync'
/**
* C114 — the two Payload hooks that trigger the Google mirror (in a module of
* their own so tests can mock the engine at the import boundary).
*
* Activity hook (afterChange + afterDelete): delegates the decision to the
* pure guard — task toggles, updates and result records never trigger a pass.
* afterDelete hooks carry no `operation`; its absence means delete.
* Config hook (afterChange on `googleCalendarSync`): D7 auto-resync — a
* changed `calendarId` or a re-enable runs the full reconciliation against
* the new calendar; state-only writes never re-trigger (no loop).
*
* Both NEVER throw: the Teqo write paths are independent of Google
* (fail-closed at the trigger; engine failures land in the `paused` state).
*/
export const activityGoogleCalendarSyncHook = async ({
doc,
previousDoc,
operation,
req,
}: {
doc: Activity
previousDoc?: Activity
operation?: 'create' | 'update'
req: PayloadRequest
}): Promise<unknown> => {
try {
const resolvedOperation = operation ?? 'delete'
if (shouldSyncActivityOperation({ operation: resolvedOperation, doc, previousDoc })) {
await runCampaignCalendarSync(req.payload, { reason: resolvedOperation, req })
}
} catch {
// recorded as paused inside the engine; the write path stays intact
}
return doc
}
export const googleCalendarSyncConfigHook = async ({
doc,
previousDoc,
operation,
req,
}: {
doc: GoogleCalendarSyncDoc
previousDoc?: GoogleCalendarSyncDoc
operation: 'create' | 'update'
req: PayloadRequest
}): Promise<unknown> => {
try {
if (shouldSyncConfigChange({ operation, doc, previousDoc })) {
await runCampaignCalendarSync(req.payload, { reason: 'config-change', req })
}
} catch {
// never throw into admin writes
}
return doc
}

View file

@ -14,6 +14,7 @@ import {
compareHomeSearchNameRelevance,
normalizeHomeSearchName,
} from '@/lib/homeSearchMunicipalityMatch'
import { primaryPhoneOf } from '@/lib/phone'
import { populatedContactName } from '@/lib/relationship'
import { matchesNormalizedAtWordStart } from '@/lib/wordStartFilter'
import type { CampaignUser, Leadership } from '@/payload-types'
@ -21,9 +22,9 @@ import { truncatedNamesLabel } from '@/utilities/campaignListUrl'
import { buildLeadershipListWhere } from '@/utilities/leadership/leadershipListUrl'
const populatedContactPhone = (contact: unknown): string | null => {
if (typeof contact !== 'object' || contact === null || !('phone' in contact)) return null
const { phone } = contact
return typeof phone === 'string' && phone.length > 0 ? phone : null
if (typeof contact !== 'object' || contact === null || !('phones' in contact)) return null
const phones = (contact as { phones?: unknown }).phones
return Array.isArray(phones) ? primaryPhoneOf(phones as { value?: string | null }[]) : null
}
const municipalityNamesFromLeadership = (doc: Leadership): string[] => {

View file

@ -3,6 +3,7 @@ import 'server-only'
import type { Payload } from 'payload'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { primaryPhoneOf } from '@/lib/phone'
import type { CampaignUser, Contact, Municipality } from '@/payload-types'
import { getEngagedLeaderMunicipalityIds } from '@/utilities/campaignAccess'
import { getSupporterRegistrationConsent } from '@/utilities/campaignConsent'
@ -36,7 +37,7 @@ const toLeaderContactListItem = (supporter: {
return {
id: supporter.id,
name: typeof contact === 'object' && contact ? contact.name : 'Contato',
phone: typeof contact === 'object' && contact ? (contact.phone ?? null) : null,
phone: typeof contact === 'object' && contact ? primaryPhoneOf(contact.phones) : null,
city: typeof contact === 'object' && contact ? (contact.city ?? null) : null,
municipalityName: typeof municipality === 'object' && municipality ? municipality.name : null,
createdAt: supporter.createdAt,

View file

@ -2,6 +2,7 @@ import 'server-only'
import type { Payload } from 'payload'
import { phoneValuesOf, primaryPhoneOf } from '@/lib/phone'
import { relationshipId } from '@/lib/relationship'
import type { SupportStatus } from '@/lib/schemas/leadership'
import { isSupportStatus } from '@/lib/schemas/leadership'
@ -42,16 +43,23 @@ export type LeadershipRowViewModel = {
const contactSummary = (
contact: Leadership['contact'],
): { id: number; name: string; phone: string | null; email: string | null } => {
): {
id: number
name: string
phone: string | null
phones: { value: string }[]
email: string | null
} => {
if (typeof contact === 'object' && contact !== null) {
return {
id: contact.id,
name: contact.name ?? 'Contato',
phone: contact.phone ?? null,
phone: primaryPhoneOf(contact.phones),
phones: phoneValuesOf(contact.phones).map((value) => ({ value })),
email: contact.email ?? null,
}
}
return { id: Number(contact), name: 'Contato', phone: null, email: null }
return { id: Number(contact), name: 'Contato', phone: null, phones: [], email: null }
}
const findMunicipalityLeadershipDocs = async (
@ -78,6 +86,9 @@ const toWizardLeadershipTile = (doc: Leadership): WizardLeadershipTileViewModel
return {
id: doc.id,
name: contact.name,
phones: (contact.phones ?? [])
.map((entry) => entry.value)
.filter((value): value is string => Boolean(value)),
phone: contact.phone,
email: contact.email,
supportStatus: isSupportStatus(doc.supportStatus) ? doc.supportStatus : null,
@ -337,6 +348,8 @@ export const loadLeadershipListPageData = async (
}
export type LeadershipDetailViewModel = LeadershipRowViewModel & {
/** Every number of the ficha, order = priority (C112). */
phones: string[]
municipalityIDs: number[]
organizationIDs: number[]
stateDeputyIDs: number[]
@ -372,6 +385,9 @@ export const loadLeadershipDetail = async (
return {
...row,
phones: phoneValuesOf(
doc.contact && typeof doc.contact === 'object' ? doc.contact.phones : null,
),
municipalityIDs: (doc.municipalities ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null),

View file

@ -2,6 +2,7 @@ import 'server-only'
import type { Payload } from 'payload'
import { primaryPhoneOf } from '@/lib/phone'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import { isSupportStatus, type SupportStatus } from '@/lib/schemas/leadership'
import type { CampaignUser, Contact, Leadership, StateDeputy } from '@/payload-types'
@ -293,7 +294,7 @@ const contactSummary = (contact: Contact | number | null | undefined): ContactSu
return {
id: contact.id,
name: contact.name ?? 'Contato',
phone: contact.phone ?? null,
phone: primaryPhoneOf(contact.phones),
email: contact.email ?? null,
city: contact.city ?? null,
}

View file

@ -2,6 +2,7 @@ import 'server-only'
import type { Payload } from 'payload'
import { primaryPhoneOf } from '@/lib/phone'
import { relationshipId } from '@/lib/relationship'
import {
PERSON_DELETE_FORBIDDEN_MESSAGE,
@ -153,7 +154,9 @@ const anonymizeContact = async (
data: {
name: 'Titular removido',
email: null,
phone: tombstonePhone,
// The tombstone REPLACES every number — the remaining phones are the
// person's PII and must not survive the anonymization (C112).
phones: [{ value: tombstonePhone }],
gender: null,
city: null,
postalCode: null,
@ -300,7 +303,7 @@ export const loadPersonDeleteManifest = async (
contact: {
id: contact.id,
name: contact.name ?? 'Contato',
phone: contact.phone ?? null,
phone: primaryPhoneOf(contact.phones),
email: contact.email ?? null,
},
leaderships: leadershipRows,

View file

@ -1,3 +1,4 @@
import { primaryPhoneOf } from '@/lib/phone'
import { isPopulatedRelationship } from '@/lib/relationship'
import type { Contact, Petition } from '@/payload-types'
import type { ToCSVFunction } from '@payloadcms/plugin-import-export/types'
@ -27,7 +28,7 @@ export const signatureContactToCSV: ToCSVFunction = ({ value, row }) => {
row.contact_name = value.name
row.contact_email = value.email ?? ''
row.contact_phone = value.phone ?? ''
row.contact_phone = primaryPhoneOf(value.phones) ?? ''
row.contact_state = value.state ?? ''
row.contact_city = value.city ?? ''
return undefined

View file

@ -2,6 +2,7 @@ import 'server-only'
import type { Payload } from 'payload'
import { phoneValuesOf, primaryPhoneOf } from '@/lib/phone'
import { populatedContactName, relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import type { CampaignUser, StateDeputy } from '@/payload-types'
import { NO_PARTY_FILTER_VALUE } from '@/utilities/campaignListUrl'
@ -40,7 +41,7 @@ const stateDeputyContactSummary = (
id: contact.id,
name: contact.name,
email: contact.email ?? null,
phone: contact.phone ?? null,
phone: primaryPhoneOf(contact.phones),
}
}
@ -240,6 +241,8 @@ export type StateDeputySummary = {
export type StateDeputyDetailViewModel = StateDeputySummary & {
email: string | null
phone: string | null
/** Every number of the ficha, order = priority (C112) — primary first. */
phones: string[]
notes: string | null
municipalities: Array<{ id: number; name: string; slug: string }>
leaderships: Array<{ id: number; name: string }>
@ -300,8 +303,12 @@ export const loadStateDeputyDetail = async (
loadCampaignUserSummaries(payload, user, uniqueRelationshipIds(stateDeputy.advisors)),
])
const contactSummary = stateDeputyContactSummary(stateDeputy.contact)
const contact = stateDeputy.contact
return {
...stateDeputyContactSummary(stateDeputy.contact),
...contactSummary,
phones: phoneValuesOf(typeof contact === 'object' && contact !== null ? contact.phones : null),
id: stateDeputy.id,
slug: stateDeputy.slug,
party: stateDeputy.party ?? null,

View file

@ -1,5 +1,7 @@
import 'server-only'
import { randomUUID } from 'node:crypto'
import { sql } from '@payloadcms/db-postgres'
import type { Payload } from 'payload'
@ -43,6 +45,7 @@ type DrizzleTx = {
}
const CONTACT_TABLE = 'contact'
const CONTACT_PHONES_TABLE = 'contact_phones'
const SUPPORTER_TABLE = 'supporter'
/**
@ -68,13 +71,15 @@ const SUPPORTER_COLUMNS = [
'updatedAt',
] as const
/** The `Contact.phones` join table (C112): order = priority, value = the number. */
const CONTACT_PHONES_COLUMNS = ['_order', '_parentID', 'value'] as const
const buildContactRows = (
rows: SupporterImportBulkRow[],
now: string,
): Array<Record<string, unknown>> =>
rows.map((row) => ({
name: row.nome,
phone: row.telefone,
email: null,
state: 'BA',
city: row.municipio ?? null,
@ -128,6 +133,12 @@ const asContactID = (row: Record<string, unknown>): number | null => {
return Number.isSafeInteger(parsed) ? parsed : null
}
const asCreatedContactID = (row: Record<string, unknown>): number | null => {
const value = row.id
const parsed = typeof value === 'number' ? value : Number(value)
return Number.isSafeInteger(parsed) ? parsed : null
}
/**
* Bulk-insert contacts and municipality-less supporters for a CSV import inside the
* caller's Payload transaction. Phone advisory locks MUST already be held for
@ -159,8 +170,10 @@ export const bulkInsertSupporterImport = async (args: {
const tables = getDrizzleTables(payload)
const contactTable = requireTable(tables, CONTACT_TABLE)
const contactPhonesTable = requireTable(tables, CONTACT_PHONES_TABLE)
const supporterTable = requireTable(tables, SUPPORTER_TABLE)
assertDrizzleColumns(supporterTable, SUPPORTER_TABLE, SUPPORTER_COLUMNS)
assertDrizzleColumns(contactPhonesTable, CONTACT_PHONES_TABLE, CONTACT_PHONES_COLUMNS)
const database = await getPostgresTransactionDatabase(payload, req)
const tx = database as unknown as DrizzleTx
@ -172,10 +185,12 @@ export const bulkInsertSupporterImport = async (args: {
for (const phoneBatch of chunk(phones, INSERT_CHUNK_SIZE)) {
const existingContactRows = drizzleResultRows(
await tx.execute(
sql`SELECT "id", "phone" FROM "contact" WHERE "phone" IN (${sql.join(
phoneBatch.map((phone) => sql`${phone}`),
sql`, `,
)})`,
sql`SELECT "contact"."id" AS "id", "contact_phones"."value" AS "phone" FROM "contact"
JOIN "contact_phones" ON "contact_phones"."_parent_id" = "contact"."id"
WHERE "contact_phones"."value" IN (${sql.join(
phoneBatch.map((phone) => sql`${phone}`),
sql`, `,
)})`,
),
)
for (const row of existingContactRows) {
@ -205,9 +220,18 @@ export const bulkInsertSupporterImport = async (args: {
const newRows = [...newContactByPhone.values()]
for (const batch of chunk(newRows, INSERT_CHUNK_SIZE)) {
const created = await tx.insert(contactTable).values(buildContactRows(batch, now)).returning()
for (const row of created) {
const inserted = asPhoneKeyedRow(row)
if (inserted) contactIdByPhone.set(inserted.phone, inserted.id)
// The phones join-table rows follow the inserted contacts: each new
// ficha starts with its CSV number as the single (primary) phone.
const phoneRows: Array<Record<string, unknown>> = []
created.forEach((row, index) => {
const insertedID = asCreatedContactID(row)
const telefone = batch[index]?.telefone
if (insertedID === null || !telefone) return
contactIdByPhone.set(telefone, insertedID)
phoneRows.push({ id: randomUUID(), _parentID: insertedID, _order: 0, value: telefone })
})
if (phoneRows.length > 0) {
await tx.insert(contactPhonesTable).values(phoneRows)
}
}
}

View file

@ -40,9 +40,10 @@ const buildPayloadSearchWhere = (terms: SupporterSearchTerms): Where => {
]
if (terms.normalizedPhone) {
searchFilters.push({ 'contact.phone': { equals: terms.normalizedPhone } })
// Matches ANY of the ficha's numbers (C112).
searchFilters.push({ 'contact.phones.value': { equals: terms.normalizedPhone } })
} else if (terms.phoneDigits) {
searchFilters.push({ 'contact.phone': { contains: terms.phoneDigits } })
searchFilters.push({ 'contact.phones.value': { contains: terms.phoneDigits } })
}
return { or: searchFilters }

View file

@ -20,9 +20,14 @@ const buildSqlSearchCondition = (terms: SupporterSearchTerms): ReturnType<typeof
]
if (terms.normalizedPhone) {
searchTerms.push(sql`"contact"."phone" = ${terms.normalizedPhone}`)
// Matches ANY of the ficha's numbers (C112) — EXISTS over the join table.
searchTerms.push(
sql`EXISTS (SELECT 1 FROM "contact_phones" WHERE "contact_phones"."_parent_id" = "contact"."id" AND "contact_phones"."value" = ${terms.normalizedPhone})`,
)
} else if (terms.phoneDigits) {
searchTerms.push(sql`"contact"."phone" ILIKE ${`%${terms.phoneDigits}%`}`)
searchTerms.push(
sql`EXISTS (SELECT 1 FROM "contact_phones" WHERE "contact_phones"."_parent_id" = "contact"."id" AND "contact_phones"."value" ILIKE ${`%${terms.phoneDigits}%`})`,
)
}
return sql`(${sql.join(searchTerms, sql` OR `)})`

View file

@ -1,5 +1,5 @@
import { territoryForCity } from '@/lib/bahiaTerritories'
import { formatBrazilianPhoneInput } from '@/lib/phone'
import { formatBrazilianPhoneInput, phoneValuesOf, primaryPhoneOf } from '@/lib/phone'
import { isPopulatedRelationship } from '@/lib/relationship'
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
import type { CampaignUser, Contact, Municipality, Supporter } from '@/payload-types'
@ -27,6 +27,8 @@ export type SupporterDetailViewModel = {
name: string
phone: string
phoneDisplay: string
/** Every number of the person, order = priority (primary first). */
phones: string[]
email: string | null
city: string | null
territory: string | null
@ -57,7 +59,7 @@ export const toSupporterListItemViewModel = (
return {
id: supporter.id,
name: contact?.name ?? 'Contato sem nome',
phone: contact?.phone ?? '',
phone: primaryPhoneOf(contact?.phones) ?? '',
email: contact?.email ?? null,
city: contact?.city ?? null,
municipalityName: municipality?.name ?? null,
@ -73,13 +75,15 @@ export const toSupporterDetailViewModel = (supporter: Supporter): SupporterDetai
? supporter.createdBy
: null
const city = contact?.city ?? null
const phone = contact?.phone ?? ''
const phones = phoneValuesOf(contact?.phones)
const phone = phones[0] ?? ''
return {
id: supporter.id,
name: contact?.name ?? 'Contato sem nome',
phone,
phoneDisplay: phone ? formatBrazilianPhoneInput(phone) : '',
phones,
email: contact?.email ?? null,
city,
territory: city ? (territoryForCity(city) ?? null) : null,

View file

@ -49,7 +49,9 @@ test.describe('Atividades — registro-fundação', () => {
await page.getByRole('link', { name: 'Adicionar demanda' }).click()
await expect(page.locator('input[name="activityId"]')).toHaveValue(/\d+/)
await expect(page.getByLabel('Atividade relacionada')).toContainText(activityTitle)
// `.first()`: transient RSC-pending duplication also copies the combobox
// trigger (same strict-mode flake as cd469857; observed on loaded machines).
await expect(page.getByLabel('Atividade relacionada').first()).toContainText(activityTitle)
await expect(page.getByLabel('Município')).toHaveValue(String(municipality.id))
await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}?tab=updates`)

View file

@ -0,0 +1,33 @@
import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js'
/**
* C114 — Google Calendar mirror surface on the agenda. The e2e only pins the
* deterministic "não configurado" state (no credential env in the test
* runtime, no sync doc in the seeded DB): engine behavior (create/update/
* delete/recover) is covered by the int tests with an in-memory calendar
* stub — this spec must never depend on the real Google API.
*/
test.describe('Agenda — sincronização Google (C114)', () => {
test('staff vê a pill "não configurado" e o diálogo explica o passo de operação', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/agenda`)
await expect(campaignPageChrome(page, 'Agenda')).toBeVisible()
const pill = page.getByRole('button', { name: 'Google: não configurado' })
await expect(pill).toBeVisible({ timeout: 15_000 })
await pill.click()
const dialog = page.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
await expect(dialog).toBeVisible()
await expect(dialog.getByText('Ainda não configurado')).toBeVisible()
// O runbook de operação aparece (a ativação depende da conta Google da campanha).
await expect(dialog.getByText(/service account do Teqo/)).toBeVisible()
})
})

View file

@ -357,7 +357,7 @@ test.describe('Wizard — atualizar liderança (B70)', () => {
}).toPass({ timeout: 15_000 })
await page.getByLabel('Nome').fill(`Liderança B70 ${suffix}`)
await page.getByLabel('Celular').fill(`719${suffix}`)
await page.getByRole('textbox', { name: 'Celular 1' }).fill(`719${suffix}`)
await page.getByRole('button', { name: 'Salvar' }).click()
await expect(page.getByRole('heading', { name: 'Quem coordena por aqui?' })).toBeVisible({

View file

@ -644,7 +644,7 @@ test.describe('Municípios — jornadas por papel', () => {
collection: 'contact',
data: {
name: fixtures.value('Contato Liderança'),
phone: leaderPhone,
phones: [{ value: leaderPhone }],
state: 'BA',
city: administered.name,
},
@ -705,7 +705,7 @@ test.describe('Municípios — jornadas por papel', () => {
const supporterName = fixtures.value('Apoiador Liderança')
const supporterPhone = fixtures.phone()
await page.getByLabel('Nome *').fill(supporterName)
await page.getByLabel('Celular *').fill(supporterPhone)
await page.getByRole('textbox', { name: 'Celular 1' }).fill(supporterPhone)
// Radix checkbox: a pre-hydration click is a silent no-op (B13/B17 flake).
await checkRadixWhenHydrated(page, 'A pessoa autorizou o cadastro *')
await page.getByRole('button', { name: 'Cadastrar contato' }).click()

View file

@ -35,7 +35,9 @@ test.describe('Wizard "Registrar pedido" (A5/B195)', () => {
// free-text field comes straight after Tipo and Atividade.
await expect(page.getByRole('heading', { name: 'Registrar pedido' })).toHaveCount(0)
await expect(page.getByLabel(CAMPAIGN_DEMAND_KIND_LABEL)).toBeVisible()
await expect(page.getByLabel(CAMPAIGN_DEMAND_ACTIVITY_LABEL)).toBeVisible()
// `.first()`: transient RSC-pending duplication also copies the combobox
// trigger (same strict-mode flake as cd469857; observed on loaded machines).
await expect(page.getByLabel(CAMPAIGN_DEMAND_ACTIVITY_LABEL).first()).toBeVisible()
await expect(page.getByLabel(CAMPAIGN_DEMAND_BODY_LABEL)).toBeVisible()
await expect(page.locator('input[name="title"]')).toHaveCount(0)
await expect(page.locator('select[name="municipalityId"]')).toHaveCount(0)

View file

@ -191,7 +191,7 @@ export class CampaignE2EOwnership {
collection: 'contact',
data: {
name: contactName,
phone: this.phone(),
phones: [{ value: this.phone() }],
state: 'BA',
city: municipalities[0]?.name ?? 'Salvador',
},

View file

@ -62,7 +62,9 @@ type EditorUserInput = {
}
type ConsentInput = Partial<Pick<Consent, 'key' | 'text'>>
type ContactInput = Partial<
Pick<Contact, 'name' | 'email' | 'phone' | 'gender' | 'state' | 'city' | 'postalCode'>
Pick<Contact, 'name' | 'email' | 'gender' | 'state' | 'city' | 'postalCode'> & {
phones: Array<{ value: string }>
}
>
type LeadershipInput = Partial<
Pick<
@ -233,8 +235,9 @@ const nextAllocatedMunicipalityIndex = async (
* Delete campaign rows attached to a freshly claimed municipality. The allocator
* guarantees no OTHER live test owns this municipality, so anything found here is
* residue from an aborted previous run that would poison count assertions.
* Also deletes the CONTACT rows those residue supporters orphaned (D10 F1).
*/
const purgeMunicipalityResidue = async (
export const purgeMunicipalityResidue = async (
payload: Payload,
municipalityID: number,
): Promise<void> => {
@ -273,7 +276,6 @@ const purgeMunicipalityResidue = async (
where: { municipality: { equals: municipalityID } },
depth: 0,
pagination: false,
select: {},
}),
payload.find({
collection: 'activity',
@ -336,6 +338,63 @@ const purgeMunicipalityResidue = async (
depth: 0,
})
}
// D10 F1: an aborted run leaves residue supporters AND their contact rows.
// The supporters are gone above; delete the contacts they orphaned (no other
// join references them) so the residue stops accumulating — same reference
// rule as the production `removeSupporterData` flow, reimplemented here so
// the fixture never runs production code under test.
const residueContactIDs = [
...new Set(
supporters.docs.map((doc) => relationId(doc.contact)).filter((id) => id !== undefined),
),
]
if (residueContactIDs.length > 0) {
const [leadershipRefs, signatureRefs, subscriptionRefs, supporterRefs] = await Promise.all([
payload.find({
collection: 'leadership',
where: { contact: { in: residueContactIDs } },
depth: 0,
pagination: false,
}),
payload.find({
collection: 'signature',
where: { contact: { in: residueContactIDs } },
depth: 0,
pagination: false,
}),
payload.find({
collection: 'subscription',
where: { contact: { in: residueContactIDs } },
depth: 0,
pagination: false,
}),
payload.find({
collection: 'supporter',
where: { contact: { in: residueContactIDs } },
depth: 0,
pagination: false,
}),
])
const referenced = new Set<number>()
for (const doc of [
...leadershipRefs.docs,
...signatureRefs.docs,
...subscriptionRefs.docs,
...supporterRefs.docs,
]) {
const contactID = relationId(doc.contact)
if (contactID !== undefined) referenced.add(contactID)
}
const orphanContactIDs = residueContactIDs.filter((id) => !referenced.has(id))
if (orphanContactIDs.length > 0) {
await payload.delete({
collection: 'contact',
where: { id: { in: orphanContactIDs } },
depth: 0,
})
}
}
}
export class CampaignFixtures {
@ -544,7 +603,7 @@ export class CampaignFixtures {
or: [
{ name: { contains: this.runID } },
{ email: { contains: this.runID } },
{ phone: { contains: this.runID } },
{ 'phones.value': { contains: this.runID } },
],
},
depth: 0,
@ -656,11 +715,12 @@ export class CampaignFixtures {
}
async createContact(input: ContactInput = {}): Promise<Contact & { phone: string }> {
const phone = input.phones?.[0]?.value ?? this.phone()
const contact = await this.rootPayload.create({
collection: 'contact',
data: {
name: this.value('Contato'),
phone: this.phone(),
phones: [{ value: phone }],
state: 'BA',
city: 'Salvador',
...input,
@ -668,12 +728,13 @@ export class CampaignFixtures {
depth: 0,
})
this.own('contact', contact)
// Contact.phone is optional at the collection level (name-only imports), but
// every fixture contact carries one — narrow so specs can rely on it.
if (typeof contact.phone !== 'string') {
// The fixture contract is "one primary phone": every fixture contact carries
// one — narrow so specs can rely on it (C112 keeps the array under the hood).
const primary = contact.phones?.[0]?.value
if (typeof primary !== 'string') {
throw new Error('Fixture contacts must always carry a phone.')
}
return { ...contact, phone: contact.phone }
return { ...contact, phone: primary }
}
/** Assign advisors to a seeded municipality (tracked for reset on cleanup). */
@ -888,7 +949,7 @@ export class CampaignFixtures {
for (const contact of contacts.docs) {
if (
this.owned.contact.has(contact.id) ||
this.hasMarker(contact.name, contact.email, contact.phone)
this.hasMarker(contact.name, contact.email, contact.phones?.[0]?.value ?? null)
) {
this.own('contact', contact)
}

View file

@ -88,6 +88,41 @@ export const restoreConsentRows = async (
}
}
/**
* Keys the consent fixtures rename the canonical stable row to while a test
* exercises the fail-closed "key absent" state. A run aborted between the
* rename commit and the fixture restore leaves the canonical key absent and an
* orphan row holding one of these names; without a purge, the next run
* snapshots the key as absent, creates a fresh row, and the same rename then
* hits the UNIQUE `consent_key_idx` — the pollution is self-sustaining (D10 F1).
*/
const ORPHANED_CONSENT_RENAME_KEYS = [
'temporarily-renamed-consent',
'chave-renomeada',
'chave-temporariamente-ausente',
] as const
/**
* Deletes orphaned consent rows left by an aborted run: the explicit temporary
* rename keys. MUST be called while holding the lease key's EXCLUSIVE lease.
* The 3 names are produced by NO live test anymore (the fail-closed rename
* tests vacate the canonical row to PRIVATE keys, D10 review finding P1-1), so
* a row carrying one of them is guaranteed orphaned — the only producer left
* is the self-heal repro's own plant, which runs under this key's exclusive
* lease immediately before the healing fixture.
*/
export const purgeOrphanedConsentRenames = async (
execute: (query: ReturnType<typeof sql>) => Promise<unknown>,
): Promise<void> => {
await execute(sql`
DELETE FROM "consent"
WHERE "key" IN (${sql.join(
ORPHANED_CONSENT_RENAME_KEYS.map((key) => sql`${key}`),
sql`, `,
)})
`)
}
export const CAMPAIGN_INVITE_CONSENT_LEASE_KEY = 'campaign-invite-consent'
export const SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY = 'supporter-registration-consent'
export const SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY = 'supporter-vote-intention-consent'
@ -104,7 +139,7 @@ export type LeasedConsentFixture = {
consentKey: string
leaseKey: string
}
const fixtureConsentText: Consent['text'] = {
export const fixtureConsentText: Consent['text'] = {
root: {
type: 'root',
children: [
@ -385,7 +420,7 @@ const withDatabaseLease = async <Result>(
}
}
const withTestDatabaseLease = async <Result>(
export const withExclusiveTestDatabaseLease = async <Result>(
payload: Payload,
leaseKey: string,
operation: () => Promise<Result>,
@ -394,7 +429,7 @@ const withTestDatabaseLease = async <Result>(
payload,
leaseKey,
acquireTestDatabaseLease,
'The leased operation and database lease release both failed.',
'The exclusive leased operation and database lease release both failed.',
operation,
)
@ -415,7 +450,13 @@ export const ensureLeasedConsent = async (
payload: Payload,
{ consentKey, leaseKey }: LeasedConsentFixture,
): Promise<Consent> =>
withTestDatabaseLease(payload, leaseKey, async () => {
withExclusiveTestDatabaseLease(payload, leaseKey, async () => {
// D10 F1: a run aborted mid-rename leaves an orphan row holding a
// temporary name and the canonical key absent; the purge clears it so the
// ensure below can recreate the canonical row without the next rename
// hitting the UNIQUE constraint. Autocommit is safe: we hold the
// exclusive lease, so no live renamer can be mid-flight.
await purgeOrphanedConsentRenames((query) => payload.db.drizzle.execute(query))
const existing = await payload.find({
collection: 'consent',
where: { key: { equals: consentKey } },
@ -440,6 +481,17 @@ export const withLeasedConsent = async <Result>(
{ consentKey, leaseKey }: LeasedConsentFixture,
operation: (consent: Consent) => Promise<Result>,
): Promise<Result> => {
// D10 F2 (measurement-driven): the operation holds the SHARED consent lease.
// Holding exclusive across the operation serialized every writer on the key
// into a 17-waiter queue and a 15s cascade under 6 workers; running the
// operation UNLEASED let a parallel fixture's created-row cleanup delete the
// canonical row out from under a consumer (FK violation). Shared is the
// stable middle: consumers never serialize against each other (their writes
// touch disjoint per-test rows, so no lock-order cycle is constructible —
// every advisory holder acquires at most one lease at a time, and Onda0
// acquires its three keys in a fixed order) and the exclusive
// missing-consent window blocks every consumer while the canonical key is
// absent, so no consumer observes the transient state.
for (;;) {
await ensureLeasedConsent(payload, { consentKey, leaseKey })
const lease = await beginTestDatabaseLease(payload, leaseKey, 'shared')
@ -500,6 +552,13 @@ export const withMissingInviteConsentFixture = async <Result>(
let setupError: unknown
try {
await faults.beforeSnapshotSetup?.()
// D10 F1: clear orphaned renames left by an aborted run BEFORE snapshotting
// (we hold the exclusive setup lease, so no live renamer exists). The
// DELETE runs in autocommit, NOT in the lease transaction: an uncommitted
// delete would keep its row lock and the fixture's own operation would
// self-deadlock when it re-uses the purged key (observed on the first D10
// implementation).
await purgeOrphanedConsentRenames((query) => payload.db.drizzle.execute(query))
snapshot = rowsFrom<ConsentSnapshot>(
await setupLease.transaction.execute(sql`
SELECT "id", "key", "text", "updated_at", "created_at"
@ -545,21 +604,23 @@ export const withMissingInviteConsentFixture = async <Result>(
}
if (setupError !== undefined) throw setupError
// Serialize the operation window under a SHARED lease (same precedent as
// onda0Provision's leases on the stable keys): parallel spec files must
// never observe the committed-absent state, or their ensure-then-read
// helpers create an ephemeral row that this restore deletes out from under
// them. Shared is enough — the ephemeral-row creators (ensures and other
// fixtures) are all EXCLUSIVE holders, which a shared window blocks without
// stalling the harmless shared readers behind it (the 15s test budget
// stayed tight under load with an exclusive window).
// Serialize the operation window under an EXCLUSIVE lease (D10 F2,
// measurement-driven change from D9's shared window): every consumer of the
// key — the shared `withLeasedConsent` holders AND the exclusive fixtures —
// is blocked while the canonical key is committed-absent, so no consumer can
// observe the transient absence or read/reference a row the restore deletes
// out from under it (baseline load runs flaked 3/4 with exactly this class:
// 'Consentimento ainda não configurado' in redeem tests and
// `consent_id` FK violations). The window is short (a fail-closed action or
// a no-op), so the queueing behind it is bounded — the D9-era 15s budget
// blowout happened with the Onda0 provision holding EXCLUSIVE for
// multi-second writes; since D10 the provision holds exclusive too, but
// only for ~500ms of short upserts.
// `serializeWindow: false` keeps the unleased window for operations that
// deliberately exercise concurrent consent writers. NOTE: with the window
// leased, the operation must NOT acquire the same lease itself (e.g. via
// ensureLeasedConsent on the same key) — that would self-deadlock.
const windowLease = serializeWindow
? await beginTestDatabaseLease(payload, leaseKey, 'shared')
: undefined
const windowLease = serializeWindow ? await beginTestDatabaseLease(payload, leaseKey) : undefined
// Close the gap between the setup's committed delete and this acquisition:
// a writer that queued during the setup (advisory locks grant FIFO) can
@ -653,6 +714,12 @@ export const withMutableConsentFixture = async <Result>(
let operationError: unknown
try {
// D10 F1: clear orphaned renames left by an aborted run BEFORE snapshotting
// (we hold the exclusive lease, so no live renamer exists). Autocommit,
// never inside the lease transaction — an uncommitted delete would keep
// its row lock and the fixture's own operation would self-deadlock when it
// re-uses the purged key.
await purgeOrphanedConsentRenames((query) => payload.db.drizzle.execute(query))
snapshot = rowsFrom<ConsentSnapshot>(
await lease.transaction.execute(sql`
SELECT "id", "key", "text", "updated_at", "created_at"

View file

@ -47,7 +47,7 @@ describe('campaignUser → Contact identity (C99)', () => {
})
expect(contact).toMatchObject({
name: 'Maria Assessora',
phone: user.phone,
phones: [{ value: user.phone }],
state: 'BA',
})
})
@ -84,7 +84,7 @@ describe('campaignUser → Contact identity (C99)', () => {
depth: 0,
overrideAccess: true,
})
expect(contact).toMatchObject({ name: 'Sem Telefone', phone: null, state: 'BA' })
expect(contact).toMatchObject({ name: 'Sem Telefone', phones: [], state: 'BA' })
})
it('never copies placeholder e-mails to the ficha', async () => {
@ -174,7 +174,10 @@ describe('campaignUser → Contact identity (C99)', () => {
depth: 0,
overrideAccess: true,
})
expect(contact).toMatchObject({ name: 'Nome Atualizado', phone: nextPhone })
expect(contact).toMatchObject({
name: 'Nome Atualizado',
phones: expect.arrayContaining([expect.objectContaining({ value: nextPhone })]),
})
})
it('shares the phone with another ficha when the account phone is updated (C111)', async () => {
@ -200,11 +203,13 @@ describe('campaignUser → Contact identity (C99)', () => {
depth: 0,
overrideAccess: true,
})
expect(contact.phone).toBe(other.phone)
expect(contact.phones?.map((entry) => entry.value)).toEqual(
expect.arrayContaining([user.phone, other.phone]),
)
const othersWithPhone = await payload.find({
collection: 'contact',
where: { phone: { equals: other.phone } },
where: { 'phones.value': { equals: other.phone } },
depth: 0,
limit: 2,
pagination: false,
@ -244,13 +249,22 @@ describe('campaignUser → Contact identity (C99)', () => {
it('creates a fresh ficha when the phone already belongs to two fichas (C111)', async () => {
const fixtures = campaignFixtures()
const phone = fixtures.phone()
const sharedNameA = fixtures.value('Compartilhado A')
const sharedNameB = fixtures.value('Compartilhado B')
// Two people sharing one phone is a legitimate C111 state; it is planted
// directly because the collection hook no longer enforces uniqueness.
await payload.db.drizzle.execute(
sql.raw(`
INSERT INTO "contact" ("name", "email", "phone", "state", "city")
VALUES ('${fixtures.value('Compartilhado A')}', '', '${phone}', 'BA', ''),
('${fixtures.value('Compartilhado B')}', '', '${phone}', 'BA', '')
INSERT INTO "contact" ("name", "state", "city")
VALUES ('${sharedNameA}', 'BA', ''),
('${sharedNameB}', 'BA', '')
`),
)
await payload.db.drizzle.execute(
sql.raw(`
INSERT INTO "contact_phones" ("id", "_parent_id", "_order", "value")
SELECT gen_random_uuid()::text, "id", 0, '${phone}' FROM "contact" WHERE "name" IN (
'${sharedNameA}', '${sharedNameB}')
`),
)
@ -265,11 +279,11 @@ describe('campaignUser → Contact identity (C99)', () => {
// The identity of the person being created is the fresh ficha — never a
// guess among the existing ones.
expect(contact.name).toBe(user.name)
expect(contact.phone).toBe(phone)
expect(contact.phones?.[0]?.value).toBe(phone)
const allWithPhone = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
where: { 'phones.value': { equals: phone } },
depth: 0,
limit: 0,
pagination: false,
@ -323,7 +337,7 @@ describe('campaignUser → Contact identity (C99)', () => {
const contacts = await payload.find({
collection: 'contact',
where: { phone: { equals: phone } },
where: { 'phones.value': { equals: phone } },
depth: 0,
limit: 1,
pagination: false,
@ -353,7 +367,7 @@ describe('campaignUser → Contact identity (C99)', () => {
depth: 0,
overrideAccess: true,
})
expect(contact.phone).toBe(user.phone)
expect(contact.phones?.[0]?.value).toBe(user.phone)
})
it('does not rewrite the ficha on a non-identity update (username)', async () => {
@ -377,7 +391,7 @@ describe('campaignUser → Contact identity (C99)', () => {
depth: 0,
overrideAccess: true,
})
expect(contact).toMatchObject({ name: user.name, phone: user.phone })
expect(contact).toMatchObject({ name: user.name, phones: [{ value: user.phone }] })
})
it('swaps a placeholder e-mail for the real one on the ficha in update', async () => {

Some files were not shown because too many files have changed in this diff Show more