mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
- 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
283 lines
11 KiB
JavaScript
283 lines
11 KiB
JavaScript
/**
|
|
* 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))
|
|
})
|