diff --git a/README.md b/README.md index ca267c5..6ddca9a 100644 --- a/README.md +++ b/README.md @@ -116,10 +116,15 @@ You can use custom contact properties in API calls. Please make sure to [add cus - [listDedicatedSendingIps()](#listdedicatedsendingips) - [listAudienceSegments()](#listaudiencesegments) - [getAudienceSegment()](#getaudiencesegment) +- [createAudienceSegment()](#createaudiencesegment) - [listThemes()](#listthemes) - [getTheme()](#gettheme) +- [createTheme()](#createtheme) +- [updateTheme()](#updatetheme) - [listComponents()](#listcomponents) - [getComponent()](#getcomponent) +- [createComponent()](#createcomponent) +- [updateComponent()](#updatecomponent) - [listCampaigns()](#listcampaigns) - [createCampaign()](#createcampaign) - [getCampaign()](#getcampaign) @@ -131,9 +136,21 @@ You can use custom contact properties in API calls. Please make sure to [add cus - [getEmailMessage()](#getemailmessage) - [updateEmailMessage()](#updateemailmessage) - [sendEmailMessagePreview()](#sendemailmessagepreview) +- [runEmailMessageGuardian()](#runemailmessageguardian) +- [listEventPatterns()](#listeventpatterns) +- [getEventPattern()](#geteventpattern) +- [getEventPatternByName()](#geteventpatternbyname) - [listWorkflows()](#listworkflows) +- [createWorkflow()](#createworkflow) - [getWorkflow()](#getworkflow) +- [updateWorkflow()](#updateworkflow) +- [changeWorkflowMailingList()](#changeworkflowmailinglist) +- [createWorkflowNode()](#createworkflownode) - [getWorkflowNode()](#getworkflownode) +- [updateWorkflowNode()](#updateworkflownode) +- [deleteWorkflowNode()](#deleteworkflownode) +- [addWorkflowBranch()](#addworkflowbranch) +- [deleteWorkflowNodesRecursive()](#deleteworkflownodesrecursive) - [createUpload()](#createupload) - [completeUpload()](#completeupload) @@ -937,6 +954,23 @@ Retrieve a single transactional email by ID. const resp = await loops.getTransactionalEmail("clfn0k1yg001imo0fdeqg30i8"); ``` +#### Response + +```json +{ + "id": "cll42l54f20i1la0lfooe3z12", + "name": "Sign up confirmation", + "draftEmailMessageId": "cle5f7g9h1i3j5k7l9m1n3p5", + "publishedEmailMessageId": "cle5f7g9h1i3j5k7l9m1n3p5", + "transactionalGroupId": "clg7n5p3q1r9s7t5u3v1w9y7", + "createdAt": "2025-06-29T07:47:39.370Z", + "updatedAt": "2025-06-29T07:47:39.370Z", + "dataVariables": [ + "confirmationUrl" + ] +} +``` + --- ### createTransactionalEmail() @@ -958,6 +992,22 @@ Create a new transactional email. An empty draft email message is created automa const resp = await loops.createTransactionalEmail({ name: "Welcome email" }); ``` +#### Response + +```json +{ + "id": "cll42l54f20i1la0lfooe3z12", + "name": "Welcome email", + "draftEmailMessageId": "cle5f7g9h1i3j5k7l9m1n3p5", + "draftEmailMessageContentRevisionId": "clrev1s10n2i3d4e5f6g7h8", + "publishedEmailMessageId": null, + "transactionalGroupId": "clg7n5p3q1r9s7t5u3v1w9y7", + "createdAt": "2025-06-29T07:47:39.370Z", + "updatedAt": "2025-06-29T07:47:39.370Z", + "dataVariables": [] +} +``` + --- ### updateTransactionalEmail() @@ -986,13 +1036,30 @@ const resp = await loops.updateTransactionalEmail("clfn0k1yg001imo0fdeqg30i8", { }); ``` +#### Response + +```json +{ + "id": "cll42l54f20i1la0lfooe3z12", + "name": "Updated name", + "draftEmailMessageId": "cle5f7g9h1i3j5k7l9m1n3p5", + "publishedEmailMessageId": "cle5f7g9h1i3j5k7l9m1n3p5", + "transactionalGroupId": "clg7n5p3q1r9s7t5u3v1w9y7", + "createdAt": "2025-06-29T07:47:39.370Z", + "updatedAt": "2025-06-29T07:47:39.370Z", + "dataVariables": [ + "confirmationUrl" + ] +} +``` + --- ### ensureTransactionalEmailDraft() Ensure a transactional email has a draft email message. Use [`updateEmailMessage()`](#updateemailmessage) to edit the draft content. -[API Reference](https://loops.so/docs/api-reference/ensure-transactional-email-draft) +[API Reference](https://loops.so/docs/api-reference/ensure-transactional-draft) #### Parameters @@ -1006,6 +1073,22 @@ Ensure a transactional email has a draft email message. Use [`updateEmailMessage const resp = await loops.ensureTransactionalEmailDraft("clfn0k1yg001imo0fdeqg30i8"); ``` +#### Response + +```json +{ + "id": "cll42l54f20i1la0lfooe3z12", + "name": "Sign up confirmation", + "draftEmailMessageId": "cle5f7g9h1i3j5k7l9m1n3p5", + "draftEmailMessageContentRevisionId": "clrev1s10n2i3d4e5f6g7h8", + "publishedEmailMessageId": null, + "transactionalGroupId": "clg7n5p3q1r9s7t5u3v1w9y7", + "createdAt": "2025-06-29T07:47:39.370Z", + "updatedAt": "2025-06-29T07:47:39.370Z", + "dataVariables": [] +} +``` + --- ### publishTransactionalEmail() @@ -1026,6 +1109,21 @@ Publish a transactional email's current draft. const resp = await loops.publishTransactionalEmail("clfn0k1yg001imo0fdeqg30i8"); ``` +#### Response + +```json +{ + "id": "cll42l54f20i1la0lfooe3z12", + "name": "Sign up confirmation", + "draftEmailMessageId": null, + "publishedEmailMessageId": "cle5f7g9h1i3j5k7l9m1n3p5", + "transactionalGroupId": "clg7n5p3q1r9s7t5u3v1w9y7", + "createdAt": "2025-06-29T07:47:39.370Z", + "updatedAt": "2025-06-29T07:47:39.370Z", + "dataVariables": [] +} +``` + --- ### listDedicatedSendingIps() @@ -1144,6 +1242,83 @@ Error handling is done through the `APIError` class, which provides `statusCode` --- +### createTheme() + +Create a new email theme. +[API Reference](https://loops.so/docs/api-reference/create-theme) + +#### Parameters + +| Name | Type | Required | Notes | +| -------- | ------ | -------- | ---------------------------------- | +| `name` | string | Yes | The theme name. | +| `styles` | object | No | Style attributes for the theme. | + +#### Example + +```javascript +const resp = await loops.createTheme({ + name: "Brand", + styles: { backgroundColor: "#111111" }, +}); +``` + +#### Response + +```json +{ + "id": "clt3u5v7w9x1y3z5a7b9c1d3", + "name": "Brand", + "styles": { + "backgroundColor": "#111111" + }, + "isDefault": false, + "createdAt": "2025-06-29T07:47:39.370Z", + "updatedAt": "2025-06-29T07:47:39.370Z" +} +``` + +--- + +### updateTheme() + +Update a theme's name and/or styles. When `styles` change, the update cascades to emails using the theme; `affectedEmailCount` reports how many were affected. +[API Reference](https://loops.so/docs/api-reference/update-theme) + +#### Parameters + +| Name | Type | Required | Notes | +| -------- | ------ | -------- | ------------------------------- | +| `themeId` | string | Yes | The ID of the theme. | +| `name` | string | No | The theme name. | +| `styles` | object | No | Style attributes for the theme. | + +#### Example + +```javascript +const resp = await loops.updateTheme("clo1z5q7s004yl70y3z4a5b6c", { + name: "Brand Updated", +}); +``` + +#### Response + +```json +{ + "id": "clt3u5v7w9x1y3z5a7b9c1d3", + "name": "Brand Updated", + "styles": { + "backgroundColor": "#111111" + }, + "isDefault": false, + "createdAt": "2025-06-29T07:47:39.370Z", + "updatedAt": "2025-06-29T07:47:39.370Z", + "affectedEmailCount": 3 +} +``` + +--- + ### listComponents() Retrieve a paginated list of email components. @@ -1221,6 +1396,73 @@ Error handling is done through the `APIError` class, which provides `statusCode` --- +### createComponent() + +Create a new email component. +[API Reference](https://loops.so/docs/api-reference/create-component) + +#### Parameters + +| Name | Type | Required | Notes | +| ------ | ------ | -------- | ------------------------------------- | +| `name` | string | Yes | The component name. | +| `lmx` | string | Yes | The component body as an LMX string. | + +#### Example + +```javascript +const resp = await loops.createComponent({ + name: "Footer", + lmx: "
...
", +}); +``` + +#### Response + +```json +{ + "id": "clp2a6r8t005yl70d7e8f9g0h", + "name": "Footer", + "lmx": "
Footer content
" +} +``` + +--- + +### updateComponent() + +Update a component's name and/or LMX body. When `lmx` changes, the update cascades to emails using the component; `affectedEmailCount` reports how many were affected. +[API Reference](https://loops.so/docs/api-reference/update-component) + +#### Parameters + +| Name | Type | Required | Notes | +| ------ | ------ | -------- | ------------------------------------- | +| `componentId` | string | Yes | The ID of the component. | +| `name` | string | No | The component name. | +| `lmx` | string | No | The component body as an LMX string. | + +#### Example + +```javascript +const resp = await loops.updateComponent("clp2a6r8t005yl70d7e8f9g0h", { + lmx: "
...
", +}); +``` + +#### Response + +```json +{ + "id": "clp2a6r8t005yl70d7e8f9g0h", + "name": "Footer", + "lmx": "
Updated footer
", + "affectedEmailCount": 2 +} +``` + +--- + ### listCampaigns() Retrieve a paginated list of campaigns. @@ -1562,6 +1804,30 @@ Retrieve a paginated list of transactional groups. const resp = await loops.listTransactionalGroups(); ``` +#### Response + +```json +{ + "pagination": { + "totalResults": 1, + "returnedResults": 1, + "perPage": 20, + "totalPages": 1, + "nextCursor": null, + "nextPage": null + }, + "data": [ + { + "id": "clg7n5p3q1r9s7t5u3v1w9y7", + "name": "Onboarding", + "description": "Top of funnel campaigns", + "createdAt": "2025-06-29T07:47:39.370Z", + "updatedAt": "2025-06-29T07:47:39.370Z" + } + ] +} +``` + --- ### createTransactionalGroup() @@ -1585,6 +1851,18 @@ const resp = await loops.createTransactionalGroup({ }); ``` +#### Response + +```json +{ + "id": "clg7n5p3q1r9s7t5u3v1w9y7", + "name": "Onboarding", + "description": "Top of funnel campaigns", + "createdAt": "2025-06-29T07:47:39.370Z", + "updatedAt": "2025-06-29T07:47:39.370Z" +} +``` + --- ### getTransactionalGroup() @@ -1604,6 +1882,18 @@ Retrieve a single transactional group by ID. const resp = await loops.getTransactionalGroup("clq3b7s9u006yl70p5q6r7s8t"); ``` +#### Response + +```json +{ + "id": "clg7n5p3q1r9s7t5u3v1w9y7", + "name": "Onboarding", + "description": "Top of funnel campaigns", + "createdAt": "2025-06-29T07:47:39.370Z", + "updatedAt": "2025-06-29T07:47:39.370Z" +} +``` + --- ### updateTransactionalGroup() @@ -1627,6 +1917,18 @@ const resp = await loops.updateTransactionalGroup("clq3b7s9u006yl70p5q6r7s8t", { }); ``` +#### Response + +```json +{ + "id": "clg7n5p3q1r9s7t5u3v1w9y7", + "name": "Updated name", + "description": "Top of funnel campaigns", + "createdAt": "2025-06-29T07:47:39.370Z", + "updatedAt": "2025-06-29T07:47:39.370Z" +} +``` + --- ### listAudienceSegments() @@ -1647,6 +1949,41 @@ Retrieve a paginated list of audience segments. const resp = await loops.listAudienceSegments(); ``` +#### Response + +```json +{ + "pagination": { + "totalResults": 1, + "returnedResults": 1, + "perPage": 20, + "totalPages": 1, + "nextCursor": null, + "nextPage": null + }, + "data": [ + { + "id": "cls6e8g0i2k4m6o8q0s2u4w6", + "name": "Power users", + "description": "Contacts on the pro plan", + "createdAt": "2025-06-29T07:47:39.370Z", + "updatedAt": "2025-06-29T07:47:39.370Z", + "filter": { + "match": "all", + "conditions": [ + { + "type": "property", + "key": "planName", + "operator": "equals", + "value": "pro" + } + ] + } + } + ] +} +``` + --- ### getAudienceSegment() @@ -1666,6 +2003,87 @@ Retrieve a single audience segment by ID. const resp = await loops.getAudienceSegment("clr4c8t0v008yl70x3y4z5a6b"); ``` +#### Response + +```json +{ + "id": "cls6e8g0i2k4m6o8q0s2u4w6", + "name": "Power users", + "description": "Contacts on the pro plan", + "createdAt": "2025-06-29T07:47:39.370Z", + "updatedAt": "2025-06-29T07:47:39.370Z", + "filter": { + "match": "all", + "conditions": [ + { + "type": "property", + "key": "planName", + "operator": "equals", + "value": "pro" + } + ] + } +} +``` + +--- + +### createAudienceSegment() + +Create a new audience segment. +[API Reference](https://loops.so/docs/api-reference/create-audience-segment) + +#### Parameters + +| Name | Type | Required | Notes | +| ---- | ---- | -------- | ----- | +| `name` | string | Yes | The name of the audience segment. Must be unique within the team. | +| `description` | string | No | An optional description of the audience segment. | +| `filter` | object | Yes | A tree of audience conditions combined with `match` (`all` or `any`). | + +#### Example + +```javascript +const resp = await loops.createAudienceSegment({ + name: "Active users", + description: "Contacts who opened a recent campaign", + filter: { + match: "all", + conditions: [ + { + type: "property", + key: "planName", + operator: "equals", + value: "pro", + }, + ], + }, +}); +``` + +#### Response + +```json +{ + "id": "cls6e8g0i2k4m6o8q0s2u4w6", + "name": "Active users", + "description": "Contacts who opened a recent campaign", + "createdAt": "2025-06-29T07:47:39.370Z", + "updatedAt": "2025-06-29T07:47:39.370Z", + "filter": { + "match": "all", + "conditions": [ + { + "type": "property", + "key": "planName", + "operator": "equals", + "value": "pro" + } + ] + } +} +``` + --- ### listCampaignGroups() @@ -1686,6 +2104,30 @@ Retrieve a paginated list of campaign groups. const resp = await loops.listCampaignGroups(); ``` +#### Response + +```json +{ + "pagination": { + "totalResults": 1, + "returnedResults": 1, + "perPage": 20, + "totalPages": 1, + "nextCursor": null, + "nextPage": null + }, + "data": [ + { + "id": "clg7n5p3q1r9s7t5u3v1w9y7", + "name": "Newsletters", + "description": "Monthly product updates", + "createdAt": "2025-06-29T07:47:39.370Z", + "updatedAt": "2025-06-29T07:47:39.370Z" + } + ] +} +``` + --- ### createCampaignGroup() @@ -1708,6 +2150,18 @@ const resp = await loops.createCampaignGroup({ }); ``` +#### Response + +```json +{ + "id": "clg7n5p3q1r9s7t5u3v1w9y7", + "name": "Newsletters", + "description": "", + "createdAt": "2025-06-29T07:47:39.370Z", + "updatedAt": "2025-06-29T07:47:39.370Z" +} +``` + --- ### getCampaignGroup() @@ -1727,6 +2181,18 @@ Retrieve a single campaign group by ID. const resp = await loops.getCampaignGroup("clq3b7s9u007yl70u9v0w1x2y"); ``` +#### Response + +```json +{ + "id": "clg7n5p3q1r9s7t5u3v1w9y7", + "name": "Newsletters", + "description": "Monthly product updates", + "createdAt": "2025-06-29T07:47:39.370Z", + "updatedAt": "2025-06-29T07:47:39.370Z" +} +``` + --- ### updateCampaignGroup() @@ -1750,12 +2216,24 @@ const resp = await loops.updateCampaignGroup("clq3b7s9u007yl70u9v0w1x2y", { }); ``` ---- - -### sendEmailMessagePreview() +#### Response -Send a test preview of an email message to one or more addresses. -[API Reference](https://loops.so/docs/api-reference/send-email-message-preview) +```json +{ + "id": "clg7n5p3q1r9s7t5u3v1w9y7", + "name": "Newsletters", + "description": "Monthly product updates", + "createdAt": "2025-06-29T07:47:39.370Z", + "updatedAt": "2025-06-29T07:47:39.370Z" +} +``` + +--- + +### sendEmailMessagePreview() + +Send a test preview of an email message to one or more addresses. +[API Reference](https://loops.so/docs/api-reference/preview-email-message) #### Parameters @@ -1778,6 +2256,183 @@ const resp = await loops.sendEmailMessagePreview("clm9x3o5q002yl70a8b3c4d5e", { }); ``` +#### Response + +```json +{ + "id": "cle5f7g9h1i3j5k7l9m1n3p5" +} +``` + +--- + +### runEmailMessageGuardian() + +Validate an email message against Guardian rules. Errors must be resolved before publishing; warnings are advisory. +[API Reference](https://loops.so/docs/api-reference/run-guardian-checks) + +#### Parameters + +| Name | Type | Required | Notes | +| ---- | ------ | -------- | ------------------------------ | +| `emailMessageId` | string | Yes | The ID of the email message. | + +#### Example + +```javascript +const resp = await loops.runEmailMessageGuardian("clm9x3o5q002yl70a8b3c4d5e"); +``` + +#### Response + +```json +{ + "errors": [ + { + "rule": "missingButtonHrefs", + "title": "Missing button link", + "description": "Buttons won't work without href value", + "items": [ + { + "label": "Click here" + } + ] + }, + { + "rule": "missingLinkHrefs", + "title": "Missing text link", + "description": "Links won't work without href value", + "items": [ + { + "label": "See more" + } + ] + } + ], + "warnings": [] +} +``` + +--- + +### listEventPatterns() + +Retrieve a paginated list of event patterns available to workflow event trigger nodes. +[API Reference](https://loops.so/docs/api-reference/list-event-patterns) + +#### Parameters + +| Name | Type | Required | Notes | +| --------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `perPage` | integer | No | How many results to return per page. Must be between 10 and 50. Defaults to 20 if omitted. | +| `cursor` | string | No | A cursor, to return a specific page of results. Cursors can be found from the `pagination.nextCursor` value in each response. | + +#### Example + +```javascript +const resp = await loops.listEventPatterns(); +``` + +#### Response + +```json +{ + "pagination": { + "totalResults": 1, + "returnedResults": 1, + "perPage": 20, + "totalPages": 1, + "nextCursor": null, + "nextPage": null + }, + "data": [ + { + "id": "cle1a2b3c004yl70d5e6f7g8h", + "eventName": "signup", + "incomingWebhookPlatform": null + } + ] +} +``` + +--- + +### getEventPattern() + +Retrieve event pattern details by ID. +[API Reference](https://loops.so/docs/api-reference/get-event-pattern) + +#### Parameters + +| Name | Type | Required | Notes | +| ---- | ------ | -------- | ---------------------------- | +| `eventPatternId` | string | Yes | The ID of the event pattern. | + +#### Example + +```javascript +const resp = await loops.getEventPattern("cle1a2b3c004yl70d5e6f7g8h"); +``` + +#### Response + +```json +{ + "id": "cle1a2b3c004yl70d5e6f7g8h", + "eventName": "signup", + "eventProperties": [ + { + "name": "plan", + "type": "string" + }, + { + "name": "trialDays", + "type": "number" + } + ], + "incomingWebhookPlatform": null +} +``` + +--- + +### getEventPatternByName() + +Retrieve event pattern details by event name. +[API Reference](https://loops.so/docs/api-reference/get-event-pattern-by-name) + +#### Parameters + +| Name | Type | Required | Notes | +| ---------- | ------ | -------- | ------------------------------ | +| `eventName` | string | Yes | The name of the event pattern. | + +#### Example + +```javascript +const resp = await loops.getEventPatternByName("signup"); +``` + +#### Response + +```json +{ + "id": "cle1a2b3c004yl70d5e6f7g8h", + "eventName": "signup", + "eventProperties": [ + { + "name": "plan", + "type": "string" + }, + { + "name": "trialDays", + "type": "number" + } + ], + "incomingWebhookPlatform": null +} +``` + --- ### listWorkflows() @@ -1798,11 +2453,80 @@ Retrieve a paginated list of workflows. const resp = await loops.listWorkflows(); ``` +#### Response + +```json +{ + "pagination": { + "totalResults": 1, + "returnedResults": 1, + "perPage": 20, + "totalPages": 1, + "nextCursor": null, + "nextPage": null + }, + "data": [ + { + "id": "clw1a3b5c7d9e1f3g5h7i9j1", + "name": "Onboarding", + "createdAt": "2025-06-29T07:47:39.370Z", + "updatedAt": "2025-06-29T07:47:39.370Z" + } + ] +} +``` + +--- + +### createWorkflow() + +Create a draft workflow with a blank trigger and exit node. +[API Reference](https://loops.so/docs/api-reference/create-workflow) + +#### Parameters + +| Name | Type | Required | Notes | +| --------------- | -------------- | -------- | -------------------------------------------------- | +| `name` | string | Yes | The name of the workflow. | +| `description` | string | No | The description of the workflow. | +| `mailingListId` | string \| null | No | The ID of a mailing list the workflow sends to. | + +#### Example + +```javascript +const resp = await loops.createWorkflow({ name: "Onboarding" }); +``` + +#### Response + +```json +{ + "id": "clw1a3b5c7d9e1f3g5h7i9j1", + "status": "Draft", + "name": "Onboarding", + "mailingListId": null, + "rootNodeId": "cf16k73gq014h3mmj5b6jdi9r", + "workflowRevisionId": "clrev0w0r1k2f3l4o5w6", + "nodes": { + "cf16k73gq014h3mmj5b6jdi9r": { + "typeName": "BlankTrigger", + "nextNodeIds": [ + "cf16k73gq014h3mmj5b4jdifg" + ] + }, + "cf16k73gq014h3mmj5b4jdifg": { + "typeName": "ExitAction", + "nextNodeIds": [] + } + } +} +``` + --- ### getWorkflow() -Retrieve a workflow graph with node type names, connections, and selected display fields. +Retrieve a workflow graph with node type names, connections, and selected display fields. Includes `workflowRevisionId` (may be `null` for older workflows) for subsequent mutations. [API Reference](https://loops.so/docs/api-reference/get-workflow) #### Parameters @@ -1817,11 +2541,201 @@ Retrieve a workflow graph with node type names, connections, and selected displa const resp = await loops.getWorkflow("cls5d9u1w009yl70c7d8e9f0g"); ``` +#### Response + +```json +{ + "id": "clw1a3b5c7d9e1f3g5h7i9j1", + "status": "Draft", + "name": "Onboarding", + "mailingListId": null, + "rootNodeId": "cf16k73gq014h3mmj5b6jdi9r", + "workflowRevisionId": "clrev0w0r1k2f3l4o5w6", + "nodes": { + "cf16k73gq014h3mmj5b6jdi9r": { + "typeName": "SignupTrigger", + "nextNodeIds": [ + "cf16k73gq014h3mmj5b4jdifg" + ] + }, + "cf16k73gq014h3mmj5b4jdifg": { + "typeName": "ExitAction", + "nextNodeIds": [] + } + } +} +``` + +--- + +### updateWorkflow() + +Update a workflow's name and/or description. To change the mailing list, use [`changeWorkflowMailingList()`](#changeworkflowmailinglist). +[API Reference](https://loops.so/docs/api-reference/update-workflow) + +#### Parameters + +| Name | Type | Required | Notes | +| -------------------- | ------ | -------- | ------------------------------------------------------------------ | +| `workflowId` | string | Yes | The ID of the workflow. | +| `expectedRevisionId` | string \| null | Yes | The workflow revision token from the latest read or mutation. Pass `null` for workflows that do not have a revision yet. | +| `name` | string | No | The updated workflow name. | +| `description` | string | No | The updated workflow description. | + +#### Example + +```javascript +const resp = await loops.updateWorkflow("cls5d9u1w009yl70c7d8e9f0g", { + expectedRevisionId: "rev_1", + name: "Updated onboarding", +}); +``` + +#### Response + +```json +{ + "id": "clw1a3b5c7d9e1f3g5h7i9j1", + "status": "Draft", + "name": "Updated onboarding", + "mailingListId": null, + "rootNodeId": "cf16k73gq014h3mmj5b6jdi9r", + "workflowRevisionId": "clrev0w0r1k2f3l4o5w6", + "nodes": { + "cf16k73gq014h3mmj5b6jdi9r": { + "typeName": "BlankTrigger", + "nextNodeIds": [ + "cf16k73gq014h3mmj5b4jdifg" + ] + }, + "cf16k73gq014h3mmj5b4jdifg": { + "typeName": "ExitAction", + "nextNodeIds": [] + } + } +} +``` + +--- + +### changeWorkflowMailingList() + +Dry run or apply a workflow mailing list change. If queued contacts would be removed, the response status is `queuedContactsFound`; retry with `queuedContactPolicy: "discard"` to apply. +[API Reference](https://loops.so/docs/api-reference/change-workflow-mailing-list) + +#### Parameters + +| Name | Type | Required | Notes | +| -------------------- | ------------------ | -------- | ------------------------------------------------------------------ | +| `workflowId` | string | Yes | The ID of the workflow. | +| `expectedRevisionId` | string \| null | Yes | The workflow revision token from the latest read or mutation. Pass `null` for workflows that do not have a revision yet. | +| `mailingListId` | string \| null | Yes | The mailing list to use, or `null` to clear it. | +| `dryRun` | boolean | No | If `true`, validate without modifying the workflow. | +| `queuedContactPolicy`| `"fail"` \| `"discard"` | No | How to handle queued contacts that would be removed. | + +#### Example + +```javascript +const resp = await loops.changeWorkflowMailingList("cls5d9u1w009yl70c7d8e9f0g", { + expectedRevisionId: "rev_1", + mailingListId: "clm1a2b3c004yl70d5e6f7g8h", +}); +``` + +#### Response + +```json +{ + "status": "updated", + "mailingListId": "clm1a2b3c004yl70d5e6f7g8h", + "workflowRevisionId": "clrev0w0r1k2f3l4o5w6", + "queuedContactCount": 0, + "queuedContactLimitReached": false +} +``` + +--- + +### createWorkflowNode() + +Create a new default workflow node. Use `insertMode: "between"` or `insertMode: "before"`, then configure with [`updateWorkflowNode()`](#updateworkflownode). +[API Reference](https://loops.so/docs/api-reference/create-workflow-node) + +#### Parameters + +| Name | Type | Required | Notes | +| -------------------- | ------ | -------- | --------------------------------------------------------------------- | +| `workflowId` | string | Yes | The ID of the workflow. | +| `expectedRevisionId` | string \| null | Yes | The workflow revision token from the latest read or mutation. Pass `null` for workflows that do not have a revision yet. | +| `insertMode` | string | Yes | `"between"` or `"before"`. | +| `nodeTypeName` | string | Yes | Node type to create (for example `TimerAction`, `AudienceFilter`). | +| `fromNodeId` | string | Cond. | Required when `insertMode` is `"between"`. | +| `toNodeId` | string | Cond. | Required when `insertMode` is `"between"`. | +| `beforeNodeId` | string | Cond. | Required when `insertMode` is `"before"`. | + +#### Example + +```javascript +const resp = await loops.createWorkflowNode("cls5d9u1w009yl70c7d8e9f0g", { + expectedRevisionId: "rev_1", + insertMode: "between", + nodeTypeName: "TimerAction", + fromNodeId: "clt6e0v2x010yl70h1i2j3k4l", + toNodeId: "clu7f1w3y011yl70m5n6o7p8q", +}); +``` + +#### Response + +```json +{ + "node": { + "id": "cln8p0q2r4s6t8u0v2w4x6z8", + "workflowId": "clw1a3b5c7d9e1f3g5h7i9j1", + "typeName": "TimerAction", + "nextNodeIds": [ + "clu7f1w3y011yl70m5n6o7p8q" + ], + "amount": 0, + "unit": "m", + "workflowRevisionId": "clrev0w0r1k2f3l4o5w6" + }, + "workflow": { + "id": "clw1a3b5c7d9e1f3g5h7i9j1", + "status": "Draft", + "name": "Onboarding", + "mailingListId": null, + "rootNodeId": "clt6e0v2x010yl70h1i2j3k4l", + "workflowRevisionId": "clrev0w0r1k2f3l4o5w6", + "nodes": { + "clt6e0v2x010yl70h1i2j3k4l": { + "typeName": "BlankTrigger", + "nextNodeIds": [ + "cln8p0q2r4s6t8u0v2w4x6z8" + ] + }, + "cln8p0q2r4s6t8u0v2w4x6z8": { + "typeName": "TimerAction", + "nextNodeIds": [ + "clu7f1w3y011yl70m5n6o7p8q" + ], + "amount": 0, + "unit": "m" + }, + "clu7f1w3y011yl70m5n6o7p8q": { + "typeName": "ExitAction", + "nextNodeIds": [] + } + } + } +} +``` + --- ### getWorkflowNode() -Retrieve detailed data for a single workflow node. +Retrieve detailed data for a single workflow node. Includes `workflowRevisionId` (may be `null` for older workflows). [API Reference](https://loops.so/docs/api-reference/get-workflow-node) #### Parameters @@ -1837,6 +2751,212 @@ Retrieve detailed data for a single workflow node. const resp = await loops.getWorkflowNode("cls5d9u1w009yl70c7d8e9f0g", "clt6e0v2x010yl70h1i2j3k4l"); ``` +#### Response + +```json +{ + "id": "cln8p0q2r4s6t8u0v2w4x6z8", + "workflowId": "clw1a3b5c7d9e1f3g5h7i9j1", + "typeName": "TimerAction", + "nextNodeIds": [ + "cf16k73gq014h3mmj5b4jdifg" + ], + "amount": 1, + "unit": "h", + "workflowRevisionId": "clrev0w0r1k2f3l4o5w6" +} +``` + +--- + +### updateWorkflowNode() + +Update workflow-node-owned fields for a single node. +[API Reference](https://loops.so/docs/api-reference/update-workflow-node) + +#### Parameters + +| Name | Type | Required | Notes | +| -------------------- | ------ | -------- | ------------------------------------------------------------------ | +| `workflowId` | string | Yes | The ID of the workflow. | +| `nodeId` | string | Yes | The ID of the workflow node. | +| `expectedRevisionId` | string \| null | Yes | The workflow revision token from the latest read or mutation. Pass `null` for workflows that do not have a revision yet. | +| `payload` | object | Yes | Node-type-specific fields to update. | + +#### Example + +```javascript +const resp = await loops.updateWorkflowNode( + "cls5d9u1w009yl70c7d8e9f0g", + "clt6e0v2x010yl70h1i2j3k4l", + { + expectedRevisionId: "rev_1", + payload: { amount: 1, unit: "h" }, + } +); +``` + +#### Response + +```json +{ + "id": "cln8p0q2r4s6t8u0v2w4x6z8", + "workflowId": "clw1a3b5c7d9e1f3g5h7i9j1", + "typeName": "TimerAction", + "nextNodeIds": [ + "cf16k73gq014h3mmj5b4jdifg" + ], + "amount": 1, + "unit": "h", + "workflowRevisionId": "clrev0w0r1k2f3l4o5w6" +} +``` + +--- + +### deleteWorkflowNode() + +Delete a single workflow node. If contacts are queued, the response status is `queuedContactsFound`; retry with `queuedContactPolicy: "discard"`. +[API Reference](https://loops.so/docs/api-reference/delete-workflow-node) + +#### Parameters + +| Name | Type | Required | Notes | +| -------------------- | ------------------ | -------- | ------------------------------------------------------------------ | +| `workflowId` | string | Yes | The ID of the workflow. | +| `nodeId` | string | Yes | The ID of the workflow node. | +| `expectedRevisionId` | string \| null | Yes | The workflow revision token from the latest read or mutation. Pass `null` for workflows that do not have a revision yet. | +| `dryRun` | boolean | No | If `true`, validate without modifying the workflow. | +| `queuedContactPolicy`| `"fail"` \| `"discard"` | No | How to handle queued contacts. | + +#### Example + +```javascript +const resp = await loops.deleteWorkflowNode( + "cls5d9u1w009yl70c7d8e9f0g", + "clt6e0v2x010yl70h1i2j3k4l", + { expectedRevisionId: "rev_1" } +); +``` + +#### Response + +```json +{ + "status": "deleted", + "nodeIds": [ + "cln8p0q2r4s6t8u0v2w4x6z8" + ], + "workflowRevisionId": "clrev0w0r1k2f3l4o5w6", + "queuedContactCount": 0, + "queuedContactLimitReached": false +} +``` + +--- + +### addWorkflowBranch() + +Add a branch and a child node under an existing Branch or Experiment node. +[API Reference](https://loops.so/docs/api-reference/add-workflow-branch) + +#### Parameters + +| Name | Type | Required | Notes | +| -------------------- | ------ | -------- | ------------------------------------------------------------------ | +| `workflowId` | string | Yes | The ID of the workflow. | +| `nodeId` | string | Yes | The ID of the Branch or Experiment node. | +| `expectedRevisionId` | string \| null | Yes | The workflow revision token from the latest read or mutation. Pass `null` for workflows that do not have a revision yet. | + +#### Example + +```javascript +const resp = await loops.addWorkflowBranch( + "cls5d9u1w009yl70c7d8e9f0g", + "clt6e0v2x010yl70h1i2j3k4l", + { expectedRevisionId: "rev_1" } +); +``` + +#### Response + +```json +{ + "node": { + "id": "cln0a2b4c6d8e0f2g4h6i8j0", + "workflowId": "clw1a3b5c7d9e1f3g5h7i9j1", + "typeName": "AudienceFilter", + "nextNodeIds": [], + "audienceSegmentId": null, + "audienceFilter": null, + "appliesDownstream": false, + "workflowRevisionId": "clrev0w0r1k2f3l4o5w6" + }, + "workflow": { + "id": "clw1a3b5c7d9e1f3g5h7i9j1", + "status": "Draft", + "name": "Onboarding", + "mailingListId": null, + "rootNodeId": "clt6e0v2x010yl70h1i2j3k4l", + "workflowRevisionId": "clrev0w0r1k2f3l4o5w6", + "nodes": { + "clt6e0v2x010yl70h1i2j3k4l": { + "typeName": "BranchNode", + "nextNodeIds": [ + "cln0a2b4c6d8e0f2g4h6i8j0" + ] + }, + "cln0a2b4c6d8e0f2g4h6i8j0": { + "typeName": "AudienceFilter", + "nextNodeIds": [] + } + } + } +} +``` + +--- + +### deleteWorkflowNodesRecursive() + +Delete a node and its downstream subtree. If contacts are queued, the response status is `queuedContactsFound`; retry with `queuedContactPolicy: "discard"`. +[API Reference](https://loops.so/docs/api-reference/delete-workflow-nodes) + +#### Parameters + +| Name | Type | Required | Notes | +| -------------------- | ------------------ | -------- | ------------------------------------------------------------------ | +| `workflowId` | string | Yes | The ID of the workflow. | +| `nodeId` | string | Yes | The ID of the workflow node. | +| `expectedRevisionId` | string \| null | Yes | The workflow revision token from the latest read or mutation. Pass `null` for workflows that do not have a revision yet. | +| `dryRun` | boolean | No | If `true`, validate without modifying the workflow. | +| `queuedContactPolicy`| `"fail"` \| `"discard"` | No | How to handle queued contacts. | + +#### Example + +```javascript +const resp = await loops.deleteWorkflowNodesRecursive( + "cls5d9u1w009yl70c7d8e9f0g", + "clt6e0v2x010yl70h1i2j3k4l", + { expectedRevisionId: "rev_1", queuedContactPolicy: "discard" } +); +``` + +#### Response + +```json +{ + "status": "deleted", + "nodeIds": [ + "cln8p0q2r4s6t8u0v2w4x6z8", + "cln9q1r3s5t7u9v1w3x5y7z9" + ], + "workflowRevisionId": "clrev0w0r1k2f3l4o5w6", + "queuedContactCount": 0, + "queuedContactLimitReached": false +} +``` + --- ### createUpload() @@ -1861,6 +2981,15 @@ const resp = await loops.createUpload({ }); ``` +#### Response + +```json +{ + "emailAssetId": "cla3s5s7e9t1i3d5f7g9h1j3", + "presignedUrl": "https://loops-assets.s3.amazonaws.com/uploads/cla3s5s7e9t1i3d5f7g9h1j3?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=..." +} +``` + --- ### completeUpload() @@ -1881,10 +3010,26 @@ Finalize an asset after the file has been uploaded to the pre-signed URL. const resp = await loops.completeUpload("clu7f1w3y011yl70m5n6o7p8q"); ``` +#### Response + +```json +{ + "emailAssetId": "cla3s5s7e9t1i3d5f7g9h1j3", + "finalUrl": "https://assets.loops.so/cla3s5s7e9t1i3d5f7g9h1j3/logo.png" +} +``` + --- ## Version history +- `v7.1.0` (Aug 3, 2026) + - Added event patterns ([`listEventPatterns()`](#listeventpatterns), [`getEventPattern()`](#geteventpattern), [`getEventPatternByName()`](#geteventpatternbyname)). + - Expanded workflows with create/update/mutate APIs ([`createWorkflow()`](#createworkflow), [`updateWorkflow()`](#updateworkflow), [`changeWorkflowMailingList()`](#changeworkflowmailinglist), [`createWorkflowNode()`](#createworkflownode), [`updateWorkflowNode()`](#updateworkflownode), [`deleteWorkflowNode()`](#deleteworkflownode), [`addWorkflowBranch()`](#addworkflowbranch), [`deleteWorkflowNodesRecursive()`](#deleteworkflownodesrecursive)). + - Added theme and component write APIs ([`createTheme()`](#createtheme), [`updateTheme()`](#updatetheme), [`createComponent()`](#createcomponent), [`updateComponent()`](#updatecomponent)). + - Added [`createAudienceSegment()`](#createaudiencesegment). + - Added [`runEmailMessageGuardian()`](#runemailmessageguardian). + - Aligned workflow response types with the API (`status`, nullable `workflowRevisionId`; removed unused `emoji`). - `v7.0.0` (Jun 24, 2026) - Added transactional email management ([`getTransactionalEmail()`](#gettransactionalemail), [`createTransactionalEmail()`](#createtransactionalemail), [`updateTransactionalEmail()`](#updatetransactionalemail), [`ensureTransactionalEmailDraft()`](#ensuretransactionalemaildraft), [`publishTransactionalEmail()`](#publishtransactionalemail)). - Added workflows ([`listWorkflows()`](#listworkflows), [`getWorkflow()`](#getworkflow), [`getWorkflowNode()`](#getworkflownode)). diff --git a/package-lock.json b/package-lock.json index 2485f5b..5373ee6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "loops", - "version": "6.4.0", + "version": "7.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loops", - "version": "6.4.0", + "version": "7.1.0", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.12", diff --git a/package.json b/package.json index 002d7cb..390cae5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "loops", - "version": "7.0.0", + "version": "7.1.0", "author": "Dan Rowden ", "license": "MIT", "main": "./dist/index.cjs", diff --git a/src/__tests__/LoopsClient.test.ts b/src/__tests__/LoopsClient.test.ts index e16d13d..c4feba0 100644 --- a/src/__tests__/LoopsClient.test.ts +++ b/src/__tests__/LoopsClient.test.ts @@ -1116,6 +1116,130 @@ describe("LoopsClient", () => { }); }); + describe("createAudienceSegment", () => { + it("should create an audience segment", async () => { + const filter = { + match: "all" as const, + conditions: [ + { + type: "property" as const, + key: "planName", + operator: "equals" as const, + value: "pro", + }, + ], + }; + const mockResponse = { + id: "cls6e8g0i2k4m6o8q0s2u4w6", + name: "Active users", + description: "Contacts on the pro plan", + createdAt: "2025-06-29T07:47:39.370Z", + updatedAt: "2025-06-29T07:47:39.370Z", + filter, + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.createAudienceSegment({ + name: "Active users", + description: "Contacts on the pro plan", + filter, + }); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("v1/audience-segments"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + name: "Active users", + filter, + description: "Contacts on the pro plan", + }), + }) + ); + }); + + it("should handle error when segment name already exists", async () => { + const filter = { + match: "all" as const, + conditions: [ + { + type: "property" as const, + key: "planName", + operator: "equals" as const, + value: "pro", + }, + ], + }; + const mockResponse = { + message: "An audience segment with this name already exists", + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 400, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + await expect( + client.createAudienceSegment({ + name: "Active users", + filter, + }) + ).rejects.toThrow(APIError); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("v1/audience-segments"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + name: "Active users", + filter, + }), + }) + ); + }); + + it("should handle error for an invalid filter", async () => { + const filter = { + match: "all" as const, + conditions: [ + { + type: "property" as const, + key: "planName", + operator: "equals" as const, + value: "pro", + }, + ], + }; + const mockResponse = { + message: "Invalid filter", + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 400, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + try { + await client.createAudienceSegment({ + name: "Active users", + filter, + }); + fail("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(APIError); + expect((error as APIError).statusCode).toBe(400); + expect((error as APIError).json).toEqual(mockResponse); + } + }); + }); + describe("listThemes", () => { it("should list themes with pagination", async () => { const mockResponse = { @@ -1449,4 +1573,564 @@ describe("LoopsClient", () => { ).rejects.toThrow(APIError); }); }); + + describe("createTheme", () => { + it("should create a theme", async () => { + const mockResponse = { + id: "clo1z5q7s004yl70y3z4a5b6c", + name: "Brand", + styles: { backgroundColor: "#111111" }, + isDefault: false, + createdAt: "2025-01-01T00:00:00.000Z", + updatedAt: "2025-01-01T00:00:00.000Z", + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.createTheme({ + name: "Brand", + styles: { backgroundColor: "#111111" }, + }); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("v1/themes"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + name: "Brand", + styles: { backgroundColor: "#111111" }, + }), + }) + ); + }); + }); + + describe("updateTheme", () => { + it("should update a theme", async () => { + const mockResponse = { + id: "clo1z5q7s004yl70y3z4a5b6c", + name: "Brand Updated", + styles: {}, + isDefault: false, + createdAt: "2025-01-01T00:00:00.000Z", + updatedAt: "2025-01-02T00:00:00.000Z", + affectedEmailCount: 3, + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.updateTheme("clo1z5q7s004yl70y3z4a5b6c", { + name: "Brand Updated", + }); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("v1/themes/clo1z5q7s004yl70y3z4a5b6c"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ name: "Brand Updated" }), + }) + ); + }); + }); + + describe("createComponent", () => { + it("should create a component", async () => { + const mockResponse = { + id: "clp2a6r8t005yl70d7e8f9g0h", + name: "Footer", + lmx: "
", + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.createComponent({ + name: "Footer", + lmx: "
", + }); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("v1/components"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ name: "Footer", lmx: "
" }), + }) + ); + }); + }); + + describe("updateComponent", () => { + it("should update a component", async () => { + const mockResponse = { + id: "clp2a6r8t005yl70d7e8f9g0h", + name: "Footer", + lmx: "
", + affectedEmailCount: 2, + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.updateComponent("clp2a6r8t005yl70d7e8f9g0h", { + lmx: "
", + }); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("v1/components/clp2a6r8t005yl70d7e8f9g0h"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ lmx: "
" }), + }) + ); + }); + }); + + describe("runEmailMessageGuardian", () => { + it("should run guardian checks", async () => { + const mockResponse = { + errors: [ + { + rule: "missingButtonHrefs", + title: "Missing button link", + description: "Buttons won't work without href value", + items: [{ label: "Click here" }], + }, + ], + warnings: [], + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.runEmailMessageGuardian( + "clm9x3o5q002yl70a8b3c4d5e" + ); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining( + "v1/email-messages/clm9x3o5q002yl70a8b3c4d5e/guardian" + ), + expect.objectContaining({ method: "GET" }) + ); + }); + }); + + describe("listEventPatterns", () => { + it("should list event patterns with pagination", async () => { + const mockResponse = { + pagination: { + totalResults: 1, + returnedResults: 1, + perPage: 20, + totalPages: 1, + nextCursor: null, + nextPage: null, + }, + data: [ + { + id: "cle1a2b3c004yl70d5e6f7g8h", + eventName: "signup", + incomingWebhookPlatform: null, + }, + ], + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.listEventPatterns({ perPage: 10 }); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("v1/event-patterns?perPage=10"), + expect.objectContaining({ method: "GET" }) + ); + }); + }); + + describe("getEventPattern", () => { + it("should get an event pattern by ID", async () => { + const mockResponse = { + id: "cle1a2b3c004yl70d5e6f7g8h", + eventName: "signup", + eventProperties: [{ name: "plan", type: "string" }], + incomingWebhookPlatform: null, + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.getEventPattern("cle1a2b3c004yl70d5e6f7g8h"); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("v1/event-patterns/cle1a2b3c004yl70d5e6f7g8h"), + expect.objectContaining({ method: "GET" }) + ); + }); + }); + + describe("getEventPatternByName", () => { + it("should get an event pattern by name", async () => { + const mockResponse = { + id: "cle1a2b3c004yl70d5e6f7g8h", + eventName: "signup completed", + eventProperties: [], + incomingWebhookPlatform: null, + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.getEventPatternByName("signup completed"); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining( + "v1/event-patterns/by-name/signup%20completed" + ), + expect.objectContaining({ method: "GET" }) + ); + }); + }); + + describe("createWorkflow", () => { + it("should create a workflow", async () => { + const mockResponse = { + id: "cls5d9u1w009yl70c7d8e9f0g", + status: "Draft", + name: "Onboarding", + mailingListId: null, + rootNodeId: "clt6e0v2x010yl70h1i2j3k4l", + nodes: {}, + workflowRevisionId: "rev_1", + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.createWorkflow({ name: "Onboarding" }); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("v1/workflows"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ name: "Onboarding" }), + }) + ); + }); + }); + + describe("updateWorkflow", () => { + it("should update a workflow", async () => { + const mockResponse = { + id: "cls5d9u1w009yl70c7d8e9f0g", + status: "Draft", + name: "Updated", + mailingListId: null, + rootNodeId: "clt6e0v2x010yl70h1i2j3k4l", + nodes: {}, + workflowRevisionId: "rev_2", + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.updateWorkflow("cls5d9u1w009yl70c7d8e9f0g", { + expectedRevisionId: "rev_1", + name: "Updated", + }); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("v1/workflows/cls5d9u1w009yl70c7d8e9f0g"), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + expectedRevisionId: "rev_1", + name: "Updated", + }), + }) + ); + }); + }); + + describe("changeWorkflowMailingList", () => { + it("should change a workflow mailing list", async () => { + const mockResponse = { + status: "updated", + mailingListId: "clm1", + workflowRevisionId: "rev_2", + queuedContactCount: 0, + queuedContactLimitReached: false, + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.changeWorkflowMailingList( + "cls5d9u1w009yl70c7d8e9f0g", + { + expectedRevisionId: "rev_1", + mailingListId: "clm1", + } + ); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining( + "v1/workflows/cls5d9u1w009yl70c7d8e9f0g/mailing-list" + ), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + expectedRevisionId: "rev_1", + mailingListId: "clm1", + }), + }) + ); + }); + }); + + describe("createWorkflowNode", () => { + it("should create a workflow node between two nodes", async () => { + const mockResponse = { + node: { + id: "new_node", + workflowId: "cls5d9u1w009yl70c7d8e9f0g", + typeName: "TimerAction", + nextNodeIds: ["to"], + workflowRevisionId: "rev_2", + }, + workflow: { + id: "cls5d9u1w009yl70c7d8e9f0g", + status: "Draft", + mailingListId: null, + rootNodeId: "from", + nodes: {}, + workflowRevisionId: "rev_2", + }, + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.createWorkflowNode( + "cls5d9u1w009yl70c7d8e9f0g", + { + expectedRevisionId: "rev_1", + insertMode: "between", + nodeTypeName: "TimerAction", + fromNodeId: "from", + toNodeId: "to", + } + ); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining( + "v1/workflows/cls5d9u1w009yl70c7d8e9f0g/nodes" + ), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + expectedRevisionId: "rev_1", + insertMode: "between", + nodeTypeName: "TimerAction", + fromNodeId: "from", + toNodeId: "to", + }), + }) + ); + }); + }); + + describe("updateWorkflowNode", () => { + it("should update a workflow node", async () => { + const mockResponse = { + id: "node1", + workflowId: "cls5d9u1w009yl70c7d8e9f0g", + typeName: "TimerAction", + nextNodeIds: [], + amount: 1, + unit: "h", + workflowRevisionId: "rev_2", + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.updateWorkflowNode( + "cls5d9u1w009yl70c7d8e9f0g", + "node1", + { + expectedRevisionId: "rev_1", + payload: { amount: 1, unit: "h" }, + } + ); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining( + "v1/workflows/cls5d9u1w009yl70c7d8e9f0g/nodes/node1" + ), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + expectedRevisionId: "rev_1", + payload: { amount: 1, unit: "h" }, + }), + }) + ); + }); + }); + + describe("deleteWorkflowNode", () => { + it("should delete a workflow node", async () => { + const mockResponse = { + status: "deleted", + nodeIds: ["node1"], + workflowRevisionId: "rev_2", + queuedContactCount: 0, + queuedContactLimitReached: false, + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.deleteWorkflowNode( + "cls5d9u1w009yl70c7d8e9f0g", + "node1", + { expectedRevisionId: "rev_1" } + ); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining( + "v1/workflows/cls5d9u1w009yl70c7d8e9f0g/nodes/node1" + ), + expect.objectContaining({ + method: "DELETE", + body: JSON.stringify({ expectedRevisionId: "rev_1" }), + }) + ); + }); + }); + + describe("addWorkflowBranch", () => { + it("should add a workflow branch", async () => { + const mockResponse = { + node: { + id: "branch_child", + workflowId: "cls5d9u1w009yl70c7d8e9f0g", + typeName: "AudienceFilter", + nextNodeIds: [], + workflowRevisionId: "rev_2", + }, + workflow: { + id: "cls5d9u1w009yl70c7d8e9f0g", + status: "Draft", + mailingListId: null, + rootNodeId: "root", + nodes: {}, + workflowRevisionId: "rev_2", + }, + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.addWorkflowBranch( + "cls5d9u1w009yl70c7d8e9f0g", + "branch1", + { expectedRevisionId: "rev_1" } + ); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining( + "v1/workflows/cls5d9u1w009yl70c7d8e9f0g/nodes/branch1/add-branch" + ), + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ expectedRevisionId: "rev_1" }), + }) + ); + }); + }); + + describe("deleteWorkflowNodesRecursive", () => { + it("should recursively delete workflow nodes", async () => { + const mockResponse = { + status: "deleted", + nodeIds: ["node1", "node2"], + workflowRevisionId: "rev_2", + queuedContactCount: 0, + queuedContactLimitReached: false, + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.deleteWorkflowNodesRecursive( + "cls5d9u1w009yl70c7d8e9f0g", + "node1", + { expectedRevisionId: "rev_1", queuedContactPolicy: "discard" } + ); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining( + "v1/workflows/cls5d9u1w009yl70c7d8e9f0g/nodes/node1/recursive" + ), + expect.objectContaining({ + method: "DELETE", + body: JSON.stringify({ + expectedRevisionId: "rev_1", + queuedContactPolicy: "discard", + }), + }) + ); + }); + }); }); diff --git a/src/index.ts b/src/index.ts index ebc5494..b4355a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -351,6 +351,39 @@ interface ListAudienceSegmentsResponse { data: AudienceSegment[]; } +type IncomingWebhookPlatform = "clerk" | "polar" | "stripe" | "supabase"; + +interface EventPatternSummary { + /** The ID of the event pattern. */ + id: string; + /** + * The name of the event pattern. Use this when sending events with the API. + */ + eventName: string; + /** + * The platform that sent this event pattern, if the event pattern is from an + * incoming webhook. `null` for custom events. + */ + incomingWebhookPlatform: IncomingWebhookPlatform | null; +} + +interface WorkflowEventProperty { + name: string; + type: "string" | "number" | "boolean" | "date"; +} + +interface EventPattern extends EventPatternSummary { + /** + * The properties of the event pattern, which can be used in emails. + */ + eventProperties: WorkflowEventProperty[]; +} + +interface ListEventPatternsResponse { + pagination: PaginationData; + data: EventPatternSummary[]; +} + interface WorkflowSummary { id: string; name: string; @@ -363,6 +396,8 @@ interface ListWorkflowsResponse { data: WorkflowSummary[]; } +type WorkflowStatus = "Draft" | "Sending" | "Paused" | "PausedAndQueueing"; + type SimplifiedWorkflowNode = { typeName: string; nextNodeIds: string[]; @@ -370,11 +405,17 @@ type SimplifiedWorkflowNode = { interface SimplifiedWorkflow { id: string; + status: WorkflowStatus; name?: string; description?: string; - emoji?: string; - mailingListId?: string | null; - rootNodeId: string | null; + mailingListId: string | null; + rootNodeId: string; + /** + * The current workflow revision token. Pass the latest value as + * `expectedRevisionId` on the next workflow mutation. `null` for workflows + * that do not have a revision token yet. + */ + workflowRevisionId: string | null; nodes: Record; } @@ -385,11 +426,215 @@ type WorkflowNode = { nextNodeIds: string[]; } & Record; +type WorkflowNodeWithRevision = WorkflowNode & { + /** + * The current workflow revision token. `null` for workflows that do not + * have a revision token yet. + */ + workflowRevisionId: string | null; +}; + +type WorkflowQueuedContactPolicy = "fail" | "discard"; + +/** Pass the latest `workflowRevisionId`, including `null` for older workflows. */ +type WorkflowExpectedRevisionId = string | null; + +type CreateWorkflowNodeTypeName = + | "AudienceFilter" + | "BranchNode" + | "ExperimentBranchNode" + | "TimerAction" + | "SendEmailAction" + | "VariantNode"; + +type CreateWorkflowNodeParams = + | { + expectedRevisionId: WorkflowExpectedRevisionId; + insertMode: "between"; + nodeTypeName: CreateWorkflowNodeTypeName; + fromNodeId: string; + toNodeId: string; + } + | { + expectedRevisionId: WorkflowExpectedRevisionId; + insertMode: "before"; + nodeTypeName: CreateWorkflowNodeTypeName; + beforeNodeId: string; + }; + +type CreatedWorkflowNode = WorkflowNodeWithRevision & { + /** + * Default child nodes created along with the requested node. + * BranchNode creation returns two AudienceFilter children. + * ExperimentBranchNode creation returns two regular VariantNode children + * and one control VariantNode. + */ + createdChildNodes?: WorkflowNode[]; +}; + +interface CreateWorkflowNodeResponse { + node: CreatedWorkflowNode; + workflow: SimplifiedWorkflow; +} + +interface AddWorkflowBranchResponse { + node: WorkflowNodeWithRevision; + workflow: SimplifiedWorkflow; +} + +interface WorkflowMailingListPreview { + status: "dryRun" | "queuedContactsFound"; + mailingListId: string | null; + queuedContactCount: number; + queuedContactLimitReached: boolean; +} + +interface WorkflowMailingListUpdatedResponse { + status: "updated"; + mailingListId: string | null; + workflowRevisionId: string; + queuedContactCount: number; + queuedContactLimitReached: boolean; +} + +type ChangeWorkflowMailingListResponse = + | WorkflowMailingListPreview + | WorkflowMailingListUpdatedResponse; + +interface WorkflowQueuedContactDeletePreview { + status: "dryRun" | "queuedContactsFound"; + nodeIds: string[]; + queuedContactCount: number; + queuedContactLimitReached: boolean; +} + +interface WorkflowDeletedResponse { + status: "deleted"; + nodeIds: string[]; + workflowRevisionId: string; + queuedContactCount: number; + queuedContactLimitReached: boolean; +} + +type DeleteWorkflowNodeResponse = + | WorkflowQueuedContactDeletePreview + | WorkflowDeletedResponse; + +type WorkflowContactPropertyComparisonOperator = + | "any" + | "contains" + | "not_contains" + | "empty" + | "not_empty" + | "equal" + | "not_equal" + | "greater_than" + | "less_than" + | "true" + | "false" + | "numeric_equal" + | "numeric_not_equal" + | "after" + | "before" + | "between"; + +interface WorkflowContactPropertyComparison { + value: string | number | boolean; + operator: WorkflowContactPropertyComparisonOperator; +} + +interface WorkflowContactPropertyQuery { + key: string; + is: WorkflowContactPropertyComparison; + was: WorkflowContactPropertyComparison; +} + +type UpdateWorkflowNodePayload = + | { typeName: "SignupTrigger" } + | { + typeName?: "EventTrigger"; + eventPatternId?: string | null; + eventName?: string | null; + reEligible?: boolean; + } + | { + typeName?: "ContactPropertyTrigger"; + contactPropertyQuery?: WorkflowContactPropertyQuery; + reEligible?: boolean; + } + | { + typeName?: "AddToListTrigger"; + reEligible?: boolean; + } + | { + audienceSegmentId?: string | null; + audienceFilter?: AudienceFilter; + appliesDownstream?: boolean; + } + | { + amount?: number; + unit?: "m" | "h" | "d"; + } + | { + samplingRate?: number; + } + | { + isControl?: boolean; + }; + interface EmailMessagePreviewResponse { /** The ID of the email message the preview was sent for. */ id: string; } +type GuardianRuleName = + | "unsupportedContactProperties" + | "missingFallbackContactProperties" + | "unsupportedEventProperties" + | "missingFallbackEventProperties" + | "unsupportedDataVariables" + | "invalidCustomDataVariables" + | "missingRequiredDataVariables" + | "missingButtonHrefs" + | "invalidButtonHrefs" + | "shortenedYouTubeButtonHrefs" + | "missingLinkHrefs" + | "invalidLinkHrefs" + | "shortenedYouTubeLinkHrefs" + | "shortenedYouTubeImageHrefs" + | "emailWithoutMailtoButtonHrefs" + | "emailWithoutMailtoLinkHrefs" + | "emailWithoutMailtoImageHrefs" + | "bareArrayNodes" + | "missingSocialIconHrefs"; + +interface GuardianRuleItem { + /** A human-readable label for the item (for example, link text or a property name). */ + label: string; + /** + * Machine-readable identifier when the rule refers to a property or variable. + */ + codeName?: string; +} + +interface GuardianRule { + /** The identifier of the Guardian rule that fired. */ + rule: GuardianRuleName; + /** A short summary of the rule. */ + title: string; + /** A longer explanation of why the issue matters. */ + description: string; + /** The specific elements that triggered the rule. */ + items: GuardianRuleItem[]; +} + +interface EmailMessageGuardianResponse { + /** Validation errors. These must be resolved before the email can be published. */ + errors: GuardianRule[]; + /** Validation warnings. These are advisory and do not block publishing. */ + warnings: GuardianRule[]; +} + interface ListTransactionalsResourceResponse { pagination: PaginationData; data: TransactionalEmailResource[]; @@ -472,6 +717,14 @@ interface ListThemesResponse { type ThemeResponse = Theme; +interface UpdateThemeResponse extends ThemeResponse { + /** + * The number of emails using this theme that are affected by the style change. + * `0` when only the name changed. + */ + affectedEmailCount: number; +} + interface Component { id: string; name: string; @@ -485,10 +738,18 @@ interface ListComponentsResponse { type ComponentResponse = Component; +interface UpdateComponentResponse extends ComponentResponse { + /** + * The number of emails using this component that were updated by the body change. + * `0` when only the name changed. + */ + affectedEmailCount: number; +} + interface Campaign { id: string; name: string; - status: string; + status: "Draft" | "Scheduled" | "Sending" | "Sent"; createdAt: string; updatedAt: string; emailMessageId: string | null; @@ -1190,7 +1451,7 @@ class LoopsClient { * * @param {string} transactionalId The ID of the transactional email. * - * @see https://loops.so/docs/api-reference/ensure-transactional-email-draft + * @see https://loops.so/docs/api-reference/ensure-transactional-draft * * @returns {Object} Transactional email with draft (JSON) */ @@ -1224,7 +1485,7 @@ class LoopsClient { /** * List dedicated sending IP addresses. * - * @see https://loops.so/docs/api-reference/get-dedicated-sending-ips + * @see https://loops.so/docs/api-reference/dedicated-sending-ips * * @returns {string[]} List of IP addresses */ @@ -1279,6 +1540,40 @@ class LoopsClient { }); } + /** + * Create an audience segment. + * + * @param {Object} params + * @param {string} params.name The name of the audience segment. Must be unique within the team. + * @param {string} [params.description] An optional description of the audience segment. + * @param {AudienceFilter} params.filter A tree of audience conditions combined with `match`. + * + * @see https://loops.so/docs/api-reference/create-audience-segment + * + * @returns {Object} Created audience segment (JSON) + */ + async createAudienceSegment({ + name, + description, + filter, + }: { + name: string; + description?: string; + filter: AudienceFilter; + }): Promise { + const payload: { + name: string; + description?: string; + filter: AudienceFilter; + } = { name, filter }; + if (description !== undefined) payload.description = description; + return this._makeQuery({ + path: "v1/audience-segments", + method: "POST", + payload, + }); + } + /** * List email themes. * @@ -1322,6 +1617,68 @@ class LoopsClient { }); } + /** + * Create a theme. + * + * @param {Object} params + * @param {string} params.name The theme name. + * @param {ThemeStyles} [params.styles] Style attributes for the theme. + * + * @see https://loops.so/docs/api-reference/create-theme + * + * @returns {Object} Created theme (JSON) + */ + async createTheme({ + name, + styles, + }: { + name: string; + styles?: ThemeStyles; + }): Promise { + const payload: { name: string; styles?: ThemeStyles } = { name }; + if (styles !== undefined) payload.styles = styles; + return this._makeQuery({ + path: "v1/themes", + method: "POST", + payload, + }); + } + + /** + * Update a theme's name and/or styles. + * + * When `styles` change, the update cascades to every email using this theme. + * `affectedEmailCount` in the response reports how many emails were affected. + * + * @param {string} themeId The ID of the theme. + * @param {Object} params + * @param {string} [params.name] The theme name. + * @param {ThemeStyles} [params.styles] Style attributes for the theme. + * + * @see https://loops.so/docs/api-reference/update-theme + * + * @returns {Object} Updated theme (JSON) + */ + async updateTheme( + themeId: string, + { + name, + styles, + }: { + name?: string; + styles?: ThemeStyles; + } + ): Promise { + const payload: { name?: string; styles?: ThemeStyles } = {}; + if (name !== undefined) payload.name = name; + if (styles !== undefined) payload.styles = styles; + return this._makeQuery({ + path: `v1/themes/${themeId}`, + method: "POST", + payload, + }); + } + /** * List email components. * @@ -1365,6 +1722,66 @@ class LoopsClient { }); } + /** + * Create a component. + * + * @param {Object} params + * @param {string} params.name The component name. + * @param {string} params.lmx The component body as an LMX string. + * + * @see https://loops.so/docs/api-reference/create-component + * + * @returns {Object} Created component (JSON) + */ + async createComponent({ + name, + lmx, + }: { + name: string; + lmx: string; + }): Promise { + return this._makeQuery({ + path: "v1/components", + method: "POST", + payload: { name, lmx }, + }); + } + + /** + * Update a component's name and/or LMX body. + * + * When `lmx` changes, the update cascades to every email using this component. + * `affectedEmailCount` in the response reports how many were affected. + * + * @param {string} componentId The ID of the component. + * @param {Object} params + * @param {string} [params.name] The component name. + * @param {string} [params.lmx] The component body as an LMX string. + * + * @see https://loops.so/docs/api-reference/update-component + * + * @returns {Object} Updated component (JSON) + */ + async updateComponent( + componentId: string, + { + name, + lmx, + }: { + name?: string; + lmx?: string; + } + ): Promise { + const payload: { name?: string; lmx?: string } = {}; + if (name !== undefined) payload.name = name; + if (lmx !== undefined) payload.lmx = lmx; + return this._makeQuery({ + path: `v1/components/${componentId}`, + method: "POST", + payload, + }); + } + /** * List campaigns. * @@ -1644,7 +2061,7 @@ class LoopsClient { * @param {Record} [params.eventProperties] Event property values to render. * @param {Record} [params.dataVariables] Transactional data variables to render. * - * @see https://loops.so/docs/api-reference/send-email-message-preview + * @see https://loops.so/docs/api-reference/preview-email-message * * @returns {Object} Preview confirmation (JSON) */ @@ -1682,6 +2099,84 @@ class LoopsClient { }); } + /** + * Run Guardian checks on an email message. + * + * Validates content against Guardian rules and returns errors and warnings. + * Errors must be resolved before the email can be published; warnings are advisory. + * + * @param {string} emailMessageId The ID of the email message. + * + * @see https://loops.so/docs/api-reference/run-guardian-checks + * + * @returns {Object} Guardian errors and warnings (JSON) + */ + async runEmailMessageGuardian( + emailMessageId: string + ): Promise { + return this._makeQuery({ + path: `v1/email-messages/${emailMessageId}/guardian`, + }); + } + + /** + * List event patterns available to workflow event trigger nodes. + * + * @param {Object} params + * @param {number} [params.perPage] How many results to return in each request. Must be between 10 and 50. Defaults to 20. + * @param {string} [params.cursor] A cursor, to return a specific page of results. + * + * @see https://loops.so/docs/api-reference/list-event-patterns + * + * @returns {Object} List of event patterns (JSON) + */ + async listEventPatterns({ + perPage, + cursor, + }: { + perPage?: number; + cursor?: string; + } = {}): Promise { + const params: { perPage: string; cursor?: string } = { + perPage: (perPage || 20).toString(), + }; + if (cursor) params["cursor"] = cursor; + return this._makeQuery({ + path: "v1/event-patterns", + params, + }); + } + + /** + * Get an event pattern by ID. + * + * @param {string} eventPatternId The ID of the event pattern. + * + * @see https://loops.so/docs/api-reference/get-event-pattern + * + * @returns {Object} Event pattern (JSON) + */ + async getEventPattern(eventPatternId: string): Promise { + return this._makeQuery({ + path: `v1/event-patterns/${eventPatternId}`, + }); + } + + /** + * Get an event pattern by event name. + * + * @param {string} eventName The name of the event pattern. + * + * @see https://loops.so/docs/api-reference/get-event-pattern-by-name + * + * @returns {Object} Event pattern (JSON) + */ + async getEventPatternByName(eventName: string): Promise { + return this._makeQuery({ + path: `v1/event-patterns/by-name/${encodeURIComponent(eventName)}`, + }); + } + /** * List workflows. * @@ -1710,6 +2205,41 @@ class LoopsClient { }); } + /** + * Create a draft workflow with a blank trigger and exit node. + * + * @param {Object} params + * @param {string} params.name The name of the workflow. + * @param {string} [params.description] The description of the workflow. + * @param {string | null} [params.mailingListId] The ID of a mailing list the workflow sends to. + * + * @see https://loops.so/docs/api-reference/create-workflow + * + * @returns {Object} Created workflow (JSON) + */ + async createWorkflow({ + name, + description, + mailingListId, + }: { + name: string; + description?: string; + mailingListId?: string | null; + }): Promise { + const payload: { + name: string; + description?: string; + mailingListId?: string | null; + } = { name }; + if (description !== undefined) payload.description = description; + if (mailingListId !== undefined) payload.mailingListId = mailingListId; + return this._makeQuery({ + path: "v1/workflows", + method: "POST", + payload, + }); + } + /** * Get a workflow by ID. * @@ -1719,12 +2249,130 @@ class LoopsClient { * * @returns {Object} Workflow graph (JSON) */ - async getWorkflow(workflowId: string): Promise { + async getWorkflow( + workflowId: string + ): Promise { return this._makeQuery({ path: `v1/workflows/${workflowId}`, }); } + /** + * Update a workflow's display properties. + * + * At least one of `name` or `description` must be provided. To change the + * mailing list, use `changeWorkflowMailingList()`. + * + * @param {string} workflowId The ID of the workflow. + * @param {Object} params + * @param {string | null} params.expectedRevisionId The workflow revision token from the latest read or mutation. Pass `null` for workflows without a revision yet. + * @param {string} [params.name] The updated workflow name. + * @param {string} [params.description] The updated workflow description. + * + * @see https://loops.so/docs/api-reference/update-workflow + * + * @returns {Object} Updated workflow (JSON) + */ + async updateWorkflow( + workflowId: string, + { + expectedRevisionId, + name, + description, + }: { + expectedRevisionId: WorkflowExpectedRevisionId; + name?: string; + description?: string; + } + ): Promise { + const payload: { + expectedRevisionId: WorkflowExpectedRevisionId; + name?: string; + description?: string; + } = { expectedRevisionId }; + if (name !== undefined) payload.name = name; + if (description !== undefined) payload.description = description; + return this._makeQuery({ + path: `v1/workflows/${workflowId}`, + method: "POST", + payload, + }); + } + + /** + * Dry run or apply a workflow mailing list change. + * + * If queued contacts would be removed, Loops returns + * `"status": "queuedContactsFound"` instead of updating. Retry with + * `queuedContactPolicy: "discard"` to apply the change. + * + * @param {string} workflowId The ID of the workflow. + * @param {Object} params + * @param {string | null} params.expectedRevisionId The workflow revision token from the latest read or mutation. Pass `null` for workflows without a revision yet. + * @param {string | null} params.mailingListId The mailing list to use, or `null` to clear it. + * @param {boolean} [params.dryRun] If `true`, validate without modifying the workflow. + * @param {"fail" | "discard"} [params.queuedContactPolicy] How to handle queued contacts that would be removed. + * + * @see https://loops.so/docs/api-reference/change-workflow-mailing-list + * + * @returns {Object} Preview or updated mailing list result (JSON) + */ + async changeWorkflowMailingList( + workflowId: string, + { + expectedRevisionId, + mailingListId, + dryRun, + queuedContactPolicy, + }: { + expectedRevisionId: WorkflowExpectedRevisionId; + mailingListId: string | null; + dryRun?: boolean; + queuedContactPolicy?: WorkflowQueuedContactPolicy; + } + ): Promise { + const payload: { + expectedRevisionId: WorkflowExpectedRevisionId; + mailingListId: string | null; + dryRun?: boolean; + queuedContactPolicy?: WorkflowQueuedContactPolicy; + } = { expectedRevisionId, mailingListId }; + if (dryRun !== undefined) payload.dryRun = dryRun; + if (queuedContactPolicy !== undefined) { + payload.queuedContactPolicy = queuedContactPolicy; + } + return this._makeQuery({ + path: `v1/workflows/${workflowId}/mailing-list`, + method: "POST", + payload, + }); + } + + /** + * Create a new default workflow node and return it with the latest workflow. + * + * Use `insertMode: "between"` to place the node between an existing connection, + * or `insertMode: "before"` to insert before a node. Configure the node after + * creation with `updateWorkflowNode()`. + * + * @param {string} workflowId The ID of the workflow. + * @param {Object} params Create parameters including `insertMode` and revision. + * + * @see https://loops.so/docs/api-reference/create-workflow-node + * + * @returns {Object} Created node and workflow (JSON) + */ + async createWorkflowNode( + workflowId: string, + params: CreateWorkflowNodeParams + ): Promise { + return this._makeQuery({ + path: `v1/workflows/${workflowId}/nodes`, + method: "POST", + payload: params, + }); + } + /** * Get a workflow node by ID. * @@ -1735,9 +2383,168 @@ class LoopsClient { * * @returns {Object} Workflow node (JSON) */ - async getWorkflowNode(workflowId: string, nodeId: string): Promise { + async getWorkflowNode( + workflowId: string, + nodeId: string + ): Promise { + return this._makeQuery({ + path: `v1/workflows/${workflowId}/nodes/${nodeId}`, + }); + } + + /** + * Update workflow-node-owned fields for a single node. + * + * Shared resources such as email messages and audience segments should be + * updated through their own APIs. + * + * @param {string} workflowId The ID of the workflow. + * @param {string} nodeId The ID of the workflow node. + * @param {Object} params + * @param {string | null} params.expectedRevisionId The workflow revision token from the latest read or mutation. Pass `null` for workflows without a revision yet. + * @param {UpdateWorkflowNodePayload} params.payload Node-type-specific fields to update. + * + * @see https://loops.so/docs/api-reference/update-workflow-node + * + * @returns {Object} Updated workflow node (JSON) + */ + async updateWorkflowNode( + workflowId: string, + nodeId: string, + { + expectedRevisionId, + payload, + }: { + expectedRevisionId: WorkflowExpectedRevisionId; + payload: UpdateWorkflowNodePayload; + } + ): Promise { return this._makeQuery({ path: `v1/workflows/${workflowId}/nodes/${nodeId}`, + method: "POST", + payload: { expectedRevisionId, payload }, + }); + } + + /** + * Delete a single workflow node. + * + * If contacts are queued at the node, Loops returns + * `"status": "queuedContactsFound"` instead of deleting. Retry with + * `queuedContactPolicy: "discard"` to delete the node. + * + * @param {string} workflowId The ID of the workflow. + * @param {string} nodeId The ID of the workflow node. + * @param {Object} params + * @param {string | null} params.expectedRevisionId The workflow revision token from the latest read or mutation. Pass `null` for workflows without a revision yet. + * @param {boolean} [params.dryRun] If `true`, validate without modifying the workflow. + * @param {"fail" | "discard"} [params.queuedContactPolicy] How to handle queued contacts. + * + * @see https://loops.so/docs/api-reference/delete-workflow-node + * + * @returns {Object} Preview or delete result (JSON) + */ + async deleteWorkflowNode( + workflowId: string, + nodeId: string, + { + expectedRevisionId, + dryRun, + queuedContactPolicy, + }: { + expectedRevisionId: WorkflowExpectedRevisionId; + dryRun?: boolean; + queuedContactPolicy?: WorkflowQueuedContactPolicy; + } + ): Promise { + const payload: { + expectedRevisionId: WorkflowExpectedRevisionId; + dryRun?: boolean; + queuedContactPolicy?: WorkflowQueuedContactPolicy; + } = { expectedRevisionId }; + if (dryRun !== undefined) payload.dryRun = dryRun; + if (queuedContactPolicy !== undefined) { + payload.queuedContactPolicy = queuedContactPolicy; + } + return this._makeQuery({ + path: `v1/workflows/${workflowId}/nodes/${nodeId}`, + method: "DELETE", + payload, + }); + } + + /** + * Add a branch and a child node under an existing Branch or Experiment node. + * + * @param {string} workflowId The ID of the workflow. + * @param {string} nodeId The ID of the Branch or Experiment node. + * @param {Object} params + * @param {string | null} params.expectedRevisionId The workflow revision token from the latest read or mutation. Pass `null` for workflows without a revision yet. + * + * @see https://loops.so/docs/api-reference/add-workflow-branch + * + * @returns {Object} Created child node and workflow (JSON) + */ + async addWorkflowBranch( + workflowId: string, + nodeId: string, + { + expectedRevisionId, + }: { + expectedRevisionId: WorkflowExpectedRevisionId; + } + ): Promise { + return this._makeQuery({ + path: `v1/workflows/${workflowId}/nodes/${nodeId}/add-branch`, + method: "POST", + payload: { expectedRevisionId }, + }); + } + + /** + * Delete a node and its downstream subtree. + * + * If contacts are queued at any node that would be deleted, Loops returns + * `"status": "queuedContactsFound"` instead of deleting. Retry with + * `queuedContactPolicy: "discard"` to delete. + * + * @param {string} workflowId The ID of the workflow. + * @param {string} nodeId The ID of the workflow node. + * @param {Object} params + * @param {string | null} params.expectedRevisionId The workflow revision token from the latest read or mutation. Pass `null` for workflows without a revision yet. + * @param {boolean} [params.dryRun] If `true`, validate without modifying the workflow. + * @param {"fail" | "discard"} [params.queuedContactPolicy] How to handle queued contacts. + * + * @see https://loops.so/docs/api-reference/delete-workflow-nodes + * + * @returns {Object} Preview or delete result (JSON) + */ + async deleteWorkflowNodesRecursive( + workflowId: string, + nodeId: string, + { + expectedRevisionId, + dryRun, + queuedContactPolicy, + }: { + expectedRevisionId: WorkflowExpectedRevisionId; + dryRun?: boolean; + queuedContactPolicy?: WorkflowQueuedContactPolicy; + } + ): Promise { + const payload: { + expectedRevisionId: WorkflowExpectedRevisionId; + dryRun?: boolean; + queuedContactPolicy?: WorkflowQueuedContactPolicy; + } = { expectedRevisionId }; + if (dryRun !== undefined) payload.dryRun = dryRun; + if (queuedContactPolicy !== undefined) { + payload.queuedContactPolicy = queuedContactPolicy; + } + return this._makeQuery({ + path: `v1/workflows/${workflowId}/nodes/${nodeId}/recursive`, + method: "DELETE", + payload, }); } @@ -2026,9 +2833,11 @@ export { Theme, ListThemesResponse, ThemeResponse, + UpdateThemeResponse, Component, ListComponentsResponse, ComponentResponse, + UpdateComponentResponse, Group, ListGroupsResponse, AudienceFilterBetweenValue, @@ -2041,12 +2850,40 @@ export { CampaignSchedulingRequest, AudienceSegment, ListAudienceSegmentsResponse, + IncomingWebhookPlatform, + EventPatternSummary, + WorkflowEventProperty, + EventPattern, + ListEventPatternsResponse, WorkflowSummary, ListWorkflowsResponse, + WorkflowStatus, SimplifiedWorkflowNode, SimplifiedWorkflow, + WorkflowExpectedRevisionId, WorkflowNode, + WorkflowNodeWithRevision, + WorkflowQueuedContactPolicy, + CreateWorkflowNodeTypeName, + CreateWorkflowNodeParams, + CreatedWorkflowNode, + CreateWorkflowNodeResponse, + AddWorkflowBranchResponse, + WorkflowMailingListPreview, + WorkflowMailingListUpdatedResponse, + ChangeWorkflowMailingListResponse, + WorkflowQueuedContactDeletePreview, + WorkflowDeletedResponse, + DeleteWorkflowNodeResponse, + WorkflowContactPropertyComparisonOperator, + WorkflowContactPropertyComparison, + WorkflowContactPropertyQuery, + UpdateWorkflowNodePayload, EmailMessagePreviewResponse, + GuardianRuleName, + GuardianRuleItem, + GuardianRule, + EmailMessageGuardianResponse, Campaign, CampaignListItem, ListCampaignsResponse, diff --git a/tsconfig.json b/tsconfig.json index 7323402..06fa51a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,8 @@ "noUnusedLocals": true, "noUnusedParameters": true, "strict": true, - "target": "ESNext" + "target": "ESNext", + "types": ["jest", "node"] }, "include": ["./src/**/*.ts"] } \ No newline at end of file