fix(campanha): preserve StateDeputy migration identity
This commit is contained in:
parent
033c270a89
commit
48623b0d06
4 changed files with 87 additions and 3 deletions
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue