From 91ed5c1bbfd37f4f546458fb41ee66f83f4b7752 Mon Sep 17 00:00:00 2001 From: Greg Methvin Date: Mon, 23 Feb 2026 18:46:25 -0800 Subject: [PATCH] Separate triggered and blast campaigns --- src/client/campaigns.ts | 14 ++- src/types/campaigns.ts | 111 ++++++++++---------- tests/integration/campaigns.test.ts | 38 ++++--- tests/unit/campaigns.test.ts | 150 ++++++++++++++++++++++++++++ 4 files changed, 246 insertions(+), 67 deletions(-) diff --git a/src/client/campaigns.ts b/src/client/campaigns.ts index 960edab..f342fa4 100644 --- a/src/client/campaigns.ts +++ b/src/client/campaigns.ts @@ -6,9 +6,10 @@ import { ArchiveCampaignsResponseSchema, CampaignMetricsResponse, CancelCampaignParams, - CreateCampaignParams, + CreateAndScheduleCampaignParams, CreateCampaignResponse, CreateCampaignResponseSchema, + CreateTriggeredCampaignParams, DeactivateTriggeredCampaignParams, GetCampaignMetricsParams, GetCampaignParams, @@ -75,8 +76,15 @@ export function Campaigns>(Base: T) { return this.validateResponse(response, GetCampaignResponseSchema); } - async createCampaign( - params: CreateCampaignParams + async createAndScheduleCampaign( + params: CreateAndScheduleCampaignParams + ): Promise { + const response = await this.client.post("/api/campaigns/create", params); + return this.validateResponse(response, CreateCampaignResponseSchema); + } + + async createTriggeredCampaign( + params: CreateTriggeredCampaignParams ): Promise { const response = await this.client.post("/api/campaigns/create", params); return this.validateResponse(response, CreateCampaignResponseSchema); diff --git a/src/types/campaigns.ts b/src/types/campaigns.ts index 6b61c56..72a346a 100644 --- a/src/types/campaigns.ts +++ b/src/types/campaigns.ts @@ -117,7 +117,6 @@ export type GetCampaignMetricsParams = z.infer< export type CampaignMetricsResponse = z.infer< typeof CampaignMetricsResponseSchema >; -export type CreateCampaignParams = z.infer; export type CreateCampaignResponse = z.infer< typeof CreateCampaignResponseSchema >; @@ -145,56 +144,68 @@ export const CampaignMetricsResponseSchema = z .array(z.record(z.string(), z.string())) .describe("Parsed campaign metrics data"); -// Campaign creation schemas -export const CreateCampaignParamsSchema = z - .object({ - name: z - .string() - .describe("The name to use in Iterable for the new campaign"), - templateId: z - .number() - .describe("The ID of a template to associate with the new campaign"), - listIds: z - .array(z.number()) - .describe( - "Array of list IDs to which the campaign should be sent (for blast campaigns)" - ) - .optional(), - campaignDataFields: z - .record(z.string(), z.any()) - .optional() - .describe( - "A JSON object containing campaign-level data fields that are available as merge parameters (for example, {{field}}) during message rendering. These fields are available in templates, data feed URLs, and all other contexts where merge parameters are supported. Campaign-level fields are overridden by user and event data fields of the same name." - ), - sendAt: IterableDateTimeSchema.optional().describe( - "Scheduled send time for blast campaign (YYYY-MM-DD HH:MM:SS UTC). Required when listIds is provided." +// Shared campaign data fields used by both blast and triggered campaign creation +const campaignDataFieldsSchema = z + .record(z.string(), z.any()) + .optional() + .describe( + "A JSON object containing campaign-level data fields that are available as merge parameters (for example, {{field}}) during message rendering. These fields are available in templates, data feed URLs, and all other contexts where merge parameters are supported. Campaign-level fields are overridden by user and event data fields of the same name." + ); + +// Create and schedule a blast campaign +export const CreateAndScheduleCampaignParamsSchema = z.object({ + name: z + .string() + .describe("The name to use in Iterable for the new campaign"), + templateId: z + .number() + .describe("The ID of a template to associate with the new campaign"), + listIds: z + .array(z.number()) + .min(1) + .describe("Array of list IDs to which the campaign should be sent"), + sendAt: IterableDateTimeSchema.describe( + "Scheduled send time (YYYY-MM-DD HH:MM:SS UTC)" + ), + campaignDataFields: campaignDataFieldsSchema, + sendMode: z + .enum(["ProjectTimeZone", "RecipientTimeZone"]) + .optional() + .describe("Send mode for blast campaigns"), + startTimeZone: z + .string() + .optional() + .describe("Starting timezone for recipient timezone sends (IANA format)"), + defaultTimeZone: z + .string() + .optional() + .describe( + "Default timezone for recipients without known timezone (IANA format)" ), - sendMode: z - .enum(["ProjectTimeZone", "RecipientTimeZone"]) - .optional() - .describe("Send mode for blast campaigns"), - startTimeZone: z - .string() - .optional() - .describe( - "Starting timezone for recipient timezone sends (IANA format)" - ), - defaultTimeZone: z - .string() - .optional() - .describe( - "Default timezone for recipients without known timezone (IANA format)" - ), - suppressionListIds: z - .array(z.number()) - .optional() - .describe("Array of suppression list IDs"), - }) - .refine((data) => !data.listIds?.length || data.sendAt, { - message: - "sendAt is required for blast campaigns (when listIds is provided).", - path: ["sendAt"], - }); + suppressionListIds: z + .array(z.number()) + .optional() + .describe("Array of suppression list IDs"), +}); + +export type CreateAndScheduleCampaignParams = z.infer< + typeof CreateAndScheduleCampaignParamsSchema +>; + +// Create a triggered campaign +export const CreateTriggeredCampaignParamsSchema = z.object({ + name: z + .string() + .describe("The name to use in Iterable for the new campaign"), + templateId: z + .number() + .describe("The ID of a template to associate with the new campaign"), + campaignDataFields: campaignDataFieldsSchema, +}); + +export type CreateTriggeredCampaignParams = z.infer< + typeof CreateTriggeredCampaignParamsSchema +>; export const CreateCampaignResponseSchema = z.object({ campaignId: z.number(), diff --git a/tests/integration/campaigns.test.ts b/tests/integration/campaigns.test.ts index e2f81e0..d7d213b 100644 --- a/tests/integration/campaigns.test.ts +++ b/tests/integration/campaigns.test.ts @@ -28,15 +28,26 @@ describe("Campaign Management Integration Tests", () => { return parseInt(templateIdMatch[1]); }; - const createTestCampaign = async (params: { + const createTestBlastCampaign = async (params: { name: string; templateId: number; - listIds?: number[]; - sendAt?: string; + listIds: number[]; + sendAt: string; }) => { const createResponse = await retryRateLimited( - () => withTimeout(client.createCampaign(params)), - `Create campaign: ${params.name}` + () => withTimeout(client.createAndScheduleCampaign(params)), + `Create blast campaign: ${params.name}` + ); + return createResponse.campaignId; + }; + + const createTestTriggeredCampaign = async (params: { + name: string; + templateId: number; + }) => { + const createResponse = await retryRateLimited( + () => withTimeout(client.createTriggeredCampaign(params)), + `Create triggered campaign: ${params.name}` ); return createResponse.campaignId; }; @@ -464,7 +475,7 @@ describe("Campaign Management Integration Tests", () => { it("should create and archive a test campaign", async () => { const campaignName = uniqueId("MCP-Test-Campaign"); - const campaignId = await createTestCampaign({ + const campaignId = await createTestTriggeredCampaign({ name: campaignName, templateId: testTemplateId, }); @@ -507,7 +518,7 @@ describe("Campaign Management Integration Tests", () => { const sendAtDate = new Date(Date.now() + 24 * 60 * 60 * 1000); const sendAt = sendAtDate.toISOString().replace('T', ' ').substring(0, 19); - const campaignId = await createTestCampaign({ + const campaignId = await createTestBlastCampaign({ name: campaignName, templateId: testTemplateId, listIds: [testListId], @@ -546,12 +557,12 @@ describe("Campaign Management Integration Tests", () => { }, 60000); it("should archive multiple campaigns at once", async () => { - const campaignId1 = await createTestCampaign({ + const campaignId1 = await createTestTriggeredCampaign({ name: uniqueId("MCP-Test-Bulk-1"), templateId: testTemplateId, }); - const campaignId2 = await createTestCampaign({ + const campaignId2 = await createTestTriggeredCampaign({ name: uniqueId("MCP-Test-Bulk-2"), templateId: testTemplateId, }); @@ -588,7 +599,7 @@ describe("Campaign Management Integration Tests", () => { }, 60000); it("should abort a campaign", async () => { - const campaignId = await createTestCampaign({ + const campaignId = await createTestTriggeredCampaign({ name: uniqueId("MCP-Test-Abort"), templateId: testTemplateId, }); @@ -611,8 +622,7 @@ describe("Campaign Management Integration Tests", () => { }, 60000); it("should activate and deactivate a triggered campaign", async () => { - // No listIds = Triggered campaign - const campaignId = await createTestCampaign({ + const campaignId = await createTestTriggeredCampaign({ name: uniqueId("MCP-Test-Triggered"), templateId: testTemplateId, }); @@ -659,7 +669,7 @@ describe("Campaign Management Integration Tests", () => { }, 60000); it("should trigger a campaign", async () => { - const campaignId = await createTestCampaign({ + const campaignId = await createTestTriggeredCampaign({ name: uniqueId("MCP-Test-Trigger"), templateId: testTemplateId, }); @@ -696,7 +706,7 @@ describe("Campaign Management Integration Tests", () => { const sendAtDate = new Date(Date.now() + 24 * 60 * 60 * 1000); const sendAt = sendAtDate.toISOString().replace('T', ' ').substring(0, 19); - const campaignId = await createTestCampaign({ + const campaignId = await createTestBlastCampaign({ name: uniqueId("MCP-Test-Send"), templateId: testTemplateId, listIds: [testListId], diff --git a/tests/unit/campaigns.test.ts b/tests/unit/campaigns.test.ts index d172979..59443db 100644 --- a/tests/unit/campaigns.test.ts +++ b/tests/unit/campaigns.test.ts @@ -14,6 +14,8 @@ import { ArchiveCampaignsParamsSchema, CampaignDetailsSchema, CancelCampaignParamsSchema, + CreateAndScheduleCampaignParamsSchema, + CreateTriggeredCampaignParamsSchema, DeactivateTriggeredCampaignParamsSchema, GetCampaignMetricsParamsSchema, GetCampaignParamsSchema, @@ -778,5 +780,153 @@ describe("Campaign Management", () => { SendCampaignParamsSchema.parse({ campaignId: "invalid" }) ).toThrow(); // invalid type }); + + it("should validate createAndScheduleCampaign parameters", () => { + // Valid parameters - all required fields + expect(() => + CreateAndScheduleCampaignParamsSchema.parse({ + name: "Test Campaign", + templateId: 123, + listIds: [456], + sendAt: "2026-03-01 10:00:00", + }) + ).not.toThrow(); + + // Valid with optional fields + expect(() => + CreateAndScheduleCampaignParamsSchema.parse({ + name: "Test Campaign", + templateId: 123, + listIds: [456, 789], + sendAt: "2026-03-01 10:00:00", + sendMode: "RecipientTimeZone", + startTimeZone: "America/New_York", + defaultTimeZone: "America/Los_Angeles", + suppressionListIds: [100], + campaignDataFields: { key: "value" }, + }) + ).not.toThrow(); + + // Missing sendAt + expect(() => + CreateAndScheduleCampaignParamsSchema.parse({ + name: "Test Campaign", + templateId: 123, + listIds: [456], + }) + ).toThrow(); + + // Missing listIds + expect(() => + CreateAndScheduleCampaignParamsSchema.parse({ + name: "Test Campaign", + templateId: 123, + sendAt: "2026-03-01 10:00:00", + }) + ).toThrow(); + + // Empty listIds + expect(() => + CreateAndScheduleCampaignParamsSchema.parse({ + name: "Test Campaign", + templateId: 123, + listIds: [], + sendAt: "2026-03-01 10:00:00", + }) + ).toThrow(); + + // Missing name + expect(() => + CreateAndScheduleCampaignParamsSchema.parse({ + templateId: 123, + listIds: [456], + sendAt: "2026-03-01 10:00:00", + }) + ).toThrow(); + + // Missing templateId + expect(() => + CreateAndScheduleCampaignParamsSchema.parse({ + name: "Test Campaign", + listIds: [456], + sendAt: "2026-03-01 10:00:00", + }) + ).toThrow(); + }); + + it("should validate createTriggeredCampaign parameters", () => { + // Valid parameters - required fields only + expect(() => + CreateTriggeredCampaignParamsSchema.parse({ + name: "Test Triggered Campaign", + templateId: 123, + }) + ).not.toThrow(); + + // Valid with optional campaignDataFields + expect(() => + CreateTriggeredCampaignParamsSchema.parse({ + name: "Test Triggered Campaign", + templateId: 123, + campaignDataFields: { key: "value" }, + }) + ).not.toThrow(); + + // Missing name + expect(() => + CreateTriggeredCampaignParamsSchema.parse({ + templateId: 123, + }) + ).toThrow(); + + // Missing templateId + expect(() => + CreateTriggeredCampaignParamsSchema.parse({ + name: "Test Triggered Campaign", + }) + ).toThrow(); + }); + }); + + describe("createAndScheduleCampaign", () => { + it("should call campaigns/create endpoint with blast params", async () => { + const mockResponse = { data: { campaignId: 12345 } }; + mockAxiosInstance.post.mockResolvedValue(mockResponse); + + const params = { + name: "Test Blast", + templateId: 100, + listIds: [200], + sendAt: "2026-03-01 10:00:00", + }; + + const result = await client.createAndScheduleCampaign(params); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + "/api/campaigns/create", + params + ); + expect(result).toEqual({ campaignId: 12345 }); + }); + }); + + describe("createTriggeredCampaign", () => { + it("should call campaigns/create endpoint with triggered params", async () => { + const mockResponse = { data: { campaignId: 67890 } }; + mockAxiosInstance.post.mockResolvedValue(mockResponse); + + const params = { + name: "Test Triggered", + templateId: 100, + }; + + const result = await client.createTriggeredCampaign(params); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + "/api/campaigns/create", + params + ); + expect(result).toEqual({ campaignId: 67890 }); + }); }); });