Skip to content

fix(campaigns): store DM button order so links stop swapping and saves stop losing them - #65

Merged
diwenne merged 1 commit into
diwenne:mainfrom
mhd64real:fix/tracked-link-button-order
Sep 18, 2026
Merged

diwenne merged 1 commit into
diwenne:mainfrom
mhd64real:fix/tracked-link-button-order

Conversation

@mhd64real

Copy link
Copy Markdown
Contributor

What users see

A campaign with two link buttons ("And then, they will get") can break in two ways:

  1. Duplicate swaps the buttons. The copy's first button opens the second URL, and its second button opens the first URL with the title "Primary campaign link".
  2. Saving can delete the first link. Save a campaign that was created with both links filled in, change nothing, and the first link is overwritten with the second link's URL and title. The first URL is gone.

The DM worker reads links in the same order, so commenters can also receive a DM where a button opens the wrong URL.

Root cause

Nothing stores which link is button 1 and which is button 2. Every reader (dashboard API, DM worker, reports, duplicate, save) inferred it from ORDER BY "createdAt" ASC.

When links are written in one request (create with both links, or duplicate), they get the same createdAt. Postgres returns tied rows in whatever order its sort and the rows' physical position produce, so the order changes as rows move on disk. Every UPDATE moves a row.

  • Duplicate. The dashboard loads every campaign's links with WHERE "automationId" IN (...) ORDER BY "createdAt". Once edits have scattered rows on disk, tied pairs come back either way round.
  • Save. PATCH updated the first link (moving that row), then queried the list again and treated links[1] as the second link. With a tie, the moved first link now came back second, so the second URL and title were written over it.

Reproduction (before this change)

On Postgres 16, with the app's own queries:

  • 41 of 41 campaigns created with two links had identical createdAt on both links.
  • Duplicate: 20 copies of a campaign, with ordinary row updates in between (what edits do). The original displayed correctly, and 13 of 20 copies came back swapped exactly as described above.
  • Save: one save with no changes turned [primary URL, second URL] into [second URL, second URL]. This happened every time on a sequential scan, which is what small self-hosted databases use.

The fix

  • TrackedLink.position (int, default 0) stores the button order.
  • TRACKED_LINK_ORDER in lib/tracking/link-order.ts is the one order every reader uses: position, then createdAt, then id. The tie breakers keep reads deterministic for rows an older build writes during a deploy. It's used in the dashboard API, all three DM worker queries, reports, and duplicate.
  • Writes set positions explicitly: create (buildInitialCampaignLinks), duplicate (numbered from the order read), and save.
  • Save (syncCampaignLinks in lib/campaigns/links.ts) reads the links once, before any write. It changes links by id, then renumbers positions, which also repairs links saved before this change. It runs in one $transaction with the campaign update. That update locks the campaign row first, so two saves of the same campaign run one after the other and cannot both create the same missing link. It also means a save can no longer land half applied.

Migration and rollout

20260917160000_tracked_link_position does two things:

  1. ADD COLUMN IF NOT EXISTS "position" INTEGER NOT NULL DEFAULT 0. On Postgres 11+ this is metadata only, with no table rewrite.
  2. A backfill that numbers each campaign's existing links oldest first, with id (COLLATE "C") breaking ties. Ids generated in one request increase in the order the links were written. On real Prisma-generated data, id order matched the intended order for 73 of 73 tied pairs.

Safe to re-run. Prisma applies a migration file statement by statement, not in a transaction. I tested this: when the backfill failed, the column had already been committed. Wrapping the file in BEGIN/COMMIT made Prisma report current transaction is aborted instead of the real error. So the file is idempotent instead:

  • A failed deploy prints the real error.
  • prisma migrate resolve --rolled-back 20260917160000_tracked_link_position followed by the next deploy re-runs it from the top. I verified that recovery path end to end.
  • Until the backfill lands, reads are still correct: every link reads as position 0 and ties fall back to createdAt, then id.

Measured on Postgres 16:

result
prisma migrate deploy on existing data (150 links, 74 tied pairs, one already corrupted by the save bug) 150/150 positions as expected, updatedAt untouched
200,000 links (100,000 tied pairs) ADD COLUMN 6 ms, backfill 1.06 s, 200,000/200,000 correct
Re-running the migration on 200,000 links 0 rows written, 136 ms
prisma migrate diff (migrated DB vs schema.prisma) no drift

