From 1e61008a28975a85bf917dd7dff33c03bde09270 Mon Sep 17 00:00:00 2001 From: Greg Methvin Date: Wed, 18 Feb 2026 22:18:50 -0800 Subject: [PATCH] Use pagination for /api/templates --- README.md | 2 + api-docs.json | 544 ++++++++++++++++++++++++++-- src/client/templates.ts | 20 +- src/types/templates.ts | 71 +++- tests/integration/campaigns.test.ts | 2 +- tests/integration/templates.test.ts | 63 +++- tests/unit/templates.test.ts | 9 +- 7 files changed, 649 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index a5a023f..c97a82e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Iterable API Client +[![npm version](https://img.shields.io/npm/v/@iterable/api.svg)](https://www.npmjs.com/package/@iterable/api) + TypeScript client library for the [Iterable API](https://api.iterable.com/api/docs). This library is currently in active development. While it is used in production by the [Iterable MCP server](https://github.com/Iterable/mcp-server), it is still considered experimental. We are rapidly iterating on features and improvements, so you may encounter breaking changes or incomplete type definitions. diff --git a/api-docs.json b/api-docs.json index 2eda7a7..86f50bc 100644 --- a/api-docs.json +++ b/api-docs.json @@ -46,7 +46,7 @@ "type": "object" }, "campaignDataFields": { - "description": "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields have higher priority than project-level fields but lower priority than user/event data.", + "description": "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name.", "type": "object" }, "ccEmails": { @@ -57,7 +57,7 @@ "type": "array" }, "clientTemplateId": { - "description": "Client template Id. Used as a secondary key to reference the template", + "description": "Client template ID. Used as a secondary key to reference the template", "type": "string" }, "creatorUserId": { @@ -250,6 +250,69 @@ }, "type": "object" }, + "ApiEmbeddedTemplateModel": { + "properties": { + "body": { + "description": "Body text of the embedded message", + "type": "string" + }, + "campaignDataFields": { + "description": "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name.", + "type": "object" + }, + "campaignId": { + "description": "Campaign ID", + "format": "int32", + "type": "integer" + }, + "clientTemplateId": { + "description": "Client template ID. Used as a secondary key to reference the template", + "type": "string" + }, + "elements": { + "$ref": "#/definitions/Elements", + "description": "Elements (buttons, media, text fields) for the embedded message" + }, + "isDefaultLocale": { + "description": "Ask your Iterable CSM to enroll you in the beta for this feature.\n\nIdentifies if the locale associated with the response is the template's default. If empty or flexible default locales are not enabled for the project, the project's default locale is assigned.", + "type": "boolean" + }, + "locale": { + "description": "The locale for the content in this request. Leave empty for default locale. Iterable automatically sends the content with a locale that matches a user profile's locale field.", + "type": "string" + }, + "messageTypeId": { + "description": "Message type ID", + "format": "int32", + "type": "integer" + }, + "name": { + "description": "Name of the template", + "type": "string" + }, + "payload": { + "description": "Payload", + "type": "object" + }, + "placementId": { + "description": "Placement ID that this template is associated with", + "type": "object" + }, + "templateId": { + "description": "Embedded message template ID", + "format": "int64", + "type": "integer" + }, + "title": { + "description": "Title of the embedded message", + "type": "string" + } + }, + "required": [ + "templateId" + ], + "type": "object" + }, "ApiInAppMessage": { "properties": { "campaignId": { @@ -336,7 +399,7 @@ "ApiInAppTemplateModel": { "properties": { "campaignDataFields": { - "description": "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields have higher priority than project-level fields but lower priority than user/event data.", + "description": "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name.", "type": "object" }, "campaignId": { @@ -345,7 +408,7 @@ "type": "integer" }, "clientTemplateId": { - "description": "Client template Id. Used as a secondary key to reference the template", + "description": "Client template ID. Used as a secondary key to reference the template", "type": "string" }, "expirationDateTime": { @@ -423,7 +486,7 @@ "type": "boolean" }, "campaignDataFields": { - "description": "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields have higher priority than project-level fields but lower priority than user/event data.", + "description": "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name.", "type": "object" }, "campaignId": { @@ -431,7 +494,7 @@ "type": "object" }, "clientTemplateId": { - "description": "Client template Id. Used as a secondary key to reference the template", + "description": "Client template ID. Used as a secondary key to reference the template", "type": "string" }, "createdAt": { @@ -550,7 +613,7 @@ "ApiSMSTemplateModel": { "properties": { "campaignDataFields": { - "description": "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields have higher priority than project-level fields but lower priority than user/event data.", + "description": "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name.", "type": "object" }, "campaignId": { @@ -558,7 +621,7 @@ "type": "object" }, "clientTemplateId": { - "description": "Client template Id. Used as a secondary key to reference the template", + "description": "Client template ID. Used as a secondary key to reference the template", "type": "string" }, "createdAt": { @@ -1600,6 +1663,46 @@ ], "type": "object" }, + "Cookie": { + "properties": { + "asJava": { + "$ref": "#/definitions/Cookie" + }, + "domain": { + "type": "string" + }, + "httpOnly": { + "type": "boolean" + }, + "maxAge": { + "type": "object" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "sameSite": { + "$ref": "#/definitions/SameSite" + }, + "secure": { + "type": "boolean" + }, + "value": { + "type": "string" + } + }, + "required": [ + "asJava", + "httpOnly", + "name", + "path", + "secure", + "value" + ], + "type": "object" + }, "CreateCampaignRequest": { "properties": { "campaignDataFields": { @@ -1872,6 +1975,37 @@ ], "type": "object" }, + "Elements": { + "properties": { + "buttons": { + "description": "Buttons to display with the embedded message. Each template may have up to two buttons with associated open URLs or custom actions.", + "items": { + "$ref": "#/definitions/Button" + }, + "type": "array" + }, + "defaultAction": { + "$ref": "#/definitions/Action", + "description": "The open action that occurs when a user clicks on the embedded message itself." + }, + "mediaUrl": { + "description": "URL for image or video content to display in the embedded message.", + "type": "string" + }, + "mediaUrlCaption": { + "description": "Alt text for the media specified by mediaUrl.", + "type": "string" + }, + "text": { + "description": "Custom text fields for the template. Each custom text field has a label key that is predefined by the template's associated placement, and a configurable text value.", + "items": { + "$ref": "#/definitions/Text" + }, + "type": "array" + } + }, + "type": "object" + }, "EmbeddedClickRequest": { "properties": { "buttonIdentifier": { @@ -1983,6 +2117,40 @@ }, "type": "object" }, + "ErrorHttpResponse": { + "properties": { + "code": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/JsObject" + }, + "error": { + "$ref": "#/definitions/ErrorWithStatus" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "data", + "error", + "message" + ], + "type": "object" + }, + "ErrorWithStatus": { + "properties": { + "status": { + "$ref": "#/definitions/Status" + } + }, + "required": [ + "status" + ], + "type": "object" + }, "ExperimentMetricsResponse": { "properties": { "headers": { @@ -2177,6 +2345,28 @@ }, "type": "object" }, + "Flash": { + "properties": { + "asJava": { + "$ref": "#/definitions/Flash" + }, + "data": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "empty": { + "type": "boolean" + } + }, + "required": [ + "asJava", + "data", + "empty" + ], + "type": "object" + }, "FrequencyCap": { "properties": { "days": { @@ -2368,11 +2558,23 @@ }, "GetTemplatesResponse": { "properties": { + "nextPageUrl": { + "description": "The URL to the next page of templates, if applicable.", + "type": "string" + }, + "previousPageUrl": { + "description": "The URL to the previous page of templates, if applicable.", + "type": "string" + }, "templates": { "items": { "$ref": "#/definitions/ApiTemplateResponse" }, "type": "array" + }, + "totalTemplatesCount": { + "description": "The total count of templates across all pages for the supplied query. Only present when using pagination.", + "type": "object" } }, "required": [ @@ -2405,6 +2607,17 @@ ], "type": "object" }, + "HttpEntity": { + "properties": { + "knownEmpty": { + "type": "boolean" + } + }, + "required": [ + "knownEmpty" + ], + "type": "object" + }, "Impression": { "properties": { "displayCount": { @@ -3380,6 +3593,28 @@ ], "type": "object" }, + "ResponseHeader": { + "properties": { + "headers": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "reasonPhrase": { + "type": "string" + }, + "status": { + "format": "int32", + "type": "integer" + } + }, + "required": [ + "headers", + "status" + ], + "type": "object" + }, "RichMediaURL": { "properties": { "android": { @@ -3461,6 +3696,17 @@ }, "type": "object" }, + "SameSite": { + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "type": "object" + }, "ScheduleCampaignRequest": { "properties": { "recipientTimeZone": { @@ -3685,6 +3931,38 @@ ], "type": "object" }, + "Status": { + "properties": { + "attrs": { + "$ref": "#/definitions/TypedMap" + }, + "body": { + "$ref": "#/definitions/HttpEntity" + }, + "header": { + "$ref": "#/definitions/ResponseHeader" + }, + "newCookies": { + "items": { + "$ref": "#/definitions/Cookie" + }, + "type": "array" + }, + "newFlash": { + "$ref": "#/definitions/Flash" + }, + "newSession": { + "$ref": "#/definitions/Session" + } + }, + "required": [ + "attrs", + "body", + "header", + "newCookies" + ], + "type": "object" + }, "SubscribeRequest": { "properties": { "listId": { @@ -3974,10 +4252,6 @@ }, "Text": { "properties": { - "id": { - "description": "Deprecated field. Do not use. Use label as a key.", - "type": "string" - }, "label": { "description": "Identifier for the text field, specified by the user who created its associated placement. This field is a key, not content. Do not display it.", "type": "string" @@ -4190,6 +4464,9 @@ ], "type": "object" }, + "TypedMap": { + "type": "object" + }, "UnsubscribeRequest": { "properties": { "campaignId": { @@ -4388,7 +4665,7 @@ "type": "array" }, "clientTemplateId": { - "description": "Id used by the client to identify a template. If multiple templates exist with the Id, all will be updated", + "description": "ID used by the client to identify a template. If multiple templates exist with the ID, all will be updated", "type": "string" }, "creatorUserId": { @@ -4473,10 +4750,63 @@ ], "type": "object" }, + "UpsertEmbeddedTemplateModel": { + "properties": { + "body": { + "description": "Body text of the embedded message", + "type": "string" + }, + "clientTemplateId": { + "description": "ID used by the client to identify a template. If multiple templates exist with the ID, all will be updated", + "type": "string" + }, + "creatorUserId": { + "description": "Specify a specific creator user ID (email). The email must be an existing member of the project. Defaults to the organization creator.", + "type": "string" + }, + "elements": { + "$ref": "#/definitions/Elements", + "description": "Elements (buttons, media, text fields) for the embedded message" + }, + "isDefaultLocale": { + "description": "Ask your Iterable CSM to enroll you in the beta for this feature.\n\nSets the locale associated with the request content as the template's default. If empty or flexible default locales are not enabled for the project, the project's default locale is assigned.", + "type": "boolean" + }, + "locale": { + "description": "The locale for the content in this request. Leave empty for default locale. Iterable will automatically send the content with locale that matches a 'locale' field in the user profile.", + "type": "string" + }, + "messageTypeId": { + "description": "Message type ID", + "format": "int32", + "type": "integer" + }, + "name": { + "description": "Name of the template", + "type": "string" + }, + "payload": { + "$ref": "#/definitions/JsObject", + "description": "Payload" + }, + "placementId": { + "description": "Placement ID that this template is associated with", + "type": "object" + }, + "title": { + "description": "Title of the embedded message", + "type": "string" + } + }, + "required": [ + "clientTemplateId" + ], + "type": "object" + }, "UpsertInAppTemplateModel": { "properties": { "clientTemplateId": { - "description": "Id used by the client to identify a template. If multiple templates exist with the Id, all will be updated", + "description": "ID used by the client to identify a template. If multiple templates exist with the ID, all will be updated", "type": "string" }, "creatorUserId": { @@ -4553,7 +4883,7 @@ "type": "boolean" }, "clientTemplateId": { - "description": "Id used by the client to identify a template. If multiple templates exist with the Id, all will be updated", + "description": "ID used by the client to identify a template. If multiple templates exist with the ID, all will be updated", "type": "string" }, "creatorUserId": { @@ -4644,7 +4974,7 @@ "UpsertSMSTemplateModel": { "properties": { "clientTemplateId": { - "description": "Id used by the client to identify a template. If multiple templates exist with the Id, all will be updated", + "description": "ID used by the client to identify a template. If multiple templates exist with the ID, all will be updated", "type": "string" }, "creatorUserId": { @@ -5061,7 +5391,7 @@ }, "/api/campaigns/create": { "post": { - "description": "Creates a new blast or triggered campaign from an existing template. This endpoint can create email, push notification, web push notification, SMS, and in-app message campaigns, but not embedded message campaigns. Important note: Global suppression lists are not automatically added to campaigns created from this endpoint. To include a global suppression list, include it in the suppressionListIds request parameter. To learn more about creating a campaign with this API, see our API Overview.", + "description": "Creates a new blast or triggered campaign from an existing template. This endpoint can create email, push notification, web push notification, SMS, in-app message, and embedded message campaigns. Important note: Global suppression lists are not automatically added to campaigns created from this endpoint. To include a global suppression list, include it in the suppressionListIds request parameter. To learn more about creating a campaign with this API, see our API Overview.", "operationId": "create campaign", "parameters": [ { @@ -8540,7 +8870,7 @@ }, "/api/subscriptions/subscribeToDoubleOptIn": { "post": { - "description": "This endpoint triggers a double opt-in subscription for a user.

Once the user responds to the subscription confirmation message, they will be subscribed to the message types specified in the request body.

This endpoint can only be used with SMS, double opt-in message types. To enable it, contact your customer success manager.

Learn about identifying users by userId and email.", + "description": "This endpoint triggers a double opt-in subscription for a user.

Once the user responds to the subscription confirmation message, they will be subscribed to the message types specified in the request body.

This endpoint can only be used with SMS, double opt-in message types. To enable it, contact your customer success manager.

Learn about identifying users by userId and email.

Response Format Notes:", "operationId": "subscribeSingleUserToDoubleOptIn", "parameters": [ { @@ -8554,26 +8884,29 @@ } ], "responses": { - "200": { - "description": "successful operation", - "schema": { - "$ref": "#/definitions/IterableApiResponse" - } - }, "202": { - "description": "Accepted a request to subscribe" + "description": "Request accepted (plain text: 'Request for double opt-in subscription accepted')" }, "400": { - "description": "Invalid parameters" + "description": "Invalid parameters - returns EITHER JSON {error, message, code, data} for request parsing errors OR plain text for validation errors (e.g., 'User not found for userId: X or email: Y')", + "schema": { + "$ref": "#/definitions/ErrorHttpResponse" + } }, "401": { - "description": "Invalid API key" + "description": "Invalid API key (JSON: {msg, code, params})", + "schema": { + "$ref": "#/definitions/IterableApiResponse" + } }, "404": { - "description": "Endpoint not found" + "description": "Endpoint not found (JSON: {msg, code, params})", + "schema": { + "$ref": "#/definitions/IterableApiResponse" + } }, "500": { - "description": "Internal server error" + "description": "Internal server error (plain text)" } }, "summary": "Trigger a double opt-in subscription flow", @@ -8875,7 +9208,7 @@ }, "/api/templates": { "get": { - "description": "Get templates for a project. Rate limit: 100 requests/second, per project.", + "description": "Get templates for a project. Use page and pageSize parameters to paginate results. If the unpaginated templates API is allowed for your project, then all templates are returned if no pagination parameters are provided. The unpaginated behavior is deprecated and may be removed in the future. Rate limit: 100 requests/second, per project.", "operationId": "getTemplates", "parameters": [ { @@ -8921,6 +9254,35 @@ "name": "endDateTime", "required": false, "type": "string" + }, + { + "default": 1, + "description": "Page number (starting at 1).", + "format": "int32", + "in": "query", + "name": "page", + "required": false, + "type": "integer", + "x-example": 1 + }, + { + "default": 20, + "description": "Number of results to return per page (defaults to 20, maximum of 1000).", + "format": "int32", + "in": "query", + "name": "pageSize", + "required": false, + "type": "integer", + "x-example": 25 + }, + { + "default": "id", + "description": "Field to sort templates by, with optional direction prefix. Use - for descending, + or no prefix for ascending. Templates can be sorted by id, name, createdAt, or updatedAt. Examples: -createdAt, +name, id", + "in": "query", + "name": "sort", + "required": false, + "type": "string", + "x-example": "id" } ], "responses": { @@ -9151,7 +9513,7 @@ }, "/api/templates/email/upsert": { "post": { - "description": "Create email template if it doesn't exist already, otherwise update all email templates which match the name provided.", + "description": "Create email template if it doesn't exist already, otherwise update all email templates that match the name provided.", "operationId": "upsertEmailTemplate", "parameters": [ { @@ -9184,6 +9546,120 @@ ] } }, + "/api/templates/embedded/get": { + "get": { + "description": "Rate limit: 100 requests/second, per project.", + "operationId": "getEmbeddedTemplate", + "parameters": [ + { + "description": "Template ID", + "format": "int64", + "in": "query", + "name": "templateId", + "required": true, + "type": "integer" + }, + { + "description": "Locale of content to get", + "in": "query", + "name": "locale", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "$ref": "#/definitions/ApiEmbeddedTemplateModel" + } + }, + "400": { + "description": "Invalid parameters" + }, + "401": { + "description": "Invalid API key" + }, + "404": { + "description": "Content does not exist for specified locale" + } + }, + "summary": "Get an embedded message template", + "tags": [ + "templates" + ] + } + }, + "/api/templates/embedded/update": { + "post": { + "description": "", + "operationId": "updateEmbeddedTemplate", + "parameters": [ + { + "description": "Only the fields specified will be updated", + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ApiEmbeddedTemplateModel" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "$ref": "#/definitions/IterableApiResponse" + } + }, + "400": { + "description": "Invalid parameters" + }, + "401": { + "description": "Invalid API key" + } + }, + "summary": "Update embedded message template", + "tags": [ + "templates" + ] + } + }, + "/api/templates/embedded/upsert": { + "post": { + "description": "Create an embedded message template if it doesn't exist yet, otherwise update all embedded message templates that match the name provided.", + "operationId": "upsertEmbeddedTemplate", + "parameters": [ + { + "description": "Only the fields specified will be updated", + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpsertEmbeddedTemplateModel" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "$ref": "#/definitions/IterableApiResponse" + } + }, + "400": { + "description": "Invalid parameters" + }, + "401": { + "description": "Invalid API key" + } + }, + "summary": "Create an embedded message template", + "tags": [ + "templates" + ] + } + }, "/api/templates/getByClientTemplateId": { "get": { "description": "Rate limit: 100 requests/second, per project.", @@ -9387,7 +9863,7 @@ }, "/api/templates/inapp/upsert": { "post": { - "description": "Create an in-app template if it doesn't exist yet, otherwise update all in-app templates which match the name provided.", + "description": "Create an in-app template if it doesn't exist yet, otherwise update all in-app templates that match the name provided.", "operationId": "upsertInAppTemplate", "parameters": [ { @@ -9536,7 +10012,7 @@ }, "/api/templates/push/upsert": { "post": { - "description": "Create a push template if it doesn't exist, otherwise update all push templates which match the name provided.", + "description": "Create a push template if it doesn't exist, otherwise update all push templates that match the name provided.", "operationId": "upsertPushTemplate", "parameters": [ { @@ -9685,7 +10161,7 @@ }, "/api/templates/sms/upsert": { "post": { - "description": "Create an SMS template if it doesn't exist yet, otherwise update all SMS templates which match the name provided.", + "description": "Create an SMS template if it doesn't exist yet, otherwise update all SMS templates that match the name provided.", "operationId": "upsertSMSTemplate", "parameters": [ { diff --git a/src/client/templates.ts b/src/client/templates.ts index 9b26452..4c75e46 100644 --- a/src/client/templates.ts +++ b/src/client/templates.ts @@ -1,7 +1,10 @@ import { z } from "zod"; -import { IterableSuccessResponse } from "../types/common.js"; -import { IterableSuccessResponseSchema } from "../types/common.js"; +import { + formatSortParam, + IterableSuccessResponse, + IterableSuccessResponseSchema, +} from "../types/common.js"; import { BulkDeleteTemplatesParams, BulkDeleteTemplatesResponse, @@ -112,7 +115,20 @@ export function Templates>(Base: T) { async getTemplates( options?: GetTemplatesParams ): Promise { + // Always use pagination with defaults to ensure consistent API behavior + const page = options?.page ?? 1; + const pageSize = options?.pageSize ?? 10; + const sort = options?.sort; + const params = new URLSearchParams(); + params.append("page", page.toString()); + params.append("pageSize", pageSize.toString()); + + const sortString = formatSortParam(sort); + if (sortString) { + params.append("sort", sortString); + } + if (options?.templateType) params.append("templateType", options.templateType); if (options?.messageMedium) diff --git a/src/types/templates.ts b/src/types/templates.ts index 0f14183..c76f5d6 100644 --- a/src/types/templates.ts +++ b/src/types/templates.ts @@ -1,6 +1,11 @@ import { z } from "zod"; -import { IterableDateTimeSchema, UnixTimestampSchema } from "./common.js"; +import { + createSortParamSchema, + IterableDateTimeSchema, + SortParam, + UnixTimestampSchema, +} from "./common.js"; /** * Template management schemas and types @@ -47,7 +52,7 @@ export const EmailTemplateSchema = BaseTemplateSchema.extend({ .record(z.string(), z.any()) .optional() .describe( - "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields have higher priority than project-level fields but lower priority than user/event data." + "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name." ), ccEmails: z.array(z.string()).optional().describe("CC emails"), dataFeedId: z @@ -92,7 +97,7 @@ export const SMSTemplateSchema = BaseTemplateSchema.extend({ .record(z.string(), z.any()) .optional() .describe( - "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields have higher priority than project-level fields but lower priority than user/event data." + "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name." ), googleAnalyticsCampaignName: z .string() @@ -121,7 +126,7 @@ export const PushTemplateSchema = BaseTemplateSchema.extend({ .record(z.string(), z.any()) .optional() .describe( - "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields have higher priority than project-level fields but lower priority than user/event data." + "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name." ), dataFeedIds: z .array(z.number()) @@ -181,7 +186,7 @@ export const InAppTemplateSchema = BaseTemplateSchema.extend({ .record(z.string(), z.any()) .optional() .describe( - "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields have higher priority than project-level fields but lower priority than user/event data." + "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name." ), expirationDateTime: z .string() @@ -249,11 +254,49 @@ export type ApiTemplateResponse = z.infer; export const GetTemplatesResponseSchema = z.object({ templates: z.array(ApiTemplateResponseSchema), + nextPageUrl: z + .string() + .optional() + .describe("The URL to the next page of templates, if applicable"), + previousPageUrl: z + .string() + .optional() + .describe("The URL to the previous page of templates, if applicable"), + totalTemplatesCount: z + .number() + .describe( + "The total count of templates across all pages for the supplied query" + ), }); export type GetTemplatesResponse = z.infer; +const TEMPLATE_SORT_FIELDS = [ + "id", + "name", + "createdAt", + "updatedAt", +] as const; + export const GetTemplatesParamsSchema = z.object({ + page: z + .number() + .int() + .min(1) + .optional() + .describe("Page number (starting at 1)"), + pageSize: z + .number() + .int() + .min(1) + .max(1000) + .optional() + .describe( + "Number of results to return per page (defaults to 20, maximum of 1000)" + ), + sort: createSortParamSchema(TEMPLATE_SORT_FIELDS).describe( + "Field to sort templates by with optional direction (defaults to id ascending)" + ), templateType: z .enum(["Base", "Blast", "Triggered", "Workflow"]) .optional() @@ -268,14 +311,12 @@ export const GetTemplatesParamsSchema = z.object({ endDateTime: IterableDateTimeSchema.optional().describe( "Get templates created before this date time (yyyy-MM-dd HH:mm:ss [ZZ])" ), - limit: z - .number() - .min(1) - .max(1000) - .optional() - .describe("Maximum number of templates to return"), }); +export type TemplateSortParam = SortParam< + (typeof TEMPLATE_SORT_FIELDS)[number] +>; + export const GetTemplateParamsSchema = z.object({ templateId: z.number().describe("Template ID to retrieve"), locale: z.string().optional().describe("Locale of content to get"), @@ -333,7 +374,7 @@ const EmailContentFields = { .record(z.string(), z.any()) .optional() .describe( - "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields have higher priority than project-level fields but lower priority than user/event data." + "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name." ), }; @@ -343,7 +384,7 @@ const SMSContentFields = { .record(z.string(), z.any()) .optional() .describe( - "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields have higher priority than project-level fields but lower priority than user/event data." + "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name." ), }; @@ -357,7 +398,7 @@ const PushContentFields = { .record(z.string(), z.any()) .optional() .describe( - "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields have higher priority than project-level fields but lower priority than user/event data." + "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name." ), }; @@ -370,7 +411,7 @@ const InAppContentFields = { .record(z.string(), z.any()) .optional() .describe( - "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields have higher priority than project-level fields but lower priority than user/event data." + "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name." ), }; diff --git a/tests/integration/campaigns.test.ts b/tests/integration/campaigns.test.ts index d8c7717..e2f81e0 100644 --- a/tests/integration/campaigns.test.ts +++ b/tests/integration/campaigns.test.ts @@ -124,7 +124,7 @@ describe("Campaign Management Integration Tests", () => { }) ); - const templatesResponse = await withTimeout(client.getTemplates({ limit: 1 })); + const templatesResponse = await withTimeout(client.getTemplates({ pageSize: 1 })); if (templatesResponse.templates.length > 0) { testTemplateId = templatesResponse.templates[0]!.templateId; } else { diff --git a/tests/integration/templates.test.ts b/tests/integration/templates.test.ts index 2fa037d..2d2ab4f 100644 --- a/tests/integration/templates.test.ts +++ b/tests/integration/templates.test.ts @@ -6,6 +6,7 @@ import { EmailTemplateSchema } from "../../src/types/templates.js"; import { cleanupTestUser, createTestIdentifiers, + retryRateLimited, retryWithBackoff, uniqueId, withTimeout, @@ -82,31 +83,77 @@ describe("Template Management Integration Tests", () => { }); describe("Template Retrieval", () => { - it("should retrieve templates", async () => { - await withTimeout(client.getTemplates()); + it("should retrieve templates with default pagination", async () => { + const response = await withTimeout(client.getTemplates()); + + expect(response).toHaveProperty("templates"); + expect(response).toHaveProperty("totalTemplatesCount"); + expect(Array.isArray(response.templates)).toBe(true); + expect(typeof response.totalTemplatesCount).toBe("number"); + // Default page size is 10 + expect(response.templates.length).toBeLessThanOrEqual(10); + }); + + it("should retrieve templates with pagination", async () => { + const response = await retryRateLimited( + () => withTimeout(client.getTemplates({ page: 1, pageSize: 5 })), + "Get templates with pagination" + ); + + expect(response).toHaveProperty("templates"); + expect(response).toHaveProperty("totalTemplatesCount"); + expect(Array.isArray(response.templates)).toBe(true); + expect(response.templates.length).toBeLessThanOrEqual(5); + + if (response.totalTemplatesCount > 5) { + expect(response).toHaveProperty("nextPageUrl"); + expect(typeof response.nextPageUrl).toBe("string"); + } + }); + + it("should retrieve templates sorted by createdAt descending", async () => { + const response = await retryRateLimited( + () => + withTimeout( + client.getTemplates({ + page: 1, + pageSize: 10, + sort: { field: "createdAt", direction: "desc" }, + }) + ), + "Get templates sorted by createdAt descending" + ); + + expect(response.templates.length).toBeGreaterThan(1); + + for (let i = 0; i < response.templates.length - 1; i++) { + expect(response.templates[i]!.createdAt).toBeGreaterThanOrEqual( + response.templates[i + 1]!.createdAt + ); + } }); it("should retrieve templates with filters", async () => { await withTimeout( - client.getTemplates({ messageMedium: "Email", limit: 5 }) + client.getTemplates({ messageMedium: "Email", pageSize: 5 }) ); await withTimeout( - client.getTemplates({ messageMedium: "SMS", limit: 5 }) + client.getTemplates({ messageMedium: "SMS", pageSize: 5 }) ); await withTimeout( - client.getTemplates({ messageMedium: "Push", limit: 5 }) + client.getTemplates({ messageMedium: "Push", pageSize: 5 }) ); await withTimeout( - client.getTemplates({ messageMedium: "InApp", limit: 5 }) + client.getTemplates({ messageMedium: "InApp", pageSize: 5 }) ); await withTimeout( - client.getTemplates({ templateType: "Triggered", limit: 5 }) + client.getTemplates({ templateType: "Triggered", pageSize: 5 }) ); await withTimeout( client.getTemplates({ messageMedium: "Email", templateType: "Base", - limit: 3, + pageSize: 3, }) ); }); diff --git a/tests/unit/templates.test.ts b/tests/unit/templates.test.ts index c8a2a3d..761b3dc 100644 --- a/tests/unit/templates.test.ts +++ b/tests/unit/templates.test.ts @@ -35,7 +35,12 @@ describe("Template Management", () => { describe("getTemplates", () => { it("should get templates with filters", async () => { - const mockResponse = { data: { templates: [createMockTemplate()] } }; + const mockResponse = { + data: { + templates: [createMockTemplate()], + totalTemplatesCount: 1, + }, + }; const options = { templateType: "Triggered" as const, messageMedium: "Email" as const, @@ -45,7 +50,7 @@ describe("Template Management", () => { await client.getTemplates(options); expect(mockAxiosInstance.get).toHaveBeenCalledWith( - "/api/templates?templateType=Triggered&messageMedium=Email" + "/api/templates?page=1&pageSize=10&templateType=Triggered&messageMedium=Email" ); }); });