mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 17:43:04 +00:00
chore(OPS36): journeys com sessão compartilhada nos e2e pesados — seed do campaign-token via payload.login sem login de browser
- Sessão por arquivo: coordinator/leader compartilhados por worker (ownership própria, cleanup no afterAll); beforeEach semeia o cookie campaign-token no context fresh de cada teste; advisors permanecem por teste (OPS29/B176) - Premissa da intenção corrigida por medição: getPayload é memoizado por worker (364 ms cold, 0 ms warm); o overhead real era login de browser (3.6 s quente / 30.3 s 1º hit vs 112-134 ms de seed) + criação de usuário - Sem mode: 'serial' (divergência declarada): seed por cookie dispensa o reuso de contexto e evita skips em cascata no relatório - Endurece 7 interações client-side com toPass (classe B13/B17 exposta pela remoção do warm-up do login) e 4 lookups do strip de ações com .first() (duplicado transitório RSC) - Medição antes/depois registrada no CHANGELOG; débito OPS39 (prewarm de home-search/mudar-tendencia) registrado com depends no OPS30
This commit is contained in:
commit
a0fe13c3d1
85 changed files with 2970 additions and 918 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
86
docs/plans/changelog-agents-truncado-ops35-ops33-impl.md
Normal file
86
docs/plans/changelog-agents-truncado-ops35-ops33-impl.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Impl: OPS38 — Changelog-AGENTS truncado em main (restaurar do histórico)
|
||||
|
||||
Status: aprovado (gate humano 2026-08-10; segunda passada 2026-08-11)
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #629
|
||||
Intenção: body da Issue (sem plano linkado — body é a spec)
|
||||
Appetite restante: ~0,25 dia eng (docs-only; exploração já consumida, execução é minutos)
|
||||
|
||||
## Segunda passada (2026-08-11) — re-truncamento parcial no squash do PR #646
|
||||
|
||||
O PR #646 (primeira restauração) foi squash-merged sobre um main que **avançou** com 4 merges em voo que tocavam o CHANGELOG — push (#634, `e75148f3`), OPS37 (#624, `e86331fe`), OPS35+ (#638, `02a33f1b`), B197 (#639, `419ba39e`). O GitHub squash resolveu o conflito do arquivo **reproduzindo o mesmo defeito da Issue**: o topo do arquivo ficou com as 8 entradas pós-base (B197, OPS35+, push, OPS37, D9, B195, OPS33, OPS35) **fora da ordem canônica de merge** e o **header do arquivo sumiu** (título + descrição + `---`).
|
||||
|
||||
- **Verificação pós-merge:** 128 entradas presentes (120 base + 8), todas byte-idênticas às fontes (md5), base intacta (diff de entradas OPS28→fim = vazio) — mas header: 0 e ordem do topo: Push, OPS37, OPS35+, B197, D9, B195, OPS33, OPS35 (errada; B197 é a mais nova).
|
||||
- **Correção desta passada:** reconstruir o arquivo = header da base boa (`0b520fdb`) + 8 entradas na **ordem canônica de merge** (mais nova primeiro: B197 → OPS35+ → Push → OPS37 → D9 → B195 → OPS33 → OPS35) + base intacta (OPS28 em diante). Verificação: `git diff 0b520fdb` = **+8 entradas, −0**; 128 entradas; md5 das 8 vs fontes OK; `format:check` OK; `gate:fast` OK.
|
||||
- **Aprendizado (reforça a regra da Issue):** squash-merge de branch que reescreve um arquivo sobre um main que avançou também clobbera — conferir o arquivo no main **depois** do merge, não só no PR.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** `docs/CHANGELOG-AGENTS.md` volta ao main com TODAS as entradas (as ~113 clobberadas restauradas do histórico + as 4 escritas depois do truncamento re-anexadas), sem duplicar nem alterar texto (fonte: git); branches em voo que editam o arquivo coordenadas (OPS37 #624, OPS30 #615).
|
||||
- **O que NÃO negociar:** texto canônico vindo do git (nunca redigitar); uma entrada por entrega; sem reordenar/reorganizar o arquivo; sem editar Issues in-progress.
|
||||
- **O que reavaliar:** a letra da Issue ("re-anexar OPS35/OPS33/OPS34/OPS28") está **desatualizada/parcial** — OPS34 e OPS28 **nunca saíram** do arquivo (estão na base boa, byte-idênticas); as entradas realmente perdidas no processo são **OPS35, OPS33, B195 e D9** (escritas após o truncamento, presentes no arquivo truncado atual). A fonte única de verdade é o inventário git abaixo.
|
||||
|
||||
## Caracterização (inventário verificado no git)
|
||||
|
||||
- **Base boa (última íntegra):** `0b520fdb` (merge OPS28, PR #614) — **246 linhas, 120 entradas**. Byte-idêntica a `0047d263` (commit OPS28) e a `07479f17^` (pai do merge OPS35).
|
||||
- **Truncamento 1 (o evento):** merge `07479f17` (OPS35, PR #621) — a branch OPS35 (`1f31d1b6`, baseada em `b4278f16` que tinha as 246 linhas) sobrescreveu o arquivo com uma cópia de **10 linhas** (só OPS28, OPS34, OPS35) na resolução de conflito → 246 → 10.
|
||||
- **Truncamento 2:** merge `f460dac6` (OPS33, PR #623) — a branch OPS33 (`f0f5b73f`) tinha o arquivo **completo (248 linhas)**; a resolução do merge descartou o lado da branch e manteve a cópia truncada de main + entrada OPS33 → 12 linhas.
|
||||
- **Depois:** B195 (merge `a2d02227`, PR #622) → 14 linhas; D9 (merge `48707d0b`, PR #630, atual main) → 18 linhas.
|
||||
- **Estado atual de main (18 linhas, 7 entradas):** B195, OPS33, D9, B193, OPS28, OPS34, OPS35.
|
||||
- **B193/OPS28/OPS34** já estão na base boa **byte-idênticos** (md5 verificado) → NÃO duplicar.
|
||||
- **B195/OPS33/D9/OPS35** são as 4 entradas pós-truncamento a re-anexar (md5 verificados byte-idênticos às fontes de branch: OPS35 = `1f31d1b6`, OPS33 = `f0f5b73f`).
|
||||
- **Ordem canônica do arquivo:** mais nova no topo (verificado: base boa começa com OPS28 #614, depois OPS34 #613 — ordem de merges). Ordem de merge das 4 re-anexadas (mais nova primeiro): **D9 (#630) → B195 (#622) → OPS33 (#623) → OPS35 (#621)**, acima da base boa (que começa em OPS28 #614).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Base boa 0b520fdb: 246 linhas, 120 entradas] --> D[+4 entradas re-anexadas no topo<br/>D9, B195, OPS33, OPS35]
|
||||
B[4 entradas extraídas verbatim<br/>do main atual (md5 vs fontes OK)] --> D
|
||||
D --> E[diff vs 0b520fdb = só as 4 linhas novas]
|
||||
E --> F[grep sanidade + format:check + gate:fast]
|
||||
F --> G[commit + push + PR Closes #629]
|
||||
G --> H[Nota de coordenação no PR:<br/>OPS37 #624 e OPS30 #615 re-anexam<br/>sua entrada no topo após rebase]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C
|
||||
**Recomendação:** A — restauração verbatim da base boa `0b520fdb` + re-anexação das 4 entradas pós-truncamento no topo, na ordem de merge (D9, B195, OPS33, OPS35). Única opção que devolve o arquivo íntegro sem reordenar nem editar texto.
|
||||
**Rejeitadas:**
|
||||
|
||||
- B — executar a letra da Issue ("re-anexar OPS35/OPS33/OPS34/OPS28"): OPS34/OPS28 já estão na base boa; re-anexá-los criaria duplicatas. A Issue foi escrita observando o arquivo truncado, não o inventário de merges.
|
||||
- C — reconstruir por concatenação de todos os commits de changelog: reescrita completa com risco de drift e sem necessidade (a base boa `0b520fdb` já contém 120 entradas íntegras).
|
||||
- D — adicionar guarda automatizada de presença de entradas no CHANGELOG: precedente D8 rejeitou ("processo = revisar diff em merges que o toquem"); ver Rabbit holes.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`docs/CHANGELOG-AGENTS.md`** (único arquivo): `git show 0b520fdb:docs/CHANGELOG-AGENTS.md` → novo conteúdo base; extrair as 4 entradas do main atual com `grep -oP` e colar verbatim no topo (acima da OPS28), separadas por linha em branco, na ordem D9 → B195 → OPS33 → OPS35. Diff final vs `0b520fdb` = exatamente +4 linhas de entrada (+4 blanks).
|
||||
- **`docs/plans/changelog-agents-truncado-ops35-ops33-impl.md`** (este plano, incluído no commit — padrão D8).
|
||||
- **Migration:** sem migration.
|
||||
- **Access / Consent:** não se aplica.
|
||||
- **UI:** não se aplica (docs-only; CI `build-affected` pula o build).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Fonte + colagem** — `git show 0b520fdb:docs/CHANGELOG-AGENTS.md > docs/CHANGELOG-AGENTS.md`; extrair as 4 entradas do arquivo atual com `grep` (linhas completas) e prependê-las verbatim.
|
||||
2. **Verificação de integridade** — `git diff 0b520fdb -- docs/CHANGELOG-AGENTS.md` mostra **só** as 4 entradas + blanks; `grep -c '^\*\*Recently resolved'` = 124 (120 + 4); cada uma das 7 entradas do arquivo atual presente exatamente 1× (B193/OPS28/OPS34 via base boa, sem duplicata); nenhuma entrada da base boa ausente (diff não mostra remoções).
|
||||
3. **Sanidade** — `pnpm format:check` (Prettier é a autoridade; `pnpm format` se necessário); `pnpm gate:fast`.
|
||||
4. **Entrega** — commit + push; PR `--base main` com `Closes #629` + auto-merge; `gh pr checks --watch --required`. Nota de coordenação no PR (ver Riscos).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Não criar guarda automatizada de presença de entradas** no CHANGELOG (precedente D8, `restaurar-entradas-changelog-b183-c102-impl.md`: "processo = revisar diff em merges que o toquem"). O processo recomendado (e repetido nesta entrega): revisar o diff do CHANGELOG em todo rebase/merge de branch que o toque — foi exatamente o que não aconteceu nos merges #621/#623.
|
||||
- Não reordenar a seção nem reorganizar o arquivo.
|
||||
- Não tocar branches de OPS37 (#624) e OPS30 (#615) — só coordenar via nota no PR.
|
||||
- Não editar outras Issues in-progress; não criar Issue nova.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Conflito com branches em voo (OPS37 #624, OPS30 #615)** — ambas editam o CHANGELOG sobre a versão truncada (OPS30 `8a459a11`: baseada em main pré-OPS28, 246 linhas + entrada OPS30; OPS37 `7c17d437`: baseada em main truncado pós-B195). Mitigação: nota no PR mandando rebasear e **re-anexar a própria entrada no topo do arquivo restaurado** (nunca resolver mantendo o lado da branch). Se um dos merges cair antes do meu, rebasear e re-aplicar a colagem (a colagem é idempotente por conteúdo).
|
||||
- **Drift de transcrição na colagem** → colagem via `git show`/`grep` (nunca redigitar) + verificação por md5 e diff (fase 2).
|
||||
- **gate:fast lento (docs-only muda nada)** → rodar mesmo assim (exigência da skill); esperado green em lint/typecheck/unit.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite da Issue ainda coberto (arquivo íntegro restaurado; entradas pós-truncamento re-anexadas; texto canônico intacto)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (docs-only; sem migration/access/Consent/DB)
|
||||
- [ ] Testes de domínio previstos: nenhum (docs); verificação por diff + grep + format:check + gate:fast
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
# Impl: Container Postgres compartilhado é recriado por outro worktree — mata dev+e2e de todos em voo
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-10
|
||||
Issue: #605
|
||||
Intenção: docs/plans/container-postgres-compartilhado-compose-replace.md
|
||||
Appetite restante: herdado (~0,5 dia eng; um outcome verificável)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** `pnpm db:start` com `teqo-postgres-1` saudável é no-op (não recria, não derruba conexões de outros worktrees); recriação legítima segue possível por comando explícito; nada de CI/produção muda.
|
||||
- **O que NÃO negociar:** sem serialização/locks de agentes; sem tocar em containers de outros projetos; fix no **owner** do container (`db:start`), não na educação dos agentes.
|
||||
- **O que reavaliar:** a pergunta em aberto da intenção — "por que o config_hash divergiu?" — foi **respondida com evidência** (ver abaixo): é o bind mount relativo `./docker/postgres/init`, único campo que difere entre worktrees. Isto descarta o fix alternativo "pinar o compose para não depender do workdir" como frágil; o fix no `db:start` cobre o mecanismo inteiro.
|
||||
|
||||
## Diagnóstico (verificado em 2026-08-10)
|
||||
|
||||
`docker-compose.yml` linha 27: `- ./docker/postgres/init:/docker-entrypoint-initdb.d:ro` — bind mount **relativo**, resolvido por compose contra o diretório do projeto (o root do worktree). Medido:
|
||||
|
||||
- `docker compose -p teqo config --hash postgres` → OPS37: `39e89956…`; B195: `cf3cb522…`; C101: `917e11bb…` — três worktrees, três hashes.
|
||||
- `docker compose -p teqo config` diff OPS37×B195: **única** diferença é `source:` do bind mount (path absoluto do worktree).
|
||||
- Container atual roda com o hash do OPS37 (`com.docker.compose.config-hash` label) — qualquer `db:start` de outro worktree → hash difere → compose faz `replace` → `terminating connection due to administrator command` para todos.
|
||||
|
||||
O bind mount só importa na **primeira** inicialização do volume (`01-create-test-db.sql` cria `teqo_test`); depois é letra morta — mas compose não sabe disso e o inclui no hash.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[pnpm db:start / worktree provisioning] --> B{container Up + healthy?}
|
||||
B -- sim --> C[no-op: nada a recriar]
|
||||
B -- não / --force-recreate --> D[compose -p teqo up -d --wait postgres]
|
||||
D --> E[container de pé, nenhuma conexão viva foi derrubada]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C
|
||||
**Recomendação:** **A** — preflight "container saudável → skip do compose" no owner `db:start` (novo `scripts/db-start.mjs` + lib compartilhada usada também pelo provisioning do worktree). Porque: ataca exatamente o caso perigoso (recriar container **de pé** com conexões vivas); quando o container não está de pé (missing/exited), compose up é seguro e continua o único caminho; dá mensagem clara ao operador; cobre os dois call sites do compose (`db:start` e `dockerComposeUp` do `worktree.mjs`, que tem o MESMO risco de replace durante `pnpm worktree next`).
|
||||
**Rejeitadas:**
|
||||
|
||||
- **B — "pinar o bind mount"** (ex.: path absoluto para o main repo): resolveria o hash, mas injeta path do host no compose commitado — quebra portabilidade (main repo pode morar em outro caminho/máquina) e o bind é read-only só usado no primeiro init; se o path sumir, o container sobe com init vazio em silêncio. Frágil demais para um problema de 1 linha no script.
|
||||
- **C — só `--no-recreate` no compose up**: comportamento correto no skip, mas silencioso — quando o config muda de verdade (bump de imagem), o operador não descobre que o container está stale, e o caminho explícito fica menos óbvio; além disso não dá diagnóstico quando algo mais está errado (container unhealthy). O preflight de A é o `--no-recreate` com luzes.
|
||||
- ~~Remover o bind mount e criar `teqo_test` via `docker exec createdb` pós-start~~: espalha o bootstrap, quebra a garantia "só no primeiro init do volume", e a mudança de compose toca todos os worktrees de uma vez.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`scripts/lib/db-start.mjs`** (novo): núcleo compartilhado —
|
||||
- `shouldSkipStart({ running, health })` → puro: `true` quando `running && health ∈ {healthy, starting}` (skip; `starting` evita o race de substituir um container que um worktree vizinho acabou de criar).
|
||||
- `parsePostgresContainerHealth(jsonText)` → puro: `docker inspect --format '{{json .State}}'` → `{ running, health }` (`health` vazio = container antigo sem healthcheck → **não** skip: compose up migra de volta para a config atual).
|
||||
- `waitForHealthyPostgres({ timeoutMs, intervalMs })` → espera um container `starting` ficar `healthy` (bounded 60s/2s; timeouts injetáveis para os testes); `false` se sumir/virar `unhealthy`/estourar o tempo → cai no compose up.
|
||||
- `startSharedPostgres({ cwd, forceRecreate })` → async: inspeciona `teqo-postgres-1`; skip com mensagem quando `healthy` (ou espera o boot de um `starting`) e `!forceRecreate`; senão `docker compose -p teqo up -d [--force-recreate] --wait --wait-timeout 120 postgres` com `cwd` (a lib não assume o root — o provisioning passa o dir do worktree). Docker inacessível → lança (o CLI imprime remédio; o `worktree.mjs` já try/catch → fallback env).
|
||||
- **`scripts/db-start.mjs`** (novo, CLI): `--force-recreate` (recriação explícita) e `--help`; args desconhecidos → usage + exit 2; cwd = `process.cwd()` (compose file do worktree atual); erro de docker → mensagem estilo `db-doctor` ("Docker looks unreachable — is Docker running?") + exit 1. Sem `--force-recreate`, container saudável → `[db] teqo-postgres-1 already up (healthy) — nothing to recreate.` exit 0.
|
||||
- **`package.json`**: `"db:start": "node scripts/db-start.mjs"` (substitui o raw compose up). `db:stop`/`db:doctor` intactos.
|
||||
- **`scripts/worktree.mjs`**: `dockerComposeUp` inlined — o `provision()` chama `await startSharedPostgres({ cwd: dir })` direto — mesmo preflight no provisioning (idempotência de `worktree next`); `SHARED_COMPOSE_PROJECT` órfão removido (agora const privada da lib).
|
||||
- **`tests/unit/dbStart.unit.spec.ts`** (novo): `shouldSkipStart` (matrix running×health), `parsePostgresContainerHealth` (healthy/starting/unhealthy/sem healthcheck/Health null/malformado), e o wrapper `startSharedPostgres`/`waitForHealthyPostgres` com `vi.mock('node:child_process')` (skip não chama compose; container ausente → compose up com argv correto; `--force-recreate` bypassa; `starting` → espera → healthy; `starting` travado/unhealthy/sumiu → false) — seguindo o padrão de `worktree.unit.spec.ts` (importa direto de `scripts/lib/*.mjs`, `// @vitest-environment node`).
|
||||
- **Docs:** `AGENTS.md` → bullet "Database & local development": `db:start` é idempotente (no-op com container saudável; `pnpm db:start --force-recreate` para recriação explícita). `docs/CHANGELOG-AGENTS.md` → uma entrada curta.
|
||||
- **Migration:** nenhuma. **Access/Consent:** n/a. **UI:** n/a.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
n/a — sem dados novos; o contrato `StartedAt` inalterado é a métrica de aceite (verificação com `docker inspect`).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Núcleo + wiring** — `scripts/lib/db-start.mjs` + `scripts/db-start.mjs` + `package.json` + delegação em `worktree.mjs`. Verificação: `pnpm db:start` com container saudável imprime o no-op e `docker inspect teqo-postgres-1 --format '{{.State.StartedAt}}'` fica **igual** antes/depois; `docker compose -p teqo config --hash postgres` do worktree == hash do container (estado atual já é esse).
|
||||
2. **Unit tests** — `tests/unit/dbStart.unit.spec.ts` (17 casos: matrix pura + wrapper mockado); rodar via `pnpm test:unit` (ou o alvo unit).
|
||||
3. **Gates** — `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, unit/int. `--force-recreate` é verificado pela flag passando ao compose (execução live opcional, avisando o humano — recriar derruba sessões de outros worktrees por ~2s).
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não mexer em `docker-compose.yml` (mantém compat: qualquer worktree continua podendo rodar compose up cru — agora inofensivo para os outros? **não**: compose up cru de outro worktree ainda recria. O escopo é o owner (`db:start`/provisioning); compose up manual é caminho do operador, e o `--force-recreate`/docs o tornam explícito).
|
||||
- Não tocar em `db:stop` (parada explícita é intenção do operador).
|
||||
- Não serializar/lockar agentes (anti-goal da intenção).
|
||||
- Race raro: container ausente e dois worktrees rodam `db:start` simultâneo → ambos sobem compose; o segundo pode recriar o primeiro (sem conexões vivas ainda — inofensivo). Documentado, não corrigido.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Container `starting` de um peer**: o `startSharedPostgres` espera o boot ficar `healthy` (60s, polling 2s) em vez de recriar; se travou (nunca healthy), cai no compose up com `--wait-timeout 120` — falha alta, sem hang infinito. `--force-recreate`/`db:doctor` seguem como escape.
|
||||
- **Container antigo sem healthcheck**: `health` vazio → não skip → compose up migra para a config atual (uma recria única, sem conexões? pode ter — caso raro e legítimo; o plano da intenção aceita recriação quando a config mudou de verdade).
|
||||
- **`worktree next` com docker inacessível**: comportamento preservado (throw → try/catch → fallback env).
|
||||
- **Knip/águeiro**: `scripts/db-start.mjs` é entry point (não exporta nada); `scripts/lib/db-start.mjs` tem consumidores reais (`db-start.mjs` + `worktree.mjs` + o spec) — nada órfão.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (no-op com saudável; recreate explícito; sem CI/prod; sem locks)
|
||||
- [x] Invariantes AGENTS/engineering-standards (idempotência; sem path absoluto no compose; owner do container)
|
||||
- [x] Testes de domínio previstos: unit das decisões puras + wrapper mockado (17 casos)
|
||||
- [x] Verificação live: `StartedAt` inalterado após `pnpm db:start` repetido (inclusive de outro worktree com hash diferente)
|
||||
36
docs/plans/e2e-combobox-label-strict-mode-flake.md
Normal file
36
docs/plans/e2e-combobox-label-strict-mode-flake.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# E2E: flake do `getByLabel('Atividade relacionada')` (strict mode) sob carga
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #619 (D11) colheu; registro D12
|
||||
Priority: P3
|
||||
Kind: defect
|
||||
Depends: [] — pré-existente em main, não nasce do D11
|
||||
|
||||
## O sintoma
|
||||
|
||||
`tests/e2e/campaignRegisterDemand.e2e.spec.ts:38` — `expect(page.getByLabel(CAMPAIGN_DEMAND_ACTIVITY_LABEL)).toBeVisible()` (label `Atividade relacionada`) falha com **strict mode violation: resolved to 2 elements**, ambos botões com `aria-label="Atividade relacionada: Nenhuma atividade"` (o trigger do `AsyncSearchCombobox`).
|
||||
|
||||
## Caracterização (colhida no D11, 2026-08-11)
|
||||
|
||||
- **Falha em runs completos, passa isolado:** 3/4 runs completos do set (dev e prod/E2E_PROD=1) falharam; o teste isolado passa sempre (dev e prod).
|
||||
- **Pré-existente:** `git stash` no worktree do D11 (código 100% main) reproduziu a falha **idêntica** no mesmo teste — inclusive o mesmo diretório de error-context. Não é regressão do D11.
|
||||
- **Ambiente:** máquina do pool com carga alta (compiles dev de 6–27s; asserts com budget 10s). O erro aparece como violação de strict mode em `getByLabel`, que é substring-match: casam o trigger button (aria-label `Atividade relacionada: …`) e um segundo nó.
|
||||
- **Snapshot pós-falha:** o form renderizado mostra um único trigger e o conteúdo do `CommandDialog` (título + descrição) montado no DOM com o dialog fechado — o segundo match é transitório/estado de reconciliação, não duas instâncias estáveis do componente.
|
||||
|
||||
## Hipóteses a investigar (sem implementação aqui)
|
||||
|
||||
1. `AsyncSearchCombobox` renderiza `CommandDialog` com conteúdo sempre montado: `CommandInput` tem `aria-label={label}` (o label exato, sem sufixo) — candidato natural a segundo match quando visível à a11y tree (possível `forceMount`/transição do Radix/cmdk).
|
||||
2. Mesmo nó resolvido duas vezes por caminhos de label distintos (aria-label + associação via `<label>`/`aria-labelledby`) — validar com `page.getByLabel(...).evaluateAll(n => n.length)` num state aberto vs fechado.
|
||||
3. Timing: o match duplicado existe durante a transição de abertura/fechamento do dialog ou numa re-render em rajada (2 workers + navegação pesada) — por isso só aparece em runs completos.
|
||||
|
||||
## Direções de fix (escolher na execução)
|
||||
|
||||
- **Spec:** `getByLabel` → `getByRole('button', { name: /Atividade relacionada:/ })` (o trigger) ou `.first()`; ou `getByLabel(CAMPAIGN_DEMAND_ACTIVITY_LABEL, { exact: false })` com alvo inequívoco.
|
||||
- **Componente:** remover redundância de labels (ex.: `aria-label` do `CommandInput` só quando aberto; ou `aria-hidden` do dialog fechado).
|
||||
- Não alterar copy visível nem o comportamento da combobox.
|
||||
|
||||
## Aceite
|
||||
|
||||
- [ ] `campaignRegisterDemand` verde em run completo (reprodução ×3) e isolado
|
||||
- [ ] Sem mudança de DOM/labels no estado fechado que afete outros specs (grep `getByLabel` em e2e)
|
||||
61
docs/plans/escala-dry-pos-b197.md
Normal file
61
docs/plans/escala-dry-pos-b197.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Escala e DRY pós-B197 (picker de colunas de assessores)
|
||||
|
||||
Status: pendente (capture-review-debts pós-B197, gate humano confirmado)
|
||||
Atualizado em: 2026-08-11
|
||||
Item do roadmap: fill-in **B197+** pós-[#627](https://github.com/fsolla/teqo/issues/627)
|
||||
Impeccable: A — sem superfície UI nova (DRY/estrutura)
|
||||
Appetite: ~0,5 dia eng
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
O B197 entregou o seletor de colunas na lista de assessores — a primeira lista do
|
||||
mecanismo B17 que **não** usa `CampaignTable`. A tabela (`AdvisorsTable.tsx`) é
|
||||
client-side com `<th>` escritos à mão, então o menu do picker
|
||||
(`advisorPickerColumns` em `assessores/page.tsx`) é um array estático que
|
||||
**espelha** os cabeçalhos da tabela: `Nome/E-mail/Celular/Municípios/Ações`.
|
||||
O espelho é coberto por comentário, mas nada impede um item futuro de adicionar
|
||||
uma coluna na tabela e esquecer o menu — a coluna nasceria fora do picker (nem
|
||||
ocultável, nem listada), silenciosamente.
|
||||
|
||||
**Já resolvido no simplify/critique (não reabrir):** nada — o achado foi
|
||||
registrado direto do gate pós-entrega (nenhum outro follow-up do B197 ficou).
|
||||
|
||||
**Explicitamente fora:** conversão do `AdvisorsTable` para `CampaignTable`
|
||||
(rejeitada no impl do B197 — redesenho de superfície fora do pedido; o shell
|
||||
não modela a linha-draft de criação nem a edição inline por célula).
|
||||
|
||||
## Objetivos
|
||||
|
||||
- O menu do picker de assessores nasce da **mesma fonte** que os `<th>` da
|
||||
tabela — adicionar coluna num lugar atualiza o outro de graça.
|
||||
- Sem mudança de comportamento: mesmos ids (`name` mandatory, `email`, `phone`,
|
||||
`municipalities`, `actions`), mesmos labels, exceção do draft intacta.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Opção A — fonte única serializável no módulo da tabela (recomendada).**
|
||||
`AdvisorsTable.tsx` (client) passa a declarar as colunas como dados
|
||||
(id/label/mandatory) e exporta uma versão serializável (`toCampaignColumnPickerColumns`
|
||||
já existe no lib B17); o `<th>` renderiza de `label`, e `assessores/page.tsx`
|
||||
importa a mesma fonte para o picker. Cliente-para-servidor vale para
|
||||
constantes serializáveis (mesmo padrão de `supporterPickerColumns` em
|
||||
`SupporterList.tsx`).
|
||||
- **Opção B — status quo (comentário).** Rejeitada: o comentário já existe e o
|
||||
achado é exatamente que ele é insuficiente para um time de agentes paralelos.
|
||||
- **Gatilho de execução:** a qualquer momento como fill-in (~0,5 dia); se C112
|
||||
(telefones) tocar o `AdvisorsTable` antes, executar junto (mesma superfície).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. Extrair a fonte única no `AdvisorsTable` + export serializável; `<th>` por
|
||||
`label`.
|
||||
2. `assessores/page.tsx` consome a fonte; remover `advisorPickerColumns`.
|
||||
3. Gates: `tsc`, `lint`, unit do lib, e2e `campaignColumnPicker` (assessores),
|
||||
`format:check`.
|
||||
|
||||
## Rabbit holes / Não escopo
|
||||
|
||||
- Não portar para `CampaignTable` (decisão travada do B197).
|
||||
- Não mexer em labels/ordem/ids das colunas — contrato do cookie `campaign_columns`.
|
||||
- Não criar coluna nova nenhuma — só o DRY do menu.
|
||||
86
docs/plans/escala-dry-pos-wizard-steps-impl.md
Normal file
86
docs/plans/escala-dry-pos-wizard-steps-impl.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Impl: Escala/DRY: chrome comum dos passos finais do wizard (pós-B195)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #619 (D11)
|
||||
Intenção: docs/plans/escala-dry-pos-wizard-steps.md
|
||||
Appetite restante: herdado (~0,5–1 dia eng) — não estoura
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** os 4 passos finais do wizard de `/campanha/acoes` param de duplicar (a) submit bar + pendência e (b) campos do cadastro de demanda, sem mudar comportamento, copy, toast/push/retorno ou a11y pinada por e2e.
|
||||
- **O que NÃO negociar:** copy intocada (`Salvando…`, sr-only de cada passo, labels de CTA, `WIZARD_*`/`CAMPAIGN_DEMAND_*` continuam onde estão e com os mesmos valores); e2e de foco/a11y verdes (`contentFocus`, aria-live, label do botão); não renomear arquivos de steps (histórico de PRs); não unificar os corpos dos steps; nada de migration/schema.
|
||||
- **O que reavaliar (hipóteses da intenção):**
|
||||
1. `WizardStepFormChrome` como "form + pending chrome + submit bar" — o 4º step (`WizardExpectedVotesStep`) **não é form**: usa `useTransition` + `fetch` + CTA `type="button"`, sem `useActionState`. Forçá-lo a form mudaria comportamento (Enter passaria a salvar). A extração precisa de dois modos, não de um form só.
|
||||
2. "Campos de demanda parametrizado por prefixo de id + `showMunicipality`" — a diferença entre os dois cadastros é maior que prefixo: município fixo (hidden input + search já vinculado) vs selecionável (gating do combobox, placeholder locked, reset de atividade, copy condicional, grid 2 colunas).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[WizardStepFormChrome<br/>shared/] --> B[WizardRegisterDemandStep]
|
||||
A --> C[WizardUpdateBodyStep]
|
||||
A --> D[WizardTrendNoteStep]
|
||||
A --> E[WizardExpectedVotesStep]
|
||||
F[DemandFields<br/>demand/] --> B
|
||||
F --> G[DemandForm /demandas/nova]
|
||||
```
|
||||
|
||||
**`WizardStepFormChrome`** — dono do chrome de passo final: wrapper (`<form action>` ou `<div>`), `aria-busy`/`data-pending`, submit bar com CTA (Spinner + `Salvando…` + label), região sr-only `aria-live` com `pendingAnnouncement`. Dois modos **por união discriminada** (type-safe, sem `as`/`variant` genérico): `{ action }` (3 steps form) | `{ onCtaClick }` (votos, preserva `useTransition`/fetch). Props de forma: `leadingSubmit?` (Limpar do trend), `submitBarClassName?` (flex-wrap do trend), `ctaClassName?` (largura).
|
||||
|
||||
**`DemandFields`** — dono dos campos do cadastro de demanda: Tipo (`NativeSelect`), Atividade (`AsyncSearchCombobox`), descrição (`Textarea`), erros via `fieldError`. Prop `municipality?` opcional: ausente = modo wizard (stacked, combobox sempre pronto, search já vinculado pelo passo); presente = modo `/demandas/nova` (grid 2 colunas Tipo+Município, gating `isQueryReady`/`queryTooShortMessage`, placeholder button locked, reset de atividade na troca de município, copy condicional da descrição). O gating/reset/copy condicional só disparam em modo município — no wizard o DOM sai idêntico ao de hoje.
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **Chrome:** A) um componente com união discriminada `action | onCtaClick` — **recomendada**; B) só-form nos 3 steps e votos mantém o bloco duplicado — rejeitada: o débito do /simplify renasce no passo 4 com o mesmo conhecimento; C) `variant`/`as` genérico — rejeitada: mesma cobertura com API mais frouxa e modos não checados por tipo.
|
||||
- **Campos:** A) `DemandFields` com `municipality?` opcional — **recomendada**; B) campos comuns sem município + DemandForm mantém o select/gating próprios — rejeitada: o Field de atividade (combobox vs placeholder locked) é inseparável do estado do município; deixar metade fora re-duplica; C) DemandForm vira dono e o wizard o consome inteiro — rejeitada: layout (`max-w-2xl gap-4` vs `gap-6`), ids, `disabled={isPending}` e estado diferem; mudaria o DOM e o comportamento do wizard.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`WizardStepFormChrome`** (`src/components/campaign/shared/WizardStepFormChrome.tsx`): novo. Props comuns `isPending`, `pendingAnnouncement`, `ctaLabel`, `children`, `leadingSubmit?`, `submitBarClassName?`, `ctaClassName?`; união `{ action: CampaignFormAction } | { onCtaClick: () => void }`. Renderiza `<form action>` ou `<div>` com `className="flex flex-col gap-6"`, `aria-busy={isPending || undefined}`, `data-pending`; bar default `flex items-center justify-end`; CTA `min-h-11` + `min-w-[7rem]` default + `<Spinner data-icon="inline-start" aria-hidden="true" />` + `Salvando…`; sr-only `aria-live="polite"` por último. `Salvando…` passa a viver aqui (1 site).
|
||||
- **`DemandFields`** (`src/components/campaign/demand/DemandFields.tsx`): novo. Props `idPrefix`, `disabled?`, `state: CampaignFormActionState`, `activity`, `onActivityChange`, `searchActivities`, `municipality?`. Reusa `fieldError` (`utilities/campaignFormFields`), consts de `lib/schemas/campaignDemand` (B195, intocadas). Layout: `municipality` ausente → campos empilhados (hoje do wizard); presente → `grid gap-4 sm:grid-cols-2` com Tipo+Município, e gating de atividade.
|
||||
- **`WizardRegisterDemandStep`** (`shared/`): passa a compor `WizardStepFormChrome` (modo `action`, `WIZARD_DEMAND_PENDING_ARIA`, `CAMPAIGN_DEMAND_SUBMIT_LABEL`, `ctaClassName="min-w-[8rem]"`) + `DemandFields` (prefixo `wizard-demand-`, `disabled={isPending}`). Mantém hidden `municipalityId`, `searchActivities` vinculado ao `municipalityId` prop, toast/push/`recordLastActedMunicipality`, hidden input e `CampaignFormActionMessage` como children.
|
||||
- **`WizardUpdateBodyStep`** (`municipality/`): compõe `WizardStepFormChrome` (modo `action`, sr-only `Salvando atualização.`, `WIZARD_UPDATE_SAVE_LABEL`, largura default). Hidden inputs, corpo (textarea/polaridade/urgente/adversary), `CampaignFormActionMessage` como children.
|
||||
- **`WizardTrendNoteStep`** (`municipality/`): idem + `leadingSubmit` = botão Limpar (`WIZARD_TREND_CLEAR_LABEL`, `disabled` por `note.length === 0`) + `submitBarClassName="flex-wrap gap-3"`. `autoFocus` da textarea intocado.
|
||||
- **`WizardExpectedVotesStep`** (`shared/`): modo `onCtaClick={handleConfirm}` (mesma validação `getWizardVoteViolation`, transition e `postCampaignJson`), `pendingAnnouncement="Salvando votos estimados."`, `ctaLabel={WIZARD_VOTES_FINAL_CTA_LABEL}`, `ctaClassName="w-full"`. Corpo (inputs, shortcuts, alerts) como children. Micro-diferenças conscientes: `aria-busy` omite `false` no idle (hoje votos emite `aria-busy="false"`) e sr-only passa a vir depois do CTA — sem efeito perceptível.
|
||||
- **`DemandForm`** (`demand/`): passa a compor `DemandFields` com `municipality` config (options/value/onValueChange); perde o select, o gating, o reset e a copy condicional próprios. Mantém `<form>` próprio (`max-w-2xl gap-4`, `CampaignFormActionMessage`, botão `self-start` com Spinner + `CAMPAIGN_DEMAND_SUBMIT_LABEL`) — não é passo de wizard, não entra no chrome.
|
||||
- **Migration:** nenhuma. **Access/Consent:** nenhum. **UI:** extração mecânica com DOM equivalente (não é Impeccable B/C/D; é refactor). **Dados → forma:** n/a.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Chrome:** criar `WizardStepFormChrome` + refatorar os 4 steps (sem `DemandFields` ainda). `pnpm gate:fast` + e2e: `campaignHomeActions`, `campaignWizardChrome`, `campaignRegisterDemand` (foco, aria-live, labels).
|
||||
2. **Campos:** criar `DemandFields` + refatorar `DemandForm` e `WizardRegisterDemandStep`. `pnpm gate:fast` + e2e `campaignRegisterDemand` + `campaignMunicipalities` (caminho /demandas/nova), `campaignUpdatesMobile` (form de update no mobile).
|
||||
3. **Gates totais e fechamento:** `pnpm gate:fast` + `pnpm push` (lint 0 warnings, format, knip — imports órfãos dos steps morrem na entrega, `check:cycles`, unit+int, build) → `/simplify` → `capture-review-debts` com gate humano → PR Ready + auto-merge.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não unificar corpos dos steps (cada um tem campos próprios) — só o chrome/pending e os campos de demanda.
|
||||
- Não renomear arquivos de steps nem `CampaignWizardShell`/`DemandForm`.
|
||||
- Não mexer em copy: `WIZARD_UPDATE_*` vs `WIZARD_DEMAND_*` continua deferido (cosmético, gatilho: próxima casa de copy).
|
||||
- Não transformar `WizardExpectedVotesStep` em `<form>` (Enter salvaria — mudança de comportamento).
|
||||
- Não mover hidden inputs nem `CampaignFormActionMessage` para dentro do chrome (cada passo decide os seus).
|
||||
|
||||
## Já resolvido no simplify (não reabrir)
|
||||
|
||||
- Duplicação interna do chrome (branches form/div) → hoist de `content` em `WizardStepFormChrome`.
|
||||
- Kind field duplicado inline → hoist de `kindField` em `DemandFields`.
|
||||
- Micro-mudanças conscientes (documentadas): votos omite `aria-busy` no idle (vs `"false"`), Spinner do votos ganha `data-icon="inline-start"` (~4px no pending), sr-only do votos passa a vir após o CTA, `required` inerte no kind select do `DemandForm`, `fieldError` normaliza 1º erro (produtores emitem ≤1).
|
||||
|
||||
## Explicitamente fora (descartes/defer deste triage)
|
||||
|
||||
- Flake e2e `getByLabel('Atividade relacionada')` strict mode → **registrado como D12** (Issue #641, pré-existente em main — provado via stash; não é débito do D11).
|
||||
- Defer com gatilho (do plano de intenção, mantidos): fail-closed `campaignDemandUniqueSlug` sem teste (gatilho: próxima mudança no hook); copy "Informe um título com letras ou números." (gatilho: feedback em campo); casa de copy `WIZARD_UPDATE_*` vs `WIZARD_DEMAND_*` (gatilho: próxima casa de copy); `canLeaderEdit` morto (pré-existente); update de demanda com descrição inalterada ainda grava (documentado em teste).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Regressão de a11y/foco nos steps legados** → e2e `campaignHomeActions`/`campaignWizardChrome`/`campaignRegisterDemand` pinam `contentFocus`, aria-live e labels; rodar nas fases 1 e 2.
|
||||
- **Mudança visual acidental no wizard** → o chrome em modo form reproduz o DOM exato de hoje (mesmas classes, mesma ordem de children); conferir por diff dos 4 steps.
|
||||
- **`DemandFields` virar config soup** → cada prop mapeia uma diferença real entre os 2 call sites existentes; nenhum prop especulativo; se um 3º cadastro surgir, o dono já está definido.
|
||||
- **Imports órfãos nos steps** (Spinner/Button/NativeSelect removidos) → knip no gate final aponta; remover na entrega (não `knip --fix` cego).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção coberto (extração sem mudar copy/comportamento/toast/retorno)
|
||||
- [x] Invariantes AGENTS/engineering-standards (sem migration, sem access, sem transação, identificadores em inglês)
|
||||
- [x] Testes: e2e dos 3 specs de wizard + demandas nas fases; gates completos no fechamento
|
||||
- [x] Depth check: `CampaignWizardShell`, consts B195 e `fieldError` reusados; os 2 módulos novos são os donos das 2 duplicações
|
||||
82
docs/plans/municipios-mobile-polimento-omnibox-cards-impl.md
Normal file
82
docs/plans/municipios-mobile-polimento-omnibox-cards-impl.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Impl: Municípios mobile: omnibox colado ao header e card mais denso (pós-B193/B184)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-10
|
||||
Issue: #607
|
||||
Intenção: docs/plans/municipios-mobile-polimento-omnibox-cards.md
|
||||
Appetite restante: herdado (~0,5–1 dia eng) — sem estouro: mudanças de superfície em 2 shared + 1 card + 1 avatar + 5 pages + e2e
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** em `/campanha/municipios` mobile (< md), a barra de filtro cola no header (sem vão, nada passa por cima no scroll), o campo fica ~40px no total, foco sem anel de cor, nome 18px com território colado, chips Classe/Tendência/Nível com label acima, avatares numa linha por grupo com sobreposição dinâmica — padrão do chassis vale para TODAS as listas (gate A); chip de classe ganha label "Classe" (gate B).
|
||||
- **O que NÃO negociar:** desktop (`md+`) intacto; sheets/edit-where-you-see intactos; labels/dados dos chips intactos; nenhum limite de avatares com "…"; área de toque dos controles do card não encolhe.
|
||||
- **O que reavaliar:** a hipótese da intenção ("encurtar o vão no contexto da barra — não o padding global do scrollport") é correta e foi confirmada: o sticky não pode subir acima do containing block (o shell), que começa 16px abaixo do scrollport por causa do `p-4` do `CampaignContentScroll`. Qualquer fix no form isolado falha (clamp do containing block).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[CampaignContentScroll<br/>max-md:has-[marcador]:pt-0] --> B[form sticky ancora em y=0<br/>colado ao header]
|
||||
B --> C[omnibox denso: min-h-8 py-1<br/>X size-8 · ring só md]
|
||||
C --> D[card: nome 18px tight · chips label-acima<br/>avatares overlap row]
|
||||
D --> E[e2e B196 + gates]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C
|
||||
**Recomendação:** A — resolver o vão via `:has()` no scrollport (o scrollport "sabe" quando hospeda a barra sticky; o form carrega um marcador de classe estável). É a única correção que move o containing block do sticky para y=0 sem tocar o padding global nem as 11 páginas de lista.
|
||||
**Rejeitadas:**
|
||||
|
||||
- **B — `-mt-4` no form:** o sticky não sobe acima do containing block; com a borda do shell a 16px, o clamp continua em 16 (e o natural position vira 0 ≠ posição colada no scroll → barra "pula"). Medido na exploração: `formY=72` = header 56 + 16.
|
||||
- **C — `pt-0` global no scrollport / remover `p-4`:** rabbit hole nomeado na intenção; destrói a folga do topo de todas as telas (dashboard, wizard…). O `:has()` escopa o `pt-0` só para páginas com o form de lista.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`campaignListOmniboxFormClassName`** (`src/components/campaign/shared/CampaignListOmnibox.tsx`): adiciona o marcador `campaign-list-omnibox-form` ao bloco de classes existente (contrato do `:has()`). Barra: `py-2` → `py-1` no mobile (`md:py-0` preservado). Nada mais muda aqui — sticky/`-mx-4`/border-b ficam.
|
||||
- **Campo do omnibox** (mesmo arquivo): `min-h-11` → `min-h-8`, `py-1.5` → `py-1` no mobile (`md:min-h-11 md:py-1.5` restauram o desktop); botão "Limpar" X `size-11` → `size-8` (span interno `size-7` → `size-6`, ícone `size-4` → `size-3.5`; `md:hidden` já é mobile-only); anel de foco `focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50` → escopado a `md:` (mobile sem anel/contorno de cor — caret é o indicador; contrato B184 borderless preservado). `text-sm` do input INTOCADO (contrato B183: 16px touch).
|
||||
- Total da barra mobile: 32 (min-h-8) + 8 (py-1×2) + 1 (border-b) = **41px ≈ 40** ✓. Área de toque do X = 32px — aceitável porque o alvo real é o campo (o X vive dentro dele).
|
||||
- **`CampaignContentScroll`** (`src/components/campaign/shell/CampaignQuickActionsHost.tsx`): `p-4` → adiciona `max-md:has-[.campaign-list-omnibox-form]:pt-0` (desktop `md:p-6` intocado). Efeito: só páginas com o form de lista perdem o padding-top no mobile; o shell (containing block do sticky) passa a começar em y=0 → barra cola no header e cards nunca passam por cima. Fallback se o arbitrary variant do Tailwind v4 não compilar: regra CSS pura em `styles.css` sob `@media (width < 48rem)` com o mesmo seletor.
|
||||
- **Compensação das 6 listas com linha de ações antes do form** (`atividades`, `liderancas`, `apoiadores`, `demandas`, `dobradinhas`, `organizacoes` — verificadas na exploração): `pt-4 md:pt-0` no `div.flex.justify-end` que precede o form (respiro no topo que o `pt-0` removeu; desktop inalterado). `municipios`, `territorios`, `atualizacoes`, `pessoas`, `assessores` têm o form como primeiro filho visível — sem compensação.
|
||||
- **`MunicipalityMobileCard`** (`src/components/campaign/municipality/MunicipalityMobileCard.tsx`):
|
||||
- Nome: `<h3>` ganha `text-lg leading-tight` (18px/22.5px vs 24px/32px atuais do `h3` global em `styles.css:371`); `gap-0.5` existente → território a ~2–4px do texto (linha-box enxuta é o que fecha o vão medido de ~13px). Badge "Cidade" e link stretch intactos.
|
||||
- Chips: bloco `flex flex-col gap-0.5` por chip com `ChipLabel` acima do valor (gate B), substituindo o `inline-flex items-center` atual; linha de chips `items-start gap-x-3 gap-y-2` (1–2 linhas aceitas pelo gate); valores com `whitespace-nowrap` (nenhum chip quebra no meio). `chipTriggerClassName` vira o estilo de bloco (sem pílula de hover: `hover:bg-transparent`; `min-h-11` mantido para a área de toque; label+valor são o alvo do tap). A label "Classe" é renderizada no CALL SITE do card (`<ChipBlock label="Classe">`), não dentro de `TerritorialClassCardReadout` — o readout continua compartilhado com a tabela desktop intocado. Caso cidade: Tendência/Nível/Classe como blocos com os mesmos valores ("Não registrada", dash). `MissingAdvisorBadge` ganha wrap (`whitespace-normal h-auto`) para o pill não clipar na célula de ~110px do grupo (desktop segue uma linha).
|
||||
- Densidade: article `gap-3` → `gap-2.5` e `p-4` → `px-4 py-3` (vertical denso; horizontal mantém o edge-to-edge B184; toques `min-h-11` dos controles intactos).
|
||||
- **`MunicipalityRelationAvatarStack`** (`src/components/campaign/shared/MunicipalityRelationAvatarStack.tsx`): o modo `wrap` (B193) vira **overlap row dinâmica** — células `flex-1` com avatar fixo centralizado: poucos avatares → sem sobreposição (célula > 28px), muitos → sobreposição proporcional à contagem, sem wrap, sem cap, sem "…", todos visíveis (a linha ocupa exatamente a largura do grupo; casos patológicos de dezenas de avatares degradam para pilha — sr-only continua com todos os nomes). Renomear a prop `wrap` → `overlapRow` (honestidade da semântica; 2 call sites no card + 1 em `MunicipalityAdvisorAvatarStack`). Adicionar `data-view="relation-avatars"` na linha (hook de teste, precedente `data-view="mobile-cards"`). Desktop (`-space-x-2`, cap 3, tooltip) intocado.
|
||||
- **`MunicipalityAdvisorAvatarStack`** (mesmo arquivo): passa `wrap` → `overlapRow` no modo denso (call site do card não-coordenador).
|
||||
- **Migration:** sem migration (nenhuma mudança de schema/collection/global).
|
||||
- **Access / Consent:** nenhum (zero paths de escrita tocados).
|
||||
- **UI:** Impeccable B — encaixe na tela existente; shape (classes acima) → craft (browser, 390px) → critique (contrastes/vão/overlap) → polish.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
N/A — nenhuma métrica nova; é densidade e alinhamento de superfície existente (intenção: "Vou apresentar dados? Não").
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer — chassis da barra** (maioria do appetite): marcador de classe + `:has()` no scrollport + densidade do campo/X/anel + compensação das 5 páginas. Verificar: `pnpm exec tsc --noEmit` + e2e B184/B183 existentes + novos e2e B196 (barra colada, altura ≤44, sem anel no foco).
|
||||
2. **UI — card denso**: nome/território, chips label-acima, overlap row de avatares, densidade vertical. Verificar: e2e B193 existentes + novos e2e B196 (nome 18px, território colado, label acima do badge, avatares numa linha com overlap e sem overlap quando poucos).
|
||||
3. **Gates**: `pnpm gate:fast` completo (tsc, lint, format, knip, cycles, unit+int, e2e do conjunto afetado) e `pnpm build`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não tocar `p-4`/`pb-*` do scrollport fora do `:has()` — o `pt-0` é escopado e é O fix do containing block.
|
||||
- Não mudar `text-sm` do input (contrato B183), nem o desktop do omnibox (`md:*` restaura tudo).
|
||||
- Não criar sistema de avatares novo — só o modo denso do componente existente.
|
||||
- **Alinhamento dos avatares no grupo (adiado):** com poucos avatares a pilha fica centralizada no grupo (label à esquerda) — ancorar à esquerda mudaria o perfil de overflow para o grupo vizinho com 4+ avatares; revisitável se o product pedir (gatilho: review Impeccable do /simplify).
|
||||
- Não polir o rodapé de atualização nem outras arestas do card (item separado na intenção).
|
||||
- Não mexer em sheets, tooltips de desktop, busca/sugestões do omnibox.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Tailwind v4 não compilar `max-md:has-[.campaign-list-omnibox-form]:pt-0`:** verificar no build da Fase 1; fallback = regra CSS pura em `styles.css` (`@media (width < 48rem)` + `.campaign-content-scroll:has(.campaign-list-omnibox-form)`).
|
||||
- **e2e B193 regressar:** `card.getByText('Nível', { exact: true }).click()` — a label continua DENTRO do trigger (bloco label-acima), então o clique continua abrindo o sheet; `[data-view="mobile-cards"]` e classes de borda intactas.
|
||||
- **Janela estreita-desktop (container < 48rem) vê o card densificado:** aceito — é o MESMO componente mobile (B193 já o estiliza com variantes `md:` no article); o desktop de verdade (tabela) não é tocado.
|
||||
- **Listas com linha de ações antes do form** perdem o respiro no topo com o `pt-0`: mitigado pelo `pt-4 md:pt-0` nas 5 páginas; e2e de regressão no `/campanha/atividades` (barra colada após scroll).
|
||||
- **`has-` casar páginas além das listas:** o marcador só existe em `campaignListOmniboxFormClassName`, usado exclusivamente pelos 11 `*Filters` (verificado por grep); wizard usa forms sem `role="search"`/marcador.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (vão zero, barra ~40px, sem anel, nome 18px+território colado, chips label-acima, avatares 1 linha sem cap/wrap/"…"; desktop intacto)
|
||||
- [x] Invariantes AGENTS/engineering-standards (sem schema, sem access, sem Consent; nomes de props em inglês; copy pt-BR preservada)
|
||||
- [x] Testes previstos: e2e B196 (barra colada + altura + anel + nome/território + labels + overlap de avatares) + regressão B184/B183/B193 existentes; sem unit/int novos (mudança puramente de superfície)
|
||||
|
||||
Self-score decision-quality: 5/5 (decisões caras com rejeitadas; cabe no appetite; rabbit holes nomeados; reusa chassis existente — zero módulos novos; intenção preservada)
|
||||
79
docs/plans/ocultar-email-por-padrao-listas-impl.md
Normal file
79
docs/plans/ocultar-email-por-padrao-listas-impl.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Impl: Ocultar a coluna de e-mail por padrão nas listas de pessoas
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-10
|
||||
Issue: #627
|
||||
Intenção: docs/plans/ocultar-email-por-padrao-listas.md
|
||||
Appetite restante: herdado (~0,5–1 dia eng)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** nas cinco listas de pessoas (lideranças, dobradinhas, pessoas, apoiadores, assessores) a coluna de e-mail nasce oculta para quem nunca tocou no seletor de colunas, e qualquer um pode religá-la pela mecânica B17 já existente — escolha lembrada por dispositivo (cookie `campaign_columns`). Na lista de pessoas, o e-mail sai da coluna "Contato" (que fica só com telefone) e vira coluna própria oculta. Na lista de assessores (hoje sem seletor), a mecânica B17 chega junto: ocultar por padrão + religar.
|
||||
- **O que NÃO negociar:** default oculto nas cinco listas; religar manualmente sempre possível (assessores incluído); quem já tem escolha salva no cookie continua com a escolha inteira respeitada (contrato atual do mecanismo — o default vale só para quem nunca mexeu); nada de reordenar colunas, redesenhar tabelas, mexer em fichas/cards mobile ou em outras colunas.
|
||||
- **O que reavaliar:** a intenção assume "só muda o default" nas quatro listas com seletor. Confirmado na leitura do código: `email` já é coluna própria em lideranças (`liderancas/page.tsx:160`) e dobradinhas (`dobradinhas/page.tsx:163`); em pessoas está embutida na célula "Contato" (`pessoas/page.tsx:135-149`) e em apoiadores **não existe coluna** (`SupporterList.tsx`) — precisa nascer (oculta). Assessores tem tabela própria client-side sem seletor (`AdvisorsTable.tsx`) — o encaixe é o ponto de maior risco.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[campaignColumnVisibility.ts<br/>+ assessores em CAMPAIGN_LIST_IDS] --> B[defaults por lista<br/>email oculto nas 5]
|
||||
B --> C[liderancas/dobradinhas<br/>só default]
|
||||
B --> D[pessoas: split Contato<br/>+ coluna email própria]
|
||||
B --> E[apoiadores: coluna email nova<br/>oculta por padrão]
|
||||
B --> F[assessores: picker B17<br/>+ th/td condicionais]
|
||||
C & D & E --> G[(cookie campaign_columns)]
|
||||
F --> G
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (defaults no mecanismo B17 + encaixe assessores) | B (merge do default com cookie existente) | C (converter AdvisorsTable para `CampaignTable`)
|
||||
**Recomendação:** A — o mecanismo já resolve "cookie ausente → default" (`resolveCampaignColumnVisibility`); as quatro listas `CampaignTable` viram uma linha no `DEFAULT_HIDDEN_COLUMN_IDS`; assessores ganha a MESMA mecânica (picker + cookie) sem reescrever a tabela.
|
||||
**Rejeitadas:**
|
||||
|
||||
- **B (merge default+cookie):** quebra o contrato B17 documentado ("hiding is stored, never the visible set") e a decisão de produto assumida ("quem já tem escolha salva continua" — `resolvedCampaignColumnVisibility` já a implementa). Usuário que mexeu no seletor de `pessoas` antes vê o e-mail visível; o default vale para quem nunca tocou. É o aceite.
|
||||
- **C (portar AdvisorsTable para CampaignTable):** a tabela tem modos client-side que o shell não modela (linha-draft de criação com validação, edição inline debounced por célula). Redesenho de superfície — corte explícito da intenção ("sem refactor da superfície").
|
||||
- **D (pessoas: esconder só a linha de e-mail dentro da célula Contato):** fora da mecânica de colunas — quem religa não ganha nada e o picker fica mentindo. Intenção recomenda A (coluna própria) e o código dá suporte trivial.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`DEFAULT_HIDDEN_COLUMN_IDS`** (`src/lib/campaignColumnVisibility.ts:70`): `liderancas: ['email']`, `dobradinhas: ['email']`, `pessoas: ['email']`, `apoiadores: ['email']`, `assessores: ['email']` — um comentário apontando B197.
|
||||
- **`CAMPAIGN_LIST_IDS`** (`campaignColumnVisibility.ts:20`): `+ 'assessores'` (primeira lista sem seletor a entrar — o id vira chave do cookie e label do picker).
|
||||
- **`liderancas/page.tsx` / `dobradinhas/page.tsx`:** zero mudança — o default resolve. (Verificar no e2e.)
|
||||
- **`pessoas/page.tsx`:** célula `contact` perde o `<span>` de e-mail (fica só telefone, `—` quando vazio); nova coluna `{ id: 'email', label: 'E-mail', cell: row.email ?? '—' }` logo após `contact` (posição de leitura natural; oculta no primeiro paint). Card mobile intocado (anti-goal).
|
||||
- **`SupporterList.tsx`:** nova coluna `{ id: 'email', label: 'E-mail', cell: (s) => s.email ?? '—' }` após `city` — `SupporterListItemViewModel.email` já existe (`supporterViewModels.ts:29`); `supporterPickerColumns` deriva e ganha a coluna automaticamente. Card mobile intocado.
|
||||
- **`assessores/page.tsx`:** `readCampaignColumnVisibility('assessores')`; `trailing={<CampaignColumnPickerTrailing columnVisibility={...} columns={advisorPickerColumns} />}` no `AdvisorFilters`; `columnVisibility` passado ao `AdvisorsTable`.
|
||||
- **`AdvisorFilters.tsx`:** prop `trailing?: ReactNode` repassada ao `CampaignListOmnibox` (slot já existe — padrão de `PeopleFilters`/`StateDeputyFilters`).
|
||||
- **`AdvisorsTable.tsx`** (client): prop `columnVisibility: CampaignColumnVisibility`; `emailHidden = hiddenColumnIds.includes('email')`; `<TableHead>` "E-mail" e o `<TableCell>` da linha só renderizam quando visível. **Exceção única:** a linha-draft de criação mantém o input de e-mail SEMPRE (e-mail é obrigatório para criar — `saveDraft` valida; coluna oculta esconde leitura, não o formulário). Em modo edição, a coluna oculta esconde também o input de e-mail — quem quer editar liga a coluna (um clique no picker; comportamento consistente com o pedido).
|
||||
- **Picker de assessores:** array estático no page (`advisorPickerColumns`): Nome (mandatory), E-mail, Celular, Municípios, Ações — rótulos idênticos aos `<th>`.
|
||||
- **Migration:** sem migration (apresentação pura; nada de schema/access/Consent; páginas já dinâmicas por auth — sem cache a invalidar).
|
||||
- **Access / Consent:** nenhum.
|
||||
- **UI:** Impeccable B — reusa integralmente os shells existentes (picker, cookie, trailing); nenhuma superfície nova.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Mecanismo (lib + unit):** `CAMPAIGN_LIST_IDS` + defaults; atualizar `campaignColumnVisibility.unit.spec.ts` (o teste da linha 35 que espera `assessores` **dropped** pelo parser agora passa a guardá-la; novos asserts de default por lista e do contrato "cookie existente vence o default"). Gate: `pnpm test` (unit do arquivo).
|
||||
2. **Quatro listas CampaignTable:** pessoas (split + coluna), apoiadores (coluna nova), liderancas/dobradinhas (nada a tocar — conferência visual/e2e). Gate: `pnpm exec tsc --noEmit` + unit.
|
||||
3. **Assessores (encaixe):** page + AdvisorFilters + AdvisorsTable (th/td condicionais + exceção do draft). Gate: `tsc` + e2e focado.
|
||||
4. **E2e:** novo `test.describe('E-mail oculto por padrão (B197)')` em `campaignColumnPicker.e2e.spec.ts` — em `/campanha/liderancas` o header "E-mail" nasce ausente, o picker mostra `1 oculta`, religar pinta a coluna e o reload persiste; em `/campanha/assessores` a coluna nasce oculta, o picker existe, religa e persiste, e a linha-draft continua com o input de e-mail. Gate: `pnpm test:e2e` (spec).
|
||||
5. **Gates finais:** `pnpm gate:fast` (lint zero, format, tsc, knip, cycles), `pnpm test` (unit+int), `pnpm test:e2e` (arquivo), `pnpm build` local; CHANGELOG-AGENTS (uma entrada curta) + planos commitados.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Merge default↔cookie por coluna (B rejeitada — contrato B17 intacto).
|
||||
- Portar `AdvisorsTable` para `CampaignTable` (C rejeitada — superfície).
|
||||
- "Já que estamos aqui": esconder telefone/municípios, reordenar, redesenhar cards mobile — fora de escopo (intenção).
|
||||
- Sincronizar com C112 (telefones, mesmas páginas): serialização externa — nada a fazer neste código.
|
||||
- Não mexer na largura `w-[24%]` do th de e-mail de assessores (table-fixed redistribui quando a coluna some).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **AdvisorsTable (client) dessincronizado do cookie:** o picker commita com `router.refresh()`; o server re-renderiza com o cookie novo e passa `hiddenColumnIds` de volta — mesma fonte de verdade das outras listas. Mitigação extra: prop derivada no server, nunca estado local no client.
|
||||
- **Linha-draft com 5 células vs header com 4 quando oculto:** desalinhamento visual só na linha de formulário; aceito (campo obrigatório). Comentário no código explicando a exceção.
|
||||
- **Staff com cookie antigo vê e-mail nas 4 listas:** decisão de produto assumida ("escolha existente é respeitada"); para assessores ninguém tem cookie ainda → 100% nascendo oculto.
|
||||
- **Teste unit existente que pina o parser descartando `assessores`:** atualização explícita (fase 1) — é o contrato MUDANDO de propósito.
|
||||
- **E2e pesado (duas rotas novas no spec do picker):** spec focado, `test.slow()` no assessores (rota de compilação dev), padrão REFRESH do arquivo.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (5 listas, religável, cookie respeitado, sem outras mudanças de coluna)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (nada de schema/access/Consent/migration; páginas dinâmicas sem cache)
|
||||
- [ ] Testes previstos: unit do mecanismo (defaults + parser assessores) e e2e do encaixe assessores + default nas listas
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# Impl: Restaurar pin de visibilidade CSS das colunas de rede em Territórios
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-10
|
||||
Issue: #616
|
||||
Intenção: docs/plans/restaurar-pin-visibilidade-colunas-territorios.md
|
||||
Appetite restante: herdado (~0,25 dia eng)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** um spec de browser mínimo pina que, a 2200px, os columnheaders de rede (Assessor / Liderança / Dobradinha) e o restante do contrato (sortables) de `/campanha/territorios` estão **visíveis** — visibilidade CSS real via container queries (`@min-[Nrem]/territory-list:table-cell`) — e Cobertura permanece oculta; verde no job e2e existente.
|
||||
- **O que NÃO negociar:** nada de re-migrar a família; não duplicar asserções do `campaignTerritoriesHttp` (presença no HTML + classes de rung continuam lá); zero mudança de CSS/UI; spec mínimo (só este comportamento).
|
||||
- **O que reavaliar:** a letra do manifesto `e2e-affected` da intenção ("rota `territorios` → specs `['campaignTerritoriesHttp', 'campaignTerritoriesColumns']`") — o prefixo de rota não acorda o spec quando o **arquivo das rungs** muda (ver D2; as rungs vivem em `src/components/campaign/municipality/TerritoryListColumns.tsx`, hoje mapeado para specs de municípios).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Spec[campaignTerritoriesColumns.e2e.spec.ts<br/>1 teste — corpo do antigo teste 3] --> Login[campaign.login<br/>viewport 2200x1000 antes do login]
|
||||
Login --> Goto[goto /campanha/territorios]
|
||||
Goto --> Sort[sortables visíveis<br/>Ordenar por 2022/Captura/2026/Classe/Assessoria]
|
||||
Sort --> Net[rede visível<br/>Assessor/Liderança/Dobradinha exact]
|
||||
Net --> Cobertura[Cobertura count 0]
|
||||
Manifest[e2e-affected: territórios<br/>+campaignTerritoriesColumns<br/>+prefixo TerritoryListColumns] --> Spec
|
||||
```
|
||||
|
||||
**Opções consideradas:** D1 (corpo do spec) A | B · D2 (manifesto) A | B
|
||||
**Recomendação:** corpo **exato** do antigo teste 3 (D1-A) + manifesto com o spec **e** o prefixo do arquivo das rungs (D2-B) — porque o corpo restaurado já foi aceito por produto (B175) e o prefixo extra é o que faz o pin acordar exatamente quando o CSS que ele protege muda.
|
||||
**Rejeitadas:** D1-B (versão enxuta só com as 3 colunas de rede) porque perde o pin de "Cobertura oculta" e dos sortables sem ganho de custo; D2-A (só a letra da intenção) porque deixa o spec mudo exatamente para a regressão que o motivou.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`tests/e2e/campaignTerritoriesColumns.e2e.spec.ts`** (novo, projeto `campaign` — casa `/campaign.*\.e2e\.spec\.ts/`): um teste que restaura o corpo do teste 3 do spec deletado (`campaignTerritories.e2e.spec.ts` em `1f31d1b6^`), com o mesmo comentário B175 e a mesma ordem: `page.setViewportSize({ width: 2200, height: 1000 })` → `campaign.login(page, email, password)` → `page.goto('/campanha/territorios')` → loop de sortables (`getByRole('columnheader', { name: new RegExp(\`^Ordenar por ${header}\`) })`visível para`['2022', 'Captura', '2026', 'Classe', 'Assessoria']`) → loop da rede (`columnheader`exact`['Assessor', 'Liderança', 'Dobradinha']`visível) →`columnheader` `'Cobertura'`com`toHaveCount(0)`. Cabeçalho do arquivo documenta a divisão de trabalho com o spec HTTP (presença + rungs no HTML são do HTTP; visibilidade em viewport é browser) e por que ele existe (OPS35+).
|
||||
- **`scripts/lib/e2e-affected-manifest.mjs`** (entrada territórios): `specs: ['campaignTerritoriesHttp']` → `['campaignTerritoriesHttp', 'campaignTerritoriesColumns']` e `prefixes` ganha `src/components/campaign/municipality/TerritoryListColumns` (arquivo das rungs; startsWith prefixo-de-arquivo é válido no contrato do manifesto — mesmos checks unit de `e2eAffectedManifest.unit.spec.ts` seguem passando).
|
||||
- **Migration:** sem migration.
|
||||
- **Access / Consent:** sem mudança.
|
||||
- **UI:** Impeccable A — N/A (infra de testes; sem UI nova).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Spec + manifesto** — criar o spec novo e a entrada do manifesto. Prova de não-vazio (muta): com `@min-[60rem]/territory-list` de `advisor` temporariamente alterado para `@min-[200rem]`, o spec deve falhar em `Assessor`; reverter. Depois rodar os pins unit afetados: `pnpm test:unit tests/unit/e2eAffectedManifest.unit.spec.ts tests/unit/ciSkipInvariants.unit.spec.ts tests/unit/testAffected.unit.spec.ts`.
|
||||
2. **E2e local** — `pnpm test:e2e tests/e2e/campaignTerritoriesColumns.e2e.spec.ts` (dev, worktree: `teqo_wt35_test`, porta 3135) verde; conferir que o job CI continua verde no PR (o spec novo entra no `campaign` project).
|
||||
3. **Gates** — `pnpm gate:fast` (lint + tsc + unit), `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`; entrega via `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Re-migrar famílias ou tocar o `campaignTerritoriesHttp`** — fora de escopo; a equivalência HTTP continua como está.
|
||||
- **Pinar rungs das outras listas** (municípios, lideranças…) — rabbit hole de produto já catalogado na intenção; o pin de municípios já existe (`campaignMunicipalityResponsiveColumns`).
|
||||
- **Cobrir mudanças de `tailwind.config`/`globals.css` no manifesto** — gap do OPS5 conhecido, não desta issue; uma regressão de container-query Tailwind que quebre a rung também quebra o pin via `TerritoryListColumns.tsx` quando o arquivo mudar.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Flake de viewport/container:** o corpo é exatamente o que passou em CI por semanas antes do OPS35 (2200px com sidebar expandida deixa o container acima de 72rem). Sem estado novo, sem interação — risco baixo.
|
||||
- **Custo do job e2e:** +1 teste browser (~5–10 s dev; <1 s prod) — o trade do OPS35 foi explícito: viewports continuam em browser.
|
||||
- **Prefixo novo no manifesto muda o alcance do `selectE2eSpecs`:** mudanças em `TerritoryListColumns.tsx` passam a rodar os 2 specs de territórios **além dos** specs de municípios (o seletor faz união de entradas) — é o comportamento desejado (a regressão de CSS de territórios acorda o pin de territórios).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (corpo do teste 3 restaurado; nada de CSS/HTTP tocado)
|
||||
- [x] Invariantes AGENTS/engineering-standards (spec novo mapeado no manifesto; unit pins verdes; sem twin de fixtures — `campaign.login` reusado)
|
||||
- [x] Testes de domínio previstos: spec e2e novo + prova de não-vazio (mutação revertida) + pins unit do manifesto
|
||||
84
docs/plans/telefone-nao-unico-impl.md
Normal file
84
docs/plans/telefone-nao-unico-impl.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Impl: Telefone não único — duas pessoas podem compartilhar o mesmo celular
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-10
|
||||
Issue: #625
|
||||
Intenção: docs/plans/telefone-nao-unico.md
|
||||
Appetite restante: herdado (~0,5–1 dia eng) — a mudança é de comportamento de escrita, sem schema e sem UI nova.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** o telefone deixa de ser único em enforcement; qualquer fluxo de escrita de pessoa (liderança, apoiador, dobradinha, assessor/conta, pessoa) salva com número já usado por outra ficha; fluxos automáticos (import CSV, convite/login por telefone) nunca "chutam" qual pessoa — falham fechado com mensagem clara ou usam a chave não ambígua (a conta/username).
|
||||
- **O que NÃO negociar:** os 5 fluxos salvam sem erro; import/convite não casam por chute em ambiguidade; busca por telefone continua achando todas as fichas; nenhum outro campo de `Contact` muda; mensagens de conflito ("Já existe outro contato com este celular.") somem dos fluxos de gestão; nada de reconciliação de fichas (anti-goal).
|
||||
- **O que reavaliar:**
|
||||
- O inventário provou que a unicidade é **enforcement-only** (hook `enforceUniqueContactPhone` + `assertContactPhone*`; `contact_phone_idx` é índice comum, não UNIQUE — migration `20260718_010733_consolidate_campaign_schema.ts:407`). Consequência: **SEM MIGRATION**, sem schema.
|
||||
- A hipótese da intenção ("falha fechada onde o número é ambíguo") vale para o **import**; para a **criação de pessoa**, 2+ fichas com o mesmo phone deixa de ser estado excepcional (vira o normal do produto) → o find-or-create **cria ficha nova** em vez de lançar AMBIGUOUS — a identidade da pessoa em criação é a ficha recém-criada (não é chute; o staff digitou o nome).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Morre[Unicidade morre]
|
||||
Hook[enforceUniqueContactPhone / skipContactPhoneInvariant] --> Removido
|
||||
AssertW[assertContactPhoneWritable] --> Removido
|
||||
AssertA[assertContactPhoneAvailable] --> Removido
|
||||
end
|
||||
subgraph Fica[O que fica]
|
||||
Locks[contactPhoneLockKeys / acquireContactPhoneLocks] --> Renomeado[contactPhoneLocks.ts]
|
||||
FOC[findOrCreateContactByPhone] --> 0cria[0 fichas → cria]
|
||||
FOC --> 1reusa[1 ficha → reusa — matching onde existe]
|
||||
FOC --> 2cria[2+ fichas → cria ficha nova — identidade é a ficha nova]
|
||||
Import[preview + confirm import CSV] --> Amb[phone com 2+ fichas → sinaliza telefone_compartilhado; confirm re-checa fail-closed com locks]
|
||||
Invite[redeemCampaignInvite] --> Username[check de contas por username permanece — login é de conta]
|
||||
end
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (remover enforcement, find-or-create cria ficha nova em 2+) | B (remover enforcement, manter AMBIGUOUS no find-or-create) | C (manter unicidade e só "avisar" no cliente).
|
||||
**Recomendação:** **A** — satisfaz o aceite literal ("salva sem erro em qualquer um dos 5 fluxos"): com phone compartilhado virando estado normal, o caso 2+ no find-or-create precisa salvar, e reusar uma das 2+ fichas seria o chute que a intenção proíbe; a ficha nova é a identidade da pessoa que o staff acabou de digitar. No import, o casamento por phone (1 ficha) permanece e a ambiguidade sinaliza (recomendação A da própria intenção).
|
||||
**Rejeitadas:** B — 2+ fichas com o mesmo phone é o caso-núcleo do C111 (duas pessoas compartilhando); manter AMBIGUOUS bloquearia o cadastro da terceira pessoa e reproduziria o problema que a entrega resolve. C — mascarar o erro no cliente mantém a base inconsistente com o produto (o estado compartilhado é desejado) e não desbloqueia os fluxos server-side.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/utilities/contactPhoneInvariant.ts` → `src/utilities/contactPhoneLocks.ts`** (rename, "edit the owner"): ficam `contactPhoneLockKeys` + `acquireContactPhoneLocks` (+ import de `POSTGRES_DEDUP_LOCK_MESSAGE`); saem `assertContactPhoneAvailable`, `assertContactPhoneWritable`, `CONTACT_PHONE_CONFLICT_MESSAGE` (sem usos restantes) e `CONTACT_PHONE_AMBIGUOUS_MESSAGE` (o find-or-create não lança mais; o import ganha mensagem própria). O lock `contact-phone:<phone>` passa a significar **serialização de leitura-escrita por phone** (find-or-create, import, tombstone, convite) — não mais unicidade. Rename é mecânico: ~5 imports de runtime + testes + allowlist `codebaseConventions.unit.spec.ts:434`.
|
||||
- **`src/collections/Contact.ts`**: remove o hook `enforceUniqueContactPhone` do `beforeChange` (fica `enforceStateDeputyName`). `beforeValidate` de phone intocado.
|
||||
- **`src/utilities/contactIdentity.ts`** (`findOrCreateContactByPhone`): 0 fichas → cria (mantém); 1 → reusa (mantém, dedupe C6/C99); **2+ → cria ficha nova** com o phone (a ficha nova é a identidade da pessoa em criação; o phone compartilhado é permitido). Remove o `context.skipContactPhoneInvariant` (o contrato morre junto do hook; o create via Local API roda sem hook de unicidade). Locks permanecem.
|
||||
- **`src/app/(campaign)/campanha/actions/leadership.ts`** (`:266`, `:335`) e **`stateDeputy.ts`** (`:187`): removem `assertContactPhoneWritable` — o update de phone simplesmente passa pelo `payload.update` da ficha (sem hook de unicidade) e compartilha.
|
||||
- **`src/utilities/campaignInviteRedemption.ts`** (`:132`, `:210`): removem `assertContactPhoneAvailable` (autofill e login) — a ficha do convite é conhecida (via `leadership.contact`); o número digitado pode coincidir com outra ficha. **Permanece** o check de contas por `username` (`:217-232`, `limit: 2` + INVALID se o username é de outra conta) — `campaignUser.username` é UNIQUE no banco (DB-level, intocado) e é a chave do login: quem não tem número exclusivo usa e-mail (recomendação A da intenção).
|
||||
- **`src/collections/CampaignUser.ts`** (`ensureCampaignUserContactIdentity`): **sem mudança de código** — o sync conta→ficha agora passa porque o hook da ficha não rejeita mais; o phone compartilhado é o estado desejado.
|
||||
- **Import CSV**:
|
||||
- **`src/app/(campaign)/campanha/actions/supporterImport.ts`** — preview (`:224-264`): o `Map<phone,id>` para de sobrescrever silenciosamente; quando um phone do CSV resolve **2+ fichas**, todas as linhas com esse phone viram status novo `telefone_compartilhado` (resolução manual). Confirm (`:331-346`): após `acquireContactPhoneLocks`, **re-checa** os phones ok contra a base e aborta com mensagem clara se algum ficou ambíguo (a base pode ter mudado desde o preview).
|
||||
- **`src/utilities/supporter/supporterImportBulk.ts`** (`:169-183`): o `SELECT … WHERE phone IN` passa a detectar phone com 2+ ids → **aborta** com a mesma mensagem (fail-closed defensivo; em produção o re-check do confirm já pega).
|
||||
- **`src/utilities/supporter/supporterImport.ts`** (types): status `telefone_compartilhado`; `isPreviewErrorRow` segue falso para ele (entra em `counts.duplicate`, "resolução manual" como o duplicado).
|
||||
- **`src/components/campaign/supporter/SupporterImportWizard.tsx`** (`:38-45`): rótulo novo `telefone_compartilhado: 'Telefone compartilhado (resolver)'`. Impeccable A — nenhuma superfície nova, só um status/label no wizard existente.
|
||||
- **formActions safeMessages** (7 arquivos: `wizardLeadershipFormActions`, `assessores`, `liderancas`, `liderancas/nova`, `dobradinhas`, `apoiadores/novo`, `leaderSupporter.ts`): removem entradas órfãs de `CONTACT_PHONE_CONFLICT_MESSAGE`/`CONTACT_PHONE_AMBIGUOUS_MESSAGE` (nada mais lança essas mensagens). `apoiadores/novo` e `liderancas/nova` perdem o import inteiro da constante.
|
||||
- **`src/utilities/people/personDelete.ts`** (`:149`) e **`supporter.ts`** (`:22,270`): **sem mudança** — tombstone `999<id>` continua único por id e os locks permanecem (serialização).
|
||||
- **Migration:** nenhuma (unicidade enforcement-only, confirmado no inventário).
|
||||
- **Access / Consent:** nenhuma mudança — nada de novo escrito por Local API com ator não verificado; os `overrideAccess` existentes mantêm suas justificativas (o hook de unicidade que os exigia some).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer — morre a unicidade:** rename do módulo + remoção do hook/asserts/mensagens + find-or-create 2+ → cria; atualizar os testes que pinam CONFLICT/AMBIGUOUS (`campaignContactIdentity`, `campaignLeadership`, `campaignMunicipalityStateDeputyCreate`, `postgresTransactionLocks`, `campaignInviteArchitecture`, `codebaseConventions`). Gates: `pnpm gate:fast`.
|
||||
2. **Import + convite:** status `telefone_compartilhado` no preview (com re-check fail-closed no confirm e no bulk); remoção dos asserts do convite mantendo o check de contas; novas coberturas int: liderança com phone de outra ficha (create/update) salva; apoiador com phone compartilhado; import com phone ambíguo na base sinaliza e confirm aborta; invite autofill com phone de outra ficha passa.
|
||||
3. **Gates:** `pnpm gate:fast` bare; `pnpm build` local; review de diff de testes/rename.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Tornar `username` não-único para "duas contas no mesmo número logarem"** — login é de conta (recomendação A da intenção); a 2ª pessoa usa e-mail. `username` UNIQUE é DB-level e fica.
|
||||
- **Adicionar coluna/índice novo em `Contact`** — nada necessário; `phone` já tem índice.
|
||||
- **Reconciliar as fichas duplicadas** que a mudança passa a permitir — anti-goal explícito; fica pós-eleição.
|
||||
- **Migrar o dedupe do seed-minimal / scripts** (`seed-minimal.mjs` upsert por phone) — scripts operam em DBs controlados; sem mudança.
|
||||
- **Mudar `CONTACT_PHONE_AMBIGUOUS_MESSAGE` de lugar para o import** — a mensagem nova do import é específica (fala de linha/CSV); a constante antiga morre junto do módulo.
|
||||
- **Achado de execução — drift C6 `supporter_import_batch.actor_id` NOT NULL + FK `ON DELETE set null`:** deletar o `campaignUser` dono de um batch não consumido (janela de 10 min, sem sweep) falha com 23502 e aborta a transação (25P02 em cascata). O teste novo de import com batch órfão expôs. Corrigido em escopo: `CampaignUser.beforeDelete` ganha `deleteCampaignUserImportBatches` (mesmo padrão de passkeys/notifications; o `deletePersonRecord` já tratava manualmente) + o fixture cleanup descobre/deleta batches dos accounts owned. Sem migration (correção via hook; a coluna NOT NULL continua sendo a guarda de dados).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Regressão do dedupe (reuso de 1 ficha):** o find-or-create mantém 0→cria / 1→reusa; apenas o caso 2+ muda (de falha para ficha nova). Testes de reuso existentes (`campaignLeadership.int.spec.ts:159-177`, `:285-310`) devem continuar passando — o único teste que muda nessa área é o de create direto de Contact duplicado (hook removido).
|
||||
- **Import com base ambígua muda de "Map sobrescreve" (silencioso) para sinalizado/abort:** é o comportamento desejado (fail-closed), mas muda counts do preview em bases já duplicadas — coberto por teste novo; a mensagem é clara e a linha vai ao relatório/resolução manual.
|
||||
- **Sync conta→ficha (C99) passa a compartilhar phone:** o teste `campaignContactIdentity.int.spec.ts:184-210` que pina o rollback do CONFLICT vira teste do novo comportamento (phone compartilhado sincroniza). A ficha do convite/account continua sendo a âncora — sem chute.
|
||||
- **Lock de phone agora serializa pessoas distintas** (duas pessoas compartilhando o número): granularidade grossa, mas os locks só cobrem janelas de escrita curtas (find-or-create, import); sem impacto funcional.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto: 5 fluxos salvam com phone compartilhado; import/convite falham fechado (ou chave não ambígua); busca por phone intacta; zero mudança em outros campos de `Contact`
|
||||
- [x] Invariantes AGENTS/engineering-standards: sem migration destrutiva; sem nova coleção; overrideAccess existentes com justificativa mantida; pt-BR nas mensagens; identificadores em inglês
|
||||
- [x] Testes de domínio previstos (unit/int) onde write paths mudam: atualizados (`campaignContactIdentity`, `campaignLeadership`, `campaignMunicipalityStateDeputyCreate`, `postgresTransactionLocks`, `campaignInviteArchitecture`, `codebaseConventions`) + novos (compartilhamento em leadership/supporter/import/invite; batch órfão no delete de conta)
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate && cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build",
|
||||
"db:start": "docker compose -p teqo up -d --wait postgres",
|
||||
"db:start": "node scripts/db-start.mjs",
|
||||
"db:stop": "docker compose -p teqo down",
|
||||
"db:doctor": "node scripts/db-doctor.mjs",
|
||||
"db:pull": "node scripts/db-pull.mjs",
|
||||
|
|
|
|||
55
scripts/db-start.mjs
Normal file
55
scripts/db-start.mjs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* `pnpm db:start` — starts the ONE shared local Postgres container
|
||||
* (`compose -p teqo`), or does nothing when it is already up and healthy.
|
||||
*
|
||||
* The no-op guard matters because the container is shared by every worktree
|
||||
* (see scripts/lib/db-start.mjs): a plain `compose up` from another worktree
|
||||
* recreates it and kills every live dev/e2e connection. Recreate on purpose:
|
||||
* `pnpm db:start --force-recreate` (image bump / config change).
|
||||
*/
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { startSharedPostgres } from './lib/db-start.mjs'
|
||||
|
||||
const USAGE = `Usage: pnpm db:start [--force-recreate]
|
||||
|
||||
Starts the shared local Postgres container (compose project "teqo").
|
||||
No-op when the container is already up and healthy.
|
||||
|
||||
Options:
|
||||
--force-recreate Recreate the container even when it is healthy
|
||||
(apply a real compose/image change on purpose).
|
||||
-h, --help Show this help.`
|
||||
|
||||
const main = async () => {
|
||||
const args = process.argv.slice(2)
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(USAGE)
|
||||
process.exit(0)
|
||||
}
|
||||
const unknown = args.filter((arg) => arg !== '--force-recreate')
|
||||
if (unknown.length > 0) {
|
||||
console.error(`[db] Unknown option: ${unknown.join(', ')}`)
|
||||
console.error(USAGE)
|
||||
process.exit(2)
|
||||
}
|
||||
const forceRecreate = args.includes('--force-recreate')
|
||||
|
||||
try {
|
||||
await startSharedPostgres({ cwd: process.cwd(), forceRecreate })
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
console.error('[db] Docker looks unreachable — is Docker running?')
|
||||
console.error('[db] Start Docker and retry: pnpm db:start')
|
||||
} else {
|
||||
const message = error.stderr?.toString()?.trim() || error.message
|
||||
console.error(`[db] Failed to start the local Postgres: ${message.split('\n')[0]}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
||||
await main()
|
||||
}
|
||||
136
scripts/lib/db-start.mjs
Normal file
136
scripts/lib/db-start.mjs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Starts the ONE shared local Postgres container (`compose -p teqo`) from any
|
||||
* worktree, WITHOUT recreating it when it is already up and serving.
|
||||
*
|
||||
* Why the guard exists (OPS37, 2026-08-10): docker-compose.yml binds the
|
||||
* init scripts via a RELATIVE path (`./docker/postgres/init`), so compose
|
||||
* resolves a different absolute mount source per worktree and computes a
|
||||
* different `config_hash` for the same pinned project. A plain
|
||||
* `docker compose -p teqo up` from any other worktree therefore decides the
|
||||
* running container is stale and REPLACES it — killing every live connection
|
||||
* (dev servers and e2e runs of ALL worktrees die with `terminating connection
|
||||
* due to administrator command`). The bind mount only matters on the FIRST
|
||||
* volume initialization; after that it is dead weight in the hash.
|
||||
*
|
||||
* The container must be immutable while healthy: `db:start` and worktree
|
||||
* provisioning skip compose entirely when `teqo-postgres-1` is running and
|
||||
* healthy — or, when a peer worktree is still booting it (`starting`), wait
|
||||
* for that boot instead of replacing the container mid-healthcheck.
|
||||
* Legitimate recreation (image bump, config change) stays possible via an
|
||||
* explicit `--force-recreate`.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
/** Fixed compose project so `db:start`/provisioning always target ONE container. */
|
||||
const SHARED_COMPOSE_PROJECT = 'teqo'
|
||||
|
||||
/** Compose names the project's service container `<project>-<service>-1`. */
|
||||
const SHARED_POSTGRES_CONTAINER = 'teqo-postgres-1'
|
||||
|
||||
/** How long to wait for a peer's `starting` container before falling back to compose. */
|
||||
const STARTING_WAIT_MS = 60_000
|
||||
|
||||
/** Poll interval while waiting for a `starting` container. */
|
||||
const STARTING_POLL_INTERVAL_MS = 2_000
|
||||
|
||||
/**
|
||||
* Pure decision: skip compose entirely when the shared container is already
|
||||
* up and serving (`healthy`), or being brought up by a peer worktree right
|
||||
* now (`starting` — replacing it then would be a pointless race; we wait for
|
||||
* that boot instead). Anything else (stopped, unhealthy, no healthcheck)
|
||||
* falls through to compose up, which is safe: no live connections exist to
|
||||
* kill, and a pre-healthcheck container gets migrated back to the current
|
||||
* config.
|
||||
*
|
||||
* @returns {boolean} true when compose must NOT run.
|
||||
*/
|
||||
export const shouldSkipStart = ({ running, health }) =>
|
||||
running === true && (health === 'healthy' || health === 'starting')
|
||||
|
||||
/**
|
||||
* Parse `docker inspect --format '{{json .State}}'` output into
|
||||
* `{ running, health }`. `health` is empty when the container predates the
|
||||
* compose healthcheck (no `Health` object) — that never skips, so `db:start`
|
||||
* brings the container back to the current config exactly once.
|
||||
*
|
||||
* @returns {{ running: boolean, health: string } | null} null on unparseable output.
|
||||
*/
|
||||
export const parsePostgresContainerHealth = (jsonText) => {
|
||||
try {
|
||||
const state = JSON.parse(jsonText)
|
||||
return {
|
||||
running: state.Running === true,
|
||||
health: typeof state.Health?.Status === 'string' ? state.Health.Status : '',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect the shared container's state. Returns null when it does not exist
|
||||
* (compose will create it). Throws when Docker itself is unreachable — the
|
||||
* caller decides the remedy (CLI prints one; worktree provisioning falls back
|
||||
* to the shared databases).
|
||||
*
|
||||
* @returns {{ running: boolean, health: string } | null}
|
||||
*/
|
||||
const inspectSharedPostgres = () => {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
'docker',
|
||||
['inspect', '--format', '{{json .State}}', SHARED_POSTGRES_CONTAINER],
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
)
|
||||
return parsePostgresContainerHealth(output)
|
||||
} catch (error) {
|
||||
const stderr = error.stderr?.toString() ?? ''
|
||||
if (stderr.includes('No such object')) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a `starting` container to become `healthy`, bounded. Returns true
|
||||
* only when it is serving; false when it disappears, turns `unhealthy`, or
|
||||
* the timeout elapses — callers then fall back to compose up (no live
|
||||
* connections exist to kill on a container that never became healthy).
|
||||
* Timeouts are injectable so unit tests run without real waits.
|
||||
*/
|
||||
export const waitForHealthyPostgres = async ({
|
||||
timeoutMs = STARTING_WAIT_MS,
|
||||
intervalMs = STARTING_POLL_INTERVAL_MS,
|
||||
} = {}) => {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
||||
const state = inspectSharedPostgres()
|
||||
if (!state) return false
|
||||
if (state.health === 'healthy') return true
|
||||
if (state.health === 'unhealthy') return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Start (or reuse) the shared Postgres container from `cwd` — a no-op when it
|
||||
* is already up and serving, unless `forceRecreate` explicitly asks for a
|
||||
* recreate (image bump / config change). Creates the container when missing,
|
||||
* starts it when stopped, and waits for a peer's fresh container instead of
|
||||
* replacing it mid-healthcheck — always with `--wait` for the healthcheck.
|
||||
*/
|
||||
export const startSharedPostgres = async ({ cwd, forceRecreate = false }) => {
|
||||
const state = inspectSharedPostgres()
|
||||
if (!forceRecreate && state && shouldSkipStart(state)) {
|
||||
if (state.health === 'healthy' || (await waitForHealthyPostgres())) {
|
||||
console.log(`[db] ${SHARED_POSTGRES_CONTAINER} already up (healthy) — nothing to recreate.`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const args = ['compose', '-p', SHARED_COMPOSE_PROJECT, 'up', '-d']
|
||||
if (forceRecreate) args.push('--force-recreate')
|
||||
args.push('--wait', '--wait-timeout', '120', 'postgres')
|
||||
execFileSync('docker', args, { cwd, stdio: 'inherit' })
|
||||
}
|
||||
|
|
@ -67,8 +67,11 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
`${CAMPAIGN_APP}/territorios`,
|
||||
'src/components/campaign/tour',
|
||||
'src/utilities/territory',
|
||||
// OPS35+ — the network-column rungs live in this file; a rung regression
|
||||
// must wake the CSS-visibility browser pin, not only the municipios specs.
|
||||
'src/components/campaign/municipality/TerritoryListColumns',
|
||||
],
|
||||
specs: ['campaignTerritoriesHttp'],
|
||||
specs: ['campaignTerritoriesHttp', 'campaignTerritoriesColumns'],
|
||||
},
|
||||
{
|
||||
prefixes: [`${CAMPAIGN_APP}/conceitos`],
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ import {
|
|||
nextClaimableIssue,
|
||||
parseArgs,
|
||||
} from './lib/agent-github.mjs'
|
||||
import { startSharedPostgres } from './lib/db-start.mjs'
|
||||
import {
|
||||
DEV_PORT_BASE,
|
||||
GENERATED_ENV_MARKER,
|
||||
|
|
@ -143,8 +144,6 @@ const printLaunchDirective = ({ dir, purpose, issueNumber }) => {
|
|||
if (line) console.log(line)
|
||||
}
|
||||
|
||||
/** Fixed compose project so `db:start`/provisioning always target ONE container. */
|
||||
const SHARED_COMPOSE_PROJECT = 'teqo'
|
||||
/** Maintenance database on the shared local container — only ever localhost. */
|
||||
const MAINTENANCE_DATABASE_URL = 'postgresql://teqo:teqo@localhost:5432/postgres'
|
||||
|
||||
|
|
@ -221,15 +220,6 @@ const envFileIsGenerated = (dir, name) => {
|
|||
}
|
||||
}
|
||||
|
||||
/** Start (or reuse) the ONE shared Postgres container from any worktree dir. */
|
||||
const dockerComposeUp = (dir) => {
|
||||
execFileSync(
|
||||
'docker',
|
||||
['compose', '-p', SHARED_COMPOSE_PROJECT, 'up', '-d', '--wait', 'postgres'],
|
||||
{ cwd: dir, stdio: 'inherit' },
|
||||
)
|
||||
}
|
||||
|
||||
/** `CREATE DATABASE` for each missing generated name on the shared container. */
|
||||
const ensureDatabases = async (names) => {
|
||||
const client = new pg.Client({
|
||||
|
|
@ -332,7 +322,8 @@ const provision = async ({ dir, branch, issue, env, skipMigrate, mainRoot, purpo
|
|||
const payloadSecret = mainEnv.PAYLOAD_SECRET ?? randomBytes(24).toString('hex')
|
||||
|
||||
try {
|
||||
dockerComposeUp(dir)
|
||||
// Start (or reuse) the ONE shared Postgres container from any worktree dir.
|
||||
await startSharedPostgres({ cwd: dir })
|
||||
} catch (error) {
|
||||
console.warn('[worktree] Docker indisponível — usando o banco compartilhado (teqo/teqo_test).')
|
||||
console.warn(`[worktree] (${error.message.split('\n')[0]})`)
|
||||
|
|
|
|||
|
|
@ -16,17 +16,11 @@ import {
|
|||
runCampaignFormAction,
|
||||
type CampaignFormActionState,
|
||||
} from '@/utilities/campaignFormActionError'
|
||||
import {
|
||||
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
|
||||
CONTACT_PHONE_CONFLICT_MESSAGE,
|
||||
} from '@/utilities/contactPhoneInvariant'
|
||||
|
||||
const safeMessages = [
|
||||
LEADERSHIP_DUPLICATE_MESSAGE,
|
||||
LEADERSHIP_INVALID_CONTACT_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
|
||||
CONTACT_PHONE_CONFLICT_MESSAGE,
|
||||
...leadershipStaffEditSafeMessages,
|
||||
] as const
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import {
|
|||
supporterCreateSchema,
|
||||
} from '@/lib/schemas/supporter'
|
||||
import { runCampaignFormAction } from '@/utilities/campaignFormActionError'
|
||||
import { CONTACT_PHONE_AMBIGUOUS_MESSAGE } from '@/utilities/contactPhoneInvariant'
|
||||
|
||||
export type SupporterFormState = {
|
||||
status?: 'success'
|
||||
|
|
@ -38,7 +37,6 @@ type SupporterFormValues = {
|
|||
|
||||
const safeActionMessages = [
|
||||
SUPPORTER_DUPLICATE_MESSAGE,
|
||||
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
|
||||
SUPPORTER_STAFF_MESSAGE,
|
||||
SUPPORTER_UNSCOPED_COORDINATOR_MESSAGE,
|
||||
SUPPORTER_REGISTRATION_CONSENT_MISSING_MESSAGE,
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-col gap-2 pt-4 sm:flex-row sm:justify-end md:pt-0">
|
||||
{isCampaignCoordinator(user) ? (
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href="/campanha/apoiadores/importar">
|
||||
|
|
|
|||
|
|
@ -18,21 +18,10 @@ import {
|
|||
runCampaignFormAction,
|
||||
type CampaignFormActionState,
|
||||
} from '@/utilities/campaignFormActionError'
|
||||
import {
|
||||
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
|
||||
CONTACT_PHONE_CONFLICT_MESSAGE,
|
||||
} from '@/utilities/contactPhoneInvariant'
|
||||
|
||||
const INVALID_FIELD_MESSAGE = 'Campo inválido.'
|
||||
|
||||
// The C99 ficha-sync fails the account edit with the ficha's own phone
|
||||
// messages when the phone belongs to another ficha (or to two).
|
||||
const advisorSafeMessages = [
|
||||
...ADVISOR_ACTION_SAFE_MESSAGES,
|
||||
INVALID_FIELD_MESSAGE,
|
||||
CONTACT_PHONE_CONFLICT_MESSAGE,
|
||||
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
|
||||
] as const
|
||||
const advisorSafeMessages = [...ADVISOR_ACTION_SAFE_MESSAGES, INVALID_FIELD_MESSAGE] as const
|
||||
|
||||
export const updateAdvisorProfileFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
|
|
|
|||
|
|
@ -4,12 +4,17 @@ import { getPayload } from 'payload'
|
|||
|
||||
import { AdvisorFilters } from '@/components/campaign/advisor/AdvisorFilters'
|
||||
import { AdvisorsTable } from '@/components/campaign/advisor/AdvisorsTable'
|
||||
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
|
||||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import {
|
||||
toCampaignColumnPickerColumns,
|
||||
type CampaignColumnPickerColumn,
|
||||
} from '@/lib/campaignColumnVisibility'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
|
||||
import { hasAdvisorListActiveFilters } from '@/utilities/advisor/advisorListFilters'
|
||||
|
|
@ -18,6 +23,7 @@ import {
|
|||
loadAdvisorListPageData,
|
||||
parseAdvisorListParams,
|
||||
} from '@/utilities/advisorData'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import { firstValue } from '@/utilities/campaignListUrl'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex'
|
||||
|
|
@ -34,6 +40,17 @@ type AdvisorsPageProps = {
|
|||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
// B197 — labels mirror the `<th>` copy of `AdvisorsTable`; the table's own
|
||||
// columns are hand-rolled (not `CampaignTableColumn`), so the picker menu is
|
||||
// spelled out here instead of derived.
|
||||
const advisorPickerColumns: readonly CampaignColumnPickerColumn[] = [
|
||||
{ id: 'name', label: 'Nome', mandatory: true },
|
||||
{ id: 'email', label: 'E-mail' },
|
||||
{ id: 'phone', label: 'Celular' },
|
||||
{ id: 'municipalities', label: 'Municípios' },
|
||||
{ id: 'actions', label: 'Ações' },
|
||||
]
|
||||
|
||||
export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps) {
|
||||
const rawSearchParams = await searchParams
|
||||
const [, payload] = await Promise.all([
|
||||
|
|
@ -43,7 +60,8 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps)
|
|||
|
||||
const state = parseAdvisorListParams(rawSearchParams)
|
||||
const autoCreateDraft = firstValue(rawSearchParams[ADVISOR_QUICK_CREATE_PARAM]) === '1'
|
||||
const [{ rows, totalDocs, totalPages }, municipalityIndex] = await Promise.all([
|
||||
const [columnVisibility, { rows, totalDocs, totalPages }, municipalityIndex] = await Promise.all([
|
||||
readCampaignColumnVisibility('assessores'),
|
||||
loadAdvisorListPageData(payload, state),
|
||||
loadMunicipalityPortfolioIndex(),
|
||||
])
|
||||
|
|
@ -58,13 +76,23 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps)
|
|||
return (
|
||||
<CampaignPageShell>
|
||||
<CampaignListPendingBoundary>
|
||||
<AdvisorFilters state={state} municipalityFilterOptions={municipalityFilterOptions} />
|
||||
<AdvisorFilters
|
||||
state={state}
|
||||
municipalityFilterOptions={municipalityFilterOptions}
|
||||
trailing={
|
||||
<CampaignColumnPickerTrailing
|
||||
columnVisibility={columnVisibility}
|
||||
columns={toCampaignColumnPickerColumns(advisorPickerColumns)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<CampaignListResults>
|
||||
<AdvisorsTable
|
||||
rows={rows}
|
||||
municipalityIndex={municipalityIndex}
|
||||
hasQuery={hasActiveFilters}
|
||||
columnVisibility={columnVisibility}
|
||||
updateProfileAction={updateAdvisorProfileFormAction}
|
||||
municipalitiesAction={setAdvisorMunicipalitiesFormAction}
|
||||
createAction={createAdvisorFormAction}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export default async function ActivityListPage({ searchParams }: ActivityListPag
|
|||
return (
|
||||
<CampaignPageShell>
|
||||
{canCreate ? (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<div className="flex flex-wrap justify-end gap-2 pt-4 md:pt-0">
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={TOUR_COMPOSER_PATH}>
|
||||
<MapPinnedIcon data-icon="inline-start" aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end pt-4 md:pt-0">
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/demandas/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -30,10 +30,6 @@ import {
|
|||
runCampaignFormAction,
|
||||
type CampaignFormActionState,
|
||||
} from '@/utilities/campaignFormActionError'
|
||||
import {
|
||||
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
|
||||
CONTACT_PHONE_CONFLICT_MESSAGE,
|
||||
} from '@/utilities/contactPhoneInvariant'
|
||||
|
||||
const safeMessages = [LEADERSHIP_STAFF_MESSAGE, LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE] as const
|
||||
|
||||
|
|
@ -41,8 +37,6 @@ const stateDeputyContactSafeMessages = [
|
|||
STATE_DEPUTY_STAFF_MESSAGE,
|
||||
STATE_DEPUTY_CONFLICT_MESSAGE,
|
||||
STATE_DEPUTY_INVALID_CONTACT_MESSAGE,
|
||||
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
|
||||
CONTACT_PHONE_CONFLICT_MESSAGE,
|
||||
] as const
|
||||
|
||||
const stateDeputyPartySafeMessages = [STATE_DEPUTY_STAFF_MESSAGE] as const
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end pt-4 md:pt-0">
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/dobradinhas/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ import { CampaignHomeSearchChromeProvider } from '@/components/campaign/shell/Ca
|
|||
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
|
||||
import { CampaignNotificationBellSlot } from '@/components/campaign/shell/CampaignNotificationBellSlot'
|
||||
import { CampaignPageChromeProvider } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import {
|
||||
CampaignPushOptInToast,
|
||||
type CampaignPushOptInOffer,
|
||||
} from '@/components/campaign/shell/CampaignPushOptInToast'
|
||||
import { CampaignQuickActionContextProvider } from '@/components/campaign/shell/CampaignQuickActionContext'
|
||||
import { CampaignSidebar } from '@/components/campaign/shell/CampaignSidebar'
|
||||
import { CampaignSidebarViewportDefault } from '@/components/campaign/shell/CampaignSidebarViewportDefault'
|
||||
|
|
@ -27,7 +31,9 @@ import { TooltipProvider } from '@/components/ui/tooltip'
|
|||
import { isStaffCampaignRole } from '@/lib/campaignRoles'
|
||||
import { deviceLabelFromUserAgent } from '@/lib/deviceLabel'
|
||||
import { getCampaignUserWithAvatar } from '@/utilities/campaignAuth'
|
||||
import { getCampaignPushConsent } from '@/utilities/campaignConsent'
|
||||
import { campaignUserShellView } from '@/utilities/campaignUserProfile'
|
||||
import { getCampaignVapidPublicKey } from '@/utilities/notification/sendCampaignPush'
|
||||
import { loadCampaignPasskeys } from '@/utilities/webauthn/campaignWebAuthnCeremony'
|
||||
import { resolveCampaignWebAuthnRelyingParty } from '@/utilities/webauthn/campaignWebAuthnConfig'
|
||||
|
||||
|
|
@ -49,18 +55,29 @@ export default async function CampaignAppLayout({ children }: { children: React.
|
|||
// ceremony. Whether THIS device is already enrolled is a question only the
|
||||
// browser can answer, so the island finishes the decision.
|
||||
const relyingParty = await resolveCampaignWebAuthnRelyingParty()
|
||||
const isStaff = isStaffCampaignRole(user.role)
|
||||
let biometricEnrollment: BiometricEnrollmentOffer | null = null
|
||||
if (relyingParty) {
|
||||
let pushOptIn: CampaignPushOptInOffer | null = null
|
||||
if (relyingParty || isStaff) {
|
||||
const payload = await getPayload({ config })
|
||||
const [passkeys, requestHeaders] = await Promise.all([
|
||||
loadCampaignPasskeys(payload, user.id),
|
||||
const [passkeys, requestHeaders, pushConsent] = await Promise.all([
|
||||
relyingParty ? loadCampaignPasskeys(payload, user.id) : Promise.resolve([]),
|
||||
headers(),
|
||||
isStaff ? getCampaignPushConsent(payload) : Promise.resolve(null),
|
||||
])
|
||||
biometricEnrollment = {
|
||||
// Only the emptiness crosses the boundary: the device labels have no
|
||||
// business in the RSC payload of every page.
|
||||
hasEnrolledPasskeys: passkeys.length > 0,
|
||||
suggestedDeviceLabel: deviceLabelFromUserAgent(requestHeaders.get('user-agent')),
|
||||
if (relyingParty) {
|
||||
biometricEnrollment = {
|
||||
// Only the emptiness crosses the boundary: the device labels have no
|
||||
// business in the RSC payload of every page.
|
||||
hasEnrolledPasskeys: passkeys.length > 0,
|
||||
suggestedDeviceLabel: deviceLabelFromUserAgent(requestHeaders.get('user-agent')),
|
||||
}
|
||||
}
|
||||
if (isStaff) {
|
||||
pushOptIn = {
|
||||
pushConsentConfigured: pushConsent !== null,
|
||||
vapidPublicKey: getCampaignVapidPublicKey(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -104,6 +121,7 @@ export default async function CampaignAppLayout({ children }: { children: React.
|
|||
) : null}
|
||||
</TooltipProvider>
|
||||
<InstallPwaToast />
|
||||
{pushOptIn ? <CampaignPushOptInToast {...pushOptIn} /> : null}
|
||||
{biometricEnrollment ? (
|
||||
<BiometricEnrollmentToast {...biometricEnrollment} />
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -24,10 +24,6 @@ import {
|
|||
runCampaignFormAction,
|
||||
type CampaignFormActionState,
|
||||
} from '@/utilities/campaignFormActionError'
|
||||
import {
|
||||
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
|
||||
CONTACT_PHONE_CONFLICT_MESSAGE,
|
||||
} from '@/utilities/contactPhoneInvariant'
|
||||
|
||||
const INVALID_FIELD_MESSAGE = 'Campo inválido.'
|
||||
|
||||
|
|
@ -38,8 +34,6 @@ const safeMessages = [
|
|||
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
|
||||
CONTACT_PHONE_CONFLICT_MESSAGE,
|
||||
INVALID_FIELD_MESSAGE,
|
||||
] as const
|
||||
|
||||
|
|
|
|||
|
|
@ -12,13 +12,11 @@ import {
|
|||
runCampaignRedirectFormAction,
|
||||
type CampaignFormActionState,
|
||||
} from '@/utilities/campaignFormActionError'
|
||||
import { CONTACT_PHONE_AMBIGUOUS_MESSAGE } from '@/utilities/contactPhoneInvariant'
|
||||
|
||||
const safeMessages = [
|
||||
LEADERSHIP_DUPLICATE_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
...leadershipStaffEditSafeMessages,
|
||||
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
|
||||
] as const
|
||||
|
||||
export const createLeadershipFormAction = async (
|
||||
|
|
|
|||
|
|
@ -417,7 +417,7 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end pt-4 md:pt-0">
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/liderancas/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end pt-4 md:pt-0">
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/organizacoes/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -136,16 +136,21 @@ const peopleColumns = ({
|
|||
id: 'contact',
|
||||
label: 'Contato',
|
||||
cellClassName: 'whitespace-normal',
|
||||
cell: (row) => (
|
||||
<span className="flex flex-col">
|
||||
{row.phone ? (
|
||||
<span className="font-medium tabular-nums">{formatBrazilianPhoneInput(row.phone)}</span>
|
||||
) : null}
|
||||
<span className={row.phone ? 'text-xs text-muted-foreground' : undefined}>
|
||||
{row.email ?? '—'}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
cell: (row) =>
|
||||
row.phone ? (
|
||||
<span className="font-medium tabular-nums">{formatBrazilianPhoneInput(row.phone)}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
// B197 — the email left the "Contato" cell (which now carries only the
|
||||
// phone) and became its own column, hidden by default. The mobile cards
|
||||
// still show it as the no-phone fallback, untouched.
|
||||
id: 'email',
|
||||
label: 'E-mail',
|
||||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
cell: (row) => (row.email ? row.email : <span className="text-muted-foreground">—</span>),
|
||||
},
|
||||
{
|
||||
id: 'assessora',
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import { requireSupporterRegistrationConsent } from '@/utilities/campaignConsent
|
|||
import { runCampaignFormAction } from '@/utilities/campaignFormActionError'
|
||||
import { LEADER_CONTACTS_HOME } from '@/utilities/campaignPageActor'
|
||||
import { findOrCreateContactByPhone } from '@/utilities/contactIdentity'
|
||||
import { CONTACT_PHONE_AMBIGUOUS_MESSAGE } from '@/utilities/contactPhoneInvariant'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import { isUniqueSupporterConflict } from '@/utilities/supporter/supporterErrors'
|
||||
|
|
@ -43,7 +42,6 @@ const MUNICIPALITY_OUT_OF_SCOPE_MESSAGE =
|
|||
|
||||
const safeActionMessages = [
|
||||
SUPPORTER_DUPLICATE_MESSAGE,
|
||||
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
|
||||
LEADER_SUPPORTER_ONLY_MESSAGE,
|
||||
SUPPORTER_REGISTRATION_CONSENT_MISSING_MESSAGE,
|
||||
MUNICIPALITY_OUT_OF_SCOPE_MESSAGE,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ import {
|
|||
reloadUnrestrictedActor,
|
||||
} from '@/utilities/campaignActionContext'
|
||||
import { findOrCreateContactByPhone } from '@/utilities/contactIdentity'
|
||||
import { assertContactPhoneWritable } from '@/utilities/contactPhoneInvariant'
|
||||
import { loadMunicipalityLeadershipSummaries } from '@/utilities/municipality/municipalityViewModels'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
|
|
@ -263,7 +262,6 @@ export const updateLeadershipContactRecord = async (
|
|||
contactData.email = data.email ?? null
|
||||
} else if (data.field === 'phone') {
|
||||
if (data.phone) {
|
||||
await assertContactPhoneWritable(payload, req, contactID, data.phone)
|
||||
contactData.phone = data.phone
|
||||
} else {
|
||||
contactData.phone = null
|
||||
|
|
@ -332,8 +330,6 @@ const updateLeadershipWizardRecord = async (
|
|||
throw new Error(LEADERSHIP_INVALID_CONTACT_MESSAGE)
|
||||
}
|
||||
|
||||
await assertContactPhoneWritable(payload, req, contactID, data.phone)
|
||||
|
||||
// bypass: contact write is staff-scoped via leadership access check above.
|
||||
await payload.update({
|
||||
collection: 'contact',
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ import {
|
|||
runStaffEntityMutation,
|
||||
type StaffEntityPolicy,
|
||||
} from '@/utilities/campaignEntityActions'
|
||||
import { assertContactPhoneWritable } from '@/utilities/contactPhoneInvariant'
|
||||
import { hookFilledCreateData } from '@/utilities/hookFilledData'
|
||||
import { revalidateMunicipalityListPaths } from '@/utilities/municipality/municipalityRevalidation'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
|
|
@ -184,7 +183,6 @@ export const updateStateDeputyContactRecord = async (
|
|||
contactData.email = data.email ?? null
|
||||
} else if (data.field === 'phone') {
|
||||
if (data.phone) {
|
||||
await assertContactPhoneWritable(payload, req, contactID, data.phone)
|
||||
contactData.phone = data.phone
|
||||
} else {
|
||||
contactData.phone = null
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
type ConsentDescriptor,
|
||||
} from '@/utilities/campaignConsent'
|
||||
import { findOrCreateContactByPhone } from '@/utilities/contactIdentity'
|
||||
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant'
|
||||
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneLocks'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import { isUniqueSupporterConflict } from '@/utilities/supporter/supporterErrors'
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
requireSupporterRegistrationConsent,
|
||||
requireSupporterVoteIntentionConsent,
|
||||
} from '@/utilities/campaignConsent'
|
||||
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant'
|
||||
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneLocks'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import { POSTGRES_DEDUP_LOCK_MESSAGE } from '@/utilities/postgresTransactionLocks'
|
||||
|
|
@ -226,39 +226,56 @@ export const previewSupporterImportText = async (
|
|||
collection: 'contact',
|
||||
where: { phone: { in: candidatePhones } },
|
||||
depth: 0,
|
||||
limit: candidatePhones.length,
|
||||
// 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 },
|
||||
overrideAccess: true,
|
||||
})
|
||||
const contactByPhone = new Map(contacts.docs.map((doc) => [doc.phone, doc.id] as const))
|
||||
// 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").
|
||||
const contactByPhone = new Map<string, number>()
|
||||
const sharedPhones = new Set<string>()
|
||||
for (const doc of contacts.docs) {
|
||||
if (!doc.phone) continue
|
||||
if (contactByPhone.has(doc.phone)) {
|
||||
sharedPhones.add(doc.phone)
|
||||
continue
|
||||
}
|
||||
contactByPhone.set(doc.phone, doc.id)
|
||||
}
|
||||
const contactIDs = [...contactByPhone.values()]
|
||||
|
||||
if (contactIDs.length > 0) {
|
||||
const existingSupporters = await payload.find({
|
||||
collection: 'supporter',
|
||||
where: {
|
||||
and: [{ contact: { in: contactIDs } }, { municipality: { exists: false } }],
|
||||
},
|
||||
depth: 0,
|
||||
limit: contactIDs.length,
|
||||
pagination: false,
|
||||
select: { contact: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
const supporterContactIDs = new Set(
|
||||
existingSupporters.docs
|
||||
.map((doc) => relationshipId(doc.contact))
|
||||
.filter((id): id is number => id !== null),
|
||||
)
|
||||
const existingSupporters = await payload.find({
|
||||
collection: 'supporter',
|
||||
where: {
|
||||
and: [{ contact: { in: contactIDs } }, { municipality: { exists: false } }],
|
||||
},
|
||||
depth: 0,
|
||||
limit: contactIDs.length,
|
||||
pagination: false,
|
||||
select: { contact: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
const supporterContactIDs = new Set(
|
||||
existingSupporters.docs
|
||||
.map((doc) => relationshipId(doc.contact))
|
||||
.filter((id): id is number => id !== null),
|
||||
)
|
||||
|
||||
for (let index = 0; index < rows.length; index += 1) {
|
||||
const row = rows[index]
|
||||
if (!row || !isSupporterImportOkRow(row)) continue
|
||||
const contactID = contactByPhone.get(row.normalizedPhone)
|
||||
if (contactID && supporterContactIDs.has(contactID)) {
|
||||
rows[index] = { ...row, status: 'duplicado_pelo_telefone' }
|
||||
}
|
||||
for (let index = 0; index < rows.length; index += 1) {
|
||||
const row = rows[index]
|
||||
if (!row || !isSupporterImportOkRow(row)) continue
|
||||
if (sharedPhones.has(row.normalizedPhone)) {
|
||||
rows[index] = { ...row, status: 'telefone_compartilhado' }
|
||||
continue
|
||||
}
|
||||
const contactID = contactByPhone.get(row.normalizedPhone)
|
||||
if (contactID && supporterContactIDs.has(contactID)) {
|
||||
rows[index] = { ...row, status: 'duplicado_pelo_telefone' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ const MAX_DEMAND_SLUG_ATTEMPTS = 20
|
|||
* Opt-in via `req.context.campaignDemandUniqueSlug` (set by the form-created
|
||||
* record action): direct creates — the activity demand drafts (C90) and the
|
||||
* admin UI — keep the strict constraint, which the activity rollback contract
|
||||
* pins. Fail closed like `skipContactPhoneInvariant`: only honored inside an
|
||||
* pins. Fail closed like the C6 bulk-import opt-outs: only honored inside an
|
||||
* active transaction. A text advisory lock on the base slug serializes
|
||||
* concurrent creates of the same derived title, so the probe cannot race the
|
||||
* insert.
|
||||
|
|
|
|||
|
|
@ -159,8 +159,9 @@ const resolveContactForAccount = async ({
|
|||
* only collect account fields). An explicit `data.contact` (different from the
|
||||
* linked ficha) always wins — the hook only fills the gap. On updates of a
|
||||
* linked account, identity changes (name/e-mail/phone) are synced one-way
|
||||
* account → ficha so the C100 list reads fresh data; a phone conflict fails
|
||||
* the operation fail-closed with the ficha's standard message.
|
||||
* account → ficha so the C100 list reads fresh data; a phone already used by
|
||||
* another ficha is shared, not rejected (C111 — the phone is a contact
|
||||
* channel; the account's `username` keeps the login key unique).
|
||||
*
|
||||
* Runs AFTER `preventSelfServicePrivilegedFields` on purpose: the strip
|
||||
* deletes the merged identity fields (including `contact`) for self-service,
|
||||
|
|
@ -233,7 +234,8 @@ const ensureCampaignUserContactIdentity: CollectionBeforeChangeHook<CampaignUser
|
|||
data: contactData,
|
||||
depth: 0,
|
||||
// Intentional admin bypass: the account operation is already authorized;
|
||||
// the ficha phone uniqueness is enforced by the Contact's own hook.
|
||||
// the ficha write is scoped by the account link and may share the phone
|
||||
// with other fichas (C111).
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
|
@ -285,6 +287,28 @@ const deleteCampaignUserNotifications: CollectionBeforeDeleteHook = async ({ id,
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Staged CSV-import batches authored by the deleted account must go BEFORE the
|
||||
* account: `supporter_import_batch.actor_id` is NOT NULL with an `ON DELETE
|
||||
* set null` FK (C6 schema drift — the column and the FK action disagree), so
|
||||
* without this the delete fails with a not-null violation whenever the account
|
||||
* left an unconsumed batch (10-minute window, no sweep). Same pattern as the
|
||||
* passkey/notification cascade above; `deletePersonRecord` spells the same
|
||||
* cleanup by hand.
|
||||
*/
|
||||
const deleteCampaignUserImportBatches: CollectionBeforeDeleteHook = async ({ id, req }) => {
|
||||
await req.payload.delete({
|
||||
collection: 'supporterImportBatch',
|
||||
where: { actor: { equals: id } },
|
||||
depth: 0,
|
||||
// Intentional bypass: the account deletion itself is the authorization;
|
||||
// the batches are transient staging rows owned by the deleted account
|
||||
// (same cascade contract as the passkey/notification deletes above).
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
}
|
||||
|
||||
export const CampaignUser: CollectionConfig = {
|
||||
slug: 'campaignUser',
|
||||
labels: {
|
||||
|
|
@ -342,7 +366,11 @@ export const CampaignUser: CollectionConfig = {
|
|||
preventSelfServicePrivilegedFields,
|
||||
ensureCampaignUserContactIdentity,
|
||||
],
|
||||
beforeDelete: [deleteCampaignUserPasskeys, deleteCampaignUserNotifications],
|
||||
beforeDelete: [
|
||||
deleteCampaignUserPasskeys,
|
||||
deleteCampaignUserNotifications,
|
||||
deleteCampaignUserImportBatches,
|
||||
],
|
||||
afterRead: [removePrivateAuthFields],
|
||||
},
|
||||
fields: [
|
||||
|
|
|
|||
|
|
@ -2,54 +2,10 @@ import { CitiesByState } from '@/lib/cities'
|
|||
import { BRAZILIAN_PHONE_INVALID_MESSAGE, normalizeBrazilianPhone } from '@/lib/phone'
|
||||
import { trimmedText } from '@/lib/text'
|
||||
import { canManageContacts, canReadContacts } from '@/utilities/campaignAccess'
|
||||
import {
|
||||
acquireContactPhoneLocks,
|
||||
assertContactPhoneAvailable,
|
||||
} from '@/utilities/contactPhoneInvariant'
|
||||
import { assertStateDeputyNameAvailable } from '@/utilities/stateDeputy/nameInvariant'
|
||||
import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload'
|
||||
import { APIError } from 'payload'
|
||||
|
||||
const enforceUniqueContactPhone: CollectionBeforeChangeHook = async ({
|
||||
data,
|
||||
operation,
|
||||
originalDoc,
|
||||
req,
|
||||
}) => {
|
||||
// Opt-out for callers that have already acquired the phone advisory locks in the
|
||||
// SAME Payload transaction (e.g. bulk import). Fail closed: the flag is only
|
||||
// honored inside an active transaction — without one the xact-level advisory
|
||||
// lock cannot be held, so we run the full check regardless of the caller's claim.
|
||||
if (req.context?.skipContactPhoneInvariant === true) {
|
||||
if (req.transactionID == null) {
|
||||
throw new APIError(
|
||||
'skipContactPhoneInvariant exige uma transação ativa com os locks de telefone já adquiridos.',
|
||||
500,
|
||||
)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
const phone = String(data.phone ?? originalDoc?.phone ?? '')
|
||||
// Phone-less contacts (e.g. name-only leadership imports) have no uniqueness to enforce.
|
||||
if (!phone) return data
|
||||
const oldPhone =
|
||||
operation === 'update' && typeof originalDoc?.phone === 'string' ? originalDoc.phone : undefined
|
||||
|
||||
await acquireContactPhoneLocks(
|
||||
req.payload,
|
||||
req,
|
||||
oldPhone === undefined ? [phone] : [oldPhone, phone],
|
||||
)
|
||||
await assertContactPhoneAvailable(
|
||||
req.payload,
|
||||
req,
|
||||
phone,
|
||||
operation === 'update' ? Number(originalDoc?.id) : undefined,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
const enforceStateDeputyName: CollectionBeforeChangeHook = async ({
|
||||
data,
|
||||
operation,
|
||||
|
|
@ -125,7 +81,7 @@ export const Contact: CollectionConfig = {
|
|||
return data
|
||||
},
|
||||
],
|
||||
beforeChange: [enforceUniqueContactPhone, enforceStateDeputyName],
|
||||
beforeChange: [enforceStateDeputyName],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
CampaignListOmnibox,
|
||||
|
|
@ -22,9 +22,11 @@ import {
|
|||
export const AdvisorFilters = ({
|
||||
state,
|
||||
municipalityFilterOptions,
|
||||
trailing,
|
||||
}: {
|
||||
state: AdvisorListState
|
||||
municipalityFilterOptions: AdvisorFilterOption[]
|
||||
trailing?: ReactNode
|
||||
}) => {
|
||||
const { navigate, isPending } = useCampaignListFilterNavigation({
|
||||
state,
|
||||
|
|
@ -94,6 +96,7 @@ export const AdvisorFilters = ({
|
|||
onClearAll={() => {
|
||||
runAction(clearAdvisorOmnibox(state))
|
||||
}}
|
||||
trailing={trailing}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/Table'
|
||||
import type { CampaignColumnVisibility } from '@/lib/campaignColumnVisibility'
|
||||
import { campaignInlineInputClassName } from '@/lib/campaignInlineInput'
|
||||
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
|
||||
import {
|
||||
|
|
@ -57,6 +58,8 @@ type AdvisorsTableProps = {
|
|||
rows: AdvisorRowViewModel[]
|
||||
municipalityIndex: MunicipalityPortfolioIndexEntry[]
|
||||
hasQuery: boolean
|
||||
/** B197 — hidden column ids from the `campaign_columns` cookie (server truth). */
|
||||
columnVisibility: CampaignColumnVisibility
|
||||
updateProfileAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
|
|
@ -92,6 +95,7 @@ export const AdvisorsTable = ({
|
|||
rows,
|
||||
municipalityIndex,
|
||||
hasQuery,
|
||||
columnVisibility,
|
||||
updateProfileAction,
|
||||
municipalitiesAction,
|
||||
createAction,
|
||||
|
|
@ -103,6 +107,12 @@ export const AdvisorsTable = ({
|
|||
const [draft, setDraft] = useState<DraftAdvisor | null>(null)
|
||||
const [isCreating, startCreate] = useTransition()
|
||||
|
||||
// B197 — email starts hidden on this list (cookie default); the picker
|
||||
// writes the cookie and the server re-renders with a fresh value. The
|
||||
// create-draft row below is the ONE exception: email is required to create
|
||||
// an advisor, so its input renders even while the column is hidden.
|
||||
const emailHidden = columnVisibility.hiddenColumnIds.includes('email')
|
||||
|
||||
const refresh = () => router.refresh()
|
||||
|
||||
// One array per row per render of `rows`, not per render of this table: the
|
||||
|
|
@ -211,7 +221,7 @@ export const AdvisorsTable = ({
|
|||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[20%]">Nome</TableHead>
|
||||
<TableHead className="w-[24%]">E-mail</TableHead>
|
||||
{!emailHidden ? <TableHead className="w-[24%]">E-mail</TableHead> : null}
|
||||
<TableHead className="w-[14%]">Celular</TableHead>
|
||||
<TableHead>Municípios</TableHead>
|
||||
<TableHead className="w-28 text-right">
|
||||
|
|
@ -340,22 +350,24 @@ export const AdvisorsTable = ({
|
|||
</Link>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{editing ? (
|
||||
<AdvisorDebouncedTextCell
|
||||
advisorId={row.id}
|
||||
field="email"
|
||||
type="email"
|
||||
defaultValue={row.email ?? ''}
|
||||
placeholder="E-mail"
|
||||
ariaLabel={`E-mail de ${row.name}`}
|
||||
placeholderEmailFallback={row.email}
|
||||
formAction={updateProfileAction}
|
||||
/>
|
||||
) : (
|
||||
<CampaignCopyableCell value={emailShown} label="E-mail" />
|
||||
)}
|
||||
</TableCell>
|
||||
{!emailHidden ? (
|
||||
<TableCell>
|
||||
{editing ? (
|
||||
<AdvisorDebouncedTextCell
|
||||
advisorId={row.id}
|
||||
field="email"
|
||||
type="email"
|
||||
defaultValue={row.email ?? ''}
|
||||
placeholder="E-mail"
|
||||
ariaLabel={`E-mail de ${row.name}`}
|
||||
placeholderEmailFallback={row.email}
|
||||
formAction={updateProfileAction}
|
||||
/>
|
||||
) : (
|
||||
<CampaignCopyableCell value={emailShown} label="E-mail" />
|
||||
)}
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell>
|
||||
{editing ? (
|
||||
<AdvisorDebouncedTextCell
|
||||
|
|
|
|||
|
|
@ -2,48 +2,30 @@
|
|||
|
||||
import { useState, useTransition } from 'react'
|
||||
|
||||
import {
|
||||
subscribeCampaignPush,
|
||||
unsubscribeCampaignPush,
|
||||
} from '@/app/(campaign)/campanha/actions/notifications'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { CAMPAIGN_PWA_SW_PATH } from '@/utilities/campaignPwa'
|
||||
import {
|
||||
CAMPAIGN_PUSH_CONSENT_UNCONFIGURED_MESSAGE,
|
||||
CAMPAIGN_PUSH_ENV_MISSING_MESSAGE,
|
||||
CAMPAIGN_PUSH_UNSUPPORTED_MESSAGE,
|
||||
} from '@/lib/campaignNotificationCopy'
|
||||
import {
|
||||
activateCampaignPushOnDevice,
|
||||
deactivateCampaignPushOnDevice,
|
||||
isIosSafari,
|
||||
isStandaloneDisplay,
|
||||
supportsCampaignPush,
|
||||
} from '@/utilities/campaignPushClient'
|
||||
|
||||
type CampaignPushNotificationsCardProps = {
|
||||
pushConsentConfigured: boolean
|
||||
vapidPublicKey: string | null
|
||||
}
|
||||
|
||||
const urlBase64ToUint8Array = (base64String: string): Uint8Array => {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const raw = window.atob(base64)
|
||||
const output = new Uint8Array(raw.length)
|
||||
for (let index = 0; index < raw.length; index += 1) {
|
||||
output[index] = raw.charCodeAt(index)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
const isIosSafari = (): boolean => {
|
||||
const ua = navigator.userAgent
|
||||
const isIos =
|
||||
/iPad|iPhone|iPod/.test(ua) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
||||
return isIos && /WebKit/.test(ua) && !/CriOS|FxiOS|EdgiOS/.test(ua)
|
||||
}
|
||||
|
||||
const isStandaloneDisplay = (): boolean => {
|
||||
const standaloneMedia = window.matchMedia('(display-mode: standalone)').matches
|
||||
const iosStandalone = Boolean((navigator as Navigator & { standalone?: boolean }).standalone)
|
||||
return standaloneMedia || iosStandalone
|
||||
}
|
||||
|
||||
export const CampaignPushNotificationsCard = ({
|
||||
pushConsentConfigured,
|
||||
vapidPublicKey,
|
||||
|
|
@ -54,97 +36,61 @@ export const CampaignPushNotificationsCard = ({
|
|||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const handleEnable = () => {
|
||||
setMessage(null)
|
||||
setError(null)
|
||||
|
||||
// Synchronous guards first, then `requestPermission` INSIDE the click
|
||||
// gesture: a permission request arriving outside the gesture (e.g. after a
|
||||
// `startTransition` async boundary) is auto-denied by Chrome without ever
|
||||
// showing the native prompt — the "Permissão de notificação negada" no-op.
|
||||
if (!pushConsentConfigured) {
|
||||
setError(CAMPAIGN_PUSH_CONSENT_UNCONFIGURED_MESSAGE)
|
||||
return
|
||||
}
|
||||
if (!vapidPublicKey) {
|
||||
setError(CAMPAIGN_PUSH_ENV_MISSING_MESSAGE)
|
||||
return
|
||||
}
|
||||
if (!consentAccepted) {
|
||||
setError('Aceite o consentimento para ativar os avisos push.')
|
||||
return
|
||||
}
|
||||
if (isIosSafari() && !isStandaloneDisplay()) {
|
||||
setError('No iPhone, instale o app na tela inicial antes de ativar os avisos push.')
|
||||
return
|
||||
}
|
||||
if (!supportsCampaignPush()) {
|
||||
setError(CAMPAIGN_PUSH_UNSUPPORTED_MESSAGE)
|
||||
return
|
||||
}
|
||||
|
||||
const permissionRequest = Notification.requestPermission()
|
||||
|
||||
startTransition(async () => {
|
||||
setMessage(null)
|
||||
setError(null)
|
||||
const permission = await permissionRequest
|
||||
const result = await activateCampaignPushOnDevice({
|
||||
pushConsentConfigured,
|
||||
vapidPublicKey,
|
||||
permission,
|
||||
})
|
||||
|
||||
if (!pushConsentConfigured) {
|
||||
setError('Consentimento de push ainda não configurado no admin.')
|
||||
return
|
||||
}
|
||||
if (!vapidPublicKey) {
|
||||
setError('Push ainda não está disponível neste ambiente.')
|
||||
return
|
||||
}
|
||||
if (!consentAccepted) {
|
||||
setError('Aceite o consentimento para ativar os avisos push.')
|
||||
return
|
||||
}
|
||||
if (isIosSafari() && !isStandaloneDisplay()) {
|
||||
setError('No iPhone, instale o app na tela inicial antes de ativar os avisos push.')
|
||||
return
|
||||
}
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
setError('Este navegador não suporta notificações push.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
setError('Permissão de notificação negada.')
|
||||
return
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.register(CAMPAIGN_PWA_SW_PATH, {
|
||||
scope: '/campanha',
|
||||
})
|
||||
await navigator.serviceWorker.ready
|
||||
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource,
|
||||
})
|
||||
const json = subscription.toJSON()
|
||||
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
|
||||
setError('Não foi possível registrar este dispositivo para push.')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await subscribeCampaignPush({
|
||||
endpoint: json.endpoint,
|
||||
p256dh: json.keys.p256dh,
|
||||
auth: json.keys.auth,
|
||||
expirationTime: subscription.expirationTime,
|
||||
consentAccepted: true,
|
||||
})
|
||||
|
||||
if ('status' in result && result.status === 'success') {
|
||||
setMessage(result.message)
|
||||
} else if ('message' in result && result.message) {
|
||||
setError(result.message)
|
||||
} else {
|
||||
setError('Não foi possível ativar os avisos push.')
|
||||
}
|
||||
} catch {
|
||||
setError('Não foi possível ativar os avisos push neste dispositivo.')
|
||||
if (result.status === 'success') {
|
||||
setMessage(result.message)
|
||||
} else {
|
||||
setError(result.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleDisable = () => {
|
||||
setMessage(null)
|
||||
setError(null)
|
||||
startTransition(async () => {
|
||||
setMessage(null)
|
||||
setError(null)
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration(CAMPAIGN_PWA_SW_PATH)
|
||||
const subscription = await registration?.pushManager.getSubscription()
|
||||
if (!subscription) {
|
||||
setMessage('Nenhuma inscrição push ativa neste dispositivo.')
|
||||
return
|
||||
}
|
||||
const endpoint = subscription.endpoint
|
||||
await subscription.unsubscribe()
|
||||
const result = await unsubscribeCampaignPush(endpoint)
|
||||
if ('status' in result && result.status === 'success') {
|
||||
setMessage(result.message)
|
||||
} else if ('message' in result && result.message) {
|
||||
setError(result.message)
|
||||
} else {
|
||||
setError('Não foi possível desativar os avisos push.')
|
||||
}
|
||||
} catch {
|
||||
setError('Não foi possível desativar os avisos push.')
|
||||
const result = await deactivateCampaignPushOnDevice()
|
||||
if (result.status === 'success') {
|
||||
setMessage(result.message)
|
||||
} else {
|
||||
setError(result.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
175
src/components/campaign/demand/DemandFields.tsx
Normal file
175
src/components/campaign/demand/DemandFields.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
'use client'
|
||||
|
||||
import { AsyncSearchCombobox } from '@/components/campaign/shared/AsyncSearchCombobox'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
CAMPAIGN_DEMAND_ACTIVITY_DESCRIPTION,
|
||||
CAMPAIGN_DEMAND_ACTIVITY_DIALOG_DESCRIPTION,
|
||||
CAMPAIGN_DEMAND_ACTIVITY_EMPTY_LABEL,
|
||||
CAMPAIGN_DEMAND_ACTIVITY_LABEL,
|
||||
CAMPAIGN_DEMAND_BODY_DESCRIPTION,
|
||||
CAMPAIGN_DEMAND_BODY_LABEL,
|
||||
CAMPAIGN_DEMAND_BODY_MAX_LENGTH,
|
||||
CAMPAIGN_DEMAND_BODY_PLACEHOLDER,
|
||||
CAMPAIGN_DEMAND_KIND_LABEL,
|
||||
campaignDemandKindLabels,
|
||||
campaignDemandKinds,
|
||||
} from '@/lib/schemas/campaignDemand'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import { fieldError } from '@/utilities/campaignFormFields'
|
||||
|
||||
export type DemandActivityValue = { id: number; label: string }
|
||||
|
||||
type DemandFieldsProps = {
|
||||
idPrefix: string
|
||||
disabled?: boolean
|
||||
state: CampaignFormActionState
|
||||
activity: DemandActivityValue | null
|
||||
onActivityChange: (value: DemandActivityValue | null) => void
|
||||
searchActivities: (query: string) => Promise<DemandActivityValue[]>
|
||||
/** Present on `/demandas/nova`: the municipality selector gates the activity search. */
|
||||
municipality?: {
|
||||
options: RelationOption[]
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared demand-create fields (D11): Tipo, Atividade and the single free-text
|
||||
* description, used by the wizard final step (fixed municipality, stacked) and
|
||||
* by `DemandForm` (selectable municipality, two-column grid).
|
||||
*/
|
||||
export const DemandFields = ({
|
||||
idPrefix,
|
||||
disabled,
|
||||
state,
|
||||
activity,
|
||||
onActivityChange,
|
||||
searchActivities,
|
||||
municipality,
|
||||
}: DemandFieldsProps) => {
|
||||
const municipalityReady = municipality ? municipality.value !== '' : true
|
||||
|
||||
const activityField = (
|
||||
<Field>
|
||||
<FieldLabel>{CAMPAIGN_DEMAND_ACTIVITY_LABEL}</FieldLabel>
|
||||
{municipality && !municipalityReady ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="min-h-11 w-full justify-between font-normal opacity-70"
|
||||
>
|
||||
Selecione o município primeiro…
|
||||
</Button>
|
||||
) : (
|
||||
<AsyncSearchCombobox
|
||||
name="activityId"
|
||||
label={CAMPAIGN_DEMAND_ACTIVITY_LABEL}
|
||||
value={activity}
|
||||
emptyOptionLabel={CAMPAIGN_DEMAND_ACTIVITY_EMPTY_LABEL}
|
||||
dialogDescription={CAMPAIGN_DEMAND_ACTIVITY_DIALOG_DESCRIPTION}
|
||||
isQueryReady={municipality ? () => municipalityReady : undefined}
|
||||
queryTooShortMessage={
|
||||
municipality ? 'Selecione o município para buscar atividades.' : undefined
|
||||
}
|
||||
search={searchActivities}
|
||||
onChange={onActivityChange}
|
||||
/>
|
||||
)}
|
||||
<FieldDescription>
|
||||
{municipality && !municipalityReady
|
||||
? 'Opcional. Escolha o município antes de vincular uma atividade.'
|
||||
: CAMPAIGN_DEMAND_ACTIVITY_DESCRIPTION}
|
||||
</FieldDescription>
|
||||
{fieldError(state.fieldErrors, 'activity') ? (
|
||||
<FieldError>{fieldError(state.fieldErrors, 'activity')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
)
|
||||
|
||||
const kindField = (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${idPrefix}-kind`}>{CAMPAIGN_DEMAND_KIND_LABEL}</FieldLabel>
|
||||
<NativeSelect
|
||||
id={`${idPrefix}-kind`}
|
||||
name="kind"
|
||||
defaultValue="material"
|
||||
required
|
||||
disabled={disabled}
|
||||
className={cn('min-h-11 w-full', !municipality && 'sm:w-56')}
|
||||
>
|
||||
{campaignDemandKinds.map((kind) => (
|
||||
<NativeSelectOption key={kind} value={kind}>
|
||||
{campaignDemandKindLabels[kind]}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{municipality ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{kindField}
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${idPrefix}-municipality`}>Município</FieldLabel>
|
||||
<NativeSelect
|
||||
id={`${idPrefix}-municipality`}
|
||||
name="municipalityId"
|
||||
required
|
||||
value={municipality.value}
|
||||
onChange={(event) => {
|
||||
municipality.onValueChange(event.target.value)
|
||||
onActivityChange(null)
|
||||
}}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="" disabled>
|
||||
Selecione o município…
|
||||
</NativeSelectOption>
|
||||
{municipality.options.map((option) => (
|
||||
<NativeSelectOption key={option.id} value={String(option.id)}>
|
||||
{option.name}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{fieldError(state.fieldErrors, 'municipality') ? (
|
||||
<FieldError>{fieldError(state.fieldErrors, 'municipality')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
kindField
|
||||
)}
|
||||
|
||||
{activityField}
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${idPrefix}-description`}>{CAMPAIGN_DEMAND_BODY_LABEL}</FieldLabel>
|
||||
<FieldDescription>{CAMPAIGN_DEMAND_BODY_DESCRIPTION}</FieldDescription>
|
||||
<Textarea
|
||||
id={`${idPrefix}-description`}
|
||||
name="description"
|
||||
rows={5}
|
||||
minLength={2}
|
||||
maxLength={CAMPAIGN_DEMAND_BODY_MAX_LENGTH}
|
||||
required
|
||||
disabled={disabled}
|
||||
placeholder={CAMPAIGN_DEMAND_BODY_PLACEHOLDER}
|
||||
className="min-h-28"
|
||||
/>
|
||||
{fieldError(state.fieldErrors, 'description') ? (
|
||||
<FieldError>{fieldError(state.fieldErrors, 'description')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,31 +2,14 @@
|
|||
|
||||
import { useActionState, useCallback, useState } from 'react'
|
||||
|
||||
import { AsyncSearchCombobox } from '@/components/campaign/shared/AsyncSearchCombobox'
|
||||
import { DemandFields, type DemandActivityValue } from '@/components/campaign/demand/DemandFields'
|
||||
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
CAMPAIGN_DEMAND_ACTIVITY_DESCRIPTION,
|
||||
CAMPAIGN_DEMAND_ACTIVITY_DIALOG_DESCRIPTION,
|
||||
CAMPAIGN_DEMAND_ACTIVITY_EMPTY_LABEL,
|
||||
CAMPAIGN_DEMAND_ACTIVITY_LABEL,
|
||||
CAMPAIGN_DEMAND_BODY_DESCRIPTION,
|
||||
CAMPAIGN_DEMAND_BODY_LABEL,
|
||||
CAMPAIGN_DEMAND_BODY_MAX_LENGTH,
|
||||
CAMPAIGN_DEMAND_BODY_PLACEHOLDER,
|
||||
CAMPAIGN_DEMAND_KIND_LABEL,
|
||||
CAMPAIGN_DEMAND_SUBMIT_LABEL,
|
||||
campaignDemandKindLabels,
|
||||
campaignDemandKinds,
|
||||
} from '@/lib/schemas/campaignDemand'
|
||||
import { CAMPAIGN_DEMAND_SUBMIT_LABEL } from '@/lib/schemas/campaignDemand'
|
||||
import type { ActivityRelationOption } from '@/utilities/activityRelationOptions'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import { fieldError } from '@/utilities/campaignFormFields'
|
||||
|
||||
type DemandFormProps = {
|
||||
municipalityOptions: RelationOption[]
|
||||
|
|
@ -53,7 +36,7 @@ export const DemandForm = ({
|
|||
const [municipalityId, setMunicipalityId] = useState(
|
||||
initialMunicipalityId ? String(initialMunicipalityId) : '',
|
||||
)
|
||||
const [activity, setActivity] = useState<{ id: number; label: string } | null>(
|
||||
const [activity, setActivity] = useState<DemandActivityValue | null>(
|
||||
initialActivity ? { id: initialActivity.id, label: initialActivity.label } : null,
|
||||
)
|
||||
|
||||
|
|
@ -62,107 +45,21 @@ export const DemandForm = ({
|
|||
(query: string) => searchActivities(query, parsedMunicipalityId),
|
||||
[parsedMunicipalityId, searchActivities],
|
||||
)
|
||||
const activityQueryReady = useCallback(
|
||||
() => parsedMunicipalityId !== null,
|
||||
[parsedMunicipalityId],
|
||||
)
|
||||
|
||||
const activityFieldDescription = parsedMunicipalityId
|
||||
? CAMPAIGN_DEMAND_ACTIVITY_DESCRIPTION
|
||||
: 'Opcional. Escolha o município antes de vincular uma atividade.'
|
||||
|
||||
return (
|
||||
<form action={submitAction} className="flex max-w-2xl flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="demand-kind">{CAMPAIGN_DEMAND_KIND_LABEL}</FieldLabel>
|
||||
<NativeSelect
|
||||
id="demand-kind"
|
||||
name="kind"
|
||||
defaultValue="material"
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
{campaignDemandKinds.map((kind) => (
|
||||
<NativeSelectOption key={kind} value={kind}>
|
||||
{campaignDemandKindLabels[kind]}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="demand-municipality">Município</FieldLabel>
|
||||
<NativeSelect
|
||||
id="demand-municipality"
|
||||
name="municipalityId"
|
||||
required
|
||||
value={municipalityId}
|
||||
onChange={(event) => {
|
||||
setMunicipalityId(event.target.value)
|
||||
setActivity(null)
|
||||
}}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="" disabled>
|
||||
Selecione o município…
|
||||
</NativeSelectOption>
|
||||
{municipalityOptions.map((option) => (
|
||||
<NativeSelectOption key={option.id} value={String(option.id)}>
|
||||
{option.name}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{fieldError(state.fieldErrors, 'municipality') ? (
|
||||
<FieldError>{fieldError(state.fieldErrors, 'municipality')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel>{CAMPAIGN_DEMAND_ACTIVITY_LABEL}</FieldLabel>
|
||||
{parsedMunicipalityId ? (
|
||||
<AsyncSearchCombobox
|
||||
name="activityId"
|
||||
label={CAMPAIGN_DEMAND_ACTIVITY_LABEL}
|
||||
value={activity}
|
||||
emptyOptionLabel={CAMPAIGN_DEMAND_ACTIVITY_EMPTY_LABEL}
|
||||
dialogDescription={CAMPAIGN_DEMAND_ACTIVITY_DIALOG_DESCRIPTION}
|
||||
isQueryReady={activityQueryReady}
|
||||
queryTooShortMessage="Selecione o município para buscar atividades."
|
||||
search={searchActivitiesForMunicipality}
|
||||
onChange={setActivity}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="min-h-11 w-full justify-between font-normal opacity-70"
|
||||
>
|
||||
Selecione o município primeiro…
|
||||
</Button>
|
||||
)}
|
||||
<FieldDescription>{activityFieldDescription}</FieldDescription>
|
||||
{fieldError(state.fieldErrors, 'activity') ? (
|
||||
<FieldError>{fieldError(state.fieldErrors, 'activity')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="demand-description">{CAMPAIGN_DEMAND_BODY_LABEL}</FieldLabel>
|
||||
<FieldDescription>{CAMPAIGN_DEMAND_BODY_DESCRIPTION}</FieldDescription>
|
||||
<Textarea
|
||||
id="demand-description"
|
||||
name="description"
|
||||
rows={5}
|
||||
minLength={2}
|
||||
maxLength={CAMPAIGN_DEMAND_BODY_MAX_LENGTH}
|
||||
required
|
||||
placeholder={CAMPAIGN_DEMAND_BODY_PLACEHOLDER}
|
||||
className="min-h-28"
|
||||
/>
|
||||
{fieldError(state.fieldErrors, 'description') ? (
|
||||
<FieldError>{fieldError(state.fieldErrors, 'description')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<DemandFields
|
||||
idPrefix="demand"
|
||||
state={state}
|
||||
activity={activity}
|
||||
onActivityChange={setActivity}
|
||||
searchActivities={searchActivitiesForMunicipality}
|
||||
municipality={{
|
||||
options: municipalityOptions,
|
||||
value: municipalityId,
|
||||
onValueChange: setMunicipalityId,
|
||||
}}
|
||||
/>
|
||||
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
|
||||
<Button type="submit" disabled={isPending} className="min-h-11 self-start">
|
||||
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
|
||||
|
|
|
|||
|
|
@ -26,9 +26,16 @@ export const advisorEntriesFromIds = (
|
|||
* overview's coluna da vergonha uses to count them. Non-priority ones keep the
|
||||
* softer pending tone: they are a gap, not a fire. It stands in for the advisor
|
||||
* names, so it only ever states an absence.
|
||||
*
|
||||
* B196: inside the mobile card's ~110px relation group the pill must wrap
|
||||
* (`whitespace-normal`, no fixed height) instead of clipping mid-letter —
|
||||
* on the wide desktop cell it still renders on a single line.
|
||||
*/
|
||||
export const MissingAdvisorBadge = ({ isPriority }: { isPriority: boolean }) => (
|
||||
<Badge variant={isPriority ? 'destructive' : 'estimate-pending'}>
|
||||
<Badge
|
||||
variant={isPriority ? 'destructive' : 'estimate-pending'}
|
||||
className="h-auto min-h-5 whitespace-normal text-center"
|
||||
>
|
||||
<CircleAlertIcon data-icon="inline-start" aria-hidden="true" />
|
||||
{isPriority ? 'Sem responsável' : municipalityListCoverageLabels.sem_assessor}
|
||||
</Badge>
|
||||
|
|
@ -49,14 +56,12 @@ export const formatAdvisorNamesTooltip = (
|
|||
export const MunicipalityAdvisorAvatarStack = ({
|
||||
advisors,
|
||||
isPriority,
|
||||
maxVisible = 3,
|
||||
wrap = false,
|
||||
overlapRow = false,
|
||||
}: {
|
||||
advisors: MunicipalityAdvisorAvatarEntry[]
|
||||
isPriority: boolean
|
||||
maxVisible?: number
|
||||
/** B193 — dense mobile card mode: every advisor gets an avatar, rows wrap. */
|
||||
wrap?: boolean
|
||||
/** B196 — dense mobile card mode: every advisor gets an avatar in one overlapping row. */
|
||||
overlapRow?: boolean
|
||||
}) => {
|
||||
if (!advisors.length) return <MissingAdvisorBadge isPriority={isPriority} />
|
||||
|
||||
|
|
@ -64,8 +69,7 @@ export const MunicipalityAdvisorAvatarStack = ({
|
|||
<MunicipalityRelationAvatarStack
|
||||
entries={advisors.map((advisor) => ({ id: advisor.id, label: advisor.name }))}
|
||||
emptyState={null}
|
||||
maxVisible={maxVisible}
|
||||
wrap={wrap}
|
||||
overlapRow={overlapRow}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,16 +81,35 @@ type MunicipalityMobileCardProps = {
|
|||
signalFormAction: MunicipalityStaffFormAction
|
||||
}
|
||||
|
||||
/** Dense chip trigger styling shared by the Tendência/Nível chips (B193). */
|
||||
const chipTriggerClassName = 'min-h-8 rounded-full px-2.5'
|
||||
/**
|
||||
* B196 — dense chip trigger styling shared by the Tendência/Nível chips:
|
||||
* the whole label+value block is the tap target, label above the pill
|
||||
* (gate B). `hover:bg-transparent` kills the base trigger's hover pill —
|
||||
* the block reads as label+value, not a button. `min-h-11` keeps the touch
|
||||
* target the plan's own `min-h-0` would have cut.
|
||||
*/
|
||||
const chipTriggerClassName =
|
||||
'flex min-h-11 flex-col items-start justify-center gap-0.5 rounded-md px-1 hover:bg-transparent'
|
||||
|
||||
/** Ghost trigger styling for the read-only relation groups (no hover pill). */
|
||||
const relationTriggerClassName = 'min-h-0 rounded-md bg-transparent px-0 hover:bg-transparent'
|
||||
/** Ghost trigger styling for the read-only relation groups (no hover pill).
|
||||
* `w-full` keeps the avatar overlap row constrained to the group's flex-1
|
||||
* box — without it the inline-block button grows with the avatars' min-content
|
||||
* and the stack spills into the neighbouring group (B196). */
|
||||
const relationTriggerClassName =
|
||||
'min-h-0 w-full rounded-md bg-transparent px-0 hover:bg-transparent'
|
||||
|
||||
const ChipLabel = ({ children }: { children: ReactNode }) => (
|
||||
<span className="text-xs font-medium text-muted-foreground">{children}</span>
|
||||
)
|
||||
|
||||
/** B196 (gate B) — one chip block: label above the value pill. */
|
||||
const ChipBlock = ({ label, children }: { label: string; children: ReactNode }) => (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<ChipLabel>{label}</ChipLabel>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
/** Read-only city rows render a plain dash everywhere a control would sit. */
|
||||
const CityDash = () => <span className="text-muted-foreground">—</span>
|
||||
|
||||
|
|
@ -105,7 +124,7 @@ const RelationGroupTrigger = ({
|
|||
}) => (
|
||||
<div className="flex w-full min-w-0 flex-col gap-1.5">
|
||||
<ChipLabel>{label}</ChipLabel>
|
||||
<MunicipalityRelationAvatarStack entries={entries} emptyState={emptyState} wrap />
|
||||
<MunicipalityRelationAvatarStack entries={entries} emptyState={emptyState} overlapRow />
|
||||
</div>
|
||||
)
|
||||
|
||||
|
|
@ -180,13 +199,16 @@ export const MunicipalityMobileCard = ({
|
|||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'relative flex flex-col gap-3 rounded-none border-b p-4 last:border-b-0 md:rounded-xl md:border md:last:border-b',
|
||||
// B196 — denser vertical rhythm: gap-2.5 between blocks and py-3
|
||||
// (the horizontal p-4 keeps the B184 edge-to-edge bleed). Touch
|
||||
// targets (min-h-11 controls) are untouched.
|
||||
'relative flex flex-col gap-2.5 rounded-none border-b px-4 py-3 last:border-b-0 md:rounded-xl md:border md:last:border-b',
|
||||
isPriority && !isCity && 'border-r-[6px] border-r-primary',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<h3 className="flex items-center gap-1.5 font-medium">
|
||||
<h3 className="flex items-center gap-1.5 text-lg leading-tight font-medium">
|
||||
<Link
|
||||
href={`/campanha/municipios/${municipality.slug}`}
|
||||
className="rounded-md after:absolute after:inset-0 after:rounded-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:after:rounded-xl"
|
||||
|
|
@ -243,13 +265,16 @@ export const MunicipalityMobileCard = ({
|
|||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<TerritorialClassCardReadout municipality={municipality} />
|
||||
{/* B196 (gate B) — every chip is a label-above-value block; the row
|
||||
accepts 1–2 lines, but no chip ever breaks in the middle. */}
|
||||
<div className="flex flex-wrap items-start gap-x-3 gap-y-2">
|
||||
<ChipBlock label="Classe">
|
||||
<TerritorialClassCardReadout municipality={municipality} />
|
||||
</ChipBlock>
|
||||
{isCity ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ChipLabel>Tendência</ChipLabel>
|
||||
<ChipBlock label="Tendência">
|
||||
<Badge variant="outline">Não registrada</Badge>
|
||||
</span>
|
||||
</ChipBlock>
|
||||
) : (
|
||||
<MunicipalityListTrendControl
|
||||
municipalityID={municipality.id}
|
||||
|
|
@ -259,19 +284,20 @@ export const MunicipalityMobileCard = ({
|
|||
variant="sheet"
|
||||
triggerClassName={chipTriggerClassName}
|
||||
trigger={(trend) => (
|
||||
<>
|
||||
<ChipLabel>Tendência</ChipLabel>
|
||||
<ChipBlock label="Tendência">
|
||||
<Badge
|
||||
variant={trend.status ? politicalTrendBadgeVariant[trend.status] : 'outline'}
|
||||
>
|
||||
{trend.status ? politicalTrendLabels[trend.status] : 'Não registrada'}
|
||||
</Badge>
|
||||
</>
|
||||
</ChipBlock>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{isCity ? (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<ChipBlock label="Nível">
|
||||
<CityDash />
|
||||
</ChipBlock>
|
||||
) : canMoveEngagementLevel ? (
|
||||
<MunicipalityListLevelControl
|
||||
municipalityID={municipality.id}
|
||||
|
|
@ -282,21 +308,19 @@ export const MunicipalityMobileCard = ({
|
|||
variant="sheet"
|
||||
triggerClassName={chipTriggerClassName}
|
||||
trigger={(level, note) => (
|
||||
<>
|
||||
<ChipLabel>Nível</ChipLabel>
|
||||
<ChipBlock label="Nível">
|
||||
<MunicipalityLevelBadge level={level} note={note} layout="table" />
|
||||
</>
|
||||
</ChipBlock>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ChipLabel>Nível</ChipLabel>
|
||||
<ChipBlock label="Nível">
|
||||
<MunicipalityLevelBadge
|
||||
level={municipality.engagementLevel}
|
||||
note={municipality.levelNote}
|
||||
layout="table"
|
||||
/>
|
||||
</span>
|
||||
</ChipBlock>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -326,7 +350,7 @@ export const MunicipalityMobileCard = ({
|
|||
<MunicipalityAdvisorAvatarStack
|
||||
advisors={advisors}
|
||||
isPriority={isPriority}
|
||||
wrap
|
||||
overlapRow
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { setMunicipalityPoliticalTrendFormAction } from '@/app/(campaign)/campan
|
|||
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
|
||||
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
|
||||
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
|
||||
import { WizardStepFormChrome } from '@/components/campaign/shared/WizardStepFormChrome'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { wizardPreviousHref, wizardReturnHref } from '@/lib/campaignActionRoutes'
|
||||
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
|
||||
|
|
@ -62,11 +62,23 @@ export const WizardTrendNoteStep = ({
|
|||
municipalityLabel={municipalityName}
|
||||
contentFocus="none"
|
||||
>
|
||||
<form
|
||||
<WizardStepFormChrome
|
||||
action={submitAction}
|
||||
className="flex flex-col gap-6"
|
||||
aria-busy={isPending || undefined}
|
||||
data-pending={isPending ? '' : undefined}
|
||||
isPending={isPending}
|
||||
pendingAnnouncement="Salvando tendência."
|
||||
ctaLabel={WIZARD_TREND_SAVE_LABEL}
|
||||
leadingSubmit={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isPending || note.length === 0}
|
||||
className="min-h-11"
|
||||
onClick={() => setNote('')}
|
||||
>
|
||||
{WIZARD_TREND_CLEAR_LABEL}
|
||||
</Button>
|
||||
}
|
||||
submitBarClassName="flex-wrap gap-3"
|
||||
>
|
||||
<input type="hidden" name="municipalityId" value={municipalityId} />
|
||||
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
|
||||
|
|
@ -91,33 +103,7 @@ export const WizardTrendNoteStep = ({
|
|||
</div>
|
||||
|
||||
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isPending || note.length === 0}
|
||||
className="min-h-11"
|
||||
onClick={() => setNote('')}
|
||||
>
|
||||
{WIZARD_TREND_CLEAR_LABEL}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending} className="min-h-11 min-w-[7rem]">
|
||||
{isPending ? (
|
||||
<>
|
||||
<Spinner data-icon="inline-start" aria-hidden="true" />
|
||||
Salvando…
|
||||
</>
|
||||
) : (
|
||||
WIZARD_TREND_SAVE_LABEL
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{isPending ? 'Salvando tendência.' : null}
|
||||
</div>
|
||||
</form>
|
||||
</WizardStepFormChrome>
|
||||
</CampaignWizardShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,10 @@ import { createMunicipalityUpdateFormAction } from '@/app/(campaign)/campanha/(a
|
|||
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
|
||||
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
|
||||
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { WizardStepFormChrome } from '@/components/campaign/shared/WizardStepFormChrome'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { wizardPreviousHref, wizardReturnHref } from '@/lib/campaignActionRoutes'
|
||||
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
|
||||
|
|
@ -60,11 +59,11 @@ export const WizardUpdateBodyStep = ({
|
|||
municipalityLabel={municipalityName}
|
||||
contentFocus="none"
|
||||
>
|
||||
<form
|
||||
<WizardStepFormChrome
|
||||
action={submitAction}
|
||||
className="flex flex-col gap-6"
|
||||
aria-busy={isPending || undefined}
|
||||
data-pending={isPending ? '' : undefined}
|
||||
isPending={isPending}
|
||||
pendingAnnouncement="Salvando atualização."
|
||||
ctaLabel={WIZARD_UPDATE_SAVE_LABEL}
|
||||
>
|
||||
<input type="hidden" name="municipalityId" value={municipalityId} />
|
||||
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
|
||||
|
|
@ -143,24 +142,7 @@ export const WizardUpdateBodyStep = ({
|
|||
)}
|
||||
|
||||
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending} className="min-h-11 min-w-[7rem]">
|
||||
{isPending ? (
|
||||
<>
|
||||
<Spinner data-icon="inline-start" aria-hidden="true" />
|
||||
Salvando…
|
||||
</>
|
||||
) : (
|
||||
WIZARD_UPDATE_SAVE_LABEL
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{isPending ? 'Salvando atualização.' : null}
|
||||
</div>
|
||||
</form>
|
||||
</WizardStepFormChrome>
|
||||
</CampaignWizardShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,6 @@ export const PeopleFilters = ({
|
|||
id="people-omnibox"
|
||||
label="Filtrar pessoas"
|
||||
placeholder="Buscar pessoa…"
|
||||
edgeToEdge
|
||||
chips={chips}
|
||||
suggestions={suggestions}
|
||||
query={query}
|
||||
|
|
@ -119,9 +118,9 @@ export const PeopleFilters = ({
|
|||
/>
|
||||
</form>
|
||||
{/* C100 mobile spec: "Salvar filtro" lives in the app header as an icon
|
||||
button; the trailing instance is desktop-only (the edge-to-edge
|
||||
omnibox hides its trailing cluster below `md`). Both instances share
|
||||
the same store, and only one is visible per breakpoint. */}
|
||||
button; the trailing cluster is desktop-only below `md` (the omnibox
|
||||
hides its container below the breakpoint). Both instances share the
|
||||
same store, and only one is visible per breakpoint. */}
|
||||
<SetCampaignHeaderAction id="save-people-filter">
|
||||
<SavePeopleFilterControl
|
||||
state={state}
|
||||
|
|
|
|||
|
|
@ -27,13 +27,6 @@ export type CampaignListOmniboxProps = {
|
|||
isPending?: boolean
|
||||
/** Domain controls beside the bar (e.g. save bookmark). */
|
||||
trailing?: ReactNode
|
||||
/**
|
||||
* C100: below `md` the trailing cluster (column picker, save bookmark) is
|
||||
* hidden — B184 already owns the mobile bar chrome (borderless field,
|
||||
* circle-X clear), and the page registers its own header controls instead.
|
||||
* `md:` restores the trailing cluster beside the framed desktop field.
|
||||
*/
|
||||
edgeToEdge?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -47,9 +40,15 @@ export type CampaignListOmniboxProps = {
|
|||
* containing block (its parent), and the form is the only wrapper whose parent
|
||||
* is the full-height `CampaignPageShell` — a sticky input column inside the
|
||||
* short omnibox row would only "stick" for its own height and then scroll away.
|
||||
*
|
||||
* `campaign-list-omnibox-form` is the stable marker `CampaignContentScroll`
|
||||
* matches (`:has()`) to drop its mobile top padding — only a page hosting the
|
||||
* list bar loses the `p-4` top gap, so the sticky's containing block starts at
|
||||
* the scrollport top and the bar glues to the header (B196). Without it the
|
||||
* sticky clamps 16px below the header and cards roll visibly through the gap.
|
||||
*/
|
||||
export const campaignListOmniboxFormClassName =
|
||||
'sticky top-0 z-20 -mx-4 border-b border-border bg-background px-4 py-2 ' +
|
||||
'campaign-list-omnibox-form sticky top-0 z-20 -mx-4 border-b border-border bg-background px-4 py-1 ' +
|
||||
'md:static md:z-auto md:mx-0 md:border-b-0 md:bg-transparent md:px-0 md:py-0'
|
||||
|
||||
/**
|
||||
|
|
@ -83,7 +82,6 @@ export const CampaignListOmnibox = ({
|
|||
onClearAll,
|
||||
isPending = false,
|
||||
trailing,
|
||||
edgeToEdge = false,
|
||||
}: CampaignListOmniboxProps) => {
|
||||
const reactId = useId()
|
||||
const id = idProp ?? `campaign-list-omnibox-${reactId}`
|
||||
|
|
@ -209,11 +207,14 @@ export const CampaignListOmnibox = ({
|
|||
<PopoverAnchor asChild>
|
||||
<div
|
||||
className={cn(
|
||||
// B184: below `md` the field is a borderless sticky bar; the
|
||||
// `md:` variants restore the framed desktop look.
|
||||
'flex min-h-11 w-full flex-wrap items-center gap-1.5 rounded-lg border-0 bg-transparent px-2 py-1.5 shadow-none',
|
||||
'md:border md:border-input md:shadow-xs',
|
||||
'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50',
|
||||
// B184/B196: below `md` the field is a borderless dense bar
|
||||
// (min-h-8, no inner vertical padding → bar ≈ 40px total)
|
||||
// with NO focus ring — the caret is the focus indicator; the
|
||||
// `md:` variants restore the framed desktop field with its
|
||||
// ring/colored border as the focus affordance.
|
||||
'flex min-h-8 w-full flex-wrap items-center gap-1.5 rounded-lg border-0 bg-transparent px-2 py-0 shadow-none',
|
||||
'md:min-h-11 md:border md:border-input md:py-1.5 md:shadow-xs',
|
||||
'md:focus-within:border-ring md:focus-within:ring-3 md:focus-within:ring-ring/50',
|
||||
)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) inputRef.current?.focus()
|
||||
|
|
@ -280,7 +281,9 @@ export const CampaignListOmnibox = ({
|
|||
type="button"
|
||||
aria-label="Limpar"
|
||||
className={cn(
|
||||
'inline-flex size-11 shrink-0 items-center justify-center rounded-full',
|
||||
// B196 — size-8 keeps the bar at ~40px total; the field
|
||||
// itself is the real touch target (the X lives inside it).
|
||||
'inline-flex size-8 shrink-0 items-center justify-center rounded-full',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'md:hidden',
|
||||
!(hasChips || query.length > 0) && 'invisible pointer-events-none',
|
||||
|
|
@ -298,8 +301,8 @@ export const CampaignListOmnibox = ({
|
|||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className="flex size-7 items-center justify-center rounded-full bg-muted text-muted-foreground transition-colors hover:bg-muted-foreground/20 hover:text-foreground">
|
||||
<XIcon className="size-4" aria-hidden />
|
||||
<span className="flex size-6 items-center justify-center rounded-full bg-muted text-muted-foreground transition-colors hover:bg-muted-foreground/20 hover:text-foreground">
|
||||
<XIcon className="size-3.5" aria-hidden />
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
|
@ -365,12 +368,11 @@ export const CampaignListOmnibox = ({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 flex-wrap items-center gap-2 md:pt-7',
|
||||
edgeToEdge && 'hidden md:flex',
|
||||
)}
|
||||
>
|
||||
{/* C100 — the trailing cluster is desktop-only: below `md` the page
|
||||
registers its own header controls, so the cluster never joins the
|
||||
mobile sticky bar (an empty container would also inflate its height
|
||||
— B196 keeps the bar ≈ 40px). */}
|
||||
<div className="hidden shrink-0 flex-wrap items-center gap-2 md:flex md:pt-7">
|
||||
{trailing}
|
||||
{onClearAll && hasChips ? (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { campaignUserInitials } from '@/utilities/campaignUserProfile'
|
||||
|
||||
export type MunicipalityRelationEntry = {
|
||||
|
|
@ -17,29 +16,54 @@ export const MunicipalityRelationAvatarStack = ({
|
|||
entries,
|
||||
emptyState,
|
||||
maxVisible = 3,
|
||||
wrap = false,
|
||||
overlapRow = false,
|
||||
}: {
|
||||
entries: MunicipalityRelationEntry[]
|
||||
emptyState: ReactNode
|
||||
maxVisible?: number
|
||||
/**
|
||||
* B193 — dense card mode: every entry gets its own avatar (no cap) and the
|
||||
* row wraps to the available width instead of overlapping in a fixed stack.
|
||||
* B196 — dense card mode: every entry gets its own avatar (no cap), in ONE
|
||||
* row that never wraps. Each avatar sits centered in a flex-1 cell, so few
|
||||
* entries render apart (cell wider than the avatar) and many overlap
|
||||
* progressively — the overlap is proportional to the count, no avatar is
|
||||
* capped or hidden behind a "…", and the row always fills the group width.
|
||||
*/
|
||||
wrap?: boolean
|
||||
overlapRow?: boolean
|
||||
}) => {
|
||||
if (entries.length === 0) return emptyState
|
||||
|
||||
const visible = wrap ? entries : entries.slice(0, maxVisible)
|
||||
const visible = overlapRow ? entries : entries.slice(0, maxVisible)
|
||||
|
||||
if (overlapRow) {
|
||||
return (
|
||||
// `overflow-hidden` clips the pile at the group's box on extreme counts
|
||||
// (the sr-only names below still list everyone) — no slivers into the
|
||||
// neighbouring group.
|
||||
<div
|
||||
className="flex w-full min-w-0 items-center overflow-hidden"
|
||||
data-view="relation-avatars"
|
||||
>
|
||||
{visible.map((entry) => (
|
||||
<div key={entry.id} className="flex min-w-0 flex-1 justify-center">
|
||||
<Avatar className="size-7 shrink-0 border-0">
|
||||
{/* The full names ride in the sr-only span below (or the trigger's
|
||||
aria-label); initials alone would be announced twice. */}
|
||||
<AvatarFallback aria-hidden="true">
|
||||
{campaignUserInitials(entry.initialsLabel ?? entry.label)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
))}
|
||||
<span className="sr-only">{entries.map((entry) => entry.label).join(', ')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', wrap && 'w-full min-w-0 flex-wrap gap-1.5')}>
|
||||
<div className={cn('flex', wrap ? 'flex-wrap gap-1.5' : '-space-x-2')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex -space-x-2">
|
||||
{visible.map((entry) => (
|
||||
<Avatar
|
||||
key={entry.id}
|
||||
className={cn('size-8 border-2 border-background', wrap && 'size-7 border-0')}
|
||||
>
|
||||
<Avatar key={entry.id} className="size-8 border-2 border-background">
|
||||
{/* The full names ride in the sr-only span below (or the trigger's
|
||||
aria-label); initials alone would be announced twice. */}
|
||||
<AvatarFallback aria-hidden="true">
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ import {
|
|||
type MunicipalityListExpectedVotesResponse,
|
||||
} from '@/app/(campaign)/campanha/(app)/municipios/expected-votes/types'
|
||||
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
|
||||
import { WizardStepFormChrome } from '@/components/campaign/shared/WizardStepFormChrome'
|
||||
import { VoteEstimateScenarioInputs } from '@/components/campaign/votePledge/VoteEstimateScenarioInputs'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { wizardPreviousHref, wizardReturnHref } from '@/lib/campaignActionRoutes'
|
||||
import { postCampaignJson } from '@/lib/campaignJsonRequest'
|
||||
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
|
||||
|
|
@ -128,10 +128,12 @@ export const WizardExpectedVotesStep = ({
|
|||
municipalityLabel={municipalityName}
|
||||
contentFocus="none"
|
||||
>
|
||||
<div
|
||||
className="flex flex-col gap-6"
|
||||
aria-busy={isPending}
|
||||
data-pending={isPending ? '' : undefined}
|
||||
<WizardStepFormChrome
|
||||
onCtaClick={handleConfirm}
|
||||
isPending={isPending}
|
||||
pendingAnnouncement="Salvando votos estimados."
|
||||
ctaLabel={WIZARD_VOTES_FINAL_CTA_LABEL}
|
||||
ctaClassName="w-full"
|
||||
>
|
||||
<VoteEstimateScenarioInputs
|
||||
fieldPrefix="expectedVotes"
|
||||
|
|
@ -172,27 +174,7 @@ export const WizardExpectedVotesStep = ({
|
|||
<AlertDescription className="text-sm">{saveError}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{isPending ? 'Salvando votos estimados.' : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 w-full"
|
||||
disabled={isPending}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Spinner className="size-4" aria-hidden />
|
||||
Salvando…
|
||||
</>
|
||||
) : (
|
||||
WIZARD_VOTES_FINAL_CTA_LABEL
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</WizardStepFormChrome>
|
||||
</CampaignWizardShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,32 +5,15 @@ import { useActionState, useCallback, useState } from 'react'
|
|||
|
||||
import { createWizardDemandFormAction } from '@/app/(campaign)/campanha/(app)/acoes/formActions'
|
||||
import { searchDemandActivityOptions } from '@/app/(campaign)/campanha/(app)/demandas/activitySearchActions'
|
||||
import { AsyncSearchCombobox } from '@/components/campaign/shared/AsyncSearchCombobox'
|
||||
import { DemandFields, type DemandActivityValue } from '@/components/campaign/demand/DemandFields'
|
||||
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
|
||||
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
|
||||
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { WizardStepFormChrome } from '@/components/campaign/shared/WizardStepFormChrome'
|
||||
import { wizardPreviousHref, wizardReturnHref } from '@/lib/campaignActionRoutes'
|
||||
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
|
||||
import { WIZARD_DEMAND_PENDING_ARIA, wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy'
|
||||
import {
|
||||
CAMPAIGN_DEMAND_ACTIVITY_DESCRIPTION,
|
||||
CAMPAIGN_DEMAND_ACTIVITY_DIALOG_DESCRIPTION,
|
||||
CAMPAIGN_DEMAND_ACTIVITY_EMPTY_LABEL,
|
||||
CAMPAIGN_DEMAND_ACTIVITY_LABEL,
|
||||
CAMPAIGN_DEMAND_BODY_DESCRIPTION,
|
||||
CAMPAIGN_DEMAND_BODY_LABEL,
|
||||
CAMPAIGN_DEMAND_BODY_MAX_LENGTH,
|
||||
CAMPAIGN_DEMAND_BODY_PLACEHOLDER,
|
||||
CAMPAIGN_DEMAND_KIND_LABEL,
|
||||
CAMPAIGN_DEMAND_SUBMIT_LABEL,
|
||||
campaignDemandKindLabels,
|
||||
campaignDemandKinds,
|
||||
} from '@/lib/schemas/campaignDemand'
|
||||
import { CAMPAIGN_DEMAND_SUBMIT_LABEL } from '@/lib/schemas/campaignDemand'
|
||||
|
||||
type WizardRegisterDemandStepProps = {
|
||||
actionSlug: string
|
||||
|
|
@ -54,7 +37,7 @@ export const WizardRegisterDemandStep = ({
|
|||
}: WizardRegisterDemandStepProps) => {
|
||||
const router = useRouter()
|
||||
const [state, submitAction, isPending] = useActionState(createWizardDemandFormAction, {})
|
||||
const [activity, setActivity] = useState<{ id: number; label: string } | null>(null)
|
||||
const [activity, setActivity] = useState<DemandActivityValue | null>(null)
|
||||
|
||||
const searchActivities = useCallback(
|
||||
(query: string) => searchDemandActivityOptions(query, municipalityId),
|
||||
|
|
@ -80,87 +63,26 @@ export const WizardRegisterDemandStep = ({
|
|||
municipalityLabel={municipalityName}
|
||||
contentFocus="none"
|
||||
>
|
||||
<form
|
||||
<WizardStepFormChrome
|
||||
action={submitAction}
|
||||
className="flex flex-col gap-6"
|
||||
aria-busy={isPending || undefined}
|
||||
data-pending={isPending ? '' : undefined}
|
||||
isPending={isPending}
|
||||
pendingAnnouncement={WIZARD_DEMAND_PENDING_ARIA}
|
||||
ctaLabel={CAMPAIGN_DEMAND_SUBMIT_LABEL}
|
||||
ctaClassName="min-w-[8rem]"
|
||||
>
|
||||
<input type="hidden" name="municipalityId" value={municipalityId} />
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="wizard-demand-kind">{CAMPAIGN_DEMAND_KIND_LABEL}</FieldLabel>
|
||||
<NativeSelect
|
||||
id="wizard-demand-kind"
|
||||
name="kind"
|
||||
defaultValue="material"
|
||||
required
|
||||
disabled={isPending}
|
||||
className="min-h-11 w-full sm:w-56"
|
||||
>
|
||||
{campaignDemandKinds.map((kind) => (
|
||||
<NativeSelectOption key={kind} value={kind}>
|
||||
{campaignDemandKindLabels[kind]}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>{CAMPAIGN_DEMAND_ACTIVITY_LABEL}</FieldLabel>
|
||||
<AsyncSearchCombobox
|
||||
name="activityId"
|
||||
label={CAMPAIGN_DEMAND_ACTIVITY_LABEL}
|
||||
value={activity}
|
||||
emptyOptionLabel={CAMPAIGN_DEMAND_ACTIVITY_EMPTY_LABEL}
|
||||
dialogDescription={CAMPAIGN_DEMAND_ACTIVITY_DIALOG_DESCRIPTION}
|
||||
search={searchActivities}
|
||||
onChange={setActivity}
|
||||
/>
|
||||
<FieldDescription>{CAMPAIGN_DEMAND_ACTIVITY_DESCRIPTION}</FieldDescription>
|
||||
{state.fieldErrors?.activity ? (
|
||||
<FieldError>{state.fieldErrors.activity}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="wizard-demand-description">{CAMPAIGN_DEMAND_BODY_LABEL}</FieldLabel>
|
||||
<FieldDescription>{CAMPAIGN_DEMAND_BODY_DESCRIPTION}</FieldDescription>
|
||||
<Textarea
|
||||
id="wizard-demand-description"
|
||||
name="description"
|
||||
rows={5}
|
||||
minLength={2}
|
||||
maxLength={CAMPAIGN_DEMAND_BODY_MAX_LENGTH}
|
||||
required
|
||||
disabled={isPending}
|
||||
placeholder={CAMPAIGN_DEMAND_BODY_PLACEHOLDER}
|
||||
className="min-h-28"
|
||||
/>
|
||||
{state.fieldErrors?.description ? (
|
||||
<FieldError>{state.fieldErrors.description}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<DemandFields
|
||||
idPrefix="wizard-demand"
|
||||
disabled={isPending}
|
||||
state={state}
|
||||
activity={activity}
|
||||
onActivityChange={setActivity}
|
||||
searchActivities={searchActivities}
|
||||
/>
|
||||
|
||||
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending} className="min-h-11 min-w-[8rem]">
|
||||
{isPending ? (
|
||||
<>
|
||||
<Spinner data-icon="inline-start" aria-hidden="true" />
|
||||
Salvando…
|
||||
</>
|
||||
) : (
|
||||
CAMPAIGN_DEMAND_SUBMIT_LABEL
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{isPending ? WIZARD_DEMAND_PENDING_ARIA : null}
|
||||
</div>
|
||||
</form>
|
||||
</WizardStepFormChrome>
|
||||
</CampaignWizardShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
89
src/components/campaign/shared/WizardStepFormChrome.tsx
Normal file
89
src/components/campaign/shared/WizardStepFormChrome.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type WizardStepFormChromeCommonProps = {
|
||||
isPending: boolean
|
||||
/** sr-only announcement read out while the step is saving. */
|
||||
pendingAnnouncement: string
|
||||
ctaLabel: string
|
||||
children: ReactNode
|
||||
leadingSubmit?: ReactNode
|
||||
submitBarClassName?: string
|
||||
ctaClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared chrome of the wizard final steps (D11): pending attributes, submit
|
||||
* bar with Spinner + "Salvando…" and the sr-only pending announcement. Two
|
||||
* modes — `action` renders a `<form>` with a submit CTA (the three
|
||||
* `useActionState` steps); `onCtaClick` renders a plain `<div>` with a
|
||||
* button CTA (the votes step, which saves via `useTransition` + fetch).
|
||||
*/
|
||||
export const WizardStepFormChrome = (
|
||||
props:
|
||||
| ({
|
||||
action: (formData: FormData) => void | Promise<void>
|
||||
} & WizardStepFormChromeCommonProps)
|
||||
| ({ onCtaClick: () => void } & WizardStepFormChromeCommonProps),
|
||||
) => {
|
||||
const {
|
||||
isPending,
|
||||
pendingAnnouncement,
|
||||
ctaLabel,
|
||||
children,
|
||||
leadingSubmit,
|
||||
submitBarClassName,
|
||||
ctaClassName,
|
||||
} = props
|
||||
|
||||
const submitButton = (
|
||||
<>
|
||||
{leadingSubmit}
|
||||
<Button
|
||||
type={'action' in props ? 'submit' : 'button'}
|
||||
disabled={isPending}
|
||||
onClick={'action' in props ? undefined : props.onCtaClick}
|
||||
className={cn('min-h-11 min-w-[7rem]', ctaClassName)}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Spinner data-icon="inline-start" aria-hidden="true" />
|
||||
Salvando…
|
||||
</>
|
||||
) : (
|
||||
ctaLabel
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
const pendingAttrs = {
|
||||
'aria-busy': isPending || undefined,
|
||||
'data-pending': isPending ? '' : undefined,
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{children}
|
||||
<div className={cn('flex items-center justify-end', submitBarClassName)}>{submitButton}</div>
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{isPending ? pendingAnnouncement : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return 'action' in props ? (
|
||||
<form action={props.action} className="flex flex-col gap-6" {...pendingAttrs}>
|
||||
{content}
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6" {...pendingAttrs}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
src/components/campaign/shell/CampaignPushOptInToast.tsx
Normal file
107
src/components/campaign/shell/CampaignPushOptInToast.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
CAMPAIGN_PUSH_ACTIVATED_MESSAGE,
|
||||
CAMPAIGN_PUSH_OPT_IN_ACCEPT_LABEL,
|
||||
CAMPAIGN_PUSH_OPT_IN_TOAST_DESCRIPTION,
|
||||
CAMPAIGN_PUSH_OPT_IN_TOAST_TITLE,
|
||||
} from '@/lib/campaignNotificationCopy'
|
||||
import {
|
||||
activateCampaignPushOnDevice,
|
||||
dismissCampaignPushOptIn,
|
||||
isIosSafari,
|
||||
isStandaloneDisplay,
|
||||
readCampaignPushSubscription,
|
||||
supportsCampaignPush,
|
||||
wasCampaignPushOptInDismissed,
|
||||
} from '@/utilities/campaignPushClient'
|
||||
|
||||
const TOAST_ID = 'campaign-push-opt-in'
|
||||
|
||||
export type CampaignPushOptInOffer = {
|
||||
pushConsentConfigured: boolean
|
||||
vapidPublicKey: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovery for push notifications, modeled on `InstallPwaToast` and
|
||||
* `BiometricEnrollmentToast`: nobody opens the profile looking for a feature
|
||||
* they have never heard of. Shows once, while the browser still CAN ask
|
||||
* (`Notification.permission === 'default'`) — never for 'denied' (a denied
|
||||
* permission cannot be re-prompted by any API) and never on iOS before the
|
||||
* PWA is installed, where the request is silently ignored. Dismissed offers
|
||||
* stay dismissed per device (localStorage); the permanent home stays the
|
||||
* profile card.
|
||||
*/
|
||||
export const CampaignPushOptInToast = ({
|
||||
pushConsentConfigured,
|
||||
vapidPublicKey,
|
||||
}: CampaignPushOptInOffer) => {
|
||||
useEffect(() => {
|
||||
if (!pushConsentConfigured || !vapidPublicKey) return
|
||||
if (!supportsCampaignPush()) return
|
||||
if (Notification.permission !== 'default') return
|
||||
if (wasCampaignPushOptInDismissed()) return
|
||||
if (isIosSafari() && !isStandaloneDisplay()) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const offer = async () => {
|
||||
// First statement, before any await: the native prompt must be
|
||||
// requested inside the click gesture or Chrome auto-denies it silently.
|
||||
const permission = await Notification.requestPermission()
|
||||
const result = await activateCampaignPushOnDevice({
|
||||
pushConsentConfigured,
|
||||
vapidPublicKey,
|
||||
permission,
|
||||
})
|
||||
|
||||
toast.dismiss(TOAST_ID)
|
||||
if (result.status === 'success') {
|
||||
dismissCampaignPushOptIn()
|
||||
toast.success(CAMPAIGN_PUSH_ACTIVATED_MESSAGE)
|
||||
} else {
|
||||
// A denied permission would deny every future attempt — stop offering.
|
||||
if (Notification.permission === 'denied') dismissCampaignPushOptIn()
|
||||
toast.error(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Same beat as the sibling toasts: let the first paint land before asking
|
||||
// for anything, and skip the offer when this device is already subscribed.
|
||||
const timer = window.setTimeout(() => {
|
||||
void readCampaignPushSubscription().then((subscription) => {
|
||||
if (cancelled || subscription) return
|
||||
|
||||
toast.message(CAMPAIGN_PUSH_OPT_IN_TOAST_TITLE, {
|
||||
id: TOAST_ID,
|
||||
description: CAMPAIGN_PUSH_OPT_IN_TOAST_DESCRIPTION,
|
||||
duration: Infinity,
|
||||
closeButton: true,
|
||||
onDismiss: dismissCampaignPushOptIn,
|
||||
action: {
|
||||
label: CAMPAIGN_PUSH_OPT_IN_ACCEPT_LABEL,
|
||||
onClick: (event) => {
|
||||
// Sonner closes the toast on action unless the click is
|
||||
// defaulted-out — keeping it open lets a declined OS prompt
|
||||
// land back on a visible offer instead of vanishing.
|
||||
event.preventDefault()
|
||||
void offer()
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
}, 2000)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timer)
|
||||
toast.dismiss(TOAST_ID)
|
||||
}
|
||||
}, [pushConsentConfigured, vapidPublicKey])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ import { isStaffCampaignRole, type CampaignRole } from '@/lib/campaignRoles'
|
|||
export const CampaignContentScroll = ({ children }: { children: ReactNode }) => (
|
||||
<div
|
||||
data-slot="campaign-content-scroll"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pb-[calc(4rem+env(safe-area-inset-bottom))] md:p-6 md:pb-6 print:h-auto print:overflow-visible print:p-0 [&::-webkit-scrollbar]:hidden [scrollbar-width:none]"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pb-[calc(4rem+env(safe-area-inset-bottom))] md:p-6 md:pb-6 print:h-auto print:overflow-visible print:p-0 [&::-webkit-scrollbar]:hidden [scrollbar-width:none] max-md:has-[.campaign-list-omnibox-form]:pt-0"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import { supporterVoteIntentionLabels } from '@/utilities/supporter/supporterUi'
|
|||
const importStatusLabels: Record<SupporterImportPreviewRow['status'], string> = {
|
||||
ok: 'Pronto',
|
||||
duplicado_pelo_telefone: 'Duplicado (telefone)',
|
||||
telefone_compartilhado: 'Telefone compartilhado (resolver)',
|
||||
telefone_invalido: 'Telefone inválido',
|
||||
municipio_nao_reconhecido: 'Município não reconhecido',
|
||||
nome_invalido: 'Nome inválido',
|
||||
|
|
|
|||
|
|
@ -102,6 +102,14 @@ const supporterColumns: Array<CampaignTableColumn<SupporterListItemViewModel>> =
|
|||
label: 'Cidade',
|
||||
cell: (supporter) => supporter.city ?? '—',
|
||||
},
|
||||
{
|
||||
// B197 — born hidden by default; the picker can turn it on. The mobile
|
||||
// card keeps no email (unchanged surface).
|
||||
id: 'email',
|
||||
label: 'E-mail',
|
||||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
cell: (supporter) => (supporter.email ? supporter.email : '—'),
|
||||
},
|
||||
{
|
||||
id: 'municipality',
|
||||
label: 'Município',
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ const CAMPAIGN_LIST_IDS = [
|
|||
'apoiadores',
|
||||
'territorios',
|
||||
'pessoas',
|
||||
// B197 — the advisors surface is a hand-rolled client table (no
|
||||
// `CampaignTable`), but it joined the picker with the same cookie key.
|
||||
'assessores',
|
||||
] as const
|
||||
|
||||
export type CampaignListId = (typeof CAMPAIGN_LIST_IDS)[number]
|
||||
|
|
@ -72,6 +75,14 @@ const DEFAULT_HIDDEN_COLUMN_IDS: Partial<Record<CampaignListId, readonly string[
|
|||
// B175 — Cobertura is the deepest rung (P3) and only shows when the actor
|
||||
// opts in from the picker, mirroring the municipios precedent.
|
||||
territorios: ['cobertura'],
|
||||
// B197 — the person lists open without the email column; the channel of the
|
||||
// day is phone/WhatsApp. Anyone can re-enable it from the picker, and a
|
||||
// device with an existing choice keeps it (stored wins over default).
|
||||
liderancas: ['email'],
|
||||
dobradinhas: ['email'],
|
||||
pessoas: ['email'],
|
||||
apoiadores: ['email'],
|
||||
assessores: ['email'],
|
||||
}
|
||||
|
||||
const isCampaignListId = (value: string): value is CampaignListId =>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,28 @@ export const CAMPAIGN_PUSH_SUBSCRIBE_ERROR_MESSAGE = 'Não foi possível ativar
|
|||
|
||||
export const CAMPAIGN_PUSH_UNSUBSCRIBE_ERROR_MESSAGE = 'Não foi possível desativar os avisos push.'
|
||||
|
||||
export const CAMPAIGN_PUSH_CONSENT_UNCONFIGURED_MESSAGE =
|
||||
'Consentimento de push ainda não configurado no admin.'
|
||||
|
||||
export const CAMPAIGN_PUSH_ENV_MISSING_MESSAGE = 'Push ainda não está disponível neste ambiente.'
|
||||
|
||||
export const CAMPAIGN_PUSH_UNSUPPORTED_MESSAGE = 'Este navegador não suporta notificações push.'
|
||||
|
||||
export const CAMPAIGN_PUSH_PERMISSION_DENIED_MESSAGE =
|
||||
'Permissão bloqueada no navegador. Abra as configurações do site (cadeado na barra de endereço), permita notificações e tente novamente.'
|
||||
|
||||
export const CAMPAIGN_PUSH_PERMISSION_NOT_SHOWN_MESSAGE =
|
||||
'O navegador não exibiu o pedido de permissão. Tente ativar novamente.'
|
||||
|
||||
export const CAMPAIGN_PUSH_OPT_IN_TOAST_TITLE = 'Receba avisos da campanha'
|
||||
|
||||
export const CAMPAIGN_PUSH_OPT_IN_TOAST_DESCRIPTION =
|
||||
'Alertas de atualizações, novos apoiadores e agenda, mesmo com o app fechado. Ao ativar, você concorda com o aviso LGPD de notificações.'
|
||||
|
||||
export const CAMPAIGN_PUSH_OPT_IN_ACCEPT_LABEL = 'Aceitar e ativar'
|
||||
|
||||
export const CAMPAIGN_PUSH_ACTIVATED_MESSAGE = 'Avisos push ativados neste dispositivo.'
|
||||
|
||||
export const CAMPAIGN_NOTIFICATION_THROW_SAFE_MESSAGES = [
|
||||
CAMPAIGN_PUSH_CONSENT_MISSING_MESSAGE,
|
||||
CAMPAIGN_PUSH_CONSENT_REQUIRED_MESSAGE,
|
||||
|
|
|
|||
|
|
@ -19,10 +19,7 @@ import {
|
|||
INVALID_CAMPAIGN_INVITE_MESSAGE,
|
||||
requireCampaignInvitePostgres,
|
||||
} from '@/utilities/campaignInviteRepository'
|
||||
import {
|
||||
assertContactPhoneAvailable,
|
||||
contactPhoneLockKeys,
|
||||
} from '@/utilities/contactPhoneInvariant'
|
||||
import { contactPhoneLockKeys } from '@/utilities/contactPhoneLocks'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
|
||||
|
|
@ -129,13 +126,9 @@ export const redeemCampaignInviteAutofillRecord = async (
|
|||
[originalContact.phone, data.phone].filter((phone): phone is string => Boolean(phone)),
|
||||
),
|
||||
)
|
||||
await assertContactPhoneAvailable(
|
||||
payload,
|
||||
req,
|
||||
data.phone,
|
||||
contactID,
|
||||
INVALID_CAMPAIGN_INVITE_MESSAGE,
|
||||
)
|
||||
// C111 — the phone is a contact channel, not a unique person identity:
|
||||
// the ficha this invite anchors is known (leadership.contact), so the
|
||||
// typed number may legitimately match another ficha's.
|
||||
await payload.update({
|
||||
collection: 'contact',
|
||||
id: contactID,
|
||||
|
|
@ -207,13 +200,10 @@ export const redeemCampaignInviteLoginRecord = async (
|
|||
`account-username:${data.phone}`,
|
||||
...(account ? [`invite-redemption-user:${account.id}`] : []),
|
||||
])
|
||||
await assertContactPhoneAvailable(
|
||||
payload,
|
||||
req,
|
||||
data.phone,
|
||||
originalContact.id,
|
||||
INVALID_CAMPAIGN_INVITE_MESSAGE,
|
||||
)
|
||||
// C111 — the ficha this invite anchors is known; the typed phone may be
|
||||
// shared with another ficha. The account side stays fail-closed below:
|
||||
// `username` is the login key and remains DB-unique, so the second
|
||||
// person with the same number uses e-mail (product recommendation A).
|
||||
const accounts = await payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: { username: { equals: data.phone } },
|
||||
|
|
|
|||
168
src/utilities/campaignPushClient.ts
Normal file
168
src/utilities/campaignPushClient.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
/**
|
||||
* Browser-side push ceremony for /campanha, shared by the profile card and the
|
||||
* opt-in toast (D6). The activation contract on every caller: the user gesture
|
||||
* MUST call `Notification.requestPermission()` synchronously inside the click
|
||||
* handler — browsers auto-deny a permission request that arrives outside the
|
||||
* gesture (a `startTransition(async …)` boundary is enough to lose it), which
|
||||
* surfaces as an instant "Permissão de notificação negada" with no prompt.
|
||||
*/
|
||||
|
||||
import {
|
||||
subscribeCampaignPush,
|
||||
unsubscribeCampaignPush,
|
||||
} from '@/app/(campaign)/campanha/actions/notifications'
|
||||
import {
|
||||
CAMPAIGN_PUSH_ACTIVATED_MESSAGE,
|
||||
CAMPAIGN_PUSH_CONSENT_UNCONFIGURED_MESSAGE,
|
||||
CAMPAIGN_PUSH_ENV_MISSING_MESSAGE,
|
||||
CAMPAIGN_PUSH_PERMISSION_DENIED_MESSAGE,
|
||||
CAMPAIGN_PUSH_PERMISSION_NOT_SHOWN_MESSAGE,
|
||||
CAMPAIGN_PUSH_UNSUPPORTED_MESSAGE,
|
||||
} from '@/lib/campaignNotificationCopy'
|
||||
import { CAMPAIGN_PUSH_OPT_IN_DISMISSED_KEY, CAMPAIGN_PWA_SW_PATH } from '@/utilities/campaignPwa'
|
||||
|
||||
export type CampaignPushResult =
|
||||
| { status: 'success'; message: string }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
const urlBase64ToUint8Array = (base64String: string): Uint8Array => {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const raw = window.atob(base64)
|
||||
const output = new Uint8Array(raw.length)
|
||||
for (let index = 0; index < raw.length; index += 1) {
|
||||
output[index] = raw.charCodeAt(index)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
export const isIosSafari = (): boolean => {
|
||||
const ua = navigator.userAgent
|
||||
const isIos =
|
||||
/iPad|iPhone|iPod/.test(ua) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
||||
return isIos && /WebKit/.test(ua) && !/CriOS|FxiOS|EdgiOS/.test(ua)
|
||||
}
|
||||
|
||||
export const isStandaloneDisplay = (): boolean => {
|
||||
const standaloneMedia = window.matchMedia('(display-mode: standalone)').matches
|
||||
const iosStandalone = Boolean((navigator as Navigator & { standalone?: boolean }).standalone)
|
||||
return standaloneMedia || iosStandalone
|
||||
}
|
||||
|
||||
export const supportsCampaignPush = (): boolean =>
|
||||
'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
|
||||
|
||||
type ActivateCampaignPushInput = {
|
||||
pushConsentConfigured: boolean
|
||||
vapidPublicKey: string | null
|
||||
/** Result of `Notification.requestPermission()` called synchronously in the gesture. */
|
||||
permission: NotificationPermission
|
||||
}
|
||||
|
||||
export const activateCampaignPushOnDevice = async ({
|
||||
pushConsentConfigured,
|
||||
vapidPublicKey,
|
||||
permission,
|
||||
}: ActivateCampaignPushInput): Promise<CampaignPushResult> => {
|
||||
if (!pushConsentConfigured) {
|
||||
return { status: 'error', message: CAMPAIGN_PUSH_CONSENT_UNCONFIGURED_MESSAGE }
|
||||
}
|
||||
if (!vapidPublicKey) {
|
||||
return { status: 'error', message: CAMPAIGN_PUSH_ENV_MISSING_MESSAGE }
|
||||
}
|
||||
if (permission !== 'granted') {
|
||||
return {
|
||||
status: 'error',
|
||||
message:
|
||||
permission === 'denied'
|
||||
? CAMPAIGN_PUSH_PERMISSION_DENIED_MESSAGE
|
||||
: CAMPAIGN_PUSH_PERMISSION_NOT_SHOWN_MESSAGE,
|
||||
}
|
||||
}
|
||||
if (!supportsCampaignPush()) {
|
||||
return { status: 'error', message: CAMPAIGN_PUSH_UNSUPPORTED_MESSAGE }
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register(CAMPAIGN_PWA_SW_PATH, {
|
||||
scope: '/campanha',
|
||||
})
|
||||
await navigator.serviceWorker.ready
|
||||
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource,
|
||||
})
|
||||
const json = subscription.toJSON()
|
||||
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
|
||||
return { status: 'error', message: 'Não foi possível registrar este dispositivo para push.' }
|
||||
}
|
||||
|
||||
const result = await subscribeCampaignPush({
|
||||
endpoint: json.endpoint,
|
||||
p256dh: json.keys.p256dh,
|
||||
auth: json.keys.auth,
|
||||
expirationTime: subscription.expirationTime,
|
||||
consentAccepted: true,
|
||||
})
|
||||
|
||||
if ('status' in result && result.status === 'success') {
|
||||
return { status: 'success', message: CAMPAIGN_PUSH_ACTIVATED_MESSAGE }
|
||||
}
|
||||
if ('message' in result && result.message) {
|
||||
return { status: 'error', message: result.message }
|
||||
}
|
||||
return { status: 'error', message: 'Não foi possível ativar os avisos push.' }
|
||||
} catch {
|
||||
return { status: 'error', message: 'Não foi possível ativar os avisos push neste dispositivo.' }
|
||||
}
|
||||
}
|
||||
|
||||
export const deactivateCampaignPushOnDevice = async (): Promise<CampaignPushResult> => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration(CAMPAIGN_PWA_SW_PATH)
|
||||
const subscription = await registration?.pushManager.getSubscription()
|
||||
if (!subscription) {
|
||||
return { status: 'success', message: 'Nenhuma inscrição push ativa neste dispositivo.' }
|
||||
}
|
||||
const endpoint = subscription.endpoint
|
||||
await subscription.unsubscribe()
|
||||
const result = await unsubscribeCampaignPush(endpoint)
|
||||
if ('status' in result && result.status === 'success') {
|
||||
return { status: 'success', message: result.message }
|
||||
}
|
||||
if ('message' in result && result.message) {
|
||||
return { status: 'error', message: result.message }
|
||||
}
|
||||
return { status: 'error', message: 'Não foi possível desativar os avisos push.' }
|
||||
} catch {
|
||||
return { status: 'error', message: 'Não foi possível desativar os avisos push.' }
|
||||
}
|
||||
}
|
||||
|
||||
export const readCampaignPushSubscription = async (): Promise<PushSubscription | null> => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration(CAMPAIGN_PWA_SW_PATH)
|
||||
return (await registration?.pushManager.getSubscription()) ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const wasCampaignPushOptInDismissed = (): boolean => {
|
||||
try {
|
||||
return window.localStorage.getItem(CAMPAIGN_PUSH_OPT_IN_DISMISSED_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const dismissCampaignPushOptIn = (): void => {
|
||||
try {
|
||||
window.localStorage.setItem(CAMPAIGN_PUSH_OPT_IN_DISMISSED_KEY, '1')
|
||||
} catch {
|
||||
// Ignore quota / private-mode failures: the offer reappearing is a far
|
||||
// smaller problem than a thrown error inside a toast callback.
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ export const CAMPAIGN_PWA_THEME_COLOR = '#c51414'
|
|||
const CAMPAIGN_PWA_BACKGROUND_COLOR = '#ffffff'
|
||||
export const CAMPAIGN_CACHE_PREFIX = 'campanha-'
|
||||
export const CAMPAIGN_PWA_INSTALL_TOAST_KEY = 'pwa-install-toast-dismissed'
|
||||
export const CAMPAIGN_PUSH_OPT_IN_DISMISSED_KEY = 'campaign-push-opt-in-dismissed'
|
||||
export const CAMPAIGN_PWA_CLEAR_CACHES_MESSAGE = 'clear-campaign-caches' as const
|
||||
|
||||
const MANIFEST_ICONS = [
|
||||
|
|
|
|||
|
|
@ -3,10 +3,7 @@ import 'server-only'
|
|||
import type { Payload, PayloadRequest } from 'payload'
|
||||
|
||||
import type { Contact } from '@/payload-types'
|
||||
import {
|
||||
acquireContactPhoneLocks,
|
||||
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
|
||||
} from '@/utilities/contactPhoneInvariant'
|
||||
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneLocks'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
|
||||
type ContactIdentityRequest = PayloadTransactionRequest | PayloadRequest
|
||||
|
|
@ -14,11 +11,15 @@ 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 (ambiguous phone fails
|
||||
* closed) — the same critical section the supporter flows used to build
|
||||
* around `upsertContactByPhone`. Without a phone, a fresh name-only ficha is
|
||||
* created. Every "cria ou vincula a ficha" path shares one owner for the
|
||||
* "BA default + ambiguous-phone fail-closed" policy.
|
||||
* 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.
|
||||
*
|
||||
* Callers must already be inside an active Payload transaction (the advisory
|
||||
* lock requires it) and authorized to own the write (staff creation flows run
|
||||
|
|
@ -51,19 +52,14 @@ export const findOrCreateContactByPhone = async ({
|
|||
depth: 0,
|
||||
limit: 2,
|
||||
pagination: false,
|
||||
// Intentional admin bypass: the phone invariant must see every ficha,
|
||||
// Intentional admin bypass: the phone match must see every ficha,
|
||||
// including ones outside the actor's campaign scope.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
if (contacts.totalDocs > 1) {
|
||||
throw new Error(CONTACT_PHONE_AMBIGUOUS_MESSAGE)
|
||||
}
|
||||
|
||||
const existing = contacts.docs[0]
|
||||
if (existing) {
|
||||
return { contactID: existing.id, reused: true }
|
||||
if (contacts.totalDocs === 1) {
|
||||
return { contactID: contacts.docs[0]!.id, reused: true }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -79,12 +75,6 @@ export const findOrCreateContactByPhone = async ({
|
|||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
// With a phone, the advisory lock is held for this transaction and the
|
||||
// find above already proved the phone is free — the Contact
|
||||
// phone-invariant hook can skip its redundant lock+availability check
|
||||
// (same contract as the C6 bulk-import path, fail-closed on a missing
|
||||
// transaction). Without a phone there is no uniqueness to enforce.
|
||||
...(phone ? { context: { skipContactPhoneInvariant: true as const } } : {}),
|
||||
req,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload, PayloadRequest } from 'payload'
|
||||
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
import {
|
||||
acquireTextAdvisoryLocks,
|
||||
POSTGRES_DEDUP_LOCK_MESSAGE,
|
||||
} from '@/utilities/postgresTransactionLocks'
|
||||
|
||||
export const CONTACT_PHONE_CONFLICT_MESSAGE = 'Já existe outro contato com este celular.'
|
||||
|
||||
/**
|
||||
* Several contacts share the phone — a data-quality problem only an admin can
|
||||
* resolve. Thrown by the upsert paths and matched verbatim by `safeMessages`.
|
||||
*/
|
||||
export const CONTACT_PHONE_AMBIGUOUS_MESSAGE =
|
||||
'Existe mais de um contato com este celular. Resolva a duplicidade no admin antes de continuar.'
|
||||
|
||||
type ContactPhonePayload = Pick<Payload, 'db' | 'find' | 'findByID'>
|
||||
type ContactPhoneRequest = PayloadTransactionRequest | PayloadRequest
|
||||
|
||||
export const contactPhoneLockKeys = (phones: string[]): string[] =>
|
||||
[...new Set(phones)].sort().map((phone) => `contact-phone:${phone}`)
|
||||
|
||||
export const acquireContactPhoneLocks = (
|
||||
payload: Pick<Payload, 'db'>,
|
||||
req: ContactPhoneRequest,
|
||||
phones: string[],
|
||||
): Promise<void> => acquireTextAdvisoryLocks(payload, req, contactPhoneLockKeys(phones))
|
||||
|
||||
export const assertContactPhoneAvailable = async (
|
||||
payload: ContactPhonePayload,
|
||||
req: ContactPhoneRequest,
|
||||
phone: string,
|
||||
contactID?: number,
|
||||
conflictMessage = CONTACT_PHONE_CONFLICT_MESSAGE,
|
||||
): Promise<void> => {
|
||||
const conflicts = await payload.find({
|
||||
collection: 'contact',
|
||||
where: {
|
||||
and: [
|
||||
{ phone: { equals: phone } },
|
||||
...(contactID === undefined ? [] : [{ id: { not_equals: contactID } }]),
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
// Intentional admin bypass: the uniqueness invariant must see EVERY contact
|
||||
// with the phone, including ones the actor cannot read — a scoped check
|
||||
// would let duplicates slip in through the invisible half of the table.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
if (conflicts.totalDocs > 0) throw new Error(conflictMessage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the phone invariant for an already-authorized Contact update. The
|
||||
* caller must prove access to the owning campaign join before using the
|
||||
* intentional admin-bypass reads/writes here.
|
||||
*/
|
||||
export const assertContactPhoneWritable = async (
|
||||
payload: ContactPhonePayload,
|
||||
req: ContactPhoneRequest,
|
||||
contactID: number,
|
||||
phone: string,
|
||||
): Promise<void> => {
|
||||
if (payload.db.name !== 'postgres') {
|
||||
throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE)
|
||||
}
|
||||
|
||||
const currentContact = await payload.findByID({
|
||||
collection: 'contact',
|
||||
id: contactID,
|
||||
depth: 0,
|
||||
select: { phone: true },
|
||||
// Intentional admin bypass: the caller has already proved ownership through
|
||||
// the campaign join; this read only obtains the old value for lock ordering.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
const oldPhone = currentContact.phone ?? undefined
|
||||
|
||||
// Lock both sides before checking availability. This prevents concurrent
|
||||
// swaps (A -> B and B -> A) from waiting on each other's hook locks.
|
||||
await acquireContactPhoneLocks(
|
||||
payload,
|
||||
req,
|
||||
[oldPhone, phone].filter((value): value is string => Boolean(value)),
|
||||
)
|
||||
|
||||
const contactsWithPhone = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { phone: { equals: phone } },
|
||||
depth: 0,
|
||||
limit: 2,
|
||||
pagination: false,
|
||||
// Intentional admin bypass: phone uniqueness must see every Contact,
|
||||
// including one outside the actor's campaign scope.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
if (contactsWithPhone.totalDocs > 1) {
|
||||
throw new Error(CONTACT_PHONE_AMBIGUOUS_MESSAGE)
|
||||
}
|
||||
|
||||
const phoneOwner = contactsWithPhone.docs[0]
|
||||
if (phoneOwner && phoneOwner.id !== contactID) {
|
||||
await assertContactPhoneAvailable(payload, req, phone, contactID)
|
||||
}
|
||||
}
|
||||
25
src/utilities/contactPhoneLocks.ts
Normal file
25
src/utilities/contactPhoneLocks.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload, PayloadRequest } from 'payload'
|
||||
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
|
||||
|
||||
type ContactPhonePayload = Pick<Payload, 'db'>
|
||||
type ContactPhoneRequest = PayloadTransactionRequest | PayloadRequest
|
||||
|
||||
/**
|
||||
* Advisory locks keyed by phone (C111 — the phone is a contact channel, not a
|
||||
* unique person identity, so these serialize read-modify-write flows that
|
||||
* look contacts up by phone: find-or-create, CSV import, invite redemption,
|
||||
* LGPD tombstone). They no longer guard uniqueness — they prevent two
|
||||
* concurrent flows from racing the same phone key.
|
||||
*/
|
||||
export const contactPhoneLockKeys = (phones: string[]): string[] =>
|
||||
[...new Set(phones)].sort().map((phone) => `contact-phone:${phone}`)
|
||||
|
||||
export const acquireContactPhoneLocks = (
|
||||
payload: ContactPhonePayload,
|
||||
req: ContactPhoneRequest,
|
||||
phones: string[],
|
||||
): Promise<void> => acquireTextAdvisoryLocks(payload, req, contactPhoneLockKeys(phones))
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
} from '@/lib/schemas/personDelete'
|
||||
import type { CampaignUser, Contact, Leadership, StateDeputy } from '@/payload-types'
|
||||
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
|
||||
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneInvariant'
|
||||
import { acquireContactPhoneLocks } from '@/utilities/contactPhoneLocks'
|
||||
import {
|
||||
withPayloadTransaction,
|
||||
type PayloadTransactionRequest,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
|
||||
|
||||
/**
|
||||
* C111 — a CSV phone that already belongs to two or more fichas cannot be
|
||||
* matched to a person; the row is flagged for manual resolution instead.
|
||||
*/
|
||||
export const SUPPORTER_IMPORT_SHARED_PHONE_MESSAGE =
|
||||
'Este celular pertence a mais de um contato na base. Remova a linha do arquivo ou resolva a duplicidade antes de continuar.'
|
||||
|
||||
type SupporterImportRowStatus =
|
||||
| 'ok'
|
||||
| 'duplicado_pelo_telefone'
|
||||
| 'telefone_compartilhado'
|
||||
| 'telefone_invalido'
|
||||
| 'municipio_nao_reconhecido'
|
||||
| 'nome_invalido'
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from '@/utilities/drizzleBulk'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
import { getPostgresTransactionDatabase } from '@/utilities/postgresTransactionLocks'
|
||||
import { SUPPORTER_IMPORT_SHARED_PHONE_MESSAGE } from '@/utilities/supporter/supporterImport'
|
||||
|
||||
export type SupporterImportBulkRow = {
|
||||
telefone: string
|
||||
|
|
@ -132,9 +133,10 @@ const asContactID = (row: Record<string, unknown>): number | null => {
|
|||
* caller's Payload transaction. Phone advisory locks MUST already be held for
|
||||
* every phone in `rows` (the caller acquires them before invoking this helper).
|
||||
*
|
||||
* Contacts are inserted via drizzle (bypassing the Contact phone-invariant hook,
|
||||
* which is safe because the locks guarantee uniqueness within the txn). The
|
||||
* supporter insert uses `ON CONFLICT DO NOTHING` on the unique
|
||||
* Contacts are inserted via drizzle (bypassing the Contact collection hooks,
|
||||
* which is safe because the phone locks serialize this flow and the shared-
|
||||
* phone abort below proves each phone still resolves to at most one ficha).
|
||||
* The supporter insert uses `ON CONFLICT DO NOTHING` on the unique
|
||||
* `(contact_id, municipality_id)` index as a last-resort guard against races.
|
||||
*
|
||||
* All reads (existing contacts, existing supporters) and the ID recovery for
|
||||
|
|
@ -178,7 +180,15 @@ export const bulkInsertSupporterImport = async (args: {
|
|||
)
|
||||
for (const row of existingContactRows) {
|
||||
const parsed = asPhoneKeyedRow(row)
|
||||
if (parsed) contactIdByPhone.set(parsed.phone, parsed.id)
|
||||
if (!parsed) continue
|
||||
// C111 — the phone is not unique. The preview flags shared phones, but
|
||||
// the base can change between preview and confirm; with the phone
|
||||
// advisory locks held, a second ficha here is a real ambiguity, so the
|
||||
// import aborts fail-closed instead of silently picking a ficha.
|
||||
if (contactIdByPhone.has(parsed.phone)) {
|
||||
throw new Error(SUPPORTER_IMPORT_SHARED_PHONE_MESSAGE)
|
||||
}
|
||||
contactIdByPhone.set(parsed.phone, parsed.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export type SupporterListItemViewModel = {
|
|||
id: number
|
||||
name: string
|
||||
phone: string
|
||||
email: string | null
|
||||
city: string | null
|
||||
municipalityName: string | null
|
||||
municipalitySlug: string | null
|
||||
|
|
@ -57,6 +58,7 @@ export const toSupporterListItemViewModel = (
|
|||
id: supporter.id,
|
||||
name: contact?.name ?? 'Contato sem nome',
|
||||
phone: contact?.phone ?? '',
|
||||
email: contact?.email ?? null,
|
||||
city: contact?.city ?? null,
|
||||
municipalityName: municipality?.name ?? null,
|
||||
municipalitySlug: municipality?.slug ?? null,
|
||||
|
|
|
|||
|
|
@ -111,3 +111,73 @@ test.describe('Seletor de colunas', () => {
|
|||
await expect(mandatory).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('E-mail oculto por padrão nas listas de pessoas (B197)', () => {
|
||||
test('lideranças nasce sem a coluna de e-mail; o picker religa e a escolha persiste', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
test.slow()
|
||||
await page.setViewportSize({ width: 1800, height: 900 })
|
||||
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora B197'),
|
||||
})
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto('/campanha/liderancas')
|
||||
|
||||
// A fresh device starts with the default: email hidden, picker saying so.
|
||||
const emailHeader = page.getByRole('columnheader', { name: 'E-mail', exact: true })
|
||||
await expect(emailHeader).toHaveCount(0)
|
||||
const picker = page.getByRole('button', { name: /Mostrar ou ocultar colunas/ })
|
||||
await expect(picker).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /1 oculta/ })).toBeVisible()
|
||||
|
||||
// Re-enabling paints the column and the choice survives a reload.
|
||||
await picker.click()
|
||||
await page.getByRole('checkbox', { name: 'E-mail', exact: true }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(emailHeader).toBeVisible(REFRESH)
|
||||
await page.reload()
|
||||
await expect(emailHeader).toBeVisible(REFRESH)
|
||||
})
|
||||
|
||||
test('assessores ganha o picker com o e-mail oculto; o draft de criação mantém o input', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
test.slow()
|
||||
await page.setViewportSize({ width: 1800, height: 900 })
|
||||
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora B197 Assessores'),
|
||||
})
|
||||
const advisorName = fixtures.value('Assessor B197')
|
||||
await fixtures.createCampaignUser('advisor', { name: advisorName })
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto('/campanha/assessores')
|
||||
|
||||
// The row renders without the email column; the picker is there and says 1 hidden.
|
||||
await expect(page.getByRole('link', { name: advisorName, exact: true })).toBeVisible()
|
||||
const emailHeader = page.getByRole('columnheader', { name: 'E-mail', exact: true })
|
||||
await expect(emailHeader).toHaveCount(0)
|
||||
const picker = page.getByRole('button', { name: /Mostrar ou ocultar colunas/ })
|
||||
await expect(picker).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /1 oculta/ })).toBeVisible()
|
||||
|
||||
// The create-draft row keeps its email input while the column is hidden
|
||||
// (email is required to create an advisor).
|
||||
await page.getByRole('button', { name: 'Novo assessor', exact: true }).click()
|
||||
await expect(page.getByLabel('E-mail do novo assessor')).toBeVisible()
|
||||
|
||||
// Re-enabling paints the column and the choice survives a reload.
|
||||
await picker.click()
|
||||
await page.getByRole('checkbox', { name: 'E-mail', exact: true }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(emailHeader).toBeVisible(REFRESH)
|
||||
await page.reload()
|
||||
await expect(emailHeader).toBeVisible(REFRESH)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -74,8 +74,8 @@ test.describe('Início — busca global (B47)', () => {
|
|||
|
||||
test('staff focused search hides action strip on input focus (B66)', async ({ page }) => {
|
||||
await page.goto('/campanha')
|
||||
const actionsChrome = page.locator('[data-slot="home-actions-chrome"]')
|
||||
await expect(page.getByLabel('Ações rápidas')).toBeVisible()
|
||||
const actionsChrome = page.locator('[data-slot="home-actions-chrome"]').first()
|
||||
await expect(page.getByLabel('Ações rápidas').first()).toBeVisible()
|
||||
|
||||
await page.getByLabel('Buscar na campanha').focus()
|
||||
await expect(actionsChrome).toHaveAttribute('data-retracted', 'true', { timeout: 5000 })
|
||||
|
|
@ -86,8 +86,8 @@ test.describe('Início — busca global (B47)', () => {
|
|||
|
||||
test('staff typing in search keeps action strip hidden after debounce', async ({ page }) => {
|
||||
await page.goto('/campanha')
|
||||
const actionsChrome = page.locator('[data-slot="home-actions-chrome"]')
|
||||
await expect(page.getByLabel('Ações rápidas')).toBeVisible()
|
||||
const actionsChrome = page.locator('[data-slot="home-actions-chrome"]').first()
|
||||
await expect(page.getByLabel('Ações rápidas').first()).toBeVisible()
|
||||
|
||||
await page.getByLabel('Buscar na campanha').fill('ca')
|
||||
await expect(actionsChrome).toHaveAttribute('data-retracted', 'true', { timeout: 5000 })
|
||||
|
|
@ -152,7 +152,7 @@ test.describe('Início — catálogo de ações (B45)', () => {
|
|||
page,
|
||||
}) => {
|
||||
await page.goto('/campanha')
|
||||
const actionsRegion = page.getByLabel('Ações rápidas')
|
||||
const actionsRegion = page.getByLabel('Ações rápidas').first()
|
||||
await expect(actionsRegion).toBeVisible()
|
||||
|
||||
for (const label of staffActionLabels) {
|
||||
|
|
@ -190,7 +190,7 @@ test.describe('Início — catálogo de ações (B45)', () => {
|
|||
}) => {
|
||||
await seedCampaignSession(context, campaign.baseURL, leaderToken)
|
||||
await page.goto('/campanha')
|
||||
await expect(page.getByLabel('Ações rápidas')).toBeVisible()
|
||||
await expect(page.getByLabel('Ações rápidas').first()).toBeVisible()
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Cadastrar apoiador', exact: true }),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { Page } from '@playwright/test'
|
|||
|
||||
import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '../../src/lib/campaignConsentKeys.js'
|
||||
import { fallbackDemandTitle } from '../../src/lib/demandTitle.js'
|
||||
import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js'
|
||||
import {
|
||||
ensureLeasedConsent,
|
||||
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
|
||||
|
|
@ -127,7 +128,7 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
|
||||
// B127: primary filter entry is the omnibox; default sort is no longer a
|
||||
// free-standing "Ordenado por …" line (only a non-default sort chip).
|
||||
await expect(page.getByLabel('Filtrar municípios')).toBeVisible()
|
||||
await expect(page.getByLabel('Filtrar municípios').first()).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('columnheader', { name: /Ordenar por Frescor do sinal/ }),
|
||||
).toBeVisible()
|
||||
|
|
@ -1135,6 +1136,219 @@ test.describe('Municípios — card denso mobile (B193)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* B196 (2026-08-10): the mobile list bar is a chassis standard for every
|
||||
* list — glued to the app top bar with no gap (the sticky's containing block
|
||||
* starts at the scrollport top via `:has()`), dense (~40px total, no focus
|
||||
* ring below `md`), and the mobile card reads denser: 18px name with the
|
||||
* territory glued, label-above chips (Classe/Tendência/Nível — gate B), and
|
||||
* one overlapping avatar row per relation group (no wrap, no cap, no "…").
|
||||
*/
|
||||
test.describe('Municípios — barra colada e card denso (B196)', () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } })
|
||||
|
||||
const mobileTopBar = (page: Page) => page.locator('[data-slot="campaign-mobile-top-bar"]')
|
||||
const contentScroll = (page: Page) => page.locator('[data-slot="campaign-content-scroll"]')
|
||||
const headerBottom = async (topBar: ReturnType<typeof mobileTopBar>) =>
|
||||
(await topBar.boundingBox())!.y + (await topBar.boundingBox())!.height
|
||||
// `?q=` matches name PREFIXES, so every card is anchored on the
|
||||
// municipality's own name link.
|
||||
const cardFor = (page: Page, name: string) =>
|
||||
page
|
||||
.locator('[data-view="mobile-cards"] article')
|
||||
.filter({ has: page.getByRole('link', { name, exact: true }) })
|
||||
.first()
|
||||
|
||||
test('bar is glued to the header, ~40px tall, no focus ring, nothing passes above on scroll', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora B196 Barra'),
|
||||
})
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios`)
|
||||
// C106 — dynamic pages stream a transient hidden `#S:*` copy of the shell.
|
||||
await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)
|
||||
|
||||
const topBar = mobileTopBar(page)
|
||||
const omniboxInput = page.getByRole('combobox', { name: 'Filtrar municípios' })
|
||||
const form = omniboxInput.locator('xpath=ancestor::form[1]')
|
||||
const field = omniboxInput.locator('..')
|
||||
await expect(form).toBeVisible()
|
||||
|
||||
// Glued: no gap between the app top bar and the filter bar.
|
||||
await expect.poll(async () => (await form.boundingBox())!.y).toBe(await headerBottom(topBar))
|
||||
|
||||
// Dense: the whole bar stays around 40px (32px field + py + border).
|
||||
await expect.poll(async () => (await form.boundingBox())!.height).toBeLessThanOrEqual(44)
|
||||
|
||||
// No focus ring: the field gains no shadow/border color on focus.
|
||||
const shadowBefore = await field.evaluate((el) => getComputedStyle(el).boxShadow)
|
||||
await omniboxInput.focus()
|
||||
await expect
|
||||
.poll(() => field.evaluate((el) => getComputedStyle(el).boxShadow))
|
||||
.toBe(shadowBefore)
|
||||
|
||||
// Scroll: the bar stays glued to the header — cards roll UNDER the
|
||||
// opaque bar, never above it.
|
||||
await contentScroll(page).evaluate((el) => el.scrollTo(0, el.scrollHeight))
|
||||
await expect.poll(async () => (await form.boundingBox())!.y).toBe(await headerBottom(topBar))
|
||||
await expect.poll(async () => (await form.boundingBox())!.height).toBeLessThanOrEqual(44)
|
||||
})
|
||||
|
||||
test('name is 18px with the territory glued; Classe/Tendência/Nível chips are label-above blocks', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora B196 Card'),
|
||||
})
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto(
|
||||
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
|
||||
)
|
||||
const card = cardFor(page, municipality.name)
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
// Smaller name (18px) with the territory glued right below it.
|
||||
const heading = card.getByRole('heading', { level: 3 })
|
||||
await expect(heading).toHaveCSS('font-size', '18px')
|
||||
const headingBox = (await heading.boundingBox())!
|
||||
const territory = card.locator('h3 + p')
|
||||
const territoryBox = (await territory.boundingBox())!
|
||||
expect(territoryBox.y - headingBox.y - headingBox.height).toBeLessThanOrEqual(6)
|
||||
|
||||
// Every chip is a label-above block: the value renders below the label
|
||||
// (gate B). "Classe" debuts as a label; Tendência/Nível keep theirs.
|
||||
for (const label of ['Classe', 'Tendência', 'Nível']) {
|
||||
const chipLabel = card.getByText(label, { exact: true })
|
||||
await expect(chipLabel).toBeVisible()
|
||||
const labelY = (await chipLabel.boundingBox())!.y
|
||||
const valueY = (await chipLabel.locator('xpath=following-sibling::*[1]').boundingBox())!.y
|
||||
expect(valueY).toBeGreaterThan(labelY)
|
||||
}
|
||||
})
|
||||
|
||||
test('relation groups: one row per group, overlap grows with the count', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora B196 Avatares'),
|
||||
})
|
||||
const fiveAdvisors = await Promise.all(
|
||||
Array.from({ length: 5 }, (_, i) =>
|
||||
fixtures.createCampaignUser('advisor', {
|
||||
name: fixtures.value(`Assessora B196 ${i + 1}`),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const twoAdvisors = await Promise.all(
|
||||
Array.from({ length: 2 }, (_, i) =>
|
||||
fixtures.createCampaignUser('advisor', {
|
||||
name: fixtures.value(`Assessora B196 dois ${i + 1}`),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const crowded = await fixtures.claimMunicipality()
|
||||
const sparse = await fixtures.claimMunicipality()
|
||||
await fixtures.payload.update({
|
||||
collection: 'municipality',
|
||||
id: crowded.id,
|
||||
data: { advisors: fiveAdvisors.map((advisor) => advisor.id) },
|
||||
depth: 0,
|
||||
})
|
||||
await fixtures.payload.update({
|
||||
collection: 'municipality',
|
||||
id: sparse.id,
|
||||
data: { advisors: twoAdvisors.map((advisor) => advisor.id) },
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
|
||||
// Five advisors: all on ONE row, each covering the start of the next —
|
||||
// the overlap is proportional to the count (no wrap, no cap, no "…").
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(crowded.name)}`)
|
||||
const crowdedCard = cardFor(page, crowded.name)
|
||||
await expect(crowdedCard).toBeVisible()
|
||||
const crowdedRow = crowdedCard.locator('[data-view="relation-avatars"]').first()
|
||||
await expect(crowdedRow.locator('.size-7')).toHaveCount(5)
|
||||
const crowdedBoxes = await crowdedRow
|
||||
.locator('.size-7')
|
||||
.evaluateAll((avatars) => avatars.map((a) => a.getBoundingClientRect()))
|
||||
expect(new Set(crowdedBoxes.map((box) => Math.round(box.y))).size).toBe(1)
|
||||
for (let i = 1; i < crowdedBoxes.length; i++) {
|
||||
expect(crowdedBoxes[i]!.x).toBeLessThan(crowdedBoxes[i - 1]!.x + crowdedBoxes[i - 1]!.width)
|
||||
}
|
||||
|
||||
// Two advisors: same single row, no overlap (few entries keep apart).
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(sparse.name)}`)
|
||||
const sparseCard = cardFor(page, sparse.name)
|
||||
await expect(sparseCard).toBeVisible()
|
||||
const sparseRow = sparseCard.locator('[data-view="relation-avatars"]').first()
|
||||
await expect(sparseRow.locator('.size-7')).toHaveCount(2)
|
||||
const sparseBoxes = await sparseRow
|
||||
.locator('.size-7')
|
||||
.evaluateAll((avatars) => avatars.map((a) => a.getBoundingClientRect()))
|
||||
expect(new Set(sparseBoxes.map((box) => Math.round(box.y))).size).toBe(1)
|
||||
expect(sparseBoxes[1]!.x).toBeGreaterThanOrEqual(sparseBoxes[0]!.x + sparseBoxes[0]!.width)
|
||||
})
|
||||
|
||||
test('chassis elsewhere: the actions row keeps its breathing room and the bar glues on scroll (atividades)', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora B196 Ativ'),
|
||||
})
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
// Enough rows to make the scrollport scrollable once the top bar takes over.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await fixtures.payload.create({
|
||||
collection: 'activity',
|
||||
data: hookFilledCreateData<'activity'>({
|
||||
title: `Atividade B196 ${fixtures.value('x')}`,
|
||||
status: 'confirmado',
|
||||
startAt: '2026-09-01T13:00:00.000Z',
|
||||
municipality: municipality.id,
|
||||
responsible: [{ relationTo: 'campaignUser', value: coordinator.id }],
|
||||
}),
|
||||
depth: 0,
|
||||
})
|
||||
}
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/atividades`)
|
||||
|
||||
const topBar = mobileTopBar(page)
|
||||
const omniboxInput = page.getByRole('combobox', { name: 'Filtrar atividades' })
|
||||
const form = omniboxInput.locator('xpath=ancestor::form[1]')
|
||||
await expect(form).toBeVisible()
|
||||
|
||||
// On load the actions row (criar/planejar) breathes ABOVE the bar — the
|
||||
// bar is not glued yet; it glues once the row scrolls away.
|
||||
const headerBottomValue = await headerBottom(topBar)
|
||||
expect((await form.boundingBox())!.y).toBeGreaterThan(headerBottomValue)
|
||||
|
||||
// The scrollport must actually scroll for the sticky to engage.
|
||||
const scrollport = contentScroll(page)
|
||||
await expect
|
||||
.poll(() => scrollport.evaluate((el) => el.scrollHeight - el.clientHeight))
|
||||
.toBeGreaterThan(0)
|
||||
await scrollport.evaluate((el) => el.scrollTo(0, el.scrollHeight))
|
||||
await expect.poll(async () => (await form.boundingBox())!.y).toBe(headerBottomValue)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* B126: FAB quick-actions overlay — no persistent drawer; opens on demand.
|
||||
*/
|
||||
|
|
|
|||
53
tests/e2e/campaignTerritoriesColumns.e2e.spec.ts
Normal file
53
tests/e2e/campaignTerritoriesColumns.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
|
||||
/**
|
||||
* OPS35+ — restores the browser pin the HTTP migration dropped. OPS35 migrated
|
||||
* `campaignTerritories` to the browserless HTTP mode (`campaignTerritoriesHttp`)
|
||||
* because its assertions were server-rendered content; its one browser-only
|
||||
* concern — CSS rung visibility at a viewport — was documented as lost at the
|
||||
* time. That loss is a real hole: the network columns hide behind container
|
||||
* queries (`@min-[Nrem]/territory-list:table-cell`, TerritoryListColumns.tsx),
|
||||
* and the HTTP spec only pins presence + rung classes in the HTML, never the
|
||||
* visual effect. This spec is that pin back: at a wide viewport the read-only
|
||||
* network columns and every rung-gated sortable are actually visible, and
|
||||
* Cobertura is not rendered at this width. It carries the exact body of the
|
||||
* deleted spec's test 3 (accepted product behavior since B175) — nothing else,
|
||||
* no CSS, no HTTP duplication.
|
||||
*/
|
||||
|
||||
test.describe('Territórios de Identidade — colunas de rede (browser)', () => {
|
||||
test('a wide panel surfaces the read-only network columns; Cobertura stays hidden by default', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Rede Larga'),
|
||||
})
|
||||
const password = coordinator.password
|
||||
const email = coordinator.email!
|
||||
|
||||
await page.setViewportSize({ width: 2200, height: 1000 })
|
||||
await campaign.login(page, email, password)
|
||||
await page.goto('/campanha/territorios')
|
||||
|
||||
// B175 — the responsive rungs are all visible at a wide panel (P0 + Captura
|
||||
// + network + Classe + Assessoria). Cobertura is P3 AND hidden in the picker.
|
||||
// Sortable headers are announced "Ordenar por <label> (…)" — match by prefix;
|
||||
// the read-only network headers are plain, so match them exactly (never by
|
||||
// substring, since "Assessor" is a prefix of "Assessoria").
|
||||
for (const header of ['2022', 'Captura', '2026', 'Classe', 'Assessoria']) {
|
||||
await expect(
|
||||
page.getByRole('columnheader', { name: new RegExp(`^Ordenar por ${header}`) }),
|
||||
`header ${header}`,
|
||||
).toBeVisible()
|
||||
}
|
||||
for (const header of ['Assessor', 'Liderança', 'Dobradinha']) {
|
||||
await expect(
|
||||
page.getByRole('columnheader', { name: header, exact: true }),
|
||||
`header ${header}`,
|
||||
).toBeVisible()
|
||||
}
|
||||
await expect(page.getByRole('columnheader', { name: 'Cobertura' })).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -44,6 +44,7 @@ type CampaignCollection =
|
|||
| 'campaignUser'
|
||||
| 'consent'
|
||||
| 'calendarFeed'
|
||||
| 'supporterImportBatch'
|
||||
|
||||
type OwnedIDs = Record<CampaignCollection, Set<number>>
|
||||
|
||||
|
|
@ -128,6 +129,7 @@ const emptyOwnedIDs = (): OwnedIDs => ({
|
|||
campaignUser: new Set(),
|
||||
consent: new Set(),
|
||||
calendarFeed: new Set(),
|
||||
supporterImportBatch: new Set(),
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
@ -938,6 +940,13 @@ export class CampaignFixtures {
|
|||
}
|
||||
|
||||
if (userIDs.length > 0) {
|
||||
const importBatches = await this.rootPayload.find({
|
||||
collection: 'supporterImportBatch',
|
||||
where: { actor: { in: userIDs } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
})
|
||||
for (const batch of importBatches.docs) this.own('supporterImportBatch', batch.id)
|
||||
const [updates, notifications, pushSubscriptions] = await Promise.all([
|
||||
this.rootPayload.find({
|
||||
collection: 'municipalityUpdate',
|
||||
|
|
@ -1149,7 +1158,12 @@ export class CampaignFixtures {
|
|||
await this.deleteOwned(collection, req)
|
||||
}
|
||||
await this.deleteNotificationOwned(req)
|
||||
for (const collection of ['campaignUser', 'consent', 'users'] as const) {
|
||||
for (const collection of [
|
||||
'supporterImportBatch',
|
||||
'campaignUser',
|
||||
'consent',
|
||||
'users',
|
||||
] as const) {
|
||||
await this.deleteOwned(collection, req)
|
||||
}
|
||||
await this.resetTouchedMunicipalities(req)
|
||||
|
|
|
|||
|
|
@ -6,10 +6,6 @@ import { getPayload } from 'payload'
|
|||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import config from '@/payload.config'
|
||||
import {
|
||||
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
|
||||
CONTACT_PHONE_CONFLICT_MESSAGE,
|
||||
} from '@/utilities/contactPhoneInvariant'
|
||||
|
||||
import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures'
|
||||
|
||||
|
|
@ -181,24 +177,22 @@ describe('campaignUser → Contact identity (C99)', () => {
|
|||
expect(contact).toMatchObject({ name: 'Nome Atualizado', phone: nextPhone })
|
||||
})
|
||||
|
||||
it('fails the update fail-closed when the new phone belongs to another ficha', async () => {
|
||||
it('shares the phone with another ficha when the account phone is updated (C111)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const actor = await fixtures.createCampaignUser('coordinator')
|
||||
const user = await fixtures.createCampaignUser('advisor', { phone: fixtures.phone() })
|
||||
const other = await fixtures.createContact()
|
||||
|
||||
await expect(
|
||||
payload.update({
|
||||
collection: 'campaignUser',
|
||||
id: user.id,
|
||||
data: { phone: other.phone },
|
||||
depth: 0,
|
||||
user: actor,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
).rejects.toThrow(CONTACT_PHONE_CONFLICT_MESSAGE)
|
||||
await payload.update({
|
||||
collection: 'campaignUser',
|
||||
id: user.id,
|
||||
data: { phone: other.phone },
|
||||
depth: 0,
|
||||
user: actor,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
// The failed update must not have half-applied.
|
||||
// The account's own ficha now carries the other ficha's phone; both remain.
|
||||
const account = await fichaOf(user.id)
|
||||
const contact = await payload.findByID({
|
||||
collection: 'contact',
|
||||
|
|
@ -206,7 +200,17 @@ describe('campaignUser → Contact identity (C99)', () => {
|
|||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(contact.phone).toBe(user.phone)
|
||||
expect(contact.phone).toBe(other.phone)
|
||||
|
||||
const othersWithPhone = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { phone: { equals: other.phone } },
|
||||
depth: 0,
|
||||
limit: 2,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(othersWithPhone.totalDocs).toBe(2)
|
||||
})
|
||||
|
||||
it('links a previously unlinked account by phone on update', async () => {
|
||||
|
|
@ -237,22 +241,41 @@ describe('campaignUser → Contact identity (C99)', () => {
|
|||
expect(relationId(updated.contact)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('fails closed on two fichas sharing the phone', async () => {
|
||||
it('creates a fresh ficha when the phone already belongs to two fichas (C111)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const phone = fixtures.phone()
|
||||
// The collection hook forbids duplicates, so the ambiguous state is planted
|
||||
// directly (the same legacy-data shape `findOrCreateContactByPhone` guards).
|
||||
// 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('Ambíguo A')}', '', '${phone}', 'BA', ''),
|
||||
('${fixtures.value('Ambíguo B')}', '', '${phone}', 'BA', '')
|
||||
VALUES ('${fixtures.value('Compartilhado A')}', '', '${phone}', 'BA', ''),
|
||||
('${fixtures.value('Compartilhado B')}', '', '${phone}', 'BA', '')
|
||||
`),
|
||||
)
|
||||
|
||||
await expect(fixtures.createCampaignUser('advisor', { phone })).rejects.toThrow(
|
||||
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
|
||||
)
|
||||
const user = await fixtures.createCampaignUser('advisor', { phone })
|
||||
const account = await fichaOf(user.id)
|
||||
const contact = await payload.findByID({
|
||||
collection: 'contact',
|
||||
id: relationId(account.contact) as number,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
// 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)
|
||||
|
||||
const allWithPhone = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { phone: { equals: phone } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(allWithPhone.totalDocs).toBe(3)
|
||||
})
|
||||
|
||||
it('lets the admin unlink explicitly without auto-relinking', async () => {
|
||||
|
|
|
|||
|
|
@ -1038,7 +1038,7 @@ describe('campaign invite domain', () => {
|
|||
)
|
||||
|
||||
itWithInviteConsent(
|
||||
'rejects duplicate phone takeover, rolls back consumption, and permits a safe retry',
|
||||
'shares a phone with another ficha on autofill and keeps the invite consumed (C111)',
|
||||
async () => {
|
||||
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
|
|
@ -1050,6 +1050,8 @@ describe('campaign invite domain', () => {
|
|||
kind: 'autopreenchimento',
|
||||
})
|
||||
|
||||
// C111 — the ficha the invite anchors is known; the typed phone may
|
||||
// legitimately match another ficha's, and the write shares it.
|
||||
await expect(
|
||||
actions.redeemCampaignInviteAutofill({
|
||||
token: invite.token,
|
||||
|
|
@ -1057,23 +1059,19 @@ describe('campaign invite domain', () => {
|
|||
phone: owner.contact.phone,
|
||||
consentAccepted: true,
|
||||
}),
|
||||
).rejects.toThrow('Convite inválido ou expirado')
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
await expect(
|
||||
payload.findByID({ collection: 'contact', id: target.contact.id, depth: 0 }),
|
||||
).resolves.toMatchObject({ phone: target.contact.phone })
|
||||
).resolves.toMatchObject({ phone: owner.contact.phone })
|
||||
await expect(
|
||||
actions.redeemCampaignInviteAutofill({
|
||||
token: invite.token,
|
||||
name: target.contact.name,
|
||||
phone: target.contact.phone,
|
||||
consentAccepted: true,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true })
|
||||
payload.findByID({ collection: 'contact', id: owner.contact.id, depth: 0 }),
|
||||
).resolves.toMatchObject({ phone: owner.contact.phone })
|
||||
},
|
||||
)
|
||||
|
||||
itWithInviteConsent(
|
||||
'serializes concurrent phone changes to one target and preserves the rejected invite',
|
||||
'lets two concurrent autofills share one phone and consumes both invites (C111)',
|
||||
async () => {
|
||||
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
|
|
@ -1108,27 +1106,20 @@ describe('campaign invite domain', () => {
|
|||
const results = await Promise.allSettled(
|
||||
submissions.map((submission) => actions.redeemCampaignInviteAutofill(submission)),
|
||||
)
|
||||
expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(1)
|
||||
expect(results.filter(({ status }) => status === 'rejected')).toHaveLength(1)
|
||||
// C111 — the phone is a contact channel: both invites may share it.
|
||||
expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(2)
|
||||
|
||||
const contacts = await Promise.all(
|
||||
[first.contact.id, second.contact.id].map((id) =>
|
||||
payload.findByID({ collection: 'contact', id, depth: 0 }),
|
||||
),
|
||||
)
|
||||
expect(contacts.filter(({ phone }) => phone === targetPhone)).toHaveLength(1)
|
||||
const rejectedIndex = contacts.findIndex(({ phone }) => phone !== targetPhone)
|
||||
await expect(
|
||||
actions.redeemCampaignInviteAutofill({
|
||||
...submissions[rejectedIndex]!,
|
||||
phone: contacts[rejectedIndex]!.phone!,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true })
|
||||
expect(contacts.every(({ phone }) => phone === targetPhone)).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
itWithInviteConsent(
|
||||
'orders old and new phone locks so opposite invite swaps finish without deadlock',
|
||||
'completes opposite phone swaps without deadlock and shares the numbers (C111)',
|
||||
async () => {
|
||||
const actions = await import('@/app/(campaign)/campanha/actions/invite')
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
|
|
@ -1158,7 +1149,15 @@ describe('campaign invite domain', () => {
|
|||
consentAccepted: true,
|
||||
}),
|
||||
])
|
||||
expect(results.every(({ status }) => status === 'rejected')).toBe(true)
|
||||
// Both write the other's number (shared channel) — the ordered locks
|
||||
// keep the swap deadlock-free instead of serializing exclusivity.
|
||||
expect(results.every(({ status }) => status === 'fulfilled')).toBe(true)
|
||||
const [firstContact, secondContact] = await Promise.all([
|
||||
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)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import { Leadership } from '@/collections/Leadership'
|
|||
import { contactSchema } from '@/lib/schemas/contact'
|
||||
import { leadershipCreateSchema, leadershipInternalUpdateSchema } from '@/lib/schemas/leadership'
|
||||
import config from '@/payload.config'
|
||||
import { CONTACT_PHONE_CONFLICT_MESSAGE } from '@/utilities/contactPhoneInvariant'
|
||||
import {
|
||||
getTestTransactionBackendPID,
|
||||
waitForAdvisoryLockWaiter,
|
||||
|
|
@ -282,7 +281,7 @@ describe('campaign leadership domain', () => {
|
|||
expect(leaderships.docs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects a duplicate Contact write before leadership dedupe can become ambiguous', async () => {
|
||||
it('allows a second Contact with the same phone and creates a fresh ficha on leadership create (C111)', async () => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
const municipality = await campaignFixtures().getMunicipality()
|
||||
const phone = campaignFixtures().phone()
|
||||
|
|
@ -292,13 +291,15 @@ describe('campaign leadership domain', () => {
|
|||
data: { name: 'Primeiro Contato', phone, state: 'BA', city: 'Salvador' },
|
||||
})
|
||||
|
||||
await expect(
|
||||
payload.create({
|
||||
collection: 'contact',
|
||||
data: { name: 'Segundo Contato', phone, state: 'BA', city: 'Salvador' },
|
||||
}),
|
||||
).rejects.toThrow('Já existe outro contato com este celular')
|
||||
// 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' },
|
||||
})
|
||||
|
||||
// Two fichas already share the phone, so the leadership create cannot
|
||||
// match a person — the identity of the person being created is a fresh
|
||||
// ficha, never a guess among the existing ones.
|
||||
await expect(
|
||||
createLeadershipRecord(payload, coordinator, {
|
||||
municipalities: [municipality.id],
|
||||
|
|
@ -306,7 +307,7 @@ describe('campaign leadership domain', () => {
|
|||
phone,
|
||||
supportStatus: 'engajado',
|
||||
}),
|
||||
).resolves.toMatchObject({ contactReused: true })
|
||||
).resolves.toMatchObject({ contactReused: false })
|
||||
})
|
||||
|
||||
it('enforces the unique contact relationship at the database', async () => {
|
||||
|
|
@ -695,7 +696,7 @@ describe('campaign leadership domain', () => {
|
|||
expect(contact.phone).toBe(newPhone)
|
||||
})
|
||||
|
||||
it('rejects leadership contact phone that belongs to another person', async () => {
|
||||
it('shares a leadership contact phone with another person (C111)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
|
@ -708,13 +709,29 @@ describe('campaign leadership domain', () => {
|
|||
supportStatus: 'engajado',
|
||||
})
|
||||
|
||||
await expect(
|
||||
updateLeadershipContactRecord(payload, coordinator, {
|
||||
id: leadership.id,
|
||||
field: 'phone',
|
||||
phone: takenPhone,
|
||||
}),
|
||||
).rejects.toThrow(CONTACT_PHONE_CONFLICT_MESSAGE)
|
||||
const updated = await updateLeadershipContactRecord(payload, coordinator, {
|
||||
id: leadership.id,
|
||||
field: 'phone',
|
||||
phone: takenPhone,
|
||||
})
|
||||
|
||||
const contact = await payload.findByID({
|
||||
collection: 'contact',
|
||||
id: relationId(updated.contact) as number,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(contact.phone).toBe(takenPhone)
|
||||
|
||||
const withPhone = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { phone: { equals: takenPhone } },
|
||||
depth: 0,
|
||||
limit: 2,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(withPhone.totalDocs).toBe(2)
|
||||
})
|
||||
|
||||
it('denies leadership contact edits outside advisor municipality scope', async () => {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,8 @@ import {
|
|||
} from '@/app/(campaign)/campanha/actions/stateDeputy'
|
||||
import { STATE_DEPUTY_CONFLICT_MESSAGE } from '@/lib/schemas/stateDeputy'
|
||||
import config from '@/payload.config'
|
||||
import { CONTACT_PHONE_CONFLICT_MESSAGE } from '@/utilities/contactPhoneInvariant'
|
||||
|
||||
import { installCampaignFixtures, relationIds } from '../helpers/campaignFixtures'
|
||||
import { installCampaignFixtures, relationId, relationIds } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
|
|
@ -234,7 +233,7 @@ describe('StateDeputy contact fields (B163)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('rejects a phone already owned by another Contact', async () => {
|
||||
it('shares a phone with another Contact (C111)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const stateDeputy = await fixtures.createStateDeputy({
|
||||
|
|
@ -243,13 +242,29 @@ describe('StateDeputy contact fields (B163)', () => {
|
|||
const takenPhone = fixtures.phone()
|
||||
await fixtures.createContact({ phone: takenPhone })
|
||||
|
||||
await expect(
|
||||
updateStateDeputyContactRecord(payload, coordinator, {
|
||||
id: stateDeputy.id,
|
||||
field: 'phone',
|
||||
phone: takenPhone,
|
||||
}),
|
||||
).rejects.toThrow(CONTACT_PHONE_CONFLICT_MESSAGE)
|
||||
const updated = await updateStateDeputyContactRecord(payload, coordinator, {
|
||||
id: stateDeputy.id,
|
||||
field: 'phone',
|
||||
phone: takenPhone,
|
||||
})
|
||||
|
||||
const contact = await payload.findByID({
|
||||
collection: 'contact',
|
||||
id: relationId(updated.contact) as number,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(contact.phone).toBe(takenPhone)
|
||||
|
||||
const withPhone = await payload.find({
|
||||
collection: 'contact',
|
||||
where: { phone: { equals: takenPhone } },
|
||||
depth: 0,
|
||||
limit: 2,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(withPhone.totalDocs).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects renaming a dobradinha to another dobradinha name', async () => {
|
||||
|
|
|
|||
|
|
@ -10,12 +10,19 @@ import {
|
|||
removeSupporterDataRecord,
|
||||
setSupporterVoteIntentionRecord,
|
||||
} from '@/app/(campaign)/campanha/actions/supporter'
|
||||
import { supporterCreateSchema } from '@/lib/schemas/supporter'
|
||||
import {
|
||||
SUPPORTER_IMPORT_BATCH_EMPTY_MESSAGE,
|
||||
supporterCreateSchema,
|
||||
} from '@/lib/schemas/supporter'
|
||||
import config from '@/payload.config'
|
||||
import {
|
||||
SUPPORTER_REGISTRATION_CONSENT_KEY,
|
||||
SUPPORTER_VOTE_INTENTION_CONSENT_KEY,
|
||||
} from '@/utilities/campaignConsent'
|
||||
import {
|
||||
isPreviewErrorRow,
|
||||
SUPPORTER_IMPORT_SHARED_PHONE_MESSAGE,
|
||||
} from '@/utilities/supporter/supporterImport'
|
||||
import {
|
||||
loadSupporterListOverviewData,
|
||||
loadSupportersPageData,
|
||||
|
|
@ -300,6 +307,58 @@ describe('campaign supporter domain', () => {
|
|||
fixtures.own('supporter', created.docs[0]!.id)
|
||||
})
|
||||
|
||||
it('flags a CSV phone shared by two fichas for manual resolution (C111)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
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 })
|
||||
|
||||
const csv = [
|
||||
'nome,telefone,municipio,intencao',
|
||||
`Maria Souza,${sharedPhone},Salvador,certo`,
|
||||
].join('\n')
|
||||
|
||||
const preview = await previewSupporterImportText(payload, coordinator, csv)
|
||||
const row = preview.sampleRows[0]
|
||||
expect(row).toBeDefined()
|
||||
expect(row.status).toBe('telefone_compartilhado')
|
||||
expect(isPreviewErrorRow(row)).toBe(true)
|
||||
|
||||
// The shared-phone row never reaches the staged ok set: confirming an
|
||||
// all-flagged batch fails closed instead of importing a guessed ficha.
|
||||
await expect(
|
||||
confirmSupporterImportRecord(payload, coordinator, {
|
||||
operatorAttested: true,
|
||||
importToken: preview.importToken,
|
||||
}),
|
||||
).rejects.toThrow(SUPPORTER_IMPORT_BATCH_EMPTY_MESSAGE)
|
||||
})
|
||||
|
||||
it('aborts the confirm fail-closed when the base gains a second ficha for an ok phone (C111)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
await ensureSupporterConsents()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const phone = fixtures.phone()
|
||||
await fixtures.createContact({ phone })
|
||||
|
||||
const csv = `nome,telefone,municipio,intencao\nAna Silva,${phone},Salvador,certo`
|
||||
const preview = await previewSupporterImportText(payload, coordinator, csv)
|
||||
expect(preview.counts.ok).toBe(1)
|
||||
|
||||
// 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 expect(
|
||||
confirmSupporterImportRecord(payload, coordinator, {
|
||||
operatorAttested: true,
|
||||
importToken: preview.importToken,
|
||||
}),
|
||||
).rejects.toThrow(SUPPORTER_IMPORT_SHARED_PHONE_MESSAGE)
|
||||
})
|
||||
|
||||
it('rejects a tampered import token and a token issued to another actor', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
await ensureSupporterConsents()
|
||||
|
|
|
|||
|
|
@ -4,10 +4,7 @@ import { getPayload, type Payload } from 'payload'
|
|||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import config from '@/payload.config'
|
||||
import {
|
||||
CONTACT_PHONE_CONFLICT_MESSAGE,
|
||||
contactPhoneLockKeys,
|
||||
} from '@/utilities/contactPhoneInvariant'
|
||||
import { contactPhoneLockKeys } from '@/utilities/contactPhoneLocks'
|
||||
import {
|
||||
acquireTextAdvisoryLocks,
|
||||
getPostgresTransactionDatabase,
|
||||
|
|
@ -67,7 +64,6 @@ describe('PostgreSQL transaction advisory locks', () => {
|
|||
'contact-phone:71999990001',
|
||||
'contact-phone:71999990002',
|
||||
])
|
||||
expect(CONTACT_PHONE_CONFLICT_MESSAGE).toBe('Já existe outro contato com este celular.')
|
||||
})
|
||||
|
||||
it('uses only the exact Payload transaction session and fails closed without it', async () => {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ describe('parseCampaignHiddenColumns', () => {
|
|||
})
|
||||
|
||||
it('drops unknown lists and column ids that are not identifiers', () => {
|
||||
expect(parseCampaignHiddenColumns('assessores:email|municipios:kind')).toEqual({
|
||||
expect(parseCampaignHiddenColumns('desconhecida:email|municipios:kind')).toEqual({
|
||||
municipios: ['kind'],
|
||||
})
|
||||
expect(parseCampaignHiddenColumns('municipios:kind~<script>~trend')).toEqual({
|
||||
|
|
@ -76,8 +76,8 @@ describe('resolveCampaignColumnVisibility', () => {
|
|||
})
|
||||
|
||||
it('keeps the existing empty default for every other list', () => {
|
||||
expect(resolveCampaignColumnVisibility('liderancas', {})).toEqual({
|
||||
listId: 'liderancas',
|
||||
expect(resolveCampaignColumnVisibility('organizacoes', {})).toEqual({
|
||||
listId: 'organizacoes',
|
||||
hiddenColumnIds: [],
|
||||
})
|
||||
})
|
||||
|
|
@ -92,6 +92,34 @@ describe('resolveCampaignColumnVisibility', () => {
|
|||
hiddenColumnIds: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('hides the email column by default on every person list (B197)', () => {
|
||||
for (const listId of [
|
||||
'liderancas',
|
||||
'dobradinhas',
|
||||
'pessoas',
|
||||
'apoiadores',
|
||||
'assessores',
|
||||
] as const) {
|
||||
expect(resolveCampaignColumnVisibility(listId, {})).toEqual({
|
||||
listId,
|
||||
hiddenColumnIds: ['email'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a stored choice on a person list winning over the email default (B197)', () => {
|
||||
expect(resolveCampaignColumnVisibility('liderancas', { liderancas: [] })).toEqual({
|
||||
listId: 'liderancas',
|
||||
hiddenColumnIds: [],
|
||||
})
|
||||
expect(
|
||||
resolveCampaignColumnVisibility('assessores', { assessores: ['email', 'phone'] }),
|
||||
).toEqual({
|
||||
listId: 'assessores',
|
||||
hiddenColumnIds: ['email', 'phone'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleHiddenColumn', () => {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,11 @@ describe('campaign invite module boundaries', () => {
|
|||
expect(creation).not.toContain('@payloadcms/db-postgres')
|
||||
|
||||
expect(redemption).toContain('resolveInviteConsent')
|
||||
expect(redemption).toContain('assertContactPhoneAvailable')
|
||||
// C111 — the invite may write a phone shared with another ficha, but the
|
||||
// account side stays fail-closed: the username is the login key (DB-unique)
|
||||
// and the "more than one account" guard must survive.
|
||||
expect(redemption).toContain('contactPhoneLockKeys')
|
||||
expect(redemption).toContain('Existe mais de uma conta para este celular.')
|
||||
expect(redemption).toContain("role: 'leader'")
|
||||
expect(redemption).not.toContain('@payloadcms/db-postgres')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -427,11 +427,14 @@ describe('src/utilities top-level is pinned', () => {
|
|||
'campaignPasswordReset.ts',
|
||||
'campaignPwa.ts',
|
||||
'campaignPwaClient.ts',
|
||||
// Push ceremony shared by the profile card and the opt-in toast (D6):
|
||||
// client-only (localStorage + SW + server action), no Payload/Next.
|
||||
'campaignPushClient.ts',
|
||||
'campaignRelationOptions.ts',
|
||||
'campaignUserProfile.ts',
|
||||
'consentContentHash.ts',
|
||||
'contactIdentity.ts',
|
||||
'contactPhoneInvariant.ts',
|
||||
'contactPhoneLocks.ts',
|
||||
'dashboardPriorityMunicipalities.ts',
|
||||
'detailTabUi.ts',
|
||||
'documentReads.ts',
|
||||
|
|
|
|||
180
tests/unit/dbStart.unit.spec.ts
Normal file
180
tests/unit/dbStart.unit.spec.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type ExecArgs = [command: string, args: string[], options: { cwd: string }]
|
||||
|
||||
const execFileSyncMock = vi.fn<(...args: ExecArgs) => unknown>()
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFileSync: (...args: ExecArgs) => execFileSyncMock(...args),
|
||||
}))
|
||||
|
||||
import {
|
||||
parsePostgresContainerHealth,
|
||||
shouldSkipStart,
|
||||
startSharedPostgres,
|
||||
waitForHealthyPostgres,
|
||||
} from '../../scripts/lib/db-start.mjs'
|
||||
|
||||
/** The `docker inspect --format '{{json .State}}'` shape, not a custom one. */
|
||||
const stateJson = ({ running = true, health = 'healthy' } = {}) =>
|
||||
JSON.stringify({
|
||||
Status: running ? 'running' : 'exited',
|
||||
Running: running,
|
||||
...(health ? { Health: { Status: health } } : {}),
|
||||
})
|
||||
|
||||
/** Throws exactly what docker does when the container does not exist. */
|
||||
const throwNoSuchObject = () => {
|
||||
const error = new Error('No such object') as Error & { stderr: Buffer }
|
||||
error.stderr = Buffer.from('No such object: teqo-postgres-1')
|
||||
throw error
|
||||
}
|
||||
|
||||
describe('shouldSkipStart', () => {
|
||||
it('skips when the container is running and healthy', () => {
|
||||
expect(shouldSkipStart({ running: true, health: 'healthy' })).toBe(true)
|
||||
})
|
||||
|
||||
it('skips when the container is running and still starting (peer just created it)', () => {
|
||||
expect(shouldSkipStart({ running: true, health: 'starting' })).toBe(true)
|
||||
})
|
||||
|
||||
it('does not skip when the container is unhealthy', () => {
|
||||
expect(shouldSkipStart({ running: true, health: 'unhealthy' })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not skip when the container predates the healthcheck (no Health object)', () => {
|
||||
expect(shouldSkipStart({ running: true, health: '' })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not skip when the container is stopped', () => {
|
||||
expect(shouldSkipStart({ running: false, health: 'healthy' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parsePostgresContainerHealth', () => {
|
||||
it('parses a running healthy container', () => {
|
||||
expect(parsePostgresContainerHealth(stateJson({ health: 'healthy' }))).toEqual({
|
||||
running: true,
|
||||
health: 'healthy',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a stopped container without a Health object', () => {
|
||||
expect(parsePostgresContainerHealth(stateJson({ running: false, health: '' }))).toEqual({
|
||||
running: false,
|
||||
health: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a running container without a Health object (legacy pre-healthcheck container)', () => {
|
||||
expect(parsePostgresContainerHealth(stateJson({ health: '' }))).toEqual({
|
||||
running: true,
|
||||
health: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('treats an explicit null Health as no healthcheck', () => {
|
||||
expect(
|
||||
parsePostgresContainerHealth('{"Status":"running","Running":true,"Health":null}'),
|
||||
).toEqual({
|
||||
running: true,
|
||||
health: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null on unparseable output', () => {
|
||||
expect(parsePostgresContainerHealth('not json')).toBeNull()
|
||||
expect(parsePostgresContainerHealth('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('startSharedPostgres', () => {
|
||||
beforeEach(() => {
|
||||
execFileSyncMock.mockReset()
|
||||
})
|
||||
|
||||
const composeArgs = () =>
|
||||
execFileSyncMock.mock.calls.find((call) => call[1][0] === 'compose')?.[1]
|
||||
|
||||
it('is a no-op when the container is already healthy (never touches compose)', async () => {
|
||||
execFileSyncMock.mockReturnValue(stateJson({ health: 'healthy' }))
|
||||
await startSharedPostgres({ cwd: '/repo' })
|
||||
expect(execFileSyncMock.mock.calls).toHaveLength(1)
|
||||
expect(execFileSyncMock.mock.calls[0][1]).toEqual([
|
||||
'inspect',
|
||||
'--format',
|
||||
'{{json .State}}',
|
||||
'teqo-postgres-1',
|
||||
])
|
||||
})
|
||||
|
||||
it('runs compose up when the container does not exist', async () => {
|
||||
execFileSyncMock.mockImplementationOnce(throwNoSuchObject).mockReturnValueOnce('')
|
||||
await startSharedPostgres({ cwd: '/repo' })
|
||||
expect(composeArgs()).toEqual([
|
||||
'compose',
|
||||
'-p',
|
||||
'teqo',
|
||||
'up',
|
||||
'-d',
|
||||
'--wait',
|
||||
'--wait-timeout',
|
||||
'120',
|
||||
'postgres',
|
||||
])
|
||||
const composeCall = execFileSyncMock.mock.calls.find((call) => call[1][0] === 'compose')
|
||||
expect(composeCall?.[2].cwd).toBe('/repo')
|
||||
})
|
||||
|
||||
it('force-recreate bypasses the healthy skip', async () => {
|
||||
execFileSyncMock.mockReturnValue(stateJson({ health: 'healthy' })).mockReturnValueOnce('')
|
||||
await startSharedPostgres({ cwd: '/repo', forceRecreate: true })
|
||||
expect(composeArgs()).toEqual([
|
||||
'compose',
|
||||
'-p',
|
||||
'teqo',
|
||||
'up',
|
||||
'-d',
|
||||
'--force-recreate',
|
||||
'--wait',
|
||||
'--wait-timeout',
|
||||
'120',
|
||||
'postgres',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForHealthyPostgres', () => {
|
||||
beforeEach(() => {
|
||||
execFileSyncMock.mockReset()
|
||||
})
|
||||
|
||||
it('waits for a starting container and returns true when it becomes healthy', async () => {
|
||||
execFileSyncMock
|
||||
.mockReturnValueOnce(stateJson({ health: 'starting' }))
|
||||
.mockReturnValue(stateJson({ health: 'healthy' }))
|
||||
expect(await waitForHealthyPostgres({ timeoutMs: 50, intervalMs: 1 })).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back (false) when a starting container never becomes healthy', async () => {
|
||||
execFileSyncMock.mockReturnValue(stateJson({ health: 'starting' }))
|
||||
expect(await waitForHealthyPostgres({ timeoutMs: 10, intervalMs: 1 })).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back (false) when the container turns unhealthy', async () => {
|
||||
execFileSyncMock
|
||||
.mockReturnValueOnce(stateJson({ health: 'starting' }))
|
||||
.mockReturnValue(stateJson({ health: 'unhealthy' }))
|
||||
expect(await waitForHealthyPostgres({ timeoutMs: 50, intervalMs: 1 })).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back (false) when the container disappears', async () => {
|
||||
execFileSyncMock
|
||||
.mockReturnValueOnce(stateJson({ health: 'starting' }))
|
||||
.mockImplementation(throwNoSuchObject)
|
||||
expect(await waitForHealthyPostgres({ timeoutMs: 50, intervalMs: 1 })).toBe(false)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue