diff --git a/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap b/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap index f47a635f2f..4e09061657 100644 --- a/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap +++ b/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap @@ -2,6 +2,126 @@ exports[`public API spec — operation naming guard > operation list (operationId, method, path) matches the committed snapshot 1`] = ` [ + { + "method": "GET", + "operationId": "ads.checkCampaignPrerequisites", + "path": "/v1/ads/campaigns/prerequisites", + }, + { + "method": "POST", + "operationId": "ads.createCampaign", + "path": "/v1/ads/campaigns", + }, + { + "method": "POST", + "operationId": "ads.createRule", + "path": "/v1/ads/conversion-rules", + }, + { + "method": "DELETE", + "operationId": "ads.deleteCampaign", + "path": "/v1/ads/campaigns/{operationId}", + }, + { + "method": "DELETE", + "operationId": "ads.deleteRule", + "path": "/v1/ads/conversion-rules/{id}", + }, + { + "method": "GET", + "operationId": "ads.getCampaignAdAccountDetails", + "path": "/v1/ads/campaigns/ad-accounts/{adAccountId}", + }, + { + "method": "POST", + "operationId": "ads.getCampaignsInsights", + "path": "/v1/ads/campaigns/insights", + }, + { + "method": "GET", + "operationId": "ads.getCampaignVideoStatus", + "path": "/v1/ads/campaigns/videos/{videoId}/status", + }, + { + "method": "GET", + "operationId": "ads.getCapiDelivery", + "path": "/v1/ads/capi-delivery", + }, + { + "method": "GET", + "operationId": "ads.getFunnel", + "path": "/v1/ads/funnel", + }, + { + "method": "GET", + "operationId": "ads.getFunnelTimeseries", + "path": "/v1/ads/funnel/timeseries", + }, + { + "method": "GET", + "operationId": "ads.getRule", + "path": "/v1/ads/conversion-rules/{id}", + }, + { + "method": "GET", + "operationId": "ads.listCampaignAdAccounts", + "path": "/v1/ads/campaigns/{channel}/{integrationId}/ad-accounts", + }, + { + "method": "GET", + "operationId": "ads.listCampaignMessengerPages", + "path": "/v1/ads/campaigns/messenger-pages", + }, + { + "method": "GET", + "operationId": "ads.listCampaigns", + "path": "/v1/ads/campaigns", + }, + { + "method": "GET", + "operationId": "ads.listChannelAdAccounts", + "path": "/v1/ads/{channel}/ad-accounts", + }, + { + "method": "GET", + "operationId": "ads.listConversionExportRows", + "path": "/v1/ads/conversions/export", + }, + { + "method": "GET", + "operationId": "ads.listRules", + "path": "/v1/ads/conversion-rules", + }, + { + "method": "POST", + "operationId": "ads.pauseCampaign", + "path": "/v1/ads/campaigns/{operationId}/pause", + }, + { + "method": "POST", + "operationId": "ads.publishCampaign", + "path": "/v1/ads/campaigns/{operationId}/publish", + }, + { + "method": "POST", + "operationId": "ads.retryCampaign", + "path": "/v1/ads/campaigns/{operationId}/retry", + }, + { + "method": "PATCH", + "operationId": "ads.toggleRuleStatus", + "path": "/v1/ads/conversion-rules/{id}/status", + }, + { + "method": "PUT", + "operationId": "ads.updateRule", + "path": "/v1/ads/conversion-rules/{id}", + }, + { + "method": "POST", + "operationId": "ads.uploadCampaignVideo", + "path": "/v1/ads/campaigns/upload-video", + }, { "method": "POST", "operationId": "aiAgents.create", diff --git a/apps/builder/__tests__/ads-campaign-public-api.test.ts b/apps/builder/__tests__/ads-campaign-public-api.test.ts new file mode 100644 index 0000000000..6dd338738c --- /dev/null +++ b/apps/builder/__tests__/ads-campaign-public-api.test.ts @@ -0,0 +1,215 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +vi.mock("@chatbotx.io/database/client", () => { + const proxy: unknown = new Proxy(() => proxy, { get: () => proxy }) + return { db: proxy } +}) + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] +} + +type CapturedProcedure = { + route: RouteConfig + handler?: (...args: any[]) => any +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: (...args: any[]) => any) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +const messagingAdCampaignService = { + createDraft: vi.fn(), + retryDraft: vi.fn(), + publish: vi.fn(), + pause: vi.fn(), + deleteOperation: vi.fn(), + list: vi.fn(), + listInsights: vi.fn(), + listMessengerPages: vi.fn(), +} + +const messagingAdsConnectionService = { + findForIntegration: vi.fn(), +} + +const listCachedMessagingAdAccounts = vi.fn() +const getCachedMessagingAdAccountDetails = vi.fn() +const buildMessagingAdsContext = vi.fn() + +vi.mock("@chatbotx.io/business", () => ({ + messagingAdCampaignService, + messagingAdsConnectionService, + listCachedMessagingAdAccounts, + getCachedMessagingAdAccountDetails, + buildMessagingAdsContext, +})) + +vi.mock("@chatbotx.io/integration-facebook-ads", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@chatbotx.io/integration-facebook-ads") + >() + return { + ...actual, + integration: { runAction: vi.fn() }, + } +}) + +await import("@/features/ads-campaign/api/public") + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (p) => p.route.method === method && p.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0] + +const OPERATION_RECORD = { + id: "op-1", + workspaceId: "1001", + channel: "whatsapp", + adAccountId: "act_1", + name: "Ad", + createState: "created", + publishState: "draft", + metaCampaignId: null, + metaAdSetId: null, + metaAdCreativeId: null, + metaAdId: null, + lastError: null, + cleanupError: null, + createdAt: new Date(), +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +test("registers the ads-campaign public router under the ads scope", () => { + expect(scopeArgAtImport).toBe("ads") +}) + +describe("POST /v1/ads/campaigns", () => { + const procedure = findProcedure("POST", "/v1/ads/campaigns") + + test("creates a campaign with no session user in context and no createdBy", async () => { + messagingAdCampaignService.createDraft.mockResolvedValueOnce( + OPERATION_RECORD, + ) + + // No `context.user` at all — the token auth stack never sets one. This + // must NOT throw `errors.superAdminRequired` (the private handler's + // `assertWorkspaceSuperAdmin` guard, deliberately omitted here). + const result = await procedure.handler?.({ + context: { workspace: { id: "1001" } }, + input: { + channel: "whatsapp", + integrationId: "1", + adAccountId: "act_1", + name: "Ad", + campaign: { specialAdCategories: ["NONE"] }, + adSet: { + dailyBudgetMinorUnits: 1000, + targeting: { countries: ["US"] }, + }, + creative: { + media: { kind: "video", videoId: "v1" }, + welcomeMessage: { type: "default" }, + }, + }, + }) + + expect(messagingAdCampaignService.createDraft).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: "1001" }), + ) + // createdBy was never set on the merged input -> undefined, never a + // session user's id. + expect( + messagingAdCampaignService.createDraft.mock.calls[0]?.[0].createdBy, + ).toBeUndefined() + // The public resource never leaks workspaceId. + expect(result).not.toHaveProperty("workspaceId") + }) +}) + +describe("POST /v1/ads/campaigns/{operationId}/publish", () => { + const procedure = findProcedure( + "POST", + "/v1/ads/campaigns/{operationId}/publish", + ) + + test("publishes with no session user in context", async () => { + messagingAdCampaignService.publish.mockResolvedValueOnce(OPERATION_RECORD) + + const result = await procedure.handler?.({ + context: { workspace: { id: "1001" } }, + input: { operationId: "op-1" }, + }) + + expect(messagingAdCampaignService.publish).toHaveBeenCalledWith({ + operationId: "op-1", + workspaceId: "1001", + }) + expect(result).not.toHaveProperty("workspaceId") + }) +}) + +describe("GET /v1/ads/campaigns", () => { + const procedure = findProcedure("GET", "/v1/ads/campaigns") + + test("sources workspaceId from context and strips it from every row", async () => { + messagingAdCampaignService.list.mockResolvedValueOnce([ + { ...OPERATION_RECORD, effectiveStatus: null }, + ]) + + const result = await procedure.handler?.({ + context: { workspace: { id: "1001" } }, + input: { channel: "whatsapp", integrationId: "1" }, + }) + + expect(messagingAdCampaignService.list).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: "1001" }), + ) + expect(result?.data[0]).not.toHaveProperty("workspaceId") + }) +}) diff --git a/apps/builder/__tests__/ads-public-api.test.ts b/apps/builder/__tests__/ads-public-api.test.ts new file mode 100644 index 0000000000..c062c760aa --- /dev/null +++ b/apps/builder/__tests__/ads-public-api.test.ts @@ -0,0 +1,280 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +// Mirrors `analytics-public-api.test.ts`: several ads business services are +// imported transitively by modules that open a real `pg.Pool` via +// `@chatbotx.io/database/client` at module load. Never reached by these +// handler-only tests, but the import chain must not try to open a +// connection. +vi.mock("@chatbotx.io/database/client", () => { + const proxy: unknown = new Proxy(() => proxy, { get: () => proxy }) + return { db: proxy } +}) + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] +} + +type CapturedProcedure = { + route: RouteConfig + handler?: (...args: any[]) => any +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: (...args: any[]) => any) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +const adsConversionService = { + list: vi.fn(), + findOrFail: vi.fn(), + create: vi.fn(), + update: vi.fn(), + toggleEnabled: vi.fn(), + remove: vi.fn(), + getCtwaFunnel: vi.fn(), + getCtwaFunnelTimeseries: vi.fn(), + getCapiDeliverySummary: vi.fn(), + listExportRows: vi.fn(), + listAllChannelExportRows: vi.fn(), +} + +const messagingAdCampaignService = { + createDraft: vi.fn(), + retryDraft: vi.fn(), + publish: vi.fn(), + pause: vi.fn(), + deleteOperation: vi.fn(), + list: vi.fn(), + listInsights: vi.fn(), + listMessengerPages: vi.fn(), +} + +const messagingAdsConnectionService = { + findForIntegration: vi.fn(), +} + +const listCachedMessagingAdAccounts = vi.fn() +const getCachedMessagingAdAccountDetails = vi.fn() +const buildMessagingAdsContext = vi.fn() + +vi.mock("@chatbotx.io/business", () => ({ + adsConversionService, + messagingAdCampaignService, + messagingAdsConnectionService, + listCachedMessagingAdAccounts, + getCachedMessagingAdAccountDetails, + buildMessagingAdsContext, +})) + +vi.mock("@chatbotx.io/integration-facebook-ads", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@chatbotx.io/integration-facebook-ads") + >() + return { + ...actual, + integration: { + runAction: vi.fn(), + }, + } +}) + +const resolveChannelAdAccountSources = vi.fn() +vi.mock("@/features/ads/queries/channel-ad-accounts", () => ({ + resolveChannelAdAccountSources, +})) + +await import("@/features/ads/api/public") + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (p) => p.route.method === method && p.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0] + +beforeEach(() => { + vi.clearAllMocks() +}) + +test("registers the ads public router under the ads scope", () => { + expect(scopeArgAtImport).toBe("ads") +}) + +describe("GET /v1/ads/conversion-rules", () => { + const procedure = findProcedure("GET", "/v1/ads/conversion-rules") + + test("sources workspaceId from context, not input, and paginates in memory", async () => { + adsConversionService.list.mockResolvedValueOnce([{ id: "r1" }]) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { page: 1, perPage: 50 }, + }) + + expect(adsConversionService.list).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: "workspace-1" }), + ) + expect(result).toEqual({ data: [{ id: "r1" }], pageCount: 1 }) + }) +}) + +describe("GET /v1/ads/conversion-rules/{id}", () => { + const procedure = findProcedure("GET", "/v1/ads/conversion-rules/{id}") + + test("sources workspaceId from context, not input", async () => { + adsConversionService.findOrFail.mockResolvedValueOnce({ id: "r1" }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { id: "r1" }, + }) + + expect(adsConversionService.findOrFail).toHaveBeenCalledWith({ + id: "r1", + workspaceId: "workspace-1", + }) + }) +}) + +describe("POST /v1/ads/conversion-rules", () => { + const procedure = findProcedure("POST", "/v1/ads/conversion-rules") + + test("sources workspaceId from context, not input", async () => { + adsConversionService.create.mockResolvedValueOnce({ id: "r1" }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { channel: "whatsapp", eventType: "lead", trigger: {} }, + }) + + expect(adsConversionService.create).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: "workspace-1" }), + ) + }) +}) + +describe("GET /v1/ads/funnel", () => { + const procedure = findProcedure("GET", "/v1/ads/funnel") + + test("sources workspaceId from context, not input", async () => { + adsConversionService.getCtwaFunnel.mockResolvedValueOnce({ + totals: { conversations: 0, leads: 0, purchases: 0, revenue: 0 }, + perAd: [], + }) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { since: new Date("2026-01-01"), until: new Date("2026-01-31") }, + }) + + expect(adsConversionService.getCtwaFunnel).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: "workspace-1" }), + ) + }) +}) + +describe("GET /v1/ads/conversions/export", () => { + const procedure = findProcedure("GET", "/v1/ads/conversions/export") + + test("uses listExportRows when allChannels is not set, and sets nextAfterId only on a full page", async () => { + adsConversionService.listExportRows.mockResolvedValueOnce([ + { id: "row-1", occurredAt: new Date() }, + ]) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { + since: new Date("2026-01-01"), + until: new Date("2026-01-31"), + segment: "leads", + limit: 500, + }, + }) + + expect(adsConversionService.listExportRows).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: "workspace-1" }), + ) + expect(adsConversionService.listAllChannelExportRows).not.toHaveBeenCalled() + // Short page (1 row < limit 500) -> no more pages. + expect(result?.nextAfterId).toBeNull() + }) + + test("uses listAllChannelExportRows when allChannels is set", async () => { + adsConversionService.listAllChannelExportRows.mockResolvedValueOnce([]) + + await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { + since: new Date("2026-01-01"), + until: new Date("2026-01-31"), + segment: "leads", + allChannels: true, + limit: 500, + }, + }) + + expect(adsConversionService.listAllChannelExportRows).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: "workspace-1" }), + ) + expect(adsConversionService.listExportRows).not.toHaveBeenCalled() + }) +}) + +describe("GET /v1/ads/{channel}/ad-accounts", () => { + const procedure = findProcedure("GET", "/v1/ads/{channel}/ad-accounts") + + test("sources workspaceId from context and strips internal `sources` provenance", async () => { + resolveChannelAdAccountSources.mockResolvedValueOnce([ + { id: "act_1", sources: [{ kind: "workspace" }] }, + ]) + + const result = await procedure.handler?.({ + context: { workspace: { id: "workspace-1" } }, + input: { channel: "whatsapp" }, + }) + + expect(resolveChannelAdAccountSources).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: "workspace-1" }), + ) + expect(result).toEqual({ data: [{ id: "act_1" }] }) + }) +}) diff --git a/apps/builder/__tests__/ads-public-scope.test.ts b/apps/builder/__tests__/ads-public-scope.test.ts new file mode 100644 index 0000000000..530fbc1c6f --- /dev/null +++ b/apps/builder/__tests__/ads-public-scope.test.ts @@ -0,0 +1,200 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { + findWorkspaceByTokenHash, + isWorkspaceScheduledForDeletion, + getAccessState, + isAtLimit, + assertApiNotRateLimited, +} = vi.hoisted(() => ({ + findWorkspaceByTokenHash: vi.fn(), + isWorkspaceScheduledForDeletion: vi.fn().mockReturnValue(false), + getAccessState: vi.fn().mockResolvedValue({ blocked: false }), + isAtLimit: vi.fn().mockResolvedValue(false), + assertApiNotRateLimited: vi.fn().mockResolvedValue(undefined), +})) + +const adsConversionService = { + list: vi.fn(), + findOrFail: vi.fn(), + create: vi.fn(), + update: vi.fn(), + toggleEnabled: vi.fn(), + remove: vi.fn(), + getCtwaFunnel: vi.fn(), + getCtwaFunnelTimeseries: vi.fn(), + getCapiDeliverySummary: vi.fn(), + listExportRows: vi.fn(), + listAllChannelExportRows: vi.fn(), +} + +const messagingAdCampaignService = { + createDraft: vi.fn(), + retryDraft: vi.fn(), + publish: vi.fn(), + pause: vi.fn(), + deleteOperation: vi.fn(), + list: vi.fn(), + listInsights: vi.fn(), + listMessengerPages: vi.fn(), +} + +const messagingAdsConnectionService = { + findForIntegration: vi.fn(), + listForChannel: vi.fn(), +} + +vi.mock("@chatbotx.io/business", () => ({ + workspaceApiTokenService: { findWorkspaceByTokenHash }, + isWorkspaceScheduledForDeletion, + userQuotaService: { getAccessState }, + quotaEnforcementService: { isAtLimit }, + adsConversionService, + messagingAdCampaignService, + messagingAdsConnectionService, + listCachedMessagingAdAccounts: vi.fn(), + getCachedMessagingAdAccountDetails: vi.fn(), + buildMessagingAdsContext: vi.fn(), + integrationFacebookAdsService: { findByWorkspaceId: vi.fn() }, +})) + +vi.mock("@chatbotx.io/redis", () => ({ + withCache: vi.fn((_key: string, loader: () => unknown) => loader()), + invalidateCacheByTags: vi.fn(), +})) + +vi.mock("@/lib/log", () => ({ + logger: { warn: vi.fn(), error: vi.fn() }, +})) + +vi.mock("@/lib/rate-limit/api-rate-limit", () => ({ + assertApiNotRateLimited, +})) + +vi.mock("@/lib/rate-limit/guest-rate-limit", () => ({ + getGuestClientIp: () => "203.0.113.9", +})) + +vi.mock("@/env", () => ({ isCloud: () => true })) + +// `@/orpc` also exports `authorizedAPI`, which pulls in the full better-auth +// stack via `authMiddleware` — irrelevant here and unsafe to initialize in a +// unit test. Same stub as workspace-token-scope-enforcement.test.ts. +vi.mock("@/middlewares/auth", () => ({ + authMiddleware: vi.fn(), +})) + +const { call } = await import("@orpc/server") +const { adsPublicRouter } = await import("../src/features/ads/api/public") + +const TOKEN = "cbx_ws_fixture" + +const authResult = (scopes: string[] | null) => ({ + workspace: { id: "ws-1", ownerId: "owner-1" }, + apiToken: { id: "token-1", permission: "full" as const, scopes }, +}) + +// Exercises heterogeneous procedures from the merged router — each has its +// own input/output shape, so this is intentionally untyped. +const invoke = (procedure: any, input: Record = {}) => + call(procedure, input, { + context: { headers: new Headers({ Authorization: `Bearer ${TOKEN}` }) }, + }) + +beforeEach(() => { + vi.clearAllMocks() + isWorkspaceScheduledForDeletion.mockReturnValue(false) + getAccessState.mockResolvedValue({ blocked: false }) + isAtLimit.mockResolvedValue(false) + assertApiNotRateLimited.mockResolvedValue(undefined) +}) + +describe("real router: ads public API scope wiring", () => { + test("a contacts-scoped token is denied the real GET /v1/ads/conversion-rules route with FORBIDDEN", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(["contacts"])) + + await expect(invoke(adsPublicRouter.listRules)).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Token is not authorized for the 'ads' scope", + }) + }) + + test("null scopes (unrestricted) passes the real GET /v1/ads/conversion-rules route", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(null)) + adsConversionService.list.mockResolvedValue([]) + + await expect(invoke(adsPublicRouter.listRules)).resolves.toMatchObject({ + data: [], + }) + }) + + test("a contacts-scoped token is denied the real POST /v1/ads/campaigns route with FORBIDDEN", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(["contacts"])) + + await expect( + invoke(adsPublicRouter.createCampaign, { + channel: "whatsapp", + integrationId: "1", + adAccountId: "act_1", + name: "Ad", + campaign: { specialAdCategories: ["NONE"] }, + adSet: { + dailyBudgetMinorUnits: 1000, + targeting: { countries: ["US"] }, + }, + creative: { + media: { + kind: "video", + videoId: "v1", + }, + welcomeMessage: { type: "default" }, + }, + }), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Token is not authorized for the 'ads' scope", + }) + }) +}) + +describe("every ads submodule declares the ads scope", () => { + test("the router was built entirely under workspaceTokenAuthAPIForScope('ads')", () => { + // Sanity check: every key on the merged router resolves to a procedure — + // if a submodule forgot to call `workspaceTokenAuthAPIForScope("ads")` + // and used a bare/differently-scoped builder instead, the FORBIDDEN + // assertions above would still pass by coincidence for the routes they + // cover, but a spot-check across both conversion-rules and campaigns + // (the two submodules composed into `adsPublicRouter`) is what actually + // proves the merge didn't drop or mis-scope either one. + expect(Object.keys(adsPublicRouter)).toEqual( + expect.arrayContaining([ + "listRules", + "getRule", + "createRule", + "updateRule", + "toggleRuleStatus", + "deleteRule", + "getFunnel", + "getFunnelTimeseries", + "getCapiDelivery", + "listConversionExportRows", + "listChannelAdAccounts", + "createCampaign", + "retryCampaign", + "publishCampaign", + "pauseCampaign", + "deleteCampaign", + "listCampaigns", + "getCampaignsInsights", + "listCampaignAdAccounts", + "getCampaignAdAccountDetails", + "uploadCampaignVideo", + "getCampaignVideoStatus", + "listCampaignMessengerPages", + "checkCampaignPrerequisites", + ]), + ) + }) +}) diff --git a/apps/builder/src/features/ads-campaign/api/public.ts b/apps/builder/src/features/ads-campaign/api/public.ts new file mode 100644 index 0000000000..76f28f491c --- /dev/null +++ b/apps/builder/src/features/ads-campaign/api/public.ts @@ -0,0 +1,361 @@ +import { + getCachedMessagingAdAccountDetails, + listCachedMessagingAdAccounts, + messagingAdCampaignService, + messagingAdsConnectionService, +} from "@chatbotx.io/business" +import { ChatbotXException } from "@chatbotx.io/business/errors" +import { facebookAdAccountSchema } from "@chatbotx.io/integration-facebook-ads" +import { z } from "zod" +import { + possibleErrorsOnCreatingResource, + possibleErrorsOnDeletingResource, + possibleErrorsOnFindingResource, + possibleErrorsOnListingResource, + possibleErrorsOnMutatingResource, +} from "@/lib/orpc/orpc-error-helper" +import { workspaceTokenAuthAPIForScope } from "@/orpc" +import { getMessagingAdsContextForIntegration } from "../lib/facebook-ads-runner" +import { toMessagingAdOperationResource } from "../lib/resource-mapper" +import { + adAccountDetailsPublicRequest, + adAccountDetailsPublicRequestParams, + checkPrerequisitesPublicRequest, + createMessagingAdPublicRequest, + listAdAccountsPublicRequest, + listAdAccountsPublicRequestParams, + listMessagingAdsPublicRequest, + listMessengerPagesPublicRequest, + messagingAdsInsightsPublicRequest, + operationIdPublicParams, + uploadAdVideoPublicRequest, + videoStatusPublicRequest, + videoStatusPublicRequestParams, +} from "../schema/public" +import { + adAccountDetailsResource, + messagingAdInsightResource, + messagingAdOperationResource, +} from "../schema/resource" +import { createMessagingAdRequest } from "../schema/wizard" + +const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("ads") + +const messagingAdOperationPublicResource = messagingAdOperationResource.omit({ + workspaceId: true, +}) + +const toPublicOperationResource = ( + row: Parameters[0], +) => { + const { workspaceId: _workspaceId, ...resource } = + toMessagingAdOperationResource(row) + return resource +} + +/** + * Every campaign-lifecycle mutation below deliberately OMITS + * `assertWorkspaceSuperAdmin` (present on the private `adsCampaignAPI` at + * `../api/private.ts`) — that guard resolves the SESSION user via + * `getCurrentUserAndTargetWorkspace`, and a workspace-token request has no + * session user (`context.user` is never set on the token auth stack, see + * `apps/builder/src/orpc.ts`). It would throw `errors.superAdminRequired` on + * every token call. Per docs/developer/workspace-api-tokens.md, a workspace + * token authenticates the WORKSPACE, not a member — member-level permission + * scoping does not apply here, and minting a token already required the + * caller to be a workspace superAdmin. `createdBy` is likewise omitted on + * every write (a token has no associated user), matching the + * `createdById: null` precedent in `features/coupons/api/public.ts`. + */ +export const adsCampaignPublicRouter = { + createCampaign: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/ads/campaigns", + summary: + "Create a messaging ad (campaign + ad set + creative + ad, all PAUSED). Created without a `createdBy` — workspace API tokens have no associated user.", + tags: ["Ads"], + }) + .input(createMessagingAdPublicRequest) + .output(messagingAdOperationPublicResource) + .errors(possibleErrorsOnCreatingResource) + .handler(async ({ context, input }) => { + // Re-validated through the private `createMessagingAdRequest` (the + // single source of truth for this schema's rules — CREDIT rejection, + // special-ad-category country, adSet time ordering, and the + // imageKey-ownership refine that needs `workspaceId` in scope) after + // merging in the token's resolved workspace. + const parsed = createMessagingAdRequest.parse({ + ...input, + workspaceId: context.workspace.id, + }) + const record = await messagingAdCampaignService.createDraft(parsed) + return toPublicOperationResource({ ...record, effectiveStatus: null }) + }), + + retryCampaign: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/ads/campaigns/{operationId}/retry", + summary: + "Resume a partially-created messaging ad using the same operationId", + tags: ["Ads"], + }) + .input(operationIdPublicParams) + .output(messagingAdOperationPublicResource) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const record = await messagingAdCampaignService.retryDraft({ + ...input, + workspaceId: context.workspace.id, + }) + return toPublicOperationResource({ ...record, effectiveStatus: null }) + }), + + publishCampaign: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/ads/campaigns/{operationId}/publish", + summary: + "Publish a messaging ad — sets campaign/ad set/ad to ACTIVE on Meta. This spends real ad budget.", + tags: ["Ads"], + }) + .input(operationIdPublicParams) + .output(messagingAdOperationPublicResource) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const record = await messagingAdCampaignService.publish({ + ...input, + workspaceId: context.workspace.id, + }) + return toPublicOperationResource({ ...record, effectiveStatus: null }) + }), + + pauseCampaign: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/ads/campaigns/{operationId}/pause", + summary: "Pause a published messaging ad on Meta", + tags: ["Ads"], + }) + .input(operationIdPublicParams) + .output(messagingAdOperationPublicResource) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const record = await messagingAdCampaignService.pause({ + ...input, + workspaceId: context.workspace.id, + }) + return toPublicOperationResource({ ...record, effectiveStatus: null }) + }), + + deleteCampaign: workspaceTokenAuthAPI + .route({ + method: "DELETE", + path: "/v1/ads/campaigns/{operationId}", + summary: "Delete a messaging ad's campaign/ad set/ad on Meta", + tags: ["Ads"], + }) + .input(operationIdPublicParams) + .output(messagingAdOperationPublicResource) + .errors(possibleErrorsOnDeletingResource) + .handler(async ({ context, input }) => { + const record = await messagingAdCampaignService.deleteOperation({ + ...input, + workspaceId: context.workspace.id, + }) + return toPublicOperationResource({ ...record, effectiveStatus: null }) + }), + + listCampaigns: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/ads/campaigns", + summary: + "List messaging ads created from ChatbotX for one channel integration, with Meta's live effective_status", + tags: ["Ads"], + }) + .input(listMessagingAdsPublicRequest) + .output(z.object({ data: z.array(messagingAdOperationPublicResource) })) + .errors(possibleErrorsOnListingResource) + .handler(async ({ context, input: { refresh, ...input } }) => { + const rows = await messagingAdCampaignService.list({ + ...input, + workspaceId: context.workspace.id, + forceRefresh: refresh, + }) + return { data: rows.map(toPublicOperationResource) } + }), + + getCampaignsInsights: workspaceTokenAuthAPI + .route({ + // POST (not GET) despite being read-only — `adIds` is an array; mirrors + // the private `getMessagingAdsInsights` POST-for-read precedent. + method: "POST", + path: "/v1/ads/campaigns/insights", + summary: + "Ads Insights for a set of messaging ads (impressions/reach/spend/clicks/messaging conversations started/cost-per-conversation)", + tags: ["Ads"], + }) + .input(messagingAdsInsightsPublicRequest) + .output(z.object({ data: z.array(messagingAdInsightResource) })) + .errors(possibleErrorsOnListingResource) + .handler(async ({ context, input: { refresh, ...input } }) => ({ + // Through the service (not the raw cached read) so ownership is + // enforced — the requested adIds/adAccountId are intersected with this + // workspace's own operations before any Graph call. + data: await messagingAdCampaignService.listInsights({ + ...input, + workspaceId: context.workspace.id, + forceRefresh: refresh, + }), + })), + + listCampaignAdAccounts: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/ads/campaigns/{channel}/{integrationId}/ad-accounts", + summary: + "List ad accounts reachable by one integration's messaging-ads connection (cached)", + tags: ["Ads"], + }) + .input(listAdAccountsPublicRequestParams.and(listAdAccountsPublicRequest)) + .output(z.object({ data: z.array(facebookAdAccountSchema) })) + .errors(possibleErrorsOnFindingResource) + .handler(async ({ context, input: { refresh, ...input } }) => ({ + data: await listCachedMessagingAdAccounts({ + ...input, + workspaceId: context.workspace.id, + forceRefresh: refresh, + }), + })), + + getCampaignAdAccountDetails: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/ads/campaigns/ad-accounts/{adAccountId}", + summary: + "Get an ad account's currency/timezone/status/minimum budget (cached)", + tags: ["Ads"], + }) + .input( + adAccountDetailsPublicRequestParams.and(adAccountDetailsPublicRequest), + ) + .output(adAccountDetailsResource) + .errors(possibleErrorsOnFindingResource) + .handler(({ context, input: { refresh, ...input } }) => + getCachedMessagingAdAccountDetails({ + ...input, + workspaceId: context.workspace.id, + forceRefresh: refresh, + }), + ), + + uploadCampaignVideo: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/ads/campaigns/upload-video", + summary: + "Upload a creative video to Meta — returns the video_id (processing is async, poll getCampaignVideoStatus)", + tags: ["Ads"], + }) + .input(uploadAdVideoPublicRequest) + .output(z.object({ videoId: z.string() })) + .errors(possibleErrorsOnCreatingResource) + .handler(async ({ context, input }) => { + const { ctx, integration } = await getMessagingAdsContextForIntegration({ + ...input, + workspaceId: context.workspace.id, + }) + return integration.runAction("uploadMessagingAdVideo", { + ctx, + props: { + adAccountId: input.adAccountId, + fileName: input.fileName, + mimeType: input.mimeType, + bytes: new Uint8Array(Buffer.from(input.base64, "base64")), + }, + }) + }), + + getCampaignVideoStatus: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/ads/campaigns/videos/{videoId}/status", + summary: + "Poll a video's processing status — a creative must not reference a not-yet-ready video", + tags: ["Ads"], + }) + .input(videoStatusPublicRequestParams.and(videoStatusPublicRequest)) + .output( + z.object({ + videoId: z.string(), + status: z.string(), + isReady: z.boolean(), + isError: z.boolean(), + }), + ) + .errors(possibleErrorsOnFindingResource) + .handler(async ({ context, input }) => { + const { ctx, integration } = await getMessagingAdsContextForIntegration({ + ...input, + workspaceId: context.workspace.id, + }) + return integration.runAction("getMessagingAdVideoStatus", { + ctx, + props: { videoId: input.videoId }, + }) + }), + + listCampaignMessengerPages: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/ads/campaigns/messenger-pages", + summary: + "List connected Messenger Pages (source of page_id for the WhatsApp ad-set step) — CTWA only", + tags: ["Ads"], + }) + .input(listMessengerPagesPublicRequest) + .output( + z.object({ + data: z.array( + z.object({ id: z.string(), name: z.string(), pageId: z.string() }), + ), + }), + ) + .errors(possibleErrorsOnListingResource) + .handler(async ({ context, input }) => { + if (input.channel !== "whatsapp") { + throw new ChatbotXException( + "Messenger pages are only listed for the WhatsApp channel", + "invalidRequest", + 400, + ) + } + return { + data: await messagingAdCampaignService.listMessengerPages( + context.workspace.id, + ), + } + }), + + checkCampaignPrerequisites: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/ads/campaigns/prerequisites", + summary: + "Whether this channel integration's messaging-ads connection is ready", + tags: ["Ads"], + }) + .input(checkPrerequisitesPublicRequest) + .output(z.object({ connected: z.boolean() })) + .errors(possibleErrorsOnFindingResource) + .handler(async ({ context, input }) => { + const connection = await messagingAdsConnectionService.findForIntegration( + { ...input, workspaceId: context.workspace.id }, + ) + return { + connected: Boolean(connection && connection.status === "active"), + } + }), +} diff --git a/apps/builder/src/features/ads-campaign/schema/public.ts b/apps/builder/src/features/ads-campaign/schema/public.ts new file mode 100644 index 0000000000..0351d54513 --- /dev/null +++ b/apps/builder/src/features/ads-campaign/schema/public.ts @@ -0,0 +1,129 @@ +import { zodBigintAsString } from "@chatbotx.io/utils" +import { z } from "zod" +import { + creativeMediaSchema, + messagingAdChannelSchema, + messagingAdsInsightsDatePresetSchema, + messagingAdTargetingSchema, + specialAdCategorySchema, + welcomeMessageSchema, +} from "./wizard" + +/** + * Public-surface twin of `createMessagingAdRequest` (`./wizard.ts`), minus + * `workspaceId` — that schema is a `.refine()`-wrapped `ZodEffects` (the + * `imageKey` ownership check reads `req.workspaceId`), so it cannot be + * `.omit()`-ed. The handler re-validates through the ORIGINAL + * `createMessagingAdRequest` after merging in `context.workspace.id`, so + * this copy only needs to match its *shape* for correct request parsing and + * OpenAPI docs — the private schema stays the single source of truth for + * the actual validation rules (imageKey namespace, CREDIT rejection, + * special-ad-category country, adSet time ordering). + */ +export const createMessagingAdPublicRequest = z.object({ + channel: messagingAdChannelSchema, + integrationId: zodBigintAsString(), + whatsappPageIntegrationId: zodBigintAsString().optional(), + adAccountId: z + .string() + .trim() + .regex(/^act_\d+$/), + name: z.string().trim().min(1).max(120), + campaign: z.object({ + specialAdCategories: z.array(specialAdCategorySchema).min(1), + specialAdCategoryCountry: z.array(z.string().trim().length(2)).optional(), + }), + adSet: z.object({ + dailyBudgetMinorUnits: z.coerce.number().int().positive(), + targeting: messagingAdTargetingSchema, + startTime: z.string().trim().optional(), + endTime: z.string().trim().optional(), + }), + creative: z.object({ + media: creativeMediaSchema, + welcomeMessage: welcomeMessageSchema, + }), +}) +export type CreateMessagingAdPublicRequest = z.infer< + typeof createMessagingAdPublicRequest +> + +export const operationIdPublicParams = z.object({ + operationId: zodBigintAsString(), +}) + +const messagingAdsIntegrationIdentityPublicShape = { + channel: messagingAdChannelSchema, + integrationId: zodBigintAsString(), +} + +export const listMessagingAdsPublicRequest = z.object({ + channel: messagingAdChannelSchema, + integrationId: zodBigintAsString(), + refresh: z.boolean().optional(), +}) + +const MAX_INSIGHTS_AD_IDS = 500 + +export const messagingAdsInsightsPublicRequest = z.object({ + ...messagingAdsIntegrationIdentityPublicShape, + adAccountId: z + .string() + .trim() + .regex(/^act_\d+$/), + adIds: z.array(z.string().trim().min(1)).min(1).max(MAX_INSIGHTS_AD_IDS), + datePreset: messagingAdsInsightsDatePresetSchema.optional(), + refresh: z.boolean().optional(), +}) + +export const listAdAccountsPublicRequestParams = z.object({ + ...messagingAdsIntegrationIdentityPublicShape, +}) + +export const listAdAccountsPublicRequest = z.object({ + refresh: z.boolean().optional(), +}) + +export const adAccountDetailsPublicRequestParams = z.object({ + adAccountId: z + .string() + .trim() + .regex(/^act_\d+$/), +}) + +export const adAccountDetailsPublicRequest = z.object({ + ...messagingAdsIntegrationIdentityPublicShape, + refresh: z.boolean().optional(), +}) + +const MAX_VIDEO_BASE64_LENGTH = 140_000_000 +const VIDEO_MIME_RE = /^video\/(mp4|quicktime)$/ + +export const uploadAdVideoPublicRequest = z.object({ + ...messagingAdsIntegrationIdentityPublicShape, + adAccountId: z + .string() + .trim() + .regex(/^act_\d+$/), + fileName: z.string().trim().min(1).max(255), + mimeType: z.string().trim().regex(VIDEO_MIME_RE), + base64: z.string().trim().min(1).max(MAX_VIDEO_BASE64_LENGTH), +}) + +export const videoStatusPublicRequestParams = z.object({ + videoId: z.string().trim().min(1), +}) + +export const videoStatusPublicRequest = z.object({ + ...messagingAdsIntegrationIdentityPublicShape, +}) + +export const listMessengerPagesPublicRequest = z.object({ + channel: messagingAdChannelSchema, + integrationId: zodBigintAsString(), +}) + +export const checkPrerequisitesPublicRequest = z.object({ + channel: messagingAdChannelSchema, + integrationId: zodBigintAsString(), +}) diff --git a/apps/builder/src/features/ads/api/public.ts b/apps/builder/src/features/ads/api/public.ts new file mode 100644 index 0000000000..6124d15641 --- /dev/null +++ b/apps/builder/src/features/ads/api/public.ts @@ -0,0 +1,258 @@ +import { adsConversionService } from "@chatbotx.io/business" +import { z } from "zod" +import { adsCampaignPublicRouter } from "@/features/ads-campaign/api/public" +import { + possibleErrorsOnCreatingResource, + possibleErrorsOnDeletingResource, + possibleErrorsOnFindingResource, + possibleErrorsOnListingResource, + possibleErrorsOnMutatingResource, +} from "@/lib/orpc/orpc-error-helper" +import { paginateInMemory } from "@/lib/public-api/list" +import { workspaceTokenAuthAPIForScope } from "@/orpc" +import { resolveChannelAdAccountSources } from "../queries/channel-ad-accounts" +import { + adsConversionRuleIdParams, + adsConversionRulePublicResource, + capiDeliverySummaryPublicResponse, + createAdsConversionRulePublicRequest, + ctwaFunnelPublicResponse, + ctwaFunnelTimeseriesPublicResponse, + getCtwaFunnelPublicRequest, + listAdsConversionExportRowsPublicRequest, + listAdsConversionExportRowsPublicResponse, + listAdsConversionRulesPublicRequest, + listChannelAdAccountsPublicRequest, + listChannelAdAccountsPublicRequestParams, + listChannelAdAccountsPublicResponse, + toggleAdsConversionRulePublicRequest, + updateAdsConversionRulePublicRequest, +} from "../schema/public" + +const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("ads") + +const adsConversionRulesPublicRouter = { + listRules: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/ads/conversion-rules", + summary: "List Ads conversion rules", + tags: ["Ads"], + }) + .input(listAdsConversionRulesPublicRequest) + .output( + z.object({ + data: z.array(adsConversionRulePublicResource), + pageCount: z.number().int(), + }), + ) + .errors(possibleErrorsOnListingResource) + .handler(async ({ context, input: { page, perPage, ...input } }) => { + const rules = await adsConversionService.list({ + ...input, + workspaceId: context.workspace.id, + }) + return paginateInMemory(rules, { page, perPage }) + }), + + getRule: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/ads/conversion-rules/{id}", + summary: "Get an Ads conversion rule", + tags: ["Ads"], + }) + .input(adsConversionRuleIdParams) + .output(adsConversionRulePublicResource) + .errors(possibleErrorsOnFindingResource) + .handler(async ({ context, input }) => + adsConversionService.findOrFail({ + id: input.id, + workspaceId: context.workspace.id, + }), + ), + + createRule: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/ads/conversion-rules", + summary: "Create an Ads conversion rule", + tags: ["Ads"], + }) + .input(createAdsConversionRulePublicRequest) + .output(adsConversionRulePublicResource) + .errors(possibleErrorsOnCreatingResource) + .handler(async ({ context, input }) => + adsConversionService.create({ + ...input, + workspaceId: context.workspace.id, + }), + ), + + updateRule: workspaceTokenAuthAPI + .route({ + method: "PUT", + path: "/v1/ads/conversion-rules/{id}", + summary: "Update an Ads conversion rule", + tags: ["Ads"], + }) + .input(adsConversionRuleIdParams.and(updateAdsConversionRulePublicRequest)) + .output(adsConversionRulePublicResource) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => + adsConversionService.update({ + ...input, + workspaceId: context.workspace.id, + }), + ), + + toggleRuleStatus: workspaceTokenAuthAPI + .route({ + method: "PATCH", + path: "/v1/ads/conversion-rules/{id}/status", + summary: "Enable or disable an Ads conversion rule", + tags: ["Ads"], + }) + .input(adsConversionRuleIdParams.and(toggleAdsConversionRulePublicRequest)) + .output(adsConversionRulePublicResource) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => + adsConversionService.toggleEnabled({ + ...input, + workspaceId: context.workspace.id, + }), + ), + + deleteRule: workspaceTokenAuthAPI + .route({ + method: "DELETE", + path: "/v1/ads/conversion-rules/{id}", + summary: "Delete an Ads conversion rule", + tags: ["Ads"], + }) + .input(adsConversionRuleIdParams) + .output(z.void()) + .errors(possibleErrorsOnDeletingResource) + .handler(async ({ context, input }) => { + await adsConversionService.remove({ + id: input.id, + workspaceId: context.workspace.id, + }) + }), +} + +const adsAnalyticsPublicRouter = { + getFunnel: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/ads/funnel", + summary: + "Get the CTWA/CTM/CTID conversion funnel (conversations/leads/purchases/revenue) per ad", + tags: ["Ads"], + }) + .input(getCtwaFunnelPublicRequest) + .output(ctwaFunnelPublicResponse) + .errors(possibleErrorsOnListingResource) + .handler(async ({ context, input }) => + adsConversionService.getCtwaFunnel({ + ...input, + workspaceId: context.workspace.id, + }), + ), + + getFunnelTimeseries: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/ads/funnel/timeseries", + summary: "Get the CTWA/CTM/CTID conversion funnel, bucketed per day", + tags: ["Ads"], + }) + .input(getCtwaFunnelPublicRequest) + .output(ctwaFunnelTimeseriesPublicResponse) + .errors(possibleErrorsOnListingResource) + .handler(async ({ context, input }) => ({ + data: await adsConversionService.getCtwaFunnelTimeseries({ + ...input, + workspaceId: context.workspace.id, + }), + })), + + getCapiDelivery: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/ads/capi-delivery", + summary: + "Get the Conversions API delivery status breakdown (sent/pending/failed/skipped)", + tags: ["Ads"], + }) + .input(getCtwaFunnelPublicRequest) + .output(capiDeliverySummaryPublicResponse) + .errors(possibleErrorsOnListingResource) + .handler(async ({ context, input }) => + adsConversionService.getCapiDeliverySummary({ + ...input, + workspaceId: context.workspace.id, + }), + ), + + listConversionExportRows: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/ads/conversions/export", + summary: + "Cursor-paginated conversion/lead/purchase rows for export (contact-level — a workspace token sees unmasked contact data, see docs/developer/workspace-api-tokens.md)", + tags: ["Ads"], + }) + .input(listAdsConversionExportRowsPublicRequest) + .output(listAdsConversionExportRowsPublicResponse) + .errors(possibleErrorsOnListingResource) + .handler(async ({ context, input }) => { + const { allChannels, ...rest } = input + const rows = allChannels + ? await adsConversionService.listAllChannelExportRows({ + ...rest, + workspaceId: context.workspace.id, + }) + : await adsConversionService.listExportRows({ + ...rest, + workspaceId: context.workspace.id, + }) + return { + data: rows, + nextAfterId: + rows.length === input.limit ? (rows.at(-1)?.id ?? null) : null, + } + }), + + listChannelAdAccounts: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/ads/{channel}/ad-accounts", + summary: + "List ad accounts for a channel — the union of every connected integration's messaging-ads connection plus the workspace-wide fallback (deduped), or one integration's own connection when integrationId is given", + tags: ["Ads"], + }) + .input( + listChannelAdAccountsPublicRequestParams.extend( + listChannelAdAccountsPublicRequest.shape, + ), + ) + .output(listChannelAdAccountsPublicResponse) + .errors(possibleErrorsOnFindingResource) + .handler(async ({ context, input }) => { + const accounts = await resolveChannelAdAccountSources({ + ...input, + workspaceId: context.workspace.id, + }) + // `sources` is internal provenance — never put it on the wire. + return { + data: accounts.map(({ sources: _sources, ...account }) => account), + } + }), +} + +export const adsPublicRouter = { + ...adsConversionRulesPublicRouter, + ...adsAnalyticsPublicRouter, + ...adsCampaignPublicRouter, +} diff --git a/apps/builder/src/features/ads/schema/public.ts b/apps/builder/src/features/ads/schema/public.ts new file mode 100644 index 0000000000..c56f12e1af --- /dev/null +++ b/apps/builder/src/features/ads/schema/public.ts @@ -0,0 +1,208 @@ +import { + adsConversionExportSegments, + adsConversionRuleResource, +} from "@chatbotx.io/business/ads-conversion/schema" +import { adsConversionChannelSchema } from "@chatbotx.io/database/schema" +import { facebookAdAccountSchema } from "@chatbotx.io/integration-facebook-ads" +import { zodBigintAsString } from "@chatbotx.io/utils" +import { adsEligibleChannelTypes } from "@chatbotx.io/utils/channel" +import { z } from "zod" +import { withPublicPaging } from "@/lib/public-api/list" +import { + createAdsConversionRuleRequest, + toggleAdsConversionRuleRequest, + updateAdsConversionRuleRequest, +} from "./conversion-rule" + +// ───────────────────────────────────────────────────────────────────────── +// Conversion rules — request/response schemas rebuilt for the public +// surface. `workspaceId` never comes from input on the token path; it is +// always injected from `context.workspace.id` in the handler. +// ───────────────────────────────────────────────────────────────────────── + +export const createAdsConversionRulePublicRequest = + createAdsConversionRuleRequest +export const updateAdsConversionRulePublicRequest = + updateAdsConversionRuleRequest.omit({ id: true }) +export const toggleAdsConversionRulePublicRequest = + toggleAdsConversionRuleRequest.omit({ id: true }) + +// `adsConversionRuleResource` includes `workspaceId` (it's a straight +// `createSelectSchema` off the table) — stripped here so no public response +// leaks it (`public-spec-operations.test.ts`'s workspaceId leak guard). +export const adsConversionRulePublicResource = adsConversionRuleResource.omit({ + workspaceId: true, +}) + +export const adsConversionRuleIdParams = z.object({ + id: zodBigintAsString(), +}) + +export const listAdsConversionRulesPublicRequest = withPublicPaging( + z.object({ + channel: adsConversionChannelSchema.optional(), + }), +) + +// ───────────────────────────────────────────────────────────────────────── +// Funnel / CAPI delivery / export — `getCtwaFunnelInput` and the export +// input schemas in `@chatbotx.io/business/ads-conversion/schema` are +// `ZodEffects` (wrapped in `.refine()`), not plain `ZodObject`s, so they +// cannot be `.omit()`-ed. Rebuilt here from their underlying shape, minus +// `workspaceId`, with the same `since <= until` ordering re-applied plus an +// additional public-only range cap (see `MAX_ADS_ANALYTICS_RANGE_DAYS`, +// `features/ads/schema/analytics.ts`) — an unbounded external range would +// fan out into a per-day funnel/Graph aggregation with no dashboard-side +// clamp to protect it. +// ───────────────────────────────────────────────────────────────────────── + +export const MAX_ADS_PUBLIC_RANGE_DAYS = 366 +const MS_PER_DAY = 24 * 60 * 60 * 1000 + +const dateRangeShape = z.object({ + since: z.coerce.date(), + until: z.coerce.date(), +}) + +// Mirrors `withOrderedDateRange` in +// `@chatbotx.io/business/ads-conversion/schema` — constrained to a concrete +// base shape (not a fully generic `z.ZodRawShape`) so the refine callback +// below can see `since`/`until` at all; a bare generic loses those fields to +// the mapped-type projection zod v4 produces for an arbitrary shape. +const withPublicDateRange = ( + schema: Schema, +) => + schema + .refine((input) => input.since.getTime() <= input.until.getTime(), { + message: "since must be before or equal to until", + path: ["until"], + }) + .refine( + (input) => + (input.until.getTime() - input.since.getTime()) / MS_PER_DAY + 1 <= + MAX_ADS_PUBLIC_RANGE_DAYS, + { + message: `Range cannot exceed ${MAX_ADS_PUBLIC_RANGE_DAYS} days`, + path: ["until"], + }, + ) + +const ctwaFunnelPublicShape = dateRangeShape.extend({ + integrationWhatsappId: zodBigintAsString().optional(), + channel: adsConversionChannelSchema.optional(), + integrationMessengerId: zodBigintAsString().optional(), + integrationInstagramId: zodBigintAsString().optional(), + allChannels: z.boolean().optional(), + timezone: z.string().optional(), +}) + +export const getCtwaFunnelPublicRequest = withPublicDateRange( + ctwaFunnelPublicShape, +).refine( + (input) => + !( + input.allChannels && + (input.integrationWhatsappId || + input.integrationMessengerId || + input.integrationInstagramId) + ), + { + message: "allChannels cannot be combined with an integration id", + path: ["allChannels"], + }, +) + +const adsConversionExportPublicShape = dateRangeShape.extend({ + segment: adsConversionExportSegments, + adId: z.string().trim().min(1).nullable().optional(), + integrationWhatsappId: zodBigintAsString().optional(), + channel: adsConversionChannelSchema.optional(), + integrationMessengerId: zodBigintAsString().optional(), + integrationInstagramId: zodBigintAsString().optional(), + allChannels: z.boolean().optional(), + afterId: zodBigintAsString().optional(), + limit: z.number().int().positive().max(1000).default(500), +}) + +export const listAdsConversionExportRowsPublicRequest = withPublicDateRange( + adsConversionExportPublicShape, +).refine((input) => !(input.allChannels && input.channel), { + message: "allChannels cannot be combined with channel", + path: ["allChannels"], +}) + +export const adsConversionExportRowPublicResource = z.object({ + id: z.string(), + contactId: z.string(), + contactName: z.string().nullable(), + phoneNumber: z.string().nullable(), + email: z.string().nullable(), + adId: z.string().nullable(), + occurredAt: z.date(), + channel: z.string().optional(), +}) + +export const listAdsConversionExportRowsPublicResponse = z.object({ + data: z.array(adsConversionExportRowPublicResource), + nextAfterId: z.string().nullable(), +}) + +// ───────────────────────────────────────────────────────────────────────── +// Ad accounts +// ───────────────────────────────────────────────────────────────────────── + +export const listChannelAdAccountsPublicRequestParams = z.object({ + channel: adsEligibleChannelTypes, +}) + +export const listChannelAdAccountsPublicRequest = z.object({ + integrationId: zodBigintAsString().optional(), +}) + +export const listChannelAdAccountsPublicResponse = z.object({ + data: z.array(facebookAdAccountSchema), +}) + +// ───────────────────────────────────────────────────────────────────────── +// Funnel / timeseries / CAPI response shapes +// ───────────────────────────────────────────────────────────────────────── + +export const ctwaFunnelAdRowPublicResource = z.object({ + adId: z.string().nullable(), + adName: z.string().nullable().optional(), + conversations: z.number(), + leads: z.number(), + purchases: z.number(), + revenue: z.number(), + channels: z.array(z.string()).optional(), +}) + +export const ctwaFunnelPublicResponse = z.object({ + totals: z.object({ + conversations: z.number(), + leads: z.number(), + purchases: z.number(), + revenue: z.number(), + }), + perAd: z.array(ctwaFunnelAdRowPublicResource), +}) + +export const ctwaFunnelTimeseriesRowPublicResource = z.object({ + date: z.string(), + adId: z.string().nullable(), + conversations: z.number(), + leads: z.number(), + purchases: z.number(), +}) + +export const ctwaFunnelTimeseriesPublicResponse = z.object({ + data: z.array(ctwaFunnelTimeseriesRowPublicResource), +}) + +export const capiDeliverySummaryPublicResponse = z.object({ + sent: z.number(), + pending: z.number(), + failed: z.number(), + skippedNoScope: z.number(), + skippedRegion: z.number(), +}) diff --git a/apps/builder/src/routers/public.ts b/apps/builder/src/routers/public.ts index 40a7485989..ea9cd15077 100644 --- a/apps/builder/src/routers/public.ts +++ b/apps/builder/src/routers/public.ts @@ -1,4 +1,5 @@ import { inboxTeamsPublicRouter } from "@/enterprise/features/inbox-teams/api/public" +import { adsPublicRouter } from "@/features/ads/api/public" import { aiAgentsPublicRouter } from "@/features/ai-agents/api/public" import { aiTriggersPublicRouter } from "@/features/ai-triggers/api/public" import { analyticsPublicRouter } from "@/features/analytics/api/public" @@ -28,6 +29,7 @@ import { webhooksPublicRouter } from "@/features/webhooks/api/public" import { workspaceMembersPublicRouter } from "@/features/workspace-members/api/public" export const publicRouter = { + ads: adsPublicRouter, aiAgents: aiAgentsPublicRouter, aiTriggers: aiTriggersPublicRouter, analytics: analyticsPublicRouter, diff --git a/docs/developer/workspace-api-tokens.md b/docs/developer/workspace-api-tokens.md index 18ef4f7a54..010b86f341 100644 --- a/docs/developer/workspace-api-tokens.md +++ b/docs/developer/workspace-api-tokens.md @@ -202,6 +202,64 @@ Two invariants to preserve when touching this surface: `triggerRepository.findWithConditions` rather than reintroducing a hardcoded `[]`. +### Ads scope — endpoint-to-scope table + +`ads` shipped in the enum/registry/i18n from day one (alongside `channels`, +`minigames`, `appointments`, `media`) but carried no endpoints until this +table's routes were added — a token scoped to `["ads"]` reached nothing +before. It now covers Ads conversion-rule CRUD, the CTWA/CTM/CTID funnel and +CAPI-delivery reads, the conversion export, ad-account reads, and the full +messaging-ad campaign lifecycle (create/retry/publish/pause/delete + video +upload). Every handler below calls the same `packages/business` service +method the corresponding UI action/oRPC procedure calls +(`.agents/rules/data-access.md`). + +| Resource | Endpoint | Service method | +| --- | --- | --- | +| Conversion rules | `GET /v1/ads/conversion-rules` | `adsConversionService.list` | +| Conversion rules | `GET /v1/ads/conversion-rules/{id}` | `adsConversionService.findOrFail` | +| Conversion rules | `POST /v1/ads/conversion-rules` | `adsConversionService.create` | +| Conversion rules | `PUT /v1/ads/conversion-rules/{id}` | `adsConversionService.update` | +| Conversion rules | `PATCH /v1/ads/conversion-rules/{id}/status` | `adsConversionService.toggleEnabled` | +| Conversion rules | `DELETE /v1/ads/conversion-rules/{id}` | `adsConversionService.remove` | +| Funnel | `GET /v1/ads/funnel` | `adsConversionService.getCtwaFunnel` | +| Funnel | `GET /v1/ads/funnel/timeseries` | `adsConversionService.getCtwaFunnelTimeseries` | +| CAPI delivery | `GET /v1/ads/capi-delivery` | `adsConversionService.getCapiDeliverySummary` | +| Export | `GET /v1/ads/conversions/export` | `adsConversionService.listExportRows` / `listAllChannelExportRows` | +| Ad accounts | `GET /v1/ads/{channel}/ad-accounts` | `resolveChannelAdAccountSources` | +| Campaigns | `GET /v1/ads/campaigns` | `messagingAdCampaignService.list` | +| Campaigns | `POST /v1/ads/campaigns/insights` | `messagingAdCampaignService.listInsights` | +| Campaigns | `POST /v1/ads/campaigns` | `messagingAdCampaignService.createDraft` | +| Campaigns | `POST /v1/ads/campaigns/{operationId}/retry` | `messagingAdCampaignService.retryDraft` | +| Campaigns | `POST /v1/ads/campaigns/{operationId}/publish` | `messagingAdCampaignService.publish` | +| Campaigns | `POST /v1/ads/campaigns/{operationId}/pause` | `messagingAdCampaignService.pause` | +| Campaigns | `DELETE /v1/ads/campaigns/{operationId}` | `messagingAdCampaignService.deleteOperation` | +| Campaigns | `POST /v1/ads/campaigns/upload-video` | `getMessagingAdsContextForIntegration` + `integration.runAction("uploadMessagingAdVideo")` | +| Campaigns | `GET /v1/ads/campaigns/videos/{videoId}/status` | `integration.runAction("getMessagingAdVideoStatus")` | +| Campaigns | `GET /v1/ads/campaigns/messenger-pages` | `messagingAdCampaignService.listMessengerPages` | +| Campaigns | `GET /v1/ads/campaigns/{channel}/{integrationId}/ad-accounts` | `listCachedMessagingAdAccounts` | +| Campaigns | `GET /v1/ads/campaigns/ad-accounts/{adAccountId}` | `getCachedMessagingAdAccountDetails` | +| Campaigns | `GET /v1/ads/campaigns/prerequisites` | `messagingAdsConnectionService.findForIntegration` | + +Two invariants specific to this scope: + +- **The campaign-lifecycle mutations deliberately omit + `assertWorkspaceSuperAdmin`** — present on the private `adsCampaignAPI` + (`features/ads-campaign/api/private.ts`), it resolves the session user via + `getCurrentUserAndTargetWorkspace`. A workspace-token request never has a + session user (the token stack never runs `authMiddleware`), so the private + guard would throw `errors.superAdminRequired` on every token call. Per the + auth-flow section above, a workspace token authenticates the workspace, + not a member, and minting a token already required the caller to be a + workspace superAdmin — so the guard is correctly absent, not an oversight. + Any future ads-campaign endpoint copied from the private router must drop + this guard on the public path, the same way `features/coupons/api/public.ts` + and the contacts public surface never re-check member-level permissions. +- **`createdBy` is `null`/omitted on every token-created campaign** — a token + has no associated user, mirroring the `createdById: null` precedent in + `features/coupons/api/public.ts`. Never resolve it from a session that + does not exist on this path. + ## Adding a new scope value 1. Add the value to `workspaceApiTokenScopes` in @@ -255,8 +313,18 @@ these helpers — import from the business package directly. `folders-public-api.test.ts` — handler-behavior tests, one per public-API submodule (some under `features/contacts/api/public/`, some in the owning sibling feature's own `api/public.ts`) +- `apps/builder/__tests__/ads-public-scope.test.ts` — real-router scope + wiring for both `features/ads/api/public.ts` and + `features/ads-campaign/api/public.ts` (merged into one `ads` router) +- `apps/builder/__tests__/ads-public-api.test.ts`, + `ads-campaign-public-api.test.ts` — handler-behavior tests; the latter + asserts a campaign mutation succeeds with no session user in context (the + `assertWorkspaceSuperAdmin` regression guard) and that `createdBy` is never + set from one - `apps/builder/__tests__/create-workspace-token-action.test.ts` - `apps/builder/__tests__/delete-workspace-token-action.test.ts` - `apps/builder/__tests__/integration-api-token-hash.test.ts` - `packages/business/__tests__/workspace-api-token.service.test.ts` +- `packages/business/__tests__/ads-conversion-rule.service.test.ts` + (`findOrFail`) - `packages/variables/__tests__/system-fields.test.ts` (`{{api_key}}`) diff --git a/packages/business/__tests__/ads-conversion-rule.service.test.ts b/packages/business/__tests__/ads-conversion-rule.service.test.ts index dcbaa33ec6..730e298c1f 100644 --- a/packages/business/__tests__/ads-conversion-rule.service.test.ts +++ b/packages/business/__tests__/ads-conversion-rule.service.test.ts @@ -630,6 +630,29 @@ describe("AdsConversionService", () => { ) }) + describe("findOrFail", () => { + test("returns the rule when it exists in the caller's workspace", async () => { + const rule = await adsConversionService.findOrFail({ + id: "301", + workspaceId: "1", + }) + + expect(mocks.findWorkspaceRule).toHaveBeenCalledWith( + { id: "301", workspaceId: "1" }, + undefined, + ) + expect(rule).toMatchObject({ id: "301", workspaceId: "1" }) + }) + + test("throws when the rule does not exist in the caller's workspace", async () => { + mocks.findWorkspaceRule.mockResolvedValueOnce(null) + + await expect( + adsConversionService.findOrFail({ id: "999", workspaceId: "1" }), + ).rejects.toThrow("Ads conversion rule not found") + }) + }) + test("maps automatic LeadSubmitted events to lead rows with attribution", async () => { await expect( adsConversionService.ingestAutomaticEvent({ diff --git a/packages/business/src/ads-conversion/service.ts b/packages/business/src/ads-conversion/service.ts index 3a3edb68b9..748273effb 100644 --- a/packages/business/src/ads-conversion/service.ts +++ b/packages/business/src/ads-conversion/service.ts @@ -814,6 +814,27 @@ class AdsConversionService extends BaseService { ) } + /** + * Single-rule read, workspace-scoped. `removeAdsConversionRuleInput`'s + * `{ id, workspaceId }` shape is reused rather than adding a near-duplicate + * schema — every field it validates (both bigint-string ids) is exactly + * what this lookup needs. Not a repository call from the handler layer: a + * `GET` by id owns the not-found contract (same message as + * `update`/`toggleEnabled`/`remove`), which makes it business logic, not a + * pure read (`.agents/rules/data-access.md`). + */ + async findOrFail( + input: RemoveAdsConversionRuleInput, + tx?: DatabaseClient, + ): Promise { + const parsed = removeAdsConversionRuleInput.parse(input) + const rule = await adsConversionRuleRepository.findWorkspaceRule(parsed, tx) + if (!rule) { + throw new ChatbotXException("Ads conversion rule not found") + } + return rule + } + async create( input: CreateAdsConversionRuleInput, tx?: DatabaseClient,