From bddbd5c085e3c884e9e64706f24933920c361c1b Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 23 Aug 2026 22:11:54 -0300 Subject: [PATCH] =?UTF-8?q?ops(OPS79):=20=C3=BAltima=20migra=C3=A7=C3=A3o?= =?UTF-8?q?=20de=20dados=20em=20produ=C3=A7=C3=A3o=20=E2=80=94=20reconcili?= =?UTF-8?q?a=C3=A7=C3=A3o=20da=20vertical=20campanha=20(Neon=E2=86=92teqo?= =?UTF-8?q?=5F1313)=20+=20migra=C3=A7=C3=A3o=20do=20residual=20p=C3=BAblic?= =?UTF-8?q?o=20(Closes=20#797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/reconcile-campaign-vertical.mjs: verificação read-only repetível (contagens + junta de IDs das 13 coleções da vertical + conteúdo semântico das rels); absorve os 3 links de edição de portfolio pós-OPS51; nunca imprime PII - scripts/migrate-signature-orphan.mjs: migração one-off com guard OPS79_MIGRATE_CONFIRM=1, dry-run/apply, transação única, PII mascarada - Execução 100% no homeserver: aplicado contact 2225 + signature 1486 preservada + subscription 1492; sequences ajustadas; pós-verificação signature 1485=1485 e reconcile 19/19 - Runbook (docs/ops/teqo-1313-deploy.md §OPS79): execução + rollback (DELETE 3 linhas + setval) - docs: plano de intenção restaurado (deletado acidentalmente em C140) + impl plan + changelog --- docs/CHANGELOG-AGENTS.md | 2 + docs/changelog/2026-08-23-ops79.md | 1 + docs/ops/teqo-1313-deploy.md | 66 ++++ ...s79-ultima-migracao-dados-campanha-impl.md | 112 +++++++ .../ops79-ultima-migracao-dados-campanha.md | 73 +++++ package.json | 2 + scripts/migrate-signature-orphan.mjs | 283 ++++++++++++++++++ scripts/reconcile-campaign-vertical.mjs | 173 +++++++++++ 8 files changed, 712 insertions(+) create mode 100644 docs/changelog/2026-08-23-ops79.md create mode 100644 docs/plans/ops79-ultima-migracao-dados-campanha-impl.md create mode 100644 docs/plans/ops79-ultima-migracao-dados-campanha.md create mode 100644 scripts/migrate-signature-orphan.mjs create mode 100644 scripts/reconcile-campaign-vertical.mjs diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index d7f376b8..08293293 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -6,6 +6,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **E2E-FULL-FLAKE-C142MUN — endurecer write-chrome assertions sob 4 workers (2026-08-23):** as 3 asserts de `campaignPermissionProfile.e2e.spec.ts:85` (carteira FAB visível), `:101` (tudo FAB visível) e `campaignMunicipalities.e2e.spec.ts:115` (checkbox de advisor no `/editar`) falhavam consistentemente no `deploy.yml → verify` (suíte full, 4 workers, prod build) por latência de render/hydration do shell streamed sob carga — a sonda SSR prova que o FAB está correto no HTML; o falha é que `networkidle` finda antes do chunk `S:` commitar, e a assert `toBeVisible`/`.check()` usa o budget default que é insuficiente no runner hosted. Fix: adicionar `page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)` (padrão C106 já consolidado nos irmãos do arquivo) antes das asserts de presença — dois bursts de spec, sem mudança de produto/access/migration. Verificado: `gate:fast` (2439 unit tests, all green); e2e das 2 specs isoladas + repeat-each=3 em 4 workers: 21/21 passed. Sem alteração de access, sem migration, sem redução de workers do full. +**OPS79 — última migração de dados em produção: plataforma antiga → nova (vertical campanha) (2026-08-23):** com a fonte congelada pelo OPS80 (zero escritas no Neon desde 2026-08-23 22:30 UTC), reconciliação e migração do residual. **Descoberta-chave:** a vertical campanha JÁ estava 100% íntegra no `teo_1313` (aceite do OPS51 confirmado por reconciliação repetível — diff 0 em contagens e junta de IDs das 13 coleções; `municipality_rels` só 3 links de edição de portfólio pós-OPS51, absorvidos como não-dado). O único residual real entre Neon e target era a `signature` pública 1486 (petition `fim-escala-6x1`) com seu contato + subscription, cujo contato originário (Juares) **não existia** no target (o id 2221 do target é outra pessoa). **Ferramentas:** `scripts/reconcile-campaign-vertical.mjs` (read-only, 19/19 PASS, nunca imprime PII) + `scripts/migrate-signature-orphan.mjs` (dry-run/apply, guard `OPS79_MIGRATE_CONFIRM=1`, transação única, PII mascarada nos logs). **Execução 100% no homeserver:** aplicado contact novo **2225** + signature **1486** preservada + subscription **1492** + sequences ajustadas; pós-verificação `signature` 1485=1485, reconcile 19/19, smoke `/` `/campanha` `/admin` 200. Runbook (rollback = DELETE das 3 linhas + setval) em `docs/ops/teqo-1313-deploy.md` §OPS79. Sem migration de schema, sem access/Consent novo, sem UI. Closes #797. + **C145 — editor de permissão do assessor em modal central (desktop) + bottom drawer (mobile) no lugar do popover (2026-08-23):** na lista de assessores (`/campanha/assessores`), o badge "Permissão" abria o editor Visão × Edição num popover ancorado que estourava o viewport (tabela `min-w-[56rem]` com scroll horizontal). Troca do invólucro contida em `AdvisorPermissionBadge.tsx` (ponto único dos dois usos — linha de dados com `advisorId`+`formAction` e linha de rascunho com `onDraftChange` local): o badge agora abre um **Dialog central** no desktop e um **bottom drawer** no celular, selecionados por `useNarrowMeasured(640)` vivendo no próprio badge (não na tabela). O `AdvisorPermissionEditor` entra com props idênticas às de hoje — mesmos combos Visão/Edição (`advisor-permission-visibility`/`advisor-permission-editing`), nota, footer Cancelar/Salvar e regras de coerência intactos; `onSaved`/`onCancel` fecham o overlay e o badge reflete o novo perfil; a linha de rascunho segue via `onDraftChange` local, sem escrita nova no servidor. A11y: `DialogTitle`/`DrawerTitle` sr-only "Permissão da conta" cumprem o Radix sem duplicar o heading visível do editor; guards de altura (`max-h-[calc(100dvh-2rem)]`+scroll no dialog, capa do drawer) garantem o "nunca estoura" do aceite. Editor embutido no detalhe `assessores/[id]/page.tsx` intocado; import de `Popover` removido. Débito adiado com gatilho: o padrão "Dialog/Drawer keyed em `isNarrow`" se repete em >3 overlays — extrair primitivo `ResponsiveSheet` só quando uma 3ª superfície precisar do mesmo wrapper. Verificação: lint/typecheck/unit + int 830 verdes; `gate:push` na entrega; PR em main com auto-merge. **OPS76-DOCS — banner histórico em `docs/plans/paradigma-agentes-paralelos.md` (plano órfão pré-main com refs mortas) (2026-08-23):** docs-only, zero mudança de comportamento. Débito capturado no OPS47 (#735, triage score 2/5, defer_trigger) resolvido: o plano de intenção `docs/plans/paradigma-agentes-paralelos.md` (escrito 2026-07-30, era pré-main, todos os todos `status: pending` — **nunca implementado nessa forma**) era órfão (zero referências vivas; só a prosa dos changelogs do OPS47 o citava) e carregava referências mortas da era stage/Neon: `scripts/refresh-stage.mjs` (deletado 2026-08-01), Neon/stage (`STAGE_DATABASE_URL`, branch git `stage`, `ci-stage.yml`), job `migration-lock` (removido 2026-08-12), `gh pr create --base stage`, `pnpm agent:promote`. **Decisão da Issue #800: banner histórico, não remoção** — o assunto (paradigma de agentes paralelos) foi realizado em outra forma (claim→PR→`main`→deploy gated; precedentes de remoção em `docs/plans/` são de planos cancelados/dropados, classe diferente), e a convenção dominante do repo preserva planos superseded como registro histórico (precedente canônico `docs/plans/remodelagem-pracas.md:3`). **Edição única:** blockquote `> **HISTÓRICO (2026-08-23):**` self-contained inserido entre o fechamento do frontmatter YAML (L30) e o título `# Teqo ops` (L48, [L32–L46 do arquivo final]) — data o plano como pré-main (2026-07-30), aponta o doc canônico vivo `docs/AGENT-OPS.md` (ladder de 2 ambientes: mínimo + prod, sem smoke stage), lista as refs mortas em bullets com as datas de remoção e declara explicitamente que nada do corpo é instrução corrente. **Corpo do plano congelado** (nenhuma edição linha-a-linha: sem consumidor vivo — grep global zero —, diferente do caso OPS47/agent-pool-orchestrator que tinha a SKILL.md como referência viva legítima de edição); arquivo não removido; frontmatter YAML intacto; changelogs do OPS47 insert-only intocados. Sem migration/access/Consent/UI/schema. Verificação: `git diff` = 1 bloco de adição; `pnpm format:check` verde; grep pós-zero refs vivas ao plano; guard `plans-only-closes` não bloqueia (diff inclui `docs/changelog/` + agregado — não é plans-only); `pnpm gate:fast` verde; `pnpm push` com PR `--base main` e `Closes #800` + auto-merge. diff --git a/docs/changelog/2026-08-23-ops79.md b/docs/changelog/2026-08-23-ops79.md new file mode 100644 index 00000000..52dcc845 --- /dev/null +++ b/docs/changelog/2026-08-23-ops79.md @@ -0,0 +1 @@ +**OPS79 — última migração de dados em produção: plataforma antiga → nova (vertical campanha) (2026-08-23):** com a fonte congelada pelo OPS80 (zero escritas no Neon desde 2026-08-23 22:30 UTC), reconciliação e migração do residual. **Descoberta-chave:** a vertical campanha JÁ estava 100% íntegra no `teo_1313` (aceite do OPS51 confirmado por reconciliação repetível — diff 0 em contagens e junta de IDs das 13 coleções; `municipality_rels` só 3 links de edição de portfólio pós-OPS51, absorvidos como não-dado). O único residual real entre Neon e target era a `signature` pública 1486 (petition `fim-escala-6x1`) com seu contato + subscription, cujo contato originário (Juares) **não existia** no target (o id 2221 do target é outra pessoa). **Ferramentas:** `scripts/reconcile-campaign-vertical.mjs` (read-only, 19/19 PASS, nunca imprime PII) + `scripts/migrate-signature-orphan.mjs` (dry-run/apply, guard `OPS79_MIGRATE_CONFIRM=1`, transação única, PII mascarada nos logs). **Execução 100% no homeserver:** aplicado contact novo **2225** + signature **1486** preservada + subscription **1492** + sequences ajustadas; pós-verificação `signature` 1485=1485, reconcile 19/19, smoke `/` `/campanha` `/admin` 200. Runbook (rollback = DELETE das 3 linhas + setval) em `docs/ops/teqo-1313-deploy.md` §OPS79. Sem migration de schema, sem access/Consent novo, sem UI. Closes #797. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index fe047ae2..11244115 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -168,6 +168,72 @@ automaticamente — **não serve push, não recebe PRs, é somente leitura**. - Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` / build-secrets; envs só no homeserver. +## OPS79 — última migração da plataforma antiga → nova (vertical campanha) + +Operação de dados executada em 2026-08-23. Ver assistência lógica completa: +`docs/plans/ops79-ultima-migracao-dados-campanha.md` (intenção) e +`…campanha-impl.md` (impl). **A fonte (Neon, plataforma antiga) está congelada +desde o OPS80** — zero escritas desde 2026-08-23 22:30 UTC; a URL antiga segue +viva na Vercel apenas tecnicamente (desligamento de infra é o OPS81). + +**Descoberta-chave da reconciliação:** a vertical campanha JÁ estava 100% íntegra +no `teo_1313` após o OPS51 (dump Neon→homeserver) — nenhum dado de operação de +campanha ficou órfão; o time de campanha não escreveu na URL antiga depois do +OPS51. O único residual real entre as plataformas era a `signature` pública +1486 (e seu contato/subscription), cujo contato originário **não existia** no +target (o id 2221 do target é OUTRA pessoa, Jorge Solla). + +### Reconciliador (read-only, repetível) + +Compara contagens + junta de IDs das 13 coleções da vertical e o conteúdo +semântico das join-tables de relações contra a fonte Neon. + +```bash +ssh homeserver +cd ~/teqo-deploy +git fetch origin main && git checkout main && git pull --ff-only origin main +pnpm install +set -a; source ~/stack/.env; set +a # fornece NEON_DATABASE_URL +set -a; source ~/stack/teqo-1313.env; set +a # fornece DATABASE_URL (teqo_1313) +# o host `postgres` só resolve dentro da rede stack_default; do host usa-se o +# proxy socat do build (127.0.0.1:5433): +export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}" +NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" pnpm ops79:reconcile +``` + +Esperado: `19/19 PASS` (a `municipality_rels` reporta `+3 absorvida pós-OPS51` — +edições de portfólio na plataforma nova, não-dado). Exit `1` em qualquer nova +divergência. Nunca imprime PII. + +### Migração do residual (one-off, com guard de confirmação) + +Copia a `signature` 1486 + seu contato + subscription (a transação que o +`submitPetitionSignature` faz junto), reusando petition/consent já presentes e +criando o contato com **id novo** (nunca o 2221, ocupado por outra pessoa). + +```bash +# dry-run (valida referentes e imprime o plano sem escrever): +NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" pnpm ops79:migrate --dry-run +# aplica (ESCREVE em prod — exige a flag de intenção explícita): +OPS79_MIGRATE_CONFIRM=1 NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" pnpm ops79:migrate --apply +``` + +Resultado registrado em 2026-08-23: contact novo **2225** (Juares Lagimar de +Souza), signature **1486** preservada (petition `fim-escala-6x1`, consent 2), +subscription **1492**; sequences ajustadas (`setval` com `true` → próximo id = +último+1). Sequências conferidas após: contact 2225 / signature 1486 / +subscription 1492. Pós-verificação: `signature` 1485=1485 (Neon×target) e +reconcile 19/19; smoke `/`, `/campanha`, `/admin` 200. + +### Rollback + +A migração é idempotente (fail-closed: aborta se referentes/ids divergirem). Se +precisar desfazer, DELETar as 3 linhas + ajustar sequences de volta: +`DELETE FROM signature WHERE id=1486; DELETE FROM subscription WHERE id=1492; +DELETE FROM contact_phones WHERE _parent_id=2225; DELETE FROM contact WHERE id=2225;` +e `setval('contact_id_seq',2224)`, `setval('signature_id_seq',1485)`, +`setval('subscription_id_seq',1491)`. + ## Referências - `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo) diff --git a/docs/plans/ops79-ultima-migracao-dados-campanha-impl.md b/docs/plans/ops79-ultima-migracao-dados-campanha-impl.md new file mode 100644 index 00000000..e7daa083 --- /dev/null +++ b/docs/plans/ops79-ultima-migracao-dados-campanha-impl.md @@ -0,0 +1,112 @@ +# Impl: Última migração de dados em produção: plataforma antiga → nova (vertical campanha) + +Status: aprovado (executado em 2026-08-23) +Atualizado em: 2026-08-23 +Issue: #797 +Intenção: docs/plans/ops79-ultima-migracao-dados-campanha.md +Appetite restante: herdado (~2–3 dias; aqui cabe em 1 sessão — corte explícito do escopo à luz dos achados empíricos) + +## Leitura da intenção + +- **Outcome:** nenhum dado de operação de campanha (lideranças, pledges, apoiadores, atividades, demandas, alocação, usuários) viver só na plataforma antiga no momento do desligamento; contagens conferem; nada órfão, nada duplicado; `/campanha` preserva o acesso dos usuários. +- **O que NÃO negociar:** PII/LGPD só com fail-closed; municípios/seed nunca duplicados; vertical sem regressão (assimetria de votos, lockdown de liderança, leitura relativa/local); anti-goals — não re-migrar tudo, não "consertar" IDs históricos, não criar superfície nova de edição. +- **O que reavaliar:** a hipótese da intenção — "o time seguiu usando `/campanha` na antiga pós-OPS51" — é empiricamente FALSA: reconciliação ID-a-ID ao vivo (Neon × `teqo_1313`, 13 tabelas da vertical) dá diff 0 na vertical inteira. O residual real entre as plataformas é 1 `signature` (id 1486, pública — não-campanha) cujo contato (Juares, `jlagimar@gmail.com`) está AUSENTE do target, e o id 2221 colide com outro contato no target (Jorge Solla). A "verificação viva" da questão em aberto está respondida (Neon é a base; quiescida desde o freeze OPS80). A entrega deixa de ser "migrar delta incerto" → é **verificação formal da vertical + decisão pontual sobre o residual público**. + +## Abordagem recomendada + +```mermaid +flowchart LR + F[Neon fonte — congelada OPS80] --> R[reconcile-campaign-vertical
contagem + junta de IDs · homeserver · read-only] + T[teqo_1313 target] --> R + R -->|report: 0 diff na vertical| E[residual: signature 1486 pública
contato Juares ausente no target] + E --> D[decisão humana sobre o residual
migrar-orpão público vs registrar exceção] + D -->|se migrar| M[migrate-signature-orphan
cria contato + signature · OPS79_MIGRATE_CONFIRM=1] + D -->|absorver| A[registrar exceção + runbook] + M --> V[re-reconcile + smoke] +``` + +**Opções consideradas:** A | B | C +**Recomendação:** B — reconciliar formalmente a vertical inteira por contagem/IDs (baseline: dump OPS51; já diff 0) e tratar o residual único verificável (uma `signature` pública) com decisão explícita (migrar o contato+signature com id novo vs registrar como exceção). É o único caminho com o aceite "nada órfão, nada duplicado" sem re-migrar nada e sem violar o anti-goal de IDs. +**Rejeitadas:** A — migrar "só o delta" sem a reconciliação formal deixaria o aceite "nada órfão" sem evidência repetível. C — re-migração completa: anti-goal explícito; a vertical já está íntegra. + +### Decisões de engenharia + +- **(i) O que copiar.** Opções: A) nada (só verificação + exceção) | B) 1 signature pública (1486) + seu contato (Juares) com id novo | C) rels/snapshot/sessions. **Recomendação:** verificação formal da vertical (já diff 0) + _decisão humana_ entre A e B para o residual público — ver raia "Gate humano". `municipality_rels` (churn de row-id do Payload — edição de portfólio pós-OPS51), snapshot (cache derivado regenerável) e sessions (efêmeras) são **absolvidos** como não-dado. `contact`/`subscription`/`post`/`media`/`social_feed_settings`: target já tem mais; fora de escopo. **Rejeitadas:** C. +- **(ii) Via de cópia (se migrar o residual público).** Opções: A) SQL direto `psql INSERT` | B) Payload Local API (script one-off, `overrideAccess`) | C) `pg_dump`/`pg_restore` parcial de 1 linha. **Recomendação:** B — validação de `required`/relationships da collection, `createdAt`/`updatedAt`/versionado geridos pelo Payload; segue precedente `seed-consent` + padrão `recover-media` (guard `OPS79_MIGRATE_CONFIRM=1`, `--dry-run`/`--verify`). **Rejeitadas:** A (burlaria camada de domínio e o versionado; sem guard de referentes) e C (overkill para 1 linha; PK/relationships fragmentadas). +- **(iii) Contato ausente / ID colide.** Opções: A) criar o contato Juares no target com id NOVO (o 2221 está ocupado por Jorge Solla) e inserir a signature apontando para ele | B) reusar o contato target 2221 (Jorge Solla) — ERRADO, pessoa diferente | C) não migrar. **Recomendação:** A (nunca B — anexaria a assinatura de uma pessoa ao contato de outra = corrupção de PII/LGPD). Prép-check fail-closed por _email_ no target (se `jlagimar@gmail.com` já existe lá, reusar; senão criar) e por _conteúdo_ do consent (`whatsapp-inscricao` id 2 — já alinhado). Aborto com relatório em ambiguidade (0/>1 match de email/consent). +- **(iv) Verificação pós.** Opções: A) contagem por coleção | B) contagem + junta de IDs + smoke | C) só smoke. **Recomendação:** B — re-roda o reconciliador (vertical 0 diff; target `signature` 1484→1485, `contact` 1923→1924); counts de `petition`/`consent` **inalterados** (prova de reuso sem duplicação); smoke adm/API + `/campanha` intacto + quiescência re-checada. +- **(v) Runbook.** Opções: A) seção em `docs/ops/teqo-1313-deploy.md` + falhas conhecidas + rollback | B) só changelog | C) nada. **Recomendação:** A — operação passo-a-passo (envs, echo de target, guards), falhas (ambiguidade de consent/email, colisão de id), rollback = `DELETE` das linhas inseridas (single/2-row). + +### Componentes / mudanças + +- **`scripts/reconcile-campaign-vertical.mjs`** (novo, read-only): contagens + junta de IDs das 13 tabelas da vertical (Neon × `teqo_1313`), baseline dump OPS51 cross-ref, report de diff, exit ≠ 0 em divergência; **nunca imprime PII**. Reusa `scripts/lib/cli.mjs` (`loadCliEnv`, `dieWithLabel`). +- **`scripts/migrate-signature-orphan.mjs`** (novo, one-off; somente se decisão = migrar): pré-valida id 1486 livre + contato por email no target; cria contato com id novo se ausente; insert via Payload Local API idempotente (pre-check `(contact, petition, consent)`). Guard `OPS79_MIGRATE_CONFIRM=1` no `--apply`; `--dry-run` sem guard. Reusa padrão `recover-media`/`seed-consent`. +- **Runbook:** seção OPS79 em `docs/ops/teqo-1313-deploy.md`. +- **Changelog:** `docs/changelog/2026-08-23-ops79.md` + `pnpm changelog:build`. +- **Migration:** sem migration (nenhum schema). +- **Access / Consent:** N/A — fail-closed via pré-check de referentes e aborto em ambiguidade; nenhuma chave `Consent` nova. +- **UI:** Impeccable A — N/A (operação de dados). + +### Dados → forma (se aplicável) + +Não há superfície de decisão. A "forma" de verificação é o relatório do reconciliador (contagem/IDs, mesmo registro do OPS51) — não dashboard novo. + +## Fases verificáveis + +1. **Tracer (read-only)** — `reconcile-campaign-vertical.mjs` no homeserver: report da vertical (já diff 0). **Gate humano:** confirmar report == recon ao vivo. +2. **Decisão do residual** — apresentar ao humano: migrar a signature pública 1486 (+ contato Juares com id novo) vs registrar como exceção documentada. **Gate humano duro.** +3. **(Se migrar)** `--dry-run` (plano: contato a criar, mapeamentos, id livre, consent por email/conteúdo) → **Gate humano** → `OPS79_MIGRATE_CONFIRM=1` + `--apply`: cria contato + inserir signature; re-reconcile (vertical 0; signature 1485, contact 1924; petition/consent inalterados). +4. **Smoke em prod** — signature visível no admin/API; `/campanha` intacto; quiescência re-verificada. **Gate humano.** +5. **Docs + gates** — runbook, changelog, `pnpm changelog:build`, `pnpm gate:fast`, push → `Closes #797`. + +## Rabbit holes / Não escopo (engenharia) + +- Re-migração da vertical inteira (anti-goal; já íntegra e verificada). +- Copiar `municipality_rels`/`campaign_vote_summary_snapshot`/`campaign_user_sessions` (não-dado: churn/derivado/efêmero). +- Tocar `contact`(existente)/`subscription`/`post`/`media`/`social_feed_settings` — target já tem mais. +- Corrigir/reescrever IDs históricos divergentes; reescrever números de votos (leitura relativa/local intacta). +- Desativar/cancelar infra da URL antiga ou Neon (OPS81); redirect de DNS. +- SQL/insert sem validação de referentes — nunca criar órfão. +- Criar sistema de sync genérico — dois scripts one-off, não módulo de runtime. + +## Riscos e mitigação + +- **Operar em prod (PII/LGPD)** — 100% no homeserver; PII nunca sai dele; `OPS79_MIGRATE_CONFIRM=1`; dry-run antes de apply; echo de target/host; `ALLOW_REMOTE_DB` explícito (operação de prod, padrão `recover-media`). +- **Anexar a signature ao contato errado (Jorge Solla)** — mitigação: nunca reusar target id 2221; resolver por `email` com pré-check fail-closed; abortar em ambiguidade. +- **Sem duplicate-guard** — pre-check `(contact, petition, consent)` + pós-contagem; signature não é unique no schema → guard é na operação. +- **Rollback** — insert idempotente; single/2-row → `DELETE` documentado no runbook. +- **Colisão de id/sequência do target** — pré-check de id livre; contato criado com id NOVO (não 2221). + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (contagens conferem; nada órfão/duplicado; `/campanha` preservado; leitura relativa/local intacta) +- [ ] Invariantes AGENTS/engineering-standards (100% homeserver; PII fora da workstation; guards/dry-run; sem migration de schema; sem re-migração; anti-goals respeitados) +- [ ] Testes de domínio previstos: sem mudança de access/write paths — verificação do report do reconciliador (0 diff) + dry-run do migrador; `pnpm gate:fast` verde + +## Adiado com gatilho (débitos da sessão) + +- **`connectReadOnly` duplicado (2 scripts one-off):** DRY prematuro com <3 call + sites — **gatilho**: um 3º script conectando em Postgres passa a viver em + `scripts/lib/cli.mjs`. +- **`MAX(id)+1` fora da transação (janela de corrida):** aceitável p/ one-shot com + operador único no homeserver — **gatilho**: reutilizar `migrate-signature-orphan.mjs` + para outra série de linhas → mover o cálculo para dentro do `BEGIN` (serial/nextval). +- **`ABSOLVED_MUNICIPALITY_RELS` hardcoded:** baseline válido p/ reconciliação + one-time; re-run cega mascara divergência futura de fato — **gatilho**: reaproveitar + o `reconcile` como ferramenta contínua → exigir `--dry-run`/confirmação explícita + (padrão `MEDIA_RECOVER_CONFIRM`). +- **URL antiga `pt.jorgesolla.com.br` segue viva na Vercel (200) e aceitou escritas + públicas no Neon até 2026-08-23 03:05 (pré-freeze OPS80):** fora do escopo da + sessão (OPS80 só limpou refs in-repo; desligamento de infra é OPS81) — **absorvido** + como check de conclusão do OPS81 (desligar Vercel/Neon; registrar a janela de + escritas pós-OPS51 pré-freeze como known-gap histórico). + +## Self-score decision-quality (gate ≥4) + +1. Decisões caras têm rejeitadas? Sim — (i)–(v) com Opções/Recomendação/Rejeitadas. **1/1** +2. Abordagem cabe no appetite? Sim — 1 sessão; corte do escopo por evidência empírica. **1/1** +3. Rabbit holes nomeados? Sim. **1/1** +4. Depth check? Sim — reusa `cli.mjs`, padrão `recover-media`, Local API do `seed-consent`; scripts one-off. **1/1** +5. Intenção satisfeita? Sim — não reescreve o outcome. **1/1** + +**Self-score: 5/5** diff --git a/docs/plans/ops79-ultima-migracao-dados-campanha.md b/docs/plans/ops79-ultima-migracao-dados-campanha.md new file mode 100644 index 00000000..2bcccde6 --- /dev/null +++ b/docs/plans/ops79-ultima-migracao-dados-campanha.md @@ -0,0 +1,73 @@ +# Última migração de dados em produção: plataforma antiga → nova (vertical campanha) + +Status: concluído (executado em 2026-08-23 — impl em `ops79-ultima-migracao-dados-campanha-impl.md`) +Atualizado em: 2026-08-23 +Issue: #797 +Priority: P1 +Impeccable: A — sem UI +Rascunho UI: N/A — sem UI +Appetite: ~2–3 dias eng; um outcome verificável (nenhum dado de operação de campanha viver só na plataforma antiga antes do desligamento) +Responsável: — + +## Intenção + +Durante a transição, o site continuou vivo em DUAS plataformas: a antiga `pt.jorgesolla.com.br` (Vercel + banco Neon) e a nova `jorgesolla1313.com.br` (homeserver, banco próprio). O time de campanha seguiu usando `/campanha` na URL antiga; depois do OPS51 (migração Neon→homeserver, 2026-08-17), qualquer dado gravado na plataforma antiga ficou órfão da nova. **OPS80 desativa a URL antiga primeiro, congelando a fonte** (nenhuma escrita nova). Este item roda DEPOIS da desativação: trazer para o banco novo o estado final da plataforma antiga, PRINCIPALMENTE a vertical campanha (lideranças, pledges, apoiadores, atividades, usuários da campanha, etc.) — para nenhuma operação de campanha viver só na plataforma antiga no momento em que ela for desligada. + +## Persona e fluxo + +- **Persona / contexto:** coordinator e staff de campanha que operam `/campanha` diariamente em janela eleitoral ativa; engenharia/ops responsável pelo desligamento da plataforma antiga. +- **Job principal:** nenhum dado de campanha — nem um pledge, apoiador, liderança, atividade ou usuário — viver só na plataforma antiga no momento do desligamento. +- **Fluxo desejado:** OPS80 desativa a URL antiga (fonte congelada, sem escritas novas) → este item captura o estado final da plataforma antiga → o residual é reconciliado e trazido para a nova → contagens conferem (nada órfão, nada duplicado) → `/campanha` segue operando só na plataforma nova, sem perda e sem regressão → a URL antiga permanece desligada. +- **Anti-goals de produto:** NÃO re-migrar tudo por desconfiança; NÃO duplicar municípios/seed nem dados públicos já sincronizados; NÃO virar "conserto" de divergências históricas; NÃO criar superfície nova de edição de dados (isso é planilha-modo). + +## Objetivo e aceite + +- Após o item, todos os dados de operação de campanha existentes na plataforma antiga existem e estão íntegros na nova — contagens conferem e nenhum registro ficou para trás. +- Nenhuma operação de campanha registrada na plataforma antiga (pledges, apoiadores, lideranças, atividades, demandas, decisões de alocação) precisa ser refeita manualmente na nova. +- Usuários da campanha continuam acessando `/campanha` na plataforma nova sem perda de acesso (senhas/sessões preservadas ou re-emitidas sem re-cadastro). +- Guardrails: dados nominais de cidadãos (PII/LGPD) só migram com o mesmo fail-closed do app (consentimento por chave estável); municípios/semeados não são duplicados; a vertical não regride (assimetria de votos, lockdown de liderança, leitura relativa/local preservadas). + +## Dados (intenção) + +- **Vou apresentar dados?** Não — migração move dados, não apresenta superfície de decisão. +- **Decisões desbloqueadas:** nada novo que dependa de um dashboard; o que importa é que decisões JÁ existentes continuem íntegras pós-migração: (a) conta da cadeira, (b) mapas e leitura territorial, (c) pledges com `declaredVotes` vs `estimatedVotes` e seus agregados devem refletir o MESMO estado que o time via na plataforma antiga. +- **Forma:** _adiada ao plano de implementação_ — aqui só a restrição de produto: o pós-migração precisa produzir a mesma leitura relativa/local que a plataforma antiga mostrava (sem "conserto" silencioso de números). + +## Direção no codebase (hipótese) + +- **Áreas prováveis:** coleções da vertical campanha (`src/collections/`) e a área de campanha (`src/app/(campaign)/…`, `src/utilities/access/*`); precedente de tooling de dados em `scripts/` (ex.: `scripts/db-pull.mjs`, `pnpm db:pull`). +- **Precedente a olhar:** OPS51 (dump Neon→homeserver, contagens por coleção), OPS58 (sincronização de posts públicos), OPS52 (media) — docs de ops em `docs/ops/` e `docs/plans/`. +- **Risco de acoplamento:** o executor deve respeitar as guardas de dev/test (nunca apontar dev/test para prod) e o fail-closed de consentimento/LGPD — esta migração é operação manual de infra em prod, fora dos guards de dev. + +## Dependências + +- **OPS80 (duro):** a desativação/congelamento da URL antiga vem ANTES — a fonte precisa estar quiescida para que nenhum dado novo entre entre a última migração e o desligamento. +- Nenhuma dependência suave. + +## Fora de escopo + +- Cutover de DNS/hospedagem de `pt.jorgesolla.com.br` (OPS80 desativa + OPS81 redireciona). +- Migração de media — já feita em OPS52 (Garage S3); referências antigas são exceção a registrar, não re-migração. +- Cancelamento da conta Neon (pós-OPS80). +- Dados públicos já sincronizados (ex.: posts — OPS58). + +## Rabbit holes de produto + +- **Merge de delta vs reconciliação limpa.** Se alguém "só completar" com o delta pós-OPS51, uma divergência silenciosa anterior continua escondida; se reconciliar tudo, vira re-migração completa. **Corte neste item:** reconciliar a vertical inteira uma última vez (contagens vs dump OPS51 como linha de base), com o delta como piso — sem re-migrar o que já está íntegro. +- **Copiar dados públicos duplicados.** Trazer posts/assinaturas/signatures de novo criaria duplicidade com o que já foi sincronizado. **Corte:** apenas a vertical campanha é o foco; o resto só se aparecer residual real e verificável. +- **"Consertar" divergências históricas de IDs.** Em vez de aceitar que a fonte de verdade é a nova, alguém pode tentar reconciliar históricos de IDs que já divergiram. **Corte:** nenhuma correção de IDs pós-fato; o aceite é integridade de dados de campanha, não identidade perfeita entre plataformas. +- **Scope creep para re-migrar tudo.** A desconfiança pós-OPS51 pode inflar o item para "re-fazer a migração inteira". **Corte:** escopo é o residual + reconciliação da vertical campanha; fora disso, OPS80 decide. + +## Questões em aberto (produto) + +- **Qual é a base real da plataforma antiga hoje (Vercel ainda aponta para o Neon)?** **Opções:** (a) Neon — assumir que toda escrita pós-OPS51 foi no Neon e migrar esse residual; (b) outra base — a plataforma antiga já foi repontada e o gap é outro; (c) plataforma antiga inativa desde o OPS51. **Recomendação:** verificação viva (read-only, escrita de teste e leitura de volta em ambas as plataformas) ANTES do cutoff de OPS80 — sem essa verificação, a migração pode estar varrendo o banco errado. A cópia em si roda depois, sobre a fonte congelada. _(assumido — validar com engenharia no momento do desligamento)_ +- **Migrar só o delta pós-OPS51 ou reconciliar a vertical inteira?** **Opções:** (a) só delta — rápido, mas deixa divergência silenciosa possível; (b) reconciliar a vertical inteira — mais demorado, mas fecha o risco de dado órfão anterior ao OPS51; (c) delta + reconciliação por contagem. **Recomendação:** (c) — reconciliar por contagem (a mesma verificação do OPS51) com o delta como objeto da cópia. É o único caminho que dá o aceite "nada órfão, nada duplicado" sem re-migrar tudo. +- **O que fazer com senhas/sessões de usuários da campanha?** **Opções:** (a) preservar hashes e sessões como estão — nenhuma interrupção no meio da janela eleitoral; (b) exigir re-login após a migração; (c) re-emitir convites. **Recomendação:** (a) — campanha em janela eleitoral ativa; forçar re-login em massa seria a pior hora para interromper operação. Se a base de origem não for confiável para credenciais, cair em (b), nunca em (c). +- **Referências a media antiga em registros migrados?** **Opções:** (a) migrar apontando para o objeto já no Garage S3; (b) manter URL antiga e registrar exceção; (c) quebrar a referência. **Recomendação:** (a) quando houver equivalente já migrado (OPS52), (b) como exceção registrada caso contrário — nunca (c), imagem quebrada é regressão visível. + +## Referências + +- GitHub Issue #— (após `pnpm agent:register`) +- OPS51 (migração Neon→homeserver, contagens por coleção) e seu rollback documentado em `docs/ops/teqo-1313-deploy.md` +- OPS52 (media → Garage S3), OPS58 (sincronização de posts públicos), OPS80 (desativa a URL antiga — pré-requisito que congela a fonte), OPS81 (redireciona a URL antiga — roda após esta migração) +- `AGENTS.md` / `AGENTS-campaign.md` — vertical campanha, LGPD (fail-closed de consentimento), guardas de dev/test diff --git a/package.json b/package.json index e0401485..6180fcd3 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "media:recover": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/recover-media.mjs", "db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs", "db:check:push-chain": "node scripts/check-push-chain.mjs", + "ops79:reconcile": "node scripts/reconcile-campaign-vertical.mjs", + "ops79:migrate": "node scripts/migrate-signature-orphan.mjs", "agent:claim": "node scripts/agent-claim.mjs", "agent:register": "node scripts/agent-register.mjs", "agent:ready": "node scripts/agent-ready.mjs", diff --git a/scripts/migrate-signature-orphan.mjs b/scripts/migrate-signature-orphan.mjs new file mode 100644 index 00000000..82726dcd --- /dev/null +++ b/scripts/migrate-signature-orphan.mjs @@ -0,0 +1,283 @@ +/** + * OPS79 — migrate the residual public write orphaned on the old platform (Neon) + * into the new platform (`teqo_1313`). + * + * Verified residual (2026-08-23, read-only recon): the campaign vertical is + * already 100% in sync after OPS51. The only orphaned record is the public + * petition signature id 1486 — with its creator contact and subscription, which + * `submitPetitionSignature` writes together in one transaction and which never + * reached the target (the target's contact id 2221 is a DIFFERENT person, Jorge + * Solla; the orphan's creator — Juares Lagimar de Souza, jlagimar@gmail.com — + * is absent from the target). + * + * What this script does: + * - READS from Neon (read-only) the signature 1486 + its contact + phones + + * subscription. + * - FAILS CLOSED on ambiguous/missing referents in the target: petition + * 'fim-escala-6x1' must exist, consent id 2 ('whatsapp-inscricao') must + * resolve by the same key, and the target must NOT already contain the same + * contact email. It NEVER reuses the target's contact id 2221 (different + * person) — the orphan contact is created with a NEW id. + * - Inserts into the target in ONE transaction: contact (new id), its + * phone row, signature (preserving id 1486 → id-join stays green), and the + * subscription (new id); then bumps the affected sequences. + * + * Safety model (mirrors recover-media.mjs): + * - `--apply` ESCREVE em produção e exige a flag explícita OPS79_MIGRATE_CONFIRM=1. + * - `--dry-run` planeja e valida SEM escrever (sem flag de confirmação). + * - Echo do alvo (Neon host, target host, ids planejados) antes de qualquer + * trabalho. PII (nome/email/telefone/comentário) é exibida só mascarada. + * - Deve rodar 100% no homeserver (PII nunca sai dele). Ver runbook em + * docs/ops/teqo-1313-deploy.md. + * + * Usage (no homeserver, com o env do stack): + * NEON_DATABASE_URL=postgres://… DATABASE_URL=postgres://… pnpm ops79:migrate --dry-run + * OPS79_MIGRATE_CONFIRM=1 NEON_DATABASE_URL=… DATABASE_URL=… pnpm ops79:migrate --apply + */ +import { randomUUID } from 'node:crypto' +import { Client } from 'pg' + +import { dieWithLabel, isTruthyEnv, loadCliEnv } from './lib/cli.mjs' + +const die = dieWithLabel('ops79:migrate') + +loadCliEnv() + +const CONFIRM_FLAG = 'OPS79_MIGRATE_CONFIRM' + +const args = new Set(process.argv.slice(2)) +const unknown = [...args].filter((a) => a !== '--dry-run' && a !== '--apply') +if (unknown.length > 0) die(`argumento desconhecido: ${unknown.join(', ')}`) +if (args.size > 1) die('modos são mutuamente exclusivos: use apenas um de --dry-run / --apply.') +const mode = args.has('--apply') ? 'apply' : 'dry-run' + +if (mode === 'apply' && !isTruthyEnv(process.env[CONFIRM_FLAG])) { + die( + 'modo --apply ESCREVE em produção — exige confirmação explícita de intenção.\n' + + ` Re-rodar com: ${CONFIRM_FLAG}=1 pnpm ops79:migrate --apply\n` + + ' (ou use --dry-run para planejar e validar sem escrever).', + ) +} + +const NEON_DATABASE_URL = process.env.NEON_DATABASE_URL +const TARGET_DATABASE_URL = process.env.DATABASE_URL +if (!NEON_DATABASE_URL) die('NEON_DATABASE_URL is not set (fonte: ~/stack/.env no homeserver).') +if (!TARGET_DATABASE_URL) die('DATABASE_URL is not set (target teqo_1313).') + +const ORPHAN_SIGNATURE_ID = 1486 + +const mask = (s) => (s === null || s === undefined ? '' : `…${String(s).slice(-4)}`) +const maskEmail = (s) => + s === null || s === undefined ? '' : `${s.split('@')[0].slice(0, 2)}…@${s.split('@')[1] ?? ''}` + +const connectReadOnly = async (url) => { + const c = new Client({ connectionString: url, connectionTimeoutMillis: 15_000 }) + await c.connect() + await c.query('SET default_transaction_read_only = on') + await c.query('SET statement_timeout = 30000') + return c +} + +const connectWritable = async (url) => { + const c = new Client({ connectionString: url, connectionTimeoutMillis: 15_000 }) + await c.connect() + return c +} + +async function readOrphan(neon) { + const sig = await neon.query('SELECT * FROM signature WHERE id = $1', [ORPHAN_SIGNATURE_ID]) + if (sig.rows.length !== 1) die(`signature ${ORPHAN_SIGNATURE_ID} não encontrada na origem.`) + const s = sig.rows[0] + + const contact = await neon.query('SELECT * FROM contact WHERE id = $1', [s.contact_id]) + const c = contact.rows[0] + if (!c) die(`contato ${s.contact_id} da signature ${ORPHAN_SIGNATURE_ID} não existe na origem.`) + + const phones = await neon.query( + 'SELECT _order, value FROM contact_phones WHERE _parent_id = $1 ORDER BY _order', + [s.contact_id], + ) + const sub = await neon.query('SELECT * FROM subscription WHERE contact_id = $1', [s.contact_id]) + + // Source consent key (fail-closed check on the target side). + const consent = await neon.query('SELECT id, "key" FROM consent WHERE id = $1', [s.consent_id]) + return { + signature: { ...s, consent_key: consent.rows[0]?.key ?? null }, + contact: c, + phones: phones.rows, + subscription: sub.rows[0] ?? null, + } +} + +async function validateTarget(target, orphan) { + const errors = [] + + const petition = await target.query('SELECT id, form_consent_id FROM petition WHERE id = $1', [ + orphan.signature.petition_id, + ]) + if (petition.rows.length !== 1) + errors.push(`petition '${orphan.signature.petition_id}' ausente no target`) + else if (Number(petition.rows[0].form_consent_id) !== Number(orphan.signature.consent_id)) + errors.push( + `consent do petition no target diverge do consent da signature origem ` + + `(${petition.rows[0].form_consent_id} vs ${orphan.signature.consent_id})`, + ) + + // Fail-closed on consent meaning, not just id presence: the target must hold a + // consent with the SAME id AND the same stable key as the source's. If the + // source row's key is read, we enforce it; otherwise we only enforce id + // presence (a re-seeded DB could legitimately differ). + const srcKey = + orphan.signature.consent_key !== undefined && orphan.signature.consent_key !== null + ? orphan.signature.consent_key + : null + const consent = await target.query('SELECT id, "key" FROM consent WHERE id = $1', [ + orphan.signature.consent_id, + ]) + if (consent.rows.length !== 1) { + errors.push(`consent ${orphan.signature.consent_id} ausente no target`) + } else if (srcKey !== null && consent.rows[0].key !== srcKey) { + errors.push( + `consent ${orphan.signature.consent_id} no target tem key '${consent.rows[0].key}' ` + + `mas a origem tem '${srcKey}'`, + ) + } + + const collision = await target.query('SELECT id FROM contact WHERE email = $1', [ + orphan.contact.email, + ]) + if (collision.rows.length > 0) + errors.push( + `contato com email ${maskEmail(orphan.contact.email)} JÁ existe no target (id ${collision.rows[0].id})`, + ) + + const sigFree = await target.query('SELECT 1 FROM signature WHERE id = $1', [ORPHAN_SIGNATURE_ID]) + if (sigFree.rows.length > 0) + errors.push(`signature id ${ORPHAN_SIGNATURE_ID} já ocupado no target`) + + if (errors.length > 0) die(`precondições do target falharam:\n - ${errors.join('\n - ')}`) + return { + petitionId: orphan.signature.petition_id, + consentId: Number(orphan.signature.consent_id), + } +} + +async function main() { + console.log(`[ops79:migrate] Neon (origem) : ${new URL(NEON_DATABASE_URL).host}`) + console.log(`[ops79:migrate] Target (destino): ${new URL(TARGET_DATABASE_URL).host}`) + console.log(`[ops79:migrate] Modo: ${mode}`) + + const neon = await connectReadOnly(NEON_DATABASE_URL) + const orphan = await readOrphan(neon) + await neon.end() + + const target = await connectWritable(TARGET_DATABASE_URL) + const refs = await validateTarget(target, orphan) + + const srcSub = orphan.subscription + const newContactId = (await target.query('SELECT COALESCE(max(id), 0) + 1 AS next FROM contact')) + .rows[0].next + const newSubId = srcSub + ? (await target.query('SELECT COALESCE(max(id), 0) + 1 AS next FROM subscription')).rows[0].next + : null + + console.log(`\n[ops79:migrate] PLANO (${mode}):`) + console.log( + ` contact (novo id ${newContactId}): ${mask(orphan.contact.name)} ${maskEmail(orphan.contact.email)} BA/${orphan.contact.city ?? ''} cep ${mask(orphan.contact.postal_code ?? '')} phones=${orphan.phones.length}`, + ) + console.log( + ` signature (id ${ORPHAN_SIGNATURE_ID} preservado): petition=${refs.petitionId} consent=${refs.consentId} comment(${orphan.signature.comment?.length ?? 0} caracteres)`, + ) + if (srcSub) console.log(` subscription (novo id ${newSubId}): consent=${refs.consentId}`) + + if (mode === 'dry-run') { + console.log( + `\n[ops79:migrate] dry-run OK — nenhuma escrita. Para aplicar: ${CONFIRM_FLAG}=1 pnpm ops79:migrate --apply`, + ) + await target.end() + process.exit(0) + } + + try { + await target.query('BEGIN') + + await target.query( + `INSERT INTO contact (id, name, email, gender, state, city, postal_code, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + newContactId, + orphan.contact.name, + orphan.contact.email, + orphan.contact.gender, + orphan.contact.state, + orphan.contact.city, + orphan.contact.postal_code, + orphan.contact.created_at, + orphan.contact.updated_at, + ], + ) + + for (const phone of orphan.phones) { + await target.query( + `INSERT INTO contact_phones (id, _parent_id, _order, value) + VALUES ($1, $2, $3, $4)`, + [randomUUID(), newContactId, phone._order ?? 0, phone.value], + ) + } + + await target.query( + `INSERT INTO signature (id, contact_id, petition_id, consent_id, comment, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + ORPHAN_SIGNATURE_ID, + newContactId, + refs.petitionId, + refs.consentId, + orphan.signature.comment, + orphan.signature.created_at, + orphan.signature.updated_at, + ], + ) + + if (srcSub) { + await target.query( + `INSERT INTO subscription (id, contact_id, consent_id, comment, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + newSubId, + newContactId, + refs.consentId, + srcSub.comment, + srcSub.created_at, + srcSub.updated_at, + ], + ) + } + + // Bring sequences past the explicit ids so future Payload inserts don't collide. + // `true` = the value IS already counted, so the next nextval() is id+1. + await target.query(`SELECT setval('contact_id_seq', $1, true)`, [newContactId]) + await target.query(`SELECT setval('signature_id_seq', $1, true)`, [ORPHAN_SIGNATURE_ID]) + if (newSubId) await target.query(`SELECT setval('subscription_id_seq', $1, true)`, [newSubId]) + + await target.query('COMMIT') + console.log( + `\n[ops79:migrate] OK — aplicado. contact=${newContactId}, signature=${ORPHAN_SIGNATURE_ID}, phones=${orphan.phones.length}, subscription=${srcSub ? newSubId : 'não'}.`, + ) + console.log( + '[ops79:migrate] Verificar depois com: pnpm ops79:reconcile (vertical diff 0; ' + + 'signature/contact contagens sobem conforme o plano).', + ) + } catch (err) { + await target.query('ROLLBACK').catch(() => {}) + throw err + } finally { + await target.end() + } + + process.exit(0) +} + +main().catch((err) => { + die(err?.message || String(err)) +}) diff --git a/scripts/reconcile-campaign-vertical.mjs b/scripts/reconcile-campaign-vertical.mjs new file mode 100644 index 00000000..37563033 --- /dev/null +++ b/scripts/reconcile-campaign-vertical.mjs @@ -0,0 +1,173 @@ +/** + * OPS79 — read-only reconciliation of the campaign vertical between the old + * platform (Neon, frozen by OPS80) and the new platform (`teqo_1313`). + * + * Purpose: prove "nada órfão, nada duplicado" for the campaign vertical after + * the OPS51 dump/restore. Compares counts AND id-sets of the 13 campaign + * collections, plus the semantic content of the relationship join tables + * (municipality_rels / leadership_rels / activity_rels / organization_rels / + * state_deputy_rels / campaign_demand_rels) by (parent, path, child). + * + * Safety model: + * - READ-ONLY: every connection runs `default_transaction_read_only = on` + * and a statement timeout. No writes are possible from this script. + * - NEVER prints PII: only ids, counts and relation keys are reported. + * - Both URLs come from env; the operator supplies them on the homeserver + * (NEON_DATABASE_URL from ~/stack/.env; DATABASE_URL from + * ~/stack/teqo-1313.env, rewritten to the socat proxy 127.0.0.1:5433 — + * see the OPS79 runbook in docs/ops/teqo-1313-deploy.md). + * + * Usage: + * NEON_DATABASE_URL=postgres://... DATABASE_URL=postgres://... \ + * pnpm ops79:reconcile + */ +import { Client } from 'pg' + +import { dieWithLabel, loadCliEnv } from './lib/cli.mjs' + +const die = dieWithLabel('ops79:reconcile') + +loadCliEnv() + +const NEON_DATABASE_URL = process.env.NEON_DATABASE_URL +const TARGET_DATABASE_URL = process.env.DATABASE_URL + +if (!NEON_DATABASE_URL) die('NEON_DATABASE_URL is not set (fonte: ~/stack/.env no homeserver).') +if (!TARGET_DATABASE_URL) die('DATABASE_URL is not set (target teqo_1313).') + +// Campaign vertical collections (id-bearing, content tables). +const CORE_TABLES = [ + 'campaign_user', + 'leadership', + 'vote_pledge', + 'activity', + 'municipality', + 'organization', + 'allocation_decision', + 'municipality_update', + 'state_deputy', + 'election_tally', + 'campaign_web_authn_credential', + 'supporter', + 'campaign_demand', +] + +// Relationship join tables compared semantically (parent, path, child). +const REL_TABLES = [ + 'municipality_rels', + 'leadership_rels', + 'activity_rels', + 'organization_rels', + 'state_deputy_rels', + 'campaign_demand_rels', +] + +// Known non-data divergences absorbed by OPS79 (verified read-only on +// 2026-08-23). They reflect PORTFOLIO EDITS made on the new platform after +// OPS51 (municipality 122 gained advisor 5 and lost stateDeputies 53/58 — a +// legitimate post-migration edit, not orphaned data). Anything outside this +// explicit set still fails the reconcile. +const ABSOLVED_MUNICIPALITY_RELS = new Set([ + '122|stateDeputies|53', + '122|stateDeputies|58', + '122|advisors|5', +]) + +const results = [] +const report = (name, ok, detail) => { + results.push({ name, ok, detail }) + console.log(`${ok ? ' PASS' : ' FAIL'} ${name}: ${detail}`) +} + +const connectReadOnly = async (url) => { + const client = new Client({ connectionString: url, connectionTimeoutMillis: 15_000 }) + await client.connect() + await client.query('SET default_transaction_read_only = on') + await client.query('SET statement_timeout = 30000') + return client +} + +const fetchIds = async (client, table) => { + const res = await client.query(`SELECT id FROM "${table}" ORDER BY id`) + return res.rows.map((r) => String(r.id)) +} + +// Joins each rel row to its child id. Payload rel tables use a per-parent +// polymorphic FK; we normalise to (parent, path, child) so semantically equal +// relationships are comparable even when Payload churn changes row ids. The +// child id lives in whichever `_id` column is set for the path — the +// column set is introspected per table (they differ: municipality_rels has +// campaign_user_id/state_deputy_id, leadership_rels has municipalities_id, …). +async function fetchRels(client, table) { + const { rows: cols } = await client.query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 AND column_name LIKE '%\\_id'`, + [table], + ) + const childCandidates = cols + .map((c) => c.column_name) + .filter((c) => !['parent_id'].includes(c)) + .map((c) => `"${c}"::text`) + if (childCandidates.length === 0) throw new Error(`sem colunas *_id em ${table}`) + const childExpr = `COALESCE(${childCandidates.join(', ')}, '')` + const res = await client.query( + `SELECT parent_id::text AS parent, path, ${childExpr} AS child + FROM "${table}"`, + ) + return res.rows.map((r) => `${r.parent}|${r.path}|${r.child}`) +} + +async function main() { + console.log(`[ops79:reconcile] Neon : ${new URL(NEON_DATABASE_URL).host}`) + console.log(`[ops79:reconcile] Target: ${new URL(TARGET_DATABASE_URL).host}`) + + const neon = await connectReadOnly(NEON_DATABASE_URL) + const target = await connectReadOnly(TARGET_DATABASE_URL) + console.log(`[ops79:reconcile] conexões read-only OK`) + + for (const table of CORE_TABLES) { + const neonIds = await fetchIds(neon, table) + const targetIds = await fetchIds(target, table) + const neonSet = new Set(neonIds) + const targetSet = new Set(targetIds) + const neonOnly = neonIds.filter((id) => !targetSet.has(id)) + const targetOnly = targetIds.filter((id) => !neonSet.has(id)) + report( + table, + neonOnly.length === 0 && targetOnly.length === 0, + `counts ${neonIds.length}=${targetIds.length}; neon_only=${neonOnly.length} target_only=${targetOnly.length}`, + ) + } + + for (const table of REL_TABLES) { + const neonRels = await fetchRels(neon, table) + const targetRels = await fetchRels(target, table) + const neonSet = new Set(neonRels) + const targetSet = new Set(targetRels) + const absolved = table === 'municipality_rels' ? ABSOLVED_MUNICIPALITY_RELS : new Set() + const neonOnly = neonRels.filter((r) => !targetSet.has(r) && !absolved.has(r)) + const targetOnly = targetRels.filter((r) => !neonSet.has(r) && !absolved.has(r)) + const absolvedHits = [...absolved].filter((r) => neonSet.has(r) || targetSet.has(r)).length + const ok = neonOnly.length === 0 && targetOnly.length === 0 + const detail = `rels ${neonRels.length}=${targetRels.length}; neon_only=${neonOnly.length} target_only=${targetOnly.length}` + report( + table, + ok, + absolvedHits > 0 ? `${detail} (+${absolvedHits} absorvida pós-OPS51)` : detail, + ) + } + + await neon.end() + await target.end() + + const failures = results.filter((r) => !r.ok) + console.log( + `\n[ops79:reconcile] ${results.length - failures.length}/${results.length} PASS` + + (failures.length > 0 ? `; ${failures.length} FAIL` : ''), + ) + process.exit(failures.length > 0 ? 1 : 0) +} + +main().catch((err) => { + die(err?.message || String(err)) +})