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