From 7c17d43723ab0aba6dd62c88d529f6104fb96146 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 10 Aug 2026 19:57:52 -0300 Subject: [PATCH 01/10] =?UTF-8?q?fix(OPS37):=20db:start=20idempotente=20?= =?UTF-8?q?=E2=80=94=20n=C3=A3o=20recria=20o=20container=20Postgres=20comp?= =?UTF-8?q?artilhado=20(no-op=20quando=20saud=C3=A1vel;=20--force-recreate?= =?UTF-8?q?=20expl=C3=ADcito)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- docs/CHANGELOG-AGENTS.md | 2 + ...gres-compartilhado-compose-replace-impl.md | 86 +++++++++ package.json | 2 +- scripts/db-start.mjs | 55 ++++++ scripts/lib/db-start.mjs | 136 +++++++++++++ scripts/worktree.mjs | 15 +- tests/unit/dbStart.unit.spec.ts | 180 ++++++++++++++++++ 8 files changed, 464 insertions(+), 14 deletions(-) create mode 100644 docs/plans/container-postgres-compartilhado-compose-replace-impl.md create mode 100644 scripts/db-start.mjs create mode 100644 scripts/lib/db-start.mjs create mode 100644 tests/unit/dbStart.unit.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 17ef92f4..5cd9cd00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ These are product/architecture constraints — not a ban on editing existing cod - Schema changes go through committed Payload migrations — `push: false` everywhere, never flip it on against a remote DB. Edit configs → `pnpm migrate:create ` → commit `src/migrations/*` (both `.ts` and `.json`, plus `index.ts`) → `pnpm migrate` locally. `pnpm build` runs `payload migrate`, so **every Vercel deploy applies pending migrations to prod**. Prod is baselined at migration `20260715_163458_initial` — never regenerate or replace the initial migration. Full workflow (incl. hand-written data/reconciliation migrations) lives in the `payload-migrations` skill (`.agents/skills/payload-migrations`). - `Consent.text` was reconciled from `varchar` to `jsonb` via `20260715_163500_consent_text_to_jsonb` (matching the `richText` field). Any future field-type change is a migration, not a manual DB edit. - To copy production content locally, use `pnpm db:pull` (`scripts/db-pull.mjs`): it only reads prod, only writes local, and excludes supporter PII (`contact`/`signature`/`subscription` row data). -- `pnpm db:doctor` diagnoses local database connectivity for the dev AND test targets: it names who holds the port, points at exited/crash-looping containers, and flags Postgres containers sharing one data volume (two postmasters on one PGDATA corrupt the WAL — this destroyed the local cluster's checkpoint on 2026-07-25; the canonical setup is ONE compose container on 5432 hosting `teqo` + `teqo_test`). `pnpm dev` runs the same preflight via `guard-dev-db` and refuses to start with a named remedy instead of a later ECONNREFUSED. `db:start`/`db:stop` pin the compose project with `-p teqo`, so they always target that ONE container even from a worktree directory. +- `pnpm db:doctor` diagnoses local database connectivity for the dev AND test targets: it names who holds the port, points at exited/crash-looping containers, and flags Postgres containers sharing one data volume (two postmasters on one PGDATA corrupt the WAL — this destroyed the local cluster's checkpoint on 2026-07-25; the canonical setup is ONE compose container on 5432 hosting `teqo` + `teqo_test`). `pnpm dev` runs the same preflight via `guard-dev-db` and refuses to start with a named remedy instead of a later ECONNREFUSED. `db:start`/`db:stop` pin the compose project with `-p teqo`, so they always target that ONE container even from a worktree directory. **`db:start` is idempotent (OPS37):** with `teqo-postgres-1` up and healthy it is a no-op — a plain `compose up` from another worktree would recreate the shared container and kill every live dev/e2e connection (the compose file's relative `./docker/postgres/init` bind mount makes the config hash differ per worktree); apply a real config/image change on purpose with `pnpm db:start --force-recreate`. <<<<<<< HEAD - # **Per-worktree environments (`pnpm worktree next`, 2026-08-08; default-go 2026-08-10):** parallel agents each get a deterministic isolated env — no fighting over port 3000, the shared `teqo_test`, or a second Postgres container. `worktree next [--stay]` provisions: a dev-server port `3100+slot` (slot = numeric part of the issue code, hash fallback, bumped on collision among live worktrees), plus dedicated databases `teqo_wt` (dev) and `teqo_wt_test` (tests) created on the shared container with the full migration chain AND the minimal seed applied (`pnpm migrate` + `pnpm db:seed:minimal` per database — OPS28 parity: CI runs the same pair before e2e, so a worktree's `pnpm test:e2e` mirrors CI instead of failing on seed-dependent specs; the seed is idempotent and local-guarded, and `--no-migrate` skips both); writes `.env.local` + `.env.test.local` (gitignored, marker `# teqo worktree env (generated by pnpm worktree next)`; manual envs are never overwritten) and copies `PAYLOAD_SECRET`/Blob/VAPID from the main repo. **`next`/`plan`/`new` print `cd ` as the last line BY DEFAULT** — the caller applies it (shell function `worktree()` in `.agents/shell/worktree.sh`, or the `/worktree` opencode command); `--stay` suppresses the cd, `--go` explicit stays accepted as a no-op (it was the old default), and `kill` ends with `cd
` so the shell never sits in a destroyed directory. **In the interactive terminal the shell function sets `TEQO_WORKTREE_TERMINAL=1` and `next`/`plan`/`new` additionally print a `launch opencode --model deepseek/deepseek-v4-flash --auto [--prompt ]` directive BEFORE the `cd` line, which the function executes after the cd (OPS26, 2026-08-10; OPS31 for `plan`): `next` opens the opencode TUI in the worktree with `/work-issue` already sent (the OPS25 command runs the whole cycle), `plan` opens with `/plan-issue` already sent (the TUI lands straight in the planning flow), `new` opens with no prompt ("apenas conversar", no skill — the opencode CLI's `--prompt` always auto-submits, there is no prefill-without-submit; that stays an upstream-only feature request, annotated in the opencode proposition). The launch is without `exec` (exiting the TUI returns to the shell in the worktree, no auto-`kill`), `--stay` suppresses both the cd and the launch, the presets are constants in `scripts/lib/worktree.mjs` (`OPENCODE_PRESET_MODEL`, `OPENCODE_SKILL_COMMAND_BY_PURPOSE`, unit-tested), and **the `/worktree` opencode command never launches a TUI** (no marker — no nested TUI). `worktree plan [bag] [--stay]` (2026-08-08) is the sibling for the `/plan-issue` skill: **each invocation creates a DIFFERENT planning worktree** so parallel `/plan-issue` sessions never share one — branch `plans/plan-issue-` when a `bag` slug is given (suffixed `-2`/`-3` if that name is taken) or the next free sequential `plans/plan-issue-` when not (`PLAN_BRANCH_PREFIX`/`planBranchName`, `scripts/lib/worktree.mjs`); the lowercase `plans/…` prefix deliberately never matches `next`'s uppercase-led `-`, so a later `next` never collides in branch or slot. `worktree new [bag] [--stay]` (OPS27, 2026-08-10) is the NEUTRAL sibling: a worktree with **no pre-defined function\*\* (explore an idea, chat, plan without registering anything) — also a DIFFERENT one per invocation, branch `work/` (suffixed `-2`/`-3` on collision) or the next free sequential `work/` (`WORK_BRANCH_PREFIX`/`workBranchName`, same `namespaceBranchName` core as `plan`); the lowercase `work/…` prefix collides with neither `next` nor `plan`, and the provisioning is identical with `purpose: 'new'` (env comment `gerado por pnpm worktree new`; the frozen env marker keeps its historical `…next` text). `kill` drops the worktree's generated databases best-effort. Tests read `.env.test.local` after `.env.test` (vitest.setup.ts / playwright.config.ts / db-doctor / gate-ci mirror the layering), and `assertTestDatabase` accepts `teqo_test` or `teqo__test` on localhost only — still fail-closed on remote. Pure derivation lives in `scripts/lib/worktree-env.mjs` (unit-tested). Without Docker (Cursor Cloud) it degrades to the shared `teqo`/`teqo_test` with a warning — and still runs migrate + seed mínimo on those shared databases (same parity; the seed re-pins campaignGoals and 5 municipalities there, overwriting manual tweaks). - **Per-worktree environments (`pnpm worktree next`, 2026-08-08; default-go 2026-08-10):** parallel agents each get a deterministic isolated env — no fighting over port 3000, the shared `teqo_test`, or a second Postgres container. **`next` claims BEFORE creating the worktree (OPS33, 2026-08-10):** the same queue/order and optimistic lock as `pnpm agent:claim` (shared `claimIssue` in `scripts/lib/agent-github.mjs`); a failed claim prints the reason and stops with no orphan worktree; `--issue ` claims the directed issue (`ready`) or **reopens** an already-claimed one (`in-progress`, no re-claim, worktree reuse stays idempotent); the claim brief is printed and the output warns "já claimada — não rodar `pnpm agent:claim`" (`plan`/`new`/`kill` never touch Issues; the pool's coordinated `claimIssueForPool` is untouched). `worktree next [--issue N] [--stay]` provisions: a dev-server port `3100+slot` (slot = numeric part of the issue code, capped at 999 with hash fallback, bumped on collision among live worktrees), plus dedicated databases `teqo_wt` (dev) and `teqo_wt_test` (tests) created on the shared container with the full migration chain AND the minimal seed applied (`pnpm migrate` + `pnpm db:seed:minimal` per database — OPS28 parity: CI runs the same pair before e2e, so a worktree's `pnpm test:e2e` mirrors CI instead of failing on seed-dependent specs; the seed is idempotent and local-guarded, and `--no-migrate` skips both); writes `.env.local` + `.env.test.local` (gitignored, marker `# teqo worktree env (generated by pnpm worktree next)`; manual envs are never overwritten) and copies `PAYLOAD_SECRET`/Blob/VAPID from the main repo. **`next`/`plan`/`new` print `cd ` as the last line BY DEFAULT** — the caller applies it (shell function `worktree()` in `.agents/shell/worktree.sh`, or the `/worktree` opencode command); `--stay` suppresses the cd, `--go` explicit stays accepted as a no-op (it was the old default), and `kill` ends with `cd
` so the shell never sits in a destroyed directory. **In the interactive terminal the shell function sets `TEQO_WORKTREE_TERMINAL=1` and `next`/`plan`/`new` additionally print a `launch opencode --model deepseek/deepseek-v4-flash --auto [--prompt "/work-issue --issue "]` directive BEFORE the `cd` line, which the function executes after the cd (OPS26, 2026-08-10; OPS33: `next` sends `/work-issue --issue ` — the claimed issue — with the prompt value quoted, and the shell tokenizes the line via xargs, quote-aware, never eval; OPS31 for `plan`): `next` opens the opencode TUI in the worktree with `/work-issue --issue ` already sent (the OPS25 command runs the whole cycle; the skill reads the rest from the Issue), `plan` opens with `/plan-issue` already sent (the TUI lands straight in the planning flow), `new` opens with no prompt ("apenas conversar", no skill — the opencode CLI's `--prompt` always auto-submits, there is no prefill-without-submit; that stays an upstream-only feature request, annotated in the opencode proposition). The launch is without `exec` (exiting the TUI returns to the shell in the worktree, no auto-`kill`), `--stay` suppresses both the cd and the launch (the claim still happens), the presets are constants in `scripts/lib/worktree.mjs` (`OPENCODE_PRESET_MODEL`, `OPENCODE_SKILL_COMMAND_BY_PURPOSE`, unit-tested), and **the `/worktree` opencode command never launches a TUI** (no marker — no nested TUI). `worktree plan [bag] [--stay]` (2026-08-08) is the sibling for the `/plan-issue` skill: **each invocation creates a DIFFERENT planning worktree** so parallel `/plan-issue` sessions never share one — branch `plans/plan-issue-` when a `bag` slug is given (suffixed `-2`/`-3` if that name is taken) or the next free sequential `plans/plan-issue-` when not (`PLAN_BRANCH_PREFIX`/`planBranchName`, `scripts/lib/worktree.mjs`); the lowercase `plans/…` prefix deliberately never matches `next`'s uppercase-led `-`, so a later `next` never collides in branch or slot. `worktree new [bag] [--stay]` (OPS27, 2026-08-10) is the NEUTRAL sibling: a worktree with **no pre-defined function\*\* (explore an idea, chat, plan without registering anything) — also a DIFFERENT one per invocation, branch `work/` (suffixed `-2`/`-3` on collision) or the next free sequential `work/` (`WORK_BRANCH_PREFIX`/`workBranchName`, same `namespaceBranchName` core as `plan`); the lowercase `work/…` prefix collides with neither `next` nor `plan`, and the provisioning is identical with `purpose: 'new'` (env comment `gerado por pnpm worktree new`; the frozen env marker keeps its historical `…next` text). `kill` drops the worktree's generated databases best-effort. Tests read `.env.test.local` after `.env.test` (vitest.setup.ts / playwright.config.ts / db-doctor / gate-ci mirror the layering), and `assertTestDatabase` accepts `teqo_test` or `teqo__test` on localhost only — still fail-closed on remote. Pure derivation lives in `scripts/lib/worktree-env.mjs` (unit-tested). Without Docker (Cursor Cloud) it degrades to the shared `teqo`/`teqo_test` with a warning — and still runs migrate + seed mínimo on those shared databases (same parity; the seed re-pins campaignGoals and 5 municipalities there, overwriting manual tweaks). diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index b33b3d8e..5b600663 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-10):** **OPS37** (Issue #605) — o container Postgres compartilhado **não é mais recriado** por um worktree vizinho: `pnpm db:start` virou `scripts/db-start.mjs` (CLI) sobre `scripts/lib/db-start.mjs` — preflight "container `teqo-postgres-1` Up + `healthy`/`starting` → **no-op** (compose nem roda); senão, `compose -p teqo up -d --wait --wait-timeout 120 postgres` como antes. **Causa raiz provada por medição:** o bind mount **relativo** `./docker/postgres/init` do compose resolve um path absoluto por worktree → `config_hash` diferente por worktree (OPS37 `39e89956…` / B195 `cf3cb522…` / C101 `917e11bb…`; diff do `config` = só o `source:` do bind) → qualquer `compose up` cru de outro worktree fazia `replace` e matava dev+e2e de todos (`terminating connection due to administrator command`) — o incidente se repetiu **ao vivo durante o gate**: o worktree `work/4` recriou o container no meio da suíte int (17 testes mortos; os 3 arquivos re-rodaram verdes e a suíte completa 691/691 passou em seguida). **Verificação do aceite:** `StartedAt` inalterado após `db:start` repetido, inclusive rodado de **outro worktree** (D9, hash diferente) — o caso que antes recriava. Recriação legítima (bump de imagem/config) segue por `pnpm db:start --force-recreate` (explícito; não testado live para não matar a run e2e do D9 em voo — flag é pass-through de 1 arg). `worktree.mjs` provisionamento delega para a mesma lib (mesmo risco no `worktree next`; `dockerComposeUp`/`SHARED_COMPOSE_PROJECT` órfãos removidos). Skip deliberado em `starting` (container que um peer acabou de criar): **espera** o boot ficar `healthy` (60s, polling 2s) em vez de recriar — e cai no compose up se travou; **não** skip em `unhealthy`/sem healthcheck (volta à config atual exatamente uma vez); `db:stop`/`db:doctor`/compose file/CI intactos. Unit novos (`dbStart.unit.spec.ts`, 17: matrix running×health + parse do inspect + wrapper mockado — skip não chama compose, argv do up/force-recreate, espera de `starting`). Sem migration/access/Consent/UI. Intenção [`container-postgres-compartilhado-compose-replace.md`](plans/container-postgres-compartilhado-compose-replace.md); impl [`container-postgres-compartilhado-compose-replace-impl.md`](plans/container-postgres-compartilhado-compose-replace-impl.md). + **Recently resolved (2026-08-10):** **B195** (Issue #591) — wizard "Registrar pedido" completo (A5, adiado no B85): o passo final saiu do stub e virou formulário funcional no shell do wizard — município no header do app (chrome `municipalityLabel`), **sem título grande de passo** (`stepTitle={null}` + `contentFocus="none"`), Tipo → Atividade (opcional) → **campo único** "O que você precisa?"; salvar ("Abrir demanda") → toast + retorno à origem (`returnPath`). **Mudança de modelo da demanda:** o cadastro (wizard e `/demandas/nova`, sem divergência) tem UM campo de texto (`description` required, min 2, max 4000 — constante `CAMPAIGN_DEMAND_BODY_MAX_LENGTH`); o **título virou derivado por IA** (`src/utilities/ai/campaignDemandTitle.ts` — `generateText` DeepSeek `deepseek-v4-flash` como o Sollinha, timeout 4s, sem key → `null`, prompt com rótulo do tipo) com **fallback = texto truncado** (`src/lib/demandTitle.ts`, puro: colapsa whitespace, ≤160) no create e **keep-previous na edição** (novo `updateCampaignDemandRecord` + editor inline no detalhe — recalc só quando a descrição muda; slug preservado pela invariante canônica); `campaignDemandCreateSchema` perdeu `title` (só os drafts de demanda da atividade mantêm título digitado — vertical fora de escopo, schema desacoplado). **Unicidade de slug defensiva só no create das forms** (`ensureUniqueDemandSlug`, opt-in `context.campaignDemandUniqueSlug` fail-closed em transação ativa — precedente `skipContactPhoneInvariant`): probe único da janela `-2…-20` + advisory lock `campaign-demand-slug:` contra race; activity drafts/admin mantêm a constraint estrita (contrato de rollback C90 pinado em teste). Page: branch `register-demand` no router + **staff gate ANTES do load do escopo** (`gate: 'staff'` → líder vai a `/campanha/contatos`, B43 — o 403 do scope load pré-existente virava erro não determinístico; stub `WizardMunicipalitySelectedStub` removido, placeholder "em breve" apagado). **Bug latente corrigido no hook compartilhado** `useCampaignFormSuccessToast` (dependência `[state.message, state.status]` → `[state]`: segundo save com a mesma mensagem — caso do editor — não disparava toast/refresh). Copy compartilhada em `schemas/campaignDemand.ts`; parser único `parseCampaignDemandCreateFormData` (`utilities/demand/`). **Sem migration** (título continua required, preenchido pelo record). Testes: unit do fallback/limites exatos; int (create deriva, colisão `-2`/`-3`, min 2, update keep-previous/skip, leader edit bloqueado, pin "sem `DEEPSEEK_API_KEY`" no `beforeAll`); e2e novo `campaignRegisterDemand` (fluxo completo com título na lista, back-nav à busca, lockdown de líder). Intenção [`registrar-pedido-wizard.md`](plans/registrar-pedido-wizard.md); impl [`registrar-pedido-wizard-impl.md`](plans/registrar-pedido-wizard-impl.md). **Recently resolved (2026-08-10):** **OPS33** (Issue #595) — `worktree next` **claima deterministicamente** antes de criar o worktree: mesma fila/ordem e lock otimista do `pnpm agent:claim`, com o claim extraído para `scripts/lib/agent-github.mjs` (`claimIssue` — lock + flip + comentário; `claimBriefLines` — brief compartilhado; `claimQueueEntry` — shape para o reopen; `claimTargetVerdict` — decisão pura testada) e o `agent-claim.mjs` virou CLI fino com a **mesma saída**; claim falhou (fila vazia/race/`--issue` inválido) → motivo e saída **sem worktree órfão** (branch validada antes do claim; hint de falha pós-claim). `--issue ` claima a Issue direcionada (`ready`) ou **reabre** uma já claimada (`in-progress` — sem re-claim, idempotente; recupera também falha pós-claim); a saída imprime o brief e avisa "já claimada — NÃO rodar `pnpm agent:claim`". O **launch** do terminal agora envia `/work-issue --issue ` (prompt citado na diretiva) e a função `worktree()` de `.agents/shell/worktree.sh` passou a **tokenizar a diretiva via xargs** (quote-aware, nunca eval) — o valor do `--prompt` carrega espaço. `--stay` suprime cd+launch mas o claim acontece; o comando `/worktree` roda o mesmo script (claim incluso, sem launch). Guard `-B` contra reset de commits de sessão no reopen. Pool intacto (`claimIssueForPool` coordenado). Docs: AGENTS.md ("Per-worktree environments"), header/uso do script, `.opencode/commands/worktree.md`, skill `worktree-next-issue` (sai "read-only"), AGENT-OPS. Sem migration/dados/access/UI. Intenção [`worktree-next-claima-issue-deterministicamente.md`](plans/worktree-next-claima-issue-deterministicamente.md); impl [`worktree-next-claima-issue-deterministicamente-impl.md`](plans/worktree-next-claima-issue-deterministicamente-impl.md). diff --git a/docs/plans/container-postgres-compartilhado-compose-replace-impl.md b/docs/plans/container-postgres-compartilhado-compose-replace-impl.md new file mode 100644 index 00000000..fcf3387b --- /dev/null +++ b/docs/plans/container-postgres-compartilhado-compose-replace-impl.md @@ -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) diff --git a/package.json b/package.json index b6314074..660d0c83 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/db-start.mjs b/scripts/db-start.mjs new file mode 100644 index 00000000..34d84dce --- /dev/null +++ b/scripts/db-start.mjs @@ -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() +} diff --git a/scripts/lib/db-start.mjs b/scripts/lib/db-start.mjs new file mode 100644 index 00000000..68afc264 --- /dev/null +++ b/scripts/lib/db-start.mjs @@ -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 `--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' }) +} diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs index 59719960..4ac1a121 100644 --- a/scripts/worktree.mjs +++ b/scripts/worktree.mjs @@ -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]})`) diff --git a/tests/unit/dbStart.unit.spec.ts b/tests/unit/dbStart.unit.spec.ts new file mode 100644 index 00000000..ad84f101 --- /dev/null +++ b/tests/unit/dbStart.unit.spec.ts @@ -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) + }) +}) From c4212b3e03a04c51791da671e00b8d6c4ec6eb47 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 10 Aug 2026 20:22:37 -0300 Subject: [PATCH 02/10] =?UTF-8?q?feat(push):=20opt-in=20autom=C3=A1tico=20?= =?UTF-8?q?e=20fix=20do=20prompt=20nativo=20no=20/campanha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - requestPermission agora é chamado sincronamente no clique: dentro do startTransition(async ...) o navegador perdia a user activation e o Chrome auto-negava silenciosamente ("Permissão de notificação negada" sem prompt) - CampaignPushOptInToast no shell (app): staff com permission 'default', sem inscrição ativa, consentimento publicado e VAPID configurado; dismiss em localStorage; iOS só com app instalado - cerimônia de ativação/desativação extraída para src/utilities/campaignPushClient.ts, compartilhada com o card de perfil - mensagens distintas para denied (reativar nas configurações do site) vs default (prompt não exibido); pin de top-level registrado - CHANGELOG-AGENTS.md: entrada única --- docs/CHANGELOG-AGENTS.md | 6 +- src/app/(campaign)/campanha/(app)/layout.tsx | 34 +++- .../auth/CampaignPushNotificationsCard.tsx | 172 ++++++------------ .../campaign/shell/CampaignPushOptInToast.tsx | 107 +++++++++++ src/lib/campaignNotificationCopy.ts | 22 +++ src/utilities/campaignPushClient.ts | 168 +++++++++++++++++ src/utilities/campaignPwa.ts | 1 + tests/unit/codebaseConventions.unit.spec.ts | 3 + 8 files changed, 387 insertions(+), 126 deletions(-) create mode 100644 src/components/campaign/shell/CampaignPushOptInToast.tsx create mode 100644 src/utilities/campaignPushClient.ts diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index f82eb61f..9cf5f980 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -1,8 +1,4 @@ -# Changelog de contexto para agentes (fatias do AGENTS.md) - -Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agentes paralelos): "Known Gaps" e todos os blocos "Recently resolved". Histórico — leia só quando precisar do contexto de uma entrega passada; o sempre-presente fica no `AGENTS.md`. Itens novos de "Recently resolved" passam a ser registrados aqui (uma entrada curta), não no AGENTS.md. - ---- +**Recently resolved (2026-08-10):** **Push /campanha — opt-in automático + fix do prompt nativo (ad-hoc).** O `requestPermission` do card de perfil rodava **dentro de `startTransition(async …)`** — qualquer fronteira assíncrona entre o clique e a chamada derruba a user activation e o Chrome **auto-nega silenciosamente** (resolve `'denied'` sem mostrar o prompt): o teste real em device deu exatamente "Permissão de notificação negada". Fix: os guards sync ficam no handler e **`Notification.requestPermission()` passa a ser chamado sincronamente no clique** (antes de qualquer `await`/transição); mensagens distintas para `denied` (permissão bloqueada — reativar nas configurações do site; um `denied` não pode ser re-perguntado por nenhuma API) vs `default` (prompt não exibido). **Opt-in automático** (pedido do usuário): novo `CampaignPushOptInToast` no shell `(app)` (padrão `InstallPwaToast`/`BiometricEnrollmentToast`), montado só para staff (leader não recebe push — sino idêntico), exibido ~2s após o login quando `Notification.permission === 'default'` (nunca `denied`), sem inscrição ativa no device, consentimento `campanha-notificacoes-push` publicado e VAPID configurado; iOS só com app instalado (standalone); "Aceitar e ativar" roda a mesma cerimônia com o requestPermission síncrono no clique do action; dismiss fica salvo em `localStorage` (chave `campaign-push-opt-in-dismissed`); ativação/desativação/leitura de inscrição extraídas para `src/utilities/campaignPushClient.ts` (compartilhado com o card — don't twin; registrado no pin de top-level com razão). **Diagnóstico de prod (read-only, `db:check:push-chain`):** consentimento `campanha-notificacoes-push` **já publicado** (PASS — nada a criar), envs VAPID presentes, SW versionado, 77 eventos no sino — **único elo quebrado: 0 inscrições push** (o erro de permissão impedia o cadastro). Sem migration/access/Consent/schema. Sem issue — entregue via ad-hoc no worktree `work/3`. **Recently resolved (2026-08-10):** **B195** (Issue #591) — wizard "Registrar pedido" completo (A5, adiado no B85): o passo final saiu do stub e virou formulário funcional no shell do wizard — município no header do app (chrome `municipalityLabel`), **sem título grande de passo** (`stepTitle={null}` + `contentFocus="none"`), Tipo → Atividade (opcional) → **campo único** "O que você precisa?"; salvar ("Abrir demanda") → toast + retorno à origem (`returnPath`). **Mudança de modelo da demanda:** o cadastro (wizard e `/demandas/nova`, sem divergência) tem UM campo de texto (`description` required, min 2, max 4000 — constante `CAMPAIGN_DEMAND_BODY_MAX_LENGTH`); o **título virou derivado por IA** (`src/utilities/ai/campaignDemandTitle.ts` — `generateText` DeepSeek `deepseek-v4-flash` como o Sollinha, timeout 4s, sem key → `null`, prompt com rótulo do tipo) com **fallback = texto truncado** (`src/lib/demandTitle.ts`, puro: colapsa whitespace, ≤160) no create e **keep-previous na edição** (novo `updateCampaignDemandRecord` + editor inline no detalhe — recalc só quando a descrição muda; slug preservado pela invariante canônica); `campaignDemandCreateSchema` perdeu `title` (só os drafts de demanda da atividade mantêm título digitado — vertical fora de escopo, schema desacoplado). **Unicidade de slug defensiva só no create das forms** (`ensureUniqueDemandSlug`, opt-in `context.campaignDemandUniqueSlug` fail-closed em transação ativa — precedente `skipContactPhoneInvariant`): probe único da janela `-2…-20` + advisory lock `campaign-demand-slug:` contra race; activity drafts/admin mantêm a constraint estrita (contrato de rollback C90 pinado em teste). Page: branch `register-demand` no router + **staff gate ANTES do load do escopo** (`gate: 'staff'` → líder vai a `/campanha/contatos`, B43 — o 403 do scope load pré-existente virava erro não determinístico; stub `WizardMunicipalitySelectedStub` removido, placeholder "em breve" apagado). **Bug latente corrigido no hook compartilhado** `useCampaignFormSuccessToast` (dependência `[state.message, state.status]` → `[state]`: segundo save com a mesma mensagem — caso do editor — não disparava toast/refresh). Copy compartilhada em `schemas/campaignDemand.ts`; parser único `parseCampaignDemandCreateFormData` (`utilities/demand/`). **Sem migration** (título continua required, preenchido pelo record). Testes: unit do fallback/limites exatos; int (create deriva, colisão `-2`/`-3`, min 2, update keep-previous/skip, leader edit bloqueado, pin "sem `DEEPSEEK_API_KEY`" no `beforeAll`); e2e novo `campaignRegisterDemand` (fluxo completo com título na lista, back-nav à busca, lockdown de líder). Intenção [`registrar-pedido-wizard.md`](plans/registrar-pedido-wizard.md); impl [`registrar-pedido-wizard-impl.md`](plans/registrar-pedido-wizard-impl.md). diff --git a/src/app/(campaign)/campanha/(app)/layout.tsx b/src/app/(campaign)/campanha/(app)/layout.tsx index 6b185126..6941e45f 100644 --- a/src/app/(campaign)/campanha/(app)/layout.tsx +++ b/src/app/(campaign)/campanha/(app)/layout.tsx @@ -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} + {pushOptIn ? : null} {biometricEnrollment ? ( ) : null} diff --git a/src/components/campaign/auth/CampaignPushNotificationsCard.tsx b/src/components/campaign/auth/CampaignPushNotificationsCard.tsx index 577c6f17..2c51e1ad 100644 --- a/src/components/campaign/auth/CampaignPushNotificationsCard.tsx +++ b/src/components/campaign/auth/CampaignPushNotificationsCard.tsx @@ -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) } }) } diff --git a/src/components/campaign/shell/CampaignPushOptInToast.tsx b/src/components/campaign/shell/CampaignPushOptInToast.tsx new file mode 100644 index 00000000..753cfca6 --- /dev/null +++ b/src/components/campaign/shell/CampaignPushOptInToast.tsx @@ -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 +} diff --git a/src/lib/campaignNotificationCopy.ts b/src/lib/campaignNotificationCopy.ts index 5752aab6..078c412f 100644 --- a/src/lib/campaignNotificationCopy.ts +++ b/src/lib/campaignNotificationCopy.ts @@ -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, diff --git a/src/utilities/campaignPushClient.ts b/src/utilities/campaignPushClient.ts new file mode 100644 index 00000000..2edfe1b3 --- /dev/null +++ b/src/utilities/campaignPushClient.ts @@ -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 => { + 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 => { + 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 => { + 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. + } +} diff --git a/src/utilities/campaignPwa.ts b/src/utilities/campaignPwa.ts index 7d68af02..7152938f 100644 --- a/src/utilities/campaignPwa.ts +++ b/src/utilities/campaignPwa.ts @@ -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 = [ diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 9daab9f4..488dfd67 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -427,6 +427,9 @@ 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', From 91ae6b5408f9963973efb605d9f729fd9fadc519 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 10 Aug 2026 21:28:52 -0300 Subject: [PATCH 03/10] =?UTF-8?q?test(OPS35+):=20restaurar=20pin=20de=20br?= =?UTF-8?q?owser=20da=20visibilidade=20CSS=20das=20colunas=20de=20rede=20e?= =?UTF-8?q?m=20Territ=C3=B3rios=20(Closes=20#616)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG-AGENTS.md | 2 + ...n-visibilidade-colunas-territorios-impl.md | 61 +++++++++++++++++++ scripts/lib/e2e-affected-manifest.mjs | 5 +- .../campaignTerritoriesColumns.e2e.spec.ts | 53 ++++++++++++++++ 4 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 docs/plans/restaurar-pin-visibilidade-colunas-territorios-impl.md create mode 100644 tests/e2e/campaignTerritoriesColumns.e2e.spec.ts diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index b33b3d8e..5e487aaf 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-10):** **OPS35+** (Issue #616) — restaurado o pin de browser que o OPS35 documentou como perdido: a **visibilidade CSS real** das colunas de rede (Assessor / Liderança / Dobradinha) e dos sortables de `/campanha/territorios` a 2200px. Spec novo mínimo `tests/e2e/campaignTerritoriesColumns.e2e.spec.ts` (projeto `campaign`, corpo exato do teste 3 do spec deletado — `getByRole('columnheader')` visível por rung container-query `@min-[Nrem]/territory-list`, Cobertura `count 0`); divisão de trabalho com o HTTP preservada (presença + classes de rung continuam no `campaignTerritoriesHttp`). Manifesto `e2e-affected`: entrada territórios agora mapeia os 2 specs **e** o arquivo dono das rungs `TerritoryListColumns.tsx` (uma mudança de rung acorda o pin; o seletor faz união, specs de municípios continuam acordando). Prova de não-vazio por mutação (rung `advisor` → `@min-[200rem]` fez o spec falhar em "Assessor"; revertido). Sem migration/access/Consent/UI/CSS. Intenção [`restaurar-pin-visibilidade-colunas-territorios.md`](plans/restaurar-pin-visibilidade-colunas-territorios.md); impl [`restaurar-pin-visibilidade-colunas-territorios-impl.md`](plans/restaurar-pin-visibilidade-colunas-territorios-impl.md). + **Recently resolved (2026-08-10):** **B195** (Issue #591) — wizard "Registrar pedido" completo (A5, adiado no B85): o passo final saiu do stub e virou formulário funcional no shell do wizard — município no header do app (chrome `municipalityLabel`), **sem título grande de passo** (`stepTitle={null}` + `contentFocus="none"`), Tipo → Atividade (opcional) → **campo único** "O que você precisa?"; salvar ("Abrir demanda") → toast + retorno à origem (`returnPath`). **Mudança de modelo da demanda:** o cadastro (wizard e `/demandas/nova`, sem divergência) tem UM campo de texto (`description` required, min 2, max 4000 — constante `CAMPAIGN_DEMAND_BODY_MAX_LENGTH`); o **título virou derivado por IA** (`src/utilities/ai/campaignDemandTitle.ts` — `generateText` DeepSeek `deepseek-v4-flash` como o Sollinha, timeout 4s, sem key → `null`, prompt com rótulo do tipo) com **fallback = texto truncado** (`src/lib/demandTitle.ts`, puro: colapsa whitespace, ≤160) no create e **keep-previous na edição** (novo `updateCampaignDemandRecord` + editor inline no detalhe — recalc só quando a descrição muda; slug preservado pela invariante canônica); `campaignDemandCreateSchema` perdeu `title` (só os drafts de demanda da atividade mantêm título digitado — vertical fora de escopo, schema desacoplado). **Unicidade de slug defensiva só no create das forms** (`ensureUniqueDemandSlug`, opt-in `context.campaignDemandUniqueSlug` fail-closed em transação ativa — precedente `skipContactPhoneInvariant`): probe único da janela `-2…-20` + advisory lock `campaign-demand-slug:` contra race; activity drafts/admin mantêm a constraint estrita (contrato de rollback C90 pinado em teste). Page: branch `register-demand` no router + **staff gate ANTES do load do escopo** (`gate: 'staff'` → líder vai a `/campanha/contatos`, B43 — o 403 do scope load pré-existente virava erro não determinístico; stub `WizardMunicipalitySelectedStub` removido, placeholder "em breve" apagado). **Bug latente corrigido no hook compartilhado** `useCampaignFormSuccessToast` (dependência `[state.message, state.status]` → `[state]`: segundo save com a mesma mensagem — caso do editor — não disparava toast/refresh). Copy compartilhada em `schemas/campaignDemand.ts`; parser único `parseCampaignDemandCreateFormData` (`utilities/demand/`). **Sem migration** (título continua required, preenchido pelo record). Testes: unit do fallback/limites exatos; int (create deriva, colisão `-2`/`-3`, min 2, update keep-previous/skip, leader edit bloqueado, pin "sem `DEEPSEEK_API_KEY`" no `beforeAll`); e2e novo `campaignRegisterDemand` (fluxo completo com título na lista, back-nav à busca, lockdown de líder). Intenção [`registrar-pedido-wizard.md`](plans/registrar-pedido-wizard.md); impl [`registrar-pedido-wizard-impl.md`](plans/registrar-pedido-wizard-impl.md). **Recently resolved (2026-08-10):** **OPS33** (Issue #595) — `worktree next` **claima deterministicamente** antes de criar o worktree: mesma fila/ordem e lock otimista do `pnpm agent:claim`, com o claim extraído para `scripts/lib/agent-github.mjs` (`claimIssue` — lock + flip + comentário; `claimBriefLines` — brief compartilhado; `claimQueueEntry` — shape para o reopen; `claimTargetVerdict` — decisão pura testada) e o `agent-claim.mjs` virou CLI fino com a **mesma saída**; claim falhou (fila vazia/race/`--issue` inválido) → motivo e saída **sem worktree órfão** (branch validada antes do claim; hint de falha pós-claim). `--issue ` claima a Issue direcionada (`ready`) ou **reabre** uma já claimada (`in-progress` — sem re-claim, idempotente; recupera também falha pós-claim); a saída imprime o brief e avisa "já claimada — NÃO rodar `pnpm agent:claim`". O **launch** do terminal agora envia `/work-issue --issue ` (prompt citado na diretiva) e a função `worktree()` de `.agents/shell/worktree.sh` passou a **tokenizar a diretiva via xargs** (quote-aware, nunca eval) — o valor do `--prompt` carrega espaço. `--stay` suprime cd+launch mas o claim acontece; o comando `/worktree` roda o mesmo script (claim incluso, sem launch). Guard `-B` contra reset de commits de sessão no reopen. Pool intacto (`claimIssueForPool` coordenado). Docs: AGENTS.md ("Per-worktree environments"), header/uso do script, `.opencode/commands/worktree.md`, skill `worktree-next-issue` (sai "read-only"), AGENT-OPS. Sem migration/dados/access/UI. Intenção [`worktree-next-claima-issue-deterministicamente.md`](plans/worktree-next-claima-issue-deterministicamente.md); impl [`worktree-next-claima-issue-deterministicamente-impl.md`](plans/worktree-next-claima-issue-deterministicamente-impl.md). diff --git a/docs/plans/restaurar-pin-visibilidade-colunas-territorios-impl.md b/docs/plans/restaurar-pin-visibilidade-colunas-territorios-impl.md new file mode 100644 index 00000000..bace0dce --- /dev/null +++ b/docs/plans/restaurar-pin-visibilidade-colunas-territorios-impl.md @@ -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
1 teste — corpo do antigo teste 3] --> Login[campaign.login
viewport 2200x1000 antes do login] + Login --> Goto[goto /campanha/territorios] + Goto --> Sort[sortables visíveis
Ordenar por 2022/Captura/2026/Classe/Assessoria] + Sort --> Net[rede visível
Assessor/Liderança/Dobradinha exact] + Net --> Cobertura[Cobertura count 0] + Manifest[e2e-affected: territórios
+campaignTerritoriesColumns
+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 diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 20653137..19947ddf 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -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`], diff --git a/tests/e2e/campaignTerritoriesColumns.e2e.spec.ts b/tests/e2e/campaignTerritoriesColumns.e2e.spec.ts new file mode 100644 index 00000000..8c58dae2 --- /dev/null +++ b/tests/e2e/campaignTerritoriesColumns.e2e.spec.ts @@ -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