From 08ea37baae05f146dd85c0a5de95d2bd16129752 Mon Sep 17 00:00:00 2001 From: OpenReply Contributor <62962459+nikolajIvanov@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:23:31 +0200 Subject: [PATCH 1/2] feat: add reviewed creator workflows, library and campaign history --- __tests__/campaign-duplicate.test.ts | 7 +- __tests__/campaign-history.test.ts | 27 +++ __tests__/campaign-lifecycle.db.test.ts | 227 ++++++++++++++++++ __tests__/campaign-selection.test.ts | 75 ++++++ __tests__/campaign-simulation.test.ts | 31 +++ __tests__/next-reel-attachment.test.ts | 61 +++++ app/(dashboard)/campaigns/[id]/page.tsx | 25 +- app/(dashboard)/campaigns/import/page.tsx | 1 + app/(dashboard)/campaigns/page.tsx | 40 +-- app/(dashboard)/dashboard/page.tsx | 2 +- app/(dashboard)/library/page.tsx | 106 ++++++++ app/api/automations/duplicate/route.ts | 1 + app/api/automations/history/route.ts | 14 ++ app/api/automations/import/route.ts | 22 +- app/api/automations/route.ts | 88 ++++--- app/api/automations/validate/route.ts | 81 +++++++ app/api/library/route.ts | 56 +++++ components/campaign-builder.tsx | 100 +++++++- components/campaign-history.tsx | 41 ++++ components/sidebar.tsx | 1 + docs/creator-workflow.md | 59 +++++ lib/automation/attach-next-reel.ts | 43 +++- lib/campaigns/duplicate.ts | 14 +- lib/campaigns/history.ts | 29 +++ lib/campaigns/mutations.ts | 27 +++ lib/campaigns/selection.ts | 66 +++++ lib/i18n/index.ts | 2 + lib/i18n/zh-TW.json | 62 ++++- lib/library/drafts.ts | 45 ++++ lib/library/http.ts | 42 ++++ lib/library/schema.ts | 44 ++++ lib/queue/dm-worker.ts | 12 +- .../migration.sql | 31 +++ prisma/schema.prisma | 45 ++++ 34 files changed, 1444 insertions(+), 83 deletions(-) create mode 100644 __tests__/campaign-history.test.ts create mode 100644 __tests__/campaign-lifecycle.db.test.ts create mode 100644 __tests__/campaign-selection.test.ts create mode 100644 __tests__/campaign-simulation.test.ts create mode 100644 __tests__/next-reel-attachment.test.ts create mode 100644 app/(dashboard)/library/page.tsx create mode 100644 app/api/automations/history/route.ts create mode 100644 app/api/automations/validate/route.ts create mode 100644 app/api/library/route.ts create mode 100644 components/campaign-history.tsx create mode 100644 docs/creator-workflow.md create mode 100644 lib/campaigns/history.ts create mode 100644 lib/campaigns/mutations.ts create mode 100644 lib/campaigns/selection.ts create mode 100644 lib/library/drafts.ts create mode 100644 lib/library/http.ts create mode 100644 lib/library/schema.ts create mode 100644 prisma/migrations/20261003090000_creator_workflow/migration.sql 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__/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")}

+ + +