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