From 8a6d0e58540e0344ee7869c613bd08557312f65b Mon Sep 17 00:00:00 2001 From: Mohamed Elsayed Date: Thu, 17 Sep 2026 20:09:03 +0400 Subject: [PATCH] fix(campaigns): store DM button order so links stop swapping and saves 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. --- __tests__/campaign-duplicate.test.ts | 54 +++ __tests__/campaign-links.test.ts | 293 ++++++++++++ __tests__/dm-worker.test.ts | 4 +- __tests__/tracked-link-order.db.test.ts | 429 ++++++++++++++++++ app/api/automations/route.ts | 121 ++--- lib/campaigns/duplicate.ts | 8 +- lib/campaigns/links.ts | 163 +++++++ lib/queue/dm-worker.ts | 7 +- lib/reports/data.ts | 3 +- lib/tracking/link-order.ts | 16 + .../migration.sql | 34 ++ prisma/schema.prisma | 5 + 12 files changed, 1037 insertions(+), 100 deletions(-) create mode 100644 __tests__/campaign-links.test.ts create mode 100644 __tests__/tracked-link-order.db.test.ts create mode 100644 lib/campaigns/links.ts create mode 100644 lib/tracking/link-order.ts create mode 100644 prisma/migrations/20260917160000_tracked_link_position/migration.sql diff --git a/__tests__/campaign-duplicate.test.ts b/__tests__/campaign-duplicate.test.ts index 235dd2c5a..8c813ee24 100644 --- a/__tests__/campaign-duplicate.test.ts +++ b/__tests__/campaign-duplicate.test.ts @@ -14,6 +14,7 @@ vi.mock("@/lib/db/client", () => ({ })); import { buildDuplicateName, duplicateCampaign } from "../lib/campaigns/duplicate"; +import { TRACKED_LINK_ORDER } from "../lib/tracking/link-order"; // A campaign with every option turned on, shaped like the stored row. const sourceCampaign = { @@ -55,6 +56,7 @@ const sourceCampaign = { slug: "tracked_1", label: "Primary campaign link", destinationUrl: "https://example.com/product", + position: 0, createdAt: new Date("2026-05-01T00:00:00.000Z"), }, { @@ -62,6 +64,7 @@ const sourceCampaign = { slug: "tracked_2", label: "Read the guide", destinationUrl: "https://example.com/guide", + position: 1, createdAt: new Date("2026-05-02T00:00:00.000Z"), }, ], @@ -175,10 +178,12 @@ describe("duplicateCampaign", () => { workspaceId: "workspace_123", label: "Primary campaign link", destinationUrl: "https://example.com/product", + position: 0, }); expect(created[1]).toMatchObject({ label: "Read the guide", destinationUrl: "https://example.com/guide", + position: 1, }); const slugs = created.map((link: { slug: string }) => link.slug); @@ -187,6 +192,55 @@ describe("duplicateCampaign", () => { expect(new Set(slugs).size).toBe(2); }); + it("reads the original's links in button order", async () => { + await duplicateCampaign({ + automationId: "automation_123", + workspaceId: "workspace_123", + }); + + expect(mockPrisma.automation.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + include: { trackedLinks: { orderBy: TRACKED_LINK_ORDER } }, + }) + ); + }); + + it("numbers the copy's links 0, 1, 2 even when the original's positions are tied", async () => { + // An older build writes position 0 for every link, so the original's + // positions cannot be copied as they are. + mockPrisma.automation.findFirst.mockResolvedValue({ + ...sourceCampaign, + trackedLinks: [ + { ...sourceCampaign.trackedLinks[0], position: 0 }, + { ...sourceCampaign.trackedLinks[1], position: 0 }, + { + ...sourceCampaign.trackedLinks[1], + id: "link_3", + label: "Third", + destinationUrl: "https://example.com/third", + position: 0, + }, + ], + }); + + await duplicateCampaign({ + automationId: "automation_123", + workspaceId: "workspace_123", + }); + + const created = createArgs().data.trackedLinks.create; + expect( + created.map((link: { destinationUrl: string; position: number }) => [ + link.destinationUrl, + link.position, + ]) + ).toEqual([ + ["https://example.com/product", 0], + ["https://example.com/guide", 1], + ["https://example.com/third", 2], + ]); + }); + it("does not copy a campaign from another workspace", async () => { mockPrisma.automation.findFirst.mockResolvedValue(null); diff --git a/__tests__/campaign-links.test.ts b/__tests__/campaign-links.test.ts new file mode 100644 index 000000000..00718f128 --- /dev/null +++ b/__tests__/campaign-links.test.ts @@ -0,0 +1,293 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { Prisma } from "../app/generated/prisma/client"; +import { + buildInitialCampaignLinks, + syncCampaignLinks, +} from "../lib/campaigns/links"; + +type Row = { + id: string; + workspaceId: string; + automationId: string; + slug: string; + label: string | null; + destinationUrl: string; + position: number; + createdAt: Date; +}; + +const PRIMARY = "https://example.com/primary"; +const SECOND = "https://example.com/second"; +const THIRD = "https://example.com/third"; +const CREATED = new Date("2026-05-01T00:00:00.000Z"); + +let rows: Row[]; +let ops: string[]; +let nextId: number; + +// A stand-in for the tracked link table that sorts the way Postgres does for +// TRACKED_LINK_ORDER and records every operation, so a test can check both the +// outcome and when the links were read. +function fakeTx() { + const pick = (row: Row) => ({ id: row.id, position: row.position }); + + return { + trackedLink: { + findMany: async ({ where }: { where: { automationId: string } }) => { + ops.push("read"); + return rows + .filter((row) => row.automationId === where.automationId) + .sort( + (a, b) => + a.position - b.position || + a.createdAt.getTime() - b.createdAt.getTime() || + a.id.localeCompare(b.id) + ) + .map(pick); + }, + create: async ({ data }: { data: Omit }) => { + ops.push("create"); + const row = { ...data, id: `link_new_${nextId++}`, createdAt: new Date() }; + rows.push(row); + return pick(row); + }, + update: async ({ + where, + data, + }: { + where: { id: string }; + data: Partial; + }) => { + ops.push(`update:${Object.keys(data).sort().join(",")}`); + const row = rows.find((r) => r.id === where.id)!; + Object.assign(row, data); + return pick(row); + }, + delete: async ({ where }: { where: { id: string } }) => { + ops.push("delete"); + rows = rows.filter((r) => r.id !== where.id); + }, + }, + } as unknown as Prisma.TransactionClient; +} + +function link(overrides: Partial & Pick): Row { + return { + workspaceId: "workspace_123", + automationId: "automation_123", + slug: `slug_${overrides.id}`, + label: null, + destinationUrl: PRIMARY, + position: 0, + createdAt: CREATED, + ...overrides, + }; +} + +const primaryLink = () => + link({ id: "link_a", label: "Primary campaign link", destinationUrl: PRIMARY, position: 0 }); +const secondLink = () => + link({ id: "link_b", label: "Read the guide", destinationUrl: SECOND, position: 1 }); + +// The campaign's links in button order, as every reader now sees them. +function buttons() { + return rows + .filter((row) => row.automationId === "automation_123") + .sort((a, b) => a.position - b.position) + .map((row) => ({ + position: row.position, + label: row.label, + destinationUrl: row.destinationUrl, + })); +} + +function save(fields: { + primaryUrl?: string | null; + secondaryUrl?: string | null; + secondaryLabel?: string | null; +}) { + return syncCampaignLinks(fakeTx(), { + workspaceId: "workspace_123", + automationId: "automation_123", + ...fields, + }); +} + +beforeEach(() => { + rows = []; + ops = []; + nextId = 1; +}); + +describe("syncCampaignLinks", () => { + it("does not touch the links when the save carries no link fields", async () => { + rows = [primaryLink(), secondLink()]; + + await save({}); + await save({ primaryUrl: null, secondaryUrl: null }); + + expect(ops).toEqual([]); + }); + + it("reads the links once, before writing anything", async () => { + rows = [primaryLink(), secondLink()]; + + await save({ primaryUrl: PRIMARY, secondaryUrl: SECOND, secondaryLabel: "Read the guide" }); + + expect(ops[0]).toBe("read"); + expect(ops.filter((op) => op === "read")).toHaveLength(1); + }); + + it("keeps each URL on its own button when a two-link campaign is saved unchanged", async () => { + rows = [primaryLink(), secondLink()]; + + await save({ primaryUrl: PRIMARY, secondaryUrl: SECOND, secondaryLabel: "Read the guide" }); + await save({ primaryUrl: PRIMARY, secondaryUrl: SECOND, secondaryLabel: "Read the guide" }); + + expect(buttons()).toEqual([ + { position: 0, label: "Primary campaign link", destinationUrl: PRIMARY }, + { position: 1, label: "Read the guide", destinationUrl: SECOND }, + ]); + }); + + it("repairs two links an older build saved with the same position", async () => { + // Before positions existed, both links of a campaign created in one + // request share a createdAt, and an older build writes position 0 for both. + rows = [ + link({ id: "link_a", label: "Primary campaign link", destinationUrl: PRIMARY }), + link({ id: "link_b", label: "Read the guide", destinationUrl: SECOND }), + ]; + + await save({ primaryUrl: PRIMARY, secondaryUrl: SECOND, secondaryLabel: "Read the guide" }); + + expect(buttons()).toEqual([ + { position: 0, label: "Primary campaign link", destinationUrl: PRIMARY }, + { position: 1, label: "Read the guide", destinationUrl: SECOND }, + ]); + }); + + it("does not rewrite positions that are already right", async () => { + rows = [primaryLink(), secondLink()]; + + await save({ primaryUrl: PRIMARY, secondaryUrl: SECOND, secondaryLabel: "Read the guide" }); + + expect(ops).not.toContain("update:position"); + }); + + it("changes only the first link when only its URL is sent", async () => { + rows = [primaryLink(), secondLink()]; + + await save({ primaryUrl: "https://example.com/new", secondaryLabel: "Ignored" }); + + expect(buttons()).toEqual([ + { position: 0, label: "Primary campaign link", destinationUrl: "https://example.com/new" }, + { position: 1, label: "Read the guide", destinationUrl: SECOND }, + ]); + }); + + it("adds a second link after the first", async () => { + rows = [primaryLink()]; + + await save({ primaryUrl: PRIMARY, secondaryUrl: SECOND, secondaryLabel: "Read the guide" }); + + expect(buttons()).toEqual([ + { position: 0, label: "Primary campaign link", destinationUrl: PRIMARY }, + { position: 1, label: "Read the guide", destinationUrl: SECOND }, + ]); + }); + + it("creates both links in button order on a campaign that had none", async () => { + await save({ primaryUrl: PRIMARY, secondaryUrl: SECOND, secondaryLabel: "Read the guide" }); + + expect(buttons()).toEqual([ + { position: 0, label: "Primary campaign link", destinationUrl: PRIMARY }, + { position: 1, label: "Read the guide", destinationUrl: SECOND }, + ]); + }); + + it("moves the second link up when the first is removed, without duplicating it", async () => { + rows = [primaryLink(), secondLink()]; + + await save({ primaryUrl: "", secondaryUrl: SECOND, secondaryLabel: "Read the guide" }); + + expect(buttons()).toEqual([ + { position: 0, label: "Read the guide", destinationUrl: SECOND }, + ]); + }); + + it("removes the second link and keeps the first", async () => { + rows = [primaryLink(), secondLink()]; + + await save({ primaryUrl: PRIMARY, secondaryUrl: "" }); + + expect(buttons()).toEqual([ + { position: 0, label: "Primary campaign link", destinationUrl: PRIMARY }, + ]); + }); + + it("keeps a third link behind the two it manages", async () => { + rows = [ + primaryLink(), + secondLink(), + link({ id: "link_c", label: "Third", destinationUrl: THIRD, position: 2 }), + ]; + + await save({ primaryUrl: "", secondaryUrl: SECOND, secondaryLabel: "Read the guide" }); + + expect(buttons()).toEqual([ + { position: 0, label: "Read the guide", destinationUrl: SECOND }, + { position: 1, label: "Third", destinationUrl: THIRD }, + ]); + }); + + it("uses the default title for a second button left blank", async () => { + rows = [primaryLink()]; + + await save({ secondaryUrl: SECOND, secondaryLabel: " " }); + + expect(buttons()[1]).toEqual({ position: 1, label: "Open link", destinationUrl: SECOND }); + }); + + it("leaves other campaigns' links alone", async () => { + const other = link({ id: "link_other", automationId: "automation_other", position: 5 }); + rows = [primaryLink(), secondLink(), other]; + + await save({ primaryUrl: "", secondaryUrl: "" }); + + expect(rows).toEqual([other]); + }); +}); + +describe("buildInitialCampaignLinks", () => { + it("numbers the links in button order", () => { + const links = buildInitialCampaignLinks({ + workspaceId: "workspace_123", + primaryUrl: PRIMARY, + secondaryUrl: SECOND, + secondaryLabel: "Read the guide", + }); + + expect(links).toMatchObject([ + { position: 0, label: "Primary campaign link", destinationUrl: PRIMARY }, + { position: 1, label: "Read the guide", destinationUrl: SECOND }, + ]); + expect(links[0].slug).not.toBe(links[1].slug); + }); + + it("puts a second link that has no first link at position 0", () => { + expect( + buildInitialCampaignLinks({ + workspaceId: "workspace_123", + primaryUrl: "", + secondaryUrl: SECOND, + secondaryLabel: null, + }) + ).toMatchObject([{ position: 0, label: "Open link", destinationUrl: SECOND }]); + }); + + it("creates nothing without URLs", () => { + expect( + buildInitialCampaignLinks({ workspaceId: "workspace_123", primaryUrl: null }) + ).toEqual([]); + }); +}); diff --git a/__tests__/dm-worker.test.ts b/__tests__/dm-worker.test.ts index bd6efdaf6..7fc996741 100644 --- a/__tests__/dm-worker.test.ts +++ b/__tests__/dm-worker.test.ts @@ -337,7 +337,9 @@ describe("DM Worker — Full Pipeline", () => { label: true, destinationUrl: true, }, - orderBy: { createdAt: "asc" }, + // Button order: position first, with createdAt and id only as + // tie breakers, so tied rows can never come back swapped. + orderBy: [{ position: "asc" }, { createdAt: "asc" }, { id: "asc" }], }, }, orderBy: { createdAt: "asc" }, diff --git a/__tests__/tracked-link-order.db.test.ts b/__tests__/tracked-link-order.db.test.ts new file mode 100644 index 000000000..5dc9f9445 --- /dev/null +++ b/__tests__/tracked-link-order.db.test.ts @@ -0,0 +1,429 @@ +/** + * Tracked link (DM button) order, tested against a real Postgres. + * + * The bug these tests guard against lives in Postgres itself: links saved in + * one request share a createdAt, and Postgres returns tied rows in whatever + * order its sort and the rows' place on disk produce. No mock reproduces that, + * so this suite needs a database and is skipped without one: + * + * 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 + * + * Each run builds the schema from prisma/migrations inside its own throwaway + * Postgres schema and drops it at the end, so it never touches existing data. + */ +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { randomBytes } from "node:crypto"; +import { Client } from "pg"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { NextRequest } from "next/server"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { PrismaClient } from "../app/generated/prisma/client"; + +const DATABASE_URL = process.env.TEST_DATABASE_URL; +const MIGRATIONS_DIR = path.join(__dirname, "..", "prisma", "migrations"); +const POSITION_MIGRATION = "20260917160000_tracked_link_position"; + +const PRIMARY = "https://example.com/primary"; +const SECOND = "https://example.com/second"; +const THIRD = "https://example.com/third"; + +const state = vi.hoisted(() => ({ + db: undefined as unknown as import("../app/generated/prisma/client").PrismaClient, + workspaceId: "", +})); + +vi.mock("@/lib/db/client", () => ({ + get prisma() { + return state.db; + }, +})); +vi.mock("@/lib/auth", () => ({ + getCurrentWorkspaceId: async () => state.workspaceId, +})); +vi.mock("@/lib/workspace-access", () => ({ + canManageWorkspace: () => true, + getCurrentWorkspaceContext: async () => ({ + userId: "user_test", + workspaceId: state.workspaceId, + role: "OWNER", + }), +})); + +import { GET, PATCH, POST } from "../app/api/automations/route"; +import { duplicateCampaign } from "../lib/campaigns/duplicate"; +import { TRACKED_LINK_ORDER } from "../lib/tracking/link-order"; + +const schema = `tracked_link_order_${randomBytes(4).toString("hex")}`; +let sql: Client; + +// Ids for the legacy rows, chosen so byte order matches creation order. +const T0 = "2026-05-01 10:00:00.000"; +const T1 = "2026-05-01 10:05:00.000"; +const legacy = { + tiedCampaign: "cmlegacytied00000000000001", + tiedFirst: "cmlegacytied00000000000a01", + tiedSecond: "cmlegacytied00000000000a02", + distinctCampaign: "cmlegacydist00000000000001", + // Created first but with an id that sorts last: createdAt must win. + distinctFirst: "cmlegacydist00000000000z99", + distinctSecond: "cmlegacydist00000000000a01", + threeCampaign: "cmlegacythree0000000000001", + threeFirst: "cmlegacythree0000000000b01", + threeSecond: "cmlegacythree0000000000b02", + threeThird: "cmlegacythree0000000000b03", + singleCampaign: "cmlegacysingle000000000001", + singleOnly: "cmlegacysingle000000000c01", + emptyCampaign: "cmlegacyempty0000000000001", +}; + +function migrationDirs() { + return readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); +} + +function migrationSql(name: string) { + return readFileSync(path.join(MIGRATIONS_DIR, name, "migration.sql"), "utf8"); +} + +function jsonRequest(method: string, url: string, body?: unknown) { + return new NextRequest(url, { + method, + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +async function createCampaign(name: string, links: { primary?: string; second?: string }) { + const res = await POST( + jsonRequest("POST", "http://localhost/api/automations", { + name, + instagramAccountId: accountId, + matchAnyPost: true, + keywords: ["LINK"], + dmMessage: "Here you go", + linkButtonLabel: "Get offer", + trackedDestinationUrl: links.primary ?? "", + secondaryDestinationUrl: links.second ?? "", + secondaryButtonLabel: "Read the guide", + }) + ); + expect(res.status).toBe(201); + return (await res.json()).data.id as string; +} + +// The body the campaign builder sends on every save. +async function saveCampaign( + id: string, + links: { primary: string; second: string; secondLabel?: string } +) { + const res = await PATCH( + jsonRequest("PATCH", `http://localhost/api/automations?id=${id}`, { + name: "Saved", + matchAnyPost: true, + pendingNextReel: false, + matchAnyWord: false, + keywords: ["LINK"], + dmMessage: "Here you go", + trackedDestinationUrl: links.primary, + linkButtonLabel: "Get offer", + secondaryDestinationUrl: links.second, + secondaryButtonLabel: links.secondLabel ?? "Read the guide", + isActive: true, + }) + ); + expect(res.status).toBe(200); +} + +async function storedLinks(automationId: string) { + return state.db.trackedLink.findMany({ + where: { automationId }, + orderBy: TRACKED_LINK_ORDER, + select: { label: true, destinationUrl: true, position: true }, + }); +} + +// Rewrites a campaign's older links in place. Postgres writes an updated row +// to a new spot on disk, which is what edits do to a real database over time, +// and it is what exposes an ORDER BY that relies on ties. +async function moveOlderLinksOnDisk(automationId: string) { + await sql.query( + `UPDATE "TrackedLink" SET "destinationUrl" = "destinationUrl" + WHERE "automationId" = $1 AND "position" = 0`, + [automationId] + ); +} + +let accountId = ""; + +describe.skipIf(!DATABASE_URL)("tracked link order on a real Postgres", () => { + beforeAll(async () => { + sql = new Client({ connectionString: DATABASE_URL }); + await sql.connect(); + await sql.query(`CREATE SCHEMA "${schema}"`); + await sql.query(`SET search_path TO "${schema}"`); + + // The database as it was just before this change. + const dirs = migrationDirs(); + expect(dirs).toContain(POSITION_MIGRATION); + for (const dir of dirs.filter((d) => d < POSITION_MIGRATION)) { + await sql.query(migrationSql(dir)); + } + + await sql.query(` + INSERT INTO "User" ("id", "email", "updatedAt") VALUES ('user_test', 'order@test.dev', now()); + INSERT INTO "Workspace" ("id", "name", "ownerId", "updatedAt") + VALUES ('workspace_test', 'Order', 'user_test', now()); + INSERT INTO "InstagramAccount" ("id", "workspaceId", "instagramId", "username", "accessToken", "updatedAt") + VALUES ('account_test', 'workspace_test', 'ig_order_test', 'order', 'token', now()); + INSERT INTO "Automation" ("id", "workspaceId", "instagramAccountId", "name", "keywords", "dmMessage", "matchAnyPost", "linkButtonLabel", "updatedAt") + VALUES + ('${legacy.tiedCampaign}', 'workspace_test', 'account_test', 'Tied', '{LINK}', 'hi', true, 'Get offer', now()), + ('${legacy.distinctCampaign}', 'workspace_test', 'account_test', 'Distinct', '{LINK}', 'hi', true, 'Get offer', now()), + ('${legacy.threeCampaign}', 'workspace_test', 'account_test', 'Three', '{LINK}', 'hi', true, 'Get offer', now()), + ('${legacy.singleCampaign}', 'workspace_test', 'account_test', 'Single', '{LINK}', 'hi', true, 'Get offer', now()), + ('${legacy.emptyCampaign}', 'workspace_test', 'account_test', 'Empty', '{LINK}', 'hi', true, 'Get offer', now()); + `); + + // Legacy links, inserted in scrambled order so that physical order and + // intended order disagree before the migration runs. + await sql.query(` + INSERT INTO "TrackedLink" ("id", "workspaceId", "automationId", "slug", "label", "destinationUrl", "createdAt", "updatedAt") + VALUES + ('${legacy.tiedSecond}', 'workspace_test', '${legacy.tiedCampaign}', 'legacy_t2', 'Read the guide', '${SECOND}', '${T0}', '${T0}'), + ('${legacy.threeThird}', 'workspace_test', '${legacy.threeCampaign}', 'legacy_h3', 'Third', '${THIRD}', '${T1}', '${T1}'), + ('${legacy.distinctSecond}', 'workspace_test', '${legacy.distinctCampaign}', 'legacy_d2', 'Read the guide', '${SECOND}', '${T1}', '${T1}'), + ('${legacy.tiedFirst}', 'workspace_test', '${legacy.tiedCampaign}', 'legacy_t1', 'Primary campaign link', '${PRIMARY}', '${T0}', '${T0}'), + ('${legacy.threeSecond}', 'workspace_test', '${legacy.threeCampaign}', 'legacy_h2', 'Read the guide', '${SECOND}', '${T0}', '${T0}'), + ('${legacy.singleOnly}', 'workspace_test', '${legacy.singleCampaign}', 'legacy_s1', 'Primary campaign link', '${PRIMARY}', '${T0}', '${T0}'), + ('${legacy.distinctFirst}', 'workspace_test', '${legacy.distinctCampaign}', 'legacy_d1', 'Primary campaign link', '${PRIMARY}', '${T0}', '${T0}'), + ('${legacy.threeFirst}', 'workspace_test', '${legacy.threeCampaign}', 'legacy_h1', 'Primary campaign link', '${PRIMARY}', '${T0}', '${T0}'); + `); + + await sql.query(migrationSql(POSITION_MIGRATION)); + for (const dir of dirs.filter((d) => d > POSITION_MIGRATION)) { + await sql.query(migrationSql(dir)); + } + + // Sequential scans only, which is what Postgres picks for the small tables + // of a typical self-hosted instance. A sequential scan returns rows in + // their order on disk, the condition under which tied rows come back + // swapped. Without this, whether the old bugs showed up here would depend + // on the plan Postgres happened to choose. + state.db = new PrismaClient({ + adapter: new PrismaPg( + { + connectionString: DATABASE_URL, + options: "-c enable_indexscan=off -c enable_bitmapscan=off", + }, + { schema } + ), + }); + state.workspaceId = "workspace_test"; + accountId = "account_test"; + }, 60_000); + + afterAll(async () => { + await state.db?.$disconnect(); + if (sql) { + await sql.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); + await sql.end(); + } + }); + + describe("the migration", () => { + it("numbers existing links in the order they were created, ids breaking ties", async () => { + const { rows } = await sql.query<{ id: string; position: number }>( + `SELECT "id", "position" FROM "TrackedLink" WHERE "slug" LIKE 'legacy_%'` + ); + const positions = Object.fromEntries(rows.map((r) => [r.id, r.position])); + + expect(positions).toEqual({ + [legacy.tiedFirst]: 0, + [legacy.tiedSecond]: 1, + [legacy.distinctFirst]: 0, + [legacy.distinctSecond]: 1, + [legacy.threeFirst]: 0, + [legacy.threeSecond]: 1, + [legacy.threeThird]: 2, + [legacy.singleOnly]: 0, + }); + }); + + it("leaves updatedAt alone", async () => { + const { rows } = await sql.query<{ changed: string }>( + `SELECT count(*) AS changed FROM "TrackedLink" + WHERE "slug" LIKE 'legacy_%' AND "updatedAt" <> "createdAt"` + ); + expect(Number(rows[0].changed)).toBe(0); + }); + + it("changes nothing when run again, as a retry after a failed deploy does", async () => { + const snapshot = `SELECT "id", "position", "updatedAt" FROM "TrackedLink" ORDER BY "id"`; + const before = (await sql.query(snapshot)).rows; + + await sql.query(migrationSql(POSITION_MIGRATION)); + + expect((await sql.query(snapshot)).rows).toEqual(before); + }); + + it("adds a column that defaults to 0 for writes that do not set it", async () => { + const { rows } = await sql.query<{ + is_nullable: string; + column_default: string; + data_type: string; + }>( + `SELECT is_nullable, column_default, data_type FROM information_schema.columns + WHERE table_schema = $1 AND table_name = 'TrackedLink' AND column_name = 'position'`, + [schema] + ); + expect(rows).toEqual([ + { is_nullable: "NO", column_default: "0", data_type: "integer" }, + ]); + }); + }); + + describe("reading", () => { + it("keeps a duplicated campaign's buttons in order on the dashboard", async () => { + // The reported bug: an original whose second link was added later, then + // copied. Each copy's links are written together, and edits keep moving + // rows on disk. Before this change most copies came back swapped here. + const originalId = await createCampaign("Original", { primary: PRIMARY }); + await saveCampaign(originalId, { primary: PRIMARY, second: SECOND }); + + const copyIds: string[] = []; + for (let i = 0; i < 20; i++) { + const copy = await duplicateCampaign({ + automationId: originalId, + workspaceId: state.workspaceId, + }); + copyIds.push(copy!.id); + await moveOlderLinksOnDisk(originalId); + } + + const res = await GET(jsonRequest("GET", "http://localhost/api/automations")); + const campaigns: { + id: string; + trackedLinks: { label: string; destinationUrl: string }[]; + }[] = (await res.json()).data; + + for (const id of [originalId, ...copyIds]) { + const campaign = campaigns.find((c) => c.id === id)!; + expect( + campaign.trackedLinks.map((l) => [l.destinationUrl, l.label]) + ).toEqual([ + [PRIMARY, "Primary campaign link"], + [SECOND, "Read the guide"], + ]); + } + }); + + it("keeps order for links an older build writes while a deploy rolls out", async () => { + // An older build does not know about position, so its two links land + // with the default 0 and the same createdAt. + const id = await createCampaign("Older build", {}); + await sql.query( + `INSERT INTO "TrackedLink" ("id", "workspaceId", "automationId", "slug", "label", "destinationUrl", "createdAt", "updatedAt") + VALUES + ('cmolderbuild000000000000b2', 'workspace_test', $1, 'older_2', 'Read the guide', $3, '${T1}', '${T1}'), + ('cmolderbuild000000000000a1', 'workspace_test', $1, 'older_1', 'Primary campaign link', $2, '${T1}', '${T1}')`, + [id, PRIMARY, SECOND] + ); + await moveOlderLinksOnDisk(id); + + expect(await storedLinks(id)).toEqual([ + { label: "Primary campaign link", destinationUrl: PRIMARY, position: 0 }, + { label: "Read the guide", destinationUrl: SECOND, position: 0 }, + ]); + + // The first save from the new build writes real positions. + await saveCampaign(id, { primary: PRIMARY, second: SECOND }); + expect(await storedLinks(id)).toEqual([ + { label: "Primary campaign link", destinationUrl: PRIMARY, position: 0 }, + { label: "Read the guide", destinationUrl: SECOND, position: 1 }, + ]); + }); + }); + + describe("writing", () => { + it("creates a campaign's two links at positions 0 and 1", async () => { + const id = await createCampaign("Created", { primary: PRIMARY, second: SECOND }); + + expect(await storedLinks(id)).toEqual([ + { label: "Primary campaign link", destinationUrl: PRIMARY, position: 0 }, + { label: "Read the guide", destinationUrl: SECOND, position: 1 }, + ]); + }); + + it("keeps both URLs when a campaign created with two links is saved unchanged", async () => { + // Before this change the first save wrote the second URL over the first + // link, every time, and the first URL was lost. + const id = await createCampaign("Save me", { primary: PRIMARY, second: SECOND }); + + // Whether the old code lost the link depended on how Postgres chose to + // scan the table, so vary where the rows sit between saves. + for (let i = 0; i < 3; i++) { + await saveCampaign(id, { primary: PRIMARY, second: SECOND }); + await moveOlderLinksOnDisk(id); + } + + expect(await storedLinks(id)).toEqual([ + { label: "Primary campaign link", destinationUrl: PRIMARY, position: 0 }, + { label: "Read the guide", destinationUrl: SECOND, position: 1 }, + ]); + }); + + it("keeps both URLs when a migrated legacy campaign is saved", async () => { + await saveCampaign(legacy.tiedCampaign, { primary: PRIMARY, second: SECOND }); + + expect(await storedLinks(legacy.tiedCampaign)).toEqual([ + { label: "Primary campaign link", destinationUrl: PRIMARY, position: 0 }, + { label: "Read the guide", destinationUrl: SECOND, position: 1 }, + ]); + }); + + it("keeps a third link in place when the first two are saved", async () => { + await saveCampaign(legacy.threeCampaign, { + primary: PRIMARY, + second: SECOND, + secondLabel: "Renamed", + }); + + expect(await storedLinks(legacy.threeCampaign)).toEqual([ + { label: "Primary campaign link", destinationUrl: PRIMARY, position: 0 }, + { label: "Renamed", destinationUrl: SECOND, position: 1 }, + { label: "Third", destinationUrl: THIRD, position: 2 }, + ]); + }); + + it("creates the second link once when two saves add it at the same time", async () => { + const id = await createCampaign("Double click", { primary: PRIMARY }); + + await Promise.all([ + saveCampaign(id, { primary: PRIMARY, second: SECOND }), + saveCampaign(id, { primary: PRIMARY, second: SECOND }), + ]); + + expect(await storedLinks(id)).toEqual([ + { label: "Primary campaign link", destinationUrl: PRIMARY, position: 0 }, + { label: "Read the guide", destinationUrl: SECOND, position: 1 }, + ]); + }); + + it("gives a duplicate positions 0 and 1 in the original's order", async () => { + const copy = await duplicateCampaign({ + automationId: legacy.distinctCampaign, + workspaceId: state.workspaceId, + }); + + expect(await storedLinks(copy!.id)).toEqual([ + { label: "Primary campaign link", destinationUrl: PRIMARY, position: 0 }, + { label: "Read the guide", destinationUrl: SECOND, position: 1 }, + ]); + }); + }); +}); diff --git a/app/api/automations/route.ts b/app/api/automations/route.ts index 0beea4650..abfc11af5 100644 --- a/app/api/automations/route.ts +++ b/app/api/automations/route.ts @@ -4,7 +4,11 @@ import { getCurrentWorkspaceId } from "@/lib/auth"; import { prisma } from "@/lib/db/client"; import { calculateCtr, normalizeTopKeywords } from "@/lib/tracking/analytics"; import { buildTrackedUrl } from "@/lib/tracking/message"; -import { generateTrackedLinkSlug } from "@/lib/tracking/server"; +import { TRACKED_LINK_ORDER } from "@/lib/tracking/link-order"; +import { + buildInitialCampaignLinks, + syncCampaignLinks, +} from "@/lib/campaigns/links"; import { buildReportUrl, generateReportShareSlug } from "@/lib/reports/share"; import { canManageWorkspace, @@ -153,7 +157,7 @@ export async function GET(request: NextRequest) { destinationUrl: true, _count: { select: { clicks: true } }, }, - orderBy: { createdAt: "asc" }, + orderBy: TRACKED_LINK_ORDER, }, }, orderBy: { createdAt: "desc" }, @@ -341,33 +345,12 @@ export async function POST(request: NextRequest) { ); } - const { trackedDestinationUrl, secondaryDestinationUrl, secondaryButtonLabel } = - parsed.data; - - // The primary link's button title comes from `linkButtonLabel`; the second - // link stores its own button title in the tracked link's `label` field. - const linkCreates: { - workspaceId: string; - slug: string; - label: string; - destinationUrl: string; - }[] = []; - if (trackedDestinationUrl) { - linkCreates.push({ - workspaceId, - slug: generateTrackedLinkSlug(), - label: "Primary campaign link", - destinationUrl: trackedDestinationUrl, - }); - } - if (secondaryDestinationUrl) { - linkCreates.push({ - workspaceId, - slug: generateTrackedLinkSlug(), - label: secondaryButtonLabel?.trim() || "Open link", - destinationUrl: secondaryDestinationUrl, - }); - } + const linkCreates = buildInitialCampaignLinks({ + workspaceId, + primaryUrl: parsed.data.trackedDestinationUrl, + secondaryUrl: parsed.data.secondaryDestinationUrl, + secondaryLabel: parsed.data.secondaryButtonLabel, + }); const { pendingNextReel, matchAnyPost, matchAnyWord, openingDmEnabled } = parsed.data; @@ -536,73 +519,25 @@ export async function PATCH(request: NextRequest) { automationData.publicReplyMessage = null; } - const updated = await prisma.automation.update({ - where: { id: automationId }, - data: automationData, - }); - - // Update, create, or clear the campaign's primary tracked link when a - // destination URL was supplied. `undefined` means "leave it alone". - if (trackedDestinationUrl !== undefined && trackedDestinationUrl !== null) { - const primaryLink = await prisma.trackedLink.findFirst({ - where: { automationId }, - orderBy: { createdAt: "asc" }, + // One transaction, so a save never lands half applied. Updating the campaign + // first locks its row, which makes a second save of the same campaign wait + // for this one before it reads the links. + const updated = await prisma.$transaction(async (tx) => { + const campaign = await tx.automation.update({ + where: { id: automationId }, + data: automationData, }); - if (trackedDestinationUrl === "") { - if (primaryLink) { - await prisma.trackedLink.delete({ where: { id: primaryLink.id } }); - } - } else if (primaryLink) { - await prisma.trackedLink.update({ - where: { id: primaryLink.id }, - data: { destinationUrl: trackedDestinationUrl }, - }); - } else { - await prisma.trackedLink.create({ - data: { - workspaceId, - automationId, - slug: generateTrackedLinkSlug(), - label: "Primary campaign link", - destinationUrl: trackedDestinationUrl, - }, - }); - } - } - - // Update, create, or clear the campaign's second tracked link. It is always - // the link at index [1] (ordered by createdAt), and its `label` holds the - // second button's title. - if (secondaryDestinationUrl !== undefined && secondaryDestinationUrl !== null) { - const links = await prisma.trackedLink.findMany({ - where: { automationId }, - orderBy: { createdAt: "asc" }, + await syncCampaignLinks(tx, { + workspaceId, + automationId, + primaryUrl: trackedDestinationUrl, + secondaryUrl: secondaryDestinationUrl, + secondaryLabel: secondaryButtonLabel, }); - const secondaryLink = links[1]; - const secondaryLabel = secondaryButtonLabel?.trim() || "Open link"; - - if (secondaryDestinationUrl === "") { - if (secondaryLink) { - await prisma.trackedLink.delete({ where: { id: secondaryLink.id } }); - } - } else if (secondaryLink) { - await prisma.trackedLink.update({ - where: { id: secondaryLink.id }, - data: { destinationUrl: secondaryDestinationUrl, label: secondaryLabel }, - }); - } else { - await prisma.trackedLink.create({ - data: { - workspaceId, - automationId, - slug: generateTrackedLinkSlug(), - label: secondaryLabel, - destinationUrl: secondaryDestinationUrl, - }, - }); - } - } + + return campaign; + }); return NextResponse.json({ success: true, data: updated }); } diff --git a/lib/campaigns/duplicate.ts b/lib/campaigns/duplicate.ts index b920c46b7..b1cdb36b8 100644 --- a/lib/campaigns/duplicate.ts +++ b/lib/campaigns/duplicate.ts @@ -1,5 +1,6 @@ import { prisma } from "@/lib/db/client"; import { generateReportShareSlug } from "@/lib/reports/share"; +import { TRACKED_LINK_ORDER } from "@/lib/tracking/link-order"; import { generateTrackedLinkSlug } from "@/lib/tracking/server"; // Matches the campaign name limit the create and update schemas enforce. @@ -42,7 +43,7 @@ export async function duplicateCampaign({ }) { const source = await prisma.automation.findFirst({ where: { id: automationId, workspaceId }, - include: { trackedLinks: { orderBy: { createdAt: "asc" } } }, + include: { trackedLinks: { orderBy: TRACKED_LINK_ORDER } }, }); if (!source) return null; @@ -65,11 +66,14 @@ export async function duplicateCampaign({ isActive: false, reportShareSlug: generateReportShareSlug(), trackedLinks: { - create: trackedLinks.map((link) => ({ + // Numbered from the order just read, so the copy's buttons match + // the original's even if the original's positions have gaps or ties. + create: trackedLinks.map((link, position) => ({ workspaceId: source.workspaceId, slug: generateTrackedLinkSlug(), label: link.label, destinationUrl: link.destinationUrl, + position, })), }, }, diff --git a/lib/campaigns/links.ts b/lib/campaigns/links.ts new file mode 100644 index 000000000..4617513ff --- /dev/null +++ b/lib/campaigns/links.ts @@ -0,0 +1,163 @@ +import type { Prisma } from "@/app/generated/prisma/client"; +import { TRACKED_LINK_ORDER } from "@/lib/tracking/link-order"; +import { generateTrackedLinkSlug } from "@/lib/tracking/server"; + +// The primary button's title is stored on the campaign as `linkButtonLabel`, +// so the primary link's own label is only a placeholder. Every later link +// stores its button title in `label`. +export const PRIMARY_LINK_LABEL = "Primary campaign link"; +export const DEFAULT_LINK_BUTTON_LABEL = "Open link"; + +type LinkFields = { + // For each URL: a URL sets the link, an empty string removes it, and null or + // undefined leaves it untouched. + primaryUrl?: string | null; + secondaryUrl?: string | null; + secondaryLabel?: string | null; +}; + +/** + * The tracked links for a new campaign, numbered in button order. + */ +export function buildInitialCampaignLinks({ + workspaceId, + primaryUrl, + secondaryUrl, + secondaryLabel, +}: LinkFields & { workspaceId: string }) { + const links: { + workspaceId: string; + slug: string; + label: string; + destinationUrl: string; + }[] = []; + + if (primaryUrl) { + links.push({ + workspaceId, + slug: generateTrackedLinkSlug(), + label: PRIMARY_LINK_LABEL, + destinationUrl: primaryUrl, + }); + } + if (secondaryUrl) { + links.push({ + workspaceId, + slug: generateTrackedLinkSlug(), + label: secondaryLabel?.trim() || DEFAULT_LINK_BUTTON_LABEL, + destinationUrl: secondaryUrl, + }); + } + + return links.map((link, position) => ({ ...link, position })); +} + +type StoredLink = { id: string; position: number }; + +/** + * Apply a campaign save to its tracked links. The first link is the primary + * button, the second is the second button, and any after that are kept as + * they are. + * + * The links are read once, before anything is written, and every change + * targets a link by id. Reading them again after a write is what used to go + * wrong: an update moves a row on disk, so when both links shared a createdAt + * the second read could return them swapped, and the second button's URL was + * written over the first link. + * + * Positions are then renumbered to match the result, which closes the gap a + * removed link leaves and repairs links an older build wrote without one. + * + * Call it in the same transaction as the campaign update, after it. That + * update locks the campaign row until the transaction ends, so two saves of + * one campaign run one after the other and cannot both create the same + * missing link. + */ +export async function syncCampaignLinks( + tx: Prisma.TransactionClient, + { + workspaceId, + automationId, + primaryUrl, + secondaryUrl, + secondaryLabel, + }: LinkFields & { workspaceId: string; automationId: string } +) { + const primaryChanged = primaryUrl !== undefined && primaryUrl !== null; + const secondaryChanged = secondaryUrl !== undefined && secondaryUrl !== null; + if (!primaryChanged && !secondaryChanged) return; + + const [primary, secondary, ...rest] = await tx.trackedLink.findMany({ + where: { automationId }, + orderBy: TRACKED_LINK_ORDER, + select: { id: true, position: true }, + }); + + let first: StoredLink | undefined = primary; + if (primaryChanged) { + if (primaryUrl === "") { + if (primary) { + await tx.trackedLink.delete({ where: { id: primary.id } }); + } + first = undefined; + } else if (primary) { + await tx.trackedLink.update({ + where: { id: primary.id }, + data: { destinationUrl: primaryUrl }, + }); + } else { + first = await tx.trackedLink.create({ + data: { + workspaceId, + automationId, + slug: generateTrackedLinkSlug(), + label: PRIMARY_LINK_LABEL, + destinationUrl: primaryUrl, + position: 0, + }, + select: { id: true, position: true }, + }); + } + } + + let second: StoredLink | undefined = secondary; + if (secondaryChanged) { + const label = secondaryLabel?.trim() || DEFAULT_LINK_BUTTON_LABEL; + + if (secondaryUrl === "") { + if (secondary) { + await tx.trackedLink.delete({ where: { id: secondary.id } }); + } + second = undefined; + } else if (secondary) { + await tx.trackedLink.update({ + where: { id: secondary.id }, + data: { destinationUrl: secondaryUrl, label }, + }); + } else { + second = await tx.trackedLink.create({ + data: { + workspaceId, + automationId, + slug: generateTrackedLinkSlug(), + label, + destinationUrl: secondaryUrl, + position: first ? 1 : 0, + }, + select: { id: true, position: true }, + }); + } + } + + const ordered = [first, second, ...rest].filter( + (link): link is StoredLink => link !== undefined + ); + for (const [position, link] of ordered.entries()) { + if (link.position !== position) { + await tx.trackedLink.update({ + where: { id: link.id }, + data: { position }, + }); + } + } +} diff --git a/lib/queue/dm-worker.ts b/lib/queue/dm-worker.ts index 0aa2691f5..ec5f12d75 100644 --- a/lib/queue/dm-worker.ts +++ b/lib/queue/dm-worker.ts @@ -43,6 +43,7 @@ import { renderMessageWithTracking, renderMessageWithoutLink, } from "@/lib/tracking/message"; +import { TRACKED_LINK_ORDER } from "@/lib/tracking/link-order"; import { ZernioApiError, @@ -254,7 +255,7 @@ async function processComment(job: Job): Promise { label: true, destinationUrl: true, }, - orderBy: { createdAt: "asc" }, + orderBy: TRACKED_LINK_ORDER, }, }, orderBy: { createdAt: "asc" }, @@ -808,7 +809,7 @@ async function processPostback(job: Job): Promise { workspace: true, trackedLinks: { select: { slug: true, label: true, destinationUrl: true }, - orderBy: { createdAt: "asc" }, + orderBy: TRACKED_LINK_ORDER, }, }, }); @@ -1125,7 +1126,7 @@ async function processMessage(job: Job): Promise { workspace: true, trackedLinks: { select: { slug: true, label: true, destinationUrl: true }, - orderBy: { createdAt: "asc" }, + orderBy: TRACKED_LINK_ORDER, }, }, orderBy: { createdAt: "asc" }, diff --git a/lib/reports/data.ts b/lib/reports/data.ts index c72cb9c7b..5eabe98c8 100644 --- a/lib/reports/data.ts +++ b/lib/reports/data.ts @@ -4,6 +4,7 @@ import { normalizeTopKeywords, summarizeDmStatuses, } from "@/lib/tracking/analytics"; +import { TRACKED_LINK_ORDER } from "@/lib/tracking/link-order"; import { buildReportUrl, isReportBranded } from "@/lib/reports/share"; function getHostname(url: string) { @@ -59,7 +60,7 @@ export async function getCampaignReportBySlug(shareSlug: string) { destinationUrl: true, _count: { select: { clicks: true } }, }, - orderBy: { createdAt: "asc" }, + orderBy: TRACKED_LINK_ORDER, }, }, }); diff --git a/lib/tracking/link-order.ts b/lib/tracking/link-order.ts new file mode 100644 index 000000000..0bb498916 --- /dev/null +++ b/lib/tracking/link-order.ts @@ -0,0 +1,16 @@ +import type { Prisma } from "@/app/generated/prisma/client"; + +/** + * The order of a campaign's tracked links, which is the order of its DM + * buttons: the first link is the primary button. + * + * `position` decides it. `createdAt` and `id` only break ties between links + * that share a position, which happens for links an older build wrote while a + * deploy was rolling out. Without them, Postgres is free to return tied rows + * in a different order on every read. + */ +export const TRACKED_LINK_ORDER = [ + { position: "asc" }, + { createdAt: "asc" }, + { id: "asc" }, +] satisfies Prisma.TrackedLinkOrderByWithRelationInput[]; diff --git a/prisma/migrations/20260917160000_tracked_link_position/migration.sql b/prisma/migrations/20260917160000_tracked_link_position/migration.sql new file mode 100644 index 000000000..0fc6bd114 --- /dev/null +++ b/prisma/migrations/20260917160000_tracked_link_position/migration.sql @@ -0,0 +1,34 @@ +-- A campaign's DM button order used to be inferred from TrackedLink.createdAt. +-- Links saved in the same request share one createdAt, and Postgres returns +-- tied rows in no fixed order, so buttons could swap, and saving a campaign +-- could write its second link over its first. The order is now stored. +-- +-- Prisma applies a migration one statement at a time, not in a transaction, +-- so both statements are safe to run again: if the backfill fails, the error +-- Prisma prints is the real one, and after `prisma migrate resolve +-- --rolled-back 20260917160000_tracked_link_position` the next deploy re-runs +-- this file from the top. Until the backfill lands, reads stay correct, since +-- every link reads as position 0 and ties fall back to createdAt, then id. + +-- AlterTable +ALTER TABLE "TrackedLink" ADD COLUMN IF NOT EXISTS "position" INTEGER NOT NULL DEFAULT 0; + +-- Number every existing link in the order the app has always intended: oldest +-- first, and for links created together, by id, since ids generated in one +-- request increase in the order the links were written. COLLATE "C" compares +-- ids byte by byte whatever the database's default collation is. Only rows +-- whose position changes are written, and updatedAt is left alone because the +-- links themselves did not change. +UPDATE "TrackedLink" AS link +SET "position" = ordered."position" +FROM ( + SELECT + "id", + (ROW_NUMBER() OVER ( + PARTITION BY "automationId" + ORDER BY "createdAt" ASC, "id" COLLATE "C" ASC + ) - 1)::INTEGER AS "position" + FROM "TrackedLink" +) AS ordered +WHERE link."id" = ordered."id" + AND link."position" <> ordered."position"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e126781d1..d47085b6b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -278,6 +278,11 @@ model TrackedLink { slug String @unique label String? destinationUrl String + // Which DM button this link is, starting at 0. Read links with + // TRACKED_LINK_ORDER (lib/tracking/link-order.ts), never by createdAt alone: + // links saved in one request share a createdAt, and Postgres returns tied + // rows in no fixed order. + position Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt