fix(campanha): preserve StateDeputy migration identity

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-08-06 18:38:47 -03:00
parent 033c270a89
commit 48623b0d06
4 changed files with 87 additions and 3 deletions

View file

@ -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).

View file

@ -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,

View file

@ -2,6 +2,34 @@ import { sql, type MigrateDownArgs, type MigrateUpArgs } from '@payloadcms/db-po
export async function up({ db }: MigrateUpArgs): Promise<void> {
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 (

View file

@ -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<MigrateDownArgs>({ 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<MigrateUpArgs>({ 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.')