diff --git a/__tests__/campaign-duplicate.test.ts b/__tests__/campaign-duplicate.test.ts index 8c813ee24..15b3beb54 100644 --- a/__tests__/campaign-duplicate.test.ts +++ b/__tests__/campaign-duplicate.test.ts @@ -2,10 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const { mockPrisma } = vi.hoisted(() => ({ mockPrisma: { + $transaction: vi.fn(), automation: { findFirst: vi.fn(), create: vi.fn(), }, + trackedLink: { findMany: vi.fn() }, + campaignRevision: { create: vi.fn() }, }, })); @@ -88,11 +91,13 @@ function createArgs() { beforeEach(() => { vi.clearAllMocks(); + mockPrisma.$transaction.mockImplementation((callback) => callback(mockPrisma)); + mockPrisma.trackedLink.findMany.mockResolvedValue([]); mockPrisma.automation.findFirst.mockResolvedValue(sourceCampaign); mockPrisma.automation.create.mockImplementation( async ({ data }: { data: Record }) => ({ - id: "automation_copy", ...data, + id: "automation_copy", }) ); }); diff --git a/__tests__/campaign-history.test.ts b/__tests__/campaign-history.test.ts new file mode 100644 index 000000000..1b8fb7560 --- /dev/null +++ b/__tests__/campaign-history.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { safeDeliveryError, safeRevisionSnapshot } from "@/lib/campaigns/history"; +describe("campaign audit projections", () => { + it("whitelists snapshots and link fields, never internal access state", () => { + expect(safeRevisionSnapshot({ name: "Campaign", version: 7, dmMessage: "Content", accessToken: "secret", workspaceId: "private", trackedLinks: [{ label: "Read", destinationUrl: "https://example.com", position: 0, slug: "private", token: "secret" }] })).toEqual({ name: "Campaign", version: 7, dmMessage: "Content", trackedLinks: [{ label: "Read", destinationUrl: "https://example.com", position: 0 }] }); + expect(safeRevisionSnapshot(null)).toEqual({}); + expect(safeRevisionSnapshot([])).toEqual({}); + }); + it("redacts common provider credential forms and bounds diagnostics", () => { + const error = safeDeliveryError('https://example.com?access_token=abc&key=xyz Bearer bearer123 client_secret="secret123", password=pwd'); + for (const value of ["abc", "xyz", "bearer123", "secret123", "pwd"]) expect(error).not.toContain(value); + expect(safeDeliveryError(null)).toBeNull(); + expect(safeDeliveryError("x".repeat(2000))).toHaveLength(1000); + }); + it("projects only saved legacy destinations without inventing a version", () => { + expect(safeRevisionSnapshot({ lifecycle: "DRAFT", trackedDestinationUrl: "https://example.com/old", secondaryDestinationUrl: "https://example.com/second", secondaryButtonLabel: "Second", accessToken: "discard" })).toEqual({ + lifecycle: "DRAFT", trackedLinks: [ + { label: null, destinationUrl: "https://example.com/old", position: 0 }, + { label: "Second", destinationUrl: "https://example.com/second", position: 1 }, + ], + }); + expect(safeRevisionSnapshot({ trackedDestinationUrl: null, secondaryDestinationUrl: 123 })).toEqual({}); + }); + it("keeps recorded link arrays authoritative, including an explicitly empty array", () => { + expect(safeRevisionSnapshot({ version: 1, trackedLinks: [], trackedDestinationUrl: "https://example.com/legacy" })).toEqual({ version: 1, trackedLinks: [] }); + }); +}); diff --git a/__tests__/campaign-lifecycle.db.test.ts b/__tests__/campaign-lifecycle.db.test.ts new file mode 100644 index 000000000..aeef29373 --- /dev/null +++ b/__tests__/campaign-lifecycle.db.test.ts @@ -0,0 +1,227 @@ +/** Real PostgreSQL migration + API tests. Only random throwaway schema; no + * production credentials, provider sends, queue jobs or persistent test 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, beforeEach, describe, expect, it, vi } from "vitest"; +import { PrismaClient } from "@/app/generated/prisma/client"; + +const state = vi.hoisted(() => ({ db: undefined as unknown as PrismaClient, workspaceId: "ws", authorized: true, role: "OWNER" })); +vi.mock("@/lib/db/client", () => ({ get prisma() { return state.db; } })); +vi.mock("@/lib/auth", () => ({ getCurrentWorkspaceId: async () => state.authorized ? state.workspaceId : null })); +vi.mock("@/lib/workspace-access", () => ({ + canManageWorkspace: (role: string) => role === "OWNER" || role === "ADMIN", + getCurrentWorkspaceContext: async () => state.authorized ? { userId: "owner", workspaceId: state.workspaceId, role: state.role } : null, +})); +import { POST, PATCH } from "@/app/api/automations/route"; +import { POST as simulate } from "@/app/api/automations/validate/route"; +import { duplicateCampaign } from "@/lib/campaigns/duplicate"; +import { POST as importCampaigns } from "@/app/api/automations/import/route"; +import { createLibraryDraft } from "@/lib/library/drafts"; +import { POST as saveLibrary } from "@/app/api/library/route"; +import { GET as editorHistory } from "@/app/api/automations/history/route"; + +const DATABASE_URL = process.env.TEST_DATABASE_URL; +const MIGRATIONS_DIR = path.join(__dirname, "..", "prisma", "migrations"); +const EXTENSION = "20261003090000_creator_workflow"; +const schema = `campaign_lifecycle_${randomBytes(6).toString("hex")}`; +let sql: Client; +function request(method: string, body: unknown, id?: string) { + return new NextRequest(`http://localhost/api/automations${id ? `?id=${id}` : ""}`, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); +} +const complete = { name: "Ready", instagramAccountId: "account", matchAnyPost: true, keywords: ["link"], dmMessage: "Here is {link}", trackedDestinationUrl: "https://example.test/resource" }; +async function create(body: unknown) { const response = await POST(request("POST", body)); return { response, payload: await response.json() }; } + +describe.skipIf(!DATABASE_URL)("campaign lifecycle on real PostgreSQL", () => { + 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}"`); + const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort(); + for (const dir of dirs.filter((d) => d < EXTENSION)) await sql.query(readFileSync(path.join(MIGRATIONS_DIR, dir, "migration.sql"), "utf8")); + await sql.query(` + INSERT INTO "User" ("id", "email", "updatedAt") VALUES ('owner','owner@example.test',now()),('other','other@example.test',now()); + INSERT INTO "Workspace" ("id","name","ownerId","updatedAt") VALUES ('ws','Test','owner',now()),('otherws','Other','other',now()); + INSERT INTO "InstagramAccount" ("id","workspaceId","instagramId","username","accessToken","updatedAt") VALUES + ('account','ws','ig-test','test','dummy',now()),('legacy-account','ws','ig-legacy','legacy','dummy',now()),('otheraccount','otherws','ig-other','other','dummy',now()); + INSERT INTO "Automation" ("id","workspaceId","instagramAccountId","name","keywords","dmMessage","isActive","pendingNextReel","createdAt","updatedAt") VALUES + ('legacy-active','ws','legacy-account','Active','{link}','text',true,true,'2026-01-01',now()), + ('legacy-overlap','ws','legacy-account','Also active','{link}','text',true,true,'2026-02-01',now()), + ('legacy-paused','ws','legacy-account','Paused','{link}','text',false,true,'2026-01-01',now()); + `); + for (const dir of dirs.filter((d) => d >= EXTENSION)) await sql.query(readFileSync(path.join(MIGRATIONS_DIR, dir, "migration.sql"), "utf8")); + state.db = new PrismaClient({ adapter: new PrismaPg({ connectionString: DATABASE_URL, max: 6 }, { schema }) }); + }, 60_000); + beforeEach(() => { state.authorized = true; state.role = "OWNER"; state.workspaceId = "ws"; }); + afterAll(async () => { + await state.db?.$disconnect(); + if (sql) { await sql.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); await sql.end(); } + }); + + it("preserves active/paused state and legacy overlaps without guessing", async () => { + const rows = await state.db.automation.findMany({ where: { instagramAccountId: "legacy-account" }, orderBy: { id: "asc" } }); + expect(rows.map((r) => [r.id, r.lifecycle, r.isActive, r.pendingNextReel])).toEqual([ + ["legacy-active", "ACTIVE", true, true], ["legacy-overlap", "ACTIVE", true, true], ["legacy-paused", "PAUSED", false, true], + ]); + expect(rows[0].armedAt).toEqual(rows[0].createdAt); + expect(rows[2].armedAt).toBeNull(); + }); + it("saves an incomplete draft but rejects activating it until complete", async () => { + const { response, payload } = await create({ name: "Incomplete", instagramAccountId: "account", lifecycle: "DRAFT" }); + expect(response.status).toBe(201); + expect(payload.data).toMatchObject({ lifecycle: "DRAFT", isActive: false, dmMessage: "" }); + const rejected = await PATCH(request("PATCH", { lifecycle: "ACTIVE" }, payload.data.id)); + expect(rejected.status).toBe(400); + expect((await state.db.automation.findUniqueOrThrow({ where: { id: payload.data.id } })).lifecycle).toBe("DRAFT"); + const accepted = await PATCH(request("PATCH", { lifecycle: "ACTIVE", matchAnyPost: true, keywords: ["link"], dmMessage: "Ready" }, payload.data.id)); + expect(accepted.status).toBe(200); + expect((await accepted.json()).data).toMatchObject({ lifecycle: "ACTIVE", isActive: true, version: 2 }); + }); + it("serializes concurrent create/activation: exactly one armed reel", async () => { + const waiting = { ...complete, matchAnyPost: false, pendingNextReel: true }; + const results = await Promise.all([create(waiting), create(waiting), create(waiting)]); + expect(results.map((r) => r.response.status).sort()).toEqual([201,409,409]); + const armed = await state.db.automation.findMany({ where: { instagramAccountId: "account", pendingNextReel: true, isActive: true } }); + expect(armed).toHaveLength(1); + expect(armed[0].armedAt!.getTime()).toBeGreaterThan(Date.now() - 10_000); + const drafts = await Promise.all([create({ ...waiting, lifecycle: "DRAFT" }), create({ ...waiting, lifecycle: "DRAFT" })]); + await PATCH(request("PATCH", { lifecycle: "PAUSED" }, armed[0].id)); + const activations = await Promise.all(drafts.map((r) => PATCH(request("PATCH", { lifecycle: "ACTIVE" }, r.payload.data.id)))); + expect(activations.map((r) => r.status).sort()).toEqual([200,409]); + const currentlyActive = await state.db.automation.findFirstOrThrow({ where: { instagramAccountId: "account", pendingNextReel: true, isActive: true } }); + await PATCH(request("PATCH", { lifecycle: "PAUSED" }, currentlyActive.id)); + }); + it("archives without deleting tracking links/report slug and records versions", async () => { + const { payload } = await create(complete); + const before = await state.db.trackedLink.findMany({ where: { automationId: payload.data.id } }); + const response = await PATCH(request("PATCH", { lifecycle: "ARCHIVED", isActive: true }, payload.data.id)); + expect(response.status).toBe(200); + const archived = (await response.json()).data; + expect(archived).toMatchObject({ lifecycle: "ARCHIVED", isActive: false, reportShareSlug: payload.data.reportShareSlug }); + expect(await state.db.trackedLink.findMany({ where: { automationId: archived.id } })).toEqual(before); + const revisions = await state.db.campaignRevision.findMany({ where: { automationId: archived.id }, orderBy: { createdAt: "asc" } }); + expect(revisions).toHaveLength(2); + expect(revisions[1].snapshot).toMatchObject({ lifecycle: "ARCHIVED", version: 2, trackedLinks: [{ destinationUrl: "https://example.test/resource" }] }); + const copy = await duplicateCampaign({ automationId: archived.id, workspaceId: "ws", actorId: "owner" }); + expect(copy).toMatchObject({ lifecycle: "DRAFT", isActive: false, armedAt: null, version: 1 }); + expect(await state.db.campaignRevision.count({ where: { automationId: copy!.id } })).toBe(1); + }); + it("preserves worker button ordering in recorded history when link positions tie", async () => { + const { payload } = await create({ ...complete, trackedDestinationUrl: undefined }); + const campaignId = payload.data.id; + await state.db.trackedLink.createMany({ data: [ + { id: "z-older-link", workspaceId: "ws", automationId: campaignId, position: 0, slug: "older-tie", destinationUrl: "https://example.test/older", createdAt: new Date("2026-01-01") }, + { id: "a-newer-link", workspaceId: "ws", automationId: campaignId, position: 0, slug: "newer-tie", destinationUrl: "https://example.test/newer", createdAt: new Date("2026-02-01") }, + ] }); + expect((await PATCH(request("PATCH", { goal: "Ordering" }, campaignId))).status).toBe(200); + const revision = await state.db.campaignRevision.findFirstOrThrow({ where: { automationId: campaignId }, orderBy: { createdAt: "desc" } }); + expect(revision.snapshot).toMatchObject({ trackedLinks: [ + { destinationUrl: "https://example.test/older" }, { destinationUrl: "https://example.test/newer" }, + ] }); + }); + it("enforces workspace and role isolation", async () => { + state.authorized = false; + expect((await POST(request("POST", complete))).status).toBe(401); + state.authorized = true; state.role = "MEMBER"; + expect((await POST(request("POST", complete))).status).toBe(403); + state.role = "OWNER"; + expect((await create({ ...complete, instagramAccountId: "otheraccount" })).response.status).toBe(400); + expect((await PATCH(request("PATCH", { name: "unauthorized" }, "not-in-workspace"))).status).toBe(404); + }); + it("simulation uses new proposed links and shared winner logic without persistence", async () => { + const { payload } = await create({ ...complete, name: "Specific", postId: "post", matchAnyPost: false }); + const before = await state.db.campaignRevision.count(); + const result = await simulate(request("POST", { + instagramAccountId: "account", kind: "comment", text: "link", mediaId: "post", + candidate: { ...complete, id: payload.data.id, name: "Edited", postId: "post", matchAnyPost: false, trackedDestinationUrl: "https://example.test/NEW" }, + })); + expect(result.status).toBe(200); + const data = (await result.json()).data; + expect(data.winner.name).toBe("Edited"); + expect(data.message).toContain("https://example.test/NEW"); + expect(data.message).not.toContain("/r/"); + expect(await state.db.campaignRevision.count()).toBe(before); + expect(await state.db.dmLog.count()).toBe(0); + }); + it("rejects executable and credential-bearing URLs", async () => { + for (const trackedDestinationUrl of ["javascript:alert(1)", "data:text/html,evil", "https://user:pass@example.com/"]) expect((await create({ ...complete, trackedDestinationUrl })).response.status).toBe(400); + }); + it("imports true, false and unspecified source states as drafts with revisions", async () => { + const response = await importCampaigns(request("POST", { instagramAccountId: "account", campaigns: [ + { postId: "import-explicit-active", name: "Source active", keywords: ["link"], dmMessage: "Import content", isActive: true }, + { postId: "import-unspecified-status", name: "Source unspecified", keywords: ["link"], dmMessage: "Import content" }, + { postId: "import-explicit-paused", name: "Source paused", keywords: ["link"], dmMessage: "Import content", isActive: false }, + ] })); + expect(response.status).toBe(200); + const payload = await response.json(); + expect(payload.message).toContain("Imported as drafts"); + expect(payload.data.created).toHaveLength(3); + expect(payload.data.created.every((row: { lifecycle: string }) => row.lifecycle === "DRAFT")).toBe(true); + const rows = await state.db.automation.findMany({ where: { postId: { in: ["import-explicit-active", "import-unspecified-status", "import-explicit-paused"] } } }); + expect(rows).toHaveLength(3); + for (const campaign of rows) { + expect(campaign).toMatchObject({ lifecycle: "DRAFT", isActive: false, armedAt: null, version: 1 }); + const revisions = await state.db.campaignRevision.findMany({ where: { automationId: campaign.id } }); + expect(revisions).toHaveLength(1); + expect(revisions[0].snapshot).toMatchObject({ lifecycle: "DRAFT", isActive: false }); + } + expect(await state.db.dmLog.count({ where: { automationId: { in: rows.map((r) => r.id) } } })).toBe(0); + }); + + it("retries library copies once with immutable initial link snapshots", async () => { + const context = { workspaceId: "ws", actorId: "owner" }; + const input = { ...complete, idempotencyKey: "library-retry", secondaryDestinationUrl: "https://example.test/secondary" }; + const results = await Promise.all([createLibraryDraft(context, input), createLibraryDraft(context, input), createLibraryDraft(context, input)]); + expect(new Set(results.map(r => r.campaignId)).size).toBe(1); + expect(results.filter(r => !r.replayed)).toHaveLength(1); + const id = results[0].campaignId; + const initial = await state.db.campaignRevision.findFirstOrThrow({ where: { automationId: id } }); + expect(initial.snapshot).toMatchObject({ version: 1, lifecycle: "DRAFT", isActive: false, postId: null, trackedLinks: [ + { destinationUrl: complete.trackedDestinationUrl, position: 0 }, { destinationUrl: "https://example.test/secondary", position: 1 }, + ] }); + expect((await PATCH(request("PATCH", { trackedDestinationUrl: "https://example.test/new" }, id))).status).toBe(200); + expect((await state.db.campaignRevision.findUniqueOrThrow({ where: { id: initial.id } })).snapshot).toEqual(initial.snapshot); + await expect(createLibraryDraft(context, { ...input, name: "Changed" })).rejects.toMatchObject({ status: 409 }); + await expect(createLibraryDraft(context, { ...input, idempotencyKey: "foreign-account", instagramAccountId: "otheraccount" })).rejects.toMatchObject({ status: 404 }); + expect(await state.db.dmLog.count({ where: { automationId: id } })).toBe(0); + }); + it("saves safe library content, enforces versions and does not modify existing drafts", async () => { + const make = (body: unknown) => new Request("http://localhost/api/library", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + const first = await saveLibrary(make({ action: "save", kind: "RESOURCE", name: "Resource", data: { + destinationUrl: "https://example.test/original", dmMessage: "Here {link}", category: "COMPANY", accessToken: "do-not-store", + } })); + expect(first.status).toBe(201); + const asset = (await first.json()).asset; + expect(asset.data).not.toHaveProperty("accessToken"); + const copy = await saveLibrary(make({ action: "create_draft", id: asset.id, instagramAccountId: "account", keyword: "guide", idempotencyKey: "resource-copy" })); + expect(copy.status).toBe(200); + const id = (await copy.json()).campaignId; + const edit = { action: "save", id: asset.id, kind: "RESOURCE", name: "Edited", version: 1, data: { destinationUrl: "https://example.test/updated" } }; + expect((await saveLibrary(make(edit))).status).toBe(200); + expect((await saveLibrary(make(edit))).status).toBe(409); + expect(await state.db.trackedLink.findFirst({ where: { automationId: id } })).toMatchObject({ destinationUrl: "https://example.test/original" }); + state.role = "MEMBER"; + expect((await saveLibrary(make(edit))).status).toBe(403); + state.role = "OWNER"; state.workspaceId = "otherws"; + expect((await saveLibrary(make({ action: "create_draft", id: asset.id, instagramAccountId: "otheraccount", idempotencyKey: "foreign-copy" }))).status).toBe(404); + }); + it("returns only recorded sanitized revisions within the owning workspace", async () => { + const { payload } = await create(complete); + await state.db.campaignRevision.create({ data: { workspaceId: "ws", automationId: payload.data.id, + actorId: "owner", snapshot: { version: 2, name: "Recorded", accessToken: "private-token", recipientId: "private-recipient", + trackedLinks: [{ destinationUrl: "https://example.test/recorded", position: 0, slug: "private-slug" }] } } }); + const response = await editorHistory(new NextRequest(`http://localhost/api/automations/history?id=${payload.data.id}`)); + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + const text = JSON.stringify(await response.json()); + for (const value of ["private-token", "private-recipient", "private-slug"]) expect(text).not.toContain(value); + state.workspaceId = "otherws"; + expect((await editorHistory(new NextRequest(`http://localhost/api/automations/history?id=${payload.data.id}`))).status).toBe(404); + state.authorized = false; + expect((await editorHistory(new NextRequest("http://localhost/api/automations/history?id=test"))).status).toBe(401); + }); +}); diff --git a/__tests__/campaign-selection.test.ts b/__tests__/campaign-selection.test.ts new file mode 100644 index 000000000..0d7132dd5 --- /dev/null +++ b/__tests__/campaign-selection.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { campaignActivationErrors, campaignCanSend, selectAutomation, type CampaignRule } from "@/lib/campaigns/selection"; +import { assertNextReelAvailable, resolveLifecycle } from "@/lib/campaigns/mutations"; +import type { Prisma } from "@/app/generated/prisma/client"; + +function campaign(overrides: Partial = {}): CampaignRule { + return { id: "campaign", lifecycle: "ACTIVE", isActive: true, createdAt: new Date("2026-01-01"), keywords: ["link"], matchAnyWord: false, wholeWordMatch: true, matchAnyPost: true, dmTriggerEnabled: true, ...overrides }; +} +const comment = { kind: "comment" as const, text: "LINK please", mediaIds: ["post"] }; +describe("deterministic campaign selection", () => { + it("prefers a specific post over an older global even with higher priority", () => { + const global = campaign({ id: "global", priority: 100 }); + const specific = campaign({ id: "specific", matchAnyPost: false, postId: "post", priority: -100 }); + expect(selectAutomation([global, specific], comment).winner?.id).toBe("specific"); + }); + it("uses priority then created/id; surfaces semantic ties independent of order", () => { + const a = campaign({ id: "a", priority: 10 }); + const b = campaign({ id: "b", priority: 10 }); + const c = campaign({ id: "c", priority: 9 }); + expect(selectAutomation([c, b, a], comment).winner?.id).toBe("a"); + expect(selectAutomation([a, b, c], comment).conflicts.map((c) => c.id)).toEqual(["b"]); + }); + it("never sends drafts, archives, paused or pending next reels", () => { + for (const lifecycle of ["DRAFT", "ARCHIVED", "PAUSED"]) expect(campaignCanSend(campaign({ lifecycle }))).toBe(false); + expect(selectAutomation([campaign({ pendingNextReel: true })], comment).winner).toBeNull(); + expect(selectAutomation([campaign({ isActive: false })], comment).winner).toBeNull(); + }); + it("does not match a specific campaign to a different post", () => { + expect(selectAutomation([campaign({ matchAnyPost: false, postId: "other" })], comment).winner).toBeNull(); + }); + it("uses provider-normalized whole word rules for exclusions", () => { + expect(selectAutomation([campaign({ excludedKeywords: ["nein"] })], { ...comment, text: "LINK aber nein" }).winner).toBeNull(); + expect(selectAutomation([campaign({ excludedKeywords: ["no"] })], { ...comment, text: "link november" }).winner).not.toBeNull(); + expect(selectAutomation([campaign({ excludedKeywords: ["no"], wholeWordMatch: false })], { ...comment, text: "link november" }).winner).toBeNull(); + }); + it("has separate DM ranking without post specificity; matches only DM-enabled campaigns", () => { + const specific = campaign({ id: "specific", matchAnyPost: false, postId: "post", priority: 0 }); + const global = campaign({ id: "global", priority: 1 }); + expect(selectAutomation([specific, global], { kind: "dm", text: "link" }).winner?.id).toBe("global"); + expect(selectAutomation([campaign({ dmTriggerEnabled: false })], { kind: "dm", text: "link" }).winner).toBeNull(); + }); + it("retains matched keywords and ignores empty any-word input", () => { + expect(selectAutomation([campaign()], comment).matchedKeyword).toBe("link"); + expect(selectAutomation([campaign({ matchAnyWord: true })], { ...comment, text: " " }).winner).toBeNull(); + }); + it("supports legacy rows without lifecycle", () => { + expect(campaignCanSend(campaign({ lifecycle: undefined }))).toBe(true); + expect(resolveLifecycle({}, { lifecycle: "PAUSED", isActive: false })).toBe("PAUSED"); + expect(resolveLifecycle({ isActive: false }, { lifecycle: "ACTIVE", isActive: true })).toBe("PAUSED"); + expect(resolveLifecycle({ lifecycle: "ARCHIVED", isActive: true })).toBe("ARCHIVED"); + }); +}); +describe("activation safety", () => { + it("rejects incomplete content and accepts complete final content", () => { + expect(campaignActivationErrors({ keywords: [], matchAnyWord: false, dmMessage: "" })).toHaveLength(3); + expect(campaignActivationErrors({ postId: "post", keywords: ["link"], matchAnyWord: false, dmMessage: "resource" })).toEqual([]); + expect(campaignActivationErrors({ matchAnyPost: true, pendingNextReel: true, keywords: ["link"], matchAnyWord: false, dmMessage: "resource" })).toContain("Choose exactly one post scope."); + }); + it("locks the account before checking another armed campaign", async () => { + const execute = vi.fn().mockResolvedValue(1); + const findFirst = vi.fn().mockResolvedValue({ name: "Other reel" }); + const tx = { $executeRaw: execute, automation: { findFirst } } as unknown as Prisma.TransactionClient; + await expect(assertNextReelAvailable(tx, "account", true, "campaign")).rejects.toThrow("Other reel"); + expect(execute.mock.calls[0][1]).toBe("next-reel:account"); + expect(execute.mock.invocationCallOrder[0]).toBeLessThan(findFirst.mock.invocationCallOrder[0]); + expect(findFirst.mock.calls[0][0].where).toMatchObject({ lifecycle: "ACTIVE", isActive: true, pendingNextReel: true, id: { not: "campaign" } }); + }); + it("always locks but does not reject saving an unarmed draft", async () => { + const execute = vi.fn().mockResolvedValue(1); + const findFirst = vi.fn(); + await assertNextReelAvailable({ $executeRaw: execute, automation: { findFirst } } as unknown as Prisma.TransactionClient, "account", false); + expect(execute).toHaveBeenCalledOnce(); + expect(findFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/campaign-simulation.test.ts b/__tests__/campaign-simulation.test.ts new file mode 100644 index 000000000..666e5444e --- /dev/null +++ b/__tests__/campaign-simulation.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +vi.mock("@/lib/auth", () => ({ getCurrentWorkspaceId: async () => "test-workspace" })); +vi.mock("@/lib/db/client", () => ({ prisma: {} })); +import { POST } from "@/app/api/automations/validate/route"; + +describe("simulation body validation", () => { + it("rejects malformed JSON without an unhandled server exception", async () => { + const response = await POST(new NextRequest("http://localhost/api/automations/validate", { + method: "POST", headers: { "Content-Type": "application/json" }, body: "{", + })); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ success: false, error: "Invalid JSON" }); + }); + it("bounds streamed bodies independently of content-length", async () => { + const response = await POST(new NextRequest("http://localhost/api/automations/validate", { + method: "POST", headers: { "Content-Type": "application/json", "Content-Length": "1" }, body: JSON.stringify({ text: "a".repeat(70_000) }), + })); + expect(response.status).toBe(413); + }); + it("requires a JSON content type", async () => { + const response = await POST(new NextRequest("http://localhost/api/automations/validate", { method: "POST", body: "{}" })); + expect(response.status).toBe(415); + }); + it("rejects foreign browser origins before accessing campaign data", async () => { + const response = await POST(new NextRequest("http://localhost/api/automations/validate", { + method: "POST", headers: { "Content-Type": "application/json", Origin: "https://foreign.example.test" }, body: "{}", + })); + expect(response.status).toBe(403); + }); +}); diff --git a/__tests__/comment-delivery.test.ts b/__tests__/comment-delivery.test.ts index 0bb95b939..262acb83d 100644 --- a/__tests__/comment-delivery.test.ts +++ b/__tests__/comment-delivery.test.ts @@ -15,6 +15,8 @@ vi.mock("@/lib/db/client", () => ({ }, })); import { claimCommentDelivery } from "../lib/queue/comment-delivery"; +import { selectTriggerWinner } from "../lib/queue/trigger-selection"; +import { scheduleFollowUp } from "../lib/queue/follow-up-delivery"; const databaseUrl = process.env.TEST_DATABASE_URL; const schema = `delivery_claims_${randomBytes(4).toString("hex")}`; @@ -127,6 +129,84 @@ describe.skipIf(!databaseUrl)("durable comment delivery on Postgres", () => { .attempts, ).toBe(3); }); + it("atomically creates one stage event with the persistent send claim", async () => { + await seed("stage-claim"); + const delivery = { workspaceId: "workspace", instagramAccountId: "account", version: 3, + stage: "REVEAL", recipientId: "user_ig", message: "Frozen message" }; + const results = await Promise.all(Array.from({ length: 8 }, () => + claimCommentDelivery("automation", "stage-claim", "dm", delivery))); + expect(results.filter(Boolean)).toHaveLength(1); + const events = await state.db.deliveryEvent.findMany({ where: { stage: "REVEAL" } }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ status: "CLAIMED", attempts: 1, campaignVersion: 3, message: "Frozen message" }); + expect(await claimCommentDelivery("automation", "stage-claim", "dm", delivery)).toBe(false); + }); + it("a paused campaign cannot claim a new durable stage", async () => { + await seed("paused-stage"); + await state.db.automation.update({ where: { id: "automation" }, data: { isActive: false, lifecycle: "PAUSED" } }); + try { + expect(await claimCommentDelivery("automation", "paused-stage", "dm", { + workspaceId: "workspace", instagramAccountId: "account", version: 1, + stage: "OPENING_DM", recipientId: "user_ig", message: "Never send" })).toBe(false); + expect(await state.db.deliveryEvent.count({ where: { stage: "OPENING_DM" } })).toBe(0); + } finally { + await state.db.automation.update({ where: { id: "automation" }, data: { isActive: true, lifecycle: "ACTIVE" } }); + } + }); + it("freezes a trigger winner and content across concurrent jobs and edits without falling through", async () => { + await state.db.automation.update({ where: { id: "automation" }, + data: { matchAnyPost: true, dmMessage: "Original delivery", priority: 10 } }); + await state.db.automation.create({ data: { id: "lower-priority", workspaceId: "workspace", + instagramAccountId: "account", name: "Lower priority", keywords: ["AI"], + dmMessage: "Never fall through", matchAnyPost: true, priority: 0 } }); + const candidates = () => state.db.automation.findMany({ + where: { id: { in: ["automation", "lower-priority"] } }, include: { instagramAccount: true, trackedLinks: true } }); + const input = { kind: "comment" as const, text: "AI", inputId: "frozen-trigger", instagramId: "test_ig" }; + try { + const campaigns = await candidates(); + const winners = await Promise.all(Array.from({ length: 8 }, () => selectTriggerWinner(campaigns, input))); + expect(winners.every((winner) => winner?.id === "automation")).toBe(true); + const reservation = await state.db.deliveryEvent.findFirstOrThrow({ where: { stage: "TRIGGER" } }); + expect(await state.db.deliveryEvent.count({ where: { stage: "TRIGGER" } })).toBe(1); + expect(JSON.stringify(reservation.payload)).not.toContain("local-test-only"); + await state.db.automation.update({ where: { id: "automation" }, data: { dmMessage: "Edited delivery", version: 2 } }); + expect(await selectTriggerWinner(await candidates(), input)).toMatchObject({ id: "automation", version: 1, dmMessage: "Original delivery" }); + await state.db.automation.update({ where: { id: "automation" }, data: { isActive: false, lifecycle: "PAUSED" } }); + expect(await selectTriggerWinner(await candidates(), input)).toBeNull(); + } finally { + await state.db.automation.update({ where: { id: "automation" }, data: { isActive: true, lifecycle: "ACTIVE" } }); + } + }); + it("persists one immutable follow-up across parallel result transactions", async () => { + const campaign = await state.db.automation.update({ where: { id: "automation" }, + data: { followUpEnabled: true, followUpMessage: "Thanks {username}", followUpDelayMinutes: 5 }, + include: { instagramAccount: true } }); + const interactionAt = new Date(); + await Promise.all(Array.from({ length: 8 }, () => state.db.$transaction((tx) => + scheduleFollowUp(campaign, { userId: "user_ig", commenterName: "Example", sourceId: "parallel-tap", interactionAt }, tx)))); + expect(await state.db.deliveryEvent.count({ where: { stage: "FOLLOW_UP" } })).toBe(1); + const event = await state.db.deliveryEvent.findFirstOrThrow({ where: { stage: "FOLLOW_UP" } }); + expect(event).toMatchObject({ status: "PENDING", message: "Thanks Example", recipientId: "user_ig" }); + await state.db.$transaction((tx) => scheduleFollowUp({ ...campaign, followUpMessage: "Edited text" }, + { userId: "user_ig", commenterName: "Example", sourceId: "parallel-tap", interactionAt }, tx)); + expect((await state.db.deliveryEvent.findUniqueOrThrow({ where: { id: event.id } })).message).toBe("Thanks Example"); + }); + it("parallel inbound-DM log creation still permits only one pre-send claim", async () => { + const results = await Promise.all(Array.from({ length: 16 }, async () => { + await state.db.dmLog.upsert({ + where: { automationId_commentId: { automationId: "automation", commentId: "dm:parallel-message" } }, + create: { workspaceId: "workspace", instagramAccountId: "account", automationId: "automation", + commenterId: "user_ig", commentId: "dm:parallel-message", commentText: "AI", status: "PENDING" }, + update: { commentId: "dm:parallel-message" }, + }); + return claimCommentDelivery("automation", "dm:parallel-message", "dm", { + workspaceId: "workspace", instagramAccountId: "account", version: 2, + stage: "REVEAL", recipientId: "user_ig", message: "Frozen message" }); + })); + expect(results.filter(Boolean)).toHaveLength(1); + expect(await state.db.dmLog.count({ where: { commentId: "dm:parallel-message" } })).toBe(1); + expect((await state.db.dmLog.findFirstOrThrow({ where: { commentId: "dm:parallel-message" } })).attempts).toBe(1); + }); it("claims public replies independently and never repeats a confirmed send", async () => { const row = await seed("public"); expect(await claimCommentDelivery("automation", "public", "public")).toBe( diff --git a/__tests__/delivery-history.test.ts b/__tests__/delivery-history.test.ts new file mode 100644 index 000000000..b25f5d797 --- /dev/null +++ b/__tests__/delivery-history.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +const mocks = vi.hoisted(() => ({ workspace: vi.fn(), prisma: { + automation: { findFirst: vi.fn() }, deliveryEvent: { findMany: vi.fn() }, + campaignRevision: { findMany: vi.fn() }, +} })); +vi.mock("@/lib/auth", () => ({ getCurrentWorkspaceId: mocks.workspace })); +vi.mock("@/lib/db/client", () => ({ prisma: mocks.prisma })); +import { GET } from "@/app/api/campaigns/history/route"; +import { deliveryHistorySelect, safeDeliveryHistory } from "@/lib/queue/delivery-history"; +const request = (id = "campaign") => new NextRequest(`https://example.test/api/campaigns/history${id ? `?id=${id}` : ""}`); +beforeEach(() => { + vi.resetAllMocks(); mocks.workspace.mockResolvedValue("workspace"); + mocks.prisma.automation.findFirst.mockResolvedValue({ id: "campaign", lifecycle: "ACTIVE", version: 3 }); + mocks.prisma.deliveryEvent.findMany.mockResolvedValue([]); + mocks.prisma.campaignRevision.findMany.mockResolvedValue([]); +}); +describe("private delivery timeline", () => { + it("requires authentication before querying campaign or delivery state", async () => { + mocks.workspace.mockResolvedValue(null); + expect((await GET(request())).status).toBe(401); + expect(mocks.prisma.automation.findFirst).not.toHaveBeenCalled(); + expect(mocks.prisma.deliveryEvent.findMany).not.toHaveBeenCalled(); + }); + it("rejects missing campaign IDs without reading events", async () => { + expect((await GET(request(""))).status).toBe(400); + expect(mocks.prisma.deliveryEvent.findMany).not.toHaveBeenCalled(); + }); + it("does not expose a campaign outside the current workspace", async () => { + mocks.prisma.automation.findFirst.mockResolvedValue(null); + expect((await GET(request("foreign-campaign"))).status).toBe(404); + expect(mocks.prisma.automation.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: "foreign-campaign", workspaceId: "workspace" } })); + expect(mocks.prisma.deliveryEvent.findMany).not.toHaveBeenCalled(); + }); + it("uses workspace-bound whitelists, stable limits and credential-redacted errors", async () => { + mocks.prisma.deliveryEvent.findMany.mockResolvedValue([{ id: "event", stage: "REVEAL", status: "UNCONFIRMED", campaignVersion: 2, + message: "Saved content", error: "https://example.test?access_token=private-value", recipientId: "private-recipient", payload: { accessToken: "private-token" }, operationKey: "private-operation" }]); + mocks.prisma.campaignRevision.findMany.mockResolvedValue([{ id: "revision", actorId: "system:test", createdAt: new Date(), snapshot: { version: 2, accessToken: "private-token", trackedLinks: [] } }]); + const response = await GET(request()); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + const body = await response.json(); + expect(body.data).toMatchObject({ campaign: { version: 3 }, delivery: [{ campaignVersion: 2, status: "UNCONFIRMED" }], limits: { delivery: 100, revisions: 50 } }); + expect(mocks.prisma.deliveryEvent.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { workspaceId: "workspace", automationId: "campaign" }, select: deliveryHistorySelect, take: 100 })); + for (const secret of ["private-value", "private-recipient", "private-token", "private-operation"]) expect(JSON.stringify(body)).not.toContain(secret); + expect(body.data.delivery[0].error).toContain("[redacted]"); + expect(body.data).not.toHaveProperty("conversions"); + }); + it("does not copy any unlisted recipient, provider or queue fields", () => { + expect(safeDeliveryHistory({ id: "event", recipientId: "private", payload: { token: "private" }, operationKey: "private", error: "Bearer credential" })).not.toHaveProperty("recipientId"); + expect(JSON.stringify(safeDeliveryHistory({ error: "Bearer credential", payload: "private" }))).not.toContain("credential"); + }); +}); diff --git a/__tests__/dm-worker.test.ts b/__tests__/dm-worker.test.ts index a9d4ffb56..dd53f8a6c 100644 --- a/__tests__/dm-worker.test.ts +++ b/__tests__/dm-worker.test.ts @@ -18,6 +18,8 @@ const { mockReleaseWorkspaceDMReservation, } = vi.hoisted(() => ({ mockPrisma: { + $transaction: vi.fn(), + deliveryEvent: { upsert: vi.fn(), findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, zernioConnection: { findUnique: vi.fn() }, postbackDelivery: { create: vi.fn(), delete: vi.fn() }, automation: { @@ -25,6 +27,7 @@ const { findFirst: vi.fn(), }, dmLog: { + findMany: vi.fn(), findUnique: vi.fn(), findFirst: vi.fn(), upsert: vi.fn(), @@ -145,6 +148,8 @@ const usagePeriodStart = new Date("2026-05-01T00:00:00.000Z"); const mockAutomation = { id: "auto_789", + createdAt: new Date("2026-05-01"), + version: 1, workspaceId: "workspace_123", instagramAccountId: "ig_account_row_1", postId: "media_101", @@ -221,6 +226,10 @@ function createMockPostbackJob( beforeEach(() => { vi.clearAllMocks(); + mockPrisma.$transaction.mockImplementation(async (fn: (tx: typeof mockPrisma) => unknown) => fn(mockPrisma)); + mockPrisma.dmLog.findMany.mockResolvedValue([]); + mockPrisma.deliveryEvent.upsert.mockReset().mockImplementation(async (args: { create: Record }) => ({ id: "event_1", ...args.create })); + mockPrisma.deliveryEvent.updateMany.mockResolvedValue({ count: 1 }); mockPrisma.postbackDelivery.create.mockReset().mockResolvedValue({}); mockPrisma.postbackDelivery.delete.mockReset().mockResolvedValue({}); @@ -234,7 +243,7 @@ beforeEach(() => { // duplicate of an already-answered one. mockPrisma.dmLog.findFirst.mockImplementation( async (args: { where?: { status?: string } } = {}) => - args.where?.status === "SENT" ? null : { commenterName: "commenter_user" } + args.where?.status === "SENT" || args.where && "OR" in args.where ? null : { commenterName: "commenter_user" } ); mockPrisma.dmLog.upsert.mockResolvedValue({}); mockPrisma.dmLog.update.mockReset().mockResolvedValue({}); @@ -244,7 +253,7 @@ beforeEach(() => { }); mockPrisma.operationalEvent.create.mockResolvedValue({}); mockDecryptToken.mockReturnValue("decrypted_token"); - mockMatchKeywords.mockReturnValue({ matched: true, matchedKeyword: "LINK" }); + mockMatchKeywords.mockReset().mockImplementation((_text: string, keywords: string[]) => ({ matched: keywords.length > 0, matchedKeyword: keywords.length ? "LINK" : null })); mockReserveWorkspaceDMSend.mockResolvedValue({ allowed: true, reserved: true, @@ -896,7 +905,7 @@ describe("DM Worker — one private reply per comment", () => { it("should skip a campaign when another already used the comment's private reply", async () => { mockPrisma.dmLog.findFirst.mockImplementation( async (args: { where?: { status?: string } } = {}) => - args.where?.status === "SENT" + args.where?.status === "SENT" || args.where && "OR" in args.where ? { automation: { name: "openreply 1" } } : { commenterName: "commenter_user" } ); @@ -1012,6 +1021,13 @@ describe("DM Worker — DM keyword trigger", () => { mockPrisma.automation.findMany.mockResolvedValue([dmTriggerAutomation]); }); + it("releases quota when persisting the pre-send log fails", async () => { + mockPrisma.dmLog.upsert.mockRejectedValueOnce(new Error("Pre-send database failure")); + await expect(getProcessor()(createMockMessageJob())).rejects.toThrow("Pre-send database failure"); + expect(mockReleaseWorkspaceDMReservation).toHaveBeenCalledWith("workspace_123", usagePeriodStart); + expect(mockSendDirectMessage).not.toHaveBeenCalled(); + }); + it("should reply to a DM whose text matches the campaign keywords", async () => { const processor = getProcessor(); await processor(createMockMessageJob()); @@ -1153,7 +1169,7 @@ describe("DM Worker — DM keyword trigger", () => { ); }); - it("should release the usage reservation and rethrow when the send fails", async () => { + it("retains the usage reservation and stops retrying an ambiguous DM send", async () => { mockSendDirectMessage.mockRejectedValue(new Error("Meta is down")); const processor = getProcessor(); @@ -1161,13 +1177,10 @@ describe("DM Worker — DM keyword trigger", () => { "Meta is down" ); - expect(mockReleaseWorkspaceDMReservation).toHaveBeenCalledWith( - "workspace_123", - usagePeriodStart - ); + expect(mockReleaseWorkspaceDMReservation).not.toHaveBeenCalled(); expect(mockPrisma.dmLog.upsert).toHaveBeenCalledWith( expect.objectContaining({ - create: expect.objectContaining({ status: "FAILED" }), + create: expect.objectContaining({ status: "FAILED", dmDeliveryUnconfirmed: true }), }) ); }); @@ -1682,6 +1695,9 @@ describe("ambiguous Meta sends and durable comment claims", () => { await expect(getProcessor()(createMockJob())).rejects.toMatchObject({ name: "UnrecoverableError", message: expect.stringContaining("Connection reset after send") }); expect(mockSendPrivateReply).toHaveBeenCalledTimes(1); expect(mockPrisma.dmLog.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ dmDeliveryUnconfirmed: true }) })); + const failedEvent = mockPrisma.deliveryEvent.upsert.mock.calls.find(([args]) => args.update.status === "UNCONFIRMED"); + expect(failedEvent).toBeDefined(); + expect(failedEvent![0].update).not.toHaveProperty("message"); }); it("blocks the historical code 1 failures before any further network send", async () => { @@ -1743,6 +1759,35 @@ it("deduplicates a redelivered Meta button tap after queue retention expires", a expect(mockSendDirectMessage).toHaveBeenCalledTimes(1); }); +it("keeps the postback attempt message and version consistent after a confirmed rejection and campaign edit", async () => { + const claims = new Set(); + const events = new Map>(); + mockPrisma.postbackDelivery.create.mockImplementation(async ({ data }: { data: { id: string } }) => { + if (claims.has(data.id)) throw { code: "P2002" }; + claims.add(data.id); return data; + }); + mockPrisma.postbackDelivery.delete.mockImplementation(async ({ where }: { where: { id: string } }) => claims.delete(where.id)); + mockPrisma.deliveryEvent.upsert.mockImplementation(async (args: { + where: { operationKey: string }; create: Record; update: Record; + }) => { + const previous = events.get(args.where.operationKey); + const next = previous ? { ...previous, ...args.update } : { id: args.where.operationKey, ...args.create }; + events.set(args.where.operationKey, next); return next; + }); + const data = { instagramAccountId: "ig_456", userId: "commenter_999", payload: "reveal:auto_789", mid: "retry-same-tap" }; + mockPrisma.automation.findFirst.mockResolvedValue({ ...mockAutomation, version: 1, dmMessage: "First message" }); + mockSendDirectMessage.mockRejectedValueOnce(new MetaApiError(10, undefined, undefined, "Confirmed rejection")); + const process = getProcessor(); + await expect(process(createMockPostbackJob(data))).rejects.toThrow("Confirmed rejection"); + expect([...events.values()][0]).toMatchObject({ status: "FAILED", campaignVersion: 1, message: "First message" }); + mockPrisma.automation.findFirst.mockResolvedValue({ ...mockAutomation, version: 2, dmMessage: "Second message" }); + await process(createMockPostbackJob(data)); + expect(mockSendDirectMessage).toHaveBeenLastCalledWith("decrypted_token", "ig_456", "commenter_999", "Second message"); + expect([...events.values()][0]).toMatchObject({ status: "SENT", campaignVersion: 2, message: "Second message" }); + await process({ ...createMockPostbackJob(data), id: "redelivery-after-success" }); + expect(mockSendDirectMessage).toHaveBeenCalledTimes(2); +}); + it("retains the public reply claim if sending succeeded but its log write failed", async () => { const { sendCommentReply } = await import("@/lib/meta/client"); vi.mocked(sendCommentReply).mockResolvedValue({ id: "public-reply" }); @@ -1756,3 +1801,75 @@ it("retains the public reply claim if sending succeeded but its log write failed expect(sendCommentReply).toHaveBeenCalledTimes(1); expect(mockSendPrivateReply).toHaveBeenCalledTimes(1); }); + +describe("persistent single-winner campaign execution", () => { + function persistReservations() { + const events = new Map>(); + mockPrisma.deliveryEvent.upsert.mockImplementation(async (args: { + where: { operationKey: string }; create: Record; update: Record; + }) => { + const row = events.get(args.where.operationKey); + const next = row ? { ...row, ...args.update } : { id: args.where.operationKey, ...args.create }; + events.set(args.where.operationKey, next); + return next; + }); + return events; + } + + it("sends one public and private reply from the specific campaign, not the older global campaign", async () => { + const { sendCommentReply } = await import("@/lib/meta/client"); + mockPrisma.automation.findMany.mockResolvedValue([ + { ...mockAutomation, id: "global", matchAnyPost: true, createdAt: new Date("2025-01-01"), publicReplyEnabled: true, publicReplyMessages: ["global"] }, + { ...mockAutomation, publicReplyEnabled: true, publicReplyMessages: ["specific"] }, + ]); + await getProcessor()(createMockJob()); + expect(sendCommentReply).toHaveBeenCalledTimes(1); + expect(mockSendPrivateReply).toHaveBeenCalledTimes(1); + expect(mockPrisma.deliveryEvent.upsert).toHaveBeenCalledWith(expect.objectContaining({ create: expect.objectContaining({ automationId: "auto_789", stage: "PUBLIC_REPLY", status: "SENT" }) })); + }); + + it("pins the same campaign and message snapshot after priorities and texts change on retry", async () => { + persistReservations(); + mockSendPrivateReply.mockRejectedValueOnce(new MetaApiError(100, undefined, undefined, "Explicit rejection")); + const processor = getProcessor(); + await expect(processor(createMockJob())).rejects.toThrow("Explicit rejection"); + mockPrisma.automation.findMany.mockResolvedValue([ + { ...mockAutomation, dmMessage: "Changed original message", version: 2 }, + { ...mockAutomation, id: "new-priority", priority: 99, dmMessage: "Wrong resource" }, + ]); + await processor({ ...createMockJob(), id: "retry" }); + expect(mockSendPrivateReply).toHaveBeenLastCalledWith("decrypted_token", "ig_456", "comment_555", "Hey commenter_user! Here is the link: https://example.com"); + }); + + it("never switches to a new winner after another campaign's ambiguous legacy delivery", async () => { + mockPrisma.dmLog.findMany.mockResolvedValue([{ automationId: "previous", status: "FAILED", errorMessage: "MetaApiError 1: Unknown", dmDeliveryUnconfirmed: false }]); + await getProcessor()(createMockJob()); + expect(mockSendPrivateReply).not.toHaveBeenCalled(); + expect(mockPrisma.deliveryEvent.upsert).not.toHaveBeenCalled(); + }); + + it("uses a durable claim for overlapping inbound DM jobs and sends only the priority winner", async () => { + persistReservations(); + mockPrisma.automation.findMany.mockResolvedValue([ + { ...mockAutomation, dmTriggerEnabled: true }, + { ...mockAutomation, id: "priority-winner", priority: 10, dmTriggerEnabled: true, dmMessage: "Priority resource" }, + ]); + let claimed = false; + mockPrisma.dmLog.updateMany.mockImplementation(async () => { + if (claimed) return { count: 0 }; claimed = true; return { count: 1 }; + }); + const processor = getProcessor(); + const job = { name: "process-message", id: "message-one", attemptsMade: 0, data: { + instagramAccountId: "ig_456", messageId: "incoming", messageText: "LINK", senderId: "commenter_999", + } }; + await Promise.all([processor(job), processor({ ...job, id: "message-redelivery" })]); + expect(mockSendDirectMessage).toHaveBeenCalledTimes(1); + expect(mockSendDirectMessage).toHaveBeenCalledWith("decrypted_token", "ig_456", "commenter_999", "Priority resource"); + }); + + it("drafts and archived campaigns never deliver even with isActive stale true", async () => { + mockPrisma.automation.findMany.mockResolvedValue([{ ...mockAutomation, lifecycle: "DRAFT" }, { ...mockAutomation, id: "archive", lifecycle: "ARCHIVED" }]); + await getProcessor()(createMockJob()); + expect(mockSendPrivateReply).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/follow-up-delivery.test.ts b/__tests__/follow-up-delivery.test.ts new file mode 100644 index 000000000..ccf36641f --- /dev/null +++ b/__tests__/follow-up-delivery.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +const mocks = vi.hoisted(() => ({ + prisma: { automation: { findFirst: vi.fn() }, deliveryEvent: { + upsert: vi.fn(), findUnique: vi.fn(), findMany: vi.fn(), update: vi.fn(), updateMany: vi.fn(), + } }, send: vi.fn(), queue: vi.fn(), context: vi.fn(), +})); +vi.mock("@/lib/db/client", () => ({ prisma: mocks.prisma })); +vi.mock("@/lib/instagram/provider", () => ({ + createInstagramContext: mocks.context, hasInstagramCredentials: () => true, sendDirectMessage: mocks.send, +})); +vi.mock("@/lib/queue/client", () => ({ FOLLOWUP_JOB_NAME: "process-followup", getDMQueue: () => ({ add: mocks.queue }) })); +import { scheduleFollowUp, deliverFollowUp, recoverPendingFollowUps } from "@/lib/queue/follow-up-delivery"; +import { MetaApiError } from "@/lib/meta/client"; + +const now = new Date("2026-10-03T09:00:00Z"); +const campaign = { + id: "campaign", workspaceId: "workspace", instagramAccountId: "connection", version: 4, + isActive: true, lifecycle: "ACTIVE", followUpEnabled: true, + followUpMessage: "Thanks {username}!", followUpDelayMinutes: 5, + instagramAccount: { instagramId: "instagram", accessToken: "secret" }, +}; +const data = { automationId: "campaign", instagramAccountId: "instagram", accountConnectionId: "connection", userId: "recipient", deliveryEventId: "event" }; +let stored: Record; +beforeEach(() => { + vi.useFakeTimers(); vi.setSystemTime(now); vi.resetAllMocks(); + stored = {}; + mocks.prisma.automation.findFirst.mockResolvedValue(campaign); + mocks.context.mockResolvedValue({ provider: "META" }); + mocks.prisma.deliveryEvent.upsert.mockImplementation(async ({ create }: { create: Record }) => { + if (!stored.id) stored = { id: "event", ...create }; return stored; + }); + mocks.prisma.deliveryEvent.findUnique.mockImplementation(async () => stored); + mocks.prisma.deliveryEvent.updateMany.mockImplementation(async ({ where, data }: { where: { status: string }; data: Record }) => { + if (stored.status !== where.status) return { count: 0 }; + stored = { ...stored, ...data }; return { count: 1 }; + }); + mocks.prisma.deliveryEvent.update.mockImplementation(async ({ data }: { data: Record }) => { stored = { ...stored, ...data }; return stored; }); +}); +afterEach(() => vi.useRealTimers()); +async function prepare() { + await scheduleFollowUp(campaign, { userId: "recipient", commenterName: "Maya", sourceId: "tap", interactionAt: now }); + vi.setSystemTime(new Date(now.getTime() + 5 * 60_000)); +} +describe("durable immutable follow-up delivery", () => { + it("snapshots rendered text and version and ignores subsequent text edits", async () => { + await prepare(); + mocks.prisma.automation.findFirst.mockResolvedValue({ ...campaign, followUpMessage: "Changed", version: 5 }); + await deliverFollowUp(data, "job"); + expect(mocks.send).toHaveBeenCalledWith(expect.objectContaining({ message: "Thanks Maya!" })); + expect(stored).toMatchObject({ status: "SENT", campaignVersion: 4 }); + expect(JSON.stringify(stored)).not.toContain("secret"); + }); + it("only one parallel worker can claim the same scheduled message", async () => { + await prepare(); + await Promise.all([deliverFollowUp(data, "one"), deliverFollowUp(data, "two")]); + expect(mocks.send).toHaveBeenCalledTimes(1); + }); + it("marks a network response ambiguous and never resends it", async () => { + await prepare(); mocks.send.mockRejectedValue(new Error("Lost response")); + await deliverFollowUp(data, "job"); await deliverFollowUp(data, "retry"); + expect(stored).toMatchObject({ status: "UNCONFIRMED" }); + expect(mocks.send).toHaveBeenCalledTimes(1); + }); + it("records an explicit Meta rejection as FAILED", async () => { + await prepare(); mocks.send.mockRejectedValue(new MetaApiError(10, undefined, undefined, "Outside window")); + await deliverFollowUp(data, "job"); + expect(stored.status).toBe("FAILED"); + }); + it("paused and archived campaigns stop queued follow-ups", async () => { + await prepare(); mocks.prisma.automation.findFirst.mockResolvedValue({ ...campaign, lifecycle: "ARCHIVED", isActive: false }); + await deliverFollowUp(data, "job"); + expect(stored.status).toBe("SKIPPED"); expect(mocks.send).not.toHaveBeenCalled(); + }); + it("does not send beyond the real interaction window even with a previously queued event", async () => { + await prepare(); vi.setSystemTime(new Date(now.getTime() + 24 * 60 * 60_000)); + await deliverFollowUp(data, "job"); + expect(stored.status).toBe("SKIPPED"); expect(mocks.send).not.toHaveBeenCalled(); + }); + it("missing and future interaction anchors cannot create a sendable reminder", async () => { + await scheduleFollowUp(campaign, { userId: "recipient", commenterName: null, sourceId: "old", interactionAt: null }); + expect(stored.status).toBe("SKIPPED"); expect(mocks.queue).not.toHaveBeenCalled(); + stored = {}; + await scheduleFollowUp(campaign, { userId: "recipient", commenterName: null, sourceId: "future", interactionAt: new Date(now.getTime() + 60_000) }); + expect(stored.status).toBe("SKIPPED"); + }); + it("legacy queue jobs are explicitly recorded as skipped", async () => { + await deliverFollowUp({ ...data, deliveryEventId: undefined }, "legacy"); + expect(stored).toMatchObject({ status: "SKIPPED", operationKey: "legacy_followup_legacy" }); + }); + it("an interrupted claimed send is never automatically resumed", async () => { + await prepare(); stored.status = "CLAIMED"; + await deliverFollowUp(data, "retry"); expect(mocks.send).not.toHaveBeenCalled(); + }); + it("recovers durable pending events after Redis insertion failed, without long-delay starvation", async () => { + mocks.queue.mockRejectedValueOnce(new Error("Redis down")); + await prepare(); expect(stored.status).toBe("PENDING"); + mocks.prisma.deliveryEvent.findMany.mockResolvedValue([stored]); + await recoverPendingFollowUps(); + expect(mocks.queue).toHaveBeenCalledTimes(2); + expect(mocks.prisma.deliveryEvent.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ scheduledAt: { lte: expect.any(Date) } }) })); + }); + it("fails closed before external send when storing the claim fails", async () => { + await prepare(); mocks.prisma.deliveryEvent.updateMany.mockRejectedValue(new Error("DB down")); + await expect(deliverFollowUp(data, "job")).rejects.toThrow("DB down"); + expect(mocks.send).not.toHaveBeenCalled(); + }); + it("rejects a job whose connection differs from the frozen account", async () => { + await prepare(); + await deliverFollowUp({ ...data, accountConnectionId: "other-connection" }, "job"); + expect(stored.status).toBe("SKIPPED"); + expect(mocks.send).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/next-reel-attachment.test.ts b/__tests__/next-reel-attachment.test.ts new file mode 100644 index 000000000..7fcca7dc7 --- /dev/null +++ b/__tests__/next-reel-attachment.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +const mocks = vi.hoisted(() => ({ + prisma: { $transaction: vi.fn(), $executeRaw: vi.fn(), automation: { + findMany: vi.fn(), findFirst: vi.fn(), updateMany: vi.fn(), findUnique: vi.fn(), + } }, media: vi.fn(), revision: vi.fn(), +})); +vi.mock("@/lib/db/client", () => ({ prisma: mocks.prisma })); +vi.mock("@/lib/campaigns/mutations", () => ({ saveCampaignRevision: mocks.revision })); +vi.mock("@/lib/instagram/provider", () => ({ createInstagramContext: async () => ({}), hasInstagramCredentials: () => true, getUserMedia: mocks.media })); +import { attachPendingNextReels } from "@/lib/automation/attach-next-reel"; +const campaign = { + id: "campaign", instagramAccountId: "account", pendingNextReel: true, + isActive: true, lifecycle: "ACTIVE", version: 1, + createdAt: new Date("2026-10-01"), armedAt: new Date("2026-10-03T08:00:00Z"), + instagramAccount: { id: "account" }, +}; +beforeEach(() => { + vi.resetAllMocks(); + mocks.prisma.$transaction.mockImplementation(async (fn: (tx: typeof mocks.prisma) => unknown) => fn(mocks.prisma)); + mocks.prisma.automation.findMany.mockResolvedValue([campaign]); + mocks.prisma.automation.findFirst.mockResolvedValueOnce(campaign).mockResolvedValue(null); + mocks.prisma.automation.updateMany.mockResolvedValue({ count: 1 }); + mocks.prisma.automation.findUnique.mockResolvedValue({ ...campaign, version: 2 }); + mocks.media.mockResolvedValue([ + { id: "old", media_product_type: "REELS", timestamp: "2026-10-02T08:00:00Z" }, + { id: "new", media_product_type: "REELS", timestamp: "2026-10-03T09:00:00Z", permalink: "https://instagram.com/reel/new" }, + ]); +}); +describe("atomic active next-reel attachment", () => { + it("uses armedAt and saves a versioned system revision", async () => { + expect(await attachPendingNextReels()).toMatchObject({ bound: 1 }); + expect(mocks.prisma.automation.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { pendingNextReel: true, isActive: true, lifecycle: "ACTIVE" } })); + expect(mocks.prisma.automation.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ postId: "new", version: { increment: 1 } }) })); + expect(mocks.revision).toHaveBeenCalledWith(mocks.prisma, expect.objectContaining({ version: 2 }), "system:next-reel"); + }); + it("refuses to bind multiple legacy waiting campaigns to the same reel", async () => { + mocks.prisma.automation.findMany.mockResolvedValue([campaign, { ...campaign, id: "other" }]); + expect(await attachPendingNextReels()).toMatchObject({ bound: 0 }); + expect(mocks.media).not.toHaveBeenCalled(); + }); + it("rechecks pause or version edits after the external media fetch", async () => { + mocks.prisma.automation.findFirst.mockReset().mockResolvedValue(null); + expect(await attachPendingNextReels()).toMatchObject({ bound: 0 }); + expect(mocks.prisma.automation.updateMany).not.toHaveBeenCalled(); + }); + it("does not silently choose another reel if the intended one was already assigned", async () => { + mocks.prisma.automation.findFirst.mockReset().mockResolvedValueOnce(campaign).mockResolvedValueOnce({ id: "occupied" }); + expect(await attachPendingNextReels()).toMatchObject({ bound: 0 }); + expect(mocks.prisma.automation.updateMany).not.toHaveBeenCalled(); + }); + it("concurrent attachment cannot count an already-bound row as a second success", async () => { + mocks.prisma.automation.updateMany.mockResolvedValue({ count: 0 }); + expect(await attachPendingNextReels()).toMatchObject({ bound: 0 }); + expect(mocks.revision).not.toHaveBeenCalled(); + }); + it("keeps a failed Meta fetch unbound and observable", async () => { + mocks.media.mockRejectedValue(new Error("Meta unavailable")); + expect(await attachPendingNextReels()).toMatchObject({ bound: 0, failedAccounts: 1 }); + expect(mocks.prisma.$transaction).not.toHaveBeenCalled(); + }); +}); diff --git a/app/(dashboard)/campaigns/[id]/page.tsx b/app/(dashboard)/campaigns/[id]/page.tsx index b0f2a9d5b..6ca805cfd 100644 --- a/app/(dashboard)/campaigns/[id]/page.tsx +++ b/app/(dashboard)/campaigns/[id]/page.tsx @@ -13,6 +13,8 @@ import { useEffect, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import CampaignPreview, { type PreviewTab } from "@/components/campaign-preview"; +import CampaignHistory from "@/components/campaign-history"; +import { campaignLifecycle } from "@/lib/campaigns/selection"; interface Campaign { id: string; @@ -39,6 +41,7 @@ interface Campaign { publicReplyMessage: string | null; publicReplyMessages: string[]; isActive: boolean; + lifecycle?: string; instagramAccountId: string; instagramAccount: { username: string }; trackedLinks?: { @@ -55,7 +58,7 @@ interface Campaign { }; } -type Tab = "insights" | "preview"; +type Tab = "insights" | "preview" | "history"; export default function CampaignDetailPage() { const { t } = useI18n(); @@ -70,6 +73,7 @@ export default function CampaignDetailPage() { const [tab, setTab] = useState("insights"); const [previewTab, setPreviewTab] = useState("dm"); const [busy, setBusy] = useState(false); + const [actionError, setActionError] = useState(null); useEffect(() => { fetch("/api/automations", { cache: "no-store" }) @@ -115,13 +119,18 @@ export default function CampaignDetailPage() { async function toggleActive() { if (!campaign) return; setBusy(true); + setActionError(null); try { - await fetch(`/api/automations?id=${campaign.id}`, { + const response = await fetch(`/api/automations?id=${campaign.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ isActive: !campaign.isActive }), }); - setCampaign({ ...campaign, isActive: !campaign.isActive }); + const result = await response.json(); + if (!response.ok || !result.success) throw new Error(result.error || "Campaign could not be updated"); + setCampaign({ ...campaign, ...result.data }); + } catch (cause) { + setActionError(cause instanceof Error ? cause.message : "Campaign could not be updated"); } finally { setBusy(false); } @@ -165,7 +174,7 @@ export default function CampaignDetailPage() { const metrics = [ { label: t("Sends"), value: campaign.analytics.sent }, { label: t("Clicks"), value: campaign.analytics.clicks }, - { label: t("CTR"), value: `${campaign.analytics.ctr}%` }, + { label: t("Clicks / 100 sends"), value: String(campaign.analytics.ctr) }, { label: t("Failed"), value: campaign.analytics.failed }, ]; @@ -190,7 +199,7 @@ export default function CampaignDetailPage() { : "bg-zinc-500/10 text-muted" }`} > - {campaign.isActive ? t("LIVE") : t("Paused")} + {campaignLifecycle(campaign)} @@ -303,6 +312,7 @@ export default function CampaignDetailPage() { setTab("preview")}> {t("Preview")} + setTab("history")}>{t("History")}
- {campaign.isActive ? t("Stop") : t("Resume")} + {campaign.isActive ? t("Stop") : campaignLifecycle(campaign) === "PAUSED" ? t("Resume") : t("Go Live")}
+ {actionError &&

{actionError}

} + {tab === "history" && } + {tab === "insights" && (
{metrics.map((m) => ( diff --git a/app/(dashboard)/campaigns/import/page.tsx b/app/(dashboard)/campaigns/import/page.tsx index 1e116c5cf..31d82facd 100644 --- a/app/(dashboard)/campaigns/import/page.tsx +++ b/app/(dashboard)/campaigns/import/page.tsx @@ -99,6 +99,7 @@ export default function ImportCampaignsPage() { opening_dm_button{t(". Keywords go in one cell, separated by commas. Use")}{" "} {"{link}"} {t("in the message to insert the tracked link.")}

+

Imports werden immer als Entwürfe gespeichert. Nach dem Import kannst du jede Kampagne prüfen und ausdrücklich über „Go Live“ aktivieren; ein Aktivstatus aus der Quelldatei wird nicht übernommen.

{error && ( diff --git a/app/(dashboard)/campaigns/page.tsx b/app/(dashboard)/campaigns/page.tsx index 1c764f442..5f2b1ca03 100644 --- a/app/(dashboard)/campaigns/page.tsx +++ b/app/(dashboard)/campaigns/page.tsx @@ -12,6 +12,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import AccountSelect, { type AccountOption } from "@/components/account-select"; import { readCache, writeCache } from "@/lib/client-cache"; +import { campaignLifecycle } from "@/lib/campaigns/selection"; interface Campaign { id: string; @@ -34,6 +35,7 @@ interface Campaign { followPromptMessage: string | null; followPromptButtonLabel: string | null; isActive: boolean; + lifecycle?: string; wholeWordMatch: boolean; instagramAccountId: string; instagramAccount: { @@ -83,7 +85,8 @@ export default function CampaignsPage() { const [menuOpenId, setMenuOpenId] = useState(null); const [copiedId, setCopiedId] = useState(null); const [search, setSearch] = useState(""); - const [statusFilter, setStatusFilter] = useState<"all" | "active" | "paused">( + const [actionError, setActionError] = useState(null); + const [statusFilter, setStatusFilter] = useState<"all" | "active" | "paused" | "draft" | "archived">( "all" ); @@ -201,14 +204,15 @@ export default function CampaignsPage() { async function toggleActive(id: string, isActive: boolean) { try { - await fetch(`/api/automations?id=${id}`, { + setActionError(null); + const response = await fetch(`/api/automations?id=${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ isActive: !isActive }), }); - setAutomations((prev) => - prev.map((a) => (a.id === id ? { ...a, isActive: !isActive } : a)) - ); + const data = await response.json(); + if (!data.success) return setActionError(data.error || "Campaign could not be updated"); + void fetchAutomations(); } catch (err) { console.error("Failed to toggle:", err); } @@ -229,11 +233,13 @@ export default function CampaignsPage() { } } - async function deleteAutomation(id: string) { - if (!confirm(t("Delete this campaign? This cannot be undone."))) return; + async function archiveAutomation(id: string) { + if (!confirm(t("Archive this campaign? Sending stops; links and reports remain available."))) return; try { - await fetch(`/api/automations?id=${id}`, { method: "DELETE" }); - setAutomations((prev) => prev.filter((a) => a.id !== id)); + const response = await fetch(`/api/automations?id=${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ lifecycle: "ARCHIVED", isActive: false }) }); + const data = await response.json(); + if (!data.success) return setActionError(data.error || "Archive failed"); + void fetchAutomations(); } catch (err) { console.error("Failed to delete:", err); } @@ -268,8 +274,9 @@ export default function CampaignsPage() { const query = search.trim().toLowerCase(); const filtered = automations.filter((a) => { - if (statusFilter === "active" && !a.isActive) return false; - if (statusFilter === "paused" && a.isActive) return false; + const status = campaignLifecycle(a).toLowerCase(); + if (statusFilter !== "all" && status !== statusFilter) return false; + if (statusFilter === "all" && status === "archived") return false; if (!query) return true; return ( a.name.toLowerCase().includes(query) || @@ -280,6 +287,7 @@ export default function CampaignsPage() { return (
+ {actionError &&

{actionError}

} {/* Header */}
@@ -322,7 +330,7 @@ export default function CampaignsPage() { className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-foreground placeholder:text-zinc-500 focus:border-accent/40 focus:outline-none" />
- {(["all", "active", "paused"] as const).map((s) => ( + {(["all", "active", "paused", "draft", "archived"] as const).map((s) => (
diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 2309e5e74..418871379 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -124,7 +124,7 @@ export default function DashboardPage() { - +
{/* Chart + Recent Activity */} diff --git a/app/(dashboard)/library/page.tsx b/app/(dashboard)/library/page.tsx new file mode 100644 index 000000000..f62ed904d --- /dev/null +++ b/app/(dashboard)/library/page.tsx @@ -0,0 +1,106 @@ +"use client"; +import { useCallback, useEffect, useState } from "react"; +import { useI18n } from "@/lib/i18n/provider"; +import { useRouter } from "next/navigation"; + +type Asset = { id: string; kind: string; name: string; version: number; data: Record }; +type Account = { id: string; username: string }; +type Campaign = { id: string; name: string; trackedLinks?: { destinationUrl: string; label: string | null }[]; [key: string]: unknown }; +const box = "rounded border border-border bg-surface p-3 w-full"; +export default function LibraryPage() { + const router = useRouter(); + const { t } = useI18n(); + const [assets, setAssets] = useState([]); + const [accounts, setAccounts] = useState([]); + const [campaigns, setCampaigns] = useState([]); + const [account, setAccount] = useState(""); + const [keyword, setKeyword] = useState("LINK"); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [url, setUrl] = useState(""); + const [message, setMessage] = useState("Hey {username}, here is your resource: {link}"); + const [category, setCategory] = useState("CREATOR"); + const [templateSource, setTemplateSource] = useState(""); + const [editing, setEditing] = useState(null); + const [canManage, setCanManage] = useState(false); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(""); + const [draftKeys, setDraftKeys] = useState>({}); + const load = useCallback(async () => { + const results = await Promise.all([fetch("/api/library"), fetch("/api/instagram/accounts"), fetch("/api/automations")]); + if (results.some(r => !r.ok)) throw new Error(t("Unable to load the library.")); + const [library, accountData, campaignData] = await Promise.all(results.map(r => r.json())); + setAssets(library.assets); setCanManage(library.canManage); + setAccounts(accountData.data.instagramAccounts); setCampaigns(campaignData.data); + setAccount(current => current || accountData.data.instagramAccounts[0]?.id || ""); + }, [t]); + useEffect(() => { void Promise.resolve().then(load).catch(e => setNotice(e.message)); }, [load]); + async function post(input: unknown) { + const response = await fetch("/api/library", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input) }); + const data = await response.json(); if (!response.ok) throw new Error(data.error ?? t("Save failed")); return data; + } + async function saveResource() { + setBusy(true); setNotice(""); + try { + await post({ action: "save", kind: "RESOURCE", name, id: editing?.id, version: editing?.version, + data: { description, destinationUrl: url, dmMessage: message, category } }); + setEditing(null); await load(); setNotice(t("Resource saved. Existing campaigns and delivered links remain unchanged.")); + } catch (e) { setNotice((e as Error).message); } finally { setBusy(false); } + } + async function saveTemplate() { + const campaign = campaigns.find(c => c.id === templateSource); if (!campaign) return; + setBusy(true); + try { + await post({ action: "save", kind: "TEMPLATE", name: campaign.name, + data: { ...campaign, trackedDestinationUrl: campaign.trackedLinks?.[0]?.destinationUrl, + secondaryDestinationUrl: campaign.trackedLinks?.[1]?.destinationUrl, secondaryButtonLabel: campaign.trackedLinks?.[1]?.label } }); + await load(); setNotice(t("Template saved as an independent content snapshot.")); + } catch (e) { setNotice((e as Error).message); } finally { setBusy(false); } + } + async function create(asset: Asset) { + setBusy(true); + const draftFingerprint = JSON.stringify([asset.id, asset.version, account, asset.kind === "RESOURCE" ? keyword : null]); + const idempotencyKey = draftKeys[draftFingerprint] ?? crypto.randomUUID(); + setDraftKeys(current => ({ ...current, [draftFingerprint]: idempotencyKey })); + try { + const data = await post({ action: "create_draft", id: asset.id, instagramAccountId: account, keyword, idempotencyKey }); + router.push(`/campaigns/${data.campaignId}/edit`); + } catch (e) { setNotice((e as Error).message); setBusy(false); } + } + function edit(asset: Asset) { + setEditing(asset); setName(asset.name); setUrl(String(asset.data.destinationUrl ?? "")); + setDescription(String(asset.data.description ?? "")); setMessage(String(asset.data.dmMessage ?? "")); + setCategory(String(asset.data.category ?? "CREATOR")); + } + return
+

{t("Templates & resources")}

{t("Reuse content across videos. Every new campaign starts as a draft, without sending.")}

+ {notice &&

{notice}

} + {canManage &&
+

{editing ? t("Edit resource · v{version}", { version: editing.version }) : t("New resource")}

+ + +