From 8d007285d5b018a805855de4be6ee847f62ef04a Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 07:41:04 -0300 Subject: [PATCH 01/14] chore(D10): auto-cura de leftovers de consent/supporter + janela missing-consent exclusiva (12/12 sob 6 workers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1: purgeOrphanedConsentRenames sob lease exclusivo (autocommit) nos 3 pontos que tocam a key canônica; renames de teste migrados para chaves privadas; F1b: purge de contatos órfãos no purgeMunicipalityResidue. F2 (medição): shared-writer ABBA refutado; janela do missing-consent exclusiva + Onda0 atômico (down transacional) + provision exclusivo. Contraprova: 7 arquivos da key x 6 workers = 12/12 verdes (baseline 1/4). --- docs/CHANGELOG-AGENTS.md | 2 + docs/plans/escala-dry-pos-d9-impl.md | 112 +++++++++++++ tests/helpers/campaignFixtures.ts | 61 ++++++- tests/helpers/testDatabaseLease.ts | 97 +++++++++-- tests/int/campaignFixtures.int.spec.ts | 54 +++++- tests/int/campaignInvite.int.spec.ts | 4 +- tests/int/onda0Provision.int.spec.ts | 99 ++++++----- tests/int/testDatabaseLease.int.spec.ts | 209 +++++++++++++++++++++++- 8 files changed, 573 insertions(+), 65 deletions(-) create mode 100644 docs/plans/escala-dry-pos-d9-impl.md diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 76bb9dae..acc0eebd 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -1,3 +1,5 @@ +**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-`/`owned-consent-vacated-`) — 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 (10 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-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):** **OPS37** (Issue #605) — o container Postgres compartilhado **não é mais recriado** por um worktree vizinho: `pnpm db:start` virou `scripts/db-start.mjs` (CLI) sobre `scripts/lib/db-start.mjs` — preflight "container `teqo-postgres-1` Up + `healthy`/`starting` → **no-op** (compose nem roda); senão, `compose -p teqo up -d --wait --wait-timeout 120 postgres` como antes. **Causa raiz provada por medição:** o bind mount **relativo** `./docker/postgres/init` do compose resolve um path absoluto por worktree → `config_hash` diferente por worktree (OPS37 `39e89956…` / B195 `cf3cb522…` / C101 `917e11bb…`; diff do `config` = só o `source:` do bind) → qualquer `compose up` cru de outro worktree fazia `replace` e matava dev+e2e de todos (`terminating connection due to administrator command`) — o incidente se repetiu **ao vivo durante o gate**: o worktree `work/4` recriou o container no meio da suíte int (17 testes mortos; os 3 arquivos re-rodaram verdes e a suíte completa 691/691 passou em seguida). **Verificação do aceite:** `StartedAt` inalterado após `db:start` repetido, inclusive rodado de **outro worktree** (D9, hash diferente) — o caso que antes recriava. Recriação legítima (bump de imagem/config) segue por `pnpm db:start --force-recreate` (explícito; não testado live para não matar a run e2e do D9 em voo — flag é pass-through de 1 arg). `worktree.mjs` provisionamento delega para a mesma lib (mesmo risco no `worktree next`; `dockerComposeUp`/`SHARED_COMPOSE_PROJECT` órfãos removidos). Skip deliberado em `starting` (container que um peer acabou de criar): **espera** o boot ficar `healthy` (60s, polling 2s) em vez de recriar — e cai no compose up se travou; **não** skip em `unhealthy`/sem healthcheck (volta à config atual exatamente uma vez); `db:stop`/`db:doctor`/compose file/CI intactos. Unit novos (`dbStart.unit.spec.ts`, 17: matrix running×health + parse do inspect + wrapper mockado — skip não chama compose, argv do up/force-recreate, espera de `starting`). Sem migration/access/Consent/UI. Intenção [`container-postgres-compartilhado-compose-replace.md`](plans/container-postgres-compartilhado-compose-replace.md); impl [`container-postgres-compartilhado-compose-replace-impl.md`](plans/container-postgres-compartilhado-compose-replace-impl.md). diff --git a/docs/plans/escala-dry-pos-d9-impl.md b/docs/plans/escala-dry-pos-d9-impl.md new file mode 100644 index 00000000..9981af2f --- /dev/null +++ b/docs/plans/escala-dry-pos-d9-impl.md @@ -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-` (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
lista explícita + padrão temporarily-unkeyed-%] + H1 --> S1[chamado sob o lease exclusivo em
withMutableConsentFixture / withMissingInviteConsentFixture / ensureLeasedConsent] + F1b[F1b: contatos órfãos de residue] --> H2[purgeMunicipalityResidue deleta
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;
leitores continuam shared] + H3 --> S3[janela do withMissingInviteConsentFixture
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-` 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-` — 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-` (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-` (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) diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts index 81186c88..bbbc4cf4 100644 --- a/tests/helpers/campaignFixtures.ts +++ b/tests/helpers/campaignFixtures.ts @@ -233,8 +233,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 => { @@ -273,7 +274,6 @@ const purgeMunicipalityResidue = async ( where: { municipality: { equals: municipalityID } }, depth: 0, pagination: false, - select: {}, }), payload.find({ collection: 'activity', @@ -336,6 +336,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() + 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 { diff --git a/tests/helpers/testDatabaseLease.ts b/tests/helpers/testDatabaseLease.ts index 0b67ba5b..da79bfa2 100644 --- a/tests/helpers/testDatabaseLease.ts +++ b/tests/helpers/testDatabaseLease.ts @@ -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) => Promise, +): Promise => { + 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 ( } } -const withTestDatabaseLease = async ( +export const withExclusiveTestDatabaseLease = async ( payload: Payload, leaseKey: string, operation: () => Promise, @@ -394,7 +429,7 @@ const withTestDatabaseLease = async ( 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 => - 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 ( { consentKey, leaseKey }: LeasedConsentFixture, operation: (consent: Consent) => Promise, ): Promise => { + // 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 ( 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( await setupLease.transaction.execute(sql` SELECT "id", "key", "text", "updated_at", "created_at" @@ -545,21 +604,23 @@ export const withMissingInviteConsentFixture = async ( } 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 ( 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( await lease.transaction.execute(sql` SELECT "id", "key", "text", "updated_at", "created_at" diff --git a/tests/int/campaignFixtures.int.spec.ts b/tests/int/campaignFixtures.int.spec.ts index 4631a7ba..a6a8b676 100644 --- a/tests/int/campaignFixtures.int.spec.ts +++ b/tests/int/campaignFixtures.int.spec.ts @@ -5,7 +5,11 @@ import { beforeAll, describe, expect, it } from 'vitest' import type { Consent } from '@/payload-types' import config from '@/payload.config' -import { createCampaignFixtures, withCampaignFixtures } from '../helpers/campaignFixtures' +import { + createCampaignFixtures, + purgeMunicipalityResidue, + withCampaignFixtures, +} from '../helpers/campaignFixtures' import { withInviteConsent, withMutableConsentFixture } from '../helpers/testDatabaseLease' let payload: Payload @@ -40,7 +44,8 @@ const exists = async ( | 'votePledge' | 'campaignDemand' | 'organization' - | 'municipalityUpdate', + | 'municipalityUpdate' + | 'supporter', id: number, ): Promise => { const result = await payload.find({ @@ -248,10 +253,15 @@ describe('campaign integration fixtures', () => { let consentID = 0 await withMutableConsentFixture(payload, async (configuredConsent) => { + // The vacating rename uses a PRIVATE key, deliberately OUTSIDE the + // orphan-rename purge list: the canonical row is shared with parallel + // spec files and any key the purge recognizes could be deleted by a + // concurrent fixture's cleanup mid-operation (D10 F2 measurement). + const vacatedKey = `owned-consent-vacated-${configuredConsent.id}` await payload.update({ collection: 'consent', id: configuredConsent.id, - data: { key: `temporarily-unkeyed-${configuredConsent.id}` }, + data: { key: vacatedKey }, depth: 0, }) await withCampaignFixtures(payload, async (fixtures) => { @@ -397,4 +407,42 @@ describe('campaign integration fixtures', () => { }) expect(persistedMunicipality.advisors ?? []).toEqual([]) }) + + it('purges residue supporters and the contacts they orphaned on a municipality claim', async () => { + // D10 F1: an aborted run leaves supporters AND their contact rows on the + // claimed municipality. The purge must delete the orphaned contacts (no + // other join references them) while preserving contacts that a live + // leadership outside the purged municipality still references. + const fixtures = createCampaignFixtures(payload) + try { + const municipality = await fixtures.getMunicipality() + const coordinator = await fixtures.createCampaignUser('coordinator') + const orphanContact = await fixtures.createContact() + const residueSupporter = await fixtures.createSupporter({ + contact: orphanContact, + municipality, + createdBy: coordinator, + }) + const preservedContact = await fixtures.createContact() + await fixtures.createSupporter({ + contact: preservedContact, + municipality, + createdBy: coordinator, + }) + const otherMunicipality = await fixtures.getMunicipality() + await fixtures.createLeadership({ + contact: preservedContact, + municipalities: [otherMunicipality.id], + createdBy: coordinator, + }) + + await purgeMunicipalityResidue(payload, municipality.id) + + expect(await exists('supporter', residueSupporter.id)).toBe(false) + expect(await exists('contact', orphanContact.id)).toBe(false) + expect(await exists('contact', preservedContact.id)).toBe(true) + } finally { + await fixtures.cleanup() + } + }) }) diff --git a/tests/int/campaignInvite.int.spec.ts b/tests/int/campaignInvite.int.spec.ts index 1875d0dd..6f2ec591 100644 --- a/tests/int/campaignInvite.int.spec.ts +++ b/tests/int/campaignInvite.int.spec.ts @@ -1611,7 +1611,7 @@ describe('campaign invite domain', () => { await payload.update({ collection: 'consent', id: consent.id, - data: { key: 'chave-renomeada' }, + data: { key: `consent-vacated-${consent.id}` }, }) await expect( @@ -1675,7 +1675,7 @@ describe('campaign invite domain', () => { await payload.update({ collection: 'consent', id: consent.id, - data: { key: 'chave-temporariamente-ausente' }, + data: { key: `consent-vacated-${consent.id}` }, }) const submission = { token: invite.token, diff --git a/tests/int/onda0Provision.int.spec.ts b/tests/int/onda0Provision.int.spec.ts index 07281a63..0622eead 100644 --- a/tests/int/onda0Provision.int.spec.ts +++ b/tests/int/onda0Provision.int.spec.ts @@ -20,6 +20,7 @@ import { SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY, restoreConsentRows, + withExclusiveTestDatabaseLease, withSharedTestDatabaseLease, type ConsentSnapshot, } from '../helpers/testDatabaseLease' @@ -40,22 +41,18 @@ describe('Onda 0 provision (integration)', () => { }) it('upserts consent keys and publishes privacy-policy idempotently', async () => { - // Shared leases on the stable keys other spec files exercise: the - // provision UPSERTS the shared rows and the spec below REMOVES them, so - // the writes must never interleave a parallel file's reads of the same - // rows (D9 flake). SHARED — not exclusive — because this spec only - // touches the four canonical keys and an exclusive holder here widened - // the consent-lease deadlock surface with the invite-domain writers - // (cascade under parallel load). The missing-consent fail-closed paths in - // other files now serialize their own windows under the lease, and the - // down-test below restores the removed rows with their original ids, so - // the shared rows are never observed absent or re-created with a new id. - // (The fourth Onda0 key, campanha-notificacoes-push, has no lease - // constant because no spec file writes it today; the down-test below - // still restores it.) - await withSharedTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY, () => - withSharedTestDatabaseLease(payload, SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, () => - withSharedTestDatabaseLease( + // EXCLUSIVE leases on the stable keys other spec files exercise (D10 F2): + // the provision REWRITES the shared rows' texts, and a consumer that reads + // the canonical consent between two of those writes would assert against a + // text that changed under it (observed: campaignInviteUi's preview + // `requiresConsent`/`consentData` mismatch). Exclusive serializes every + // consumer for the ~500ms of the provision — bounded, unlike the pre-D10 + // cascade where the ONDA0 exclusive sat behind a queue of EXCLUSIVE + // writers. The down-test below restores the removed rows with their + // original ids atomically, so the shared rows are never observed absent. + await withExclusiveTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY, () => + withExclusiveTestDatabaseLease(payload, SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, () => + withExclusiveTestDatabaseLease( payload, SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY, async () => { @@ -113,34 +110,54 @@ describe('Onda 0 provision (integration)', () => { expect(privacyRows.rows[0]?.published).toBe(true) expect(privacyRows.rows[0]?.body).toBeTruthy() - await removeOnda0ConsentAndPrivacyDb(db) + // The down-migration must stay invisible to parallel spec files: + // delete + count + restore run in ONE transaction, so no consumer + // ever observes the stable keys committed-absent or re-created + // with a new id (D10 F2 — the non-atomic version flaked load runs + // with `consent_id` FK violations and fail-closed reads). + const transactionID = await payload.db.beginTransaction() + if (transactionID === null) { + throw new Error('Expected a PostgreSQL transaction for the Onda0 down.') + } + const transactionDb = payload.db.sessions?.[String(transactionID)]?.db as + | typeof db + | undefined + if (!transactionDb) { + await payload.db.rollbackTransaction(transactionID).catch(() => undefined) + throw new Error('The Onda0 down transaction session is unavailable.') + } + try { + await removeOnda0ConsentAndPrivacyDb(transactionDb) - const consentAfterDown = await db.execute(sql` - SELECT count(*)::integer AS count - FROM "consent" - WHERE "key" IN (${sql.join( - ONDA0_CONSENT_KEY_LIST.map((key) => sql`${key}`), - sql`, `, - )}) - `) - expect(consentAfterDown.rows[0]?.count).toBe(0) + const consentAfterDown = await transactionDb.execute(sql` + SELECT count(*)::integer AS count + FROM "consent" + WHERE "key" IN (${sql.join( + ONDA0_CONSENT_KEY_LIST.map((key) => sql`${key}`), + sql`, `, + )}) + `) + expect(consentAfterDown.rows[0]?.count).toBe(0) - const privacyAfterDown = await db.execute(sql` - SELECT "published" - FROM "privacy_policy" - LIMIT 1 - `) - expect(privacyAfterDown.rows[0]?.published).toBe(false) + const privacyAfterDown = await transactionDb.execute(sql` + SELECT "published" + FROM "privacy_policy" + LIMIT 1 + `) + expect(privacyAfterDown.rows[0]?.published).toBe(false) - // The down-migration must stay a no-op for parallel spec files: - // restoring the removed rows with their ORIGINAL ids (inside the - // exclusive lease, invisible to them) keeps the stable keys - // present with unchanged ids, so no other file's restore assertion - // can observe the absence (D9 flake). - await restoreConsentRows( - (query) => db.execute(query), - consentRows.rows as ConsentSnapshot[], - ) + // Restore the removed rows with their ORIGINAL ids inside the + // same transaction — the commit publishes delete+restore as one + // atomic state change. + await restoreConsentRows( + (query) => transactionDb.execute(query), + consentRows.rows as ConsentSnapshot[], + ) + await payload.db.commitTransaction(transactionID) + } catch (error) { + await payload.db.rollbackTransaction(transactionID).catch(() => undefined) + throw error + } }, ), ), diff --git a/tests/int/testDatabaseLease.int.spec.ts b/tests/int/testDatabaseLease.int.spec.ts index 7bb01d0b..4231385c 100644 --- a/tests/int/testDatabaseLease.int.spec.ts +++ b/tests/int/testDatabaseLease.int.spec.ts @@ -10,6 +10,8 @@ import { acquireTestDatabaseLease, CAMPAIGN_INVITE_CONSENT_LEASE_KEY, ensureLeasedConsent, + fixtureConsentText, + purgeOrphanedConsentRenames, startTestDatabaseLeaseAcquisition, waitForAdvisoryLockWaiter, withInviteConsent, @@ -182,6 +184,7 @@ describe('test database lease', () => { const before = await withInviteConsent(payload, async (consent) => consent) let writerAcquisition: Promise | undefined + let readerAcquisition: Promise | undefined try { await withMissingInviteConsentFixture(payload, async () => { let writerEntered = false @@ -202,14 +205,44 @@ describe('test database lease', () => { }) expectExactWaiter(waiting, writerPID, 'ExclusiveLock') expect(writerEntered).toBe(false) + + // D10 F2: the window is EXCLUSIVE, so even a SHARED consumer queues + // while the canonical key is committed-absent — no consumer can + // observe the transient absence (baseline load runs flaked 3/4 with + // consumers reading the absent key or referencing a row the restore + // deleted out from under them). The reader is NOT awaited here: it + // only unblocks when this window releases, which is after the + // operation returns. + let readerEntered = false + const pendingReader = startTestDatabaseLeaseAcquisition( + payload, + CAMPAIGN_INVITE_CONSENT_LEASE_KEY, + 'shared', + ) + readerAcquisition = pendingReader.acquisition.then((lease) => { + readerEntered = true + return lease + }) + const readerPID = await pendingReader.backendPID + const readerWaiting = await waitForAdvisoryLockWaiter(payload, { + key: `test:${CAMPAIGN_INVITE_CONSENT_LEASE_KEY}`, + mode: 'ShareLock', + waiterPID: readerPID, + }) + expectExactWaiter(readerWaiting, readerPID, 'ShareLock') + expect(readerEntered).toBe(false) }) } finally { - // Release the waiter lease even when an assertion above fails, so the - // pending acquisition never blocks parallel spec files until teardown. + // Release the waiter leases even when an assertion above fails, so the + // pending acquisitions never block parallel spec files until teardown. if (writerAcquisition) { const writer = await writerAcquisition await writer.release() } + if (readerAcquisition) { + const reader = await readerAcquisition + await reader.release() + } } const after = await withInviteConsent(payload, async (consent) => consent) @@ -473,4 +506,176 @@ describe('test database lease', () => { const subsequent = await acquireTestDatabaseLease(payload, leaseKey) await subsequent.release() }) + + it('purges orphaned consent renames left by an aborted run on ensure', async () => { + // D10 F1: every consent fixture must clear the rename shapes an aborted + // run can leave behind before snapshotting, or the next run's rename hits + // the UNIQUE constraint. Legitimate rows (the private key below) survive. + const privateKey = `purge-ensure-${Date.now()}-${Math.random()}` + const privateLeaseKey = `purge-ensure-lease-${Date.now()}-${Math.random()}` + const legitKey = `purge-legit-${Date.now()}-${Math.random()}` + const orphanIDs: number[] = [] + let legitID = 0 + // The orphans are planted under the key's EXCLUSIVE lease: the rename + // keys are LIVE inside other spec files' fixtures, and an unleased + // create could hit their UNIQUE constraint mid-rename (D10 F2 load + // measurement). The lease start also purges any leftover from a previous + // failed run of this very test, so the plant never collides with it. + const plantLease = await acquireTestDatabaseLease(payload, privateLeaseKey) + try { + await purgeOrphanedConsentRenames((query) => payload.db.drizzle.execute(query)) + for (const key of [ + 'temporarily-renamed-consent', + 'chave-renomeada', + 'chave-temporariamente-ausente', + ]) { + const orphan = await payload.create({ + collection: 'consent', + data: { key, text: fixtureConsentText }, + depth: 0, + }) + orphanIDs.push(orphan.id) + } + const legit = await payload.create({ + collection: 'consent', + data: { key: legitKey, text: fixtureConsentText }, + depth: 0, + }) + legitID = legit.id + } finally { + await plantLease.release() + } + + try { + const configured = await ensureLeasedConsent(payload, { + consentKey: privateKey, + leaseKey: privateLeaseKey, + }) + expect(configured.key).toBe(privateKey) + + for (const orphanID of orphanIDs) { + const orphan = await payload.find({ + collection: 'consent', + where: { id: { equals: orphanID } }, + depth: 0, + limit: 1, + }) + expect(orphan.docs).toHaveLength(0) + } + expect( + await payload.findByID({ collection: 'consent', id: legitID, depth: 0 }), + ).toMatchObject({ key: legitKey }) + } finally { + // Cleanup runs even on failure — a failed run of this very test must + // not leave the orphans it planted behind (self-healing tests). + for (const orphanID of orphanIDs) { + await payload + .delete({ collection: 'consent', id: orphanID, depth: 0 }) + .catch(() => undefined) + } + if (legitID > 0) { + await payload + .delete({ collection: 'consent', id: legitID, depth: 0 }) + .catch(() => undefined) + } + } + }) + + it('purges orphaned renames during the missing-consent fixture setup', async () => { + const privateKey = `purge-missing-${Date.now()}-${Math.random()}` + const privateLeaseKey = `purge-missing-lease-${Date.now()}-${Math.random()}` + // Planted under the exclusive lease (the rename keys are live inside + // other spec files' fixtures — an unleased create could hit their UNIQUE + // constraint mid-rename). The lease start also purges any leftover from + // a previous failed run of this very test. + const plantLease = await acquireTestDatabaseLease(payload, privateLeaseKey) + let orphanID = 0 + try { + await purgeOrphanedConsentRenames((query) => payload.db.drizzle.execute(query)) + orphanID = ( + await payload.create({ + collection: 'consent', + data: { key: 'chave-temporariamente-ausente', text: fixtureConsentText }, + depth: 0, + }) + ).id + } finally { + await plantLease.release() + } + + await withMissingInviteConsentFixture(payload, async () => undefined, { + consentKey: privateKey, + leaseKey: privateLeaseKey, + }) + + try { + const orphan = await payload.find({ + collection: 'consent', + where: { id: { equals: orphanID } }, + depth: 0, + limit: 1, + }) + expect(orphan.docs).toHaveLength(0) + } finally { + // A failed run of this very test must not leave its plant behind. + await payload.delete({ collection: 'consent', id: orphanID, depth: 0 }).catch(() => undefined) + } + }) + + it('self-heals the canonical invite consent after an aborted rename on the next fixture run', async () => { + // The exact residue an aborted run leaves: the canonical row committed + // with a temporary key and the snapshot lost. The plant holds the + // exclusive lease so parallel spec files never observe the state; the + // fixture's own purge then heals it — the in-fixture rename must succeed + // instead of hitting the UNIQUE `consent_key_idx`. The lease start also + // purges any leftover from a previous failed run of this very test. + const plantLease = await acquireTestDatabaseLease(payload, CAMPAIGN_INVITE_CONSENT_LEASE_KEY) + try { + await purgeOrphanedConsentRenames((query) => payload.db.drizzle.execute(query)) + const canonical = await payload.find({ + collection: 'consent', + where: { key: { equals: 'lideranca-autopreenchimento' } }, + limit: 1, + sort: 'id', + depth: 0, + }) + if (!canonical.docs[0]) { + throw new Error( + 'The canonical invite consent row must exist before the aborted-rename repro.', + ) + } + await payload.update({ + collection: 'consent', + id: canonical.docs[0].id, + data: { key: 'chave-renomeada' }, + depth: 0, + }) + } finally { + await plantLease.release() + } + + let renamedDuringFixture = false + await withMutableConsentFixture(payload, async (consent) => { + // Without the F1 purge this rename throws "Valor deve ser único". + await payload.update({ + collection: 'consent', + id: consent.id, + data: { key: 'chave-renomeada' }, + depth: 0, + }) + renamedDuringFixture = true + }) + expect(renamedDuringFixture).toBe(true) + + // Second run proves the run following the healed one stays clean. + await withMutableConsentFixture(payload, async () => undefined) + + const orphans = await payload.find({ + collection: 'consent', + where: { key: { equals: 'chave-renomeada' } }, + depth: 0, + pagination: false, + }) + expect(orphans.totalDocs).toBe(0) + }) }) From 6de8fad8f67b51f0f560d023344ef9f1f194038c Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 10:47:02 -0300 Subject: [PATCH 02/14] =?UTF-8?q?feat(C112):=20m=C3=BAltiplos=20telefones?= =?UTF-8?q?=20por=20pessoa=20=E2=80=94=20Contact.phones=20array=20(ordem?= =?UTF-8?q?=20=3D=20prioridade)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Contact.phone (text único) → phones (array field Payload, subcampo value): fonte única; principal = phones[0]; beforeValidate normaliza, filtra vazios, rejeita inválido e duplicado DENTRO da ficha (C111 entre fichas preservado) - migration add_contact_phones_array: backfill phone → phones[0] (gen_random_uuid) antes do drop da coluna + índice em value (hand-edit documentado; snapshot .json gerado mantém as colunas C99/C100 já existentes) - primaryPhoneOf / phoneValuesOf / reorderWithPrimaryPhone em src/lib/phone.ts (ordem = prioridade; inline edit = seta o principal preservando o resto) - findOrCreateContactByPhone aceita phones[] (dedupe pelo primário; reuse faz append dos faltantes; locks em todos os números digitados) - sync conta→ficha (CampaignUser): phone da conta faz APPEND quando a ficha não tem (nunca reordena nem remove); tombstone LGPD substitui TODOS os telefones - convite redemption: telefone digitado vira principal, demais preservados - import CSV: preview sinaliza compartilhado também por número secundário; bulk insere contact_phones (JOIN nas leituras, uuid no insert) - busca por telefone (apoiadores, pickers, combobox) casa qualquer número - UI: PhonesFieldEditor compartilhado (repetível mascarado, remover, ↑/↓, modo inline com Salvar) nas 5 superfícies — liderança (form/ficha/lista), apoiador (form/ficha), dobradinha (ficha), assessor (ficha), wizard - UI pública (petição/WhatsApp) intocada: zod e forms ficam com phone; as ações mapeiam phone → phones[0] - testes: spec novo campaignMultiPhones (aceite completo), specs C111/C99 atualizados, e2e wizard/apoiador ajustados --- .../multiplos-telefones-por-pessoa-impl.md | 71 + scripts/seed-minimal.mjs | 16 +- .../acoes/wizardLeadershipFormActions.ts | 9 +- .../(app)/apoiadores/[id]/formActions.ts | 33 +- .../campanha/(app)/apoiadores/[id]/page.tsx | 16 +- .../(app)/apoiadores/novo/formActions.ts | 15 +- .../campanha/(app)/assessores/[id]/page.tsx | 30 +- .../campanha/(app)/assessores/formActions.ts | 22 + .../(app)/atividades/contactSearchActions.ts | 11 +- .../campanha/(app)/dobradinhas/[id]/page.tsx | 2 +- .../campanha/(app)/dobradinhas/formActions.ts | 7 + .../campanha/(app)/liderancas/[id]/page.tsx | 2 +- .../campanha/(app)/liderancas/formActions.ts | 7 + .../(app)/liderancas/nova/formActions.ts | 9 +- .../(campaign)/campanha/actions/advisor.ts | 24 + .../campanha/actions/leaderSupporter.ts | 17 +- .../(campaign)/campanha/actions/leadership.ts | 32 +- .../campanha/actions/stateDeputy.ts | 23 +- .../(campaign)/campanha/actions/supporter.ts | 60 +- .../campanha/actions/supporterImport.ts | 23 +- .../actions/submitPetitionSignature.ts | 8 +- src/app/(frontend)/actions/submitWhatsapp.ts | 8 +- src/collections/CampaignUser.ts | 30 +- src/collections/Contact.ts | 94 +- .../campaign/leadership/LeaderContactForm.tsx | 34 +- .../leadership/LeadershipContactSection.tsx | 20 +- .../campaign/leadership/LeadershipForm.tsx | 22 +- .../leadership/WizardLeadershipForm.tsx | 24 +- .../campaign/shared/PhonesFieldEditor.tsx | 285 + .../stateDeputy/StateDeputyContactSection.tsx | 21 +- .../campaign/supporter/SupporterForm.tsx | 30 +- src/lib/formData.ts | 18 + src/lib/phone.ts | 35 + src/lib/schemas/contact.ts | 20 + src/lib/schemas/leadership.ts | 7 +- src/lib/schemas/supporter.ts | 6 +- src/lib/wizardLeadershipContract.ts | 2 + ...60811_111828_add_contact_phones_array.json | 13337 ++++++++++++++++ ...0260811_111828_add_contact_phones_array.ts | 41 + src/migrations/index.ts | 6 + src/payload-types.ts | 14 +- src/utilities/activityLeadershipOptions.ts | 2 +- src/utilities/activityResponsibleSearch.ts | 2 +- src/utilities/activityViewModels.ts | 3 +- src/utilities/advisorData.ts | 28 +- src/utilities/campaignInviteCreation.ts | 6 +- src/utilities/campaignInvitePageData.ts | 5 +- src/utilities/campaignInviteRedemption.ts | 22 +- src/utilities/contactIdentity.ts | 58 +- .../homeSearch/searchHomeLeaderships.ts | 7 +- src/utilities/leaderContactsPageData.ts | 3 +- src/utilities/leadership/leadershipData.ts | 22 +- src/utilities/people/peopleData.ts | 3 +- src/utilities/people/personDelete.ts | 7 +- src/utilities/signatureExport.ts | 3 +- src/utilities/stateDeputyData.ts | 11 +- .../supporter/supporterImportBulk.ts | 40 +- .../supporter/supporterListFilters.ts | 5 +- .../supporter/supporterListSqlFilters.ts | 9 +- .../supporter/supporterViewModels.ts | 10 +- tests/e2e/campaignHomeActions.e2e.spec.ts | 2 +- tests/e2e/campaignMunicipalities.e2e.spec.ts | 4 +- tests/e2e/fixtures/campaignE2EFixtures.ts | 2 +- tests/helpers/campaignFixtures.ts | 20 +- tests/int/campaignContactIdentity.int.spec.ts | 40 +- tests/int/campaignFixtures.int.spec.ts | 6 +- tests/int/campaignInvite.int.spec.ts | 25 +- tests/int/campaignInviteUi.int.spec.ts | 10 +- tests/int/campaignLeadership.int.spec.ts | 65 +- .../int/campaignLeadershipActions.int.spec.ts | 26 +- tests/int/campaignMultiPhones.int.spec.ts | 458 + ...aignMunicipalityLeadershipList.int.spec.ts | 6 +- ...nMunicipalityStateDeputyCreate.int.spec.ts | 8 +- tests/int/campaignSupporter.int.spec.ts | 36 +- tests/int/campaignUserPhone.int.spec.ts | 1 - .../int/collectionAccessLockdown.int.spec.ts | 6 +- .../int/contactPhoneNormalization.int.spec.ts | 30 +- tests/int/personDelete.int.spec.ts | 3 +- tests/int/submitPetitionSignature.int.spec.ts | 2 +- tests/int/submitWhatsapp.int.spec.ts | 2 +- tests/int/supporterListFilters.int.spec.ts | 2 +- tests/unit/signatureExport.unit.spec.ts | 4 +- tests/unit/supporterSchema.unit.spec.ts | 10 +- 83 files changed, 15116 insertions(+), 389 deletions(-) create mode 100644 docs/plans/multiplos-telefones-por-pessoa-impl.md create mode 100644 src/components/campaign/shared/PhonesFieldEditor.tsx create mode 100644 src/migrations/20260811_111828_add_contact_phones_array.json create mode 100644 src/migrations/20260811_111828_add_contact_phones_array.ts create mode 100644 tests/int/campaignMultiPhones.int.spec.ts diff --git a/docs/plans/multiplos-telefones-por-pessoa-impl.md b/docs/plans/multiplos-telefones-por-pessoa-impl.md new file mode 100644 index 00000000..b7e70624 --- /dev/null +++ b/docs/plans/multiplos-telefones-por-pessoa-impl.md @@ -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 diff --git a/scripts/seed-minimal.mjs b/scripts/seed-minimal.mjs index 60d97938..73c6cb5d 100644 --- a/scripts/seed-minimal.mjs +++ b/scripts/seed-minimal.mjs @@ -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) diff --git a/src/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions.ts b/src/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions.ts index dea6c4d2..fc94467d 100644 --- a/src/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/acoes/wizardLeadershipFormActions.ts @@ -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), diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts index 6ff4c709..eb0635e9 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts @@ -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): { 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 => + 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.', + }) diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx index 7b6f2bac..5ada0bee 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx @@ -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 Dados de contato -

{supporter.phoneDisplay || '—'}

+ {supporter.email ?

{supporter.email}

: null}

{supporter.municipalityName ? ( diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts index 777b905d..1e62292b 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/formActions.ts @@ -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 } : {}), diff --git a/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx index a11b028f..d698d1da 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx @@ -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 )} +

+

+ Telefones da pessoa +

+

+ Todos os números da ficha — o primeiro é o principal (listas, WhatsApp e convites). +

+ {advisor.contactID !== null ? ( + + ) : ( +

Ficha de contato ainda não vinculada.

+ )} +
+

Acesso diff --git a/src/app/(campaign)/campanha/(app)/assessores/formActions.ts b/src/app/(campaign)/campanha/(app)/assessores/formActions.ts index bc59f427..83afa3d8 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/assessores/formActions.ts @@ -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 => + 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, diff --git a/src/app/(campaign)/campanha/(app)/atividades/contactSearchActions.ts b/src/app/(campaign)/campanha/(app)/atividades/contactSearchActions.ts index 44d7e7aa..c708e250 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/contactSearchActions.ts +++ b/src/app/(campaign)/campanha/(app)/atividades/contactSearchActions.ts @@ -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 ( diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/[id]/page.tsx index ed5f5538..b2087d34 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/[id]/page.tsx @@ -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 ? (

{stateDeputy.notes}

diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts index 88e25e7a..ac66627d 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/formActions.ts @@ -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) } diff --git a/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx index 7da9f977..c63de589 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx @@ -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 ? ( diff --git a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts index f1833ac1..8d9d409b 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts @@ -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) } diff --git a/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts index bad50f50..3374efc7 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/liderancas/nova/formActions.ts @@ -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'), diff --git a/src/app/(campaign)/campanha/actions/advisor.ts b/src/app/(campaign)/campanha/actions/advisor.ts index 4149f3fe..790aa2aa 100644 --- a/src/app/(campaign)/campanha/actions/advisor.ts +++ b/src/app/(campaign)/campanha/actions/advisor.ts @@ -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, diff --git a/src/app/(campaign)/campanha/actions/leaderSupporter.ts b/src/app/(campaign)/campanha/actions/leaderSupporter.ts index d2da6385..a6d6f2a6 100644 --- a/src/app/(campaign)/campanha/actions/leaderSupporter.ts +++ b/src/app/(campaign)/campanha/actions/leaderSupporter.ts @@ -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 => { 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, diff --git a/src/app/(campaign)/campanha/actions/leadership.ts b/src/app/(campaign)/campanha/actions/leadership.ts index be8b4f16..ace7df07 100644 --- a/src/app/(campaign)/campanha/actions/leadership.ts +++ b/src/app/(campaign)/campanha/actions/leadership.ts @@ -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 -type ValidatedLeadershipCreateData = Omit & { - phone?: string | null +type ValidatedLeadershipCreateData = Omit & { + 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> = {} + const contactData: Partial> & { + 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, diff --git a/src/app/(campaign)/campanha/actions/stateDeputy.ts b/src/app/(campaign)/campanha/actions/stateDeputy.ts index 9aca7413..7db83759 100644 --- a/src/app/(campaign)/campanha/actions/stateDeputy.ts +++ b/src/app/(campaign)/campanha/actions/stateDeputy.ts @@ -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> = {} + const contactData: Partial> & { + 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. diff --git a/src/app/(campaign)/campanha/actions/supporter.ts b/src/app/(campaign)/campanha/actions/supporter.ts index 0e2f62db..f502500d 100644 --- a/src/app/(campaign)/campanha/actions/supporter.ts +++ b/src/app/(campaign)/campanha/actions/supporter.ts @@ -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 => 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, diff --git a/src/app/(campaign)/campanha/actions/supporterImport.ts b/src/app/(campaign)/campanha/actions/supporterImport.ts index a8e5ca44..ed6d5fc2 100644 --- a/src/app/(campaign)/campanha/actions/supporterImport.ts +++ b/src/app/(campaign)/campanha/actions/supporterImport.ts @@ -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() const sharedPhones = new Set() 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()] diff --git a/src/app/(frontend)/actions/submitPetitionSignature.ts b/src/app/(frontend)/actions/submitPetitionSignature.ts index f4205879..03a8f7d5 100644 --- a/src/app/(frontend)/actions/submitPetitionSignature.ts +++ b/src/app/(frontend)/actions/submitPetitionSignature.ts @@ -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 }, }) diff --git a/src/app/(frontend)/actions/submitWhatsapp.ts b/src/app/(frontend)/actions/submitWhatsapp.ts index 807d457e..54126aed 100644 --- a/src/app/(frontend)/actions/submitWhatsapp.ts +++ b/src/app/(frontend)/actions/submitWhatsapp.ts @@ -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 }, }) diff --git a/src/collections/CampaignUser.ts b/src/collections/CampaignUser.ts index d64a2844..192e541b 100644 --- a/src/collections/CampaignUser.ts +++ b/src/collections/CampaignUser.ts @@ -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 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 diff --git a/src/collections/Contact.ts b/src/collections/Contact.ts index 180de13d..743e3e7f 100644 --- a/src/collections/Contact.ts +++ b/src/collections/Contact.ts @@ -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() + const normalized: ContactPhoneValue[] = [] + + for (const entry of phones) { + if (entry === null || entry === undefined) continue + const raw = + typeof entry === 'object' && 'value' in (entry as Record) + ? (entry as Record).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', diff --git a/src/components/campaign/leadership/LeaderContactForm.tsx b/src/components/campaign/leadership/LeaderContactForm.tsx index 534f5f4a..299b3bf9 100644 --- a/src/components/campaign/leadership/LeaderContactForm.tsx +++ b/src/components/campaign/leadership/LeaderContactForm.tsx @@ -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} - - Celular * - - {phoneField.error ? ( - {phoneField.error} - ) : null} - + Nome * @@ -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} /> diff --git a/src/components/campaign/leadership/LeadershipContactSection.tsx b/src/components/campaign/leadership/LeadershipContactSection.tsx index 232ac990..76ff1ed6 100644 --- a/src/components/campaign/leadership/LeadershipContactSection.tsx +++ b/src/components/campaign/leadership/LeadershipContactSection.tsx @@ -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) => (

@@ -50,16 +52,16 @@ export const LeadershipContactSection = ({
-
Celular
+
+ Celulares (o primeiro é o principal) +
-
diff --git a/src/components/campaign/leadership/LeadershipForm.tsx b/src/components/campaign/leadership/LeadershipForm.tsx index 37fb9071..f1952658 100644 --- a/src/components/campaign/leadership/LeadershipForm.tsx +++ b/src/components/campaign/leadership/LeadershipForm.tsx @@ -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 = ({
- - Celular (com DDD) - - {fieldError(state.fieldErrors, 'phone') ? ( - {fieldError(state.fieldErrors, 'phone')} - ) : null} - + E-mail (opcional) diff --git a/src/components/campaign/leadership/WizardLeadershipForm.tsx b/src/components/campaign/leadership/WizardLeadershipForm.tsx index 99235210..02288c42 100644 --- a/src/components/campaign/leadership/WizardLeadershipForm.tsx +++ b/src/components/campaign/leadership/WizardLeadershipForm.tsx @@ -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 = ({
- - Celular - - {fieldError(state.fieldErrors, 'phone') ? ( - {fieldError(state.fieldErrors, 'phone')} - ) : null} - + E-mail `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 + recordId?: number + recordIdField?: string + saveLabel?: string + cancelLabel?: string + className?: string +}) => { + const router = useRouter() + const [rows, setRows] = useState(() => { + 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(null) + const [isEditing, setIsEditing] = useState(!saveAction) + const rowsRef = useRef(rows) + rowsRef.current = rows + const appliedDefaultsRef = useRef(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 => { + 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) => ( +
+ updateRow(row.key, event.currentTarget.value)} + /> + {rows.length > 1 ? ( + <> + + + + ) : null} + +
+ )) + + const addButton = ( + + ) + + if (saveAction && !isEditing) { + return ( +
+
    + {(rows.length ? rows : [{ key: EMPTY_ROW_KEY, value: '' }]).map((row, index) => ( +
  • + {index + 1}. + + {row.value ? formatBrazilianPhoneInput(row.value) : '—'} + +
  • + ))} +
+ +
+ ) + } + + return ( +
+ {label} +
+ {rowInputs} + {addButton} +
+ {hasError ? ( + + {saveError ?? (Array.isArray(error) ? error[0] : error)} + + ) : null} + {saveAction ? ( +
+ + + {isPending ? : null} +
+ ) : null} +
+ ) +} diff --git a/src/components/campaign/stateDeputy/StateDeputyContactSection.tsx b/src/components/campaign/stateDeputy/StateDeputyContactSection.tsx index 3ca19172..f7671792 100644 --- a/src/components/campaign/stateDeputy/StateDeputyContactSection.tsx +++ b/src/components/campaign/stateDeputy/StateDeputyContactSection.tsx @@ -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) => (

@@ -31,6 +33,7 @@ export const StateDeputyContactSection = ({ value={name} label="Nome" formAction={updateStateDeputyContactFormAction} + href={`/campanha/dobradinhas/${stateDeputyId}`} />

@@ -44,19 +47,21 @@ export const StateDeputyContactSection = ({ value={email} label="E-mail" formAction={updateStateDeputyContactFormAction} + readBehavior="copy" />
-
Telefone
+
+ Telefones (o primeiro é o principal) +
-
diff --git a/src/components/campaign/supporter/SupporterForm.tsx b/src/components/campaign/supporter/SupporterForm.tsx index 4673a924..f17c68d5 100644 --- a/src/components/campaign/supporter/SupporterForm.tsx +++ b/src/components/campaign/supporter/SupporterForm.tsx @@ -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 = ({ - - Celular * - - {phoneField.error ? ( - {phoneField.error} - ) : null} - + E-mail diff --git a/src/lib/formData.ts b/src/lib/formData.ts index b1981455..946170e5 100644 --- a/src/lib/formData.ts +++ b/src/lib/formData.ts @@ -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 diff --git a/src/lib/phone.ts b/src/lib/phone.ts index d3e01267..68a129b5 100644 --- a/src/lib/phone.ts +++ b/src/lib/phone.ts @@ -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) +} diff --git a/src/lib/schemas/contact.ts b/src/lib/schemas/contact.ts index e0aec974..768fd0e4 100644 --- a/src/lib/schemas/contact.ts +++ b/src/lib/schemas/contact.ts @@ -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 diff --git a/src/lib/schemas/leadership.ts b/src/lib/schemas/leadership.ts index d6445bb9..230ccfdc 100644 --- a/src/lib/schemas/leadership.ts +++ b/src/lib/schemas/leadership.ts @@ -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 { + 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 { + // 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;`) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 0081665e..a6d78dfe 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -47,6 +47,7 @@ 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'; export const migrations = [ { @@ -294,4 +295,9 @@ 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' + }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index d2d53137..3b72eaa0 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -298,7 +298,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' @@ -1977,7 +1982,12 @@ export interface PetitionSelect { export interface ContactSelect { name?: T; email?: T; - phone?: T; + phones?: + | T + | { + value?: T; + id?: T; + }; gender?: T; state?: T; city?: T; diff --git a/src/utilities/activityLeadershipOptions.ts b/src/utilities/activityLeadershipOptions.ts index 7d7c61d4..748eede2 100644 --- a/src/utilities/activityLeadershipOptions.ts +++ b/src/utilities/activityLeadershipOptions.ts @@ -42,7 +42,7 @@ export const searchActivityLeadershipOptions = async ( { or: [ { 'contact.name': { contains: trimmed } }, - { 'contact.phone': { contains: digits || trimmed } }, + { 'contact.phones.value': { contains: digits || trimmed } }, ], }, ], diff --git a/src/utilities/activityResponsibleSearch.ts b/src/utilities/activityResponsibleSearch.ts index df9fcb5d..deb90368 100644 --- a/src/utilities/activityResponsibleSearch.ts +++ b/src/utilities/activityResponsibleSearch.ts @@ -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, diff --git a/src/utilities/activityViewModels.ts b/src/utilities/activityViewModels.ts index 9832d8e8..de005c8c 100644 --- a/src/utilities/activityViewModels.ts +++ b/src/utilities/activityViewModels.ts @@ -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, diff --git a/src/utilities/advisorData.ts b/src/utilities/advisorData.ts index c0258856..57667df0 100644 --- a/src/utilities/advisorData.ts +++ b/src/utilities/advisorData.ts @@ -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) ?? [], } } diff --git a/src/utilities/campaignInviteCreation.ts b/src/utilities/campaignInviteCreation.ts index c0742b1f..bc5e08d2 100644 --- a/src/utilities/campaignInviteCreation.ts +++ b/src/utilities/campaignInviteCreation.ts @@ -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, diff --git a/src/utilities/campaignInvitePageData.ts b/src/utilities/campaignInvitePageData.ts index 622139d0..b8fee5f7 100644 --- a/src/utilities/campaignInvitePageData.ts +++ b/src/utilities/campaignInvitePageData.ts @@ -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, }, diff --git a/src/utilities/campaignInviteRedemption.ts b/src/utilities/campaignInviteRedemption.ts index c67312c6..47667137 100644 --- a/src/utilities/campaignInviteRedemption.ts +++ b/src/utilities/campaignInviteRedemption.ts @@ -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, diff --git a/src/utilities/contactIdentity.ts b/src/utilities/contactIdentity.ts index 9cafaa03..34d6bbf2 100644 --- a/src/utilities/contactIdentity.ts +++ b/src/utilities/contactIdentity.ts @@ -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, diff --git a/src/utilities/homeSearch/searchHomeLeaderships.ts b/src/utilities/homeSearch/searchHomeLeaderships.ts index 4a2a02c2..64feb02c 100644 --- a/src/utilities/homeSearch/searchHomeLeaderships.ts +++ b/src/utilities/homeSearch/searchHomeLeaderships.ts @@ -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[] => { diff --git a/src/utilities/leaderContactsPageData.ts b/src/utilities/leaderContactsPageData.ts index f45fc8e6..97779ba9 100644 --- a/src/utilities/leaderContactsPageData.ts +++ b/src/utilities/leaderContactsPageData.ts @@ -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, diff --git a/src/utilities/leadership/leadershipData.ts b/src/utilities/leadership/leadershipData.ts index b086bf9e..a2c469f4 100644 --- a/src/utilities/leadership/leadershipData.ts +++ b/src/utilities/leadership/leadershipData.ts @@ -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), diff --git a/src/utilities/people/peopleData.ts b/src/utilities/people/peopleData.ts index 8ca04724..c8edfeaa 100644 --- a/src/utilities/people/peopleData.ts +++ b/src/utilities/people/peopleData.ts @@ -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, } diff --git a/src/utilities/people/personDelete.ts b/src/utilities/people/personDelete.ts index d59b703f..1061e5d7 100644 --- a/src/utilities/people/personDelete.ts +++ b/src/utilities/people/personDelete.ts @@ -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, diff --git a/src/utilities/signatureExport.ts b/src/utilities/signatureExport.ts index e45e2be8..9a88d7e2 100644 --- a/src/utilities/signatureExport.ts +++ b/src/utilities/signatureExport.ts @@ -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 diff --git a/src/utilities/stateDeputyData.ts b/src/utilities/stateDeputyData.ts index be1e3574..95919ec2 100644 --- a/src/utilities/stateDeputyData.ts +++ b/src/utilities/stateDeputyData.ts @@ -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, diff --git a/src/utilities/supporter/supporterImportBulk.ts b/src/utilities/supporter/supporterImportBulk.ts index d5b3b322..d31847fa 100644 --- a/src/utilities/supporter/supporterImportBulk.ts +++ b/src/utilities/supporter/supporterImportBulk.ts @@ -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> => 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): number | null => { return Number.isSafeInteger(parsed) ? parsed : null } +const asCreatedContactID = (row: Record): 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> = [] + 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) } } } diff --git a/src/utilities/supporter/supporterListFilters.ts b/src/utilities/supporter/supporterListFilters.ts index fbc15fa7..f8528e96 100644 --- a/src/utilities/supporter/supporterListFilters.ts +++ b/src/utilities/supporter/supporterListFilters.ts @@ -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 } diff --git a/src/utilities/supporter/supporterListSqlFilters.ts b/src/utilities/supporter/supporterListSqlFilters.ts index 4c9300d0..722ad1f7 100644 --- a/src/utilities/supporter/supporterListSqlFilters.ts +++ b/src/utilities/supporter/supporterListSqlFilters.ts @@ -20,9 +20,14 @@ const buildSqlSearchCondition = (terms: SupporterSearchTerms): ReturnType { await expect(page.getByRole('heading', { name: 'Nova liderança' })).toBeVisible() 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({ diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index d865848b..b613c04c 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -641,7 +641,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, }, @@ -702,7 +702,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() diff --git a/tests/e2e/fixtures/campaignE2EFixtures.ts b/tests/e2e/fixtures/campaignE2EFixtures.ts index 64f82b26..6407ce5a 100644 --- a/tests/e2e/fixtures/campaignE2EFixtures.ts +++ b/tests/e2e/fixtures/campaignE2EFixtures.ts @@ -185,7 +185,7 @@ class CampaignE2EOwnership { collection: 'contact', data: { name: contactName, - phone: this.phone(), + phones: [{ value: this.phone() }], state: 'BA', city: municipalities[0]?.name ?? 'Salvador', }, diff --git a/tests/helpers/campaignFixtures.ts b/tests/helpers/campaignFixtures.ts index 81186c88..3ada076b 100644 --- a/tests/helpers/campaignFixtures.ts +++ b/tests/helpers/campaignFixtures.ts @@ -62,7 +62,9 @@ type EditorUserInput = { } type ConsentInput = Partial> type ContactInput = Partial< - Pick + Pick & { + phones: Array<{ value: string }> + } > type LeadershipInput = Partial< Pick< @@ -544,7 +546,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 +658,12 @@ export class CampaignFixtures { } async createContact(input: ContactInput = {}): Promise { + 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 +671,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 +892,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) } diff --git a/tests/int/campaignContactIdentity.int.spec.ts b/tests/int/campaignContactIdentity.int.spec.ts index 3a7a61e1..025d22e8 100644 --- a/tests/int/campaignContactIdentity.int.spec.ts +++ b/tests/int/campaignContactIdentity.int.spec.ts @@ -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 () => { diff --git a/tests/int/campaignFixtures.int.spec.ts b/tests/int/campaignFixtures.int.spec.ts index 4631a7ba..2185a945 100644 --- a/tests/int/campaignFixtures.int.spec.ts +++ b/tests/int/campaignFixtures.int.spec.ts @@ -217,7 +217,7 @@ describe('campaign integration fixtures', () => { collection: 'contact', data: { name: fixtures.value('Contato criado pelo servidor E2E'), - phone: fixtures.phone(), + phones: [{ value: fixtures.phone() }], state: 'BA', city: 'Salvador', }, @@ -276,14 +276,14 @@ describe('campaign integration fixtures', () => { const findOrCreateSentinelContact = async (name: string, phone: string) => { const existing = await payload.find({ collection: 'contact', - where: { phone: { equals: phone } }, + where: { 'phones.value': { equals: phone } }, depth: 0, limit: 1, }) if (existing.docs[0]) return existing.docs[0] return payload.create({ collection: 'contact', - data: { name, phone, state: 'BA', city: 'Salvador' }, + data: { name, phones: [{ value: phone }], state: 'BA', city: 'Salvador' }, depth: 0, }) } diff --git a/tests/int/campaignInvite.int.spec.ts b/tests/int/campaignInvite.int.spec.ts index 1875d0dd..7ffaa6b8 100644 --- a/tests/int/campaignInvite.int.spec.ts +++ b/tests/int/campaignInvite.int.spec.ts @@ -194,7 +194,6 @@ const createInviteLeadershipGraph = async ( ) => { const contact = await campaignFixtures().createContact({ name: campaignFixtures().value('Liderança convite'), - phone: campaignFixtures().phone(), }) const leadership = await campaignFixtures().createLeadership({ contact, @@ -960,7 +959,7 @@ describe('campaign invite domain', () => { }) expect(updatedContact).toMatchObject({ name: 'Maria Confirmada', - phone: submission.phone, + phones: expect.arrayContaining([expect.objectContaining({ value: submission.phone })]), email: 'confirmada@example.com', gender: 'feminino', }) @@ -1063,10 +1062,14 @@ describe('campaign invite domain', () => { await expect( payload.findByID({ collection: 'contact', id: target.contact.id, depth: 0 }), - ).resolves.toMatchObject({ phone: owner.contact.phone }) + ).resolves.toMatchObject({ + phones: expect.arrayContaining([expect.objectContaining({ value: owner.contact.phone })]), + }) await expect( payload.findByID({ collection: 'contact', id: owner.contact.id, depth: 0 }), - ).resolves.toMatchObject({ phone: owner.contact.phone }) + ).resolves.toMatchObject({ + phones: expect.arrayContaining([expect.objectContaining({ value: owner.contact.phone })]), + }) }, ) @@ -1114,7 +1117,11 @@ describe('campaign invite domain', () => { payload.findByID({ collection: 'contact', id, depth: 0 }), ), ) - expect(contacts.every(({ phone }) => phone === targetPhone)).toBe(true) + expect( + contacts.every((contact) => + (contact.phones ?? []).some((entry) => entry.value === targetPhone), + ), + ).toBe(true) }, ) @@ -1156,8 +1163,12 @@ describe('campaign invite domain', () => { payload.findByID({ collection: 'contact', id: first.contact.id, depth: 0 }), payload.findByID({ collection: 'contact', id: second.contact.id, depth: 0 }), ]) - expect(firstContact.phone).toBe(second.contact.phone) - expect(secondContact.phone).toBe(first.contact.phone) + expect(firstContact.phones?.map((entry) => entry.value)).toEqual( + expect.arrayContaining([second.contact.phone]), + ) + expect(secondContact.phones?.map((entry) => entry.value)).toEqual( + expect.arrayContaining([first.contact.phone]), + ) }, ) diff --git a/tests/int/campaignInviteUi.int.spec.ts b/tests/int/campaignInviteUi.int.spec.ts index 8a92c2be..3d20c037 100644 --- a/tests/int/campaignInviteUi.int.spec.ts +++ b/tests/int/campaignInviteUi.int.spec.ts @@ -126,7 +126,7 @@ const createPublicInviteScenario = async ( collection: 'contact', data: { name: 'Maria da Conceição', - phone: campaignFixtures().phone(), + phones: [{ value: campaignFixtures().phone() }], email: 'maria@example.com', state: 'BA', city: 'Salvador', @@ -216,7 +216,7 @@ describe('campaign invite UI contracts', () => { kind: 'autopreenchimento', profile: { name: fixture.contact.name, - phone: fixture.contact.phone, + phone: fixture.contact.phones?.[0]?.value ?? '', email: fixture.contact.email, gender: fixture.contact.gender, }, @@ -354,7 +354,7 @@ describe('campaign invite UI contracts', () => { const fixture = await createPublicInviteScenario('login') const formData = new FormData() formData.set('name', fixture.contact.name) - formData.set('phone', fixture.contact.phone!) + formData.set('phone', fixture.contact.phones?.[0]?.value ?? '') formData.set('email', fixture.contact.email ?? '') formData.set('password', 'senha-segura') formData.set('passwordConfirmation', 'senha-diferente') @@ -377,7 +377,7 @@ describe('campaign invite UI contracts', () => { const fixture = await createPublicInviteScenario('login') const formData = new FormData() formData.set('name', fixture.contact.name) - formData.set('phone', fixture.contact.phone!) + formData.set('phone', fixture.contact.phones?.[0]?.value ?? '') formData.set('email', fixture.contact.email ?? '') formData.set('password', ' senha-segura ') formData.set('passwordConfirmation', 'senha-segura') @@ -401,7 +401,7 @@ describe('campaign invite UI contracts', () => { const fixture = await createPublicInviteScenario('autopreenchimento') const formData = new FormData() formData.set('name', 'Maria Atualizada') - formData.set('phone', fixture.contact.phone!) + formData.set('phone', fixture.contact.phones?.[0]?.value ?? '') formData.set('email', fixture.contact.email ?? '') formData.set('gender', 'feminino') formData.set('consentAccepted', 'on') diff --git a/tests/int/campaignLeadership.int.spec.ts b/tests/int/campaignLeadership.int.spec.ts index 5742d881..e60b9857 100644 --- a/tests/int/campaignLeadership.int.spec.ts +++ b/tests/int/campaignLeadership.int.spec.ts @@ -44,7 +44,7 @@ describe('campaign leadership domain', () => { expect(Contact.fields).toEqual( expect.arrayContaining([ expect.objectContaining({ name: 'email', required: false }), - expect.objectContaining({ name: 'phone', index: true }), + expect.objectContaining({ name: 'phones', type: 'array' }), expect.objectContaining({ name: 'gender', type: 'select' }), ]), ) @@ -63,14 +63,14 @@ describe('campaign leadership domain', () => { const parsed = leadershipCreateSchema.parse({ municipalities: [1, 1, 2], name: 'Maria da Silva', - phone: '+55 (71) 99999-0000', + phones: ['+55 (71) 99999-0000'], supportStatus: 'engajado', createdBy: 999, user: 999, consent: 999, }) - expect(parsed.phone).toBe('71999990000') + expect(parsed.phones).toEqual(['71999990000']) expect(parsed.municipalities).toEqual([1, 2]) expect(Object.hasOwn(parsed, 'createdBy')).toBe(false) expect(Object.hasOwn(parsed, 'user')).toBe(false) @@ -79,7 +79,7 @@ describe('campaign leadership domain', () => { leadershipCreateSchema.parse({ municipalities: [], name: 'Sem município', - phone: '71999990000', + phones: ['71999990000'], }), ).toThrow() const update = leadershipInternalUpdateSchema.parse({ @@ -124,7 +124,7 @@ describe('campaign leadership domain', () => { const first = await createLeadershipRecord(payload, coordinator, { municipalities: [firstMunicipality.id], name: 'Maria de Jesus', - phone: `+55 ${phone}`, + phones: [`+55 ${phone}`], email: '', gender: 'feminino', supportStatus: 'engajado', @@ -135,7 +135,7 @@ describe('campaign leadership domain', () => { const contacts = await payload.find({ collection: 'contact', - where: { phone: { equals: phone } }, + where: { 'phones.value': { equals: phone } }, pagination: false, depth: 0, }) @@ -149,7 +149,7 @@ describe('campaign leadership domain', () => { createLeadershipRecord(payload, coordinator, { municipalities: [secondMunicipality.id], name: 'Nome que não sobrescreve contato existente', - phone, + phones: [phone], supportStatus: 'a_abordar', }), ).rejects.toThrow(DUPLICATE_LEADERSHIP_MESSAGE) @@ -159,12 +159,15 @@ describe('campaign leadership domain', () => { const coordinator = await campaignFixtures().createCampaignUser('coordinator') const municipality = await campaignFixtures().getMunicipality() const phone = campaignFixtures().phone() - const contact = await campaignFixtures().createContact({ name: 'Contato Existente', phone }) + const contact = await campaignFixtures().createContact({ + name: 'Contato Existente', + phones: [{ value: phone }], + }) const created = await createLeadershipRecord(payload, coordinator, { municipalities: [municipality.id], name: 'Nome que não sobrescreve contato existente', - phone: `+55 ${phone}`, + phones: [`+55 ${phone}`], supportStatus: 'engajado', }) @@ -209,7 +212,7 @@ describe('campaign leadership domain', () => { const first = createLeadershipRecord(payload, coordinator, { municipalities: [firstMunicipality.id], name: 'Contato Concorrente', - phone, + phones: [phone], supportStatus: 'engajado', }) await firstReadReached @@ -230,7 +233,7 @@ describe('campaign leadership domain', () => { const second = createLeadershipRecord(payload, coordinator, { municipalities: [secondMunicipality.id], name: 'Contato Concorrente Duplicado', - phone: `+55 ${phone}`, + phones: [`+55 ${phone}`], supportStatus: 'a_abordar', }) @@ -267,7 +270,7 @@ describe('campaign leadership domain', () => { const contacts = await payload.find({ collection: 'contact', - where: { phone: { equals: phone } }, + where: { 'phones.value': { equals: phone } }, pagination: false, depth: 0, }) @@ -288,13 +291,13 @@ describe('campaign leadership domain', () => { await payload.create({ collection: 'contact', - data: { name: 'Primeiro Contato', phone, state: 'BA', city: 'Salvador' }, + data: { name: 'Primeiro Contato', phones: [{ value: phone }], state: 'BA', city: 'Salvador' }, }) // C111 — the phone is a contact channel, not a unique identity. await payload.create({ collection: 'contact', - data: { name: 'Segundo Contato', phone, state: 'BA', city: 'Salvador' }, + data: { name: 'Segundo Contato', phones: [{ value: phone }], state: 'BA', city: 'Salvador' }, }) // Two fichas already share the phone, so the leadership create cannot @@ -304,7 +307,7 @@ describe('campaign leadership domain', () => { createLeadershipRecord(payload, coordinator, { municipalities: [municipality.id], name: 'Contato Reutilizado', - phone, + phones: [phone], supportStatus: 'engajado', }), ).resolves.toMatchObject({ contactReused: false }) @@ -345,14 +348,14 @@ describe('campaign leadership domain', () => { const ownLeadership = await createLeadershipRecord(payload, advisor, { municipalities: [assigned.id], name: 'Liderança do município', - phone: campaignFixtures().phone(), + phones: [campaignFixtures().phone()], supportStatus: 'engajado', notes: 'Avaliação interna', }) await createLeadershipRecord(payload, coordinator, { municipalities: [other.id], name: 'Liderança Alheia', - phone: campaignFixtures().phone(), + phones: [campaignFixtures().phone()], supportStatus: 'engajado', }) @@ -376,7 +379,7 @@ describe('campaign leadership domain', () => { createLeadershipRecord(payload, advisor, { municipalities: [other.id], name: 'Cadastro Fora do Escopo', - phone: campaignFixtures().phone(), + phones: [campaignFixtures().phone()], supportStatus: 'engajado', }), ).rejects.toThrow(OUT_OF_SCOPE_MUNICIPALITY_MESSAGE) @@ -392,7 +395,7 @@ describe('campaign leadership domain', () => { const own = await createLeadershipRecord(payload, advisor, { municipalities: [assigned.id], name: 'Ação Permitida', - phone: campaignFixtures().phone(), + phones: [campaignFixtures().phone()], supportStatus: 'engajado', }) @@ -413,7 +416,7 @@ describe('campaign leadership domain', () => { const otherLeadership = await createLeadershipRecord(payload, coordinator, { municipalities: [other.id], name: 'Outra Liderança', - phone: campaignFixtures().phone(), + phones: [campaignFixtures().phone()], supportStatus: 'engajado', }) await expect( @@ -432,7 +435,7 @@ describe('campaign leadership domain', () => { createLeadershipRecord(payload, leader, { municipalities: [municipality.id], name: 'Criação por liderança', - phone: campaignFixtures().phone(), + phones: [campaignFixtures().phone()], supportStatus: 'engajado', }), ).rejects.toThrow('Somente a coordenação e a assessoria podem gerenciar lideranças.') @@ -473,7 +476,7 @@ describe('campaign leadership domain', () => { createLeadershipRecord(payload, coordinator, { municipalities: [municipality.id], name: 'Contato que Deve Reverter', - phone, + phones: [phone], supportStatus: 'engajado', }), ).rejects.toThrow('falha forçada após contato') @@ -482,7 +485,7 @@ describe('campaign leadership domain', () => { const [contacts, leaderships] = await Promise.all([ payload.find({ collection: 'contact', - where: { phone: { equals: phone } }, + where: { 'phones.value': { equals: phone } }, depth: 0, pagination: false, }), @@ -602,7 +605,7 @@ describe('campaign leadership domain', () => { const created = await createLeadershipRecord(payload, coordinator, { municipalities: [municipality.id], name: 'Registro Permanente', - phone: campaignFixtures().phone(), + phones: [campaignFixtures().phone()], supportStatus: 'a_abordar', }) @@ -663,7 +666,7 @@ describe('campaign leadership domain', () => { const created = await createLeadershipRecord(payload, coordinator, { municipalities: [municipality.id], name: 'Nome Original', - phone, + phones: [phone], email: 'original@example.com', supportStatus: 'engajado', }) @@ -693,7 +696,7 @@ describe('campaign leadership domain', () => { }) expect(contact.name).toBe('Nome Corrigido') expect(contact.email).toBe('corrigido@example.com') - expect(contact.phone).toBe(newPhone) + expect(contact.phones?.[0]?.value).toBe(newPhone) }) it('shares a leadership contact phone with another person (C111)', async () => { @@ -701,11 +704,11 @@ describe('campaign leadership domain', () => { const coordinator = await fixtures.createCampaignUser('coordinator') const municipality = await fixtures.getMunicipality() const takenPhone = fixtures.phone() - await fixtures.createContact({ phone: takenPhone }) + await fixtures.createContact({ phones: [{ value: takenPhone }] }) const leadership = await createLeadershipRecord(payload, coordinator, { municipalities: [municipality.id], name: 'Liderança Sem Conflito', - phone: fixtures.phone(), + phones: [fixtures.phone()], supportStatus: 'engajado', }) @@ -721,11 +724,11 @@ describe('campaign leadership domain', () => { depth: 0, overrideAccess: true, }) - expect(contact.phone).toBe(takenPhone) + expect(contact.phones?.[0]?.value).toBe(takenPhone) const withPhone = await payload.find({ collection: 'contact', - where: { phone: { equals: takenPhone } }, + where: { 'phones.value': { equals: takenPhone } }, depth: 0, limit: 2, pagination: false, @@ -744,7 +747,7 @@ describe('campaign leadership domain', () => { const leadership = await createLeadershipRecord(payload, coordinator, { municipalities: [away.id], name: 'Fora da Carteira', - phone: fixtures.phone(), + phones: [fixtures.phone()], supportStatus: 'engajado', }) diff --git a/tests/int/campaignLeadershipActions.int.spec.ts b/tests/int/campaignLeadershipActions.int.spec.ts index 914636f2..75e4b560 100644 --- a/tests/int/campaignLeadershipActions.int.spec.ts +++ b/tests/int/campaignLeadershipActions.int.spec.ts @@ -37,7 +37,7 @@ const createFormData = (municipalityIds: number[], name: string, phone: string) for (const municipalityId of municipalityIds) formData.append('municipalities', String(municipalityId)) formData.set('name', name) - formData.set('phone', phone) + formData.append('phones', phone) formData.set('supportStatus', 'engajado') return formData } @@ -82,7 +82,7 @@ describe('campaign leadership exported form actions', () => { const contacts = await payload.find({ collection: 'contact', - where: { phone: { equals: phone } }, + where: { 'phones.value': { equals: phone } }, limit: 2, depth: 0, }) @@ -114,14 +114,14 @@ describe('campaign leadership exported form actions', () => { const contacts = await payload.find({ collection: 'contact', - where: { phone: { equals: phone } }, + where: { 'phones.value': { equals: phone } }, limit: 2, depth: 0, }) expect(contacts.totalDocs).toBe(1) expect(contacts.docs[0]).toMatchObject({ name: 'Francisco', - phone, + phones: [{ value: phone }], }) const leaderships = await payload.find({ collection: 'leadership', @@ -145,7 +145,7 @@ describe('campaign leadership exported form actions', () => { const formData = new FormData() formData.append('municipalities', String(municipality.id)) formData.set('name', 'Liderança mínima') - formData.set('phone', phone) + formData.append('phones', phone) authState.user = coordinator await expectRedirectTo(createLeadershipFormAction({}, formData), '/campanha/liderancas/') @@ -153,14 +153,14 @@ describe('campaign leadership exported form actions', () => { const contact = ( await payload.find({ collection: 'contact', - where: { phone: { equals: phone } }, + where: { 'phones.value': { equals: phone } }, limit: 1, depth: 0, }) ).docs[0]! expect(contact).toMatchObject({ name: 'Liderança mínima', - phone, + phones: [{ value: phone }], email: null, gender: null, }) @@ -188,7 +188,7 @@ describe('campaign leadership exported form actions', () => { await createLeadershipRecord(payload, coordinator, { municipalities: [municipality.id], name: 'Pessoa já cadastrada', - phone, + phones: [phone], supportStatus: 'engajado', }) @@ -207,7 +207,7 @@ describe('campaign leadership exported form actions', () => { const created = await createLeadershipRecord(payload, coordinator, { municipalities: [municipality.id], name: 'Liderança com campos internos', - phone: campaignFixtures().phone(), + phones: [campaignFixtures().phone()], exclusive: true, notes: 'Nota para limpar', }) @@ -234,14 +234,14 @@ describe('campaign leadership exported form actions', () => { const defaulted = await createLeadershipRecord(payload, coordinator, { municipalities: [municipality.id], name: 'Liderança exclusiva default', - phone: campaignFixtures().phone(), + phones: [campaignFixtures().phone()], }) expect(defaulted.exclusive).toBe(true) const divided = await createLeadershipRecord(payload, coordinator, { municipalities: [municipality.id], name: 'Liderança dividida', - phone: campaignFixtures().phone(), + phones: [campaignFixtures().phone()], exclusive: false, }) expect(divided.exclusive).toBe(false) @@ -258,7 +258,7 @@ describe('campaign leadership exported form actions', () => { const otherLeadership = await createLeadershipRecord(payload, coordinator, { municipalities: [other.id], name: 'Liderança fora do escopo', - phone: campaignFixtures().phone(), + phones: [campaignFixtures().phone()], supportStatus: 'engajado', }) authState.user = advisor @@ -288,7 +288,7 @@ describe('campaign leadership exported form actions', () => { const existing = await createLeadershipRecord(payload, coordinator, { municipalities: [municipality.id], name: 'Registro protegido', - phone: campaignFixtures().phone(), + phones: [campaignFixtures().phone()], supportStatus: 'engajado', }) authState.user = leaderAccount diff --git a/tests/int/campaignMultiPhones.int.spec.ts b/tests/int/campaignMultiPhones.int.spec.ts new file mode 100644 index 00000000..428bc39a --- /dev/null +++ b/tests/int/campaignMultiPhones.int.spec.ts @@ -0,0 +1,458 @@ +// @vitest-environment node + +import type { Payload } from 'payload' +import { getPayload } from 'payload' +import { beforeAll, describe, expect, it, vi } from 'vitest' + +import type { CampaignUser } from '@/payload-types' + +const authState = vi.hoisted(() => ({ + user: null as CampaignUser | null, +})) +const requestHeadersState = vi.hoisted(() => ({ + value: new Headers({ origin: 'http://localhost:3212' }), +})) + +vi.mock('@/utilities/campaignAuth', () => ({ + getCampaignUser: async () => authState.user, +})) +vi.mock('next/headers', () => ({ + headers: async () => requestHeadersState.value, +})) +vi.mock('next/cache', () => ({ + revalidatePath: vi.fn(), +})) + +import { + createLeadershipRecord, + updateLeadershipContactRecord, +} from '@/app/(campaign)/campanha/actions/leadership' +import { + createSupporterRecord, + updateSupporterContactRecord, +} from '@/app/(campaign)/campanha/actions/supporter' +import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '@/lib/campaignConsentKeys' +import { + BRAZILIAN_PHONE_DUPLICATE_MESSAGE, + BRAZILIAN_PHONE_INVALID_MESSAGE, + primaryPhoneOf, + reorderWithPrimaryPhone, +} from '@/lib/phone' +import config from '@/payload.config' +import { toSupporterDetailViewModel } from '@/utilities/supporter/supporterViewModels' +import { + ensureLeasedConsent, + SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, +} from '../helpers/testDatabaseLease' + +import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures' + +let payload: Payload +const campaignFixtures = installCampaignFixtures({ + getPayload: () => payload, + setPayload: (nextPayload) => { + payload = nextPayload + }, +}) + +describe('C112 — múltiplos telefones por pessoa', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + it('keeps order = priority through the pure helpers', () => { + expect(primaryPhoneOf(null)).toBeNull() + expect(primaryPhoneOf([])).toBeNull() + expect(primaryPhoneOf([{ value: '71911110000' }, { value: '71922220000' }])).toBe('71911110000') + + expect(reorderWithPrimaryPhone([], '71911110000')).toEqual(['71911110000']) + expect( + reorderWithPrimaryPhone([{ value: '71911110000' }, { value: '71922220000' }], '71922220000'), + ).toEqual(['71922220000', '71911110000']) + expect(reorderWithPrimaryPhone([{ value: '71911110000' }], null)).toEqual([]) + }) + + it('creates a ficha with N phones on leadership create and lists the primary', async () => { + const coordinator = await campaignFixtures().createCampaignUser('coordinator') + const municipality = await campaignFixtures().getMunicipality() + const primary = campaignFixtures().phone() + const secondary = campaignFixtures().phone() + + const created = await createLeadershipRecord(payload, coordinator, { + municipalities: [municipality.id], + name: 'Liderança Multi Telefones', + phones: [primary, secondary], + supportStatus: 'engajado', + }) + + const contact = await payload.findByID({ + collection: 'contact', + id: relationId(created.contact) as number, + depth: 0, + overrideAccess: true, + }) + expect(contact.phones?.map((entry) => entry.value)).toEqual([primary, secondary]) + + const summary = await payload.find({ + collection: 'leadership', + where: { id: { equals: created.id } }, + depth: 1, + limit: 1, + pagination: false, + user: coordinator, + overrideAccess: false, + }) + const contactSummary = summary.docs[0]?.contact + expect( + typeof contactSummary === 'object' && contactSummary !== null + ? primaryPhoneOf(contactSummary.phones) + : null, + ).toBe(primary) + }) + + it('rejects the same number twice in one ficha (dedupe within the person)', async () => { + const coordinator = await campaignFixtures().createCampaignUser('coordinator') + const municipality = await campaignFixtures().getMunicipality() + const phone = campaignFixtures().phone() + + await expect( + createLeadershipRecord(payload, coordinator, { + municipalities: [municipality.id], + name: 'Liderança Repetida', + phones: [phone, phone], + supportStatus: 'engajado', + }), + ).rejects.toThrow(BRAZILIAN_PHONE_DUPLICATE_MESSAGE) + + await expect( + payload.create({ + collection: 'contact', + data: { + name: 'Ficha Repetida', + phones: [{ value: phone }, { value: phone }], + state: 'BA', + city: 'Salvador', + }, + depth: 0, + overrideAccess: true, + }), + ).rejects.toThrow(BRAZILIAN_PHONE_DUPLICATE_MESSAGE) + + // The invalid-number refusal is unchanged. + await expect( + payload.create({ + collection: 'contact', + data: { + name: 'Ficha Inválida', + phones: [{ value: '1199999999' }], + state: 'BA', + city: 'Salvador', + }, + depth: 0, + overrideAccess: true, + }), + ).rejects.toThrow(BRAZILIAN_PHONE_INVALID_MESSAGE) + }) + + it('sets the primary phone preserving the rest through the inline edit', async () => { + const coordinator = await campaignFixtures().createCampaignUser('coordinator') + const municipality = await campaignFixtures().getMunicipality() + const first = campaignFixtures().phone() + const second = campaignFixtures().phone() + + const created = await createLeadershipRecord(payload, coordinator, { + municipalities: [municipality.id], + name: 'Liderança Troca Principal', + phones: [first, second], + supportStatus: 'engajado', + }) + + const updated = await updateLeadershipContactRecord(payload, coordinator, { + id: created.id, + field: 'phone', + phone: second, + }) + const contact = await payload.findByID({ + collection: 'contact', + id: relationId(updated.contact) as number, + depth: 0, + overrideAccess: true, + }) + expect(contact.phones?.map((entry) => entry.value)).toEqual([second, first]) + + // Clearing the primary removes it — the rest shifts up. + await updateLeadershipContactRecord(payload, coordinator, { + id: created.id, + field: 'phone', + phone: null, + }) + const afterClear = await payload.findByID({ + collection: 'contact', + id: relationId(created.contact) as number, + depth: 0, + overrideAccess: true, + }) + expect(afterClear.phones?.map((entry) => entry.value)).toEqual([first]) + }) + + it('writes the full list through the phones branch (ficha editor)', async () => { + const coordinator = await campaignFixtures().createCampaignUser('coordinator') + const municipality = await campaignFixtures().getMunicipality() + const created = await createLeadershipRecord(payload, coordinator, { + municipalities: [municipality.id], + name: 'Liderança Editor', + phones: [campaignFixtures().phone()], + supportStatus: 'engajado', + }) + const kept = campaignFixtures().phone() + const added = campaignFixtures().phone() + + const updated = await updateLeadershipContactRecord(payload, coordinator, { + id: created.id, + field: 'phones', + phones: [kept, added], + }) + const contact = await payload.findByID({ + collection: 'contact', + id: relationId(updated.contact) as number, + depth: 0, + overrideAccess: true, + }) + expect(contact.phones?.map((entry) => entry.value)).toEqual([kept, added]) + }) + + it('supporter detail shows every number and the list still shows the primary', async () => { + const fixtures = campaignFixtures() + await ensureLeasedConsent(payload, { + consentKey: SUPPORTER_REGISTRATION_CONSENT_KEY, + leaseKey: SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, + }) + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.getMunicipality() + const primary = fixtures.phone() + const secondary = fixtures.phone() + + const created = await createSupporterRecord(payload, coordinator, { + name: fixtures.value('Apoiador Multi Telefones'), + phones: [primary, secondary], + municipality: municipality.id, + city: 'Salvador', + consentAccepted: true, + }) + const supporter = await payload.findByID({ + collection: 'supporter', + id: created.id, + depth: 1, + overrideAccess: true, + }) + const detail = toSupporterDetailViewModel(supporter) + expect(detail.phones).toEqual([primary, secondary]) + expect(detail.phone).toBe(primary) + + // The supporter ficha editor (phones branch) replaces the whole list. + const third = fixtures.phone() + const updated = await updateSupporterContactRecord(payload, coordinator, { + id: created.id, + field: 'phones', + phones: [third, primary], + }) + const contact = await payload.findByID({ + collection: 'contact', + id: relationId(updated.contact) as number, + depth: 0, + overrideAccess: true, + }) + expect(contact.phones?.map((entry) => entry.value)).toEqual([third, primary]) + }) +}) + +describe('C112 — busca e convite com números secundários', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + it('finds the supporter by a NON-primary number (payload and SQL search paths)', async () => { + const fixtures = campaignFixtures() + await ensureLeasedConsent(payload, { + consentKey: SUPPORTER_REGISTRATION_CONSENT_KEY, + leaseKey: SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, + }) + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.getMunicipality() + const primary = fixtures.phone() + const secondary = fixtures.phone() + + const created = await createSupporterRecord(payload, coordinator, { + name: fixtures.value('Apoiador Busca Secundária'), + phones: [primary, secondary], + municipality: municipality.id, + city: 'Salvador', + consentAccepted: true, + }) + + // Payload where path: the list search terms normalize to the full number. + const { toPayloadWhere } = await import('@/utilities/supporter/supporterListFilters') + const where = toPayloadWhere({ q: secondary }) + const hits = await payload.find({ + collection: 'supporter', + where, + depth: 1, + limit: 10, + pagination: false, + overrideAccess: true, + }) + expect(hits.docs.map((doc) => doc.id)).toContain(created.id) + + // SQL aggregate path: the raw-conditions builder must match a secondary too. + const { toAggregateSqlConditions } = + await import('@/utilities/supporter/supporterListSqlFilters') + const sql = await import('@payloadcms/db-postgres') + const { conditions, needsContactJoin } = toAggregateSqlConditions({ + q: secondary, + }) + expect(needsContactJoin).toBe(true) + expect(conditions.length).toBeGreaterThan(0) + const conditionSql = sql.sql.join(conditions, sql.sql` AND `) + const result = await payload.db.drizzle.execute( + sql.sql`SELECT "supporter"."id" FROM "supporter" + JOIN "contact" ON "contact"."id" = "supporter"."contact_id" + WHERE ${conditionSql}`, + ) + const rows = Array.isArray(result) + ? result + : ((result as { rows: unknown }).rows as Array<{ id: number }>) + const ids = rows.map((row) => row.id) + expect(ids).toContain(created.id) + }) + + it('builds the invite WhatsApp link with the PRIMARY of a multi-phone ficha', async () => { + const { buildCampaignInviteWhatsAppLink } = await import('@/utilities/campaignInvite') + const primary = campaignFixtures().phone() + const secondary = campaignFixtures().phone() + const url = buildCampaignInviteWhatsAppLink({ + phone: primary, + recipientName: 'Liderança', + senderName: 'Coordenação', + inviteUrl: 'https://pt.jorgesolla.com.br/campanha/convite/abc', + kind: 'autopreenchimento', + }) + expect(url).toContain(`wa.me/55${primary}`) + expect(url).not.toContain(`wa.me/55${secondary}`) + + // And the invitation creation gate uses the ficha's primary. + const coordinator = await campaignFixtures().createCampaignUser('coordinator') + const municipality = await campaignFixtures().getMunicipality() + const created = await createLeadershipRecord(payload, coordinator, { + municipalities: [municipality.id], + name: campaignFixtures().value('Liderança Convite Multi'), + phones: [primary, secondary], + supportStatus: 'engajado', + }) + const { createCampaignInvite } = await import('@/app/(campaign)/campanha/actions/invite') + authState.user = coordinator + const invite = await createCampaignInvite({ + leadership: created.id, + kind: 'autopreenchimento', + }) + expect(invite.whatsappUrl).toContain(`wa.me/55${primary}`) + }) +}) + +describe('C112 — dobradinha e assessor com N telefones', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + it('writes the dobradinha ficha list through the phones branch', async () => { + const { updateStateDeputyContactRecord } = + await import('@/app/(campaign)/campanha/actions/stateDeputy') + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const stateDeputy = await fixtures.createStateDeputy({ + name: `${fixtures.value('Dobradinha')} Multi`, + }) + const first = fixtures.phone() + const second = fixtures.phone() + + const updated = await updateStateDeputyContactRecord(payload, coordinator, { + id: stateDeputy.id, + field: 'phones', + phones: [first, second], + }) + const contact = await payload.findByID({ + collection: 'contact', + id: relationId(updated.contact) as number, + depth: 0, + overrideAccess: true, + }) + expect(contact.phones?.map((entry) => entry.value)).toEqual([first, second]) + }) + + it('writes the advisor ficha list and rejects duplicates', async () => { + const { updateAdvisorContactFicha } = await import('@/app/(campaign)/campanha/actions/advisor') + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + authState.user = coordinator + const advisor = await fixtures.createCampaignUser('advisor', { + name: fixtures.value('Assessora Multi Telefones'), + }) + const first = fixtures.phone() + const second = fixtures.phone() + + const contactID = relationId( + await payload + .findByID({ + collection: 'campaignUser', + id: advisor.id, + depth: 0, + select: { contact: true }, + overrideAccess: true, + }) + .then((account) => account.contact), + ) + expect(contactID).not.toBeNull() + + await updateAdvisorContactFicha({ contactId: contactID!, phones: [first, second] }) + const contact = await payload.findByID({ + collection: 'contact', + id: contactID!, + depth: 0, + overrideAccess: true, + }) + expect(contact.phones?.map((entry) => entry.value)).toEqual([first, second]) + + await expect( + updateAdvisorContactFicha({ contactId: contactID!, phones: [first, first] }), + ).rejects.toThrow(BRAZILIAN_PHONE_DUPLICATE_MESSAGE) + }) +}) + +describe('C112 — CSV import com telefone compartilhado como secundário', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + it('flags a CSV phone carried as SECONDARY by two fichas (preview = confirm fail-closed)', async () => { + const fixtures = campaignFixtures() + await ensureLeasedConsent(payload, { + consentKey: SUPPORTER_REGISTRATION_CONSENT_KEY, + leaseKey: SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY, + }) + const coordinator = await fixtures.createCampaignUser('coordinator') + const sharedSecondary = fixtures.phone() + // Two fichas whose PRIMARY differs but which both carry the CSV number. + await fixtures.createContact({ + phones: [{ value: fixtures.phone() }, { value: sharedSecondary }], + }) + await fixtures.createContact({ + phones: [{ value: fixtures.phone() }, { value: sharedSecondary }], + }) + + const { previewSupporterImportText } = + await import('@/app/(campaign)/campanha/actions/supporterImport') + const csv = `nome,telefone,municipio,intencao\nMaria Souza,${sharedSecondary},Salvador,certo` + const preview = await previewSupporterImportText(payload, coordinator, csv) + expect(preview.counts.ok).toBe(0) + expect(preview.sampleRows.some((row) => row.status === 'telefone_compartilhado')).toBe(true) + }) +}) diff --git a/tests/int/campaignMunicipalityLeadershipList.int.spec.ts b/tests/int/campaignMunicipalityLeadershipList.int.spec.ts index 207d9b48..7f50c832 100644 --- a/tests/int/campaignMunicipalityLeadershipList.int.spec.ts +++ b/tests/int/campaignMunicipalityLeadershipList.int.spec.ts @@ -152,7 +152,7 @@ describe('B155 — lideranças na lista de municípios (leitura reversa + opçõ depth: 0, overrideAccess: true, }) - expect(contact.phone).toBeNull() + expect(contact.phones).toEqual([]) const { leadershipIDsByMunicipality, summariesById } = await loadMunicipalityLeadershipSummaries(payload, coordinator, [municipality.id]) @@ -170,13 +170,13 @@ describe('B155 — lideranças na lista de municípios (leitura reversa + opçõ const first = await createLeadershipRecord(payload, coordinator, { municipalities: [municipality.id], name: 'Nome Original', - phone, + phones: [phone], }) await expect( createLeadershipRecord(payload, coordinator, { municipalities: [municipality.id], name: 'Outro Nome', - phone, + phones: [phone], }), ).rejects.toThrow('Esta pessoa já está cadastrada como liderança') diff --git a/tests/int/campaignMunicipalityStateDeputyCreate.int.spec.ts b/tests/int/campaignMunicipalityStateDeputyCreate.int.spec.ts index 0de78fbe..deaa1365 100644 --- a/tests/int/campaignMunicipalityStateDeputyCreate.int.spec.ts +++ b/tests/int/campaignMunicipalityStateDeputyCreate.int.spec.ts @@ -229,7 +229,7 @@ describe('StateDeputy contact fields (B163)', () => { expect(updated.contact).toMatchObject({ name: correctedName, email: 'corrigido@example.com', - phone, + phones: expect.arrayContaining([expect.objectContaining({ value: phone })]), }) }) @@ -240,7 +240,7 @@ describe('StateDeputy contact fields (B163)', () => { name: `${fixtures.value('Dobradinha')} Principal`, }) const takenPhone = fixtures.phone() - await fixtures.createContact({ phone: takenPhone }) + await fixtures.createContact({ phones: [{ value: takenPhone }] }) const updated = await updateStateDeputyContactRecord(payload, coordinator, { id: stateDeputy.id, @@ -254,11 +254,11 @@ describe('StateDeputy contact fields (B163)', () => { depth: 0, overrideAccess: true, }) - expect(contact.phone).toBe(takenPhone) + expect(contact.phones?.[0]?.value).toBe(takenPhone) const withPhone = await payload.find({ collection: 'contact', - where: { phone: { equals: takenPhone } }, + where: { 'phones.value': { equals: takenPhone } }, depth: 0, limit: 2, pagination: false, diff --git a/tests/int/campaignSupporter.int.spec.ts b/tests/int/campaignSupporter.int.spec.ts index bc647a98..3ba7ee4a 100644 --- a/tests/int/campaignSupporter.int.spec.ts +++ b/tests/int/campaignSupporter.int.spec.ts @@ -74,7 +74,7 @@ describe('campaign supporter domain', () => { expect( supporterCreateSchema.safeParse({ name: 'Maria Silva', - phone: '71999990000', + phones: ['71999990000'], consentAccepted: true, }).success, ).toBe(true) @@ -82,14 +82,14 @@ describe('campaign supporter domain', () => { expect( supporterCreateSchema.safeParse({ name: 'Maria Silva', - phone: '71999990000', + phones: ['71999990000'], }).success, ).toBe(false) expect( supporterCreateSchema.safeParse({ name: 'Maria Silva', - phone: 'invalid', + phones: ['invalid'], consentAccepted: true, }).success, ).toBe(false) @@ -105,7 +105,7 @@ describe('campaign supporter domain', () => { await expect( createSupporterRecord(payload, coordinator, { name: fixtures.value('Apoiador'), - phone: fixtures.phone(), + phones: [fixtures.phone()], consentAccepted: true, }), ).rejects.toThrow(/Consentimento de cadastro de apoiador ainda não configurado/) @@ -126,7 +126,7 @@ describe('campaign supporter domain', () => { const created = await createSupporterRecord(payload, coordinator, { name: fixtures.value('Apoiador'), - phone, + phones: [phone], city: 'Salvador', consentAccepted: true, }) @@ -146,7 +146,7 @@ describe('campaign supporter domain', () => { await expect( createSupporterRecord(payload, coordinator, { name: contact.name, - phone: contact.phone, + phones: [contact.phone], municipality: municipality.id, consentAccepted: true, }), @@ -164,7 +164,7 @@ describe('campaign supporter domain', () => { await expect( createSupporterRecord(payload, advisor, { name: fixtures.value('Apoiador'), - phone: fixtures.phone(), + phones: [fixtures.phone()], consentAccepted: true, }), ).rejects.toThrow(/sem município/) @@ -172,7 +172,7 @@ describe('campaign supporter domain', () => { await expect( createSupporterRecord(payload, advisor, { name: fixtures.value('Apoiador'), - phone: fixtures.phone(), + phones: [fixtures.phone()], municipality: otherMunicipality.id, consentAccepted: true, }), @@ -180,7 +180,7 @@ describe('campaign supporter domain', () => { const created = await createSupporterRecord(payload, advisor, { name: fixtures.value('Apoiador'), - phone: fixtures.phone(), + phones: [fixtures.phone()], municipality: municipality.id, consentAccepted: true, }) @@ -247,7 +247,7 @@ describe('campaign supporter domain', () => { const advisor = await fixtures.createCampaignUser('advisor') const phoneOk = fixtures.phone() const phoneDup = fixtures.phone() - const existing = await fixtures.createContact({ phone: phoneDup }) + const existing = await fixtures.createContact({ phones: [{ value: phoneDup }] }) await fixtures.createSupporter({ contact: existing.id, createdBy: coordinator.id, @@ -288,7 +288,7 @@ describe('campaign supporter domain', () => { const contact = await payload.find({ collection: 'contact', - where: { phone: { equals: phoneOk } }, + where: { 'phones.value': { equals: phoneOk } }, depth: 0, limit: 1, overrideAccess: true, @@ -312,8 +312,8 @@ describe('campaign supporter domain', () => { await ensureSupporterConsents() const coordinator = await fixtures.createCampaignUser('coordinator') const sharedPhone = fixtures.phone() - await fixtures.createContact({ name: 'Comitê Central', phone: sharedPhone }) - await fixtures.createContact({ name: 'Ana', phone: sharedPhone }) + await fixtures.createContact({ name: 'Comitê Central', phones: [{ value: sharedPhone }] }) + await fixtures.createContact({ name: 'Ana', phones: [{ value: sharedPhone }] }) const csv = [ 'nome,telefone,municipio,intencao', @@ -341,7 +341,7 @@ describe('campaign supporter domain', () => { await ensureSupporterConsents() const coordinator = await fixtures.createCampaignUser('coordinator') const phone = fixtures.phone() - await fixtures.createContact({ phone }) + await fixtures.createContact({ phones: [{ value: phone }] }) const csv = `nome,telefone,municipio,intencao\nAna Silva,${phone},Salvador,certo` const preview = await previewSupporterImportText(payload, coordinator, csv) @@ -349,7 +349,7 @@ describe('campaign supporter domain', () => { // Simulate the race: between preview and confirm another ficha claims the // same phone. The confirm holds the phone locks and must fail closed. - await fixtures.createContact({ name: 'Segunda Ficha', phone }) + await fixtures.createContact({ name: 'Segunda Ficha', phones: [{ value: phone }] }) await expect( confirmSupporterImportRecord(payload, coordinator, { @@ -394,7 +394,7 @@ describe('campaign supporter domain', () => { }) const contact = await payload.find({ collection: 'contact', - where: { phone: { equals: phoneOk } }, + where: { 'phones.value': { equals: phoneOk } }, depth: 0, limit: 1, overrideAccess: true, @@ -417,7 +417,7 @@ describe('campaign supporter domain', () => { const phone = fixtures.phone() const created = await createSupporterRecord(payload, coordinator, { name: fixtures.value('Apoiador'), - phone, + phones: [phone], consentAccepted: true, }) const contactID = relationId(created.contact) @@ -434,7 +434,7 @@ describe('campaign supporter domain', () => { depth: 0, }) expect(contact.name).toBe('Titular removido') - expect(contact.phone).not.toBe(phone) + expect(contact.phones?.map((entry) => entry.value)).not.toContain(phone) }) it('aggregates supporter overview KPIs in a single SQL query for the coordinator', async () => { diff --git a/tests/int/campaignUserPhone.int.spec.ts b/tests/int/campaignUserPhone.int.spec.ts index f8838fc1..f88911b4 100644 --- a/tests/int/campaignUserPhone.int.spec.ts +++ b/tests/int/campaignUserPhone.int.spec.ts @@ -29,7 +29,6 @@ const createEngagedPhoneAccessGraph = async ( ) => { const contact = await fixtures.createContact({ name: fixtures.value('Contato telefone'), - phone: fixtures.phone(), }) return fixtures.createLeadership({ contact, diff --git a/tests/int/collectionAccessLockdown.int.spec.ts b/tests/int/collectionAccessLockdown.int.spec.ts index df1001f1..f64acec4 100644 --- a/tests/int/collectionAccessLockdown.int.spec.ts +++ b/tests/int/collectionAccessLockdown.int.spec.ts @@ -337,7 +337,7 @@ describe('leader supporter creation scope', () => { const supporter = await withSupporterRegistrationConsent(() => createLeaderSupporterRecord(payload, leaderAccount, { name: fixtures.value('Apoiador'), - phone: fixtures.phone(), + phones: [fixtures.phone()], city: 'Salvador', municipality: linked.id, consentAccepted: true, @@ -367,7 +367,7 @@ describe('leader supporter creation scope', () => { withSupporterRegistrationConsent(() => createLeaderSupporterRecord(payload, leaderAccount, { name: fixtures.value('Apoiador'), - phone: fixtures.phone(), + phones: [fixtures.phone()], city: 'Salvador', municipality: outside.id, consentAccepted: true, @@ -384,7 +384,7 @@ describe('leader supporter creation scope', () => { await expect( createLeaderSupporterRecord(payload, advisor, { name: fixtures.value('Apoiador'), - phone: fixtures.phone(), + phones: [fixtures.phone()], city: 'Salvador', municipality: municipality.id, consentAccepted: true, diff --git a/tests/int/contactPhoneNormalization.int.spec.ts b/tests/int/contactPhoneNormalization.int.spec.ts index a9d94cf7..61ee973a 100644 --- a/tests/int/contactPhoneNormalization.int.spec.ts +++ b/tests/int/contactPhoneNormalization.int.spec.ts @@ -25,14 +25,14 @@ const contactPhoneHook = Contact.hooks?.beforeValidate?.[0] type ContactPhoneHookArgs = Parameters>[0] -const runContactPhoneHook = async (phone: string) => { +const runContactPhoneHook = async (phones: unknown) => { if (typeof contactPhoneHook !== 'function') { throw new Error('Expected Contact.beforeValidate phone hook.') } - const data = { phone } + const data: { phones?: unknown } = { phones } await contactPhoneHook(stub({ data })) - return data.phone + return data.phones } describe('Contact phone normalization boundary', () => { @@ -44,21 +44,35 @@ describe('Contact phone normalization boundary', () => { const parsed = leadershipCreateSchema.parse({ municipalities: [1], name: 'Liderança normalizada', - phone: '+55 (71) 99999-1234', + phones: ['+55 (71) 99999-1234'], }) - expect(parsed.phone).toBe('71999991234') - expect(await runContactPhoneHook(parsed.phone)).toBe('71999991234') + expect(parsed.phones).toEqual(['71999991234']) + expect(await runContactPhoneHook([{ value: parsed.phones[0] }])).toEqual([ + { value: '71999991234' }, + ]) expect(phoneMocks.normalizeBrazilianPhone).toHaveBeenCalledTimes(1) }) it('normalizes a formatted phone sent directly to the Contact API boundary', async () => { - expect(await runContactPhoneHook('+55 (71) 99999-1234')).toBe('71999991234') + expect(await runContactPhoneHook([{ value: '+55 (71) 99999-1234' }])).toEqual([ + { value: '71999991234' }, + ]) expect(phoneMocks.normalizeBrazilianPhone).toHaveBeenCalledTimes(1) }) it('rejects an invalid canonical-length direct API phone without re-normalizing it', async () => { - await expect(runContactPhoneHook('00000000000')).rejects.toThrow('Celular brasileiro inválido.') + await expect(runContactPhoneHook([{ value: '00000000000' }])).rejects.toThrow( + 'Celular brasileiro inválido.', + ) expect(phoneMocks.normalizeBrazilianPhone).not.toHaveBeenCalled() }) + + it('drops empty entries and rejects a duplicate within the same ficha', async () => { + expect(await runContactPhoneHook([{ value: '' }, { value: null }])).toEqual([]) + + await expect( + runContactPhoneHook([{ value: '71999991234' }, { value: '71999991234' }]), + ).rejects.toThrow('Telefone repetido na ficha.') + }) }) diff --git a/tests/int/personDelete.int.spec.ts b/tests/int/personDelete.int.spec.ts index edfc412f..4ea6b495 100644 --- a/tests/int/personDelete.int.spec.ts +++ b/tests/int/personDelete.int.spec.ts @@ -130,7 +130,8 @@ describe('C100 — apagar pessoa (manifest + cascata)', () => { overrideAccess: true, }) expect(remaining.name).toBe('Titular removido') - expect(remaining.phone).toMatch(/^999\d{8}$/) + expect(remaining.phones?.[0]?.value).toMatch(/^999\d{8}$/) + expect(remaining.phones).toHaveLength(1) expect(remaining.email).toBeNull() expect(remaining.city).toBeNull() } finally { diff --git a/tests/int/submitPetitionSignature.int.spec.ts b/tests/int/submitPetitionSignature.int.spec.ts index 1e8a984d..52ff75e1 100644 --- a/tests/int/submitPetitionSignature.int.spec.ts +++ b/tests/int/submitPetitionSignature.int.spec.ts @@ -95,7 +95,7 @@ describe('submitPetitionSignature (server-side consent resolution)', () => { const contacts = await payload.find({ collection: 'contact', - where: { phone: { equals: phone } }, + where: { 'phones.value': { equals: phone } }, depth: 0, limit: 1, pagination: false, diff --git a/tests/int/submitWhatsapp.int.spec.ts b/tests/int/submitWhatsapp.int.spec.ts index d28a2368..26b448e5 100644 --- a/tests/int/submitWhatsapp.int.spec.ts +++ b/tests/int/submitWhatsapp.int.spec.ts @@ -70,7 +70,7 @@ describe('submitWhatsapp (consent by stable key)', () => { const contacts = await payload.find({ collection: 'contact', - where: { phone: { equals: phone } }, + where: { 'phones.value': { equals: phone } }, depth: 0, limit: 1, pagination: false, diff --git a/tests/int/supporterListFilters.int.spec.ts b/tests/int/supporterListFilters.int.spec.ts index 017cdd68..baee77f1 100644 --- a/tests/int/supporterListFilters.int.spec.ts +++ b/tests/int/supporterListFilters.int.spec.ts @@ -46,7 +46,7 @@ describe('supporterListFilters', () => { or: [ { 'contact.name': { contains: '(71) 98888-7777' } }, { 'contact.city': { contains: '(71) 98888-7777' } }, - { 'contact.phone': { equals: '71988887777' } }, + { 'contact.phones.value': { equals: '71988887777' } }, ], }, ], diff --git a/tests/unit/signatureExport.unit.spec.ts b/tests/unit/signatureExport.unit.spec.ts index 22683456..c65569a8 100644 --- a/tests/unit/signatureExport.unit.spec.ts +++ b/tests/unit/signatureExport.unit.spec.ts @@ -26,7 +26,7 @@ describe('signatureExport', () => { id: 1, name: 'Maria Silva', email: 'maria@example.com', - phone: '71999990000', + phones: [{ value: '71999990000' }], state: 'BA', city: 'Salvador', }), @@ -44,7 +44,7 @@ describe('signatureExport', () => { id: 2, name: 'João Souza', email: null, - phone: '71988887777', + phones: [{ value: '71988887777' }], state: 'BA', city: 'Feira de Santana', }) diff --git a/tests/unit/supporterSchema.unit.spec.ts b/tests/unit/supporterSchema.unit.spec.ts index 61daed57..a07d66a1 100644 --- a/tests/unit/supporterSchema.unit.spec.ts +++ b/tests/unit/supporterSchema.unit.spec.ts @@ -10,15 +10,15 @@ import { } from '@/lib/schemas/supporter' describe('supporter schemas', () => { - it('normalizes phone and optional BA municipality', () => { + it('normalizes phones and optional BA municipality', () => { const parsed = supporterCreateSchema.parse({ name: 'Maria da Silva', - phone: '+55 (71) 98888-7777', + phones: ['+55 (71) 98888-7777'], city: 'salvador', consentAccepted: true, }) - expect(parsed.phone).toBe('71988887777') + expect(parsed.phones).toEqual(['71988887777']) expect(parsed.city).toBe('Salvador') expect(resolveBahiaMunicipality('feira de santana')).toBe('Feira de Santana') expect(resolveBahiaMunicipality('Nowhere')).toBeNull() @@ -28,7 +28,7 @@ describe('supporter schemas', () => { expect( supporterCreateSchema.safeParse({ name: 'Maria Silva', - phone: '71988887777', + phones: ['71988887777'], voteIntention: 'certo', consentAccepted: true, }).success, @@ -37,7 +37,7 @@ describe('supporter schemas', () => { expect( supporterCreateSchema.safeParse({ name: 'Maria Silva', - phone: '71988887777', + phones: ['71988887777'], voteIntention: 'certo', consentAccepted: true, voteIntentionConsentAccepted: true, From be4bb49da94799ae56f17791303cb36b37df1fe0 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 13:17:05 -0300 Subject: [PATCH 03/14] =?UTF-8?q?plans(C123,C124):=20agenda=20=E2=80=94=20?= =?UTF-8?q?modal=20central=20como=20=C3=BAnica=20superf=C3=ADcie=20de=20cr?= =?UTF-8?q?iar/editar=20e=20tarefa=20sem=20data/hora?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/agenda-criacao-modal-central.md | 83 ++++++++++++++++++++++ docs/plans/atividades-tarefas-sem-prazo.md | 69 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 docs/plans/agenda-criacao-modal-central.md create mode 100644 docs/plans/atividades-tarefas-sem-prazo.md diff --git a/docs/plans/agenda-criacao-modal-central.md b/docs/plans/agenda-criacao-modal-central.md new file mode 100644 index 00000000..97796da3 --- /dev/null +++ b/docs/plans/agenda-criacao-modal-central.md @@ -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 `` 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` diff --git a/docs/plans/atividades-tarefas-sem-prazo.md b/docs/plans/atividades-tarefas-sem-prazo.md new file mode 100644 index 00000000..01af95b3 --- /dev/null +++ b/docs/plans/atividades-tarefas-sem-prazo.md @@ -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` From a740f1eb16fb874a4237b48d0c1724be04edcf64 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 13:16:54 -0300 Subject: [PATCH 04/14] =?UTF-8?q?fix(e2e):=20first()=20no=20getByLabel('At?= =?UTF-8?q?ividade=20relacionada')=20sob=20duplica=C3=A7=C3=A3o=20transit?= =?UTF-8?q?=C3=B3ria=20(flake=20pr=C3=A9-existente,=20precedente=20cd46985?= =?UTF-8?q?7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D12 (#641): strict-mode violation (2 triggers idênticos) só em runs completos sob carga. Estado estável tem 1 match (verificado em browser); o 2º match é duplicação transitória RSC-pending, mesma classe de cd469857. Mesmo alvo em campaignActivity:52 (único outro uso do label em e2e). --- ...2-getbylabel-atividade-relacionada-impl.md | 73 +++++++++++++++++++ tests/e2e/campaignActivity.e2e.spec.ts | 4 +- tests/e2e/campaignRegisterDemand.e2e.spec.ts | 4 +- 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 docs/plans/d12-getbylabel-atividade-relacionada-impl.md diff --git a/docs/plans/d12-getbylabel-atividade-relacionada-impl.md b/docs/plans/d12-getbylabel-atividade-relacionada-impl.md new file mode 100644 index 00000000..3d6656d0 --- /dev/null +++ b/docs/plans/d12-getbylabel-atividade-relacionada-impl.md @@ -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
classe cd469857] + D --> E["Fix: .first() no assert
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 diff --git a/tests/e2e/campaignActivity.e2e.spec.ts b/tests/e2e/campaignActivity.e2e.spec.ts index cdc88810..04e7c146 100644 --- a/tests/e2e/campaignActivity.e2e.spec.ts +++ b/tests/e2e/campaignActivity.e2e.spec.ts @@ -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`) diff --git a/tests/e2e/campaignRegisterDemand.e2e.spec.ts b/tests/e2e/campaignRegisterDemand.e2e.spec.ts index 3d9b4407..4e0551f7 100644 --- a/tests/e2e/campaignRegisterDemand.e2e.spec.ts +++ b/tests/e2e/campaignRegisterDemand.e2e.spec.ts @@ -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) From 95ac668c0b6b4839f9fe60e6e2020d24843d54c5 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 13:32:08 -0300 Subject: [PATCH 05/14] =?UTF-8?q?ops(work-issue):=20capture-review-debts?= =?UTF-8?q?=20aut=C3=B4nomo=20=E2=80=94=20agente=20decide=20o=20que=20regi?= =?UTF-8?q?strar=20e=20descartar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/capture-review-debts/SKILL.md | 22 ++++++++++++----- .agents/skills/work-issue/SKILL.md | 24 ++++++++++--------- .../skills/work-issue/execution-pipeline.md | 2 +- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/.agents/skills/capture-review-debts/SKILL.md b/.agents/skills/capture-review-debts/SKILL.md index 639b207c..d2680c00 100644 --- a/.agents/skills/capture-review-debts/SKILL.md +++ b/.agents/skills/capture-review-debts/SKILL.md @@ -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 diff --git a/.agents/skills/work-issue/SKILL.md b/.agents/skills/work-issue/SKILL.md index fcf4d038..6658cd3a 100644 --- a/.agents/skills/work-issue/SKILL.md +++ b/.agents/skills/work-issue/SKILL.md @@ -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/-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:** `-` 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 diff --git a/.agents/skills/work-issue/execution-pipeline.md b/.agents/skills/work-issue/execution-pipeline.md index cfbbce6e..fb745fbd 100644 --- a/.agents/skills/work-issue/execution-pipeline.md +++ b/.agents/skills/work-issue/execution-pipeline.md @@ -40,5 +40,5 @@ entrega. | Ator | Branch | UI | `capture-review-debts` | Cloud | | ---- | ------ | --- | ---------------------- | ----- | -| **Humano** (`work-issue`) | `-` (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`) | `-` (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/-` (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 | From 70070db8dc47d4f730219b09ccb087db0819b9d2 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 12:30:15 -0300 Subject: [PATCH 06/14] =?UTF-8?q?feat(C114):=20agenda=20da=20campanha=20es?= =?UTF-8?q?pelhada=20no=20Google=20Calendar=20com=20notifica=C3=A7=C3=A3o?= =?UTF-8?q?=20(push=20Teqo=20=E2=86=92=20Google)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Espelho oficial da agenda em calendário Google compartilhado via API (service account, jose + fetch): reconciliação por passada com id determinístico teqo+base32hex (upsert idempotente, sem estado por evento), hooks afterChange/afterDelete na activity (só campos espelhados, nunca lança), auto-resync quando calendarId muda, retry na view da agenda, pill de estado no header + FAB + diálogo (4 estados). Cancelado no Teqo deleta o evento no Google. Sem participantes no v1 (decisão de produto). Sem migration nova de tabela (hand-written). Closes #635 --- .env.example | 8 + docs/CHANGELOG-AGENTS.md | 2 + ...oogle-calendar-push-agenda-oficial-impl.md | 135 +++++ ...22-c114-followup-testes-estados-espelho.md | 57 ++ package.json | 1 + pnpm-lock.yaml | 11 +- .../(campaign)/campanha/(app)/agenda/page.tsx | 21 +- .../campanha/actions/googleCalendarSync.ts | 96 ++++ src/collections/Activity.ts | 5 + src/collections/GoogleCalendarSync.ts | 111 ++++ .../activity/AgendaGoogleSyncChrome.tsx | 121 +++++ .../activity/GoogleCalendarSyncDialog.tsx | 291 +++++++++++ src/lib/activityDescription.ts | 21 + src/lib/activityQuickActions.ts | 21 +- src/lib/campaignQuickActionContext.ts | 2 + src/lib/googleCalendarEventMapping.ts | 189 +++++++ src/lib/googleCalendarLink.ts | 28 + ...0260811_133332_add_google_calendar_sync.ts | 43 ++ src/migrations/index.ts | 8 +- src/payload-types.ts | 42 ++ src/payload.config.ts | 2 + src/utilities/access/googleCalendarSync.ts | 46 ++ src/utilities/calendarFeed.ts | 50 +- src/utilities/googleCalendarClient.ts | 206 ++++++++ src/utilities/googleCalendarSync.ts | 494 ++++++++++++++++++ src/utilities/googleCalendarSyncHooks.ts | 67 +++ .../e2e/campaignAgendaGoogleSync.e2e.spec.ts | 33 ++ tests/int/googleCalendarSync.int.spec.ts | 334 ++++++++++++ tests/unit/codebaseConventions.unit.spec.ts | 10 + tests/unit/googleCalendarClient.unit.spec.ts | 185 +++++++ .../googleCalendarEventMapping.unit.spec.ts | 183 +++++++ tests/unit/googleCalendarLink.unit.spec.ts | 30 ++ tests/unit/googleCalendarSync.unit.spec.ts | 240 +++++++++ .../unit/googleCalendarSyncHooks.unit.spec.ts | 146 ++++++ 34 files changed, 3209 insertions(+), 30 deletions(-) create mode 100644 docs/plans/c114-google-calendar-push-agenda-oficial-impl.md create mode 100644 docs/plans/c122-c114-followup-testes-estados-espelho.md create mode 100644 src/app/(campaign)/campanha/actions/googleCalendarSync.ts create mode 100644 src/collections/GoogleCalendarSync.ts create mode 100644 src/components/campaign/activity/AgendaGoogleSyncChrome.tsx create mode 100644 src/components/campaign/activity/GoogleCalendarSyncDialog.tsx create mode 100644 src/lib/activityDescription.ts create mode 100644 src/lib/googleCalendarEventMapping.ts create mode 100644 src/lib/googleCalendarLink.ts create mode 100644 src/migrations/20260811_133332_add_google_calendar_sync.ts create mode 100644 src/utilities/access/googleCalendarSync.ts create mode 100644 src/utilities/googleCalendarClient.ts create mode 100644 src/utilities/googleCalendarSync.ts create mode 100644 src/utilities/googleCalendarSyncHooks.ts create mode 100644 tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts create mode 100644 tests/int/googleCalendarSync.int.spec.ts create mode 100644 tests/unit/googleCalendarClient.unit.spec.ts create mode 100644 tests/unit/googleCalendarEventMapping.unit.spec.ts create mode 100644 tests/unit/googleCalendarLink.unit.spec.ts create mode 100644 tests/unit/googleCalendarSync.unit.spec.ts create mode 100644 tests/unit/googleCalendarSyncHooks.unit.spec.ts diff --git a/.env.example b/.env.example index f16a2ea4..2299af2a 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 200b3337..3966f556 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**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-`/`owned-consent-vacated-`) — 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). diff --git a/docs/plans/c114-google-calendar-push-agenda-oficial-impl.md b/docs/plans/c114-google-calendar-push-agenda-oficial-impl.md new file mode 100644 index 00000000..a2cc7db7 --- /dev/null +++ b/docs/plans/c114-google-calendar-push-agenda-oficial-impl.md @@ -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
campos relevantes mudaram?] -->|sim| S[runCampaignCalendarSync
utilities/googleCalendarSync.ts] + P[agenda page view
estado pausado → auto-retry] --> S + B["botão 'Sincronizar agora'"
actions/googleCalendarSync.ts] --> S + S --> L[load activities janela 90d+365d
espelho cheio do staff] + L --> G[Google Calendar API v3
client com service account JWT
jose + fetch] + G --> D[diff por id determinístico teqo_activity_<id>
cria/atualiza/remove] + D --> W[grava collection googleCalendarSync
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_` (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_`), `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=` + URL iCal pública `webcal://calendar.google.com/calendar/ical//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 `` 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` diff --git a/docs/plans/c122-c114-followup-testes-estados-espelho.md b/docs/plans/c122-c114-followup-testes-estados-espelho.md new file mode 100644 index 00000000..f459632a --- /dev/null +++ b/docs/plans/c122-c114-followup-testes-estados-espelho.md @@ -0,0 +1,57 @@ +# C122 — C114 follow-up: cobertura de testes dos estados do espelho Google + +Status: rascunho +Atualizado em: 2026-08-11 +Issue: # +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). diff --git a/package.json b/package.json index 660d0c83..0b02849c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74e4078a..0314f2ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/app/(campaign)/campanha/(app)/agenda/page.tsx b/src/app/(campaign)/campanha/(app)/agenda/page.tsx index 17a79263..20a3f1f4 100644 --- a/src/app/(campaign)/campanha/(app)/agenda/page.tsx +++ b/src/app/(campaign)/campanha/(app)/agenda/page.tsx @@ -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. */} + { + 'use server' + return runGoogleCalendarSyncNow() + }} + onSetDisabled={async (disabled) => { + 'use server' + return setGoogleCalendarSyncDisabled(disabled) + }} + /> => ({ + ...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 => + 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 => { + 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 => { + 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 => { + 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) + } +} diff --git a/src/collections/Activity.ts b/src/collections/Activity.ts index 49fdc4a8..aa7474b7 100644 --- a/src/collections/Activity.ts +++ b/src/collections/Activity.ts @@ -29,6 +29,7 @@ import { eligibleCampaignStaffWhere, } from '@/utilities/campaignAccess' import { systemStampedActorField } from '@/utilities/campaignAuditFields' +import { activityGoogleCalendarSyncHook } from '@/utilities/googleCalendarSyncHooks' const isActivityMutationShortcut = (context: Record | 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: [ { diff --git a/src/collections/GoogleCalendarSync.ts b/src/collections/GoogleCalendarSync.ts new file mode 100644 index 00000000..b3ca252e --- /dev/null +++ b/src/collections/GoogleCalendarSync.ts @@ -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, + }, + }, + ], +} diff --git a/src/components/campaign/activity/AgendaGoogleSyncChrome.tsx b/src/components/campaign/activity/AgendaGoogleSyncChrome.tsx new file mode 100644 index 00000000..11bafe2b --- /dev/null +++ b/src/components/campaign/activity/AgendaGoogleSyncChrome.tsx @@ -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 + onSetDisabled: (disabled: boolean) => Promise +} + +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 ( + + ) + }, [state.status, openSync]) + + return ( + <> + {pill} + + + + ) +} diff --git a/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx b/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx new file mode 100644 index 00000000..fb5ad864 --- /dev/null +++ b/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx @@ -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 + onSetDisabled: (disabled: boolean) => Promise +} + +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 = + +/** + * 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(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 ? ( +
+ +
+ + +
+ {copied && ( +

+ Link copiado! +

+ )} +

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

+
+ ) : null + + const instructions = ( +
+

Como adicionar ao Google Calendar:

+
    +
  1. Abra o Google Calendar no computador
  2. +
  3. + No menu lateral, clique em {'"'} + {'+'} + {'"'} ao lado de {'"'}Outras agendas{'"'} +
  4. +
  5. + Selecione {'"'}Por URL{'"'} +
  6. +
  7. + Cole o link acima e clique em {'"'}Adicionar agenda{'"'} +
  8. +
+

+ Apple Calendar e Outlook assinam a URL iCal pública do mesmo calendário. +

+
+ ) + + const syncButton = ( + + ) + + let body: React.ReactNode + + if (state.status === 'not-configured') { + body = ( +
+
+

Ainda não configurado

+

+ O Teqo segue 100% funcional — a agenda do Teqo nunca depende do Google. Para ativar o + espelho, um administrador precisa concluir a configuração: +

+
    + {NOT_CONFIGURED_RUNBOOK.map((step) => ( +
  1. {step}
  2. + ))} +
+
+
+ ) + } else if (state.status === 'disabled') { + body = ( +
+
+ A sincronização está desativada. A agenda do Teqo continua funcionando normalmente; nada é + enviado ao Google enquanto estiver desativada. +
+ {actionError &&

{actionError}

} +
+ +
+
+ ) + } else { + const paused = state.status === 'paused' + body = ( +
+ {paused ? ( +
+

Pausado — re-tentando

+

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

+ {state.lastErrorAt ? ( +

+ Última tentativa: {formatBahiaDateTimeLabel(state.lastErrorAt)} —{' '} + {state.lastError ?? 'erro desconhecido'} +

+ ) : null} +
+ ) : ( +

+ Sincronizado — as mudanças da agenda já refletiram no Google. + {state.lastSuccessAt + ? ` Última sincronização: ${formatBahiaDateTimeLabel(state.lastSuccessAt)}.` + : ''} +

+ )} + + {linkBlock} + {instructions} + + {actionError &&

{actionError}

} + +
+ {paused ? ( + + ) : null} + {syncButton} + +
+
+ ) + } + + 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 ( + + + + Agenda da Campanha no Google + {description} + +
{body}
+ + Fechar + +
+
+ ) + } + + return ( + + + + Agenda da Campanha no Google + {description} + + {body} + + + ) +} diff --git a/src/lib/activityDescription.ts b/src/lib/activityDescription.ts new file mode 100644 index 00000000..98f27b98 --- /dev/null +++ b/src/lib/activityDescription.ts @@ -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 + +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 +} diff --git a/src/lib/activityQuickActions.ts b/src/lib/activityQuickActions.ts index 80cada99..957b2183 100644 --- a/src/lib/activityQuickActions.ts +++ b/src/lib/activityQuickActions.ts @@ -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', diff --git a/src/lib/campaignQuickActionContext.ts b/src/lib/campaignQuickActionContext.ts index 68420972..eb06ea72 100644 --- a/src/lib/campaignQuickActionContext.ts +++ b/src/lib/campaignQuickActionContext.ts @@ -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 => ({}) diff --git a/src/lib/googleCalendarEventMapping.ts b/src/lib/googleCalendarEventMapping.ts new file mode 100644 index 00000000..011b61ab --- /dev/null +++ b/src/lib/googleCalendarEventMapping.ts @@ -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, + 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) diff --git a/src/lib/googleCalendarLink.ts b/src/lib/googleCalendarLink.ts new file mode 100644 index 00000000..4be4d901 --- /dev/null +++ b/src/lib/googleCalendarLink.ts @@ -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` diff --git a/src/migrations/20260811_133332_add_google_calendar_sync.ts b/src/migrations/20260811_133332_add_google_calendar_sync.ts new file mode 100644 index 00000000..0593b765 --- /dev/null +++ b/src/migrations/20260811_133332_add_google_calendar_sync.ts @@ -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 { + 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 { + 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; + `) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index a6d78dfe..b2f81972 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -48,6 +48,7 @@ import * as migration_20260808_184113_remodel_activity_responsible from './20260 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 = [ { @@ -298,6 +299,11 @@ export const migrations = [ { 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' + 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', }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index 3b72eaa0..37bf8da5 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -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 | MunicipalityUpdateSelect; activity: ActivitySelect | ActivitySelect; calendarFeed: CalendarFeedSelect | CalendarFeedSelect; + googleCalendarSync: GoogleCalendarSyncSelect | GoogleCalendarSyncSelect; electionTally: ElectionTallySelect | ElectionTallySelect; electionCandidateVote: ElectionCandidateVoteSelect | ElectionCandidateVoteSelect; electionCandidate: ElectionCandidateSelect | ElectionCandidateSelect; @@ -890,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). * @@ -1359,6 +1382,10 @@ export interface PayloadLockedDocument { relationTo: 'calendarFeed'; value: number | CalendarFeed; } | null) + | ({ + relationTo: 'googleCalendarSync'; + value: number | GoogleCalendarSync; + } | null) | ({ relationTo: 'electionTally'; value: number | ElectionTally; @@ -1858,6 +1885,20 @@ export interface CalendarFeedSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "googleCalendarSync_select". + */ +export interface GoogleCalendarSyncSelect { + 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". @@ -2423,6 +2464,7 @@ export interface TaskCreateCollectionExport { | 'municipalityUpdate' | 'activity' | 'calendarFeed' + | 'googleCalendarSync' | 'electionTally' | 'electionCandidateVote' | 'electionCandidate' diff --git a/src/payload.config.ts b/src/payload.config.ts index e0878d68..91297270 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -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, diff --git a/src/utilities/access/googleCalendarSync.ts b/src/utilities/access/googleCalendarSync.ts new file mode 100644 index 00000000..f7479b56 --- /dev/null +++ b/src/utilities/access/googleCalendarSync.ts @@ -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 diff --git a/src/utilities/calendarFeed.ts b/src/utilities/calendarFeed.ts index d7cc4540..13d86455 100644 --- a/src/utilities/calendarFeed.ts +++ b/src/utilities/calendarFeed.ts @@ -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 => { - 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 diff --git a/src/utilities/googleCalendarClient.ts b/src/utilities/googleCalendarClient.ts new file mode 100644 index 00000000..fa928c87 --- /dev/null +++ b/src/utilities/googleCalendarClient.ts @@ -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 + insertEvent: (calendarId: string, event: GoogleRemoteEvent) => Promise + updateEvent: (calendarId: string, eventId: string, event: GoogleRemoteEvent) => Promise + deleteEvent: (calendarId: string, eventId: string) => Promise +} + +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 => { + 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 => { + 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 => { + 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 => { + 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 } +} diff --git a/src/utilities/googleCalendarSync.ts b/src/utilities/googleCalendarSync.ts new file mode 100644 index 00000000..17529d35 --- /dev/null +++ b/src/utilities/googleCalendarSync.ts @@ -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 => { + // 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 => { + 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 }> => { + 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> => { + 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 => { + 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 => { + 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() + for (const activity of activities) { + wantedById.set(activity.id, activity) + } + + let created = 0 + let updated = 0 + let deleted = 0 + + const remoteEventIds = new Set() + + 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 + >, +): Promise => { + 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 => { + 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 | 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. + */ diff --git a/src/utilities/googleCalendarSyncHooks.ts b/src/utilities/googleCalendarSyncHooks.ts new file mode 100644 index 00000000..ec79ee6b --- /dev/null +++ b/src/utilities/googleCalendarSyncHooks.ts @@ -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 => { + 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 => { + try { + if (shouldSyncConfigChange({ operation, doc, previousDoc })) { + await runCampaignCalendarSync(req.payload, { reason: 'config-change', req }) + } + } catch { + // never throw into admin writes + } + return doc +} diff --git a/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts b/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts new file mode 100644 index 00000000..20fd5911 --- /dev/null +++ b/tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts @@ -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() + }) +}) diff --git a/tests/int/googleCalendarSync.int.spec.ts b/tests/int/googleCalendarSync.int.spec.ts new file mode 100644 index 00000000..055a2b6c --- /dev/null +++ b/tests/int/googleCalendarSync.int.spec.ts @@ -0,0 +1,334 @@ +// @vitest-environment node + +import { getPayload, type Payload } from 'payload' +import { afterEach, beforeAll, describe, expect, it } from 'vitest' + +import { googleEventIdForActivity } from '@/lib/googleCalendarEventMapping' +import config from '@/payload.config' +import type { GoogleCalendarClient, GoogleRemoteEvent } from '@/utilities/googleCalendarClient' +import { + GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV, + loadGoogleCalendarSyncConfig, + readGoogleCalendarSyncView, + runCampaignCalendarSync, +} from '@/utilities/googleCalendarSync' +import { hookFilledCreateData } from '@/utilities/hookFilledData' + +import { installCampaignFixtures } from '../helpers/campaignFixtures' + +let payload: Payload +const campaignFixtures = installCampaignFixtures({ + getPayload: () => payload, + setPayload: (nextPayload) => { + payload = nextPayload + }, +}) + +const FAKE_KEY = Buffer.from( + JSON.stringify({ + client_email: 'teqo-sa@projeto.iam.gserviceaccount.com', + private_key: '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n', + }), + 'utf8', +).toString('base64') + +const originalKey = process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] + +const withCredential = async (run: () => Promise): Promise => { + process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] = FAKE_KEY + try { + return await run() + } finally { + if (originalKey === undefined) { + delete process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] + } else { + process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] = originalKey + } + } +} + +/** In-memory Google calendar stub — list/insert/update/delete over an array. */ +const createStubClient = (store: GoogleRemoteEvent[] = []): GoogleCalendarClient => ({ + listEvents: async (_calendarId, range) => + store.filter((event) => { + const start = event.start?.dateTime ?? event.start?.date ?? '' + return start >= range.timeMin && start <= range.timeMax + }), + insertEvent: async (_calendarId, event) => { + store.push(event) + }, + updateEvent: async (_calendarId, eventId, event) => { + const index = store.findIndex((entry) => entry.id === eventId) + if (index >= 0) store[index] = event + }, + deleteEvent: async (_calendarId, eventId) => { + const index = store.findIndex((entry) => entry.id === eventId) + if (index >= 0) store.splice(index, 1) + }, +}) + +const calendarA = 'c_campanha_a@group.calendar.google.com' +const calendarB = 'c_campanha_b@group.calendar.google.com' + +describe('campaign Google calendar sync engine (C114)', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + afterEach(async () => { + // Teqo never depends on Google: the engine must never throw into callers. + // Cleanup happens per test; the env is restored by withCredential. + await payload.delete({ + collection: 'activity', + where: { title: { like: 'C114%' } }, + overrideAccess: true, + }) + await payload.delete({ + collection: 'googleCalendarSync', + where: { id: { exists: true } }, + overrideAccess: true, + }) + }) + + const createActivity = async (overrides: Record = {}) => { + const municipality = await campaignFixtures().getMunicipality() + return payload.create({ + collection: 'activity', + data: hookFilledCreateData<'activity'>({ + title: `C114 ${crypto.randomUUID().slice(0, 8)}`, + tags: ['Caminhada'], + status: 'confirmado', + startAt: new Date(Date.now() + 86_400_000).toISOString(), + municipality: municipality.id, + ...overrides, + }), + depth: 0, + overrideAccess: true, + }) + } + + const createConfig = async (calendarId: string) => + payload.create({ + collection: 'googleCalendarSync', + data: { calendarId }, + depth: 0, + overrideAccess: true, + }) + + it('creates the full mirror on the first pass (timed + all-day, municipality summary)', async () => { + const municipality = await campaignFixtures().getMunicipality() + const timed = await createActivity({ municipality: municipality.id, locality: 'Centro' }) + const allDay = await createActivity({ + municipality: municipality.id, + allDay: true, + startAt: '2026-08-10T03:00:00.000Z', + endAt: '2026-08-12T03:00:00.000Z', + }) + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + + const outcome = await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client }), + ) + + expect(outcome.status).toBe('synced') + expect(outcome.created).toBe(2) + expect(outcome.updated).toBe(0) + expect(outcome.deleted).toBe(0) + + const timedEvent = store.find((event) => event.id === googleEventIdForActivity(timed.id)) + expect(timedEvent?.summary).toBe(`[${municipality.name}] ${timed.title}`) + expect(timedEvent?.location).toBe('Centro') + expect(timedEvent?.start).toEqual({ dateTime: expect.stringMatching(/-03:00$/) }) + + const allDayEvent = store.find((event) => event.id === googleEventIdForActivity(allDay.id)) + expect(allDayEvent?.start).toEqual({ date: '2026-08-10' }) + expect(allDayEvent?.end).toEqual({ date: '2026-08-13' }) + + const doc = await loadGoogleCalendarSyncConfig(payload) + expect(doc?.lastSuccessAt).toBeTruthy() + expect(doc?.lastError).toBeNull() + }) + + it('converges: a second pass with no changes touches nothing', async () => { + await createActivity() + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + + const first = await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client }), + ) + expect(first.created).toBe(1) + + const second = await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client }), + ) + expect(second).toMatchObject({ created: 0, updated: 0, deleted: 0, status: 'synced' }) + }) + + it('updates only the drifted event and ignores foreign calendar events', async () => { + const activity = await createActivity() + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [ + { id: 'foreign-manual-event', summary: 'Mantido pela conta da campanha' }, + ] + const client = createStubClient(store) + await withCredential(() => runCampaignCalendarSync(payload, { reason: 'manual', client })) + + // Title is immutable after creation (canonical slug rule) — drift a + // mutable mirrored field instead (locality is in the mirror surface). + await payload.update({ + collection: 'activity', + id: activity.id, + data: { locality: 'Centro (atualizado)' }, + depth: 0, + overrideAccess: true, + }) + + const outcome = await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client }), + ) + expect(outcome.updated).toBe(1) + expect(outcome.deleted).toBe(0) + expect(store.find((event) => event.id === 'foreign-manual-event')).toBeTruthy() + expect( + store.find((event) => event.id === googleEventIdForActivity(activity.id))?.location, + ).toBe('Centro (atualizado)') + }) + + it('canceled activities leave Google: their events are deleted, none created', async () => { + const activity = await createActivity() + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + await withCredential(() => runCampaignCalendarSync(payload, { reason: 'manual', client })) + expect(store).toHaveLength(1) + + await payload.update({ + collection: 'activity', + id: activity.id, + data: { status: 'cancelado' }, + depth: 0, + overrideAccess: true, + }) + + const outcome = await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client }), + ) + expect(outcome.deleted).toBe(1) + expect(store).toHaveLength(0) + }) + + it('hard-deleted activities leave no ghost in Google', async () => { + const activity = await createActivity() + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + await withCredential(() => runCampaignCalendarSync(payload, { reason: 'manual', client })) + expect(store).toHaveLength(1) + + await payload.delete({ collection: 'activity', id: activity.id, overrideAccess: true }) + + const outcome = await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client }), + ) + expect(outcome.deleted).toBe(1) + expect(store).toHaveLength(0) + }) + + it('activities outside the window never reach Google', async () => { + await createActivity({ startAt: '2029-01-01T12:00:00.000Z' }) + await createConfig(calendarA) + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + + const outcome = await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client }), + ) + expect(outcome.created).toBe(0) + expect(store).toHaveLength(0) + }) + + it('failures land in paused and the next success recovers to synced', async () => { + await createActivity() + await createConfig(calendarA) + + const failingClient: GoogleCalendarClient = { + ...createStubClient(), + listEvents: async () => { + throw new Error('Google fora do ar (simulado)') + }, + } + const failed = await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client: failingClient }), + ) + expect(failed.status).toBe('paused') + + const doc = await loadGoogleCalendarSyncConfig(payload) + expect(doc?.lastErrorAt).toBeTruthy() + expect(doc?.lastError).toContain('Google fora do ar') + + // The pill reads with the credential present (same context the run had). + const failedView = await withCredential(() => readGoogleCalendarSyncView(payload)) + expect(failedView.status).toBe('paused') + + const store: GoogleRemoteEvent[] = [] + const recovered = await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client: createStubClient(store) }), + ) + expect(recovered.status).toBe('synced') + expect(recovered.created).toBe(1) + const recoveredView = await withCredential(() => readGoogleCalendarSyncView(payload)) + expect(recoveredView.status).toBe('synced') + }) + + it('without a credential or calendar the engine is a no-op (fail-closed)', async () => { + await createActivity() + const store: GoogleRemoteEvent[] = [] + const client = createStubClient(store) + + // No config doc, no env key. + const outcome = await runCampaignCalendarSync(payload, { reason: 'manual', client }) + expect(outcome.status).toBe('not-configured') + expect(store).toHaveLength(0) + + await createConfig(calendarA) + // Config present but env key absent (outside withCredential). + const withoutKey = await runCampaignCalendarSync(payload, { reason: 'manual', client }) + expect(withoutKey.status).toBe('not-configured') + expect(store).toHaveLength(0) + }) + + it('changing the calendarId reconciles into the new calendar (D7 engine side)', async () => { + await createActivity() + await createConfig(calendarA) + const storeA: GoogleRemoteEvent[] = [] + await withCredential(() => + runCampaignCalendarSync(payload, { reason: 'manual', client: createStubClient(storeA) }), + ) + expect(storeA).toHaveLength(1) + + const config = await loadGoogleCalendarSyncConfig(payload) + await payload.update({ + collection: 'googleCalendarSync', + id: config!.id, + data: { calendarId: calendarB }, + depth: 0, + overrideAccess: true, + }) + + const storeB: GoogleRemoteEvent[] = [] + const outcome = await withCredential(() => + runCampaignCalendarSync(payload, { + reason: 'config-change', + client: createStubClient(storeB), + }), + ) + expect(outcome.created).toBe(1) + expect(storeB).toHaveLength(1) + // The abandoned calendar keeps its last mirror (documented, no retro-cleanup). + expect(storeA).toHaveLength(1) + }) +}) diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 34587e54..7c27af90 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -87,6 +87,9 @@ describe('banned campaign terminology', () => { 'src/lib/projectionSheetParse.ts', // Fixture entry deliberately exercises legacy visits written pre-rename. 'tests/unit/recentVisits.unit.spec.ts', + // English prose ("one row in practice") trips the accent-tolerant + // /pra[çc]/ — genuine code comment, not the retired term. + 'src/collections/GoogleCalendarSync.ts', ], }, { @@ -400,6 +403,13 @@ describe('src/utilities top-level is pinned', () => { 'campaignAccess.ts', 'campaignActionContext.ts', 'campaignAuditFields.ts', + // C114 — Google Calendar mirror: thin Calendar API v3 transport (jose+fetch). + 'googleCalendarClient.ts', + // C114 — Google Calendar mirror: reconciliation engine + hook guards. + 'googleCalendarSync.ts', + // C114 — Google Calendar mirror: activity/config hooks (separate module so + // the engine is mockable at the import boundary in tests). + 'googleCalendarSyncHooks.ts', 'campaignAuth.ts', 'campaignBiometricsPrompt.ts', 'campaignColumnVisibilityCookie.ts', diff --git a/tests/unit/googleCalendarClient.unit.spec.ts b/tests/unit/googleCalendarClient.unit.spec.ts new file mode 100644 index 00000000..e77858f6 --- /dev/null +++ b/tests/unit/googleCalendarClient.unit.spec.ts @@ -0,0 +1,185 @@ +// @vitest-environment node + +import { exportPKCS8, exportSPKI, generateKeyPair, importSPKI, jwtVerify } from 'jose' +import { beforeAll, describe, expect, it } from 'vitest' + +import { + buildServiceAccountAssertion, + createGoogleCalendarClient, + GOOGLE_CALENDAR_SCOPE, + GoogleCalendarApiError, + type FetchLike, + type GoogleCalendarCredentials, +} from '@/utilities/googleCalendarClient' + +describe('buildServiceAccountAssertion', () => { + let credentials: GoogleCalendarCredentials + let publicKeyPem: string + + beforeAll(async () => { + const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true }) + credentials = { + clientEmail: 'teqo-sa@projeto.iam.gserviceaccount.com', + privateKey: await exportPKCS8(privateKey), + } + publicKeyPem = await exportSPKI(publicKey) + }) + + it('signs an RS256 JWT with the service-account claims the token endpoint expects', async () => { + const assertion = await buildServiceAccountAssertion(credentials, 1_800_000_000) + const { payload } = await jwtVerify(assertion, await importSPKI(publicKeyPem, 'RS256')) + + expect(payload.iss).toBe(credentials.clientEmail) + expect(payload.aud).toBe('https://oauth2.googleapis.com/token') + expect(payload.scope).toBe(GOOGLE_CALENDAR_SCOPE) + expect(payload.iat).toBe(1_800_000_000) + expect((payload.exp as number) - (payload.iat as number)).toBe(3600) + }) +}) + +describe('createGoogleCalendarClient', () => { + let credentials: GoogleCalendarCredentials + + beforeAll(async () => { + // The token endpoint is stubbed, but the client still SIGNS the JWT + // assertion before posting — a real key keeps the tests honest. + const { privateKey } = await generateKeyPair('RS256', { extractable: true }) + credentials = { + clientEmail: 'teqo-sa@projeto.iam.gserviceaccount.com', + privateKey: await exportPKCS8(privateKey), + } + }) + + const calendarId = 'c_abc@group.calendar.google.com' + + /** Minimal fake transport: token endpoint + calendar REST in memory. */ + const stubTransport = (events: Array> = []) => { + let tokenRequests = 0 + const calls: Array<{ url: string; method: string; body?: string; auth?: string }> = [] + + const fetchImpl: FetchLike = (async (input: RequestInfo | URL, _init?: RequestInit) => { + const url = String(input) + const method = (_init?.method ?? 'GET').toUpperCase() + const body = typeof _init?.body === 'string' ? _init.body : undefined + const auth = (_init?.headers as Record | undefined)?.['Authorization'] + calls.push({ url, method, body, auth }) + + if (url === 'https://oauth2.googleapis.com/token') { + tokenRequests += 1 + return new Response( + JSON.stringify({ access_token: `token-${tokenRequests}`, expires_in: 3600 }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + + if (url.includes('/events') && method === 'POST') { + const event = JSON.parse(body ?? '{}') as Record + events.push(event) + return new Response(JSON.stringify(event), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + if (url.includes('/events') && method === 'PUT') { + const event = JSON.parse(body ?? '{}') as Record + const index = events.findIndex((e) => e.id === event.id) + if (index >= 0) events[index] = event + return new Response(JSON.stringify(event), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + if (url.includes('/events') && method === 'DELETE') { + const eventId = url.split('/').pop() + const index = events.findIndex((e) => e.id === eventId) + if (index >= 0) events.splice(index, 1) + return new Response(null, { status: 204 }) + } + if (url.includes('/events')) { + return new Response(JSON.stringify({ items: events }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + return new Response('not found', { status: 404 }) + }) as FetchLike + + return { fetchImpl, calls, events, getTokenRequests: () => tokenRequests } + } + + it('inserts with the deterministic id, bearer auth and JSON body', async () => { + const { fetchImpl, calls, events } = stubTransport() + const client = createGoogleCalendarClient(credentials, fetchImpl) + + await client.insertEvent(calendarId, { id: 'teqo1a', summary: 'S', description: 'D' }) + + expect(events).toHaveLength(1) + expect(events[0].id).toBe('teqo1a') + const insertCall = calls.find((call) => call.method === 'POST' && call.url.includes('/events')) + expect(insertCall?.url).toContain(`/calendars/${encodeURIComponent(calendarId)}/events`) + expect(insertCall?.auth).toBe('Bearer token-1') + }) + + it('lists with the window params and pagination', async () => { + const { fetchImpl, calls } = stubTransport() + const client = createGoogleCalendarClient(credentials, fetchImpl) + + await client.listEvents(calendarId, { + timeMin: '2026-01-01T00:00:00Z', + timeMax: '2026-12-31T00:00:00Z', + }) + + const listCall = calls.find((call) => call.method === 'GET') + expect(listCall?.url).toContain('timeMin=2026-01-01T00%3A00%3A00Z') + expect(listCall?.url).toContain('timeMax=2026-12-31T00%3A00%3A00Z') + expect(listCall?.url).toContain('maxResults=2500') + }) + + it('re-mints the token once on a 401 and retries the call', async () => { + let calendarCalls = 0 + const fetchImpl: FetchLike = (async (input: RequestInfo | URL, _init?: RequestInit) => { + const url = String(input) + if (url === 'https://oauth2.googleapis.com/token') { + return new Response(JSON.stringify({ access_token: 'fresh-token', expires_in: 3600 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + calendarCalls += 1 + if (calendarCalls === 1) { + return new Response('unauthorized', { status: 401 }) + } + return new Response(JSON.stringify({ items: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) as FetchLike + + const client = createGoogleCalendarClient(credentials, fetchImpl) + const events = await client.listEvents(calendarId, { + timeMin: '2026-01-01T00:00:00Z', + timeMax: '2026-12-31T00:00:00Z', + }) + + expect(events).toEqual([]) + expect(calendarCalls).toBe(2) + }) + + it('throws a typed error on a non-401 failure without leaking the body', async () => { + const fetchImpl: FetchLike = (async (input: RequestInfo | URL) => { + const url = String(input) + if (url === 'https://oauth2.googleapis.com/token') { + return new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 403 }) + } + return new Response('ok', { status: 200 }) + }) as FetchLike + + const client = createGoogleCalendarClient(credentials, fetchImpl) + await expect( + client.listEvents(calendarId, { timeMin: 'a', timeMax: 'b' }), + ).rejects.toBeInstanceOf(GoogleCalendarApiError) + }) +}) diff --git a/tests/unit/googleCalendarEventMapping.unit.spec.ts b/tests/unit/googleCalendarEventMapping.unit.spec.ts new file mode 100644 index 00000000..d633f7fa --- /dev/null +++ b/tests/unit/googleCalendarEventMapping.unit.spec.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest' + +import { + buildGoogleCalendarDescription, + buildGoogleEventPayload, + decodeGoogleEventActivityId, + googleEventContentEquals, + googleEventIdForActivity, + type GoogleRemoteEvent, +} from '@/lib/googleCalendarEventMapping' + +type MappingSource = Parameters[0] + +const baseActivity = (overrides: Partial = {}): MappingSource => + ({ + id: 42, + title: 'Caminhada no Cabula', + startAt: '2026-08-15T19:00:00.000Z', + endAt: '2026-08-15T22:00:00.000Z', + allDay: false, + locality: 'Centro', + tags: ['Caminhada'], + deputyPresent: true, + ...overrides, + }) as MappingSource + +describe('googleEventIdForActivity / decodeGoogleEventActivityId', () => { + it('encodes ids in base32hex under the teqo prefix (charset do Google)', () => { + expect(googleEventIdForActivity(0)).toBe('teqo0') + expect(googleEventIdForActivity(1)).toBe('teqo1') + expect(googleEventIdForActivity(31)).toBe('teqov') + expect(googleEventIdForActivity(32)).toBe('teqo10') + expect(googleEventIdForActivity(1000)).toBe('teqov8') + }) + + it('round-trips any positive id', () => { + for (const id of [1, 7, 42, 255, 1024, 123_456, 999_999_999]) { + const encoded = googleEventIdForActivity(id) + expect(encoded).toMatch(/^teqo[a-v0-9]+$/) + expect(decodeGoogleEventActivityId(encoded)).toBe(id) + } + }) + + it('rejects foreign and malformed ids', () => { + expect(decodeGoogleEventActivityId('meu-evento-42')).toBeNull() + expect(decodeGoogleEventActivityId('teqo')).toBeNull() + expect(decodeGoogleEventActivityId('teqoz')).toBeNull() + expect(decodeGoogleEventActivityId('teqoA1')).toBeNull() + }) + + it('rejects invalid activity ids', () => { + expect(() => googleEventIdForActivity(-1)).toThrow() + expect(() => googleEventIdForActivity(1.5)).toThrow() + }) +}) + +describe('buildGoogleEventPayload', () => { + it('maps a timed activity to RFC3339 dateTimes with the fixed -03:00 offset', () => { + const payload = buildGoogleEventPayload(baseActivity(), 'Salvador') + + expect(payload.id).toBe('teqo1a') + expect(payload.summary).toBe('[Salvador] Caminhada no Cabula') + expect(payload.start).toEqual({ dateTime: '2026-08-15T16:00:00-03:00' }) + expect(payload.end).toEqual({ dateTime: '2026-08-15T19:00:00-03:00' }) + expect(payload.location).toBe('Centro') + }) + + it('builds the description without unnecessary PII', () => { + const payload = buildGoogleEventPayload(baseActivity(), 'Salvador') + + expect(payload.description).toContain('Município: Salvador') + expect(payload.description).toContain('Local: Centro') + expect(payload.description).toContain('Tags: Caminhada') + expect(payload.description).toContain('Deputado presente') + }) + + it('synthesizes end = start + 1h when the activity has no endAt (Google exige end > start)', () => { + const payload = buildGoogleEventPayload( + baseActivity({ endAt: null, startAt: '2026-08-15T19:00:00.000Z' }), + 'Salvador', + ) + + expect(payload.start).toEqual({ dateTime: '2026-08-15T16:00:00-03:00' }) + expect(payload.end).toEqual({ dateTime: '2026-08-15T17:00:00-03:00' }) + }) + + it('maps all-day activities to civil dates with the exclusive end (C104 convention)', () => { + const payload = buildGoogleEventPayload( + baseActivity({ + allDay: true, + startAt: '2026-08-10T03:00:00.000Z', + endAt: '2026-08-12T03:00:00.000Z', + locality: '', + }), + 'Feira de Santana', + ) + + expect(payload.start).toEqual({ date: '2026-08-10' }) + expect(payload.end).toEqual({ date: '2026-08-13' }) + }) + + it('falls back to the municipality name as the location when locality is empty', () => { + const payload = buildGoogleEventPayload(baseActivity({ locality: '' }), 'Salvador') + expect(payload.location).toBe('Salvador') + }) + + it('omits location entirely when neither locality nor municipality name exist', () => { + const payload = buildGoogleEventPayload(baseActivity({ locality: '' })) + expect(payload.location).toBeUndefined() + expect(payload.summary).toBe('Caminhada no Cabula') + }) +}) + +describe('buildGoogleCalendarDescription', () => { + it('is empty when there is nothing to say', () => { + expect(buildGoogleCalendarDescription({ locality: '', tags: [], deputyPresent: false })).toBe( + '', + ) + }) +}) + +describe('googleEventContentEquals', () => { + const payload = buildGoogleEventPayload(baseActivity(), 'Salvador') + + it('treats the same instant under a different timezone echo as equal', () => { + const remote: GoogleRemoteEvent = { + id: payload.id, + summary: payload.summary, + description: payload.description, + location: payload.location, + // Calendar in America/Sao_Paulo echoes the same instant with its own offset. + start: { dateTime: '2026-08-15T16:00:00-03:00', timeZone: 'America/Sao_Paulo' }, + end: { dateTime: '2026-08-15T19:00:00-03:00', timeZone: 'America/Sao_Paulo' }, + } + expect(googleEventContentEquals(remote, payload)).toBe(true) + }) + + it('flags content drift', () => { + const remote: GoogleRemoteEvent = { ...payload, summary: '[Salvador] Outro título' } + expect(googleEventContentEquals(remote, payload)).toBe(false) + }) + + it('flags a moved instant', () => { + const remote: GoogleRemoteEvent = { + ...payload, + start: { dateTime: '2026-08-16T16:00:00-03:00' }, + end: { dateTime: '2026-08-16T19:00:00-03:00' }, + } + expect(googleEventContentEquals(remote, payload)).toBe(false) + }) + + it('compares all-day events by civil date', () => { + const allDayPayload = buildGoogleEventPayload( + baseActivity({ + allDay: true, + startAt: '2026-08-10T03:00:00.000Z', + endAt: '2026-08-12T03:00:00.000Z', + }), + 'Feira de Santana', + ) + const remote: GoogleRemoteEvent = { + id: allDayPayload.id, + summary: allDayPayload.summary, + description: allDayPayload.description, + location: allDayPayload.location, + start: { date: '2026-08-10' }, + end: { date: '2026-08-13' }, + } + expect(googleEventContentEquals(remote, allDayPayload)).toBe(true) + }) + + it('treats a missing remote description as empty (not drift)', () => { + const remote: GoogleRemoteEvent = { + id: payload.id, + summary: payload.summary, + start: payload.start, + end: payload.end, + } + expect( + googleEventContentEquals(remote, { ...payload, description: '', location: undefined }), + ).toBe(true) + }) +}) diff --git a/tests/unit/googleCalendarLink.unit.spec.ts b/tests/unit/googleCalendarLink.unit.spec.ts new file mode 100644 index 00000000..6acde48c --- /dev/null +++ b/tests/unit/googleCalendarLink.unit.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' + +import { + buildGoogleCalendarAddLink, + buildGoogleCalendarPublicIcalUrl, + buildGoogleCalendarWebcalUrl, +} from '@/lib/googleCalendarLink' + +describe('googleCalendarLink', () => { + const calendarId = 'c_abc123@group.calendar.google.com' + + it('builds the webcal subscription URL Apple/Outlook consume', () => { + expect(buildGoogleCalendarWebcalUrl(calendarId)).toBe( + 'webcal://calendar.google.com/calendar/ical/c_abc123%40group.calendar.google.com/public/basic.ics', + ) + }) + + it('builds the Google add-by-URL link with the webcal URL as cid', () => { + const link = buildGoogleCalendarAddLink(calendarId) + expect(link).toBe( + 'https://calendar.google.com/calendar/r?cid=webcal%3A%2F%2Fcalendar.google.com%2Fcalendar%2Fical%2Fc_abc123%2540group.calendar.google.com%2Fpublic%2Fbasic.ics', + ) + }) + + it('builds the public HTTPS iCal URL of the same calendar', () => { + expect(buildGoogleCalendarPublicIcalUrl(calendarId)).toBe( + 'https://calendar.google.com/calendar/ical/c_abc123%40group.calendar.google.com/public/basic.ics', + ) + }) +}) diff --git a/tests/unit/googleCalendarSync.unit.spec.ts b/tests/unit/googleCalendarSync.unit.spec.ts new file mode 100644 index 00000000..569786bf --- /dev/null +++ b/tests/unit/googleCalendarSync.unit.spec.ts @@ -0,0 +1,240 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import type { Activity, GoogleCalendarSync as GoogleCalendarSyncDoc } from '@/payload-types' + +import { + deriveGoogleCalendarSyncStatus, + GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV, + readGoogleServiceAccountCredentials, + shouldSyncActivityOperation, + shouldSyncConfigChange, +} from '@/utilities/googleCalendarSync' + +describe('deriveGoogleCalendarSyncStatus', () => { + const configured = { hasCredential: true, calendarId: 'c_abc@group.calendar.google.com' } + + it('is not-configured without credential or calendar id (fail-closed)', () => { + expect(deriveGoogleCalendarSyncStatus({ hasCredential: false, calendarId: null })).toBe( + 'not-configured', + ) + expect(deriveGoogleCalendarSyncStatus({ hasCredential: true, calendarId: null })).toBe( + 'not-configured', + ) + expect(deriveGoogleCalendarSyncStatus({ hasCredential: false, calendarId: 'x' })).toBe( + 'not-configured', + ) + }) + + it('is disabled while disabledAt is set', () => { + expect( + deriveGoogleCalendarSyncStatus({ ...configured, disabledAt: '2026-08-11T10:00:00.000Z' }), + ).toBe('disabled') + }) + + it('is paused while the last attempt failed', () => { + expect( + deriveGoogleCalendarSyncStatus({ + ...configured, + lastSuccessAt: '2026-08-11T09:00:00.000Z', + lastErrorAt: '2026-08-11T10:00:00.000Z', + }), + ).toBe('paused') + expect( + deriveGoogleCalendarSyncStatus({ + ...configured, + lastErrorAt: '2026-08-11T10:00:00.000Z', + }), + ).toBe('paused') + }) + + it('recovers to synced after the last success', () => { + expect( + deriveGoogleCalendarSyncStatus({ + ...configured, + lastSuccessAt: '2026-08-11T10:30:00.000Z', + lastErrorAt: '2026-08-11T10:00:00.000Z', + }), + ).toBe('synced') + expect(deriveGoogleCalendarSyncStatus(configured)).toBe('synced') + }) +}) + +describe('readGoogleServiceAccountCredentials', () => { + const originalKey = process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] + + afterEach(() => { + if (originalKey === undefined) { + delete process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] + } else { + process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] = originalKey + } + }) + + it('parses the base64 JSON key into credentials', () => { + const payload = { + client_email: 'teqo-sa@projeto.iam.gserviceaccount.com', + private_key: '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n', + } + process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] = Buffer.from( + JSON.stringify(payload), + 'utf8', + ).toString('base64') + + expect(readGoogleServiceAccountCredentials()).toEqual({ + clientEmail: payload.client_email, + privateKey: payload.private_key, + }) + }) + + it('returns null when the env is absent or malformed (fail-closed)', () => { + delete process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] + expect(readGoogleServiceAccountCredentials()).toBeNull() + + process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] = 'not-base64-json' + expect(readGoogleServiceAccountCredentials()).toBeNull() + + process.env[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV] = Buffer.from( + JSON.stringify({ client_email: 'x' }), + 'utf8', + ).toString('base64') + expect(readGoogleServiceAccountCredentials()).toBeNull() + }) +}) + +describe('shouldSyncActivityOperation', () => { + const activity = (overrides: Record = {}) => + ({ id: 1, title: 'Caminhada', status: 'confirmado', ...overrides }) as unknown as Activity + + it('always syncs on create and delete', () => { + expect(shouldSyncActivityOperation({ operation: 'create', doc: activity() })).toBe(true) + expect(shouldSyncActivityOperation({ operation: 'delete', doc: activity() })).toBe(true) + }) + + it('skips updates that only touch non-mirrored fields (tasks, derived state)', () => { + expect( + shouldSyncActivityOperation({ + operation: 'update', + doc: activity({ tasks: [{ title: 'Novo' }], taskDoneCount: 1 }), + previousDoc: activity({ tasks: [{ title: 'Antigo' }], taskDoneCount: 0 }), + }), + ).toBe(false) + }) + + it.each([ + 'title', + 'status', + 'startAt', + 'endAt', + 'allDay', + 'locality', + 'tags', + 'deputyPresent', + ] as const)('syncs when the mirrored field %s changed', (field) => { + expect( + shouldSyncActivityOperation({ + operation: 'update', + doc: activity({ [field]: 'changed' }), + previousDoc: activity({ [field]: 'original' }), + }), + ).toBe(true) + }) + + it('syncs when the municipality relationship changed', () => { + expect( + shouldSyncActivityOperation({ + operation: 'update', + doc: activity({ municipality: 2 }), + previousDoc: activity({ municipality: 1 }), + }), + ).toBe(true) + }) + + it('does NOT sync when only non-mirrored fields changed (tasks, description, updates, result)', () => { + expect( + shouldSyncActivityOperation({ + operation: 'update', + doc: activity({ description: 'novo texto', tasks: [{ title: 'Nova tarefa' }] }), + previousDoc: activity({ description: 'texto antigo', tasks: [{ title: 'Antiga' }] }), + }), + ).toBe(false) + }) + + it('normalizes populated vs depth-0 relationship shapes (afterChange doc vs previousDoc)', () => { + // The afterChange `doc` carries the populated municipality object; the + // `previousDoc` is loaded depth-0 (numeric id) — same id must NOT change. + expect( + shouldSyncActivityOperation({ + operation: 'update', + doc: activity({ municipality: { id: 7, name: 'Salvador' } }), + previousDoc: activity({ municipality: 7 }), + }), + ).toBe(false) + expect( + shouldSyncActivityOperation({ + operation: 'update', + doc: activity({ tags: ['B', 'A'] }), + previousDoc: activity({ tags: ['A', 'B'] }), + }), + ).toBe(false) + expect( + shouldSyncActivityOperation({ + operation: 'update', + doc: activity({ tags: ['A'] }), + previousDoc: activity({ tags: ['B'] }), + }), + ).toBe(true) + }) +}) + +describe('shouldSyncConfigChange', () => { + const config = (overrides: Record = {}) => + ({ + id: 1, + calendarId: 'c_a@group.calendar.google.com', + ...overrides, + }) as unknown as GoogleCalendarSyncDoc + + it('always syncs on create', () => { + expect(shouldSyncConfigChange({ operation: 'create', doc: config() })).toBe(true) + }) + + it('skips state-only writes (engine recording outcomes) — no re-entry loop', () => { + expect( + shouldSyncConfigChange({ + operation: 'update', + doc: config({ lastSyncedAt: '2026-08-11T10:00:00.000Z', lastSuccessAt: 'x' }), + previousDoc: config(), + }), + ).toBe(false) + }) + + it('skips disabling (no sync needed while off)', () => { + expect( + shouldSyncConfigChange({ + operation: 'update', + doc: config({ disabledAt: '2026-08-11T10:00:00.000Z' }), + previousDoc: config(), + }), + ).toBe(false) + }) + + it('syncs when the calendarId changed (reconcile into the new calendar)', () => { + expect( + shouldSyncConfigChange({ + operation: 'update', + doc: config({ calendarId: 'c_b@group.calendar.google.com' }), + previousDoc: config(), + }), + ).toBe(true) + }) + + it('syncs when re-enabled (disabledAt cleared)', () => { + expect( + shouldSyncConfigChange({ + operation: 'update', + doc: config(), + previousDoc: config({ disabledAt: '2026-08-11T10:00:00.000Z' }), + }), + ).toBe(true) + }) +}) diff --git a/tests/unit/googleCalendarSyncHooks.unit.spec.ts b/tests/unit/googleCalendarSyncHooks.unit.spec.ts new file mode 100644 index 00000000..55654d0b --- /dev/null +++ b/tests/unit/googleCalendarSyncHooks.unit.spec.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { Activity, GoogleCalendarSync as GoogleCalendarSyncDoc } from '@/payload-types' +import type { PayloadRequest } from 'payload' + +/** + * Hook → engine wiring (the guards themselves are unit-tested in + * `googleCalendarSync.unit.spec.ts`): the hooks must call the engine exactly + * when the guard says so, and NEVER throw into the write path. The hooks live + * in their own module (`googleCalendarSyncHooks.ts`), so mocking the engine + * at the import boundary reliably intercepts the wiring. + */ +vi.mock('@/utilities/googleCalendarSync', async (importOriginal) => { + const original = await importOriginal() + return { ...original, runCampaignCalendarSync: vi.fn() } +}) + +import { runCampaignCalendarSync } from '@/utilities/googleCalendarSync' +import { + activityGoogleCalendarSyncHook, + googleCalendarSyncConfigHook, +} from '@/utilities/googleCalendarSyncHooks' + +const mockedRun = vi.mocked(runCampaignCalendarSync) + +const fakeReq = { payload: {} } as unknown as PayloadRequest +const activity = (overrides: Record = {}) => + ({ id: 1, title: 'Caminhada', status: 'confirmado', ...overrides }) as unknown as Activity +const config = (overrides: Record = {}) => + ({ + id: 1, + calendarId: 'c_a@group.calendar.google.com', + ...overrides, + }) as unknown as GoogleCalendarSyncDoc + +const settledOutcome = { status: 'synced' as const, created: 0, updated: 0, deleted: 0, at: '' } + +describe('activityGoogleCalendarSyncHook wiring', () => { + beforeEach(() => { + mockedRun.mockReset() + mockedRun.mockResolvedValue(settledOutcome) + }) + + it('calls the engine on create, delete and relevant updates; skips task-only updates', async () => { + await activityGoogleCalendarSyncHook({ + doc: activity(), + operation: 'create', + req: fakeReq, + }) + expect(mockedRun).toHaveBeenCalledTimes(1) + + await activityGoogleCalendarSyncHook({ + doc: activity({ tasks: [{ title: 'Novo' }] }), + previousDoc: activity({ tasks: [{ title: 'Antigo' }] }), + operation: 'update', + req: fakeReq, + }) + expect(mockedRun).toHaveBeenCalledTimes(1) + + await activityGoogleCalendarSyncHook({ + doc: activity({ title: 'Caminhada (remarcada)' }), + previousDoc: activity(), + operation: 'update', + req: fakeReq, + }) + expect(mockedRun).toHaveBeenCalledTimes(2) + + // Delete: afterDelete hooks carry no `operation` — absence means delete. + await activityGoogleCalendarSyncHook({ + doc: activity(), + req: fakeReq, + }) + expect(mockedRun).toHaveBeenCalledTimes(3) + }) + + it('never throws into the activity write path', async () => { + mockedRun.mockRejectedValue(new Error('boom')) + await expect( + activityGoogleCalendarSyncHook({ + doc: activity(), + operation: 'create', + req: fakeReq, + }), + ).resolves.toBeDefined() + }) +}) + +describe('googleCalendarSyncConfigHook wiring', () => { + beforeEach(() => { + mockedRun.mockReset() + mockedRun.mockResolvedValue(settledOutcome) + }) + + it('calls the engine on create, calendarId change and re-enable; skips state writes and disable', async () => { + await googleCalendarSyncConfigHook({ + doc: config(), + operation: 'create', + req: fakeReq, + }) + expect(mockedRun).toHaveBeenCalledTimes(1) + + await googleCalendarSyncConfigHook({ + doc: config({ lastSyncedAt: '2026-08-11T10:00:00.000Z' }), + previousDoc: config(), + operation: 'update', + req: fakeReq, + }) + expect(mockedRun).toHaveBeenCalledTimes(1) + + await googleCalendarSyncConfigHook({ + doc: config({ disabledAt: '2026-08-11T10:00:00.000Z' }), + previousDoc: config(), + operation: 'update', + req: fakeReq, + }) + expect(mockedRun).toHaveBeenCalledTimes(1) + + await googleCalendarSyncConfigHook({ + doc: config({ calendarId: 'c_b@group.calendar.google.com' }), + previousDoc: config(), + operation: 'update', + req: fakeReq, + }) + expect(mockedRun).toHaveBeenCalledTimes(2) + + await googleCalendarSyncConfigHook({ + doc: config(), + previousDoc: config({ disabledAt: '2026-08-11T10:00:00.000Z' }), + operation: 'update', + req: fakeReq, + }) + expect(mockedRun).toHaveBeenCalledTimes(3) + }) + + it('never throws into admin writes', async () => { + mockedRun.mockRejectedValue(new Error('boom')) + await expect( + googleCalendarSyncConfigHook({ + doc: config({ calendarId: 'c_b@group.calendar.google.com' }), + previousDoc: config(), + operation: 'update', + req: fakeReq, + }), + ).resolves.toBeDefined() + }) +}) From aa8fe582c056d862a109253ad51597ce96e52e9a Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 13:34:49 -0300 Subject: [PATCH 07/14] feat(B198): drawer mobile do Sollinha fecha ao navegar por link interno MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O renderer de links do chat decide interno vs externo (B187/B188); a decisão pura de fechamento mora no owner src/lib/ai/markdownLinks.ts (shouldCloseDrawerOnLinkClick: interno && mobile && clique simples) e o markdownComponents estatico virou useMemo no corpo do CampaignAIChat para alcancar o contexto. Desktop inalterado; links externos/modos de nova aba mantem o drawer da aba de origem; sessao e restore intactos (close persiste open:false). - unit: 12 testes da decisao pura - e2e (modo CI, E2E_PROD=1): interno fecha + navega sem load + reload nao reabre; externo abre popup e mantem o drawer - debito B199/#672 (depends #660): race de persistencia ao fechar mid-stream --- docs/CHANGELOG-AGENTS.md | 1 + .../sollinha-close-mid-stream-persist.md | 62 ++++++++++++ .../sollinha-drawer-fecha-link-mobile-impl.md | 95 +++++++++++++++++++ scripts/lib/e2e-affected-manifest.mjs | 1 + .../campaign/shell/ai/CampaignAIChat.tsx | 61 ++++++++---- src/lib/ai/markdownLinks.ts | 39 ++++++++ tests/e2e/campaignSollinhaContext.e2e.spec.ts | 87 ++++++++++++++++- tests/unit/aiMarkdownLinks.unit.spec.ts | 79 ++++++++++++++- 8 files changed, 402 insertions(+), 23 deletions(-) create mode 100644 docs/plans/sollinha-close-mid-stream-persist.md create mode 100644 docs/plans/sollinha-drawer-fecha-link-mobile-impl.md diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 3966f556..c34ed4ba 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,7 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-11):** **B198** (Issue #660) — o drawer mobile do Sollinha fecha ao navegar por link interno: tocar num link `/campanha…` da resposta fecha o drawer **no toque** (antes de a página carregar) e a navegação segue no mesmo tab; desktop inalterado (painel fica aberto); link externo (nova aba) não fecha o drawer da aba de origem; reload não reabre (o close persiste `open: false`). **Engrenagem:** a decisão pura mora no owner `src/lib/ai/markdownLinks.ts` — regex `^\/campanha(?:\/|$)` promovido a `isCampaignInternalLink` (era `APP_INTERNAL_LINK` no renderer) + `shouldCloseDrawerOnLinkClick(href, isMobile, event)` (fecha ⇔ interno && mobile && clique simples — ignora ctrl/meta/shift/alt, middle-click e `defaultPrevented`, pois abrem nova aba e a origem mantém o chat); o `markdownComponents` estático do `CampaignAIChat` virou `useMemo` no corpo (acesso ao `ctx.isMobile`/`setOpen`; identidade estável, recomputa só no crossing de breakpoint). Sessão/restauração intactas (fechar com `requestOpen(false)` marca `openBy:'user'` com `open:false` — inerte, o restore só reabre com `open:true`). Testes: unit (12 — interno/externo, viewport, modifiers, middle, prevented) + e2e no `campaignSollinhaContext` (interno fecha+navega sem `load`+reload não reabre; externo mantém drawer e abre popup interceptado). Nota: dev-mode local flakeou no prewarm/compile cold — e2e rodado no modo CI (`E2E_PROD=1` com `NEXT_DIST_DIR=.next/e2e`, 7/7). **Débito registrado no gate (B199/#672, `depends` #660):** race pré-existente do persist effect (só grava com `status==='ready'`) — fechar o drawer mid-stream e recarregar antes do settle reabre o drawer "sozinho" (classe OPS22); o tap-no-link do B198 amplia a superfície. Intenção [`sollinha-drawer-fecha-link-mobile.md`](plans/sollinha-drawer-fecha-link-mobile.md); impl [`sollinha-drawer-fecha-link-mobile-impl.md`](plans/sollinha-drawer-fecha-link-mobile-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-`/`owned-consent-vacated-`) — 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). diff --git a/docs/plans/sollinha-close-mid-stream-persist.md b/docs/plans/sollinha-close-mid-stream-persist.md new file mode 100644 index 00000000..27ed2488 --- /dev/null +++ b/docs/plans/sollinha-close-mid-stream-persist.md @@ -0,0 +1,62 @@ +# Sollinha: fechar o drawer durante streaming pode reabri-lo num reload (race de persistência) + +Status: registrado +Atualizado em: 2026-08-11 +Issue: # +Priority: P3 +Model: composer-2.5 +Impeccable: B — comportamento do drawer existente, sem nova superfície +Appetite: ~0,5 dia eng; um ajuste no persist effect do provider + +## Intenção + +O persist effect do chat (`CampaignAISidebarContext.tsx`) só grava a sessão quando `status === 'ready'`. Se o usuário fecha o drawer enquanto a resposta ainda está chegando (X manual, swipe — ou, desde B198, um toque num link da resposta que fecha o drawer no mesmo tab) e um reload acontece antes do settle, o `sessionStorage` ainda carrega o estado velho `open: true` — o restore reabre o drawer "sozinho" na próxima página (mesma classe OPS22 que o repo já caça). + +Pré-existente (o X tinha a mesma janela), mas o link-navegação do B198 torna o fechamento-durante-streaming um fluxo normal de campo: o usuário toca no link no meio da resposta, o drawer fecha, ele navega; um reload (ou o próprio fluxo de volta) não deve ressuscitar o drawer. + +## Persona e fluxo + +- **Persona / contexto:** assessor/liderança no celular; toca num link interno enquanto o Sollinha ainda está respondendo. +- **Job principal:** fechar o drawer em qualquer momento (mesmo mid-stream) e ter um reload sem drawer fantasma. +- **Fluxo desejado:** resposta em streaming → toque no link → drawer fecha → reload → drawer continua fechado, conversa restaurada. +- **Anti-goal:** não reabrir a raça de restore (OPS22/B188): fechar mid-stream não pode ser tratado como "estado velho válido". + +## Objetivo e aceite + +- Fechar o drawer com `status !== 'ready'` persiste `open: false` mesmo antes do settle — um reload imediato não reabre o drawer. +- A conversa em andamento continua persistindo normalmente (nada de descartar mensagens do stream em andamento). +- O mecanismo `openBy`/`userToggledOpenRef` continua governando o restore como hoje (não inverter intenção). + +## Dados (intenção) + +Dados: N/A — persistência client-side (`sessionStorage`), sem servidor. + +## Direção no codebase (hipótese) + +- **Área provável:** `src/components/campaign/shell/ai/CampaignAISidebarContext.tsx` — o persist effect (linhas ~116-121) hoje early-returna com `status !== 'ready'`. Hipótese: persistir `open` em mudanças imediatas (efeito separado só para `open`, sem gate de status, reusando `writeSollinhaChatSession` com as mensagens atuais do último commit), deixando o gate de `status` apenas para as mensagens do stream. Validar interação com `sessionRestored` e com o write do settle (que já escreve tudo junto). +- **Precedente a olhar:** `src/lib/sollinhaChatSession.ts` (write/read fail-closed), `docs/plans/sollinha-contexto-sessao-janela.md` (B188) e `sollinha-drawer-fecha-link-mobile-impl.md` (B198). +- **Risco:** escrever a sessão com um `messages` mid-stream congelaria uma resposta pela metade no storage — se o write de `open` e o write de `messages` forem o mesmo `writeSollinhaChatSession`, o gate de mensagens precisa continuar (ou o write de close pode usar o último snapshot `ready` de mensagens). + +## Dependências + +- #660 (B198) — o fluxo de fechamento por link é o amplificador; destrava sozinha quando o pai flipar `done`. + +## Fora de escopo + +- Mudar o mecanismo de restore/`openBy` (OPS22/B188) — só o write de fechamento. +- Persistir mensagens mid-stream (mantém a regra atual de só gravar stream completo). + +## Rabbit holes de produto + +- **Escrever o stream pela metade.** Se o ajuste gravar `messages` mid-stream, um reload perde a cauda do turno. **Corte:** fechamento persiste `open: false` com o snapshot de mensagens do último estado `ready` (ou o array atual se vazio/estável). +- **Reabrir a raça de restore.** Mexer em `openBy` para "consertar" isto troca um bug raro por outro. **Corte:** só o `open` muda; `openBy` continua como hoje. + +## Questões em aberto (produto) + +- Nenhuma — comportamento: fechar é fechar, em qualquer fase do turno. + +## Referências + +- GitHub Issue # (pai: #660) +- `src/components/campaign/shell/ai/CampaignAISidebarContext.tsx`, `src/lib/sollinhaChatSession.ts` +- `docs/plans/sollinha-contexto-sessao-janela.md`, `docs/plans/sollinha-drawer-fecha-link-mobile-impl.md` diff --git a/docs/plans/sollinha-drawer-fecha-link-mobile-impl.md b/docs/plans/sollinha-drawer-fecha-link-mobile-impl.md new file mode 100644 index 00000000..2a72d973 --- /dev/null +++ b/docs/plans/sollinha-drawer-fecha-link-mobile-impl.md @@ -0,0 +1,95 @@ +# Impl: Sollinha mobile: fechar o drawer ao navegar por link + +Status: aprovado +Atualizado em: 2026-08-11 +Issue: #660 +Intenção: docs/plans/sollinha-drawer-fecha-link-mobile.md +Appetite restante: herdado (~0,5 dia eng; um encaixe no renderer de links do chat) + +## Leitura da intenção + +- **Outcome:** em mobile, tocar num link interno (`/campanha…`) na resposta do Sollinha fecha o drawer no toque (antes de a página carregar) e a navegação segue no mesmo tab; reabrir restaura a conversa intacta; desktop inalterado; link externo (nova aba) não fecha o drawer da aba de origem; reload não reabre o drawer. +- **O que NÃO negociar:** a conversa não pode ser descartada (sessão intacta); o painel desktop não pode fechar; só links das respostas do chat fecham (chips/sugestões/busca ficam fora — são `sendMessage`, não navegação, então nem chegam perto do renderer de links). +- **O que reavaliar:** a hipótese "fechar é chamar `setOpen(false)` no toque, provavelmente só quando `isMobile`" — validar o efeito no `userToggledOpenRef`/`openBy` persistido (ver Riscos). + +## Decisões de engenharia + +### D1 — Onde fechar o drawer + +``` +Opções: A | B | C +Recomendação: A — no onClick do renderer de link interno (mobile) — porque é o +único ponto que conhece a tripla href + viewport + gesto do clique: o renderer +já decide interno vs externo (B187/B188), o `ctx.isMobile` vem do provider e o +click event diz se é clique simples (mesmo tab). Fecha no toque, exatamente o +aceite ("antes de a página carregar"). Sessão intocada. +Alternativas rejeitadas: B (efeito em pathname no provider) porque fecha em +QUALQUER navegação — incluindo back/forward e cliques na faixa de 10% da página +visível acima do drawer —, mais amplo que o aceite (o corte da intenção é "só +os links das respostas") e dispara após o commit da navegação, não no toque; C +(expor `closeForNavigation` no contexto que não marca intenção de usuário) +porque `requestOpen(false)` já é seguro (ver Riscos) — crescer o API do +contexto sem mudança de comportamento é over-engineering. +``` + +### D2 — Onde mora a decisão pura (testável) + +``` +Opções: A | B +Recomendação: A — em `src/lib/ai/markdownLinks.ts` (dono da decisão de link +interno/externo, B187) — porque o regex `APP_INTERNAL_LINK` já é conhecimento +de link que vive no renderer; consolidar lá como `isCampaignInternalLink` e +adicionar `shouldCloseDrawerOnLinkClick` pura (href + isMobile + metadados do +click), unit-testável sem DOM. O renderer vira fiação fina. +Alternativas rejeitadas: B (deixar a condição inline no componente) porque a +regra de fechamento (mobile + interno + clique simples, ignorando +ctrl/meta/shift/alt/middle-click e defaultPrevented) merece pin de teste de +unidade, e o owner já existe. +``` + +### D3 — Como o renderer alcança o contexto + +`markdownComponents` hoje é constante de módulo (sem acesso ao `useAISidebar`). +Mover para dentro de `CampaignAIChat` num `useMemo` (deps `[ctx?.isMobile, +ctx?.setOpen]`) — o `a` interno ganha `onClick` que fecha quando a função pura +responde true. `setOpen` (requestOpen) é estável via `useCallback`; o memo não +re-renderiza o markdown à toa. Alternativa rejeitada: componente novo +`AssistantMarkdown` — o chat já é o dono do markdown; fatiar em outro +componente é cerimônia sem volatilidade. + +## Componentes / mudanças + +- **`isCampaignInternalLink`** (`src/lib/ai/markdownLinks.ts`): regex `^\/campanha(?:\/|$)` promovido de `CampaignAIChat.tsx` para o owner; usado pelo renderer e pela função de fechamento. Sem mudança de semântica. +- **`shouldCloseDrawerOnLinkClick(href, isMobile, event)`** (`src/lib/ai/markdownLinks.ts`): `true` ⇔ link interno && `isMobile` && clique simples (`button === 0`, sem ctrl/meta/shift/alt, `!defaultPrevented`). Modifier/middle-click abre em nova aba — a aba de origem mantém o drawer (mesma regra do link externo). +- **`CampaignAIChat.tsx`**: `markdownComponents` vira `useMemo` no corpo do componente lendo `ctx`; o branch interno do `a` adiciona `onClick` que chama `setOpen(false)` quando `shouldCloseDrawerOnLinkClick`. Branch externo e desktop intactos (desktop: `isMobile` false → no-op). +- **`tests/unit/aiMarkdownLinks.unit.spec.ts`**: pin das duas funções puras (interno/externo, viewport, modifiers, botão, defaultPrevented). +- **`tests/e2e/campaignSollinhaContext.e2e.spec.ts`**: dois testes mobile com o mock de SSE já usado no arquivo (resposta com link interno → drawer fecha + URL muda sem `load`; resposta com link externo → drawer permanece e abre nova aba). +- **Migration:** sem migration. +- **Access / Consent:** N/A. +- **UI:** Impeccable B — comportamento do drawer existente, sem superfície nova; shape→critique leve (não há shape novo). + +## Fases verificáveis + +1. **Núcleo puro** — `markdownLinks.ts` + unit tests; `pnpm test:unit` (spec de markdownLinks). +2. **Fiação UI** — `CampaignAIChat.tsx` (useMemo + onClick); `pnpm typecheck` + `pnpm lint`. +3. **E2E** — dois testes no `campaignSollinhaContext.e2e.spec.ts`; rodar só o arquivo. +4. **Gates** — `pnpm gate:fast`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`; push via `pnpm push` no PR. + +## Rabbit holes / Não escopo (engenharia) + +- Animar/transicionar o fechamento; mudar drawer/vaul; chips/sugestões/busca (não navegam); sessão/restauração; painel desktop; prefetch de link. +- Decisão de "qual drawer state se anima durante navegação": não existe — `setOpen(false)` usa o mecanismo existente. + +## Riscos e mitigação + +- **`openBy` persistido como `'user'` após fechamento por navegação** (porque `requestOpen` marca `userToggledOpenRef`): inerte — o restore só reabre quando `session.open === true` (CampaignAISidebarContext.tsx:89), e aqui `open` vira `false` antes do persist effect rodar (`status` ready + mudança de `open`). Reload não reabre (aceite). Validação no teste e2e existente "drawer aberto volta aberto após reload" não é afetada (aquele fluxo não navega). +- **Clique modificado (cmd/ctrl+click) em link interno**: abre nova aba; sem fechar — a aba de origem mantém o chat montado. Coberto por unit test da função pura. +- **Clique em link durante frame de hidratação (isMobile não medido)**: impossível — o drawer só renderiza com `isMobile` true, que só vira true após a medição (`useIsMobileMeasured`). +- **`markdownComponents` recriado por render**: o `useMemo` mantém identidade estável; `ReactMarkdown` recebe `components` novo apenas quando viewport/`setOpen` mudam. +- **Fechar + navegar no mesmo handler**: state update (drawer) e router push (next/link) coexistem sem conflito; o `e.defaultPrevented` não é tocado. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto (5 itens do aceite → testes unit + e2e) +- [x] Invariantes AGENTS/engineering-standards (sem migration, sem access, identificadores em inglês, pt-BR só em strings de UI) +- [x] Testes de domínio previstos: unit (decisão pura) + e2e (comportamento drawer mobile) diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 19947ddf..4dda1e14 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -114,6 +114,7 @@ export const E2E_AFFECTED_MANIFEST = [ prefixes: [ 'src/components/campaign/shell/ai', 'src/lib/sollinhaChatSession', + 'src/lib/ai/markdownLinks', `${CAMPAIGN_APP}/api/ai-chat`, ], specs: [ diff --git a/src/components/campaign/shell/ai/CampaignAIChat.tsx b/src/components/campaign/shell/ai/CampaignAIChat.tsx index 51e33380..8b4688fd 100644 --- a/src/components/campaign/shell/ai/CampaignAIChat.tsx +++ b/src/components/campaign/shell/ai/CampaignAIChat.tsx @@ -12,7 +12,11 @@ import { InputGroup, InputGroupButton } from '@/components/ui/input-group' import { Marker, MarkerContent } from '@/components/ui/marker' import { Message, MessageAvatar, MessageContent } from '@/components/ui/message' import { Spinner } from '@/components/ui/Spinner' -import { externalLinkTarget } from '@/lib/ai/markdownLinks' +import { + externalLinkTarget, + isCampaignInternalLink, + shouldCloseDrawerOnLinkClick, +} from '@/lib/ai/markdownLinks' import { cn } from '@/lib/utils' import { useAISidebar } from '@/components/campaign/shell/ai/CampaignAISidebarContext' @@ -30,35 +34,52 @@ import { getSollinhaOpeningQuestions } from '@/lib/sollinhaOpeningQuestions' * only ever emits `/campanha…` paths — navigate through `next/link` (B188), so * following a Sollinha link keeps the layout (and the conversation) mounted * instead of reloading the page. Anything else keeps the plain anchor. + * B198 — on mobile the same-tab internal navigation closes the drawer in the + * touch, so the destination is visible on arrival; external and new-tab + * navigations leave the drawer open for the origin tab's conversation. */ -const APP_INTERNAL_LINK = /^\/campanha(?:\/|$)/ /** B192 — narrower drawer surface: fewer follow-up chips than on desktop. */ const FOLLOW_UP_MOBILE_LIMIT = 2 -const markdownComponents: Components = { - a: ({ node: _node, href, children, ...rest }) => { - const external = typeof href === 'string' ? externalLinkTarget(href) : null - if (typeof href === 'string' && APP_INTERNAL_LINK.test(href)) { - return ( - - {children} - - ) - } - return ( - - {children} - - ) - }, -} - export const CampaignAIChat = ({ className }: { className?: string }) => { const ctx = useAISidebar() const [input, setInput] = useState('') const bottomRef = useRef(null) + // B198 — the markdown link renderer needs the context (viewport + close), + // so the module-level components object moved in here: same shape as before, + // plus the mobile same-tab navigation closing the drawer on touch. The memo + // keeps the renderer identity stable across message renders; it recomputes + // only on a breakpoint crossing (`isMobileViewport`) or context churn. + const isMobileViewport = ctx?.isMobile ?? false + const closeChat = ctx?.setOpen + const markdownComponents = useMemo(() => { + return { + a: ({ node: _node, href, children, ...rest }) => { + const external = typeof href === 'string' ? externalLinkTarget(href) : null + if (typeof href === 'string' && isCampaignInternalLink(href)) { + return ( + { + if (shouldCloseDrawerOnLinkClick(href, isMobileViewport, e)) closeChat?.(false) + }} + > + {children} + + ) + } + return ( + + {children} + + ) + }, + } + }, [isMobileViewport, closeChat]) + const mic = useMicTranscript() const textareaRef = useRef(null) diff --git a/src/lib/ai/markdownLinks.ts b/src/lib/ai/markdownLinks.ts index 8c193c5c..9393f4f5 100644 --- a/src/lib/ai/markdownLinks.ts +++ b/src/lib/ai/markdownLinks.ts @@ -3,6 +3,14 @@ export type ExternalLinkTarget = { rel: 'noopener noreferrer' } +/** + * App-internal destinations the assistant emits — the B162 link catalog only + * ever produces `/campanha…` paths (B187/B188). + */ +const CAMPAIGN_INTERNAL_LINK = /^\/campanha(?:\/|$)/ + +export const isCampaignInternalLink = (href: string): boolean => CAMPAIGN_INTERNAL_LINK.test(href) + /** * Decides whether a markdown href rendered by the Sollinha chat should open in * a new tab. Only absolute http(s) URLs (and protocol-relative ones) qualify — @@ -16,3 +24,34 @@ export const externalLinkTarget = (href: string): ExternalLinkTarget | null => { } return null } + +/** The click metadata that decides same-tab vs new-tab navigation (B198). */ +export type LinkClickMeta = { + button: number + altKey: boolean + ctrlKey: boolean + metaKey: boolean + shiftKey: boolean + defaultPrevented: boolean +} + +/** + * B198 — a mobile link click on the mobile drawer closes it only for the + * navigation that replaces the page in the SAME tab: an internal link touched + * with a plain left click. Modified clicks (cmd/ctrl/shift/alt), middle clicks + * and already-prevented clicks open the destination in a new tab (or nowhere) + * — the origin tab keeps the drawer so the conversation is where it was. The + * desktop panel never closes (the accept keeps it open while navigating). + */ +export const shouldCloseDrawerOnLinkClick = ( + href: string, + isMobile: boolean, + event: LinkClickMeta, +): boolean => { + if (!isMobile) return false + if (!isCampaignInternalLink(href)) return false + if (event.defaultPrevented) return false + if (event.button !== 0) return false + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false + return true +} diff --git a/tests/e2e/campaignSollinhaContext.e2e.spec.ts b/tests/e2e/campaignSollinhaContext.e2e.spec.ts index 5ed77e12..8a4b7777 100644 --- a/tests/e2e/campaignSollinhaContext.e2e.spec.ts +++ b/tests/e2e/campaignSollinhaContext.e2e.spec.ts @@ -4,6 +4,9 @@ import { expect, test } from './fixtures/campaignE2EFixtures.js' const SESSION_KEY = 'teqo:campaign:sollinha-chat-session' +const DEFAULT_REPLY = 'Resposta mockada com [Municípios](/campanha/municipios).' +const EXTERNAL_REPLY = 'Resposta mockada com [portal da saúde](https://www.saude.ba.gov.br/).' + /** * Mock the AI endpoint with a minimal SSE stream so sending a message neither * needs a DeepSeek API key nor hits the real rate limiter. The chunks follow @@ -12,7 +15,7 @@ const SESSION_KEY = 'teqo:campaign:sollinha-chat-session' * schema and the chat would never settle. The reply carries a markdown link so * the "navigate without reload" path has a real anchor. */ -const mockAiChat = (page: Page) => +const mockAiChat = (page: Page, reply: string = DEFAULT_REPLY) => page.route('**/campanha/api/ai-chat', async (route) => { await route.fulfill({ status: 200, @@ -20,7 +23,7 @@ const mockAiChat = (page: Page) => body: [ 'data: {"type":"start"}\n\n', 'data: {"type":"text-start","id":"t1"}\n\n', - 'data: {"type":"text-delta","id":"t1","delta":"Resposta mockada com [Municípios](/campanha/municipios)."}\n\n', + `data: {"type":"text-delta","id":"t1","delta":${JSON.stringify(reply)}}\n\n`, 'data: {"type":"text-end","id":"t1"}\n\n', 'data: {"type":"finish","finishReason":"stop"}\n\n', ].join(''), @@ -44,6 +47,14 @@ const waitForChatSettled = async (page: Page) => { ).toBeEnabled({ timeout: 20_000 }) } +/** Opens the mobile drawer and waits for it to cover the page. */ +const openMobileDrawer = async (page: Page) => { + const drawer = page.getByRole('dialog', { name: 'Sollinha — Assistente virtual' }) + await page.getByRole('button', { name: 'Sollinha — Assistente virtual' }).click() + await expect(drawer).toBeVisible({ timeout: 20_000 }) + return drawer +} + /** Raw stored session, or null when nothing (valid) is in the tab's storage. */ const storedSession = (page: Page) => page.evaluate((key) => { @@ -175,3 +186,75 @@ test.describe('B188 — contexto da conversa persiste na sessão da janela/tab', }) }) }) + +test.describe('B198 — link de resposta fecha o drawer mobile ao navegar no mesmo tab', () => { + test.beforeEach(async ({ page }) => { + test.slow() + await mockAiChat(page) + }) + + test('link interno: drawer fecha no toque e a navegação segue no mesmo tab', async ({ + page, + campaign, + }) => { + const user = await campaign.fixtures.createCampaignUser('coordinator', { + name: campaign.fixtures.value('Drawer Link Interno'), + }) + await page.setViewportSize({ width: 500, height: 800 }) + await campaign.login(page, user.email!, user.password) + await page.goto('/campanha') + + const drawer = await openMobileDrawer(page) + + await openChatAndSend(page) + await waitForChatSettled(page) + + // A full browser navigation fires `load`; client-side routing does not. + let loadEvents = 0 + page.on('load', () => { + loadEvents += 1 + }) + + await drawer.getByRole('link', { name: 'Municípios' }).click() + await expect(page).toHaveURL(/\/campanha\/municipios/) + await expect(drawer).toHaveCount(0) + expect(loadEvents).toBe(0) + + // The navigation-originated close persists `open: false` — a reload never + // reopens the drawer on its own. + await page.reload() + await expect(drawer).toHaveCount(0) + }) + + test('link externo: abre em nova aba e o drawer permanece aberto', async ({ page, campaign }) => { + // Last-registered route wins (LIFO) — overrides the beforeEach mock. + await mockAiChat(page, EXTERNAL_REPLY) + // The popup would hit the real site; fulfill it so CI stays deterministic. + await page + .context() + .route('**://saude.ba.gov.br/**', (route) => + route.fulfill({ status: 200, contentType: 'text/html', body: 'ok' }), + ) + + const user = await campaign.fixtures.createCampaignUser('coordinator', { + name: campaign.fixtures.value('Drawer Link Externo'), + }) + await page.setViewportSize({ width: 500, height: 800 }) + await campaign.login(page, user.email!, user.password) + await page.goto('/campanha') + + const drawer = await openMobileDrawer(page) + + await openChatAndSend(page) + await waitForChatSettled(page) + + const popupPromise = page.waitForEvent('popup') + await drawer.getByRole('link', { name: 'portal da saúde' }).click() + const popup = await popupPromise + await popup.waitForLoadState('domcontentloaded') + await popup.close() + + // The origin tab keeps the conversation reachable — the drawer stays. + await expect(drawer).toBeVisible() + }) +}) diff --git a/tests/unit/aiMarkdownLinks.unit.spec.ts b/tests/unit/aiMarkdownLinks.unit.spec.ts index 2fc2957a..084c18c6 100644 --- a/tests/unit/aiMarkdownLinks.unit.spec.ts +++ b/tests/unit/aiMarkdownLinks.unit.spec.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' -import { externalLinkTarget } from '@/lib/ai/markdownLinks' +import { + externalLinkTarget, + isCampaignInternalLink, + shouldCloseDrawerOnLinkClick, + type LinkClickMeta, +} from '@/lib/ai/markdownLinks' /** * B187: only absolute web URLs get the new-tab treatment in the Sollinha chat; @@ -46,3 +51,75 @@ describe('externalLinkTarget', () => { expect(externalLinkTarget('')).toBeNull() }) }) + +describe('isCampaignInternalLink', () => { + it('accepts campaign paths and the campaign root', () => { + expect(isCampaignInternalLink('/campanha')).toBe(true) + expect(isCampaignInternalLink('/campanha/')).toBe(true) + expect(isCampaignInternalLink('/campanha/municipios')).toBe(true) + expect(isCampaignInternalLink('/campanha/municipios/ilheus')).toBe(true) + }) + + it('rejects look-alike paths, web URLs and non-web protocols', () => { + expect(isCampaignInternalLink('/campanha-mobile')).toBe(false) + expect(isCampaignInternalLink('/campanhaX')).toBe(false) + expect(isCampaignInternalLink('https://pt.jorgesolla.com.br/campanha')).toBe(false) + expect(isCampaignInternalLink('mailto:contato@jorgesolla.com.br')).toBe(false) + expect(isCampaignInternalLink('')).toBe(false) + }) +}) + +describe('shouldCloseDrawerOnLinkClick', () => { + const plainClick: LinkClickMeta = { + button: 0, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + defaultPrevented: false, + } + + it('closes the drawer on a plain internal link click on mobile', () => { + expect(shouldCloseDrawerOnLinkClick('/campanha/municipios/ilheus', true, plainClick)).toBe(true) + expect(shouldCloseDrawerOnLinkClick('/campanha', true, plainClick)).toBe(true) + }) + + it('never closes on desktop', () => { + expect(shouldCloseDrawerOnLinkClick('/campanha/municipios/ilheus', false, plainClick)).toBe( + false, + ) + }) + + it('never closes for external links (they open in a new tab)', () => { + expect(shouldCloseDrawerOnLinkClick('https://saude.ba.gov.br', true, plainClick)).toBe(false) + expect(shouldCloseDrawerOnLinkClick('mailto:contato@jorgesolla.com.br', true, plainClick)).toBe( + false, + ) + }) + + it('keeps the drawer for modified clicks and middle clicks (new-tab navigation)', () => { + for (const key of ['altKey', 'ctrlKey', 'metaKey', 'shiftKey'] as const) { + expect( + shouldCloseDrawerOnLinkClick('/campanha/municipios/ilheus', true, { + ...plainClick, + [key]: true, + }), + ).toBe(false) + } + expect( + shouldCloseDrawerOnLinkClick('/campanha/municipios/ilheus', true, { + ...plainClick, + button: 1, + }), + ).toBe(false) + }) + + it('respects a prevented default', () => { + expect( + shouldCloseDrawerOnLinkClick('/campanha/municipios/ilheus', true, { + ...plainClick, + defaultPrevented: true, + }), + ).toBe(false) + }) +}) From 5fa4e22c9d270c1fdfe5771827646483bbf860ee Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 12:50:06 -0300 Subject: [PATCH 08/14] =?UTF-8?q?feat(C119):=20status=20de=20apoio=20'Lemb?= =?UTF-8?q?ran=C3=A7a'=20em=20lideran=C3=A7as=20=E2=80=94=20enum,=20badge?= =?UTF-8?q?=20e=20exclus=C3=A3o=20de=20pend=C3=AAncias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/lembranca-status-liderancas-impl.md | 89 +++++++++++++++++++ src/app/(frontend)/styles.css | 4 + src/collections/Leadership.ts | 1 + .../leadership/SupportStatusBadge.tsx | 1 + src/components/ui/Badge.tsx | 2 + src/lib/schemas/leadership.ts | 1 + src/migrations/index.ts | 14 +-- src/payload-types.ts | 2 +- src/utilities/ai/systemPrompt.ts | 1 + src/utilities/ai/tools/getLeaderships.ts | 11 +-- src/utilities/leadership/leadershipLabels.ts | 1 + src/utilities/people/peopleData.ts | 10 ++- tests/e2e/fixtures/campaignE2EFixtures.ts | 2 +- tests/int/campaignLeadership.int.spec.ts | 21 +++++ tests/unit/campaignComponents.unit.spec.ts | 1 + tests/unit/leadershipListUrl.unit.spec.ts | 2 +- .../unit/pendingLeadershipsTool.unit.spec.ts | 3 +- 17 files changed, 140 insertions(+), 26 deletions(-) create mode 100644 docs/plans/lembranca-status-liderancas-impl.md diff --git a/docs/plans/lembranca-status-liderancas-impl.md b/docs/plans/lembranca-status-liderancas-impl.md new file mode 100644 index 00000000..c1730d4a --- /dev/null +++ b/docs/plans/lembranca-status-liderancas-impl.md @@ -0,0 +1,89 @@ +# Impl: Status de apoio "Lembrança" em lideranças + +Status: em execução +Atualizado em: 2026-08-11 +Issue: #661 +Intenção: docs/plans/lembranca-status-liderancas.md +Appetite restante: herdado (~1 dia eng) — item pequeno, cabe folgado + +## Leitura da intenção + +- **Outcome:** novo valor `lembranca` no enum `supportStatus` de `leadership` (entre `em_disputa` e `negativo` no seletor), visível e marcável em TODAS as superfícies onde os 4 status atuais aparecem (célula da lista com auto-save, ficha/wizard/admin, filtro das listas de lideranças e de pessoas, Sollinha); **nunca** entra no critério de pendência de abordagem (fail-closed na query, não só na UI); badge visualmente distinto; nenhuma leitura existente quebra. +- **O que NÃO negociar:** liderança "Lembrança" é favorável e não é pendente — exclusão das pendências espelhando o "Negativo" (na query `getPendingLeaderships`, que já é fail-closed); sem segundo eixo de classificação; sem fluxo próprio de votos (usa o campo de votos declarados existente); leader lockdown intacto (status é staff-only). +- **O que reavaliar:** a hipótese da intenção cobre as áreas certas, mas encontrei duas superfícies que ela não nomeou: (1) `peopleData.ts` `leadershipStatusesInOrder` — lista dura dos 4 valores que **silenciosamente omitiria** o novo status do facet da lista de pessoas; (2) `getLeaderships.ts` mantém uma tabela de rótulos duplicada (`SUPPORT_LABELS`) que também esqueceria o valor novo — candidata a DRY com `supportStatusLabels`. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[leadershipSupportStatuses\n+ 'lembranca'] --> B[collection options\n+ migration ALTER TYPE] + A --> C[labels + badge variant\n+ tokens CSS] + A --> D[listas/filtros/forms\nvia enum array] + A --> E[getLeaderships usa\nsupportStatusLabels] + F[getPendingLeaderships\nINTOCADO + teste pin] --> G[pendências excluem\nlembranca fail-closed] + H[systemPrompt +1 linha\nreconhecimento] --> I[Sollinha responde\nsobre o status] +``` + +**Opções consideradas:** A (valor novo no enum existente, fonte única) | B (segundo campo/eixo de classificação) | C (status composto em string) +**Recomendação:** A — o produto decidiu no gate que é um valor a mais no enum; a engenharia tem um único source of truth (`leadershipSupportStatuses` + `supportStatusLabels`) e as superfícies que derivam dele (listas, forms, URLs, omnibox) ganham o valor automaticamente. +**Rejeitadas:** B porque a intenção corta explicitamente o segundo eixo (matriz de combinações); C porque destrói filtro `in` no Postgres e indexação. + +### Decisões de engenharia + +- **D1 — Migration do enum.** `ALTER TYPE "public"."enum_leadership_support_status" ADD VALUE 'lembranca' BEFORE 'negativo'` — o `BEFORE` preserva a ordem do enum no Postgres igual à do seletor (decisão do gate: entre "Em disputa" e "Negativo"), então `sort: 'supportStatus'` na lista ordena igual ao seletor. + - _Rejeitadas:_ `ADD VALUE` sem `BEFORE` (anexa no fim → ordenação por status divergiria do seletor); recriar o tipo inteiro (rename+create+cast, precedente C14) — destrutivo demais para um valor novo, risco desnecessário em prod. + - Precedentes: `20260723_025513` (ADD VALUE … BEFORE), `20260728_041547` / `20260724_180000` (ADD VALUE). Se o `migrate:create` gerar `ADD VALUE` sem `BEFORE`, editar o `.ts` da migration nova (migration não shipada — editável; nunca editar as antigas). +- **D2 — Badge distinto.** Novo variant `support-remembered` no `Badge` + tokens `--support-remembered`/`-foreground` (índigo suave: `#e0e7ff` / `#3730a3`) — distinto dos 4 atuais (verde/cinza/âmbar/vermelho) e semanticamente "resolvido e favorável". + - _Rejeitadas:_ reusar `estimate-*` (âmbar colide com "Em disputa"); variant `default` (não distinto). +- **D3 — `getLeaderships.ts`: matar a tabela duplicada.** Substituir `SUPPORT_LABELS` por `supportStatusLabels` de `leadershipLabels.ts` (tabela de strings pura, client-safe, importável do server). Um valor novo no enum seria propagado pela fonte única em vez de exigir lembrar da duplicata. + - _Rejeitada:_ só adicionar `lembranca` na duplicata — deixa a segunda fonte de verdade viva para o próximo status. +- **D4 — `peopleData.ts` `leadershipStatusesInOrder`:** trocar a lista dura `['engajado','a_abordar','em_disputa','negativo']` por `leadershipSupportStatuses.filter(s => statuses.has(s))`. Sem isso o status novo não apareceria no facet da lista de pessoas (miss silencioso, sem teste pegando). +- **D5 — Pendência: não tocar.** `PENDING_SUPPORT_STATUSES = ['a_abordar','em_disputa','engajado']` e `PENDING_CRITERION` ficam como estão — "Lembrança" e "Negativo" já são excluídos pela ausência da lista (fail-closed na query) e o critério declarado continua sem o novo estado (aceite: "pendências declaram o critério sem o novo estado"). Adicionar teste que pin a exclusão. +- **D6 — Sollinha reconhece o status:** +1 linha na seção "Lideranças pendentes de abordagem" do `systemPrompt.ts` (ex.: "Liderança com status 'Lembrança' é favorável sem compromisso — não conta como pendente de abordagem"). O rótulo na leitura vem de D3. +- **Semânticas que excluem "Lembrança" intencionalmente (verificar com testes, não mudar):** convite de login exige `engajado` (`campaignInvite*`), acesso ao app exige `engajado` (`access/contacts.ts`, `access/municipalities.ts`), opção de "responsável" de atividade exige `engajado` (`activityLeadershipOptions.ts`) — uma "Lembrança" não é engajada, comportamento correto. + +### Componentes / mudanças + +- **`leadershipSupportStatuses`** (`src/lib/schemas/leadership.ts`): `['engajado', 'a_abordar', 'em_disputa', 'lembranca', 'negativo']` — tipo `SupportStatus` e `isSupportStatus` derivam sozinhos. +- **`Leadership.ts`** (collection): opção `{ label: 'Lembrança', value: 'lembranca' }` na posição 4 (admin select + admin filter automáticos). +- **Migration:** `add_lembranca_support_status` via `pnpm migrate:create`, SQL conforme D1. +- **`payload-types.ts`:** regenerar via `pnpm generate:types`. +- **`leadershipLabels.ts`:** `lembranca: 'Lembrança'`. +- **`SupportStatusBadge.tsx`:** `lembranca: { variant: 'support-remembered' }`. +- **`Badge.tsx`** + **`styles.css`:** variant e tokens (D2). +- **`getLeaderships.ts`:** `SUPPORT_LABELS` → `supportStatusLabels` (D3). +- **`systemPrompt.ts`:** linha de reconhecimento (D6). +- **`peopleData.ts`:** `leadershipStatusesInOrder` via enum canônico (D4). +- **Automático (sem edição):** `LeadershipListSupportStatusControl`, `LeadershipForm`/`LeadershipInternalForm`/`WizardLeadershipForm`, `leadershipListFilters`/`peopleListFilters`, `leadershipListUrl`/`peopleListUrl` (parse enum exaustivo), omniboxes, `leadershipData`, `wizardLeadershipContract`, `municipalityV2Network*`, actions/routes zod (`z.enum(leadershipSupportStatuses)`), `support-status/route.ts`. +- **Access / Consent:** nenhum — status é staff-only (`canManageCampaignStaffField`), leader lockdown intocado. + +### Dados → forma + +Não se aplica (nenhuma superfície de dado nova; valor de enum flui por filtros/facets existentes — decisão de produto do gate). + +## Fases verificáveis + +1. **Schema+server** — enum array + collection options + migration + `generate:types`; verificação: `pnpm migrate:status` e `pnpm migrate` local. +2. **UI** — labels + Badge + tokens + `SupportStatusBadge`; verificação: badge unit test (it.each) renderiza `Lembrança`. +3. **IA + pessoas** — `getLeaderships` DRY, `systemPrompt`, `peopleData` ordem; verificação: testes de tool e listas. +4. **Testes** — atualizar os que enumeram status (badge it.each, "every status member" do `leadershipListUrl`, fixture e2e, int spec com `lembranca` persistindo, pin de exclusão de pendência); NENHUM teste novo de feature em volume. +5. **Gates** — `tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build` local. + +## Rabbit holes / Não escopo (engenharia) + +- Migração destrutiva do enum (recreate) — desnecessária com `BEFORE`. +- Reordenar `a_abordar` no seletor — produto não pediu. +- Tratamento especial de votos para "Lembrança" — decidido no gate: campo de votos declarados já existente. +- Qualquer mudança em convites/acesso-app/atividade (engajado-only é o comportamento correto). + +## Riscos e mitigação + +- **Migration `ADD VALUE` + transação Postgres:** permitido desde PG 12 dentro de transação (novo valor não usado na mesma transação); precedente shipado em prod (`20260723_025513`). Mitigação: verificar `pnpm migrate` local e `migrate:status` antes do push. +- **Superfície enumerada esquecida:** mitigado por D3/D4 (fonte única) + teste de badge it.each + teste "every status member" da URL. +- **Filtro `lembranca` vazando para pendências:** fail-closed já está na query; o teste unit do `getPendingLeaderships` passa a incluir `lembranca` na exclusão pinada. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto (superfícies todas + pendências excluem + badge distinto + Sollinha reconhece) +- [x] Invariantes AGENTS/engineering-standards (sem transações novas, sem access novo, nomes EN, rótulos pt-BR) +- [x] Testes de domínio previstos (badge it.each, URL enum exaustivo, pin pendência, int persistence) diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index 432c6e1a..7f6161a0 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -72,6 +72,8 @@ body, --color-support-to-approach: var(--support-to-approach); --color-support-disputed-foreground: var(--support-disputed-foreground); --color-support-disputed: var(--support-disputed); + --color-support-remembered-foreground: var(--support-remembered-foreground); + --color-support-remembered: var(--support-remembered); --color-support-negative-foreground: var(--support-negative-foreground); --color-support-negative: var(--support-negative); --color-estimate-confirmed-foreground: var(--estimate-confirmed-foreground); @@ -249,6 +251,8 @@ body, --support-to-approach-foreground: #46505c; --support-disputed: #fef3c7; --support-disputed-foreground: #92400e; + --support-remembered: #e0e7ff; + --support-remembered-foreground: #3730a3; --support-negative: #fee2e2; --support-negative-foreground: #991b1b; --estimate-confirmed: #dcfce7; diff --git a/src/collections/Leadership.ts b/src/collections/Leadership.ts index cc858e20..4105ac37 100644 --- a/src/collections/Leadership.ts +++ b/src/collections/Leadership.ts @@ -146,6 +146,7 @@ export const Leadership: CollectionConfig = { { label: 'Engajado', value: 'engajado' }, { label: 'A abordar', value: 'a_abordar' }, { label: 'Em disputa', value: 'em_disputa' }, + { label: 'Lembrança', value: 'lembranca' }, { label: 'Negativo', value: 'negativo' }, ], }, diff --git a/src/components/campaign/leadership/SupportStatusBadge.tsx b/src/components/campaign/leadership/SupportStatusBadge.tsx index c849d01b..b892a259 100644 --- a/src/components/campaign/leadership/SupportStatusBadge.tsx +++ b/src/components/campaign/leadership/SupportStatusBadge.tsx @@ -10,6 +10,7 @@ const statusPresentation = { engajado: { variant: 'support-engaged' }, a_abordar: { variant: 'support-to-approach' }, em_disputa: { variant: 'support-disputed' }, + lembranca: { variant: 'support-remembered' }, negativo: { variant: 'support-negative' }, } as const diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx index 5d2b2a67..dd92fbad 100644 --- a/src/components/ui/Badge.tsx +++ b/src/components/ui/Badge.tsx @@ -24,6 +24,8 @@ const badgeVariants = cva( 'bg-support-to-approach text-support-to-approach-foreground [a]:hover:bg-support-to-approach/80', 'support-disputed': 'bg-support-disputed text-support-disputed-foreground [a]:hover:bg-support-disputed/80', + 'support-remembered': + 'bg-support-remembered text-support-remembered-foreground [a]:hover:bg-support-remembered/80', 'support-negative': 'bg-support-negative text-support-negative-foreground [a]:hover:bg-support-negative/80', 'estimate-confirmed': diff --git a/src/lib/schemas/leadership.ts b/src/lib/schemas/leadership.ts index 230ccfdc..6a3aaa5f 100644 --- a/src/lib/schemas/leadership.ts +++ b/src/lib/schemas/leadership.ts @@ -14,6 +14,7 @@ export const leadershipSupportStatuses = [ 'engajado', 'a_abordar', 'em_disputa', + 'lembranca', 'negativo', ] as const diff --git a/src/migrations/index.ts b/src/migrations/index.ts index b2f81972..20ef82c5 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -47,8 +47,7 @@ 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'; +import * as migration_20260811_153004_add_lembranca_support_status from './20260811_153004_add_lembranca_support_status'; export const migrations = [ { @@ -297,13 +296,8 @@ export const migrations = [ 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', + up: migration_20260811_153004_add_lembranca_support_status.up, + down: migration_20260811_153004_add_lembranca_support_status.down, + name: '20260811_153004_add_lembranca_support_status' }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index 37bf8da5..4ed0842c 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -590,7 +590,7 @@ export interface Leadership { stateDeputies?: (number | StateDeputy)[] | null; advisors?: (number | CampaignUser)[] | null; exclusive?: boolean | null; - supportStatus: 'engajado' | 'a_abordar' | 'em_disputa' | 'negativo'; + supportStatus: 'engajado' | 'a_abordar' | 'em_disputa' | 'lembranca' | 'negativo'; user?: (number | null) | CampaignUser; consent?: (number | null) | Consent; consentContentHash?: string | null; diff --git a/src/utilities/ai/systemPrompt.ts b/src/utilities/ai/systemPrompt.ts index 1b3aa36a..037330e0 100644 --- a/src/utilities/ai/systemPrompt.ts +++ b/src/utilities/ai/systemPrompt.ts @@ -43,6 +43,7 @@ export const AI_SYSTEM_PROMPT = `Você é o Sollinha, assistente virtual da camp ## Lideranças pendentes de abordagem - Use a ferramenta "getPendingLeaderships" para "quais lideranças ainda precisam ser abordadas em X", "lideranças sem assessor responsável" ou "quais municípios não têm liderança cadastrada". - A resposta da ferramenta traz o critério de pendência — declare-o sempre na sua resposta (ex.: "critério: status 'A abordar' ou 'Em disputa'; ou 'Engajado' sem compromisso de votos no escopo consultado"). +- Liderança com status "Lembrança" é favorável sem compromisso — não conta como pendente de abordagem; se perguntarem, explique isso e, se houver, aponte essas lideranças pela leitura normal de lideranças. - Escopos: território de identidade (aceita grafia sem acento, ex.: "Vale do Jiquiriça"), cidade ("Salvador" = as 19 zonas eleitorais juntas) ou município. Sem escopo, vale o escopo de acesso do usuário. - Quando a resposta trouxer "escopoRestrito: true", deixe claro que os resultados estão limitados aos municípios do portfólio do usuário; quando trouxer "truncado: true", sugira estreitar o escopo para ver o restante. - Ofereça links de navegação (buildCampaignLinks) para as lideranças citadas (por id) e municípios (por slug); assessores (por id) quando o perfil permitir. diff --git a/src/utilities/ai/tools/getLeaderships.ts b/src/utilities/ai/tools/getLeaderships.ts index 6d647337..36f7c5b4 100644 --- a/src/utilities/ai/tools/getLeaderships.ts +++ b/src/utilities/ai/tools/getLeaderships.ts @@ -3,13 +3,7 @@ import type { Where } from 'payload' import { z } from 'zod' import type { AIToolContext } from '@/lib/ai/types' - -const SUPPORT_LABELS: Record = { - engajado: 'Engajado', - a_abordar: 'A abordar', - em_disputa: 'Em disputa', - negativo: 'Negativo', -} +import { supportStatusLabels } from '@/utilities/leadership/leadershipLabels' export const getLeaderships = (ctx: AIToolContext) => tool({ @@ -67,7 +61,8 @@ export const getLeaderships = (ctx: AIToolContext) => organizacoes: organizations.map((o) => o.name), apoioExclusivo: doc.exclusive === true, status: - SUPPORT_LABELS[(doc.supportStatus as string) ?? ''] ?? (doc.supportStatus as string), + supportStatusLabels[doc.supportStatus as keyof typeof supportStatusLabels] ?? + (doc.supportStatus as string), observacoes: (doc.notes as string) ?? null, } }) diff --git a/src/utilities/leadership/leadershipLabels.ts b/src/utilities/leadership/leadershipLabels.ts index 4842cc10..2cb825a0 100644 --- a/src/utilities/leadership/leadershipLabels.ts +++ b/src/utilities/leadership/leadershipLabels.ts @@ -8,6 +8,7 @@ export const supportStatusLabels: Record = { engajado: 'Engajado', a_abordar: 'A abordar', em_disputa: 'Em disputa', + lembranca: 'Lembrança', negativo: 'Negativo', } diff --git a/src/utilities/people/peopleData.ts b/src/utilities/people/peopleData.ts index c8edfeaa..471163d5 100644 --- a/src/utilities/people/peopleData.ts +++ b/src/utilities/people/peopleData.ts @@ -4,7 +4,11 @@ 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 { + isSupportStatus, + leadershipSupportStatuses, + type SupportStatus, +} from '@/lib/schemas/leadership' import type { CampaignUser, Contact, Leadership, StateDeputy } from '@/payload-types' import { municipalityIdsByAdvisorIds } from '@/utilities/advisorData' import { getAdvisorMunicipalityIds, isCampaignStaff } from '@/utilities/campaignAccess' @@ -275,9 +279,7 @@ export const peopleFilterFacetsFromRows = ( } const leadershipStatusesInOrder = (statuses: ReadonlySet): SupportStatus[] => - (['engajado', 'a_abordar', 'em_disputa', 'negativo'] as const).filter((status) => - statuses.has(status), - ) + leadershipSupportStatuses.filter((status) => statuses.has(status)) const EMPTY_FACETS: PeopleListFilterFacets = { municipalityIDs: [], statuses: [] } diff --git a/tests/e2e/fixtures/campaignE2EFixtures.ts b/tests/e2e/fixtures/campaignE2EFixtures.ts index 2abf4c51..1f3a93ae 100644 --- a/tests/e2e/fixtures/campaignE2EFixtures.ts +++ b/tests/e2e/fixtures/campaignE2EFixtures.ts @@ -183,7 +183,7 @@ export class CampaignE2EOwnership { }: { namePrefix: string municipalities: { id: number; name: string }[] - supportStatus?: 'a_abordar' | 'em_disputa' | 'engajado' | 'negativo' + supportStatus?: 'a_abordar' | 'em_disputa' | 'lembranca' | 'engajado' | 'negativo' user?: number }): Promise<{ contactName: string; contactId: number; leadershipId: number }> { const contactName = this.value(namePrefix) diff --git a/tests/int/campaignLeadership.int.spec.ts b/tests/int/campaignLeadership.int.spec.ts index e60b9857..7274493e 100644 --- a/tests/int/campaignLeadership.int.spec.ts +++ b/tests/int/campaignLeadership.int.spec.ts @@ -427,6 +427,27 @@ describe('campaign leadership domain', () => { ).rejects.toThrow() }) + it('persists the "lembranca" support status through create and internal update (C119)', async () => { + const coordinator = await campaignFixtures().createCampaignUser('coordinator') + const municipality = await campaignFixtures().getMunicipality() + + const created = await createLeadershipRecord(payload, coordinator, { + municipalities: [municipality.id], + name: 'Liderança Lembrança', + phone: campaignFixtures().phone(), + supportStatus: 'lembranca', + }) + + const doc = await payload.findByID({ collection: 'leadership', id: created.id, depth: 0 }) + expect(doc.supportStatus).toBe('lembranca') + + const updated = await updateLeadershipInternalRecord(payload, coordinator, { + id: created.id, + supportStatus: 'lembranca', + }) + expect(updated.supportStatus).toBe('lembranca') + }) + it('denies leadership creation to leader accounts', async () => { const leader = await campaignFixtures().createCampaignUser('leader') const municipality = await campaignFixtures().getMunicipality() diff --git a/tests/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts index 800fed71..850751f0 100644 --- a/tests/unit/campaignComponents.unit.spec.ts +++ b/tests/unit/campaignComponents.unit.spec.ts @@ -188,6 +188,7 @@ describe('campaign visual foundation', () => { ['engajado', 'Engajado'], ['a_abordar', 'A abordar'], ['em_disputa', 'Em disputa'], + ['lembranca', 'Lembrança'], ['negativo', 'Negativo'], ] as const)('renders the %s support status with text and semantic state', (status, label) => { const html = renderToStaticMarkup(createElement(SupportStatusBadge, { status })) diff --git a/tests/unit/leadershipListUrl.unit.spec.ts b/tests/unit/leadershipListUrl.unit.spec.ts index 90d5877d..30ccf7aa 100644 --- a/tests/unit/leadershipListUrl.unit.spec.ts +++ b/tests/unit/leadershipListUrl.unit.spec.ts @@ -63,7 +63,7 @@ describe('leadership list URL contract', () => { it('canonicalizes selecting every status member to the absent filter (B18)', () => { expect( parseLeadershipListParams({ - status: ['engajado', 'a_abordar', 'em_disputa', 'negativo'], + status: ['engajado', 'a_abordar', 'em_disputa', 'lembranca', 'negativo'], }), ).toEqual({ page: 1 }) }) diff --git a/tests/unit/pendingLeadershipsTool.unit.spec.ts b/tests/unit/pendingLeadershipsTool.unit.spec.ts index 43af73cf..19ded2f4 100644 --- a/tests/unit/pendingLeadershipsTool.unit.spec.ts +++ b/tests/unit/pendingLeadershipsTool.unit.spec.ts @@ -86,7 +86,7 @@ describe('getPendingLeaderships gate (B185)', () => { }) describe('getPendingLeaderships pending criterion (B185)', () => { - it('lists a_abordar/em_disputa e engajado sem pledge; exclui engajado com pledge e qualquer negativo', async () => { + it('lists a_abordar/em_disputa e engajado sem pledge; exclui engajado com pledge, qualquer negativo e qualquer lembranca (C119)', async () => { const find = vi.fn() find.mockResolvedValueOnce( findResult([ @@ -95,6 +95,7 @@ describe('getPendingLeaderships pending criterion (B185)', () => { leadershipDoc({ id: 3, supportStatus: 'em_disputa' }), leadershipDoc({ id: 4, supportStatus: 'engajado' }), leadershipDoc({ id: 5, supportStatus: 'negativo' }), + leadershipDoc({ id: 6, supportStatus: 'lembranca' }), ]), ) find.mockResolvedValueOnce(findResult([{ leadership: 2 }])) From 3a4e44f422e2e6a9e7515681db2a8ef2d9f85cd3 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 13:09:43 -0300 Subject: [PATCH 09/14] chore(C119): regenera migration sobre main rebaseada (C112 phones) e ajusta teste int para schema phones --- ...1_155116_add_lembranca_support_status.json | 13338 ++++++++++++++++ ...811_155116_add_lembranca_support_status.ts | 16 + src/migrations/index.ts | 14 +- tests/int/campaignLeadership.int.spec.ts | 2 +- 4 files changed, 13365 insertions(+), 5 deletions(-) create mode 100644 src/migrations/20260811_155116_add_lembranca_support_status.json create mode 100644 src/migrations/20260811_155116_add_lembranca_support_status.ts diff --git a/src/migrations/20260811_155116_add_lembranca_support_status.json b/src/migrations/20260811_155116_add_lembranca_support_status.json new file mode 100644 index 00000000..b2213084 --- /dev/null +++ b/src/migrations/20260811_155116_add_lembranca_support_status.json @@ -0,0 +1,13338 @@ +{ + "version": "7", + "dialect": "postgresql", + "tables": { + "public.users_roles": { + "name": "users_roles", + "schema": "", + "columns": { + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "enum_users_roles", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + } + }, + "indexes": { + "users_roles_order_idx": { + "name": "users_roles_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_roles_parent_idx": { + "name": "users_roles_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_roles_parent_fk": { + "name": "users_roles_parent_fk", + "tableFrom": "users_roles", + "tableTo": "users", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users_sessions": { + "name": "users_sessions", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "users_sessions_order_idx": { + "name": "users_sessions_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_sessions_parent_id_idx": { + "name": "users_sessions_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_sessions_parent_id_fk": { + "name": "users_sessions_parent_id_fk", + "tableFrom": "users_sessions", + "tableTo": "users", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "reset_password_token": { + "name": "reset_password_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "reset_password_expiration": { + "name": "reset_password_expiration", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "salt": { + "name": "salt", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "hash": { + "name": "hash", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "login_attempts": { + "name": "login_attempts", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "lock_until": { + "name": "lock_until", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_updated_at_idx": { + "name": "users_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_created_at_idx": { + "name": "users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_user_sessions": { + "name": "campaign_user_sessions", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "campaign_user_sessions_order_idx": { + "name": "campaign_user_sessions_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_user_sessions_parent_id_idx": { + "name": "campaign_user_sessions_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "campaign_user_sessions_parent_id_fk": { + "name": "campaign_user_sessions_parent_id_fk", + "tableFrom": "campaign_user_sessions", + "tableTo": "campaign_user", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_user": { + "name": "campaign_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "avatar_id": { + "name": "avatar_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "enum_campaign_user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'leader'" + }, + "phone": { + "name": "phone", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "reset_password_token": { + "name": "reset_password_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "reset_password_expiration": { + "name": "reset_password_expiration", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "salt": { + "name": "salt", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "hash": { + "name": "hash", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "login_attempts": { + "name": "login_attempts", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "lock_until": { + "name": "lock_until", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "campaign_user_contact_idx": { + "name": "campaign_user_contact_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_user_avatar_idx": { + "name": "campaign_user_avatar_idx", + "columns": [ + { + "expression": "avatar_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_user_phone_idx": { + "name": "campaign_user_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_user_updated_at_idx": { + "name": "campaign_user_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_user_created_at_idx": { + "name": "campaign_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_user_email_idx": { + "name": "campaign_user_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_user_username_idx": { + "name": "campaign_user_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "campaign_user_contact_id_contact_id_fk": { + "name": "campaign_user_contact_id_contact_id_fk", + "tableFrom": "campaign_user", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "campaign_user_avatar_id_media_id_fk": { + "name": "campaign_user_avatar_id_media_id_fk", + "tableFrom": "campaign_user", + "tableTo": "media", + "columnsFrom": [ + "avatar_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_web_authn_credential": { + "name": "campaign_web_authn_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "transports": { + "name": "transports", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "device_label": { + "name": "device_label", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "campaign_web_authn_credential_user_idx": { + "name": "campaign_web_authn_credential_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_web_authn_credential_credential_id_idx": { + "name": "campaign_web_authn_credential_credential_id_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_web_authn_credential_updated_at_idx": { + "name": "campaign_web_authn_credential_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_web_authn_credential_created_at_idx": { + "name": "campaign_web_authn_credential_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "campaign_web_authn_credential_user_id_campaign_user_id_fk": { + "name": "campaign_web_authn_credential_user_id_campaign_user_id_fk", + "tableFrom": "campaign_web_authn_credential", + "tableTo": "campaign_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "enum_notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_recipient_idx": { + "name": "notification_recipient_idx", + "columns": [ + { + "expression": "recipient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_type_idx": { + "name": "notification_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_municipality_idx": { + "name": "notification_municipality_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_read_at_idx": { + "name": "notification_read_at_idx", + "columns": [ + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_updated_at_idx": { + "name": "notification_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_created_at_idx": { + "name": "notification_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_recipient_id_campaign_user_id_fk": { + "name": "notification_recipient_id_campaign_user_id_fk", + "tableFrom": "notification", + "tableTo": "campaign_user", + "columnsFrom": [ + "recipient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "notification_municipality_id_municipality_id_fk": { + "name": "notification_municipality_id_municipality_id_fk", + "tableFrom": "notification", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscription": { + "name": "push_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "expiration_time": { + "name": "expiration_time", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "consent_id": { + "name": "consent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "consent_content_hash": { + "name": "consent_content_hash", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "consented_at": { + "name": "consented_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "push_subscription_user_idx": { + "name": "push_subscription_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_subscription_endpoint_idx": { + "name": "push_subscription_endpoint_idx", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_subscription_consent_idx": { + "name": "push_subscription_consent_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_subscription_updated_at_idx": { + "name": "push_subscription_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "push_subscription_created_at_idx": { + "name": "push_subscription_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "push_subscription_user_id_campaign_user_id_fk": { + "name": "push_subscription_user_id_campaign_user_id_fk", + "tableFrom": "push_subscription", + "tableTo": "campaign_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "push_subscription_consent_id_consent_id_fk": { + "name": "push_subscription_consent_id_consent_id_fk", + "tableFrom": "push_subscription", + "tableTo": "consent", + "columnsFrom": [ + "consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_invite": { + "name": "campaign_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "leadership_id": { + "name": "leadership_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "enum_campaign_invite_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "campaign_invite_token_hash_idx": { + "name": "campaign_invite_token_hash_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_invite_leadership_idx": { + "name": "campaign_invite_leadership_idx", + "columns": [ + { + "expression": "leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_invite_kind_idx": { + "name": "campaign_invite_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_invite_expires_at_idx": { + "name": "campaign_invite_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_invite_used_at_idx": { + "name": "campaign_invite_used_at_idx", + "columns": [ + { + "expression": "used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_invite_revoked_at_idx": { + "name": "campaign_invite_revoked_at_idx", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_invite_created_by_idx": { + "name": "campaign_invite_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_invite_updated_at_idx": { + "name": "campaign_invite_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_invite_created_at_idx": { + "name": "campaign_invite_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_kind_idx": { + "name": "leadership_kind_idx", + "columns": [ + { + "expression": "leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "campaign_invite_leadership_id_leadership_id_fk": { + "name": "campaign_invite_leadership_id_leadership_id_fk", + "tableFrom": "campaign_invite", + "tableTo": "leadership", + "columnsFrom": [ + "leadership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "campaign_invite_created_by_id_campaign_user_id_fk": { + "name": "campaign_invite_created_by_id_campaign_user_id_fk", + "tableFrom": "campaign_invite", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_vote_summary_snapshot": { + "name": "campaign_vote_summary_snapshot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "day": { + "name": "day", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'statewide'" + }, + "staff_vote_total_central": { + "name": "staff_vote_total_central", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "campaign_vote_summary_snapshot_day_idx": { + "name": "campaign_vote_summary_snapshot_day_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_vote_summary_snapshot_scope_key_idx": { + "name": "campaign_vote_summary_snapshot_scope_key_idx", + "columns": [ + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_vote_summary_snapshot_updated_at_idx": { + "name": "campaign_vote_summary_snapshot_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_vote_summary_snapshot_created_at_idx": { + "name": "campaign_vote_summary_snapshot_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "day_scopeKey_idx": { + "name": "day_scopeKey_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.municipality_strengths": { + "name": "municipality_strengths", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "municipality_strengths_order_idx": { + "name": "municipality_strengths_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_strengths_parent_id_idx": { + "name": "municipality_strengths_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "municipality_strengths_parent_id_fk": { + "name": "municipality_strengths_parent_id_fk", + "tableFrom": "municipality_strengths", + "tableTo": "municipality", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.municipality_risks": { + "name": "municipality_risks", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "municipality_risks_order_idx": { + "name": "municipality_risks_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_risks_parent_id_idx": { + "name": "municipality_risks_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "municipality_risks_parent_id_fk": { + "name": "municipality_risks_parent_id_fk", + "tableFrom": "municipality_risks", + "tableTo": "municipality", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.municipality": { + "name": "municipality", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "enum_municipality_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "region": { + "name": "region", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "ibge_code": { + "name": "ibge_code", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "tse_city_code": { + "name": "tse_city_code", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "zone_number": { + "name": "zone_number", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "enum_municipality_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'normal'" + }, + "engagement_level": { + "name": "engagement_level", + "type": "enum_municipality_engagement_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "level_note": { + "name": "level_note", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "level_changed_at": { + "name": "level_changed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expected_votes_pessimistic": { + "name": "expected_votes_pessimistic", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "expected_votes_central": { + "name": "expected_votes_central", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "expected_votes_optimistic": { + "name": "expected_votes_optimistic", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "political_trend_status": { + "name": "political_trend_status", + "type": "enum_municipality_political_trend_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "political_trend_note": { + "name": "political_trend_note", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "political_trend_recorded_by_id": { + "name": "political_trend_recorded_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "political_trend_recorded_at": { + "name": "political_trend_recorded_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "dobradinha_notes": { + "name": "dobradinha_notes", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "next_steps": { + "name": "next_steps", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "budget_notes": { + "name": "budget_notes", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "last_update_at": { + "name": "last_update_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "municipality_name_idx": { + "name": "municipality_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_slug_idx": { + "name": "municipality_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_kind_idx": { + "name": "municipality_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_city_idx": { + "name": "municipality_city_idx", + "columns": [ + { + "expression": "city", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_region_idx": { + "name": "municipality_region_idx", + "columns": [ + { + "expression": "region", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_ibge_code_idx": { + "name": "municipality_ibge_code_idx", + "columns": [ + { + "expression": "ibge_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_tse_city_code_idx": { + "name": "municipality_tse_city_code_idx", + "columns": [ + { + "expression": "tse_city_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_zone_number_idx": { + "name": "municipality_zone_number_idx", + "columns": [ + { + "expression": "zone_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_priority_idx": { + "name": "municipality_priority_idx", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_engagement_level_idx": { + "name": "municipality_engagement_level_idx", + "columns": [ + { + "expression": "engagement_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_level_changed_at_idx": { + "name": "municipality_level_changed_at_idx", + "columns": [ + { + "expression": "level_changed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_political_trend_political_trend_recorded_by_idx": { + "name": "municipality_political_trend_political_trend_recorded_by_idx", + "columns": [ + { + "expression": "political_trend_recorded_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_last_update_at_idx": { + "name": "municipality_last_update_at_idx", + "columns": [ + { + "expression": "last_update_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_updated_at_idx": { + "name": "municipality_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_created_at_idx": { + "name": "municipality_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "municipality_political_trend_recorded_by_id_campaign_user_id_fk": { + "name": "municipality_political_trend_recorded_by_id_campaign_user_id_fk", + "tableFrom": "municipality", + "tableTo": "campaign_user", + "columnsFrom": [ + "political_trend_recorded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.municipality_rels": { + "name": "municipality_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "campaign_user_id": { + "name": "campaign_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "state_deputy_id": { + "name": "state_deputy_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "municipality_rels_order_idx": { + "name": "municipality_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_rels_parent_idx": { + "name": "municipality_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_rels_path_idx": { + "name": "municipality_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_rels_campaign_user_id_idx": { + "name": "municipality_rels_campaign_user_id_idx", + "columns": [ + { + "expression": "campaign_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_rels_state_deputy_id_idx": { + "name": "municipality_rels_state_deputy_id_idx", + "columns": [ + { + "expression": "state_deputy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "municipality_rels_parent_fk": { + "name": "municipality_rels_parent_fk", + "tableFrom": "municipality_rels", + "tableTo": "municipality", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "municipality_rels_campaign_user_fk": { + "name": "municipality_rels_campaign_user_fk", + "tableFrom": "municipality_rels", + "tableTo": "campaign_user", + "columnsFrom": [ + "campaign_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "municipality_rels_state_deputy_fk": { + "name": "municipality_rels_state_deputy_fk", + "tableFrom": "municipality_rels", + "tableTo": "state_deputy", + "columnsFrom": [ + "state_deputy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leadership": { + "name": "leadership", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "exclusive": { + "name": "exclusive", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "support_status": { + "name": "support_status", + "type": "enum_leadership_support_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'a_abordar'" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "consent_id": { + "name": "consent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "consent_content_hash": { + "name": "consent_content_hash", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "consented_at": { + "name": "consented_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leadership_contact_idx": { + "name": "leadership_contact_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_exclusive_idx": { + "name": "leadership_exclusive_idx", + "columns": [ + { + "expression": "exclusive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_support_status_idx": { + "name": "leadership_support_status_idx", + "columns": [ + { + "expression": "support_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_user_idx": { + "name": "leadership_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_consent_idx": { + "name": "leadership_consent_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_created_by_idx": { + "name": "leadership_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_updated_at_idx": { + "name": "leadership_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_created_at_idx": { + "name": "leadership_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leadership_contact_id_contact_id_fk": { + "name": "leadership_contact_id_contact_id_fk", + "tableFrom": "leadership", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "leadership_user_id_campaign_user_id_fk": { + "name": "leadership_user_id_campaign_user_id_fk", + "tableFrom": "leadership", + "tableTo": "campaign_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "leadership_consent_id_consent_id_fk": { + "name": "leadership_consent_id_consent_id_fk", + "tableFrom": "leadership", + "tableTo": "consent", + "columnsFrom": [ + "consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "leadership_created_by_id_campaign_user_id_fk": { + "name": "leadership_created_by_id_campaign_user_id_fk", + "tableFrom": "leadership", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leadership_rels": { + "name": "leadership_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "state_deputy_id": { + "name": "state_deputy_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_user_id": { + "name": "campaign_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "leadership_rels_order_idx": { + "name": "leadership_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_rels_parent_idx": { + "name": "leadership_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_rels_path_idx": { + "name": "leadership_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_rels_municipality_id_idx": { + "name": "leadership_rels_municipality_id_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_rels_organization_id_idx": { + "name": "leadership_rels_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_rels_state_deputy_id_idx": { + "name": "leadership_rels_state_deputy_id_idx", + "columns": [ + { + "expression": "state_deputy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_rels_campaign_user_id_idx": { + "name": "leadership_rels_campaign_user_id_idx", + "columns": [ + { + "expression": "campaign_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leadership_rels_parent_fk": { + "name": "leadership_rels_parent_fk", + "tableFrom": "leadership_rels", + "tableTo": "leadership", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leadership_rels_municipality_fk": { + "name": "leadership_rels_municipality_fk", + "tableFrom": "leadership_rels", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leadership_rels_organization_fk": { + "name": "leadership_rels_organization_fk", + "tableFrom": "leadership_rels", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leadership_rels_state_deputy_fk": { + "name": "leadership_rels_state_deputy_fk", + "tableFrom": "leadership_rels", + "tableTo": "state_deputy", + "columnsFrom": [ + "state_deputy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "leadership_rels_campaign_user_fk": { + "name": "leadership_rels_campaign_user_fk", + "tableFrom": "leadership_rels", + "tableTo": "campaign_user", + "columnsFrom": [ + "campaign_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "enum_organization_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_name_idx": { + "name": "organization_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_slug_idx": { + "name": "organization_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_kind_idx": { + "name": "organization_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_created_by_idx": { + "name": "organization_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_updated_at_idx": { + "name": "organization_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_created_at_idx": { + "name": "organization_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_created_by_id_campaign_user_id_fk": { + "name": "organization_created_by_id_campaign_user_id_fk", + "tableFrom": "organization", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_rels": { + "name": "organization_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_rels_order_idx": { + "name": "organization_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_rels_parent_idx": { + "name": "organization_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_rels_path_idx": { + "name": "organization_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_rels_municipality_id_idx": { + "name": "organization_rels_municipality_id_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_rels_parent_fk": { + "name": "organization_rels_parent_fk", + "tableFrom": "organization_rels", + "tableTo": "organization", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_rels_municipality_fk": { + "name": "organization_rels_municipality_fk", + "tableFrom": "organization_rels", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.state_deputy": { + "name": "state_deputy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "party": { + "name": "party", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "state_deputy_contact_idx": { + "name": "state_deputy_contact_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "state_deputy_slug_idx": { + "name": "state_deputy_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "state_deputy_party_idx": { + "name": "state_deputy_party_idx", + "columns": [ + { + "expression": "party", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "state_deputy_created_by_idx": { + "name": "state_deputy_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "state_deputy_updated_at_idx": { + "name": "state_deputy_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "state_deputy_created_at_idx": { + "name": "state_deputy_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "state_deputy_contact_id_contact_id_fk": { + "name": "state_deputy_contact_id_contact_id_fk", + "tableFrom": "state_deputy", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "state_deputy_created_by_id_campaign_user_id_fk": { + "name": "state_deputy_created_by_id_campaign_user_id_fk", + "tableFrom": "state_deputy", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.state_deputy_rels": { + "name": "state_deputy_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "campaign_user_id": { + "name": "campaign_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "state_deputy_rels_order_idx": { + "name": "state_deputy_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "state_deputy_rels_parent_idx": { + "name": "state_deputy_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "state_deputy_rels_path_idx": { + "name": "state_deputy_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "state_deputy_rels_campaign_user_id_idx": { + "name": "state_deputy_rels_campaign_user_id_idx", + "columns": [ + { + "expression": "campaign_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "state_deputy_rels_parent_fk": { + "name": "state_deputy_rels_parent_fk", + "tableFrom": "state_deputy_rels", + "tableTo": "state_deputy", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "state_deputy_rels_campaign_user_fk": { + "name": "state_deputy_rels_campaign_user_fk", + "tableFrom": "state_deputy_rels", + "tableTo": "campaign_user", + "columnsFrom": [ + "campaign_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vote_pledge": { + "name": "vote_pledge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "leadership_id": { + "name": "leadership_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "declared_votes": { + "name": "declared_votes", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "declared_at": { + "name": "declared_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "declared_by_id": { + "name": "declared_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "estimated_votes_pessimistic": { + "name": "estimated_votes_pessimistic", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "estimated_votes_central": { + "name": "estimated_votes_central", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "estimated_votes_optimistic": { + "name": "estimated_votes_optimistic", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "estimate_note": { + "name": "estimate_note", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "estimated_by_id": { + "name": "estimated_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "estimated_at": { + "name": "estimated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "vote_pledge_leadership_idx": { + "name": "vote_pledge_leadership_idx", + "columns": [ + { + "expression": "leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vote_pledge_municipality_idx": { + "name": "vote_pledge_municipality_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vote_pledge_declared_votes_idx": { + "name": "vote_pledge_declared_votes_idx", + "columns": [ + { + "expression": "declared_votes", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vote_pledge_declared_by_idx": { + "name": "vote_pledge_declared_by_idx", + "columns": [ + { + "expression": "declared_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vote_pledge_estimated_by_idx": { + "name": "vote_pledge_estimated_by_idx", + "columns": [ + { + "expression": "estimated_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vote_pledge_updated_at_idx": { + "name": "vote_pledge_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vote_pledge_created_at_idx": { + "name": "vote_pledge_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leadership_municipality_idx": { + "name": "leadership_municipality_idx", + "columns": [ + { + "expression": "leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vote_pledge_leadership_id_leadership_id_fk": { + "name": "vote_pledge_leadership_id_leadership_id_fk", + "tableFrom": "vote_pledge", + "tableTo": "leadership", + "columnsFrom": [ + "leadership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "vote_pledge_municipality_id_municipality_id_fk": { + "name": "vote_pledge_municipality_id_municipality_id_fk", + "tableFrom": "vote_pledge", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "vote_pledge_declared_by_id_campaign_user_id_fk": { + "name": "vote_pledge_declared_by_id_campaign_user_id_fk", + "tableFrom": "vote_pledge", + "tableTo": "campaign_user", + "columnsFrom": [ + "declared_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "vote_pledge_estimated_by_id_campaign_user_id_fk": { + "name": "vote_pledge_estimated_by_id_campaign_user_id_fk", + "tableFrom": "vote_pledge", + "tableTo": "campaign_user", + "columnsFrom": [ + "estimated_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._vote_pledge_v": { + "name": "_vote_pledge_v", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_leadership_id": { + "name": "version_leadership_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version_municipality_id": { + "name": "version_municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version_declared_votes": { + "name": "version_declared_votes", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "version_declared_at": { + "name": "version_declared_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_declared_by_id": { + "name": "version_declared_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_estimated_votes_pessimistic": { + "name": "version_estimated_votes_pessimistic", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "version_estimated_votes_central": { + "name": "version_estimated_votes_central", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "version_estimated_votes_optimistic": { + "name": "version_estimated_votes_optimistic", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "version_estimate_note": { + "name": "version_estimate_note", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_estimated_by_id": { + "name": "version_estimated_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_estimated_at": { + "name": "version_estimated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_updated_at": { + "name": "version_updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "_vote_pledge_v_parent_idx": { + "name": "_vote_pledge_v_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_vote_pledge_v_version_version_leadership_idx": { + "name": "_vote_pledge_v_version_version_leadership_idx", + "columns": [ + { + "expression": "version_leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_vote_pledge_v_version_version_municipality_idx": { + "name": "_vote_pledge_v_version_version_municipality_idx", + "columns": [ + { + "expression": "version_municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_vote_pledge_v_version_version_declared_votes_idx": { + "name": "_vote_pledge_v_version_version_declared_votes_idx", + "columns": [ + { + "expression": "version_declared_votes", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_vote_pledge_v_version_version_declared_by_idx": { + "name": "_vote_pledge_v_version_version_declared_by_idx", + "columns": [ + { + "expression": "version_declared_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_vote_pledge_v_version_version_estimated_by_idx": { + "name": "_vote_pledge_v_version_version_estimated_by_idx", + "columns": [ + { + "expression": "version_estimated_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_vote_pledge_v_version_version_updated_at_idx": { + "name": "_vote_pledge_v_version_version_updated_at_idx", + "columns": [ + { + "expression": "version_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_vote_pledge_v_version_version_created_at_idx": { + "name": "_vote_pledge_v_version_version_created_at_idx", + "columns": [ + { + "expression": "version_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_vote_pledge_v_created_at_idx": { + "name": "_vote_pledge_v_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_vote_pledge_v_updated_at_idx": { + "name": "_vote_pledge_v_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "version_leadership_version_municipality_idx": { + "name": "version_leadership_version_municipality_idx", + "columns": [ + { + "expression": "version_leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "_vote_pledge_v_parent_id_vote_pledge_id_fk": { + "name": "_vote_pledge_v_parent_id_vote_pledge_id_fk", + "tableFrom": "_vote_pledge_v", + "tableTo": "vote_pledge", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "_vote_pledge_v_version_leadership_id_leadership_id_fk": { + "name": "_vote_pledge_v_version_leadership_id_leadership_id_fk", + "tableFrom": "_vote_pledge_v", + "tableTo": "leadership", + "columnsFrom": [ + "version_leadership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "_vote_pledge_v_version_municipality_id_municipality_id_fk": { + "name": "_vote_pledge_v_version_municipality_id_municipality_id_fk", + "tableFrom": "_vote_pledge_v", + "tableTo": "municipality", + "columnsFrom": [ + "version_municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "_vote_pledge_v_version_declared_by_id_campaign_user_id_fk": { + "name": "_vote_pledge_v_version_declared_by_id_campaign_user_id_fk", + "tableFrom": "_vote_pledge_v", + "tableTo": "campaign_user", + "columnsFrom": [ + "version_declared_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "_vote_pledge_v_version_estimated_by_id_campaign_user_id_fk": { + "name": "_vote_pledge_v_version_estimated_by_id_campaign_user_id_fk", + "tableFrom": "_vote_pledge_v", + "tableTo": "campaign_user", + "columnsFrom": [ + "version_estimated_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.allocation_decision": { + "name": "allocation_decision", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pattern_id": { + "name": "pattern_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "enum_allocation_decision_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "rationale": { + "name": "rationale", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "alternative_reading": { + "name": "alternative_reading", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "decided_by_id": { + "name": "decided_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "allocation_decision_municipality_idx": { + "name": "allocation_decision_municipality_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "allocation_decision_pattern_id_idx": { + "name": "allocation_decision_pattern_id_idx", + "columns": [ + { + "expression": "pattern_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "allocation_decision_decided_by_idx": { + "name": "allocation_decision_decided_by_idx", + "columns": [ + { + "expression": "decided_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "allocation_decision_updated_at_idx": { + "name": "allocation_decision_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "allocation_decision_created_at_idx": { + "name": "allocation_decision_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "allocation_decision_municipality_id_municipality_id_fk": { + "name": "allocation_decision_municipality_id_municipality_id_fk", + "tableFrom": "allocation_decision", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "allocation_decision_decided_by_id_campaign_user_id_fk": { + "name": "allocation_decision_decided_by_id_campaign_user_id_fk", + "tableFrom": "allocation_decision", + "tableTo": "campaign_user", + "columnsFrom": [ + "decided_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_demand_status_history": { + "name": "campaign_demand_status_history", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "enum_campaign_demand_status_history_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "campaign_demand_status_history_order_idx": { + "name": "campaign_demand_status_history_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_status_history_parent_id_idx": { + "name": "campaign_demand_status_history_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_status_history_author_idx": { + "name": "campaign_demand_status_history_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "campaign_demand_status_history_author_id_campaign_user_id_fk": { + "name": "campaign_demand_status_history_author_id_campaign_user_id_fk", + "tableFrom": "campaign_demand_status_history", + "tableTo": "campaign_user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "campaign_demand_status_history_parent_id_fk": { + "name": "campaign_demand_status_history_parent_id_fk", + "tableFrom": "campaign_demand_status_history", + "tableTo": "campaign_demand", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_demand": { + "name": "campaign_demand", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "enum_campaign_demand_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "activity_id": { + "name": "activity_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "leadership_id": { + "name": "leadership_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "enum_campaign_demand_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'aberta'" + }, + "decision_note": { + "name": "decision_note", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "decided_by_id": { + "name": "decided_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "campaign_demand_title_idx": { + "name": "campaign_demand_title_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_slug_idx": { + "name": "campaign_demand_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_kind_idx": { + "name": "campaign_demand_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_municipality_idx": { + "name": "campaign_demand_municipality_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_activity_idx": { + "name": "campaign_demand_activity_idx", + "columns": [ + { + "expression": "activity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_leadership_idx": { + "name": "campaign_demand_leadership_idx", + "columns": [ + { + "expression": "leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_status_idx": { + "name": "campaign_demand_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_decided_by_idx": { + "name": "campaign_demand_decided_by_idx", + "columns": [ + { + "expression": "decided_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_created_by_idx": { + "name": "campaign_demand_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_updated_at_idx": { + "name": "campaign_demand_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_created_at_idx": { + "name": "campaign_demand_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "campaign_demand_municipality_id_municipality_id_fk": { + "name": "campaign_demand_municipality_id_municipality_id_fk", + "tableFrom": "campaign_demand", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "campaign_demand_activity_id_activity_id_fk": { + "name": "campaign_demand_activity_id_activity_id_fk", + "tableFrom": "campaign_demand", + "tableTo": "activity", + "columnsFrom": [ + "activity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "campaign_demand_leadership_id_leadership_id_fk": { + "name": "campaign_demand_leadership_id_leadership_id_fk", + "tableFrom": "campaign_demand", + "tableTo": "leadership", + "columnsFrom": [ + "leadership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "campaign_demand_decided_by_id_campaign_user_id_fk": { + "name": "campaign_demand_decided_by_id_campaign_user_id_fk", + "tableFrom": "campaign_demand", + "tableTo": "campaign_user", + "columnsFrom": [ + "decided_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "campaign_demand_created_by_id_campaign_user_id_fk": { + "name": "campaign_demand_created_by_id_campaign_user_id_fk", + "tableFrom": "campaign_demand", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_demand_rels": { + "name": "campaign_demand_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "media_id": { + "name": "media_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "campaign_demand_rels_order_idx": { + "name": "campaign_demand_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_rels_parent_idx": { + "name": "campaign_demand_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_rels_path_idx": { + "name": "campaign_demand_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "campaign_demand_rels_media_id_idx": { + "name": "campaign_demand_rels_media_id_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "campaign_demand_rels_parent_fk": { + "name": "campaign_demand_rels_parent_fk", + "tableFrom": "campaign_demand_rels", + "tableTo": "campaign_demand", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "campaign_demand_rels_media_fk": { + "name": "campaign_demand_rels_media_fk", + "tableFrom": "campaign_demand_rels", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.supporter": { + "name": "supporter", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vote_intention": { + "name": "vote_intention", + "type": "enum_supporter_vote_intention", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "consent_id": { + "name": "consent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "consent_content_hash": { + "name": "consent_content_hash", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "consented_at": { + "name": "consented_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "vote_intention_consent_id": { + "name": "vote_intention_consent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vote_intention_consent_content_hash": { + "name": "vote_intention_consent_content_hash", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "vote_intention_consented_at": { + "name": "vote_intention_consented_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "enum_supporter_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "consent_note": { + "name": "consent_note", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "supporter_contact_idx": { + "name": "supporter_contact_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_municipality_idx": { + "name": "supporter_municipality_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_vote_intention_idx": { + "name": "supporter_vote_intention_idx", + "columns": [ + { + "expression": "vote_intention", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_consent_idx": { + "name": "supporter_consent_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_vote_intention_consent_idx": { + "name": "supporter_vote_intention_consent_idx", + "columns": [ + { + "expression": "vote_intention_consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_source_idx": { + "name": "supporter_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_created_by_idx": { + "name": "supporter_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_updated_at_idx": { + "name": "supporter_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_created_at_idx": { + "name": "supporter_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "supporter_contact_id_contact_id_fk": { + "name": "supporter_contact_id_contact_id_fk", + "tableFrom": "supporter", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "supporter_municipality_id_municipality_id_fk": { + "name": "supporter_municipality_id_municipality_id_fk", + "tableFrom": "supporter", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "supporter_consent_id_consent_id_fk": { + "name": "supporter_consent_id_consent_id_fk", + "tableFrom": "supporter", + "tableTo": "consent", + "columnsFrom": [ + "consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "supporter_vote_intention_consent_id_consent_id_fk": { + "name": "supporter_vote_intention_consent_id_consent_id_fk", + "tableFrom": "supporter", + "tableTo": "consent", + "columnsFrom": [ + "vote_intention_consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "supporter_created_by_id_campaign_user_id_fk": { + "name": "supporter_created_by_id_campaign_user_id_fk", + "tableFrom": "supporter", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.supporter_import_batch": { + "name": "supporter_import_batch", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "batch_id": { + "name": "batch_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "rows": { + "name": "rows", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "supporter_import_batch_batch_id_idx": { + "name": "supporter_import_batch_batch_id_idx", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_import_batch_actor_idx": { + "name": "supporter_import_batch_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_import_batch_expires_at_idx": { + "name": "supporter_import_batch_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_import_batch_updated_at_idx": { + "name": "supporter_import_batch_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "supporter_import_batch_created_at_idx": { + "name": "supporter_import_batch_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "supporter_import_batch_actor_id_campaign_user_id_fk": { + "name": "supporter_import_batch_actor_id_campaign_user_id_fk", + "tableFrom": "supporter_import_batch", + "tableTo": "campaign_user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.municipality_update": { + "name": "municipality_update", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "polarity": { + "name": "polarity", + "type": "enum_municipality_update_polarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'neutra'" + }, + "urgent": { + "name": "urgent", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "adversary_signal": { + "name": "adversary_signal", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "body": { + "name": "body", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "active_volunteers": { + "name": "active_volunteers", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "new_supports": { + "name": "new_supports", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "municipality_update_municipality_idx": { + "name": "municipality_update_municipality_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_update_author_idx": { + "name": "municipality_update_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_update_polarity_idx": { + "name": "municipality_update_polarity_idx", + "columns": [ + { + "expression": "polarity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_update_urgent_idx": { + "name": "municipality_update_urgent_idx", + "columns": [ + { + "expression": "urgent", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_update_adversary_signal_idx": { + "name": "municipality_update_adversary_signal_idx", + "columns": [ + { + "expression": "adversary_signal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_update_updated_at_idx": { + "name": "municipality_update_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "municipality_update_created_at_idx": { + "name": "municipality_update_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "municipality_update_municipality_id_municipality_id_fk": { + "name": "municipality_update_municipality_id_municipality_id_fk", + "tableFrom": "municipality_update", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "municipality_update_author_id_campaign_user_id_fk": { + "name": "municipality_update_author_id_campaign_user_id_fk", + "tableFrom": "municipality_update", + "tableTo": "campaign_user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity_tasks": { + "name": "activity_tasks", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "responsible_id": { + "name": "responsible_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "due": { + "name": "due", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "done": { + "name": "done", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "done_at": { + "name": "done_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "activity_tasks_order_idx": { + "name": "activity_tasks_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_tasks_parent_id_idx": { + "name": "activity_tasks_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_tasks_responsible_idx": { + "name": "activity_tasks_responsible_idx", + "columns": [ + { + "expression": "responsible_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_tasks_responsible_id_contact_id_fk": { + "name": "activity_tasks_responsible_id_contact_id_fk", + "tableFrom": "activity_tasks", + "tableTo": "contact", + "columnsFrom": [ + "responsible_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "activity_tasks_parent_id_fk": { + "name": "activity_tasks_parent_id_fk", + "tableFrom": "activity_tasks", + "tableTo": "activity", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity_updates": { + "name": "activity_updates", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "body": { + "name": "body", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "activity_updates_order_idx": { + "name": "activity_updates_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_updates_parent_id_idx": { + "name": "activity_updates_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_updates_author_idx": { + "name": "activity_updates_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_updates_author_id_campaign_user_id_fk": { + "name": "activity_updates_author_id_campaign_user_id_fk", + "tableFrom": "activity_updates", + "tableTo": "campaign_user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "activity_updates_parent_id_fk": { + "name": "activity_updates_parent_id_fk", + "tableFrom": "activity_updates", + "tableTo": "activity", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity": { + "name": "activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "enum_activity_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'confirmado'" + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "deputy_present": { + "name": "deputy_present", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "all_day": { + "name": "all_day", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "start_at": { + "name": "start_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "end_at": { + "name": "end_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locality": { + "name": "locality", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "task_total": { + "name": "task_total", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "task_done_count": { + "name": "task_done_count", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "result_summary": { + "name": "result_summary", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "result_recorded_by_id": { + "name": "result_recorded_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "result_recorded_at": { + "name": "result_recorded_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "activity_title_idx": { + "name": "activity_title_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_slug_idx": { + "name": "activity_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_status_idx": { + "name": "activity_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_deputy_present_idx": { + "name": "activity_deputy_present_idx", + "columns": [ + { + "expression": "deputy_present", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_all_day_idx": { + "name": "activity_all_day_idx", + "columns": [ + { + "expression": "all_day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_start_at_idx": { + "name": "activity_start_at_idx", + "columns": [ + { + "expression": "start_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_municipality_idx": { + "name": "activity_municipality_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_result_recorded_by_idx": { + "name": "activity_result_recorded_by_idx", + "columns": [ + { + "expression": "result_recorded_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_created_by_idx": { + "name": "activity_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_updated_at_idx": { + "name": "activity_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_created_at_idx": { + "name": "activity_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_municipality_id_municipality_id_fk": { + "name": "activity_municipality_id_municipality_id_fk", + "tableFrom": "activity", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "activity_result_recorded_by_id_campaign_user_id_fk": { + "name": "activity_result_recorded_by_id_campaign_user_id_fk", + "tableFrom": "activity", + "tableTo": "campaign_user", + "columnsFrom": [ + "result_recorded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "activity_created_by_id_campaign_user_id_fk": { + "name": "activity_created_by_id_campaign_user_id_fk", + "tableFrom": "activity", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity_texts": { + "name": "activity_texts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "activity_texts_order_parent": { + "name": "activity_texts_order_parent", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_texts_text_idx": { + "name": "activity_texts_text_idx", + "columns": [ + { + "expression": "text", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_texts_parent_fk": { + "name": "activity_texts_parent_fk", + "tableFrom": "activity_texts", + "tableTo": "activity", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity_rels": { + "name": "activity_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_user_id": { + "name": "campaign_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "leadership_id": { + "name": "leadership_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "state_deputy_id": { + "name": "state_deputy_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "media_id": { + "name": "media_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "activity_rels_order_idx": { + "name": "activity_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_rels_parent_idx": { + "name": "activity_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_rels_path_idx": { + "name": "activity_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_rels_organization_id_idx": { + "name": "activity_rels_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_rels_campaign_user_id_idx": { + "name": "activity_rels_campaign_user_id_idx", + "columns": [ + { + "expression": "campaign_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_rels_leadership_id_idx": { + "name": "activity_rels_leadership_id_idx", + "columns": [ + { + "expression": "leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_rels_state_deputy_id_idx": { + "name": "activity_rels_state_deputy_id_idx", + "columns": [ + { + "expression": "state_deputy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_rels_media_id_idx": { + "name": "activity_rels_media_id_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_rels_parent_fk": { + "name": "activity_rels_parent_fk", + "tableFrom": "activity_rels", + "tableTo": "activity", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_rels_organization_fk": { + "name": "activity_rels_organization_fk", + "tableFrom": "activity_rels", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_rels_campaign_user_fk": { + "name": "activity_rels_campaign_user_fk", + "tableFrom": "activity_rels", + "tableTo": "campaign_user", + "columnsFrom": [ + "campaign_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_rels_leadership_fk": { + "name": "activity_rels_leadership_fk", + "tableFrom": "activity_rels", + "tableTo": "leadership", + "columnsFrom": [ + "leadership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_rels_state_deputy_fk": { + "name": "activity_rels_state_deputy_fk", + "tableFrom": "activity_rels", + "tableTo": "state_deputy", + "columnsFrom": [ + "state_deputy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_rels_media_fk": { + "name": "activity_rels_media_fk", + "tableFrom": "activity_rels", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.calendar_feed": { + "name": "calendar_feed", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "secret_slug": { + "name": "secret_slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "filter_municipality_id": { + "name": "filter_municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "filter_deputy_present": { + "name": "filter_deputy_present", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "filter_tag": { + "name": "filter_tag", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "calendar_feed_secret_slug_idx": { + "name": "calendar_feed_secret_slug_idx", + "columns": [ + { + "expression": "secret_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_feed_filter_municipality_idx": { + "name": "calendar_feed_filter_municipality_idx", + "columns": [ + { + "expression": "filter_municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_feed_revoked_at_idx": { + "name": "calendar_feed_revoked_at_idx", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_feed_created_by_idx": { + "name": "calendar_feed_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_feed_updated_at_idx": { + "name": "calendar_feed_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "calendar_feed_created_at_idx": { + "name": "calendar_feed_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendar_feed_filter_municipality_id_municipality_id_fk": { + "name": "calendar_feed_filter_municipality_id_municipality_id_fk", + "tableFrom": "calendar_feed", + "tableTo": "municipality", + "columnsFrom": [ + "filter_municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "calendar_feed_created_by_id_campaign_user_id_fk": { + "name": "calendar_feed_created_by_id_campaign_user_id_fk", + "tableFrom": "calendar_feed", + "tableTo": "campaign_user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.election_tally": { + "name": "election_tally", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "year": { + "name": "year", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "office": { + "name": "office", + "type": "enum_election_tally_office", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "enum_election_tally_turn", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'BA'" + }, + "city_code": { + "name": "city_code", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "city_name": { + "name": "city_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "zone_number": { + "name": "zone_number", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "aptos": { + "name": "aptos", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "comparecimento": { + "name": "comparecimento", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "abstencoes": { + "name": "abstencoes", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "votos_validos": { + "name": "votos_validos", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "votos_nominais_validos": { + "name": "votos_nominais_validos", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "votos_legenda": { + "name": "votos_legenda", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "votos_branco": { + "name": "votos_branco", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "votos_nulo": { + "name": "votos_nulo", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "votos_anulados": { + "name": "votos_anulados", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "winner_candidate_number": { + "name": "winner_candidate_number", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "winner_candidate_name": { + "name": "winner_candidate_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "winner_votes": { + "name": "winner_votes", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "winner_coalition": { + "name": "winner_coalition", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "winner_party": { + "name": "winner_party", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "election_tally_year_idx": { + "name": "election_tally_year_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_office_idx": { + "name": "election_tally_office_idx", + "columns": [ + { + "expression": "office", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_turn_idx": { + "name": "election_tally_turn_idx", + "columns": [ + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_state_idx": { + "name": "election_tally_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_city_code_idx": { + "name": "election_tally_city_code_idx", + "columns": [ + { + "expression": "city_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_city_name_idx": { + "name": "election_tally_city_name_idx", + "columns": [ + { + "expression": "city_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_zone_number_idx": { + "name": "election_tally_zone_number_idx", + "columns": [ + { + "expression": "zone_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_updated_at_idx": { + "name": "election_tally_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_tally_created_at_idx": { + "name": "election_tally_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "year_office_turn_state_cityCode_zoneNumber_idx": { + "name": "year_office_turn_state_cityCode_zoneNumber_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "office", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "city_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.election_candidate_vote": { + "name": "election_candidate_vote", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "year": { + "name": "year", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "office": { + "name": "office", + "type": "enum_election_candidate_vote_office", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "enum_election_candidate_vote_turn", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'BA'" + }, + "city_code": { + "name": "city_code", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "city_name": { + "name": "city_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "zone_number": { + "name": "zone_number", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "candidate_number": { + "name": "candidate_number", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "candidate_name": { + "name": "candidate_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "coalition": { + "name": "coalition", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "party": { + "name": "party", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "vote_type": { + "name": "vote_type", + "type": "enum_election_candidate_vote_vote_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'nominal'" + }, + "votes": { + "name": "votes", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "election_candidate_vote_year_idx": { + "name": "election_candidate_vote_year_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_office_idx": { + "name": "election_candidate_vote_office_idx", + "columns": [ + { + "expression": "office", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_turn_idx": { + "name": "election_candidate_vote_turn_idx", + "columns": [ + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_state_idx": { + "name": "election_candidate_vote_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_city_code_idx": { + "name": "election_candidate_vote_city_code_idx", + "columns": [ + { + "expression": "city_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_city_name_idx": { + "name": "election_candidate_vote_city_name_idx", + "columns": [ + { + "expression": "city_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_zone_number_idx": { + "name": "election_candidate_vote_zone_number_idx", + "columns": [ + { + "expression": "zone_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_candidate_number_idx": { + "name": "election_candidate_vote_candidate_number_idx", + "columns": [ + { + "expression": "candidate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_vote_type_idx": { + "name": "election_candidate_vote_vote_type_idx", + "columns": [ + { + "expression": "vote_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_updated_at_idx": { + "name": "election_candidate_vote_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_vote_created_at_idx": { + "name": "election_candidate_vote_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compound_index_idx": { + "name": "compound_index_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "office", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "city_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "candidate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vote_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.election_candidate": { + "name": "election_candidate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "year": { + "name": "year", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "office": { + "name": "office", + "type": "enum_election_candidate_office", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "enum_election_candidate_turn", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'BA'" + }, + "candidate_number": { + "name": "candidate_number", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "urna_name": { + "name": "urna_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "complete_name": { + "name": "complete_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "party": { + "name": "party", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "coalition": { + "name": "coalition", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "candidate_status": { + "name": "candidate_status", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "elected": { + "name": "elected", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "elected_by": { + "name": "elected_by", + "type": "enum_election_candidate_elected_by", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "total_votes_state": { + "name": "total_votes_state", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "identity_key": { + "name": "identity_key", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "running_again2026": { + "name": "running_again2026", + "type": "enum_election_candidate_running_again2026", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'desconhecido'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "election_candidate_year_idx": { + "name": "election_candidate_year_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_office_idx": { + "name": "election_candidate_office_idx", + "columns": [ + { + "expression": "office", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_turn_idx": { + "name": "election_candidate_turn_idx", + "columns": [ + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_state_idx": { + "name": "election_candidate_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_candidate_number_idx": { + "name": "election_candidate_candidate_number_idx", + "columns": [ + { + "expression": "candidate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_elected_idx": { + "name": "election_candidate_elected_idx", + "columns": [ + { + "expression": "elected", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_identity_key_idx": { + "name": "election_candidate_identity_key_idx", + "columns": [ + { + "expression": "identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_updated_at_idx": { + "name": "election_candidate_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "election_candidate_created_at_idx": { + "name": "election_candidate_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "year_office_turn_state_candidateNumber_idx": { + "name": "year_office_turn_state_candidateNumber_idx", + "columns": [ + { + "expression": "year", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "office", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "candidate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "alt": { + "name": "alt", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_u_r_l": { + "name": "thumbnail_u_r_l", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filesize": { + "name": "filesize", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_x": { + "name": "focal_x", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_y": { + "name": "focal_y", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "media_updated_at_idx": { + "name": "media_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_created_at_idx": { + "name": "media_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_filename_idx": { + "name": "media_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.petition": { + "name": "petition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "subtitle": { + "name": "subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "body": { + "name": "body", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "form_title": { + "name": "form_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "form_subtitle": { + "name": "form_subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "form_consent_id": { + "name": "form_consent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tracking_facebook_pixel_id": { + "name": "tracking_facebook_pixel_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "_status": { + "name": "_status", + "type": "enum_petition_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + } + }, + "indexes": { + "petition_form_form_consent_idx": { + "name": "petition_form_form_consent_idx", + "columns": [ + { + "expression": "form_consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "petition_updated_at_idx": { + "name": "petition_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "petition_created_at_idx": { + "name": "petition_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "petition__status_idx": { + "name": "petition__status_idx", + "columns": [ + { + "expression": "_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "petition_form_consent_id_consent_id_fk": { + "name": "petition_form_consent_id_consent_id_fk", + "tableFrom": "petition", + "tableTo": "consent", + "columnsFrom": [ + "form_consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._petition_v": { + "name": "_petition_v", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_title": { + "name": "version_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_subtitle": { + "name": "version_subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_enabled": { + "name": "version_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "version_body": { + "name": "version_body", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "version_form_title": { + "name": "version_form_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_form_subtitle": { + "name": "version_form_subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_form_consent_id": { + "name": "version_form_consent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_tracking_facebook_pixel_id": { + "name": "version_tracking_facebook_pixel_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_updated_at": { + "name": "version_updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version__status": { + "name": "version__status", + "type": "enum__petition_v_version_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latest": { + "name": "latest", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "_petition_v_parent_idx": { + "name": "_petition_v_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_version_form_version_form_consent_idx": { + "name": "_petition_v_version_form_version_form_consent_idx", + "columns": [ + { + "expression": "version_form_consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_version_version_updated_at_idx": { + "name": "_petition_v_version_version_updated_at_idx", + "columns": [ + { + "expression": "version_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_version_version_created_at_idx": { + "name": "_petition_v_version_version_created_at_idx", + "columns": [ + { + "expression": "version_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_version_version__status_idx": { + "name": "_petition_v_version_version__status_idx", + "columns": [ + { + "expression": "version__status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_created_at_idx": { + "name": "_petition_v_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_updated_at_idx": { + "name": "_petition_v_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_petition_v_latest_idx": { + "name": "_petition_v_latest_idx", + "columns": [ + { + "expression": "latest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "_petition_v_parent_id_petition_id_fk": { + "name": "_petition_v_parent_id_petition_id_fk", + "tableFrom": "_petition_v", + "tableTo": "petition", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "_petition_v_version_form_consent_id_consent_id_fk": { + "name": "_petition_v_version_form_consent_id_consent_id_fk", + "tableFrom": "_petition_v", + "tableTo": "consent", + "columnsFrom": [ + "version_form_consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_phones": { + "name": "contact_phones", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "contact_phones_order_idx": { + "name": "contact_phones_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_phones_parent_id_idx": { + "name": "contact_phones_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_phones_value_idx": { + "name": "contact_phones_value_idx", + "columns": [ + { + "expression": "value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contact_phones_parent_id_fk": { + "name": "contact_phones_parent_id_fk", + "tableFrom": "contact_phones", + "tableTo": "contact", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact": { + "name": "contact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "gender": { + "name": "gender", + "type": "enum_contact_gender", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "enum_contact_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contact_updated_at_idx": { + "name": "contact_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contact_created_at_idx": { + "name": "contact_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.consent": { + "name": "consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "consent_key_idx": { + "name": "consent_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "consent_updated_at_idx": { + "name": "consent_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "consent_created_at_idx": { + "name": "consent_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signature": { + "name": "signature", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "petition_id": { + "name": "petition_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "consent_id": { + "name": "consent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "comment": { + "name": "comment", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "signature_contact_idx": { + "name": "signature_contact_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signature_petition_idx": { + "name": "signature_petition_idx", + "columns": [ + { + "expression": "petition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signature_consent_idx": { + "name": "signature_consent_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signature_updated_at_idx": { + "name": "signature_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "signature_created_at_idx": { + "name": "signature_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "signature_contact_id_contact_id_fk": { + "name": "signature_contact_id_contact_id_fk", + "tableFrom": "signature", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "signature_petition_id_petition_id_fk": { + "name": "signature_petition_id_petition_id_fk", + "tableFrom": "signature", + "tableTo": "petition", + "columnsFrom": [ + "petition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "signature_consent_id_consent_id_fk": { + "name": "signature_consent_id_consent_id_fk", + "tableFrom": "signature", + "tableTo": "consent", + "columnsFrom": [ + "consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "consent_id": { + "name": "consent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "comment": { + "name": "comment", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscription_contact_idx": { + "name": "subscription_contact_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_consent_idx": { + "name": "subscription_consent_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_updated_at_idx": { + "name": "subscription_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_created_at_idx": { + "name": "subscription_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscription_contact_id_contact_id_fk": { + "name": "subscription_contact_id_contact_id_fk", + "tableFrom": "subscription", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "subscription_consent_id_consent_id_fk": { + "name": "subscription_consent_id_consent_id_fk", + "tableFrom": "subscription", + "tableTo": "consent", + "columnsFrom": [ + "consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post": { + "name": "post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "enum_post_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "subtitle": { + "name": "subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "cover_image_id": { + "name": "cover_image_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "published_date": { + "name": "published_date", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "_status": { + "name": "_status", + "type": "enum_post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + } + }, + "indexes": { + "post_slug_idx": { + "name": "post_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_category_idx": { + "name": "post_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_cover_image_idx": { + "name": "post_cover_image_idx", + "columns": [ + { + "expression": "cover_image_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_updated_at_idx": { + "name": "post_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_created_at_idx": { + "name": "post_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post__status_idx": { + "name": "post__status_idx", + "columns": [ + { + "expression": "_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_category_id_tag_id_fk": { + "name": "post_category_id_tag_id_fk", + "tableFrom": "post", + "tableTo": "tag", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "post_cover_image_id_media_id_fk": { + "name": "post_cover_image_id_media_id_fk", + "tableFrom": "post", + "tableTo": "media", + "columnsFrom": [ + "cover_image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_rels": { + "name": "post_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "post_rels_order_idx": { + "name": "post_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_rels_parent_idx": { + "name": "post_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_rels_path_idx": { + "name": "post_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_rels_tag_id_idx": { + "name": "post_rels_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_rels_parent_fk": { + "name": "post_rels_parent_fk", + "tableFrom": "post_rels", + "tableTo": "post", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_rels_tag_fk": { + "name": "post_rels_tag_fk", + "tableFrom": "post_rels", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._post_v": { + "name": "_post_v", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_title": { + "name": "version_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_slug": { + "name": "version_slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_type": { + "name": "version_type", + "type": "enum__post_v_version_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "version_category_id": { + "name": "version_category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_subtitle": { + "name": "version_subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version_cover_image_id": { + "name": "version_cover_image_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "version_published_date": { + "name": "version_published_date", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_body": { + "name": "version_body", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "version_updated_at": { + "name": "version_updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version__status": { + "name": "version__status", + "type": "enum__post_v_version_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latest": { + "name": "latest", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "_post_v_parent_idx": { + "name": "_post_v_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_version_version_slug_idx": { + "name": "_post_v_version_version_slug_idx", + "columns": [ + { + "expression": "version_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_version_version_category_idx": { + "name": "_post_v_version_version_category_idx", + "columns": [ + { + "expression": "version_category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_version_version_cover_image_idx": { + "name": "_post_v_version_version_cover_image_idx", + "columns": [ + { + "expression": "version_cover_image_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_version_version_updated_at_idx": { + "name": "_post_v_version_version_updated_at_idx", + "columns": [ + { + "expression": "version_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_version_version_created_at_idx": { + "name": "_post_v_version_version_created_at_idx", + "columns": [ + { + "expression": "version_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_version_version__status_idx": { + "name": "_post_v_version_version__status_idx", + "columns": [ + { + "expression": "version__status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_created_at_idx": { + "name": "_post_v_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_updated_at_idx": { + "name": "_post_v_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_latest_idx": { + "name": "_post_v_latest_idx", + "columns": [ + { + "expression": "latest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "_post_v_parent_id_post_id_fk": { + "name": "_post_v_parent_id_post_id_fk", + "tableFrom": "_post_v", + "tableTo": "post", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "_post_v_version_category_id_tag_id_fk": { + "name": "_post_v_version_category_id_tag_id_fk", + "tableFrom": "_post_v", + "tableTo": "tag", + "columnsFrom": [ + "version_category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "_post_v_version_cover_image_id_media_id_fk": { + "name": "_post_v_version_cover_image_id_media_id_fk", + "tableFrom": "_post_v", + "tableTo": "media", + "columnsFrom": [ + "version_cover_image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._post_v_rels": { + "name": "_post_v_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "_post_v_rels_order_idx": { + "name": "_post_v_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_rels_parent_idx": { + "name": "_post_v_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_rels_path_idx": { + "name": "_post_v_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_post_v_rels_tag_id_idx": { + "name": "_post_v_rels_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "_post_v_rels_parent_fk": { + "name": "_post_v_rels_parent_fk", + "tableFrom": "_post_v_rels", + "tableTo": "_post_v", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "_post_v_rels_tag_fk": { + "name": "_post_v_rels_tag_fk", + "tableFrom": "_post_v_rels", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag": { + "name": "tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "hidden": { + "name": "hidden", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tag_slug_idx": { + "name": "tag_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tag_updated_at_idx": { + "name": "tag_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tag_created_at_idx": { + "name": "tag_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exports": { + "name": "exports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "enum_exports_format", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'csv'" + }, + "limit": { + "name": "limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "page": { + "name": "page", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "sort": { + "name": "sort", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "enum_exports_sort_order", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "drafts": { + "name": "drafts", + "type": "enum_exports_drafts", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'yes'" + }, + "collection_slug": { + "name": "collection_slug", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'signature'" + }, + "where": { + "name": "where", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_u_r_l": { + "name": "thumbnail_u_r_l", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filesize": { + "name": "filesize", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_x": { + "name": "focal_x", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_y": { + "name": "focal_y", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "exports_updated_at_idx": { + "name": "exports_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "exports_created_at_idx": { + "name": "exports_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "exports_filename_idx": { + "name": "exports_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exports_texts": { + "name": "exports_texts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "exports_texts_order_parent": { + "name": "exports_texts_order_parent", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "exports_texts_parent_fk": { + "name": "exports_texts_parent_fk", + "tableFrom": "exports_texts", + "tableTo": "exports", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.imports": { + "name": "imports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "collection_slug": { + "name": "collection_slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "import_mode": { + "name": "import_mode", + "type": "enum_imports_import_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "match_field": { + "name": "match_field", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'id'" + }, + "status": { + "name": "status", + "type": "enum_imports_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "summary_imported": { + "name": "summary_imported", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "summary_updated": { + "name": "summary_updated", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "summary_total": { + "name": "summary_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "summary_issues": { + "name": "summary_issues", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "summary_issue_details": { + "name": "summary_issue_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_u_r_l": { + "name": "thumbnail_u_r_l", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filesize": { + "name": "filesize", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_x": { + "name": "focal_x", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_y": { + "name": "focal_y", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "imports_updated_at_idx": { + "name": "imports_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "imports_created_at_idx": { + "name": "imports_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "imports_filename_idx": { + "name": "imports_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_kv": { + "name": "payload_kv", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "payload_kv_key_idx": { + "name": "payload_kv_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_jobs_log": { + "name": "payload_jobs_log", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + }, + "task_slug": { + "name": "task_slug", + "type": "enum_payload_jobs_log_task_slug", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "task_i_d": { + "name": "task_i_d", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "enum_payload_jobs_log_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payload_jobs_log_order_idx": { + "name": "payload_jobs_log_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_log_parent_id_idx": { + "name": "payload_jobs_log_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payload_jobs_log_parent_id_fk": { + "name": "payload_jobs_log_parent_id_fk", + "tableFrom": "payload_jobs_log", + "tableTo": "payload_jobs", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_jobs": { + "name": "payload_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "total_tried": { + "name": "total_tried", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "has_error": { + "name": "has_error", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "task_slug": { + "name": "task_slug", + "type": "enum_payload_jobs_task_slug", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "queue": { + "name": "queue", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "wait_until": { + "name": "wait_until", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "processing": { + "name": "processing", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_jobs_completed_at_idx": { + "name": "payload_jobs_completed_at_idx", + "columns": [ + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_total_tried_idx": { + "name": "payload_jobs_total_tried_idx", + "columns": [ + { + "expression": "total_tried", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_has_error_idx": { + "name": "payload_jobs_has_error_idx", + "columns": [ + { + "expression": "has_error", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_task_slug_idx": { + "name": "payload_jobs_task_slug_idx", + "columns": [ + { + "expression": "task_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_queue_idx": { + "name": "payload_jobs_queue_idx", + "columns": [ + { + "expression": "queue", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_wait_until_idx": { + "name": "payload_jobs_wait_until_idx", + "columns": [ + { + "expression": "wait_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_processing_idx": { + "name": "payload_jobs_processing_idx", + "columns": [ + { + "expression": "processing", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_updated_at_idx": { + "name": "payload_jobs_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_jobs_created_at_idx": { + "name": "payload_jobs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_locked_documents": { + "name": "payload_locked_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "global_slug": { + "name": "global_slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_locked_documents_global_slug_idx": { + "name": "payload_locked_documents_global_slug_idx", + "columns": [ + { + "expression": "global_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_updated_at_idx": { + "name": "payload_locked_documents_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_created_at_idx": { + "name": "payload_locked_documents_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_locked_documents_rels": { + "name": "payload_locked_documents_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "users_id": { + "name": "users_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_user_id": { + "name": "campaign_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_web_authn_credential_id": { + "name": "campaign_web_authn_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "push_subscription_id": { + "name": "push_subscription_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_invite_id": { + "name": "campaign_invite_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_vote_summary_snapshot_id": { + "name": "campaign_vote_summary_snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "municipality_id": { + "name": "municipality_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "leadership_id": { + "name": "leadership_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "state_deputy_id": { + "name": "state_deputy_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vote_pledge_id": { + "name": "vote_pledge_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "allocation_decision_id": { + "name": "allocation_decision_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_demand_id": { + "name": "campaign_demand_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "supporter_id": { + "name": "supporter_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "supporter_import_batch_id": { + "name": "supporter_import_batch_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "municipality_update_id": { + "name": "municipality_update_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "activity_id": { + "name": "activity_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "calendar_feed_id": { + "name": "calendar_feed_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "election_tally_id": { + "name": "election_tally_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "election_candidate_vote_id": { + "name": "election_candidate_vote_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "election_candidate_id": { + "name": "election_candidate_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "media_id": { + "name": "media_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "petition_id": { + "name": "petition_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "consent_id": { + "name": "consent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signature_id": { + "name": "signature_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payload_locked_documents_rels_order_idx": { + "name": "payload_locked_documents_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_parent_idx": { + "name": "payload_locked_documents_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_path_idx": { + "name": "payload_locked_documents_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_users_id_idx": { + "name": "payload_locked_documents_rels_users_id_idx", + "columns": [ + { + "expression": "users_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_campaign_user_id_idx": { + "name": "payload_locked_documents_rels_campaign_user_id_idx", + "columns": [ + { + "expression": "campaign_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_campaign_web_authn_credent_idx": { + "name": "payload_locked_documents_rels_campaign_web_authn_credent_idx", + "columns": [ + { + "expression": "campaign_web_authn_credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_notification_id_idx": { + "name": "payload_locked_documents_rels_notification_id_idx", + "columns": [ + { + "expression": "notification_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_push_subscription_id_idx": { + "name": "payload_locked_documents_rels_push_subscription_id_idx", + "columns": [ + { + "expression": "push_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_campaign_invite_id_idx": { + "name": "payload_locked_documents_rels_campaign_invite_id_idx", + "columns": [ + { + "expression": "campaign_invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_campaign_vote_summary_snap_idx": { + "name": "payload_locked_documents_rels_campaign_vote_summary_snap_idx", + "columns": [ + { + "expression": "campaign_vote_summary_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_municipality_id_idx": { + "name": "payload_locked_documents_rels_municipality_id_idx", + "columns": [ + { + "expression": "municipality_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_leadership_id_idx": { + "name": "payload_locked_documents_rels_leadership_id_idx", + "columns": [ + { + "expression": "leadership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_organization_id_idx": { + "name": "payload_locked_documents_rels_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_state_deputy_id_idx": { + "name": "payload_locked_documents_rels_state_deputy_id_idx", + "columns": [ + { + "expression": "state_deputy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_vote_pledge_id_idx": { + "name": "payload_locked_documents_rels_vote_pledge_id_idx", + "columns": [ + { + "expression": "vote_pledge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_allocation_decision_id_idx": { + "name": "payload_locked_documents_rels_allocation_decision_id_idx", + "columns": [ + { + "expression": "allocation_decision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_campaign_demand_id_idx": { + "name": "payload_locked_documents_rels_campaign_demand_id_idx", + "columns": [ + { + "expression": "campaign_demand_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_supporter_id_idx": { + "name": "payload_locked_documents_rels_supporter_id_idx", + "columns": [ + { + "expression": "supporter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_supporter_import_batch_id_idx": { + "name": "payload_locked_documents_rels_supporter_import_batch_id_idx", + "columns": [ + { + "expression": "supporter_import_batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_municipality_update_id_idx": { + "name": "payload_locked_documents_rels_municipality_update_id_idx", + "columns": [ + { + "expression": "municipality_update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_activity_id_idx": { + "name": "payload_locked_documents_rels_activity_id_idx", + "columns": [ + { + "expression": "activity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_calendar_feed_id_idx": { + "name": "payload_locked_documents_rels_calendar_feed_id_idx", + "columns": [ + { + "expression": "calendar_feed_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_election_tally_id_idx": { + "name": "payload_locked_documents_rels_election_tally_id_idx", + "columns": [ + { + "expression": "election_tally_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_election_candidate_vote_id_idx": { + "name": "payload_locked_documents_rels_election_candidate_vote_id_idx", + "columns": [ + { + "expression": "election_candidate_vote_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_election_candidate_id_idx": { + "name": "payload_locked_documents_rels_election_candidate_id_idx", + "columns": [ + { + "expression": "election_candidate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_media_id_idx": { + "name": "payload_locked_documents_rels_media_id_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_petition_id_idx": { + "name": "payload_locked_documents_rels_petition_id_idx", + "columns": [ + { + "expression": "petition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_contact_id_idx": { + "name": "payload_locked_documents_rels_contact_id_idx", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_consent_id_idx": { + "name": "payload_locked_documents_rels_consent_id_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_signature_id_idx": { + "name": "payload_locked_documents_rels_signature_id_idx", + "columns": [ + { + "expression": "signature_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_subscription_id_idx": { + "name": "payload_locked_documents_rels_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_post_id_idx": { + "name": "payload_locked_documents_rels_post_id_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_tag_id_idx": { + "name": "payload_locked_documents_rels_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payload_locked_documents_rels_parent_fk": { + "name": "payload_locked_documents_rels_parent_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "payload_locked_documents", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_users_fk": { + "name": "payload_locked_documents_rels_users_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "users", + "columnsFrom": [ + "users_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_campaign_user_fk": { + "name": "payload_locked_documents_rels_campaign_user_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "campaign_user", + "columnsFrom": [ + "campaign_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_campaign_web_authn_credenti_fk": { + "name": "payload_locked_documents_rels_campaign_web_authn_credenti_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "campaign_web_authn_credential", + "columnsFrom": [ + "campaign_web_authn_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_notification_fk": { + "name": "payload_locked_documents_rels_notification_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_push_subscription_fk": { + "name": "payload_locked_documents_rels_push_subscription_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "push_subscription", + "columnsFrom": [ + "push_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_campaign_invite_fk": { + "name": "payload_locked_documents_rels_campaign_invite_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "campaign_invite", + "columnsFrom": [ + "campaign_invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_campaign_vote_summary_snaps_fk": { + "name": "payload_locked_documents_rels_campaign_vote_summary_snaps_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "campaign_vote_summary_snapshot", + "columnsFrom": [ + "campaign_vote_summary_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_municipality_fk": { + "name": "payload_locked_documents_rels_municipality_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "municipality", + "columnsFrom": [ + "municipality_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_leadership_fk": { + "name": "payload_locked_documents_rels_leadership_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "leadership", + "columnsFrom": [ + "leadership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_organization_fk": { + "name": "payload_locked_documents_rels_organization_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_state_deputy_fk": { + "name": "payload_locked_documents_rels_state_deputy_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "state_deputy", + "columnsFrom": [ + "state_deputy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_vote_pledge_fk": { + "name": "payload_locked_documents_rels_vote_pledge_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "vote_pledge", + "columnsFrom": [ + "vote_pledge_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_allocation_decision_fk": { + "name": "payload_locked_documents_rels_allocation_decision_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "allocation_decision", + "columnsFrom": [ + "allocation_decision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_campaign_demand_fk": { + "name": "payload_locked_documents_rels_campaign_demand_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "campaign_demand", + "columnsFrom": [ + "campaign_demand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_supporter_fk": { + "name": "payload_locked_documents_rels_supporter_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "supporter", + "columnsFrom": [ + "supporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_supporter_import_batch_fk": { + "name": "payload_locked_documents_rels_supporter_import_batch_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "supporter_import_batch", + "columnsFrom": [ + "supporter_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_municipality_update_fk": { + "name": "payload_locked_documents_rels_municipality_update_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "municipality_update", + "columnsFrom": [ + "municipality_update_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_activity_fk": { + "name": "payload_locked_documents_rels_activity_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "activity", + "columnsFrom": [ + "activity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_calendar_feed_fk": { + "name": "payload_locked_documents_rels_calendar_feed_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "calendar_feed", + "columnsFrom": [ + "calendar_feed_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_election_tally_fk": { + "name": "payload_locked_documents_rels_election_tally_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "election_tally", + "columnsFrom": [ + "election_tally_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_election_candidate_vote_fk": { + "name": "payload_locked_documents_rels_election_candidate_vote_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "election_candidate_vote", + "columnsFrom": [ + "election_candidate_vote_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_election_candidate_fk": { + "name": "payload_locked_documents_rels_election_candidate_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "election_candidate", + "columnsFrom": [ + "election_candidate_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_media_fk": { + "name": "payload_locked_documents_rels_media_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_petition_fk": { + "name": "payload_locked_documents_rels_petition_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "petition", + "columnsFrom": [ + "petition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_contact_fk": { + "name": "payload_locked_documents_rels_contact_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "contact", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_consent_fk": { + "name": "payload_locked_documents_rels_consent_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "consent", + "columnsFrom": [ + "consent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_signature_fk": { + "name": "payload_locked_documents_rels_signature_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "signature", + "columnsFrom": [ + "signature_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_subscription_fk": { + "name": "payload_locked_documents_rels_subscription_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "subscription", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_post_fk": { + "name": "payload_locked_documents_rels_post_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_tag_fk": { + "name": "payload_locked_documents_rels_tag_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_preferences": { + "name": "payload_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_preferences_key_idx": { + "name": "payload_preferences_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_updated_at_idx": { + "name": "payload_preferences_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_created_at_idx": { + "name": "payload_preferences_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_preferences_rels": { + "name": "payload_preferences_rels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "users_id": { + "name": "users_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_user_id": { + "name": "campaign_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payload_preferences_rels_order_idx": { + "name": "payload_preferences_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_parent_idx": { + "name": "payload_preferences_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_path_idx": { + "name": "payload_preferences_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_users_id_idx": { + "name": "payload_preferences_rels_users_id_idx", + "columns": [ + { + "expression": "users_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_campaign_user_id_idx": { + "name": "payload_preferences_rels_campaign_user_id_idx", + "columns": [ + { + "expression": "campaign_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payload_preferences_rels_parent_fk": { + "name": "payload_preferences_rels_parent_fk", + "tableFrom": "payload_preferences_rels", + "tableTo": "payload_preferences", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_preferences_rels_users_fk": { + "name": "payload_preferences_rels_users_fk", + "tableFrom": "payload_preferences_rels", + "tableTo": "users", + "columnsFrom": [ + "users_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_preferences_rels_campaign_user_fk": { + "name": "payload_preferences_rels_campaign_user_fk", + "tableFrom": "payload_preferences_rels", + "tableTo": "campaign_user", + "columnsFrom": [ + "campaign_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payload_migrations": { + "name": "payload_migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "batch": { + "name": "batch", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_migrations_updated_at_idx": { + "name": "payload_migrations_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_migrations_created_at_idx": { + "name": "payload_migrations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings_social_links": { + "name": "site_settings_social_links", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "enum_site_settings_social_links_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "site_settings_social_links_order_idx": { + "name": "site_settings_social_links_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "site_settings_social_links_parent_id_idx": { + "name": "site_settings_social_links_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "site_settings_social_links_parent_id_fk": { + "name": "site_settings_social_links_parent_id_fk", + "tableFrom": "site_settings_social_links", + "tableTo": "site_settings", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "header_title": { + "name": "header_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "_status": { + "name": "_status", + "type": "enum_site_settings_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "site_settings__status_idx": { + "name": "site_settings__status_idx", + "columns": [ + { + "expression": "_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._site_settings_v_version_social_links": { + "name": "_site_settings_v_version_social_links", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "enum__site_settings_v_version_social_links_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "_uuid": { + "name": "_uuid", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "_site_settings_v_version_social_links_order_idx": { + "name": "_site_settings_v_version_social_links_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_site_settings_v_version_social_links_parent_id_idx": { + "name": "_site_settings_v_version_social_links_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "_site_settings_v_version_social_links_parent_id_fk": { + "name": "_site_settings_v_version_social_links_parent_id_fk", + "tableFrom": "_site_settings_v_version_social_links", + "tableTo": "_site_settings_v", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public._site_settings_v": { + "name": "_site_settings_v", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "version_header_title": { + "name": "version_header_title", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "version__status": { + "name": "version__status", + "type": "enum__site_settings_v_version_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + }, + "version_updated_at": { + "name": "version_updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "version_created_at": { + "name": "version_created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latest": { + "name": "latest", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "autosave": { + "name": "autosave", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "_site_settings_v_version_version__status_idx": { + "name": "_site_settings_v_version_version__status_idx", + "columns": [ + { + "expression": "version__status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_site_settings_v_created_at_idx": { + "name": "_site_settings_v_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_site_settings_v_updated_at_idx": { + "name": "_site_settings_v_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_site_settings_v_latest_idx": { + "name": "_site_settings_v_latest_idx", + "columns": [ + { + "expression": "latest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "_site_settings_v_autosave_idx": { + "name": "_site_settings_v_autosave_idx", + "columns": [ + { + "expression": "autosave", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.home": { + "name": "home", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "image_id": { + "name": "image_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "home_image_idx": { + "name": "home_image_idx", + "columns": [ + { + "expression": "image_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "home_image_id_media_id_fk": { + "name": "home_image_id_media_id_fk", + "tableFrom": "home", + "tableTo": "media", + "columnsFrom": [ + "image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.metadata_keywords": { + "name": "metadata_keywords", + "schema": "", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "metadata_keywords_order_idx": { + "name": "metadata_keywords_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "metadata_keywords_parent_id_idx": { + "name": "metadata_keywords_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "metadata_keywords_parent_id_fk": { + "name": "metadata_keywords_parent_id_fk", + "tableFrom": "metadata_keywords", + "tableTo": "metadata", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.metadata": { + "name": "metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "open_graph_site_name": { + "name": "open_graph_site_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "open_graph_description": { + "name": "open_graph_description", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "twitter_creator": { + "name": "twitter_creator", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "twitter_description": { + "name": "twitter_description", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "image_id": { + "name": "image_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "metadata_image_idx": { + "name": "metadata_image_idx", + "columns": [ + { + "expression": "image_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "metadata_image_id_media_id_fk": { + "name": "metadata_image_id_media_id_fk", + "tableFrom": "metadata", + "tableTo": "media", + "columnsFrom": [ + "image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.privacy_policy": { + "name": "privacy_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "body": { + "name": "body", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign_goals": { + "name": "campaign_goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "state_goal": { + "name": "state_goal", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 150000 + }, + "margin": { + "name": "margin", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "base_year": { + "name": "base_year", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.enum_users_roles": { + "name": "enum_users_roles", + "schema": "public", + "values": [ + "admin", + "editor" + ] + }, + "public.enum_campaign_user_role": { + "name": "enum_campaign_user_role", + "schema": "public", + "values": [ + "coordinator", + "advisor", + "candidate", + "leader" + ] + }, + "public.enum_notification_type": { + "name": "enum_notification_type", + "schema": "public", + "values": [ + "municipality_update", + "new_supporter", + "activity_attention", + "invite_accepted" + ] + }, + "public.enum_campaign_invite_kind": { + "name": "enum_campaign_invite_kind", + "schema": "public", + "values": [ + "login", + "autopreenchimento" + ] + }, + "public.enum_municipality_kind": { + "name": "enum_municipality_kind", + "schema": "public", + "values": [ + "municipio", + "zona" + ] + }, + "public.enum_municipality_priority": { + "name": "enum_municipality_priority", + "schema": "public", + "values": [ + "alta", + "normal" + ] + }, + "public.enum_municipality_engagement_level": { + "name": "enum_municipality_engagement_level", + "schema": "public", + "values": [ + "n0", + "n1", + "n2", + "n3", + "n4" + ] + }, + "public.enum_municipality_political_trend_status": { + "name": "enum_municipality_political_trend_status", + "schema": "public", + "values": [ + "favoravel", + "neutra", + "desfavoravel" + ] + }, + "public.enum_leadership_support_status": { + "name": "enum_leadership_support_status", + "schema": "public", + "values": [ + "engajado", + "a_abordar", + "em_disputa", + "lembranca", + "negativo" + ] + }, + "public.enum_organization_kind": { + "name": "enum_organization_kind", + "schema": "public", + "values": [ + "sindicato", + "associacao", + "religioso", + "movimento", + "categoria_profissional", + "outro" + ] + }, + "public.enum_allocation_decision_outcome": { + "name": "enum_allocation_decision_outcome", + "schema": "public", + "values": [ + "aceita", + "descarta", + "movimento", + "adiada" + ] + }, + "public.enum_campaign_demand_status_history_status": { + "name": "enum_campaign_demand_status_history_status", + "schema": "public", + "values": [ + "aberta", + "em_analise", + "escalada", + "aprovada", + "rejeitada" + ] + }, + "public.enum_campaign_demand_kind": { + "name": "enum_campaign_demand_kind", + "schema": "public", + "values": [ + "material", + "servico", + "transporte", + "alimentacao", + "infraestrutura", + "espaco", + "equipamento", + "pessoal_apoio", + "outro" + ] + }, + "public.enum_campaign_demand_status": { + "name": "enum_campaign_demand_status", + "schema": "public", + "values": [ + "aberta", + "em_analise", + "escalada", + "aprovada", + "rejeitada" + ] + }, + "public.enum_supporter_vote_intention": { + "name": "enum_supporter_vote_intention", + "schema": "public", + "values": [ + "certo", + "tende_a_certo", + "indeciso", + "outro" + ] + }, + "public.enum_supporter_source": { + "name": "enum_supporter_source", + "schema": "public", + "values": [ + "import_csv", + "manual", + "lideranca", + "convite", + "evento" + ] + }, + "public.enum_municipality_update_polarity": { + "name": "enum_municipality_update_polarity", + "schema": "public", + "values": [ + "boa", + "neutra", + "ruim" + ] + }, + "public.enum_activity_status": { + "name": "enum_activity_status", + "schema": "public", + "values": [ + "confirmado", + "realizado", + "cancelado" + ] + }, + "public.enum_election_tally_office": { + "name": "enum_election_tally_office", + "schema": "public", + "values": [ + "presidente", + "governador", + "deputado_federal", + "deputado_estadual" + ] + }, + "public.enum_election_tally_turn": { + "name": "enum_election_tally_turn", + "schema": "public", + "values": [ + "1", + "2" + ] + }, + "public.enum_election_candidate_vote_office": { + "name": "enum_election_candidate_vote_office", + "schema": "public", + "values": [ + "presidente", + "governador", + "deputado_federal", + "deputado_estadual" + ] + }, + "public.enum_election_candidate_vote_turn": { + "name": "enum_election_candidate_vote_turn", + "schema": "public", + "values": [ + "1", + "2" + ] + }, + "public.enum_election_candidate_vote_vote_type": { + "name": "enum_election_candidate_vote_vote_type", + "schema": "public", + "values": [ + "nominal", + "legenda" + ] + }, + "public.enum_election_candidate_office": { + "name": "enum_election_candidate_office", + "schema": "public", + "values": [ + "presidente", + "governador", + "deputado_federal", + "deputado_estadual" + ] + }, + "public.enum_election_candidate_turn": { + "name": "enum_election_candidate_turn", + "schema": "public", + "values": [ + "1", + "2" + ] + }, + "public.enum_election_candidate_elected_by": { + "name": "enum_election_candidate_elected_by", + "schema": "public", + "values": [ + "QP", + "média", + "2º turno" + ] + }, + "public.enum_election_candidate_running_again2026": { + "name": "enum_election_candidate_running_again2026", + "schema": "public", + "values": [ + "sim", + "nao", + "desconhecido" + ] + }, + "public.enum_petition_status": { + "name": "enum_petition_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.enum__petition_v_version_status": { + "name": "enum__petition_v_version_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.enum_contact_gender": { + "name": "enum_contact_gender", + "schema": "public", + "values": [ + "feminino", + "masculino", + "outro", + "nao_informado" + ] + }, + "public.enum_contact_state": { + "name": "enum_contact_state", + "schema": "public", + "values": [ + "AC", + "AL", + "AM", + "AP", + "BA", + "CE", + "DF", + "ES", + "GO", + "MA", + "MG", + "MS", + "MT", + "PA", + "PB", + "PE", + "PI", + "PR", + "RJ", + "RN", + "RO", + "RR", + "RS", + "SC", + "SE", + "SP", + "TO" + ] + }, + "public.enum_post_type": { + "name": "enum_post_type", + "schema": "public", + "values": [ + "noticia", + "campanha", + "artigo", + "evento" + ] + }, + "public.enum_post_status": { + "name": "enum_post_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.enum__post_v_version_type": { + "name": "enum__post_v_version_type", + "schema": "public", + "values": [ + "noticia", + "campanha", + "artigo", + "evento" + ] + }, + "public.enum__post_v_version_status": { + "name": "enum__post_v_version_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.enum_exports_format": { + "name": "enum_exports_format", + "schema": "public", + "values": [ + "csv", + "json" + ] + }, + "public.enum_exports_sort_order": { + "name": "enum_exports_sort_order", + "schema": "public", + "values": [ + "asc", + "desc" + ] + }, + "public.enum_exports_drafts": { + "name": "enum_exports_drafts", + "schema": "public", + "values": [ + "yes", + "no" + ] + }, + "public.enum_imports_import_mode": { + "name": "enum_imports_import_mode", + "schema": "public", + "values": [ + "create", + "update", + "upsert" + ] + }, + "public.enum_imports_status": { + "name": "enum_imports_status", + "schema": "public", + "values": [ + "pending", + "completed", + "partial", + "failed" + ] + }, + "public.enum_payload_jobs_log_task_slug": { + "name": "enum_payload_jobs_log_task_slug", + "schema": "public", + "values": [ + "inline", + "createCollectionExport", + "createCollectionImport", + "schedulePublish" + ] + }, + "public.enum_payload_jobs_log_state": { + "name": "enum_payload_jobs_log_state", + "schema": "public", + "values": [ + "failed", + "succeeded" + ] + }, + "public.enum_payload_jobs_task_slug": { + "name": "enum_payload_jobs_task_slug", + "schema": "public", + "values": [ + "inline", + "createCollectionExport", + "createCollectionImport", + "schedulePublish" + ] + }, + "public.enum_site_settings_social_links_platform": { + "name": "enum_site_settings_social_links_platform", + "schema": "public", + "values": [ + "instagram", + "facebook", + "youtube", + "whatsapp" + ] + }, + "public.enum_site_settings_status": { + "name": "enum_site_settings_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.enum__site_settings_v_version_social_links_platform": { + "name": "enum__site_settings_v_version_social_links_platform", + "schema": "public", + "values": [ + "instagram", + "facebook", + "youtube", + "whatsapp" + ] + }, + "public.enum__site_settings_v_version_status": { + "name": "enum__site_settings_v_version_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "id": "17d89cfa-69b8-4d52-af52-dc4c24d017a5", + "prevId": "00000000-0000-0000-0000-000000000000" +} \ No newline at end of file diff --git a/src/migrations/20260811_155116_add_lembranca_support_status.ts b/src/migrations/20260811_155116_add_lembranca_support_status.ts new file mode 100644 index 00000000..20a21843 --- /dev/null +++ b/src/migrations/20260811_155116_add_lembranca_support_status.ts @@ -0,0 +1,16 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +export async function up({ db, payload, req }: MigrateUpArgs): Promise { + await db.execute(sql` + ALTER TYPE "public"."enum_leadership_support_status" ADD VALUE 'lembranca' BEFORE 'negativo';`) +} + +export async function down({ db, payload, req }: MigrateDownArgs): Promise { + await db.execute(sql` + ALTER TABLE "leadership" ALTER COLUMN "support_status" SET DATA TYPE text; + ALTER TABLE "leadership" ALTER COLUMN "support_status" SET DEFAULT 'a_abordar'::text; + DROP TYPE "public"."enum_leadership_support_status"; + CREATE TYPE "public"."enum_leadership_support_status" AS ENUM('engajado', 'a_abordar', 'em_disputa', 'negativo'); + ALTER TABLE "leadership" ALTER COLUMN "support_status" SET DEFAULT 'a_abordar'::"public"."enum_leadership_support_status"; + ALTER TABLE "leadership" ALTER COLUMN "support_status" SET DATA TYPE "public"."enum_leadership_support_status" USING "support_status"::"public"."enum_leadership_support_status";`) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 20ef82c5..9fb3ae11 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -47,7 +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_153004_add_lembranca_support_status from './20260811_153004_add_lembranca_support_status'; +import * as migration_20260811_111828_add_contact_phones_array from './20260811_111828_add_contact_phones_array'; +import * as migration_20260811_155116_add_lembranca_support_status from './20260811_155116_add_lembranca_support_status'; export const migrations = [ { @@ -296,8 +297,13 @@ export const migrations = [ name: '20260810_010844_add_activity_all_day', }, { - up: migration_20260811_153004_add_lembranca_support_status.up, - down: migration_20260811_153004_add_lembranca_support_status.down, - name: '20260811_153004_add_lembranca_support_status' + 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_155116_add_lembranca_support_status.up, + down: migration_20260811_155116_add_lembranca_support_status.down, + name: '20260811_155116_add_lembranca_support_status' }, ]; diff --git a/tests/int/campaignLeadership.int.spec.ts b/tests/int/campaignLeadership.int.spec.ts index 7274493e..77f7b60c 100644 --- a/tests/int/campaignLeadership.int.spec.ts +++ b/tests/int/campaignLeadership.int.spec.ts @@ -434,7 +434,7 @@ describe('campaign leadership domain', () => { const created = await createLeadershipRecord(payload, coordinator, { municipalities: [municipality.id], name: 'Liderança Lembrança', - phone: campaignFixtures().phone(), + phones: [campaignFixtures().phone()], supportStatus: 'lembranca', }) From c91b6a508d7ea639f7170698b663cbadd0d08937 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 13:24:59 -0300 Subject: [PATCH 10/14] test(C119): pina facet de status na lista de pessoas, parse exaustivo da URL de pessoas e variant do badge --- tests/unit/campaignComponents.unit.spec.ts | 24 +++++++++++++--------- tests/unit/peopleListUrl.unit.spec.ts | 8 ++++++++ tests/unit/peopleMerge.unit.spec.ts | 22 ++++++++++++++++++++ 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/tests/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts index 850751f0..a6498992 100644 --- a/tests/unit/campaignComponents.unit.spec.ts +++ b/tests/unit/campaignComponents.unit.spec.ts @@ -185,17 +185,21 @@ describe('campaign visual foundation', () => { }) it.each([ - ['engajado', 'Engajado'], - ['a_abordar', 'A abordar'], - ['em_disputa', 'Em disputa'], - ['lembranca', 'Lembrança'], - ['negativo', 'Negativo'], - ] as const)('renders the %s support status with text and semantic state', (status, label) => { - const html = renderToStaticMarkup(createElement(SupportStatusBadge, { status })) + ['engajado', 'Engajado', 'support-engaged'], + ['a_abordar', 'A abordar', 'support-to-approach'], + ['em_disputa', 'Em disputa', 'support-disputed'], + ['lembranca', 'Lembrança', 'support-remembered'], + ['negativo', 'Negativo', 'support-negative'], + ] as const)( + 'renders the %s support status with text, semantic state and variant', + (status, label, variant) => { + const html = renderToStaticMarkup(createElement(SupportStatusBadge, { status })) - expect(html).toContain(label) - expect(html).toContain(`data-support-status="${status}"`) - }) + expect(html).toContain(label) + expect(html).toContain(`data-support-status="${status}"`) + expect(html).toContain(`data-variant="${variant}"`) + }, + ) it('qualifies TSE zones instead of showing an ambiguous number', () => { const html = renderToStaticMarkup(createElement(TseZoneBadge, { zoneNumber: 12 })) diff --git a/tests/unit/peopleListUrl.unit.spec.ts b/tests/unit/peopleListUrl.unit.spec.ts index 3a3ae00d..2478c595 100644 --- a/tests/unit/peopleListUrl.unit.spec.ts +++ b/tests/unit/peopleListUrl.unit.spec.ts @@ -50,6 +50,14 @@ describe('people list URL contract', () => { ).toEqual({ page: 1, municipalities: [12, 3], statuses: ['engajado', 'em_disputa'] }) }) + it('canonicalizes selecting every status member to the absent filter (C119)', () => { + expect( + parsePeopleListParams({ + status: ['engajado', 'a_abordar', 'em_disputa', 'lembranca', 'negativo'], + }), + ).toEqual({ page: 1 }) + }) + it('serializes canonical params in stable order and omits defaults', () => { const params = serializeCanonicalPeopleListSearchParams({ page: 1, diff --git a/tests/unit/peopleMerge.unit.spec.ts b/tests/unit/peopleMerge.unit.spec.ts index 9dfc4383..15a3e0d7 100644 --- a/tests/unit/peopleMerge.unit.spec.ts +++ b/tests/unit/peopleMerge.unit.spec.ts @@ -221,4 +221,26 @@ describe('peopleFilterFacetsFromRows', () => { expect(facets.municipalityIDs).toEqual([1, 3, 4, 5, 6, 99]) expect(facets.statuses).toEqual(['engajado']) }) + + it('keeps every leadership status present in the rows, in canonical selector order (C119)', () => { + const withAllStatuses: PeopleMergeSources = { + ...source, + leaderships: (['engajado', 'a_abordar', 'em_disputa', 'lembranca', 'negativo'] as const).map( + (supportStatus, index) => ({ + ...source.leaderships[0]!, + id: 10 + index, + contactID: 100 + index, + supportStatus, + }), + ), + } + const facets = peopleFilterFacetsFromRows(mergePeopleSources(withAllStatuses), { page: 1 }) + expect(facets.statuses).toEqual([ + 'engajado', + 'a_abordar', + 'em_disputa', + 'lembranca', + 'negativo', + ]) + }) }) From ece1933c2b9e355918810340825f821cb8f024b2 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 14:54:33 -0300 Subject: [PATCH 11/14] =?UTF-8?q?chore(C119):=20snapshot=20da=20migration?= =?UTF-8?q?=20contra=20main=20com=20google=5Fcalendar=20(C114)=20=E2=80=94?= =?UTF-8?q?=20evita=20drift=20no=20pr=C3=B3ximo=20migrate:create?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...1_155116_add_lembranca_support_status.json | 166 +++++++++++++++++- src/migrations/index.ts | 8 +- 2 files changed, 172 insertions(+), 2 deletions(-) diff --git a/src/migrations/20260811_155116_add_lembranca_support_status.json b/src/migrations/20260811_155116_add_lembranca_support_status.json index b2213084..329c7620 100644 --- a/src/migrations/20260811_155116_add_lembranca_support_status.json +++ b/src/migrations/20260811_155116_add_lembranca_support_status.json @@ -6654,6 +6654,136 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.google_calendar_sync": { + "name": "google_calendar_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "calendar_id": { + "name": "calendar_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "google_calendar_sync_calendar_id_idx": { + "name": "google_calendar_sync_calendar_id_idx", + "columns": [ + { + "expression": "calendar_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "google_calendar_sync_disabled_at_idx": { + "name": "google_calendar_sync_disabled_at_idx", + "columns": [ + { + "expression": "disabled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "google_calendar_sync_updated_at_idx": { + "name": "google_calendar_sync_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "google_calendar_sync_created_at_idx": { + "name": "google_calendar_sync_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.election_tally": { "name": "election_tally", "schema": "", @@ -10776,6 +10906,12 @@ "primaryKey": false, "notNull": false }, + "google_calendar_sync_id": { + "name": "google_calendar_sync_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, "election_tally_id": { "name": "election_tally_id", "type": "integer", @@ -11174,6 +11310,21 @@ "method": "btree", "with": {} }, + "payload_locked_documents_rels_google_calendar_sync_id_idx": { + "name": "payload_locked_documents_rels_google_calendar_sync_id_idx", + "columns": [ + { + "expression": "google_calendar_sync_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, "payload_locked_documents_rels_election_tally_id_idx": { "name": "payload_locked_documents_rels_election_tally_id_idx", "columns": [ @@ -11601,6 +11752,19 @@ "onDelete": "cascade", "onUpdate": "no action" }, + "payload_locked_documents_rels_google_calendar_sync_fk": { + "name": "payload_locked_documents_rels_google_calendar_sync_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "google_calendar_sync", + "columnsFrom": [ + "google_calendar_sync_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, "payload_locked_documents_rels_election_tally_fk": { "name": "payload_locked_documents_rels_election_tally_fk", "tableFrom": "payload_locked_documents_rels", @@ -13333,6 +13497,6 @@ "tables": {}, "columns": {} }, - "id": "17d89cfa-69b8-4d52-af52-dc4c24d017a5", + "id": "994374af-56a6-42f7-a4c2-907da32cbe60", "prevId": "00000000-0000-0000-0000-000000000000" } \ No newline at end of file diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 9fb3ae11..6782c7bf 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -48,6 +48,7 @@ import * as migration_20260808_184113_remodel_activity_responsible from './20260 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'; import * as migration_20260811_155116_add_lembranca_support_status from './20260811_155116_add_lembranca_support_status'; export const migrations = [ @@ -301,9 +302,14 @@ export const migrations = [ 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', + }, { up: migration_20260811_155116_add_lembranca_support_status.up, down: migration_20260811_155116_add_lembranca_support_status.down, - name: '20260811_155116_add_lembranca_support_status' + name: '20260811_155116_add_lembranca_support_status', }, ]; From a83bedd7a8f8b8a88a563741a514cd6b67146cf0 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 13:50:20 -0300 Subject: [PATCH 12/14] =?UTF-8?q?feat(C116):=20pessoas=20=E2=80=94=20edi?= =?UTF-8?q?=C3=A7=C3=A3o=20onde=20voc=C3=AA=20v=C3=AA=20na=20lista=20deskt?= =?UTF-8?q?op=20(c=C3=A9lulas=20sempre=20input,=20chips=20territoriais=20i?= =?UTF-8?q?nternos,=20nome-link,=20tooltips=20nas=20a=C3=A7=C3=B5es)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG-AGENTS.md | 2 + .../pessoas-edicao-inplace-lista-impl.md | 110 ++++++ .../campanha/(app)/pessoas/formActions.ts | 188 +++++++++++ .../campanha/(app)/pessoas/page.tsx | 315 +++++++++++++----- .../campanha/actions/municipality.ts | 76 +++-- src/app/(campaign)/campanha/actions/person.ts | 278 ++++++++++++++++ .../campaign/people/PeopleAssessoradoCell.tsx | 89 +++++ .../people/PeopleMunicipalityCell.tsx | 112 +++++++ .../shared/CampaignInlineEditableCell.tsx | 107 +++++- .../shared/MunicipalityPortfolioCell.tsx | 33 +- .../campaign/shared/RelationChipCell.tsx | 87 ++++- .../campaign/shared/RelationOptionCell.tsx | 8 + src/lib/municipalityPortfolio.ts | 73 +++- src/lib/schemas/contact.ts | 5 + src/lib/schemas/personCell.ts | 39 +++ src/utilities/people/peopleData.ts | 18 +- .../int/campaignPersonCellEditing.int.spec.ts | 307 +++++++++++++++++ tests/unit/municipalityPortfolio.unit.spec.ts | 68 ++++ .../sharedSheetHostConventions.unit.spec.ts | 9 + 19 files changed, 1808 insertions(+), 116 deletions(-) create mode 100644 docs/plans/pessoas-edicao-inplace-lista-impl.md create mode 100644 src/app/(campaign)/campanha/(app)/pessoas/formActions.ts create mode 100644 src/components/campaign/people/PeopleAssessoradoCell.tsx create mode 100644 src/components/campaign/people/PeopleMunicipalityCell.tsx create mode 100644 src/lib/schemas/personCell.ts create mode 100644 tests/int/campaignPersonCellEditing.int.spec.ts diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index c34ed4ba..e97edea2 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-11):** **C116** (Issue #655) — `/campanha/pessoas` desktop vira **edit-where-you-see**: toda célula (exceto Ações) é um input permanente sem destaque — salva no blur/Enter, Esc descarta, vazio mostra "—" apagado. **Nome** mantém o valor como link para a ficha (`/campanha/liderancas/[id]`) com o mecanismo travado no gate: input invisível (`text-transparent`, z-0) sobre o link (z-10, `pointer-events-auto`, `w-auto` — cobre só o texto; o resto da célula é a área clicável do input), draft renderizado **no texto do link** (o valor real do input nunca é o display), clique no link navega, teclado/área fora do texto edita. **Células de municípios** (Assessora, Lidera, Aliada em) e **Assessorado** usam chips internos com: grupo novo **"Salvador (19)"** (`buildMunicipalityPortfolioChips` ganha kind `city`, checado antes do território — senão as 19 ZEs colapsariam como "Metropolitano de Salvador"), **chip batch clicável expande in loco** (estado de apresentação do wrapper `PeopleMunicipalityCell` — clique expande, mouse-leave colapsa se todos os membros seguem atribuídos; X num membro remove e impede o recolapso), **"+X"** no toggle de overflow do B170, `quiet` (sem tint de hover/foco; indicador de foco discreto só no input), commit por **match exato** no Enter/espaço (digitar "salvador-ze-2"+Enter adiciona a ZE certa, não a pre-selecionada — regra da intenção). Escrita: `updatePersonContactRecord` (escopo "edita o que vê" — assessor só com entidade da pessoa na carteira; `contactFieldUpdateSchema` ganhou variante `city`, convivendo com `phones` do C112), `setPersonAssessoraMembershipRecord` (unrestricted-only, single-account — multi-conta nega com mensagem segura e célula read-only; toggle extraído para `toggleMunicipalityAdvisorMembership` em `municipality.ts`, reusado pelo record B27) e `setPersonAdvisorMembershipRecord` (union person-centric: delta de assessor nas DUAS entidades quando ambas existem). Matriz por papel na página: assessor edita Lidera/Aliada com `addableIds`=carteira e **read-only** em Assessora/Assessorado (regras das collections donas); tooltips de label (WhatsApp/Convidar/Apagar) via `CampaignHoverTooltip`; `CampaignListSheetProvider` na tabela (guard miss #52 — wrapper de composição registrado no `sharedSheetHostConventions`). Mobile intacto. Sem migration. Testes: unit (grupo Salvador + expand + match exato; guard do sheet host), int novo `campaignPersonCellEditing` (11 — escopo por papel, assessora single/multi/sem-conta, union nas duas entidades), e2e `campaignPeople` 4/4. Intenção [`pessoas-edicao-inplace-lista.md`](plans/pessoas-edicao-inplace-lista.md); impl [`pessoas-edicao-inplace-lista-impl.md`](plans/pessoas-edicao-inplace-lista-impl.md). + **Recently resolved (2026-08-11):** **B198** (Issue #660) — o drawer mobile do Sollinha fecha ao navegar por link interno: tocar num link `/campanha…` da resposta fecha o drawer **no toque** (antes de a página carregar) e a navegação segue no mesmo tab; desktop inalterado (painel fica aberto); link externo (nova aba) não fecha o drawer da aba de origem; reload não reabre (o close persiste `open: false`). **Engrenagem:** a decisão pura mora no owner `src/lib/ai/markdownLinks.ts` — regex `^\/campanha(?:\/|$)` promovido a `isCampaignInternalLink` (era `APP_INTERNAL_LINK` no renderer) + `shouldCloseDrawerOnLinkClick(href, isMobile, event)` (fecha ⇔ interno && mobile && clique simples — ignora ctrl/meta/shift/alt, middle-click e `defaultPrevented`, pois abrem nova aba e a origem mantém o chat); o `markdownComponents` estático do `CampaignAIChat` virou `useMemo` no corpo (acesso ao `ctx.isMobile`/`setOpen`; identidade estável, recomputa só no crossing de breakpoint). Sessão/restauração intactas (fechar com `requestOpen(false)` marca `openBy:'user'` com `open:false` — inerte, o restore só reabre com `open:true`). Testes: unit (12 — interno/externo, viewport, modifiers, middle, prevented) + e2e no `campaignSollinhaContext` (interno fecha+navega sem `load`+reload não reabre; externo mantém drawer e abre popup interceptado). Nota: dev-mode local flakeou no prewarm/compile cold — e2e rodado no modo CI (`E2E_PROD=1` com `NEXT_DIST_DIR=.next/e2e`, 7/7). **Débito registrado no gate (B199/#672, `depends` #660):** race pré-existente do persist effect (só grava com `status==='ready'`) — fechar o drawer mid-stream e recarregar antes do settle reabre o drawer "sozinho" (classe OPS22); o tap-no-link do B198 amplia a superfície. Intenção [`sollinha-drawer-fecha-link-mobile.md`](plans/sollinha-drawer-fecha-link-mobile.md); impl [`sollinha-drawer-fecha-link-mobile-impl.md`](plans/sollinha-drawer-fecha-link-mobile-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). diff --git a/docs/plans/pessoas-edicao-inplace-lista-impl.md b/docs/plans/pessoas-edicao-inplace-lista-impl.md new file mode 100644 index 00000000..3bc5d95e --- /dev/null +++ b/docs/plans/pessoas-edicao-inplace-lista-impl.md @@ -0,0 +1,110 @@ +# Impl: Pessoas — edição onde você vê (edit-where-you-see) na lista desktop + +Status: aprovado +Atualizado em: 2026-08-11 +Issue: #655 +Intenção: docs/plans/pessoas-edicao-inplace-lista.md +Appetite restante: ~1,5–2 dias eng; um outcome verificável — a linha da lista é editável sem sair da tabela + +## Leitura da intenção + +- **Outcome:** na tabela desktop de `/campanha/pessoas`, toda célula (exceto Ações) é um **input permanente sem destaque** — parece texto, se comporta como input; salva no blur/Enter (Esc descarta); Nome mantém o valor como link; colunas de municípios (Assessora, Lidera, Aliada em) e Assessorado usam **chips internos** com colapso por território ("Sertão do São Francisco (5)"), grupo "Salvador (19)", "+X" expande, X no hover remove; ações ganham tooltip. +- **O que NÃO negociar:** sem migration (escreve no dono da ficha — `Contact` nome/telefone/e-mail/cidade; vínculos nas collections donas); leader fora da rota; escopo do assessor preservado (só a carteira dele); mobile intacto; nada criado fora do catálogo (texto sem match não vira chip); link do Nome navega; "—" vira placeholder apagado. +- **O que reavaliar:** a hipótese de reusar `CampaignInlineEditableCell` (B163) — seu `editTrigger='cell'` tem estado de edição explícito; o paradigma travado exige input **sempre montado**, então a mudança é um **modo novo** no componente compartilhado, não um ajuste do modo existente. A hipótese de reusar `MunicipalityPortfolioCell`/`RelationChipCell` confirma-se: colapso por território já existe (chips batch); o trabalho novo é **expandir chip batch por clique/hover** (interação que nenhuma célula do sistema tem hoje), o grupo "Salvador (19)" (zona-cidade completa) e o modo `quiet` (sem tint de hover/foco). + +## Abordagem recomendada + +```mermaid +flowchart LR + L[lib/municipalityPortfolio: grupo Salvador + expand chips] --> M[MunicipalityPortfolioCell: opt-ins quiet/+X/expand] + M --> P[PeopleMunicipalityCell: estado de expansão + colapso no mouse-leave] + C[CampaignInlineEditableCell: modo permanent] --> T[Células de texto: Nome link-overlay, Contato, E-mail, Base] + R[RelationOptionCell: Assessorado readOnly + quiet] --> A[PeopleAssessoradoCell] + S[Actions: updatePersonContact, setPersonAssessora, setPersonAdvisor] --> F[pessoas/formActions.ts] + T --> PG[page.tsx: colunas com matriz de edição por papel] + A --> PG + P --> PG +``` + +**Opções consideradas:** + +- **A — estender os componentes compartilhados com props opt-in** (`CampaignInlineEditableCell` modo `permanent`; `RelationChipCell` com `quiet`/`onChipClick`/`overflowToggleLabel`; `MunicipalityPortfolioCell` repassa e expande chips; novo grupo no `buildMunicipalityPortfolioChips`): uma máquina de save e uma máquina de chips para todo o sistema; as outras listas ficam intactas (defaults inalterados). +- **B — componentes novos `people/*` que duplicam a máquina de save/chips**: isolamento total, mas dois donos para blur/Enter/Esc/pending/erro e para o combobox ARIA — exatamente o twin que o repo proíbe. +- **C — modo `permanent` virar o novo default do B163**: coerente com o paradigma, mas muda o comportamento visual das dobradinhas/lideranças sem ser pedido deste item. + +**Recomendação: A** — o trabalho de interação nova (expandir chip batch, +X, quiet) entra como opt-in nas máquinas existentes e a expansão fica como **estado de apresentação** do wrapper `PeopleMunicipalityCell` (puro, sem tocar o ciclo de commit do `RelationChipCell`). C é rejeitada porque altera superfícies fora do escopo; B porque cria twin. + +### Componentes / mudanças + +**Puro (lib, client-safe):** + +- **`src/lib/municipalityPortfolio.ts`**: (1) novo kind de chip `city` — zonas de uma zona-cidade **completas** viram UM chip `{ kind: 'city', label: 'Salvador', city: 'Salvador', municipalityIds: [19 ids] }`, checado **antes** do colapso por território (senão as 19 zonas colapsariam como "Metropolitano de Salvador (19)"); `MunicipalityPortfolioChip` ganha o variant; (2) helper puro `expandMunicipalityPortfolioChips(chips, expandedKeys)` — substitui chip batch (territory/city) cuja key está expandida pelos chips municipais membros; (3) testes unit. +- **`src/lib/schemas/contact.ts`**: variante `city` no `contactFieldUpdateSchema` compartilhado (bounds da collection: 2–100; aditivo — as actions de dobradinhas/liderança não expõem `city` e continuam falhando em campo desconhecido). + +**Server (`person.ts` + formActions):** + +- **`src/app/(campaign)/campanha/actions/person.ts`**: três record-functions novas, todas `withPayloadTransaction` + `req`, Local API com `user`/`overrideAccess: false` e bypass justificado só depois da checagem: + - `updatePersonContactRecord(payload, actor, input)` — escopo: irrestrito sempre; assessor somente se **pelo menos uma** entidade da pessoa (leadership OU stateDeputy, lida com `overrideAccess: false`) é gerenciável por ele (regra "edita o que vê" da linha; staff-only → assessor nega). Depois, bypass do Contact (precedente B163/C99). + - `setPersonAssessoraMembershipRecord(payload, actor, { contactID, municipalityIds, assigned })` — `reloadUnrestrictedActor` (canAssignMunicipalityAdvisors é unrestricted-only); transação única: resolve as contas staff da pessoa por `contact`, **asserta exatamente uma** (multi-conta → célula read-only no client; o server nega com mensagem segura), e para cada município: lock advisory `municipality-advisors:` + toggle — **extrair** o toggle de `setMunicipalityAdvisorMembershipRecord` (`src/app/(campaign)/campanha/actions/municipality.ts`) para um helper compartilhado usado pelos dois (edit the owner, sem duplicar o bloco lock+toggle). + - `setPersonAdvisorMembershipRecord(payload, actor, { contactID, advisorId, assigned })` — `reloadUnrestrictedActor` (regra B156); transação única: aplica o delta de advisor em **cada entidade** da pessoa (leadership e/ou stateDeputy) via `nextAdvisorIdsAfterMembership`/helper existente — semântica person-centric: a coluna Assessorado é atributo da pessoa, adicionar/remover atinge os dois vínculos quando ambos existem. +- **`src/app/(campaign)/campanha/(app)/pessoas/formActions.ts`** (novo): `updatePersonContactFormAction`, `setPersonAssessoraFormAction`, `setPersonAssessoradoFormAction`, `setPersonLeadershipMunicipalitiesFormAction` (envolve `setLeadershipMunicipalitiesMembership`), `setPersonStateDeputyMunicipalitiesFormAction` (envolve `setStateDeputyMunicipalitiesBatch`) — cascas `runCampaignFormAction` no padrão das dobradinhas, com safe-messages existentes. +- **Revalidação:** `/campanha/pessoas` + caminhos de detalhe das entidades tocadas (`liderancas/[id]`, `dobradinhas/[id]`, municípios tocados no assessora) — espelhando `revalidateStateDeputyPaths`/`revalidateLeadershipMunicipalityPaths`. +- **`src/utilities/people/peopleData.ts`**: `PeopleRowViewModel` ganha `assessoradoIDs: number[]` (união `leadershipAdvisorIDs ∪ deputyAdvisorIDs`, já resolvida hoje para os nomes) — ids que os chips do Assessorado precisam. + +**UI (Impeccable B — encaixe em tela existente):** + +- **`src/components/campaign/shared/CampaignInlineEditableCell.tsx`**: prop `permanent?: boolean` (default false — B163/dobradinhas/lideranças intactos). Em `permanent`: + - texto (Contato/E-mail/Base): input **sempre montado**, `bg-transparent border-transparent` (mesmo vocabulário de `campaignInlineInputClassName`), valor = draft, salva no blur/Enter, Esc reverte, vazio → `placeholder="—"` apagado (o "—" atual vira placeholder), telefone mantém formatação; máquina de save/pending/erro/saved-feedback/requestId reutilizada tal qual. + - Nome (`permanent` + `href`): **mecanismo travado no gate** — input sempre montado `absolute inset-0 z-0` com `value` **sempre vazio** (`bg-transparent`), link em `z-10` com `pointer-events-auto` cujo texto renderiza `draft || value`; cada tecla atualiza o draft **no display** (texto do link), o valor real do input permanece vazio; clique no link navega; foco por Tab/clique fora do texto → digitação; blur/Enter salva, Esc descarta. Sufixo `({party})` permanece fora do link como texto read-only. + - Indicador de foco **discreto**: `focus-visible` ring suave; nada em hover (paradigma sem fundo/borda). +- **`src/components/campaign/shared/RelationChipCell.tsx`**: três opt-ins, defaults inalterados (B34/B36/B37/B156/B157/B159/B169 intactos): + - `quiet?: boolean` — remove `pointer-fine:hover:bg-muted/40` do container; mantém apenas indicador discreto de foco (guardrail de acessibilidade); + - `onChipClick?: (chip) => void` — chip **sem href** clicável (hoje só navega); com stopPropagation; + - `overflowToggleLabel?: (hiddenCount) => string` — o toggle de overflow do B170 renderiza como chip `+N` em vez de "Ver mais…". +- **`src/components/campaign/shared/MunicipalityPortfolioCell.tsx`**: repassa os três opt-ins ao `RelationChipCell` e aplica `expandMunicipalityPortfolioChips` no `buildChips` quando `expandedKeys` é passado. +- **`src/components/campaign/people/PeopleMunicipalityCell.tsx`** (novo, substitui o `PeopleMunicipalityCell` local do page.tsx): wrapper de `MunicipalityPortfolioCell` com estado local `expandedKeys` + `onMouseLeave` que **colapsa** chips expandidos cujos membros continuam todos atribuídos (lê `ids` da prop via ref); serve as colunas Assessora/Lidera/Aliada com `commitAction`/`buildFormData`/`minItems`/`readOnly` definidos pela página. +- **`src/components/campaign/people/PeopleAssessoradoCell.tsx`** (novo): wrapper de `RelationOptionCell` — chips = assessorados com href `/campanha/assessores/[id]` (só para irrestrito; regra B156), `options` de `loadEligibleAdvisorOptions`, `readOnly` para assessor; copy do `ADVISOR_COPY` (sem o twin: extrair/reusar a copy de `LeadershipAdvisorRelationCell` se o diff permitir, senão constante local). +- **`src/app/(campaign)/campanha/(app)/pessoas/page.tsx`**: colunas Contato/E-mail/Base com `CampaignInlineEditableCell permanent`; Nome com `permanent` + `href` (`/campanha/liderancas/[id]` quando `leadershipID`); Assessora/Lidera/Aliada com `PeopleMunicipalityCell`; Assessorado com `PeopleAssessoradoCell`; **matriz de edição por papel** (helper puro na page ou `lib`): irrestrito edita tudo; assessor edita células de texto e Lidera/Aliada (com `addableIds = administeredIds`, `minItems=1` em Lidera) apenas nas linhas cujo vínculo cruza a carteira; Assessora e Assessorado **read-only** para assessor (canAssignMunicipalityAdvisors / B156 são unrestricted-only). Ações: `CampaignHoverTooltip` com a label (WhatsApp, Convidar, Apagar) nos três botões. `PeopleMobileCards` intacto. +- **Migration:** nenhuma — sem mudança de schema. + +### Dados → forma + +- **Forma escolhida:** inputs permanentes sem destaque + chips internos ao input — o paradigma travado no gate 2026-08-11, que É a forma deste item (não há dado analítico; o chip de território mostra contagem, não métrica). Rejeitadas: toggle global de edição, lápis obrigatório e células read→edit com estado visual — tudo divergente do aceite. + +## Fases verificáveis + +1. **Tracer / puro + UI machines** (~1/2 appetite): grupo `city` de Salvador + `expandMunicipalityPortfolioChips` + unit tests; opt-ins no `RelationChipCell`/`MunicipalityPortfolioCell`; modo `permanent` no `CampaignInlineEditableCell`; `PeopleMunicipalityCell` + `PeopleAssessoradoCell`. Validar que dobradinhas/lideranças não regridem (testes existentes + revisão visual). +2. **Server / access** (~1/3 appetite): variante `city` no schema; as três record-functions + formActions; `assessoradoIDs` no VM; int tests — coordenador/candidato editam tudo, assessor só a carteira (célula de texto em linha em escopo ok; linha staff-only negada; Lidera/Aliada com addableIds; Assessora/Assessorado read-only), multi-conta do Assessora nega, union do Assessorado escreve nas duas entidades, telefone conflitante falha com mensagem segura. +3. **Page + polish** (~1/3 appetite): colunas com a matriz por papel, tooltips nas ações, indicador de foco discreto, `+X`, colapso por mouse-leave; shape→craft→critique→polish. +4. **Gates:** após cada slice `pnpm exec tsc --noEmit` + testes focados; ao final `pnpm lint` (0 warnings), `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm test:e2e` (se tocar jornada), `pnpm build` local. + +## Rabbit holes / Não escopo (engenharia) + +- Contenteditable real para chips internos — o comportamento continua o de input (chips como dados, teclado previsível); a mecânica é chips + input inline do `RelationChipCell`. +- Estado de expansão global / animações entre linhas — expansão é local à célula, por hover/foco, sem estado persistido. +- Tree-view de território com sub-chips persistentes — grupo colapsa/expande in loco; nada de hierarquia. +- Edição em lote / spreadsheet mode / reorder de colunas / coluna nova. +- C112 (telefones múltiplos) e B155+ (rota em vez de form action) continuam suaves — o editor de Contato segue o shape atual e o padrão de escrita vigente (form action). +- Migration de schema — nenhuma. + +## Riscos e mitigação + +- **Regressão B163/dobradinhas/lideranças:** modo `permanent` default-off; testes existentes do controle compartilhado cobrem os modos antigos; revisão visual das duas listas na Fase 1. +- **Regressão `RelationChipCell` (B34/B36/B37/B156/B157/B159/B169):** três props opt-in com defaults inalterados; testes unit/int existentes das células de relação rodam intactos na Fase 1. +- **Mecanismo do Nome (input vazio + draft no link):** caret invisível é trade-off do mecanismo travado; a navegação e a digitação coexistem; teste de componente cobre clique-no-link (navega) vs clique-fora (edita), Enter/Esc/blur. +- **Assessor com linha visível por capacidade fora da carteira:** matriz por coluna derruba a edição quando a entidade subjacente não é gerenciável (Lidera/Aliada read-only na linha); o server nega de qualquer forma (access da collection dona). +- **Pessoa com múltiplas contas staff (Assessora):** célula read-only + server nega; edge raro e honesto. +- **Chips batch (território/Salvador) removidos no X removem o grupo todo:** semântica já existente no sistema; o undo toast cobre a remoção em lote. +- **Revalidação de paths novos** (`/campanha/pessoas` + detalhes das entidades): seguir os helpers de revalidação existentes por entidade; nunca `revalidatePath` global. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (inputs permanentes, Nome-link, chips com colapso por território/Salvador, +X, X no hover, tooltips, mobile intacto, sem lote) +- [ ] Sem migration; escrita no dono da ficha (`Contact`) e nas collections donas dos vínculos +- [ ] Local API com `user` + `overrideAccess: false`; bypass do Contact/entities justificado após checagem de escopo; transações com `req` em toda escrita multi-collection +- [ ] Escopo do assessor preservado (matriz por coluna + access da collection dona); leader fora da rota; multi-conta do Assessora nega +- [ ] `contactFieldUpdateSchema` ganha `city` de forma aditiva; mensagens seguras allowlisted +- [ ] Testes previstos: unit (grupo Salvador, expand chips, +X) e int (escopo de célula por papel, assessora single-account, union do assessorado, telefone conflitante) +- [ ] Nome em identificadores inglês; copy pt-BR + +**Self-score decision-quality: 5/5** — decisões caras (estender vs criar twin, mecanismo do Nome, semântica do Assessorado/Assessora) têm alternativas rejeitadas com justificativa; appetite bounded por fases; rabbit holes nomeados; reuso máximo das máquinas existentes; o aceite da intenção não foi alterado. diff --git a/src/app/(campaign)/campanha/(app)/pessoas/formActions.ts b/src/app/(campaign)/campanha/(app)/pessoas/formActions.ts new file mode 100644 index 00000000..79f666c1 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/pessoas/formActions.ts @@ -0,0 +1,188 @@ +'use server' + +import { setLeadershipMunicipalitiesMembership } from '@/app/(campaign)/campanha/actions/leadership' +import { + setPersonAdvisorMembership, + setPersonAssessoraMembership, + updatePersonContact, +} from '@/app/(campaign)/campanha/actions/person' +import { setStateDeputyMunicipalitiesBatch } from '@/app/(campaign)/campanha/actions/stateDeputy' +import { + nullableFormText, + repeatedRelationshipFormValues, + requiredFormBoolean, + requiredFormText, + requiredRelationshipFormValue, +} from '@/lib/formData' +import { + LEADERSHIP_MUNICIPALITY_CAP_MESSAGE, + LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE, + LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, + LEADERSHIP_STAFF_MESSAGE, +} from '@/lib/schemas/leadership' +import { + PERSON_ADVISORS_UNRESTRICTED_MESSAGE, + PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE, + PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE, + PERSON_ASSESSORA_UNRESTRICTED_MESSAGE, + PERSON_CELL_NOT_IN_SCOPE_MESSAGE, + PERSON_CELL_STAFF_MESSAGE, + PERSON_CONTACT_INVALID_MESSAGE, +} from '@/lib/schemas/personCell' +import { + STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES, + STATE_DEPUTY_STAFF_MESSAGE, +} from '@/lib/schemas/stateDeputy' +import { + runCampaignFormAction, + type CampaignFormActionState, +} from '@/utilities/campaignFormActionError' + +const personContactSafeMessages = [ + PERSON_CELL_STAFF_MESSAGE, + PERSON_CELL_NOT_IN_SCOPE_MESSAGE, + PERSON_CONTACT_INVALID_MESSAGE, +] as const + +const personAssessoraSafeMessages = [ + PERSON_ASSESSORA_UNRESTRICTED_MESSAGE, + PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE, + PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE, +] as const + +const personAssessoradoSafeMessages = [ + PERSON_ADVISORS_UNRESTRICTED_MESSAGE, + PERSON_CONTACT_INVALID_MESSAGE, +] as const + +const leadershipMunicipalitiesSafeMessages = [ + LEADERSHIP_STAFF_MESSAGE, + LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE, + LEADERSHIP_MUNICIPALITY_CAP_MESSAGE, + LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE, +] as const + +/** Per-field Contact edit for the people list cells (C116). */ +export const updatePersonContactFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => + runCampaignFormAction({ + execute: async () => { + const contactId = requiredRelationshipFormValue(formData, 'contactId') + const field = requiredFormText(formData, 'field') + + if (field === 'name') { + await updatePersonContact({ + id: contactId, + field: 'name', + name: requiredFormText(formData, 'name'), + }) + } else if (field === 'email') { + await updatePersonContact({ + id: contactId, + field: 'email', + email: nullableFormText(formData, 'email') ?? undefined, + }) + } else if (field === 'phone') { + await updatePersonContact({ + id: contactId, + field: 'phone', + phone: nullableFormText(formData, 'phone'), + }) + } else if (field === 'city') { + await updatePersonContact({ + id: contactId, + field: 'city', + city: requiredFormText(formData, 'city'), + }) + } else { + throw new Error(PERSON_CELL_NOT_IN_SCOPE_MESSAGE) + } + + return { message: 'Salvo.' } + }, + safeMessages: personContactSafeMessages, + genericMessage: 'Não foi possível salvar. Verifique os dados e tente novamente.', + }) + +/** + * Assessora column (C116): one territory / "Salvador (19)" batch or single + * municipality toggling the person's staff account on `municipality.advisors`. + */ +export const setPersonAssessoraFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => + runCampaignFormAction({ + execute: async () => { + await setPersonAssessoraMembership({ + contactId: requiredRelationshipFormValue(formData, 'contactId'), + municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'), + assigned: requiredFormBoolean(formData, 'assigned'), + }) + return { message: 'Carteira atualizada.' } + }, + safeMessages: personAssessoraSafeMessages, + genericMessage: 'Não foi possível atualizar a carteira. Tente novamente.', + }) + +/** + * Assessorado column (C116): one advisor delta applied to every entity of the + * person (leadership and/or dobradinha). + */ +export const setPersonAssessoradoFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => + runCampaignFormAction({ + execute: async () => { + await setPersonAdvisorMembership({ + contactId: requiredRelationshipFormValue(formData, 'contactId'), + advisorId: requiredRelationshipFormValue(formData, 'advisorId'), + assigned: requiredFormBoolean(formData, 'assigned'), + }) + return { message: 'Assessores atualizados.' } + }, + safeMessages: personAssessoradoSafeMessages, + genericMessage: 'Não foi possível atualizar os assessores. Tente novamente.', + }) + +/** Lidera column (C116) — reuses the leadership municipalities write. */ +export const setPersonLeadershipMunicipalitiesFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => + runCampaignFormAction({ + execute: async () => { + await setLeadershipMunicipalitiesMembership({ + leadershipId: requiredRelationshipFormValue(formData, 'ownerId'), + municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'), + assigned: requiredFormBoolean(formData, 'assigned'), + }) + return { message: 'Municípios atualizados.' } + }, + safeMessages: leadershipMunicipalitiesSafeMessages, + genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.', + }) + +/** Aliada em column (C116) — reuses the dobradinha municipalities write. */ +export const setPersonStateDeputyMunicipalitiesFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => + runCampaignFormAction({ + execute: async () => { + await setStateDeputyMunicipalitiesBatch({ + stateDeputyId: requiredRelationshipFormValue(formData, 'ownerId'), + municipalityIds: repeatedRelationshipFormValues(formData, 'municipalityIds'), + assigned: requiredFormBoolean(formData, 'assigned'), + }) + return { message: 'Municípios atualizados.' } + }, + safeMessages: [ + ...STATE_DEPUTY_MUNICIPALITIES_SAFE_MESSAGES, + STATE_DEPUTY_STAFF_MESSAGE, + ] as const, + genericMessage: 'Não foi possível atualizar os municípios. Tente novamente.', + }) diff --git a/src/app/(campaign)/campanha/(app)/pessoas/page.tsx b/src/app/(campaign)/campanha/(app)/pessoas/page.tsx index d94f6e65..250d6f46 100644 --- a/src/app/(campaign)/campanha/(app)/pessoas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/pessoas/page.tsx @@ -6,20 +6,26 @@ import type { ReactNode } from 'react' import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction' import { DeletePersonButton } from '@/components/campaign/people/DeletePersonButton' +import { PeopleAssessoradoCell } from '@/components/campaign/people/PeopleAssessoradoCell' import { PeopleFilters } from '@/components/campaign/people/PeopleFilters' import { PeopleListPageChrome } from '@/components/campaign/people/PeopleListPageChrome' +import { PeopleMunicipalityCell } from '@/components/campaign/people/PeopleMunicipalityCell' import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing' +import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip' +import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell' import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter' import { CampaignListPendingBoundary, CampaignListResults, CampaignTransitionAnchor, } from '@/components/campaign/shared/CampaignListPending' +import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost' import { CampaignTable, CampaignTableHead, type CampaignTableColumn, } from '@/components/campaign/shared/CampaignTable' +import type { RelationCellOption } from '@/components/campaign/shared/RelationOptionCell' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { Badge } from '@/components/ui/Badge' import { Button, buttonVariants } from '@/components/ui/button' @@ -34,14 +40,18 @@ import { import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility' import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import { isUnrestrictedCampaignRole } from '@/lib/campaignRoles' +import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio' import { resolvedPortfolioEntriesById, type ResolvedPortfolioEntry, } from '@/lib/municipalityPortfolio' import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone' import { cn } from '@/lib/utils' +import type { CampaignUser } from '@/payload-types' +import { getAdvisorMunicipalityIds } from '@/utilities/access/municipalities' import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' +import { loadEligibleAdvisorOptions } from '@/utilities/campaignRelationOptions' import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex' import { loadPeopleListPageData, type PeopleRowViewModel } from '@/utilities/people/peopleData' import { @@ -51,6 +61,14 @@ import { } from '@/utilities/people/peopleListFilters' import { buildPeopleListHref, resolvePeopleListUrl } from '@/utilities/people/peopleListUrl' +import { + setPersonAssessoraFormAction, + setPersonAssessoradoFormAction, + setPersonLeadershipMunicipalitiesFormAction, + setPersonStateDeputyMunicipalitiesFormAction, + updatePersonContactFormAction, +} from './formActions' + export const metadata = campaignPageMetadataFromCatalog('pessoas') type PeoplePageProps = { @@ -84,64 +102,91 @@ const PeopleListEmptyState = ({ hasFilters, scoped }: { hasFilters: boolean; sco ) -const PeopleMunicipalityCell = ({ - ids, - index, -}: { - ids: readonly number[] - index: ReadonlyMap -}) => { - if (!ids.length) return — - const names = ids - .map((id) => index.get(id)?.name) - .filter((name): name is string => name !== undefined) - if (!names.length) return — - const visible = names.slice(0, 2) - const rest = names.length - visible.length - return ( -
- {visible.map((name) => ( - - {name} - - ))} - {rest > 0 ? ( - - +{rest} - - ) : null} -
- ) +/** + * C116 editability matrix by actor. The advisor rule mirrors each owner + * collection's access: Lidera/Aliada em follow + * `canManageLeadership`/`canReadStateDeputy` (the row's own municipalities + * must cross the carteira — the row may be visible via another capacity); + * Assessora and Assessorado are unrestricted-only + * (`canAssignMunicipalityAdvisors` / B156), and Assessora needs exactly ONE + * staff account for the batch write to have a target. The ficha text cells + * stay always-editable and the server enforces the scope ("you edit what you + * see") with a per-cell error. + */ +type PeopleEditability = { + administered: ReadonlySet | null + canEditLidera: (row: PeopleRowViewModel) => boolean + canEditAliada: (row: PeopleRowViewModel) => boolean + canEditAssessora: (row: PeopleRowViewModel) => boolean + canEditAssessorado: boolean +} + +const buildPeopleEditability = ( + userRole: CampaignUser['role'], + administered: ReadonlySet | null, +): PeopleEditability => { + const unrestricted = isUnrestrictedCampaignRole(userRole) + const inCarteira = (ids: readonly number[]): boolean => + administered !== null && ids.some((id) => administered.has(id)) + return { + administered, + canEditLidera: (row) => + row.leadershipID !== null && (unrestricted || inCarteira(row.leadershipMunicipalityIDs)), + canEditAliada: (row) => + row.deputyID !== null && (unrestricted || inCarteira(row.deputyMunicipalityIDs)), + canEditAssessora: (row) => unrestricted && row.staff.length === 1, + canEditAssessorado: unrestricted, + } } const peopleColumns = ({ municipalityIndex, canDelete, + editability, + advisorOptions, }: { - municipalityIndex: ReadonlyMap + municipalityIndex: readonly MunicipalityPortfolioIndexEntry[] canDelete: boolean + editability: PeopleEditability + advisorOptions: RelationCellOption[] }): Array> => [ { id: 'name', label: 'Nome', mandatory: true, cell: (row) => ( - - {row.name} - {row.party ? ({row.party}) : null} - +
+ + {row.party ? ({row.party}) : null} +
), }, { id: 'contact', label: 'Contato', cellClassName: 'whitespace-normal', - cell: (row) => - row.phone ? ( - {formatBrazilianPhoneInput(row.phone)} - ) : ( - — - ), + cell: (row) => ( + + ), }, { // B197 — the email left the "Contato" cell (which now carries only the @@ -150,14 +195,33 @@ const peopleColumns = ({ id: 'email', label: 'E-mail', cellClassName: 'max-w-56 whitespace-normal', - cell: (row) => (row.email ? row.email : —), + cell: (row) => ( + + ), }, { id: 'assessora', label: 'Assessora', cellClassName: 'max-w-56 whitespace-normal', cell: (row) => ( - + ), }, { @@ -165,7 +229,18 @@ const peopleColumns = ({ label: 'Lidera', cellClassName: 'max-w-56 whitespace-normal', cell: (row) => ( - + ), }, { @@ -173,19 +248,48 @@ const peopleColumns = ({ label: 'Aliada em', cellClassName: 'max-w-56 whitespace-normal', cell: (row) => ( - + ), }, { id: 'assessorado', label: 'Assessorado', cellClassName: 'max-w-56 whitespace-normal text-muted-foreground', - cell: (row) => row.assessoradoNames.join(', ') || '—', + cell: (row) => ( + + ), }, { id: 'base', label: 'Base', - cell: (row) => row.city ?? '—', + cell: (row) => ( + + ), }, { id: 'actions', @@ -201,23 +305,27 @@ const peopleColumns = ({ return (
{row.leadershipID !== null ? ( - + + + ) : null} {whatsAppHref ? ( - + + + ) : (
) @@ -308,7 +418,7 @@ const PeopleMobileCards = ({ capacity.ids.length > 0 ? (
{capacity.label}: - +
) : null, )} @@ -327,6 +437,36 @@ const PeopleMobileCards = ({ ) +const PeopleMunicipalityCellMobile = ({ + ids, + index, +}: { + ids: readonly number[] + index: ReadonlyMap +}) => { + if (!ids.length) return — + const names = ids + .map((id) => index.get(id)?.name) + .filter((name): name is string => name !== undefined) + if (!names.length) return — + const visible = names.slice(0, 2) + const rest = names.length - visible.length + return ( +
+ {visible.map((name) => ( + + {name} + + ))} + {rest > 0 ? ( + + +{rest} + + ) : null} +
+ ) +} + export default async function PeoplePage({ searchParams }: PeoplePageProps) { const rawSearchParams = await searchParams const canonicalUrl = resolvePeopleListUrl(rawSearchParams) @@ -337,11 +477,16 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) { getPayload({ config }), ]) - const [columnVisibility, listData, municipalityIndex] = await Promise.all([ + const [columnVisibility, listData, municipalityIndex, administeredIds] = await Promise.all([ readCampaignColumnVisibility('pessoas'), loadPeopleListPageData(payload, user, canonicalUrl.state), loadMunicipalityPortfolioIndex(), + user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null, ]) + const editability = buildPeopleEditability( + user.role, + administeredIds ? new Set(administeredIds) : null, + ) const resolvedUrl = resolvePeopleListUrl(rawSearchParams, listData.totalPages) if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref) const { state } = resolvedUrl @@ -355,8 +500,24 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) { .filter((option): option is PeopleFilterOption => option !== null) .sort((left, right) => left.label.localeCompare(right.label, 'pt-BR')) + const advisorOptions = editability.canEditAssessorado + ? (await loadEligibleAdvisorOptions(payload, user)).map((option) => ({ + id: option.id, + searchLabel: option.name, + item: { + id: option.id, + label: option.name, + href: `/campanha/assessores/${option.id}`, + }, + })) + : [] const canDelete = isUnrestrictedCampaignRole(user.role) - const columns = peopleColumns({ municipalityIndex: resolvedIndex, canDelete }) + const columns = peopleColumns({ + municipalityIndex, + canDelete, + editability, + advisorOptions, + }) const hasFilters = Boolean( state.q || state.capacities?.length || state.municipalities?.length || state.statuses?.length, ) @@ -385,17 +546,21 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) { } /> - row.contactID} - empty={ - - } - /> + {/* One shared Drawer for every chip-cell sheet on coarse pointers + (miss #52 — never a Drawer root per opened cell). */} + + row.contactID} + empty={ + + } + /> + {listData.rows.length ? ( => { + await acquireTextAdvisoryLocks(payload, req, [`municipality-advisors:${municipalityId}`]) + + // Intentional bypass: the unrestricted-role gate is the caller's job (see + // the doc above); the `advisors` field is admin-only by field access, and + // the `validateMunicipalityAdvisors` beforeValidate hook still runs. + const current = await payload.findByID({ + collection: 'municipality', + id: municipalityId, + depth: 0, + select: { advisors: true }, + overrideAccess: true, + req, + }) + + const currentAdvisorIDs = uniqueRelationshipIds(current.advisors) + const nextAdvisorIDs = nextAdvisorIdsAfterMembership(currentAdvisorIDs, advisorId, assigned) + if (nextAdvisorIDs === null) return null + + // Same intentional bypass as the read above: the gate ran in the caller. + return payload.update({ + collection: 'municipality', + id: municipalityId, + data: { advisors: nextAdvisorIDs }, + depth: 0, + overrideAccess: true, + req, + }) +} + export const setMunicipalityAdvisorMembershipRecord = async ( payload: Payload, actor: CampaignUser, @@ -307,9 +353,17 @@ export const setMunicipalityAdvisorMembershipRecord = async ( req, ) - await acquireTextAdvisoryLocks(payload, req, [`municipality-advisors:${municipality}`]) + const toggled = await toggleMunicipalityAdvisorMembership( + payload, + req, + municipality, + advisor, + assigned, + ) + if (toggled !== null) return toggled - const current = await payload.findByID({ + // No-op: nothing to write — the caller still expects the current doc. + return payload.findByID({ collection: 'municipality', id: municipality, depth: 0, @@ -317,22 +371,6 @@ export const setMunicipalityAdvisorMembershipRecord = async ( overrideAccess: true, req, }) - - const currentAdvisorIDs = uniqueRelationshipIds(current.advisors) - const nextAdvisorIDs = nextAdvisorIdsAfterMembership(currentAdvisorIDs, advisor, assigned) - if (nextAdvisorIDs === null) return current - - // Intentional admin bypass: unrestricted role was freshly verified above; - // the advisors field is admin-only by field access, and eligibility is - // reconfirmed by the `validateMunicipalityAdvisors` beforeValidate hook. - return payload.update({ - collection: 'municipality', - id: municipality, - data: { advisors: nextAdvisorIDs }, - depth: 0, - overrideAccess: true, - req, - }) }, { beginFailureMessage: 'Não foi possível iniciar a atualização de assessores.' }, ) diff --git a/src/app/(campaign)/campanha/actions/person.ts b/src/app/(campaign)/campanha/actions/person.ts index 6418bbaa..89d79498 100644 --- a/src/app/(campaign)/campanha/actions/person.ts +++ b/src/app/(campaign)/campanha/actions/person.ts @@ -1,14 +1,41 @@ 'use server' +import { revalidatePath } from 'next/cache' + +import { toggleMunicipalityAdvisorMembership } from '@/app/(campaign)/campanha/actions/municipality' +import { nextLeadershipAdvisorIdsAfterMembership } from '@/lib/leadershipAdvisorMembership' +import { uniqueRelationshipIds } from '@/lib/relationship' +import { contactFieldUpdateSchema, type ContactFieldUpdateInput } from '@/lib/schemas/contact' +import { + PERSON_ADVISORS_UNRESTRICTED_MESSAGE, + PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE, + PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE, + PERSON_ASSESSORA_UNRESTRICTED_MESSAGE, + PERSON_CELL_NOT_IN_SCOPE_MESSAGE, + PERSON_CELL_STAFF_MESSAGE, + PERSON_CONTACT_INVALID_MESSAGE, + personAdvisorMembershipSchema, + personAssessoraMembershipSchema, + type PersonAdvisorMembershipInput, + type PersonAssessoraMembershipInput, +} from '@/lib/schemas/personCell' import { PERSON_DELETE_FORBIDDEN_MESSAGE, personDeleteInputSchema, } from '@/lib/schemas/personDelete' +import { nextStateDeputyAdvisorIdsAfterMembership } from '@/lib/stateDeputyAdvisorMembership' +import type { CampaignUser, Contact } from '@/payload-types' +import { isCampaignUnrestricted } from '@/utilities/campaignAccess' import { getCampaignActionContext, + reloadStaffActor, reloadUnrestrictedActor, } from '@/utilities/campaignActionContext' +import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation' +import { withPayloadTransaction } from '@/utilities/payloadTransaction' import { deletePersonRecord, loadPersonDeleteManifest } from '@/utilities/people/personDelete' +import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' +import type { Payload } from 'payload' /** * C100 "Apagar pessoa" server actions. Both are coordinator/candidate-only @@ -34,3 +61,254 @@ export const deletePersonAction = async (input: unknown) => { ) return deletePersonRecord(payload, currentActor, data.contactId) } + +// --------------------------------------------------------------------------- +// C116 — "edit where you see": per-cell writes of the people list table. +// --------------------------------------------------------------------------- + +/** + * C116 — the cell-edit scope rule for a person's ficha fields: unrestricted + * actors always pass; an advisor passes only when at least ONE entity of the + * person (leadership or dobradinha) is readable with his own access — the "you + * edit what you see" rule, mirroring the row's visibility (a staff-only person + * is not entity-manageable and therefore not editable by an advisor). + */ +const assertPersonContactEditable = async ( + payload: Payload, + actor: CampaignUser, + contactID: number, + req: { transactionID: number | string }, +): Promise => { + if (isCampaignUnrestricted(actor)) return + + const [leaderships, deputies] = await Promise.all([ + payload.find({ + collection: 'leadership', + where: { contact: { equals: contactID } }, + depth: 0, + limit: 1, + pagination: false, + user: actor, + overrideAccess: false, + req, + }), + payload.find({ + collection: 'stateDeputy', + where: { contact: { equals: contactID } }, + depth: 0, + limit: 1, + pagination: false, + user: actor, + overrideAccess: false, + req, + }), + ]) + + if (leaderships.docs.length === 0 && deputies.docs.length === 0) { + throw new Error(PERSON_CELL_NOT_IN_SCOPE_MESSAGE) + } +} + +export const updatePersonContactRecord = async ( + payload: Payload, + actor: CampaignUser, + input: ContactFieldUpdateInput, +) => { + const data = contactFieldUpdateSchema.parse(input) + + return withPayloadTransaction( + payload, + async ({ req }) => { + const currentActor = await reloadStaffActor(payload, actor, PERSON_CELL_STAFF_MESSAGE, req) + + await assertPersonContactEditable(payload, currentActor, data.id, req) + + const contactData: Partial> = {} + if (data.field === 'name') { + contactData.name = data.name + } else if (data.field === 'email') { + contactData.email = data.email ?? null + } else if (data.field === 'phone') { + contactData.phone = data.phone + } else if (data.field === 'city') { + contactData.city = data.city + } + + // Bypass: the scope check above established the actor's right over the + // person's ficha (same precedent as the leadership/dobradinha cell edits). + await payload.update({ + collection: 'contact', + id: data.id, + data: contactData, + depth: 0, + overrideAccess: true, + req, + }) + }, + { beginFailureMessage: 'Não foi possível iniciar a edição da pessoa.' }, + ) +} + +export const updatePersonContact = async (input: ContactFieldUpdateInput) => { + const { payload, actor } = await getCampaignActionContext() + await updatePersonContactRecord(payload, actor, input) + revalidatePath('/campanha/pessoas', 'page') + revalidatePath('/campanha/liderancas', 'page') + revalidatePath('/campanha/dobradinhas', 'page') +} + +/** + * C116 — the Assessora column of a person: toggles the person's staff account + * on `municipality.advisors` for a batch of municipalities (one territory / ZE + * chip or a "Salvador (19)" group), in ONE transaction. Unrestricted-only + * (`canAssignMunicipalityAdvisors`), and the person must have EXACTLY ONE + * staff account — with more, "which account" is ambiguous, so the server + * refuses with a safe message (the cell renders read-only for those rows). + */ +export const setPersonAssessoraMembershipRecord = async ( + payload: Payload, + actor: CampaignUser, + input: PersonAssessoraMembershipInput, +) => { + const { contactId, municipalityIds, assigned } = personAssessoraMembershipSchema.parse(input) + + return withPayloadTransaction( + payload, + async ({ req }) => { + await reloadUnrestrictedActor(payload, actor, PERSON_ASSESSORA_UNRESTRICTED_MESSAGE, req) + + const accounts = await payload.find({ + collection: 'campaignUser', + where: { contact: { equals: contactId } }, + depth: 0, + limit: 2, + pagination: false, + // Intentional bypass: the unrestricted gate above is the authorization; + // `contact` is identity-gated and the accounts are only counted. + overrideAccess: true, + req, + }) + + if (accounts.docs.length === 0) throw new Error(PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE) + if (accounts.docs.length > 1) throw new Error(PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE) + const accountId = accounts.docs[0].id + + for (const municipalityId of municipalityIds) { + await toggleMunicipalityAdvisorMembership(payload, req, municipalityId, accountId, assigned) + } + }, + { beginFailureMessage: 'Não foi possível iniciar a atualização da carteira.' }, + ) +} + +export const setPersonAssessoraMembership = async (input: PersonAssessoraMembershipInput) => { + const { payload, actor } = await getCampaignActionContext() + await setPersonAssessoraMembershipRecord(payload, actor, input) + revalidatePath('/campanha/pessoas', 'page') + revalidatePath('/campanha/assessores', 'page') + revalidateMunicipalityListPaths({ scope: 'list' }) +} + +/** + * C116 — the Assessorado column: the advisor delta is applied to EVERY entity + * of the person (leadership and/or dobradinha) in one transaction — the column + * is a person attribute, so adding/removing an advisor touches both vínculos + * when both exist. Unrestricted-only (B156 rule: advisor assignment is + * coordinator/candidate). + */ +export const setPersonAdvisorMembershipRecord = async ( + payload: Payload, + actor: CampaignUser, + input: PersonAdvisorMembershipInput, +) => { + const { contactId, advisorId, assigned } = personAdvisorMembershipSchema.parse(input) + + return withPayloadTransaction( + payload, + async ({ req }) => { + await reloadUnrestrictedActor(payload, actor, PERSON_ADVISORS_UNRESTRICTED_MESSAGE, req) + + const [leaderships, deputies] = await Promise.all([ + payload.find({ + collection: 'leadership', + where: { contact: { equals: contactId } }, + depth: 0, + pagination: false, + select: { id: true, advisors: true }, + // Intentional bypass: the unrestricted gate above is the + // authorization; the `advisors` field is admin-only by field access + // (same contract as the C99/B156 per-entity cells). + overrideAccess: true, + req, + }), + payload.find({ + collection: 'stateDeputy', + where: { contact: { equals: contactId } }, + depth: 0, + pagination: false, + select: { id: true, advisors: true }, + // Intentional bypass: same gate as the leadership read above. + overrideAccess: true, + req, + }), + ]) + + const touched: number[] = [] + for (const leadership of leaderships.docs) { + await acquireTextAdvisoryLocks(payload, req, [`leadership-advisors:${leadership.id}`]) + const currentAdvisorIDs = uniqueRelationshipIds(leadership.advisors) + const nextAdvisorIDs = nextLeadershipAdvisorIdsAfterMembership( + currentAdvisorIDs, + advisorId, + assigned, + ) + if (nextAdvisorIDs === null) continue + // Intentional bypass: the unrestricted gate above is the authorization; + // the `advisors` field is admin-only by field access (C99 contract). + await payload.update({ + collection: 'leadership', + id: leadership.id, + data: { advisors: nextAdvisorIDs }, + depth: 0, + overrideAccess: true, + req, + }) + touched.push(leadership.id) + } + + for (const deputy of deputies.docs) { + await acquireTextAdvisoryLocks(payload, req, [`state-deputy-advisors:${deputy.id}`]) + const currentAdvisorIDs = uniqueRelationshipIds(deputy.advisors) + const nextAdvisorIDs = nextStateDeputyAdvisorIdsAfterMembership( + currentAdvisorIDs, + advisorId, + assigned, + ) + if (nextAdvisorIDs === null) continue + // Intentional bypass: same gate as the leadership write above. + await payload.update({ + collection: 'stateDeputy', + id: deputy.id, + data: { advisors: nextAdvisorIDs }, + depth: 0, + overrideAccess: true, + req, + }) + touched.push(deputy.id) + } + + if (touched.length === 0) { + throw new Error(PERSON_CONTACT_INVALID_MESSAGE) + } + }, + { beginFailureMessage: 'Não foi possível iniciar a atualização dos assessores.' }, + ) +} + +export const setPersonAdvisorMembership = async (input: PersonAdvisorMembershipInput) => { + const { payload, actor } = await getCampaignActionContext() + await setPersonAdvisorMembershipRecord(payload, actor, input) + revalidatePath('/campanha/pessoas', 'page') + revalidatePath('/campanha/liderancas', 'page') + revalidatePath('/campanha/dobradinhas', 'page') +} diff --git a/src/components/campaign/people/PeopleAssessoradoCell.tsx b/src/components/campaign/people/PeopleAssessoradoCell.tsx new file mode 100644 index 00000000..8fdec92f --- /dev/null +++ b/src/components/campaign/people/PeopleAssessoradoCell.tsx @@ -0,0 +1,89 @@ +'use client' + +import { useCallback } from 'react' + +import type { RelationChipCellCopy } from '@/components/campaign/shared/RelationChipCell' +import { + RelationOptionCell, + type RelationCellOption, +} from '@/components/campaign/shared/RelationOptionCell' +import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' + +const ASSESSORADO_COPY: RelationChipCellCopy = { + searchPlaceholder: 'Buscar assessor…', + searchLabel: 'Buscar assessor', + suggestionsLabel: 'Sugestões de assessores', + emptyDrawerMessage: 'Nenhum assessor vinculado.', + savingMessage: 'Salvando assessores…', + savedMessage: 'Assessores salvos.', + removedMessage: (count: number) => + count === 1 ? 'Assessor removido.' : `${count} assessores removidos.`, +} + +type PeopleAssessoradoCellProps = { + /** The person (`contactID`) — the write is person-centric, not per entity. */ + ownerId: number | null + ownerName: string + /** Assigned advisors, names resolved server-side (union of the person's entities). */ + assessorados: Array<{ id: number; name: string }> + /** The addable staff catalog (`loadEligibleAdvisorOptions`). Empty when read-only. */ + options: RelationCellOption[] + commitAction: ( + state: CampaignFormActionState, + formData: FormData, + ) => Promise + /** + * Read-only for staff who may see but not assign (B156 rule): linked chips + * lose their href — `/campanha/assessores/[id]` is unrestricted-only. + */ + readOnly?: boolean +} + +/** + * C116 — the "Assessorado" column of `/campanha/pessoas`: the union of the + * person's advisor links (leadership + dobradinha), edited person-centrically — + * the commit writes the advisor delta on EVERY entity the person has, in one + * transaction. Same `RelationOptionCell` machine as the C99/B156 advisor cells, + * in the `quiet` transparent form. + */ +export const PeopleAssessoradoCell = ({ + ownerId, + ownerName, + assessorados, + options, + commitAction, + readOnly = false, +}: PeopleAssessoradoCellProps) => { + const buildFormData = useCallback( + (changedIds: number[], assigned: boolean) => { + const formData = new FormData() + formData.set('contactId', String(ownerId)) + formData.set('advisorId', String(changedIds[0])) + formData.set('assigned', assigned ? 'true' : 'false') + return formData + }, + [ownerId], + ) + + return ( + ({ + id: advisor.id, + label: advisor.name, + ...(readOnly ? {} : { href: `/campanha/assessores/${advisor.id}` }), + }))} + options={options} + buildFormData={buildFormData} + commitAction={commitAction} + copy={ASSESSORADO_COPY} + drawerTitle="Assessores da pessoa" + triggerLabel={`Editar assessores de ${ownerName}`} + updateErrorMessage="Não foi possível atualizar os assessores. Tente novamente." + readOnly={readOnly} + quiet + overflowToggleLabel={(count) => `+${count}`} + /> + ) +} diff --git a/src/components/campaign/people/PeopleMunicipalityCell.tsx b/src/components/campaign/people/PeopleMunicipalityCell.tsx new file mode 100644 index 00000000..b7d8f5b5 --- /dev/null +++ b/src/components/campaign/people/PeopleMunicipalityCell.tsx @@ -0,0 +1,112 @@ +'use client' + +import { useCallback, useRef, useState } from 'react' + +import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell' +import { + buildMunicipalityPortfolioChips, + type MunicipalityPortfolioIndexEntry, +} from '@/lib/municipalityPortfolio' +import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' + +type PeopleMunicipalityCellProps = { + /** The entity the write targets (`leadership.id`, `stateDeputy.id` or the staff account). */ + ownerId: number | null + /** Whose relation this is — spoken in the aria-labels and the Drawer. */ + ownerName: string + municipalityIds: number[] + municipalityIndex: readonly MunicipalityPortfolioIndexEntry[] + /** Ids the actor may ADD (administered carteira for an advisor). */ + addableIds?: ReadonlySet + /** Floor the relation enforces server-side — 1 for `leadership.municipalities`. */ + minItems?: number + /** Read-only chips (actor may see the relation but not edit it). */ + readOnly?: boolean + commitAction: ( + state: CampaignFormActionState, + formData: FormData, + ) => Promise + drawerTitle: string + updateErrorMessage: string +} + +/** + * C116 — the people-list municipality columns (Assessora, Lidera, Aliada em): + * the shared `MunicipalityPortfolioCell` in its `quiet` form (transparent, + * always-input paradigm) plus the batch-chip expansion state — clicking a + * territory / "Salvador (19)" chip expands it into its member municipalities + * IN PLACE, and leaving the cell with the mouse collapses complete batches + * again. Expansion is local presentation: it never touches the commit cycle. + */ +export const PeopleMunicipalityCell = ({ + ownerId, + ownerName, + municipalityIds, + municipalityIndex, + addableIds, + minItems, + readOnly = false, + commitAction, + drawerTitle, + updateErrorMessage, +}: PeopleMunicipalityCellProps) => { + const [expandedKeys, setExpandedKeys] = useState>(() => new Set()) + const idsRef = useRef(municipalityIds) + idsRef.current = municipalityIds + + const toggleExpand = useCallback((chipKey: string) => { + setExpandedKeys((current) => { + const next = new Set(current) + if (next.has(chipKey)) next.delete(chipKey) + else next.add(chipKey) + return next + }) + }, []) + + /** + * Leave-with-mouse collapses the expanded batches whose members are ALL + * still assigned ("se a pessoa ainda tiver todas as cidades do território, + * colapsa de novo"). A batch whose member was removed via its chip's X no + * longer exists as a complete group — the chip builder drops it, so the key + * falls out here and the members keep rendering individually. + */ + const handleMouseLeave = useCallback(() => { + setExpandedKeys((current) => { + if (current.size === 0) return current + const chips = buildMunicipalityPortfolioChips(idsRef.current, municipalityIndex) + const assigned = new Set(idsRef.current) + let changed = false + const next = new Set(current) + for (const key of current) { + const chip = chips.find((candidate) => candidate.key === key) + if (!chip || (chip.kind !== 'territory' && chip.kind !== 'city')) continue + if (chip.municipalityIds.every((id) => assigned.has(id))) { + next.delete(key) + changed = true + } + } + return changed ? next : current + }) + }, [municipalityIndex]) + + return ( +
+ `+${count}`} + readOnly={readOnly} + /> +
+ ) +} diff --git a/src/components/campaign/shared/CampaignInlineEditableCell.tsx b/src/components/campaign/shared/CampaignInlineEditableCell.tsx index 9d23ee13..e6bc9888 100644 --- a/src/components/campaign/shared/CampaignInlineEditableCell.tsx +++ b/src/components/campaign/shared/CampaignInlineEditableCell.tsx @@ -20,7 +20,7 @@ import type { CampaignFormActionState } from '@/utilities/campaignFormActionErro const SAVE_DEBOUNCE_MS = 500 -export type CampaignInlineEditableField = 'name' | 'email' | 'phone' | 'party' +export type CampaignInlineEditableField = 'name' | 'email' | 'phone' | 'party' | 'city' type CampaignInlineEditableCellProps = { recordId: number @@ -36,6 +36,15 @@ type CampaignInlineEditableCellProps = { editTrigger?: 'pencil' | 'cell' readBehavior?: 'text' | 'copy' saveOnChange?: boolean + /** + * C116 — "edit where you see": the input is ALWAYS mounted, borderless and + * tintless (no edit state, no label/border/background change). Saves on + * blur/Enter, Escape discards, empty shows a "—" placeholder. With `href` + + * `field === 'name'` the value renders as a LINK above the invisible input — + * the draft lives in the link's text, so clicking the link navigates and + * typing edits in the same place (mechanism locked at the C116 gate). + */ + permanent?: boolean className?: string } @@ -45,6 +54,17 @@ const inputTypeForField = (field: CampaignInlineEditableField): 'text' | 'email' return 'text' } +/** The discreet keyboard-focus indicator of the permanent mode (a11y guardrail). */ +const permanentFocusClassName = + 'rounded-sm focus-visible:ring-1 focus-visible:ring-ring/50 focus-visible:outline-none' + +/** + * Permanent cells display the phone already formatted — the raw 11 digits stay + * on the server, so the draft and the save baseline share the display. + */ +const displayDraftFor = (field: CampaignInlineEditableField, raw: string): string => + field === 'phone' && raw ? formatBrazilianPhoneInput(raw) : raw + export const CampaignInlineEditableCell = ({ recordId, recordIdField, @@ -56,6 +76,7 @@ export const CampaignInlineEditableCell = ({ editTrigger = 'pencil', readBehavior = 'text', saveOnChange = true, + permanent = false, className, }: CampaignInlineEditableCellProps) => { const router = useRouter() @@ -73,8 +94,9 @@ export const CampaignInlineEditableCell = ({ useEffect(() => { if (inputRef.current && document.activeElement === inputRef.current) return const next = value ?? '' - setDraft(next) - lastSaved.current = next.trim() + const display = displayDraftFor(field, next) + setDraft(display) + lastSaved.current = display.trim() }, [field, recordId, value]) useEffect(() => { @@ -176,6 +198,85 @@ export const CampaignInlineEditableCell = ({ startEditing() } + /** + * C116 — "edit where you see": input always mounted, no edit state. The + * name-link cell renders the LINK above an invisible input (the draft lives + * in the link's text, the input value is never the display — locked + * mechanism); every other field is a borderless input that reads as text. + */ + if (permanent) { + const isNameLink = href !== undefined && field === 'name' + const inputClassName = cn( + isNameLink + ? 'absolute inset-0 z-0 h-full w-full cursor-text border-transparent bg-transparent px-1 text-transparent caret-transparent shadow-none' + : 'min-h-10 w-full border-transparent bg-transparent px-1 shadow-none', + permanentFocusClassName, + ) + return ( +
+ {isNameLink ? ( + event.stopPropagation()} + > + {draft || value || ''} + + ) : null} + { + if (event.key === 'Escape') { + event.preventDefault() + setDraft(displayDraftFor(field, value ?? '')) + setErrorMessage(null) + setShowSavedFeedback(false) + // Blur saves the reverted draft — a no-op the equality check catches. + event.currentTarget.blur() + return + } + if (event.key === 'Enter') { + event.preventDefault() + event.currentTarget.blur() + } + }} + {...(field === 'phone' ? { inputMode: 'numeric' as const } : {})} + /> + {isPending ? ( + + ) : null} + {showSavedFeedback ? ( + + Salvo. + + ) : null} + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
+ ) + } + if (isEditing) { return (
diff --git a/src/components/campaign/shared/MunicipalityPortfolioCell.tsx b/src/components/campaign/shared/MunicipalityPortfolioCell.tsx index 06060796..654c1f36 100644 --- a/src/components/campaign/shared/MunicipalityPortfolioCell.tsx +++ b/src/components/campaign/shared/MunicipalityPortfolioCell.tsx @@ -11,6 +11,7 @@ import { } from '@/components/campaign/shared/RelationChipCell' import { buildMunicipalityPortfolioChips, + expandMunicipalityPortfolioChips, scopedPortfolioIndex, searchMunicipalityPortfolio, type MunicipalityPortfolioChip, @@ -26,7 +27,7 @@ const hitDescription = (hit: MunicipalityPortfolioSearchHit): string => { } const toRelationChip = (chip: MunicipalityPortfolioChip): RelationChip => - chip.kind === 'territory' + chip.kind === 'territory' || chip.kind === 'city' ? { key: chip.key, label: chip.label, @@ -76,6 +77,16 @@ type MunicipalityPortfolioCellProps = { ) => Promise drawerTitle: string updateErrorMessage: string + /** C116 quiet cell — see `RelationChipCell.quiet`. */ + quiet?: boolean + /** C116 expanded batch keys (territory / zone-city) — see `expandMunicipalityPortfolioChips`. */ + expandedKeys?: ReadonlySet + /** C116 — click on a collapsed batch chip (key like `territory:Irecê`), for the wrapper to expand it. */ + onChipClick?: (chipKey: string) => void + /** C116 — "+N" overflow label — see `RelationChipCell.overflowToggleLabel`. */ + overflowToggleLabel?: (hiddenCount: number) => string + /** C116 — read-only chips (actor may see the relation but not edit it). */ + readOnly?: boolean } /** @@ -97,6 +108,11 @@ export const MunicipalityPortfolioCell = ({ commitAction, drawerTitle, updateErrorMessage, + quiet = false, + expandedKeys, + onChipClick, + overflowToggleLabel, + readOnly = false, }: MunicipalityPortfolioCellProps) => { /** * `addableIds` also hides: an advisor's `canUpdateMunicipality` is scoped to @@ -124,8 +140,15 @@ export const MunicipalityPortfolioCell = ({ ) const buildChips = useCallback( - (ids: number[]) => buildMunicipalityPortfolioChips(ids, municipalityIndex).map(toRelationChip), - [municipalityIndex], + (ids: number[]) => { + const base = buildMunicipalityPortfolioChips(ids, municipalityIndex) + return ( + expandedKeys + ? expandMunicipalityPortfolioChips(base, expandedKeys, municipalityIndex) + : base + ).map(toRelationChip) + }, + [municipalityIndex, expandedKeys], ) const searchHits = useCallback( @@ -178,6 +201,10 @@ export const MunicipalityPortfolioCell = ({ triggerLabel={`Editar municípios de ${ownerName}`} updateErrorMessage={updateErrorMessage} copy={copy} + quiet={quiet} + overflowToggleLabel={overflowToggleLabel} + onChipClick={onChipClick ? (chip) => onChipClick(chip.key) : undefined} + readOnly={readOnly} /> ) } diff --git a/src/components/campaign/shared/RelationChipCell.tsx b/src/components/campaign/shared/RelationChipCell.tsx index a104491b..0acaa26f 100644 --- a/src/components/campaign/shared/RelationChipCell.tsx +++ b/src/components/campaign/shared/RelationChipCell.tsx @@ -166,6 +166,27 @@ type RelationChipCellProps = { * see the relation but not edit it (staff viewing `StateDeputy.advisors`). */ readOnly?: boolean + + // C116 quiet/expand surface ───────────────────────────────────────────────── + /** + * Transparent cell (C116 "edit where you see"): no hover/focus background + * tint — the cell looks like plain text. Keyboard focus keeps a discreet + * indicator on the search input only (accessibility guardrail), never a box + * or tint around the cell. + */ + quiet?: boolean + /** + * C116 — click on a link-less (batch) chip fires this instead of nothing, + * letting a wrapper expand the batch into its member chips in place. Chips + * WITH a link keep navigating. Only the inline row editor is wired: the + * coarse Drawer keeps its list with the permanent remove buttons. + */ + onChipClick?: (chip: RelationChip) => void + /** + * C116 — replaces the overflow toggle's collapsed label ("Ver mais…") with a + * "+N" chip label (e.g. "+3"), keeping the B170 measure/collapse contract. + */ + overflowToggleLabel?: (hiddenCount: number) => string } /** @@ -203,6 +224,9 @@ export const RelationChipCell = ({ createLabel, createErrorMessage = updateErrorMessage, readOnly = false, + quiet = false, + onChipClick, + overflowToggleLabel, }: RelationChipCellProps) => { const [query, setQuery] = useState('') const [open, setOpen] = useState(false) @@ -352,6 +376,18 @@ export const RelationChipCell = ({ : null const listOptions = createHit ? [...hits, createHit] : hits + /** + * C116 — the "typed text IS the match" rule: a suggestion whose normalized + * label exactly equals the query (e.g. "Feira de Santana"). Space or Enter + * then commits THAT suggestion instead of the ARIA pre-selected first hit — + * without it, "Salvador" + Enter would silently add "Salvador — ZE 1" while + * every zone matches at word start. The create option can never be the exact + * match (it only exists when there are no hits). + */ + const exactMatchOption = listOptions.find( + (option) => normalizeSearchPhrase(option.label) === normalizeSearchPhrase(trimmedQuery), + ) + const optionsKey = listOptions.map((option) => option.key).join('|') useEffect(() => { setActiveIndex(0) @@ -626,10 +662,10 @@ export const RelationChipCell = ({ setActiveIndex(event.key === 'Home' ? 0 : listOptions.length - 1) return } - if (event.key === 'Enter') { + if (event.key === 'Enter' || (event.key === ' ' && exactMatchOption)) { if (listOptions.length === 0) return event.preventDefault() - pickOption(activeIndex) + pickOption(exactMatchOption ? listOptions.indexOf(exactMatchOption) : activeIndex) return } if (event.key === 'Escape') { @@ -676,6 +712,16 @@ export const RelationChipCell = ({ ? copy.savedMessage : '' + /** + * C116 — the collapsed overflow toggle may render as a "+N" chip instead of + * the "Ver mais…" link. `visibleChipCount === null` means the first + * measurement is still in flight, when the toggle is invisible anyway. + */ + const collapsedOverflowLabel = + !showAllChips && overflowToggleLabel && visibleChipCount !== null + ? overflowToggleLabel(chips.length - visibleChipCount) + : null + /** * Mounted while measuring so its width is reserved on the last line, but kept * out of the tab order and the accessibility tree until the overflow it @@ -691,7 +737,10 @@ export const RelationChipCell = ({ aria-hidden={measuring || undefined} className={cn( // `min-h-6`: 24px is the SC 2.5.8 target floor for a text button. - 'inline-flex min-h-6 items-center px-1 text-xs font-medium text-primary underline-offset-4 hover:underline', + 'inline-flex min-h-6 items-center', + collapsedOverflowLabel + ? 'rounded-md border border-input px-1.5 text-xs font-normal text-muted-foreground tabular-nums hover:bg-muted/60' + : 'px-1 text-xs font-medium text-primary underline-offset-4 hover:underline', measuring && 'invisible pointer-events-none', )} onClick={(event) => { @@ -705,7 +754,7 @@ export const RelationChipCell = ({ } }} > - {showAllChips ? 'Ver menos' : 'Ver mais…'} + {showAllChips ? 'Ver menos' : (collapsedOverflowLabel ?? 'Ver mais…')} ) : null @@ -744,6 +793,22 @@ export const RelationChipCell = ({ {chipBody(chip)} + ) : onChipClick ? ( + /* C116 — a link-less batch chip becomes a button: the wrapper + expands/collapses it in place. `stopPropagation` keeps the cell + click (focus + suggestions) out of the way. */ + + + ) : ( {chipBody(chip)} @@ -953,7 +1018,12 @@ export const RelationChipCell = ({ // "nothing happened" until the next navigation proves otherwise. aria-busy={isPending || undefined} // Same box in both pointer modes so nothing reflows when the row is touched. - className="relative min-w-56 rounded-md border border-transparent p-1 outline-none pointer-fine:hover:bg-muted/40 pointer-fine:focus-within:bg-muted/40" + className={cn( + 'relative min-w-56 rounded-md border border-transparent p-1 outline-none', + // C116 quiet: no tint at all — the cell reads as plain text (the locked + // paradigm); keyboard focus keeps only the input's discreet ring. + !quiet && 'pointer-fine:hover:bg-muted/40 pointer-fine:focus-within:bg-muted/40', + )} // Reached only on a fine pointer: the coarse overlay below covers the cell // and stops the click, so the pointer policy stays in the media queries. onClick={(event) => { @@ -1018,7 +1088,12 @@ export const RelationChipCell = ({ onKeyDown={onSearchKeyDown} placeholder={chips.length ? 'Adicionar…' : copy.searchPlaceholder} aria-label={copy.searchLabel} - className="hidden min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground pointer-fine:block" + className={cn( + 'hidden min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground pointer-fine:block', + // C116 quiet: the ONLY focus indicator — a discreet ring on + // the input itself, never a box or tint around the cell. + quiet && 'rounded-sm focus-visible:ring-1 focus-visible:ring-ring/50', + )} /> )}
diff --git a/src/components/campaign/shared/RelationOptionCell.tsx b/src/components/campaign/shared/RelationOptionCell.tsx index ec83ed5d..53912bd7 100644 --- a/src/components/campaign/shared/RelationOptionCell.tsx +++ b/src/components/campaign/shared/RelationOptionCell.tsx @@ -44,6 +44,10 @@ type RelationOptionCellProps = { measureOverflow?: boolean /** Read-only rendering (B156): linked chips, no search/remove/Drawer affordances. */ readOnly?: boolean + /** C116 quiet cell — see `RelationChipCell.quiet`. */ + quiet?: boolean + /** C116 — "+N" overflow label — see `RelationChipCell.overflowToggleLabel`. */ + overflowToggleLabel?: (hiddenCount: number) => string } const itemLabel = (item: RelationCellItem): string => @@ -91,6 +95,8 @@ export const RelationOptionCell = ({ updateErrorMessage, measureOverflow = true, readOnly = false, + quiet = false, + overflowToggleLabel, }: RelationOptionCellProps) => { const itemById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items]) // Additions resolve through `options` (the addable catalog), same asymmetry @@ -149,6 +155,8 @@ export const RelationOptionCell = ({ copy={copy} measureOverflow={measureOverflow} readOnly={readOnly} + quiet={quiet} + overflowToggleLabel={overflowToggleLabel} /> ) } diff --git a/src/lib/municipalityPortfolio.ts b/src/lib/municipalityPortfolio.ts index 10744126..9b636205 100644 --- a/src/lib/municipalityPortfolio.ts +++ b/src/lib/municipalityPortfolio.ts @@ -4,6 +4,7 @@ import { getMunicipalityCatalogEntry, municipalityCatalog, municipalityCatalogEntriesForCity, + ZONE_MUNICIPALITY_CITIES, type MunicipalityCatalogEntry, } from '@/lib/municipalityCatalog' import { matchesNormalizedAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter' @@ -31,6 +32,7 @@ export type ResolvedPortfolioEntry = { id: number slug: string name: string + city: string region: BahiaIdentityTerritory } @@ -42,6 +44,14 @@ export type MunicipalityPortfolioChip = territory: BahiaIdentityTerritory municipalityIds: number[] } + | { + /** A zone-city (Salvador) with ALL of its zones assigned — "Salvador (19)". */ + kind: 'city' + key: string + label: string + city: string + municipalityIds: number[] + } | { kind: 'municipality' key: string @@ -97,6 +107,7 @@ type PortfolioIndexDerivations = { byId: Map idBySlug: Map idsByTerritory: Map + idsByCity: Map normalizedNames: Map } @@ -123,6 +134,7 @@ const portfolioIndexDerivations = ( byId: new Map(), idBySlug: new Map(), idsByTerritory: new Map(), + idsByCity: new Map(), normalizedNames: new Map(), } for (const entry of index) { @@ -137,6 +149,7 @@ const portfolioIndexDerivations = ( id: entry.id, slug: entry.slug, name: catalogEntry.name, + city: catalogEntry.city, region: catalogEntry.region, } derived.byId.set(resolved.id, resolved) @@ -145,6 +158,9 @@ const portfolioIndexDerivations = ( const territoryIds = derived.idsByTerritory.get(resolved.region) if (territoryIds) territoryIds.push(resolved.id) else derived.idsByTerritory.set(resolved.region, [resolved.id]) + const cityIds = derived.idsByCity.get(resolved.city) + if (cityIds) cityIds.push(resolved.id) + else derived.idsByCity.set(resolved.city, [resolved.id]) } derivationsByIndex.set(index, derived) @@ -208,7 +224,11 @@ const municipalityIdsForTseZone = (zoneNumber: number, idBySlug: Map { - const { byId, idsByTerritory } = portfolioIndexDerivations(index) + const { byId, idsByTerritory, idsByCity } = portfolioIndexDerivations(index) const remaining = new Set(assignedMunicipalityIds) const chips: MunicipalityPortfolioChip[] = [] + for (const city of ZONE_MUNICIPALITY_CITIES) { + const cityIds = idsByCity.get(city) + if (!cityIds || cityIds.length === 0) continue + // A zone-city group never fits in fewer ids than it has (19 for Salvador). + if (cityIds.length > remaining.size) continue + if (!cityIds.every((id) => remaining.has(id))) continue + + chips.push({ + kind: 'city', + key: `city:${city}`, + label: city, + city, + municipalityIds: [...cityIds], + }) + for (const id of cityIds) remaining.delete(id) + } + for (const territory of bahiaIdentityTerritories) { const territoryIds = idsByTerritory.get(territory) if (!territoryIds || territoryIds.length === 0) continue @@ -365,3 +402,35 @@ export const searchMunicipalityPortfolio = ( return hits } + +/** + * Replace expanded batch chips (territory / zone-city) with their member + * municipality chips, in place, keeping every other chip untouched. Pure + * presentation: nothing here writes — the members are real municipalities, so + * they carry the slug for the detail link and their own `ids` for removal. + */ +export const expandMunicipalityPortfolioChips = ( + chips: readonly MunicipalityPortfolioChip[], + expandedKeys: ReadonlySet, + index: readonly MunicipalityPortfolioIndexEntry[], +): MunicipalityPortfolioChip[] => { + if (expandedKeys.size === 0) return [...chips] + const { byId } = portfolioIndexDerivations(index) + return chips.flatMap((chip) => { + if (!expandedKeys.has(chip.key)) return [chip] + if (chip.kind !== 'territory' && chip.kind !== 'city') return [chip] + const members: MunicipalityPortfolioChip[] = [] + for (const id of chip.municipalityIds) { + const entry = byId.get(id) + if (!entry) continue + members.push({ + kind: 'municipality', + key: `municipality:${id}`, + label: entry.name, + municipalityId: id, + slug: entry.slug, + }) + } + return members + }) +} diff --git a/src/lib/schemas/contact.ts b/src/lib/schemas/contact.ts index 768fd0e4..c0fc35d3 100644 --- a/src/lib/schemas/contact.ts +++ b/src/lib/schemas/contact.ts @@ -81,6 +81,11 @@ export const contactFieldUpdateSchema = z.discriminatedUnion('field', [ field: z.literal('phones'), phones: contactPhonesSchema, }), + z.object({ + id: positiveRelationshipId, + field: z.literal('city'), + city: z.string().trim().min(2, 'Cidade inválida').max(100, 'Cidade muito longa'), + }), ]) export type ContactFieldUpdateInput = z.input diff --git a/src/lib/schemas/personCell.ts b/src/lib/schemas/personCell.ts new file mode 100644 index 00000000..7d2ec1ec --- /dev/null +++ b/src/lib/schemas/personCell.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' + +import { positiveRelationshipId } from '@/lib/schemas/primitives' + +/** + * C116 — cell-edit contracts of `/campanha/pessoas`: per-field Contact writes + * (via the shared `contactFieldUpdateSchema`), the Assessora carteira batch + * (unrestricted-only, person with exactly ONE staff account) and the + * person-centric Assessorado advisor delta. + */ + +export const personAssessoraMembershipSchema = z.object({ + contactId: positiveRelationshipId, + municipalityIds: z.array(positiveRelationshipId).min(1), + assigned: z.boolean(), +}) + +export const personAdvisorMembershipSchema = z.object({ + contactId: positiveRelationshipId, + advisorId: positiveRelationshipId, + assigned: z.boolean(), +}) + +export type PersonAssessoraMembershipInput = z.input +export type PersonAdvisorMembershipInput = z.input + +/** Refusal messages shared verbatim between the server action and the cell. */ +export const PERSON_CELL_STAFF_MESSAGE = + 'Somente a coordenação e o candidato podem editar essa pessoa.' +export const PERSON_CELL_NOT_IN_SCOPE_MESSAGE = 'Esta pessoa não está no seu escopo de edição.' +export const PERSON_CONTACT_INVALID_MESSAGE = 'Esta pessoa não tem ficha de contato.' +export const PERSON_ASSESSORA_UNRESTRICTED_MESSAGE = + 'Somente a coordenação e o candidato podem editar a carteira de assessores.' +export const PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE = + 'Esta pessoa não tem perfil de assessor para vincular municípios.' +export const PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE = + 'Esta pessoa tem mais de um perfil de assessor; edite os municípios por perfil no admin.' +export const PERSON_ADVISORS_UNRESTRICTED_MESSAGE = + 'Somente a coordenação e o candidato podem editar os assessores.' diff --git a/src/utilities/people/peopleData.ts b/src/utilities/people/peopleData.ts index c8edfeaa..c6fa3c60 100644 --- a/src/utilities/people/peopleData.ts +++ b/src/utilities/people/peopleData.ts @@ -62,6 +62,8 @@ export type PeopleRowViewModel = { capacityMunicipalityIDs: number[] /** Union of leadership + dobradinha advisors, names resolved (Assessorado). */ assessoradoNames: string[] + /** Union of leadership + dobradinha advisors as id+name pairs (Assessorado chips, C116). */ + assessorados: Array<{ id: number; name: string }> } /** Merge intermediate: the ids the loader resolves into `assessoradoNames`. */ @@ -146,6 +148,7 @@ export const mergePeopleSources = (sources: PeopleMergeSources): MergedPerson[] staff: [], assessoraMunicipalityIDs: [], capacityMunicipalityIDs: [], + assessorados: [], } byContact.set(contactID, person) return person @@ -444,13 +447,11 @@ export const loadPeopleListPageData = async ( const advisorNames = await loadCampaignUserNamesByIds(payload, [...advisorIDs]) const rows: PeopleRowViewModel[] = pageRows.map((person) => { - const assessoradoNames = [ - ...new Set( - [...person.leadershipAdvisorIDs, ...person.deputyAdvisorIDs] - .map((id) => advisorNames.get(id)) - .filter((name): name is string => name !== undefined), - ), - ].sort((left, right) => left.localeCompare(right, 'pt-BR')) + const advisorIDs = [...new Set([...person.leadershipAdvisorIDs, ...person.deputyAdvisorIDs])] + const assessorados = advisorIDs + .map((id) => ({ id, name: advisorNames.get(id) })) + .filter((pair): pair is { id: number; name: string } => pair.name !== undefined) + .sort((left, right) => left.name.localeCompare(right.name, 'pt-BR')) return { contactID: person.contactID, name: person.name, @@ -467,7 +468,8 @@ export const loadPeopleListPageData = async ( staff: person.staff, assessoraMunicipalityIDs: person.assessoraMunicipalityIDs, capacityMunicipalityIDs: person.capacityMunicipalityIDs, - assessoradoNames, + assessoradoNames: assessorados.map((pair) => pair.name), + assessorados, } }) diff --git a/tests/int/campaignPersonCellEditing.int.spec.ts b/tests/int/campaignPersonCellEditing.int.spec.ts new file mode 100644 index 00000000..406a4130 --- /dev/null +++ b/tests/int/campaignPersonCellEditing.int.spec.ts @@ -0,0 +1,307 @@ +// @vitest-environment node + +import type { Payload } from 'payload' +import { getPayload } from 'payload' +import { beforeAll, describe, expect, it } from 'vitest' + +import { + setPersonAdvisorMembershipRecord, + setPersonAssessoraMembershipRecord, + updatePersonContactRecord, +} from '@/app/(campaign)/campanha/actions/person' +import { uniqueRelationshipIds } from '@/lib/relationship' +import { + PERSON_ADVISORS_UNRESTRICTED_MESSAGE, + PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE, + PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE, + PERSON_ASSESSORA_UNRESTRICTED_MESSAGE, + PERSON_CELL_NOT_IN_SCOPE_MESSAGE, + PERSON_CELL_STAFF_MESSAGE, +} from '@/lib/schemas/personCell' +import config from '@/payload.config' + +import { installCampaignFixtures } from '../helpers/campaignFixtures' + +let payload: Payload +const campaignFixtures = installCampaignFixtures({ + getPayload: () => payload, + setPayload: (nextPayload) => { + payload = nextPayload + }, +}) + +describe('C116 — cell edits of the people list', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + describe('updatePersonContactRecord', () => { + it('lets a coordinator edit name/email/city of a leadership-anchored person', async () => { + const fixtures = campaignFixtures() + const actor = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.getMunicipality() + const contact = await fixtures.createContact({ city: 'Feira de Santana' }) + await fixtures.createLeadership({ contact, municipalities: [municipality] }) + + await updatePersonContactRecord(payload, actor, { + id: contact.id, + field: 'name', + name: 'Maria Nova da Silva', + }) + await updatePersonContactRecord(payload, actor, { + id: contact.id, + field: 'city', + city: 'Salvador', + }) + await updatePersonContactRecord(payload, actor, { + id: contact.id, + field: 'phone', + phone: '71999998888', + }) + + const updated = await payload.findByID({ + collection: 'contact', + id: contact.id, + depth: 0, + overrideAccess: true, + }) + expect(updated.name).toBe('Maria Nova da Silva') + expect(updated.city).toBe('Salvador') + expect(updated.phone).toBe('71999998888') + }) + + it('lets an advisor edit a person anchored in his carteira', async () => { + const fixtures = campaignFixtures() + const advisor = await fixtures.createCampaignUser('advisor') + const municipality = await fixtures.getMunicipality() + await fixtures.assignMunicipalityAdvisors(municipality, [advisor]) + const contact = await fixtures.createContact() + await fixtures.createLeadership({ contact, municipalities: [municipality] }) + + await updatePersonContactRecord(payload, advisor, { + id: contact.id, + field: 'name', + name: 'Assessorada na Carteira', + }) + + const updated = await payload.findByID({ + collection: 'contact', + id: contact.id, + depth: 0, + overrideAccess: true, + }) + expect(updated.name).toBe('Assessorada na Carteira') + }) + + it('denies an advisor a person whose entities are outside his carteira', async () => { + const fixtures = campaignFixtures() + const advisor = await fixtures.createCampaignUser('advisor') + const outside = await fixtures.getMunicipality() + const contact = await fixtures.createContact() + await fixtures.createLeadership({ contact, municipalities: [outside] }) + + await expect( + updatePersonContactRecord(payload, advisor, { + id: contact.id, + field: 'name', + name: 'Fora da Carteira', + }), + ).rejects.toThrow(PERSON_CELL_NOT_IN_SCOPE_MESSAGE) + }) + + it('denies an advisor a staff-only person (no leadership/dobradinha anchor)', async () => { + const fixtures = campaignFixtures() + const advisor = await fixtures.createCampaignUser('advisor') + const contact = await fixtures.createContact() + // The identity hook links the account to the existing ficha by phone. + await fixtures.createCampaignUser('advisor', { phone: contact.phone }) + + await expect( + updatePersonContactRecord(payload, advisor, { + id: contact.id, + field: 'email', + email: 'staff.only@example.com', + }), + ).rejects.toThrow(PERSON_CELL_NOT_IN_SCOPE_MESSAGE) + }) + + it('denies a leader actor', async () => { + const fixtures = campaignFixtures() + const leader = await fixtures.createCampaignUser('leader') + const municipality = await fixtures.getMunicipality() + const contact = await fixtures.createContact() + await fixtures.createLeadership({ contact, municipalities: [municipality] }) + + await expect( + updatePersonContactRecord(payload, leader, { + id: contact.id, + field: 'name', + name: 'Líder não edita', + }), + ).rejects.toThrow(PERSON_CELL_STAFF_MESSAGE) + }) + }) + + describe('setPersonAssessoraMembershipRecord', () => { + it('lets a coordinator toggle the single account of a person on municipalities', async () => { + const fixtures = campaignFixtures() + const actor = await fixtures.createCampaignUser('coordinator') + const contact = await fixtures.createContact() + // The identity hook links the account to the existing ficha by phone. + const account = await fixtures.createCampaignUser('advisor', { phone: contact.phone }) + const first = await fixtures.getMunicipality() + const second = await fixtures.getMunicipality() + + await setPersonAssessoraMembershipRecord(payload, actor, { + contactId: contact.id, + municipalityIds: [first.id, second.id], + assigned: true, + }) + + for (const municipality of [first, second]) { + const updated = await payload.findByID({ + collection: 'municipality', + id: municipality.id, + depth: 0, + select: { advisors: true }, + overrideAccess: true, + }) + expect(uniqueRelationshipIds(updated.advisors)).toContain(account.id) + } + + await setPersonAssessoraMembershipRecord(payload, actor, { + contactId: contact.id, + municipalityIds: [first.id], + assigned: false, + }) + const updatedFirst = await payload.findByID({ + collection: 'municipality', + id: first.id, + depth: 0, + select: { advisors: true }, + overrideAccess: true, + }) + expect(uniqueRelationshipIds(updatedFirst.advisors)).not.toContain(account.id) + }) + + it('refuses a person with more than one staff account', async () => { + const fixtures = campaignFixtures() + const actor = await fixtures.createCampaignUser('coordinator') + const contact = await fixtures.createContact() + await fixtures.createCampaignUser('advisor', { phone: contact.phone }) + await fixtures.createCampaignUser('advisor', { phone: contact.phone }) + const municipality = await fixtures.getMunicipality() + + await expect( + setPersonAssessoraMembershipRecord(payload, actor, { + contactId: contact.id, + municipalityIds: [municipality.id], + assigned: true, + }), + ).rejects.toThrow(PERSON_ASSESSORA_MULTI_ACCOUNT_MESSAGE) + }) + + it('refuses a person with no staff account', async () => { + const fixtures = campaignFixtures() + const actor = await fixtures.createCampaignUser('coordinator') + const contact = await fixtures.createContact() + const municipality = await fixtures.getMunicipality() + + await expect( + setPersonAssessoraMembershipRecord(payload, actor, { + contactId: contact.id, + municipalityIds: [municipality.id], + assigned: true, + }), + ).rejects.toThrow(PERSON_ASSESSORA_NO_ACCOUNT_MESSAGE) + }) + + it('denies an advisor actor', async () => { + const fixtures = campaignFixtures() + const advisor = await fixtures.createCampaignUser('advisor') + const contact = await fixtures.createContact() + const municipality = await fixtures.getMunicipality() + + await expect( + setPersonAssessoraMembershipRecord(payload, advisor, { + contactId: contact.id, + municipalityIds: [municipality.id], + assigned: true, + }), + ).rejects.toThrow(PERSON_ASSESSORA_UNRESTRICTED_MESSAGE) + }) + }) + + describe('setPersonAdvisorMembershipRecord', () => { + it('writes the advisor delta on EVERY entity of the person (leadership + dobradinha)', async () => { + const fixtures = campaignFixtures() + const actor = await fixtures.createCampaignUser('coordinator') + const contact = await fixtures.createContact() + const municipality = await fixtures.getMunicipality() + await fixtures.createLeadership({ contact, municipalities: [municipality] }) + await fixtures.createStateDeputy({ contact }) + const advisor = await fixtures.createCampaignUser('advisor') + + await setPersonAdvisorMembershipRecord(payload, actor, { + contactId: contact.id, + advisorId: advisor.id, + assigned: true, + }) + + const leadership = await payload.find({ + collection: 'leadership', + where: { contact: { equals: contact.id } }, + depth: 0, + pagination: false, + overrideAccess: true, + }) + const deputy = await payload.find({ + collection: 'stateDeputy', + where: { contact: { equals: contact.id } }, + depth: 0, + pagination: false, + overrideAccess: true, + }) + expect(uniqueRelationshipIds(leadership.docs[0].advisors)).toContain(advisor.id) + expect(uniqueRelationshipIds(deputy.docs[0].advisors)).toContain(advisor.id) + + await setPersonAdvisorMembershipRecord(payload, actor, { + contactId: contact.id, + advisorId: advisor.id, + assigned: false, + }) + const leadershipAfter = await payload.find({ + collection: 'leadership', + where: { contact: { equals: contact.id } }, + depth: 0, + pagination: false, + overrideAccess: true, + }) + const deputyAfter = await payload.find({ + collection: 'stateDeputy', + where: { contact: { equals: contact.id } }, + depth: 0, + pagination: false, + overrideAccess: true, + }) + expect(uniqueRelationshipIds(leadershipAfter.docs[0].advisors)).not.toContain(advisor.id) + expect(uniqueRelationshipIds(deputyAfter.docs[0].advisors)).not.toContain(advisor.id) + }) + + it('denies an advisor actor', async () => { + const fixtures = campaignFixtures() + const advisor = await fixtures.createCampaignUser('advisor') + const contact = await fixtures.createContact() + const municipality = await fixtures.getMunicipality() + await fixtures.createLeadership({ contact, municipalities: [municipality] }) + + await expect( + setPersonAdvisorMembershipRecord(payload, advisor, { + contactId: contact.id, + advisorId: advisor.id, + assigned: true, + }), + ).rejects.toThrow(PERSON_ADVISORS_UNRESTRICTED_MESSAGE) + }) + }) +}) diff --git a/tests/unit/municipalityPortfolio.unit.spec.ts b/tests/unit/municipalityPortfolio.unit.spec.ts index 8d7d8f20..94265f61 100644 --- a/tests/unit/municipalityPortfolio.unit.spec.ts +++ b/tests/unit/municipalityPortfolio.unit.spec.ts @@ -5,6 +5,7 @@ import { municipalityCatalog } from '@/lib/municipalityCatalog' import { buildMunicipalityPortfolioChips, catalogEntriesForTseZone, + expandMunicipalityPortfolioChips, searchMunicipalityPortfolio, type MunicipalityPortfolioIndexEntry, } from '@/lib/municipalityPortfolio' @@ -47,6 +48,73 @@ describe('municipalityPortfolio', () => { ]) }) + it('collapses the complete Salvador zone set into one "Salvador (N)" chip', () => { + const index = buildIndex() + const salvadorZones = slugsForCities(['Salvador']) + const assignedIds = index + .filter((entry) => salvadorZones.has(entry.slug)) + .map((entry) => entry.id) + + const chips = buildMunicipalityPortfolioChips(assignedIds, index) + expect(chips).toHaveLength(1) + expect(chips[0]).toMatchObject({ kind: 'city', label: 'Salvador' }) + expect(chips[0]?.kind === 'city' ? chips[0].municipalityIds.length : 0).toBe(19) + }) + + it('keeps a partial Salvador zone set as municipality chips (never the city chip)', () => { + const index = buildIndex() + const salvadorZones = slugsForCities(['Salvador']) + const one = index.find((entry) => salvadorZones.has(entry.slug)) + expect(one).toBeTruthy() + const chips = buildMunicipalityPortfolioChips([one!.id], index) + expect(chips).toHaveLength(1) + expect(chips[0]).toMatchObject({ kind: 'municipality', municipalityId: one!.id }) + }) + + it('expands a collapsed territory chip into its member municipality chips', () => { + const index = buildIndex() + const irece = slugsForCities(citiesForTerritory('Irecê')) + const assignedIds = index.filter((entry) => irece.has(entry.slug)).map((entry) => entry.id) + const collapsed = buildMunicipalityPortfolioChips(assignedIds, index) + expect(collapsed).toHaveLength(1) + expect(collapsed[0]?.kind).toBe('territory') + + const expanded = expandMunicipalityPortfolioChips( + collapsed, + new Set(['territory:Irecê']), + index, + ) + expect(expanded).toHaveLength(assignedIds.length) + const municipalityChips = expanded.filter((chip) => chip.kind === 'municipality') + expect(municipalityChips).toHaveLength(expanded.length) + expect(new Set(municipalityChips.map((chip) => chip.municipalityId))).toEqual( + new Set(assignedIds), + ) + }) + + it('expands the Salvador city chip into its 19 zone municipality chips', () => { + const index = buildIndex() + const salvadorZones = slugsForCities(['Salvador']) + const assignedIds = index + .filter((entry) => salvadorZones.has(entry.slug)) + .map((entry) => entry.id) + const collapsed = buildMunicipalityPortfolioChips(assignedIds, index) + + const expanded = expandMunicipalityPortfolioChips(collapsed, new Set(['city:Salvador']), index) + expect(expanded).toHaveLength(19) + expect(expanded.every((chip) => chip.kind === 'municipality')).toBe(true) + }) + + it('leaves non-expanded chips untouched', () => { + const index = buildIndex() + const irece = slugsForCities(citiesForTerritory('Irecê')) + const assignedIds = index.filter((entry) => irece.has(entry.slug)).map((entry) => entry.id) + const collapsed = buildMunicipalityPortfolioChips(assignedIds, index) + + const unchanged = expandMunicipalityPortfolioChips(collapsed, new Set(['city:Salvador']), index) + expect(unchanged).toEqual(collapsed) + }) + it('drops an id the index does not know (no name, no link to render)', () => { const index = buildIndex() expect(buildMunicipalityPortfolioChips([999_999], index)).toEqual([]) diff --git a/tests/unit/sharedSheetHostConventions.unit.spec.ts b/tests/unit/sharedSheetHostConventions.unit.spec.ts index de42e059..101cf27e 100644 --- a/tests/unit/sharedSheetHostConventions.unit.spec.ts +++ b/tests/unit/sharedSheetHostConventions.unit.spec.ts @@ -13,6 +13,14 @@ import { describe, expect, it } from 'vitest' describe('shared sheet host for chip-relation cells (miss #52)', () => { const componentRoots = ['src/components', 'src/app'] as const const chipCellUsage = / { const offenders: string[] = [] @@ -29,6 +37,7 @@ describe('shared sheet host for chip-relation cells (miss #52)', () => { const source = readFileSync(file, 'utf8') if (!chipCellUsage.test(source)) continue if (source.includes('CampaignListSheetProvider')) continue + if (cellWrapperFiles.has(relative(process.cwd(), file))) continue offenders.push(relative(process.cwd(), file)) } } From 04d9940317641b1f9a45a6095350b3075710fe2e Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 14:01:01 -0300 Subject: [PATCH 13/14] fix(C116): editor de Contato convive com o shape phones do C112 (primary phone via reorderWithPrimaryPhone) --- src/app/(campaign)/campanha/actions/person.ts | 24 +++++++++++++++++-- .../int/campaignPersonCellEditing.int.spec.ts | 4 +++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/app/(campaign)/campanha/actions/person.ts b/src/app/(campaign)/campanha/actions/person.ts index 89d79498..a2a6283a 100644 --- a/src/app/(campaign)/campanha/actions/person.ts +++ b/src/app/(campaign)/campanha/actions/person.ts @@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache' import { toggleMunicipalityAdvisorMembership } from '@/app/(campaign)/campanha/actions/municipality' import { nextLeadershipAdvisorIdsAfterMembership } from '@/lib/leadershipAdvisorMembership' +import { reorderWithPrimaryPhone } from '@/lib/phone' import { uniqueRelationshipIds } from '@/lib/relationship' import { contactFieldUpdateSchema, type ContactFieldUpdateInput } from '@/lib/schemas/contact' import { @@ -123,13 +124,32 @@ export const updatePersonContactRecord = async ( await assertPersonContactEditable(payload, currentActor, data.id, req) - const contactData: Partial> = {} + const contactData: Partial> & { + 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') { - contactData.phone = data.phone + // Inline cell edit = set the PRIMARY phone, keeping the rest of the + // list untouched (C112 shape — same contract as the leadership and + // dobradinha siblings): the new number goes first, clearing removes + // the primary and the rest shifts up. + const current = await payload.findByID({ + collection: 'contact', + id: data.id, + depth: 0, + select: { phones: true }, + // Intentional bypass: the scope check above established the actor's + // right over the person's ficha; this read only resolves the current + // primary-phone list to reorder. + overrideAccess: true, + req, + }) + contactData.phones = reorderWithPrimaryPhone(current.phones, data.phone).map((value) => ({ + value, + })) } else if (data.field === 'city') { contactData.city = data.city } diff --git a/tests/int/campaignPersonCellEditing.int.spec.ts b/tests/int/campaignPersonCellEditing.int.spec.ts index 406a4130..f8a1dfda 100644 --- a/tests/int/campaignPersonCellEditing.int.spec.ts +++ b/tests/int/campaignPersonCellEditing.int.spec.ts @@ -9,6 +9,7 @@ import { setPersonAssessoraMembershipRecord, updatePersonContactRecord, } from '@/app/(campaign)/campanha/actions/person' +import { primaryPhoneOf } from '@/lib/phone' import { uniqueRelationshipIds } from '@/lib/relationship' import { PERSON_ADVISORS_UNRESTRICTED_MESSAGE, @@ -67,7 +68,8 @@ describe('C116 — cell edits of the people list', () => { }) expect(updated.name).toBe('Maria Nova da Silva') expect(updated.city).toBe('Salvador') - expect(updated.phone).toBe('71999998888') + // C112 shape: the cell edit set the PRIMARY phone, the ficha list carries it first. + expect(primaryPhoneOf(updated.phones)).toBe('71999998888') }) it('lets an advisor edit a person anchored in his carteira', async () => { From 2ef0d38dd9fcfaec821d8de6fefb9c4b51ed7706 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 15:24:33 -0300 Subject: [PATCH 14/14] =?UTF-8?q?plans(OPS40):=20PORT=20do=20.env.local=20?= =?UTF-8?q?n=C3=A3o=20=C3=A9=20honrado=20pelo=20next=20dev=20no=20worktree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/worktree-dev-port-nao-honrado.md | 43 +++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/plans/worktree-dev-port-nao-honrado.md diff --git a/docs/plans/worktree-dev-port-nao-honrado.md b/docs/plans/worktree-dev-port-nao-honrado.md new file mode 100644 index 00000000..75c7c57c --- /dev/null +++ b/docs/plans/worktree-dev-port-nao-honrado.md @@ -0,0 +1,43 @@ +# OPS40 — worktree dev: `PORT` do `.env.local` não é honrado pelo `next dev` + +Status: rascunho +Atualizado em: 2026-08-11 +Issue: (OPS40 — a registrar) +Priority: P2 +Model: composer-2.5 +Impeccable: A — sem UI +Appetite: ~2–4h eng; um outcome verificável — dois worktrees em `pnpm dev` simultâneos não colidem na porta + +## Intenção + +O contrato de ambientes paralelos (OPS24/OPS26/OPS28, AGENTS.md "Per-worktree environments") promete um dev-server por worktree em `3100+slot` (ex.: C116 → 3216). O provisionador escreve `PORT=` no `.env.local` do worktree — mas **`next dev` (Next 15.4.11) não honra `PORT` vindo de arquivo `.env`**: o startup log mostra "Local: http://localhost:3000" com `PORT=3216` presente no `.env.local` (evidência colhida na sessão do C116, 2026-08-11). Consequência: dois agentes com `pnpm dev` rodando colidem na porta 3000 (ou um rouba a porta do outro), quebrando o isolamento que o modelo paralelo vende. O e2e escapa do defeito porque o `playwright.config.ts` injeta `PORT` **explicitamente no env do webServer** — o caminho manual (`pnpm dev`) é o exposto. + +## Objetivo e aceite + +- `pnpm dev` num worktree provisionado sobe em `3100+slot` (a porta do `.env.local`), não em 3000. +- Dois worktrees com `pnpm dev` simultâneos não colidem (cada um na sua porta). +- O e2e (webServer com `PORT` explícito) e o main repo (`pnpm dev` em 3000) ficam intactos. +- O mecanismo escolhido não depende de o usuário lembrar `-p `. + +## Direção no codebase (hipótese) + +- `package.json` script `dev` (`node scripts/guard-dev-db.mjs && cross-env NODE_OPTIONS=--no-deprecation next dev`) — o script pode derivar a porta do `.env.local` e passar `next dev -p ` (ou ler `process.env.PORT` — o cross-env repassa env do processo; o problema é o `next dev` ignorar env-file, então a porta precisa ir como **flag CLI**). +- Candidatos: (a) wrapper `scripts/dev.mjs` que lê `PORT` do `.env.local` (ou `process.env.PORT` via dotenv) e executa `next dev -p `; (b) trocar `cross-env ... next dev` por `next dev -p "$PORT"` — mas `PORT` do arquivo `.env.local` não está no shell — precisaria de carga explícita; (c) o próprio Next respeitar `PORT` de env-file (upstream — não controlável). +- Verificar se o prewarm/setup e2e (que também usa `pnpm dev` como webServer command em dev-mode) não depende do comportamento atual. + +## Fora de escopo + +- Mudar como o Next resolve porta (upstream). +- Re-provisionar worktrees existentes (o `.env.local` já tem a porta certa; só o dev server a ignora). + +## Rabbit holes + +- Carregar `.env.local` duas vezes (guard + script) com ordens conflitantes — o wrapper deve ler a MESMA fonte que o provisionador escreveu (variável `PORT` do arquivo). +- Quebrar o e2e dev-mode: o webServer passa `PORT` no env do processo — se o script novo der prioridade ao `.env.local` sobre o env do processo, o e2e perderia a porta forçada. O wrapper deve dar prioridade ao `process.env.PORT` real (o que o webServer injeta) e cair no `.env.local` só quando ausente. + +## Referências + +- `scripts/lib/worktree-env.mjs` (escreve `PORT=${env.devPort}` no `.env.local`) +- `scripts/guard-dev-db.mjs` (preflight do `pnpm dev`) +- `playwright.config.ts` webServer (`env: { PORT: webServerPort }` — precedente do env explícito) +- Evidência da sessão C116: log `next dev` com "Local: http://localhost:3000" e `.env.local` com `PORT=3216`