From 48623b0d06a500ac70679be8d5af2fa8172d924e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 6 Aug 2026 18:38:47 -0300 Subject: [PATCH] fix(campanha): preserve StateDeputy migration identity --- docs/CHANGELOG-AGENTS.md | 2 +- scripts/import-projecao.mjs | 29 +++++++++++++++-- ...0260806_082110_add_state_deputy_contact.ts | 28 +++++++++++++++++ ...ampaignMigrationReconciliation.int.spec.ts | 31 +++++++++++++++++++ 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 776f93c4..225e9008 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,7 +4,7 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- -**Recently resolved (2026-08-06):** **B163** (Issue #391) — `/campanha/dobradinhas` agora edita Nome, Partido, E-mail e Telefone inline; `Contact` virou a fonte de identidade, dobradinha nova cria o Contact na mesma transação, e a ficha usa ID canônico com fallback/redirect de slugs antigos. A migration faz backfill idempotente, mantém a invariável de nome/telefone e os links de relações/busca apontam para ID. Intenção [`editar-campos-lista-dobradinhas.md`](plans/editar-campos-lista-dobradinhas.md); impl [`editar-campos-lista-dobradinhas-impl.md`](plans/editar-campos-lista-dobradinhas-impl.md). +**Recently resolved (2026-08-06):** **B163** (Issue #391) — `/campanha/dobradinhas` agora edita Nome, Partido, E-mail e Telefone inline; `Contact` virou a fonte de identidade, dobradinha nova cria o Contact na mesma transação, e a ficha usa ID canônico com fallback/redirect de slugs antigos. A migration faz backfill idempotente, preserva nomes legados com preflight de limite, mantém a invariável de nome/telefone e o importador da projeção também reencontra dobradinhas pelo nome atual do Contact quando o alias legado diverge; os links de relações/busca apontam para ID. Intenção [`editar-campos-lista-dobradinhas.md`](plans/editar-campos-lista-dobradinhas.md); impl [`editar-campos-lista-dobradinhas-impl.md`](plans/editar-campos-lista-dobradinhas-impl.md). **Recently resolved (2026-08-05):** **B158** (Issue #364) — `/campanha/municipios` troca cards↔tabela e revela colunas pela largura do painel de conteúdo com named container queries (48–84rem), sem rolagem horizontal; Território virou sublinha de Município, Tendência/Sinal preservam um único editor na tabela quando ocultos manualmente, e `campaign_columns=municipios:__none__` diferencia “todas visíveis” dos defaults. E2E mede cada limiar em Chromium e mantém um caso iPad-sized no projeto WebKit focado. Sem migration ou mudança de RBAC. Intenção [`colunas-responsivas-municipios.md`](docs/plans/colunas-responsivas-municipios.md); impl [`colunas-responsivas-municipios-impl.md`](docs/plans/colunas-responsivas-municipios-impl.md). diff --git a/scripts/import-projecao.mjs b/scripts/import-projecao.mjs index dea1a9f5..9089c619 100644 --- a/scripts/import-projecao.mjs +++ b/scripts/import-projecao.mjs @@ -796,13 +796,38 @@ const existingDeputies = await payload.find({ select: { contact: true, slug: true }, }) const deputiesBySlug = new Map(existingDeputies.docs.map((deputy) => [deputy.slug, deputy])) +const deputiesByContactName = new Map() +for (const deputy of existingDeputies.docs) { + const contact = + typeof deputy.contact === 'object' && deputy.contact !== null ? deputy.contact : null + const contactNameSlug = slugify(String(contact?.name ?? '')) + if (!contactNameSlug) continue + const matches = deputiesByContactName.get(contactNameSlug) ?? [] + matches.push(deputy) + deputiesByContactName.set(contactNameSlug, matches) +} +const matchedDeputyIDs = new Set() const deputyPlans = new Map() for (const [slug, entry] of deputyRoster) { - const existing = deputiesBySlug.get(slug) ?? null + const name = canonicalRosterName(entry) + const existingBySlug = deputiesBySlug.get(slug) + const nameMatches = (deputiesByContactName.get(slugify(name)) ?? []).filter( + (deputy) => !matchedDeputyIDs.has(deputy.id), + ) + const existingByName = nameMatches.length === 1 ? nameMatches[0] : null + const existing = + (existingBySlug && !matchedDeputyIDs.has(existingBySlug.id) ? existingBySlug : null) ?? + existingByName + if (existing && existingBySlug?.id !== existing.id) { + warnings.push( + `Dobradinha "${name}" encontrada pelo nome atual do Contato; o slug legado "${existing.slug}" foi preservado.`, + ) + } + if (existing) matchedDeputyIDs.add(existing.id) deputyPlans.set(slug, { slug, - name: canonicalRosterName(entry), + name, existing, deputyId: existing ? existing.id : null, entry, diff --git a/src/migrations/20260806_082110_add_state_deputy_contact.ts b/src/migrations/20260806_082110_add_state_deputy_contact.ts index d04c34cc..7efbc515 100644 --- a/src/migrations/20260806_082110_add_state_deputy_contact.ts +++ b/src/migrations/20260806_082110_add_state_deputy_contact.ts @@ -2,6 +2,34 @@ import { sql, type MigrateDownArgs, type MigrateUpArgs } from '@payloadcms/db-po export async function up({ db }: MigrateUpArgs): Promise { await db.execute(sql` + DO $$ + DECLARE + has_legacy_name boolean; + has_long_name boolean; + BEGIN + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'state_deputy' + AND column_name = 'name' + ) INTO has_legacy_name; + + IF has_legacy_name THEN + EXECUTE $check$ + SELECT EXISTS ( + SELECT 1 + FROM "state_deputy" + WHERE char_length("name") > 120 + ) + $check$ INTO has_long_name; + + IF has_long_name THEN + RAISE EXCEPTION 'Cannot migrate StateDeputy: a legacy name exceeds Contact''s 120-character limit'; + END IF; + END IF; + END $$; + DO $$ BEGIN IF NOT EXISTS ( diff --git a/tests/int/campaignMigrationReconciliation.int.spec.ts b/tests/int/campaignMigrationReconciliation.int.spec.ts index fe0feb92..a83db6a4 100644 --- a/tests/int/campaignMigrationReconciliation.int.spec.ts +++ b/tests/int/campaignMigrationReconciliation.int.spec.ts @@ -225,6 +225,37 @@ describe('campaign migration existing-schema reconciliation', () => { expect(contactCount.rows[0]?.count).toBe(2) }) + it('rejects legacy names over the Contact limit before changing schema', async () => { + if (!database) throw new Error('Disposable database is not initialized.') + const migration = migrations.find(({ name }) => name === stateDeputyContactMigrationName) + if (!migration) throw new Error('StateDeputy Contact migration is not registered.') + + await migration.down(stub({ db: database })) + const legacyName = 'L'.repeat(121) + await database.execute(sql` + INSERT INTO "state_deputy" ("name", "slug") + VALUES (${legacyName}, 'legado-longo') + `) + + await expect(migration.up(stub({ db: database }))).rejects.toThrow() + + const columns = await database.execute(sql` + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'state_deputy' + AND column_name IN ('name', 'contact_id') + ORDER BY column_name + `) + expect(columns.rows).toEqual([{ column_name: 'name' }]) + const contacts = await database.execute(sql` + SELECT count(*)::integer AS count + FROM "contact" + WHERE "name" = ${legacyName} + `) + expect(contacts.rows[0]?.count).toBe(0) + }) + it('keeps the required StateDeputy Contact relation from being nulled on delete', async () => { if (!database) throw new Error('Disposable database is not initialized.')