Mixed versions during a deploy (web and worker deploy separately):

  • Old build on the migrated database works. I ran main's own create, save queries, duplicateCampaign and worker query from a checkout of main with its own generated client. Its writes get position 0 and read back in the right order through the tie breakers. The next save from the new build assigns real positions.
  • New worker before the migration runs fails its query with P2022. processJob rethrows it, so BullMQ retries after 5 and then 15 minutes. That is the same exposure the last several migrations already had: main's worker fails identically (InstagramAccount.provider does not exist) against a database missing 20260908163000_zernio_provider. Deploy order is unchanged, since vercel-build (or migrate deploy in the Dokploy start command) applies migrations.

Tests

Unit (in npm test):

  • __tests__/campaign-links.test.ts: save and create against an in-memory table. Covers unchanged saves, repairing tied positions, adding, removing, promoting the second link, a third link, other campaigns' links left alone, and the read happening once before any write.
  • __tests__/campaign-duplicate.test.ts: copies are numbered 0, 1, 2 even when the original's positions are tied.
  • __tests__/dm-worker.test.ts: the pinned worker query now expects the new order.

Real Postgres (__tests__/tracked-link-order.db.test.ts, 12 tests):

These are skipped unless TEST_DATABASE_URL is set, since CI has no database. They build the schema from prisma/migrations in a throwaway Postgres schema, seed legacy rows in scrambled physical order, apply the migration, and then drive the real GET/POST/PATCH route handlers and duplicateCampaign. Connections are pinned to sequential scans, the plan small instances get, so tie-order bugs show up deterministically rather than depending on the planner.

docker run --rm -d -p 55432:5432 -e POSTGRES_PASSWORD=postgres postgres:16
TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:55432/postgres \
  npx vitest run __tests__/tracked-link-order.db.test.ts

To check that these tests really catch the bugs, I reintroduced each one and confirmed the suite fails:

Reintroduced bug Tests that fail
Reads order by createdAt only 3, including the duplicate-on-dashboard test
Save re-reads links after writing (the old PATCH) 4, including "keeps both URLs when a campaign created with two links is saved unchanged"
Save outside a transaction the concurrent-saves test, 5 of 5 runs
Backfill orders by id only 2
Duplicate does not number positions 1

The suite with the fix passed 10 of 10 consecutive runs.

npm run typecheck   pass
npm run lint        pass
npm test            pass (273 passed, 12 skipped: the database suite)
npm run build       pass

I can add a Postgres service to CI so the database suite runs on every PR, if you want it.

Behavior changes

  • Clearing the first link while keeping the second used to create a duplicate of the second link (the re-read found only one link and created another). Now the second link moves up to become the first button.
  • A save is atomic, so if any part of it fails, the campaign is left unchanged.

Not recoverable

Links already overwritten by the save bug keep the second URL on both rows. The original first URL was not stored anywhere, so this change cannot restore it.

…s stop losing them

Which tracked link is the first DM button was inferred from
ORDER BY createdAt. A campaign's links are usually written in one
request, so they share a createdAt, and Postgres returns tied rows in
whatever order its sort and the rows' place on disk produce. Two
visible bugs came out of that:

- Duplicated campaigns could show their buttons swapped: the second
  URL on the first button, and the first URL on the second button
  titled "Primary campaign link".
- Saving a campaign could delete its first link. The save updated the
  first link, then read the links again, and because the update had
  moved that row on disk, took it for the second link and wrote the
  second URL over it.

The DM worker used the same order, so real DMs could send the wrong
URL under a button.

Store the order in a new TrackedLink.position column and read every
link list with one shared order (position, then createdAt and id as
tie breakers), in the dashboard API, the DM worker, reports and
duplicate. Create, save and duplicate write positions explicitly.

Saving now reads the links once, changes them by id, renumbers
positions, and runs with the campaign update in one transaction. The
update locks the campaign row first, so two saves of the same campaign
cannot both create the same missing link.

The migration adds the column and backfills existing links oldest
first, with id breaking ties. It is safe to re-run, because Prisma does
not apply a migration file atomically.
@vercel

vercel Bot commented Sep 17, 2026

Copy link
Copy Markdown

@mhd64real is attempting to deploy a commit to the diwenne's projects Team on Vercel.

A member of the Team first needs to authorize it.

@diwenne
diwenne merged commit 0d723ef into diwenne:main Sep 18, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants