teqo/scripts/seed-minimal.mjs
Francisco Solla 6de8fad8f6 feat(C112): múltiplos telefones por pessoa — Contact.phones array (ordem = prioridade)
- Contact.phone (text único) → phones (array field Payload, subcampo value):
  fonte única; principal = phones[0]; beforeValidate normaliza, filtra vazios,
  rejeita inválido e duplicado DENTRO da ficha (C111 entre fichas preservado)
- migration add_contact_phones_array: backfill phone → phones[0] (gen_random_uuid)
  antes do drop da coluna + índice em value (hand-edit documentado; snapshot .json
  gerado mantém as colunas C99/C100 já existentes)
- primaryPhoneOf / phoneValuesOf / reorderWithPrimaryPhone em src/lib/phone.ts
  (ordem = prioridade; inline edit = seta o principal preservando o resto)
- findOrCreateContactByPhone aceita phones[] (dedupe pelo primário; reuse faz
  append dos faltantes; locks em todos os números digitados)
- sync conta→ficha (CampaignUser): phone da conta faz APPEND quando a ficha não
  tem (nunca reordena nem remove); tombstone LGPD substitui TODOS os telefones
- convite redemption: telefone digitado vira principal, demais preservados
- import CSV: preview sinaliza compartilhado também por número secundário;
  bulk insere contact_phones (JOIN nas leituras, uuid no insert)
- busca por telefone (apoiadores, pickers, combobox) casa qualquer número
- UI: PhonesFieldEditor compartilhado (repetível mascarado, remover, ↑/↓,
  modo inline com Salvar) nas 5 superfícies — liderança (form/ficha/lista),
  apoiador (form/ficha), dobradinha (ficha), assessor (ficha), wizard
- UI pública (petição/WhatsApp) intocada: zod e forms ficam com phone;
  as ações mapeiam phone → phones[0]
- testes: spec novo campaignMultiPhones (aceite completo), specs C111/C99
  atualizados, e2e wizard/apoiador ajustados
2026-08-11 10:47:02 -03:00

285 lines
9.5 KiB
JavaScript

/**
* Minimal synthetic campaign database for agents, Cursor Cloud and PR CI.
*
* Populates ONLY the rows the app refuses to boot/test without — everything is
* synthetic (`.invalid` emails, 719999999Xx phones) and idempotent (upsert by
* key/slug/email/username/phone). Municipalities are NOT created: migrations
* seed the full 435-row catalog; this script only pins deterministic strategy
* fields on the five listed in the manifest.
*
* The content contract lives in `scripts/lib/seed-minimal-manifest.mjs` and is
* pinned by `tests/unit/seedMinimalManifest.unit.spec.ts`. CONTRACT: a delivery
* that adds a migration, collection, Consent key or required boot data must
* update the manifest in the same PR — PR CI runs migrate → seed:minimal →
* test:int, so a broken seed fails the gate.
*
* Safety: refuses non-local DATABASE_URL unless ALLOW_REMOTE_DB=true. Agents
* and Cloud environments must never set that override.
*
* Usage:
* pnpm migrate && pnpm db:seed:minimal
*/
import { getPayload } from 'payload'
import { assertLocalDatabase } from './assert-local-database.mjs'
import { loadCliEnv } from './lib/cli.mjs'
import {
MINIMAL_CAMPAIGN_GOALS,
MINIMAL_CAMPAIGN_USERS,
MINIMAL_CONSENT_KEYS,
MINIMAL_LEADERSHIPS,
MINIMAL_MUNICIPALITIES,
MINIMAL_ORGANIZATION,
MINIMAL_STATE_DEPUTY,
MINIMAL_SUPPORTERS,
} from './lib/seed-minimal-manifest.mjs'
loadCliEnv()
assertLocalDatabase(
'seed:minimal',
'This script upserts synthetic rows. It must never run against stage/prod.',
)
const config = (await import('../src/payload.config.ts')).default
const { provisionOnda0ConsentAndPrivacy } = await import('../src/utilities/onda0Provision.ts')
const { hashConsentContent } = await import('../src/utilities/consentContentHash.ts')
const { withPayloadTransaction } = await import('../src/utilities/payloadTransaction.ts')
const { WHATSAPP_SUBSCRIPTION_CONSENT_KEY } = await import('../src/lib/campaignConsentKeys.ts')
const payload = await getPayload({ config })
const minimalLexical = (text) => ({
root: {
type: 'root',
children: [
{
type: 'paragraph',
children: [{ type: 'text', text, version: 1 }],
direction: null,
format: '',
indent: 0,
version: 1,
},
],
direction: null,
format: '',
indent: 0,
version: 1,
},
})
const findOne = async (collection, where) => {
const result = await payload.find({ collection, where, depth: 0, limit: 1, pagination: false })
return result.docs[0]
}
const upsert = async (collection, where, data, label) => {
const existing = await findOne(collection, where)
if (existing) {
console.log(`[seed:minimal] ok (exists) ${label}`)
return existing
}
const created = await payload.create({ collection, data, depth: 0 })
console.log(`[seed:minimal] created ${label}`)
return created
}
const municipalityIdBySlug = async (slug) => {
const doc = await findOne('municipality', { slug: { equals: slug } })
if (!doc) {
throw new Error(
`Municipality "${slug}" missing — run pnpm migrate before pnpm db:seed:minimal.`,
)
}
return doc.id
}
try {
// 1. Consent keys (fail-closed flows refuse to run without them). The Onda 0
// provision covers three of the four keys + the privacy-policy global;
// whatsapp-inscricao is upserted here with the same minimal text.
await provisionOnda0ConsentAndPrivacy(payload)
const whatsappText = minimalLexical(
'Autorizo o recebimento de mensagens da campanha via WhatsApp (texto sintético do seed mínimo).',
)
await upsert(
'consent',
{ key: { equals: WHATSAPP_SUBSCRIPTION_CONSENT_KEY } },
{ key: WHATSAPP_SUBSCRIPTION_CONSENT_KEY, text: whatsappText },
`consent ${WHATSAPP_SUBSCRIPTION_CONSENT_KEY}`,
)
const consentIdByKey = new Map()
for (const key of MINIMAL_CONSENT_KEYS) {
const doc = await findOne('consent', { key: { equals: key } })
if (!doc) throw new Error(`Consent "${key}" missing after provisioning.`)
consentIdByKey.set(key, doc)
}
// 2. Campaign users, one per role (synthetic credentials for test DBs only).
for (const user of MINIMAL_CAMPAIGN_USERS) {
await upsert(
'campaignUser',
user.email ? { email: { equals: user.email } } : { username: { equals: user.username } },
{ ...user },
`campaignUser ${user.role} (${user.email ?? user.username})`,
)
}
// 3. Municipality strategy pins (rows come from the migration-seeded catalog).
for (const entry of MINIMAL_MUNICIPALITIES) {
const id = await municipalityIdBySlug(entry.slug)
await payload.update({
collection: 'municipality',
id,
data: { priority: entry.priority, expectedVotes: entry.expectedVotes },
depth: 0,
})
console.log(`[seed:minimal] pinned municipality ${entry.slug}`)
}
// 4. Organization + state deputy.
const organization = await upsert(
'organization',
{ slug: { equals: MINIMAL_ORGANIZATION.slug } },
{
name: MINIMAL_ORGANIZATION.name,
slug: MINIMAL_ORGANIZATION.slug,
kind: MINIMAL_ORGANIZATION.kind,
municipalities: await Promise.all(
MINIMAL_ORGANIZATION.municipalitySlugs.map(municipalityIdBySlug),
),
},
`organization ${MINIMAL_ORGANIZATION.slug}`,
)
let stateDeputy = await findOne('stateDeputy', {
slug: { equals: MINIMAL_STATE_DEPUTY.slug },
})
if (!stateDeputy) {
stateDeputy = await withPayloadTransaction(payload, async ({ req }) => {
const contactResult = await payload.find({
collection: 'contact',
where: { email: { equals: `${MINIMAL_STATE_DEPUTY.slug}@planilha.invalid` } },
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
req,
})
const contact =
contactResult.docs[0] ??
(await payload.create({
collection: 'contact',
data: {
name: MINIMAL_STATE_DEPUTY.name,
email: `${MINIMAL_STATE_DEPUTY.slug}@planilha.invalid`,
state: 'BA',
city: null,
},
depth: 0,
overrideAccess: true,
req,
}))
return payload.create({
collection: 'stateDeputy',
data: { contact: contact.id, party: MINIMAL_STATE_DEPUTY.party },
depth: 0,
overrideAccess: true,
req,
})
})
console.log(`[seed:minimal] created contact ${MINIMAL_STATE_DEPUTY.name}`)
console.log(`[seed:minimal] created stateDeputy ${MINIMAL_STATE_DEPUTY.slug}`)
} else {
console.log(`[seed:minimal] ok (exists) stateDeputy ${MINIMAL_STATE_DEPUTY.slug}`)
}
// 5. Leaderships (contact upserted by phone; leadership unique by contact).
for (const entry of MINIMAL_LEADERSHIPS) {
const contact = await upsert(
'contact',
{ 'phones.value': { equals: entry.contactPhone } },
{
name: entry.contactName,
phones: [{ value: entry.contactPhone }],
state: 'BA',
},
`contact ${entry.contactPhone}`,
)
const consent = consentIdByKey.get(entry.consentKey)
await upsert(
'leadership',
{ contact: { equals: contact.id } },
{
contact: contact.id,
municipalities: await Promise.all(entry.municipalitySlugs.map(municipalityIdBySlug)),
organizations: [organization.id],
stateDeputies: [stateDeputy.id],
supportStatus: entry.supportStatus,
exclusive: entry.exclusive,
consent: consent.id,
consentContentHash: hashConsentContent(consent.text ?? null),
consentedAt: new Date().toISOString(),
},
`leadership ${entry.contactName}`,
)
}
// 6. Supporters (unique by contact+municipality).
for (const entry of MINIMAL_SUPPORTERS) {
const contact = await upsert(
'contact',
{ 'phones.value': { equals: entry.contactPhone } },
{
name: entry.contactName,
phones: [{ value: entry.contactPhone }],
state: 'BA',
},
`contact ${entry.contactPhone}`,
)
const municipalityID = await municipalityIdBySlug(entry.municipalitySlug)
const consent = consentIdByKey.get(entry.consentKey)
const voteIntentionConsent = entry.voteIntentionConsentKey
? consentIdByKey.get(entry.voteIntentionConsentKey)
: undefined
await upsert(
'supporter',
{ and: [{ contact: { equals: contact.id } }, { municipality: { equals: municipalityID } }] },
{
contact: contact.id,
municipality: municipalityID,
source: entry.source,
...(entry.voteIntention ? { voteIntention: entry.voteIntention } : {}),
consent: consent.id,
consentContentHash: hashConsentContent(consent.text ?? null),
consentedAt: new Date().toISOString(),
...(voteIntentionConsent
? {
voteIntentionConsent: voteIntentionConsent.id,
voteIntentionConsentContentHash: hashConsentContent(
voteIntentionConsent.text ?? null,
),
voteIntentionConsentedAt: new Date().toISOString(),
}
: {}),
},
`supporter ${entry.contactName}`,
)
}
// 7. Campaign goals global (E8 defaults).
await payload.updateGlobal({
slug: 'campaignGoals',
data: { ...MINIMAL_CAMPAIGN_GOALS },
overrideAccess: true,
})
console.log('[seed:minimal] pinned campaignGoals global')
console.log('[seed:minimal] OK — minimal synthetic database ready.')
} catch (error) {
console.error(`\n[seed:minimal] ${error instanceof Error ? error.message : String(error)}\n`)
process.exit(1)
}
process.exit(0)