From 5db09d7078682cc4ea64c84013d23f9c57bbfbdb Mon Sep 17 00:00:00 2001 From: Mike Engel Date: Wed, 15 Jul 2026 12:46:24 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20add=20App=20API=20content=20&=20sen?= =?UTF-8?q?der=20utility=20methods=20=E2=80=94=20snippets,=20sender=20iden?= =?UTF-8?q?tities,=20messages=20(CDP-6277)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch 13 (stacked on Batch 12). Adds 4 snippet, 3 sender-identity, and 3 message-log methods to APIClient. Contracts verified against the backend, not the OpenAPI spec: - Snippets are keyed by name (create + upsert both take { name, value }; delete is by name in the path; DELETE returns 204). - Sender identities use integer ids; list supports start/limit/sort/hidden; used_by returns campaign + newsletter usage. - Messages are the workspace-wide delivery log: list supports the full filter set (channel, metric, drafts, resource-id scopes, time window, associations, tracked responses); a single message id is a delivery id string, with optional archived_message/associations expansions plus a dedicated archived_message endpoint. Includes unit tests (100% coverage), live round-trip/read coverage, and docs. --- docs/app.md | 140 +++++++++++++++++++++++ lib/api.ts | 241 +++++++++++++++++++++++++++++++++++++++ test/api.ts | 110 ++++++++++++++++++ test/integration/live.ts | 19 +++ 4 files changed, 510 insertions(+) diff --git a/docs/app.md b/docs/app.md index 1d3e6dc..a1e8399 100644 --- a/docs/app.md +++ b/docs/app.md @@ -2006,3 +2006,143 @@ api.deleteReportingWebhook(4); #### Options - **webhookId**: The webhook's numeric id (required) + +## Content & sender utilities + +### Snippets + +Manage [snippets](https://customer.io/docs/journeys/snippets/) — reusable content blocks referenced from messages. Snippets are identified by `name`. + +### api.getSnippets() + +List the snippets in your workspace. + +```javascript +api.getSnippets(); +``` + +### api.createSnippet(snippet) + +Create a snippet. + +```javascript +api.createSnippet({ name: "footer", value: "

© 2026 Example

" }); +``` + +#### Options + +- **snippet**: The snippet definition + - _name_: The snippet's unique name/key (required) + - _value_: The snippet's value; may contain Liquid (required) + +### api.updateSnippet(snippet) + +Create or update a snippet (upsert by name). + +```javascript +api.updateSnippet({ name: "footer", value: "

© 2027 Example

" }); +``` + +#### Options + +- **snippet**: The snippet definition — `name` and `value` (both required) + +### api.deleteSnippet(name) + +Delete a snippet by name. Returns no content on success. Fails if the snippet is still in use. + +```javascript +api.deleteSnippet("footer"); +``` + +#### Options + +- **name**: The snippet's name (required) + +### Sender identities + +### api.getSenderIdentities(options) + +List the sender identities in your workspace. + +```javascript +api.getSenderIdentities({ limit: 50, sort: "asc" }); +``` + +#### Options + +- **options**: Object (optional) — `start`, `limit`, `sort` ("asc" / "desc"), `hidden` (omit for all, `true`/`false` to filter) + +### api.getSenderIdentity(senderId) + +Get a single sender identity. + +```javascript +api.getSenderIdentity(12); +``` + +#### Options + +- **senderId**: The sender identity's numeric id (required) + +### api.getSenderIdentityUsedBy(senderId) + +List the campaigns and newsletters that use a sender identity. + +```javascript +api.getSenderIdentityUsedBy(12); +``` + +#### Options + +- **senderId**: The sender identity's numeric id (required) + +### Messages + +Look up sent messages (deliveries) across your workspace. For a single person's messages, use [`getCustomerMessages`](#apigetcustomermessagescustomerid-options). + +### api.getMessages(options) + +List sent messages. + +```javascript +api.getMessages({ metric: "delivered", type: "email", limit: 50 }); +``` + +#### Options + +- **options**: Object (optional) + - _start_: Pagination cursor from a previous page's `next` + - _limit_: Maximum number of results + - _drafts_: Return only drafts (`true`) or exclude them (default) + - _metric_: Filter to a delivery metric (e.g. `delivered`, `opened`, `bounced`) + - _type_: Scope to a channel ("email" / "webhook" / "twilio" / "whatsapp" / "slack" / "push" / "in_app") + - _campaign_id_ / _action_id_ / _newsletter_id_ / _transactional_id_ / _trigger_id_ / _template_id_ / _content_id_: Scope to a single resource + - _start_ts_ / _end_ts_: Unix timestamp bounds (seconds) + - _associations_: Include related campaigns/actions/newsletters/contents + - _get_tracked_responses_: Include tracked responses on each delivery + +### api.getMessage(messageId, options) + +Get a single sent message. + +```javascript +api.getMessage("RPCImftJDcAAAAd...", { archived_message: true }); +``` + +#### Options + +- **messageId**: The delivery id (`CIO-Delivery-ID`) (required) +- **options**: Object (optional) — `archived_message`, `associations`, `get_tracked_responses` + +### api.getArchivedMessage(messageId) + +Get the archived content of a single sent message. + +```javascript +api.getArchivedMessage("RPCImftJDcAAAAd..."); +``` + +#### Options + +- **messageId**: The delivery id (`CIO-Delivery-ID`) (required) diff --git a/lib/api.ts b/lib/api.ts index eeb23ba..e0b38c0 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -570,6 +570,64 @@ export type ReportingWebhookUpdate = { disabled?: boolean; }; +/** A snippet definition for {@link APIClient.createSnippet} / {@link APIClient.updateSnippet}. */ +export type SnippetInput = { + /** The snippet's unique name/key. */ + name: string; + /** The snippet's value (may contain Liquid). */ + value: string; +}; + +/** Options for {@link APIClient.getSenderIdentities}. */ +export type SenderIdentitiesOptions = PaginationOptions & { + /** Sort direction. Defaults to `asc`. */ + sort?: SortDirection; + /** Filter by hidden status. Omit to return all; `true`/`false` to filter. */ + hidden?: boolean; +}; + +/** Options for {@link APIClient.getMessages}. */ +export type MessagesOptions = PaginationOptions & { + /** Return only drafts (`true`) or exclude them (default). */ + drafts?: boolean; + /** Filter to deliveries with this metric (e.g. `delivered`, `opened`, `bounced`). */ + metric?: string; + /** Scope to a single channel. */ + type?: MetricType; + /** Scope to a single campaign. */ + campaign_id?: string | number; + /** Scope to a single campaign action. */ + action_id?: string | number; + /** Scope to a single newsletter. */ + newsletter_id?: string | number; + /** Scope to a single transactional message. */ + transactional_id?: string | number; + /** Scope to a single broadcast trigger (requires `campaign_id`). */ + trigger_id?: string | number; + /** Scope to a single template. */ + template_id?: string | number; + /** Scope to a single content id. */ + content_id?: string | number; + /** Only include deliveries after this Unix timestamp (seconds). */ + start_ts?: number; + /** Only include deliveries before this Unix timestamp (seconds). */ + end_ts?: number; + /** Include the related campaigns/actions/newsletters/contents in the response. */ + associations?: boolean; + /** Include tracked responses on each delivery. */ + get_tracked_responses?: boolean; +}; + +/** Options for {@link APIClient.getMessage}. */ +export type MessageOptions = { + /** Include the archived message content (rate-limited). */ + archived_message?: boolean; + /** Include the related campaign/action/newsletter/content in the response. */ + associations?: boolean; + /** Include tracked responses on the delivery. */ + get_tracked_responses?: boolean; +}; + type APIDefaults = RequestDefaults & { region: Region; url?: string; retry?: Partial }; type Recipients = Record; @@ -3571,6 +3629,189 @@ export class APIClient { return this.request.destroy(`${this.apiRoot}/reporting_webhooks/${encodeURIComponent(webhookId)}`); } + + /** + * List the snippets in your workspace. + * + * @returns The parsed JSON response body (`{ snippets: [...] }`). + */ + getSnippets() { + return this.request.get(`${this.apiRoot}/snippets`); + } + + /** + * Create a snippet. + * + * @param snippet The snippet definition. `name` and `value` are required. See {@link SnippetInput}. + * @returns The parsed JSON response body (`{ snippet: {...} }`). + * @throws {MissingParamError} If `snippet` is missing/not an object, or `snippet.name`/`snippet.value` is empty. + */ + createSnippet(snippet: SnippetInput) { + if (snippet == null || typeof snippet !== 'object') { + throw new MissingParamError('snippet'); + } + + if (isEmpty(snippet.name)) { + throw new MissingParamError('snippet.name'); + } + + if (isEmpty(snippet.value)) { + throw new MissingParamError('snippet.value'); + } + + return this.request.post(`${this.apiRoot}/snippets`, snippet); + } + + /** + * Create or update a snippet (upsert by name). + * + * @param snippet The snippet definition. `name` and `value` are required. See {@link SnippetInput}. + * @returns The parsed JSON response body (`{ snippet: {...} }`). + * @throws {MissingParamError} If `snippet` is missing/not an object, or `snippet.name`/`snippet.value` is empty. + */ + updateSnippet(snippet: SnippetInput) { + if (snippet == null || typeof snippet !== 'object') { + throw new MissingParamError('snippet'); + } + + if (isEmpty(snippet.name)) { + throw new MissingParamError('snippet.name'); + } + + if (isEmpty(snippet.value)) { + throw new MissingParamError('snippet.value'); + } + + return this.request.put(`${this.apiRoot}/snippets`, snippet); + } + + /** + * Delete a snippet by name. Fails if the snippet is still in use. + * + * @param name The snippet's name. + * @returns The parsed JSON response body (empty on success — the API returns 204). + * @throws {MissingParamError} If `name` is empty. + */ + deleteSnippet(name: string) { + if (isEmpty(name)) { + throw new MissingParamError('name'); + } + + return this.request.destroy(`${this.apiRoot}/snippets/${encodeURIComponent(name)}`); + } + + /** + * List the sender identities in your workspace. + * + * @param options Optional sort, pagination, and hidden filter. See {@link SenderIdentitiesOptions}. + * @returns The parsed JSON response body (`{ sender_identities: [...], next }`). + */ + getSenderIdentities(options: SenderIdentitiesOptions = {}) { + const query = buildQueryString({ + start: options.start, + limit: options.limit, + sort: options.sort, + hidden: options.hidden, + }); + + return this.request.get(`${this.apiRoot}/sender_identities${query}`); + } + + /** + * Get a single sender identity. + * + * @param senderId The sender identity's numeric id. + * @returns The parsed JSON response body (`{ sender_identity: {...} }`). + * @throws {MissingParamError} If `senderId` is empty. + */ + getSenderIdentity(senderId: string | number) { + if (isEmpty(senderId)) { + throw new MissingParamError('senderId'); + } + + return this.request.get(`${this.apiRoot}/sender_identities/${encodeURIComponent(senderId)}`); + } + + /** + * List the campaigns and newsletters that use a sender identity. + * + * @param senderId The sender identity's numeric id. + * @returns The parsed JSON response body (`{ campaigns, sent_newsletters, draft_newsletters }`). + * @throws {MissingParamError} If `senderId` is empty. + */ + getSenderIdentityUsedBy(senderId: string | number) { + if (isEmpty(senderId)) { + throw new MissingParamError('senderId'); + } + + return this.request.get(`${this.apiRoot}/sender_identities/${encodeURIComponent(senderId)}/used_by`); + } + + /** + * List sent messages (deliveries) across your workspace. + * + * @param options Optional filters and pagination. See {@link MessagesOptions}. + * @returns The parsed JSON response body (`{ messages: [...], next, ... }`). + */ + getMessages(options: MessagesOptions = {}) { + const query = buildQueryString({ + start: options.start, + limit: options.limit, + drafts: options.drafts, + metric: options.metric, + type: options.type, + campaign_id: options.campaign_id, + action_id: options.action_id, + newsletter_id: options.newsletter_id, + transactional_id: options.transactional_id, + trigger_id: options.trigger_id, + template_id: options.template_id, + content_id: options.content_id, + start_ts: options.start_ts, + end_ts: options.end_ts, + associations: options.associations, + get_tracked_responses: options.get_tracked_responses, + }); + + return this.request.get(`${this.apiRoot}/messages${query}`); + } + + /** + * Get a single sent message (delivery). + * + * @param messageId The delivery id (`CIO-Delivery-ID`). + * @param options Optional response expansions. See {@link MessageOptions}. + * @returns The parsed JSON response body (`{ message: {...}, ... }`). + * @throws {MissingParamError} If `messageId` is empty. + */ + getMessage(messageId: string, options: MessageOptions = {}) { + if (isEmpty(messageId)) { + throw new MissingParamError('messageId'); + } + + const query = buildQueryString({ + archived_message: options.archived_message, + associations: options.associations, + get_tracked_responses: options.get_tracked_responses, + }); + + return this.request.get(`${this.apiRoot}/messages/${encodeURIComponent(messageId)}${query}`); + } + + /** + * Get the archived content of a single sent message. + * + * @param messageId The delivery id (`CIO-Delivery-ID`). + * @returns The parsed JSON response body (`{ archived_message: {...} }`). + * @throws {MissingParamError} If `messageId` is empty. + */ + getArchivedMessage(messageId: string) { + if (isEmpty(messageId)) { + throw new MissingParamError('messageId'); + } + + return this.request.get(`${this.apiRoot}/messages/${encodeURIComponent(messageId)}/archived_message`); + } } export { diff --git a/test/api.ts b/test/api.ts index 45053f0..1ed7fd5 100644 --- a/test/api.ts +++ b/test/api.ts @@ -2256,3 +2256,113 @@ test('#deleteReportingWebhook: deletes a webhook and validates', (t) => { t.context.client.deleteReportingWebhook(4); t.true(destroy.calledWith(`${API}/reporting_webhooks/4`)); }); + +// Snippets + +test('#getSnippets: gets the snippets endpoint', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.context.client.getSnippets(); + t.true(get.calledWith(`${API}/snippets`)); +}); + +test('#createSnippet: posts the snippet body and validates', (t) => { + const post = sinon.stub(t.context.client.request, 'post'); + t.throws(() => (t.context.client.createSnippet as any)(null), { message: 'snippet is required' }); + t.throws(() => (t.context.client.createSnippet as any)({ value: 'v' }), { message: 'snippet.name is required' }); + t.throws(() => (t.context.client.createSnippet as any)({ name: 'n' }), { message: 'snippet.value is required' }); + const snippet = { name: 'footer', value: '

© 2026

' }; + t.context.client.createSnippet(snippet); + t.true(post.calledWith(`${API}/snippets`, snippet)); +}); + +test('#updateSnippet: puts the snippet body (upsert) and validates', (t) => { + const put = sinon.stub(t.context.client.request, 'put'); + t.throws(() => (t.context.client.updateSnippet as any)(null), { message: 'snippet is required' }); + t.throws(() => (t.context.client.updateSnippet as any)({ value: 'v' }), { message: 'snippet.name is required' }); + t.throws(() => (t.context.client.updateSnippet as any)({ name: 'n' }), { message: 'snippet.value is required' }); + const snippet = { name: 'footer', value: '

updated

' }; + t.context.client.updateSnippet(snippet); + t.true(put.calledWith(`${API}/snippets`, snippet)); +}); + +test('#deleteSnippet: deletes a snippet by name and validates', (t) => { + const destroy = sinon.stub(t.context.client.request, 'destroy'); + t.throws(() => t.context.client.deleteSnippet(''), { message: 'name is required' }); + t.context.client.deleteSnippet('foo bar'); + t.true(destroy.calledWith(`${API}/snippets/foo%20bar`)); +}); + +// Sender identities + +test('#getSenderIdentities: gets the endpoint with sort/pagination/hidden', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.context.client.getSenderIdentities(); + t.true(get.calledWith(`${API}/sender_identities`)); + t.context.client.getSenderIdentities({ start: 'cur', limit: 10, sort: 'desc', hidden: false }); + t.true(get.calledWith(`${API}/sender_identities?start=cur&limit=10&sort=desc&hidden=false`)); +}); + +test('#getSenderIdentity: gets a single identity and validates', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.throws(() => t.context.client.getSenderIdentity(''), { message: 'senderId is required' }); + t.context.client.getSenderIdentity(12); + t.true(get.calledWith(`${API}/sender_identities/12`)); +}); + +test('#getSenderIdentityUsedBy: gets the used_by endpoint and validates', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.throws(() => t.context.client.getSenderIdentityUsedBy(''), { message: 'senderId is required' }); + t.context.client.getSenderIdentityUsedBy(12); + t.true(get.calledWith(`${API}/sender_identities/12/used_by`)); +}); + +// Messages + +test('#getMessages: gets the messages endpoint with no query by default', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.context.client.getMessages(); + t.true(get.calledWith(`${API}/messages`)); +}); + +test('#getMessages: forwards filters and pagination', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.context.client.getMessages({ + start: 'cur', + limit: 20, + drafts: false, + metric: 'delivered', + type: 'email', + campaign_id: 3, + action_id: 4, + newsletter_id: 5, + transactional_id: 6, + trigger_id: 7, + template_id: 8, + content_id: 9, + start_ts: 100, + end_ts: 200, + associations: true, + get_tracked_responses: true, + }); + t.true( + get.calledWith( + `${API}/messages?start=cur&limit=20&drafts=false&metric=delivered&type=email&campaign_id=3&action_id=4&newsletter_id=5&transactional_id=6&trigger_id=7&template_id=8&content_id=9&start_ts=100&end_ts=200&associations=true&get_tracked_responses=true`, + ), + ); +}); + +test('#getMessage: gets a single message with expansions and validates', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.throws(() => t.context.client.getMessage(''), { message: 'messageId is required' }); + t.context.client.getMessage('abc-123'); + t.true(get.calledWith(`${API}/messages/abc-123`)); + t.context.client.getMessage('abc-123', { archived_message: true, associations: true }); + t.true(get.calledWith(`${API}/messages/abc-123?archived_message=true&associations=true`)); +}); + +test('#getArchivedMessage: gets the archived_message endpoint and validates', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.throws(() => t.context.client.getArchivedMessage(''), { message: 'messageId is required' }); + t.context.client.getArchivedMessage('abc-123'); + t.true(get.calledWith(`${API}/messages/abc-123/archived_message`)); +}); diff --git a/test/integration/live.ts b/test/integration/live.ts index f249be8..c5c0752 100644 --- a/test/integration/live.ts +++ b/test/integration/live.ts @@ -486,6 +486,25 @@ liveTest('reporting webhook create -> read -> update -> delete round-trip', asyn t.pass(); }); +liveTest('snippet upsert -> read -> delete round-trip', async (t) => { + const name = `sdk-live-${customerId}`; + await api!.updateSnippet({ name, value: 'hello' }).catch(() => undefined); + const list = (await api!.getSnippets()) as { snippets?: unknown }; + t.true('snippets' in list); + await api!.deleteSnippet(name).catch(() => undefined); + t.pass(); +}); + +liveTest('sender identity reads resolve', async (t) => { + const result = (await api!.getSenderIdentities({ limit: 5 })) as { sender_identities?: unknown }; + t.true('sender_identities' in result); +}); + +liveTest('getMessages resolves', async (t) => { + const result = (await api!.getMessages({ limit: 5 })) as { messages?: unknown }; + t.true('messages' in result); +}); + liveTest('track records an event on the profile', async (t) => { await track!.track(customerId, { name: 'sdk_live_event', data: { run: customerId } }); t.pass(); From 83bc764bd9c2d9404972a5d263bbae6cb51a58ab Mon Sep 17 00:00:00 2001 From: Mike Engel Date: Wed, 15 Jul 2026 15:08:57 +0200 Subject: [PATCH 2/2] feat: add App API imports, data index & workspace methods (CDP-6278) (#245) --- docs/app.md | 78 +++++++++++++++++ lib/api.ts | 136 ++++++++++++++++++++++++++++++ test/api.ts | 61 ++++++++++++++ test/integration/live.env.example | 2 + test/integration/live.ts | 37 ++++++++ 5 files changed, 314 insertions(+) diff --git a/docs/app.md b/docs/app.md index a1e8399..84c5128 100644 --- a/docs/app.md +++ b/docs/app.md @@ -2146,3 +2146,81 @@ api.getArchivedMessage("RPCImftJDcAAAAd..."); #### Options - **messageId**: The delivery id (`CIO-Delivery-ID`) (required) + +## Imports, data index & workspace info + +### api.createImport(importData) + +Start a [CSV import](https://customer.io/docs/api/app/#operation/createImports). The CSV is loaded from a hosted URL you provide as `data_file_url`. + +```javascript +api.createImport({ + data_file_url: "https://example.com/people.csv", + type: "people", + identifier: "email", + name: "Q3 signups", +}); +``` + +#### Options + +- **importData**: The import definition + - _data_file_url_: URL of the CSV file to import (required) + - _type_: `"people"` / `"event"` / `"object"` / `"relationship"` (required) + - _identifier_: Which column keys the rows — `"id"`/`"email"` for people & events; `"id"`/`"email"`/`"cio_id"` for relationships + - _object_type_id_: Required when `type` is `"object"` + - _name_: Display name (defaults to the filename) + - _description_: Description of the import + - _people_to_process_ / _data_to_process_: `"all"` / `"only_existing"` / `"only_new"` (mutually exclusive) + +### api.getImport(importId) + +Get the status of an import. + +```javascript +api.getImport(15); +``` + +#### Options + +- **importId**: The import's numeric id (required) + +### api.batchUpdateAttributes(attributes) + +Batch-update attribute metadata (descriptions, etc.) — up to 100 at a time. + +```javascript +api.batchUpdateAttributes([{ name: "plan", description: "Subscription plan" }]); +``` + +#### Options + +- **attributes**: A non-empty array of attribute updates (max 100). Each requires a `name`; may also include `description`, `object_type_id`, `is_relationship`, `event_name`, `privacy_level` + +### api.batchUpdateEvents(events) + +Batch-update event metadata — up to 100 at a time. + +```javascript +api.batchUpdateEvents([{ name: "purchase", description: "Completed a purchase" }]); +``` + +#### Options + +- **events**: A non-empty array of event updates (max 100). Each requires a `name`; may also include `description` + +### api.listWorkspaces() + +List the workspaces (environments) in your account, with usage counts. + +```javascript +api.listWorkspaces(); +``` + +### api.getIpAddresses() + +List the Customer.io egress IP addresses (for allowlisting). + +```javascript +api.getIpAddresses(); +``` diff --git a/lib/api.ts b/lib/api.ts index e0b38c0..777f2f7 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -628,6 +628,56 @@ export type MessageOptions = { get_tracked_responses?: boolean; }; +/** What a CSV import loads. */ +export type ImportType = 'people' | 'event' | 'object' | 'relationship'; + +/** Which records an import processes. */ +export type ImportProcessScope = 'all' | 'only_existing' | 'only_new'; + +/** Definition for creating a CSV import via {@link APIClient.createImport}. */ +export type ImportInput = { + /** URL of the CSV file to import (required). */ + data_file_url: string; + /** What the CSV loads (required). */ + type: ImportType; + /** Which identifier the CSV keys rows by. `id`/`email` for people & events; `id`/`email`/`cio_id` for relationships. */ + identifier?: 'id' | 'email' | 'cio_id'; + /** Object type id — required when `type` is `object`. */ + object_type_id?: string | number; + /** Display name (defaults to the filename). */ + name?: string; + /** Description of the import. */ + description?: string; + /** For people imports: which profiles to process. Mutually exclusive with `data_to_process`. */ + people_to_process?: ImportProcessScope; + /** For object/relationship imports: which records to process. Mutually exclusive with `people_to_process`. */ + data_to_process?: ImportProcessScope; +}; + +/** A single attribute-metadata update for {@link APIClient.batchUpdateAttributes}. */ +export type DataIndexAttribute = { + /** The attribute name (required). */ + name: string; + /** A human-readable description. */ + description?: string; + /** Scope the attribute to an object type. */ + object_type_id?: number; + /** Whether the attribute is a relationship attribute. */ + is_relationship?: boolean; + /** Scope the attribute to an event. */ + event_name?: string; + /** Privacy level (requires the sensitive-attributes feature). */ + privacy_level?: number; +}; + +/** A single event-metadata update for {@link APIClient.batchUpdateEvents}. */ +export type DataIndexEvent = { + /** The event name (required). */ + name: string; + /** A human-readable description. */ + description?: string; +}; + type APIDefaults = RequestDefaults & { region: Region; url?: string; retry?: Partial }; type Recipients = Record; @@ -3812,6 +3862,92 @@ export class APIClient { return this.request.get(`${this.apiRoot}/messages/${encodeURIComponent(messageId)}/archived_message`); } + + /** + * Start a CSV import. + * + * @param importData The import definition. `data_file_url` and `type` are required. See {@link ImportInput}. + * @returns The parsed JSON response body (`{ import: {...} }`). + * @throws {MissingParamError} If `importData` is missing/not an object, or `data_file_url`/`type` is empty. + */ + createImport(importData: ImportInput) { + if (importData == null || typeof importData !== 'object') { + throw new MissingParamError('importData'); + } + + if (isEmpty(importData.data_file_url)) { + throw new MissingParamError('importData.data_file_url'); + } + + if (isEmpty(importData.type)) { + throw new MissingParamError('importData.type'); + } + + return this.request.post(`${this.apiRoot}/imports`, { import: importData }); + } + + /** + * Get the status of an import. + * + * @param importId The import's numeric id. + * @returns The parsed JSON response body (`{ import: {...} }`). + * @throws {MissingParamError} If `importId` is empty. + */ + getImport(importId: string | number) { + if (isEmpty(importId)) { + throw new MissingParamError('importId'); + } + + return this.request.get(`${this.apiRoot}/imports/${encodeURIComponent(importId)}`); + } + + /** + * Batch-update attribute metadata (up to 100 at a time). + * + * @param attributes The attribute updates. Each requires a `name`. See {@link DataIndexAttribute}. + * @returns The parsed JSON response body (empty on success — the API returns 204). + * @throws {MissingParamError} If `attributes` is not a non-empty array. + */ + batchUpdateAttributes(attributes: DataIndexAttribute[]) { + if (!Array.isArray(attributes) || attributes.length === 0) { + throw new MissingParamError('attributes'); + } + + return this.request.post(`${this.apiRoot}/data_index/attributes`, { attributes }); + } + + /** + * Batch-update event metadata (up to 100 at a time). + * + * @param events The event updates. Each requires a `name`. See {@link DataIndexEvent}. + * @returns The parsed JSON response body (empty on success — the API returns 204). + * @throws {MissingParamError} If `events` is not a non-empty array. + */ + batchUpdateEvents(events: DataIndexEvent[]) { + if (!Array.isArray(events) || events.length === 0) { + throw new MissingParamError('events'); + } + + return this.request.post(`${this.apiRoot}/data_index/events`, { events }); + } + + /** + * List the workspaces (environments) in your account, with usage counts. + * + * @returns The parsed JSON response body (`{ workspaces: [...] }`). + */ + listWorkspaces() { + return this.request.get(`${this.apiRoot}/workspaces`); + } + + /** + * List the Customer.io egress IP addresses (for allowlisting). + * + * @returns The parsed JSON response body (`{ ip_addresses: [...] }`). + */ + getIpAddresses() { + return this.request.get(`${this.apiRoot}/info/ip_addresses`); + } } export { diff --git a/test/api.ts b/test/api.ts index 1ed7fd5..1a740a9 100644 --- a/test/api.ts +++ b/test/api.ts @@ -2366,3 +2366,64 @@ test('#getArchivedMessage: gets the archived_message endpoint and validates', (t t.context.client.getArchivedMessage('abc-123'); t.true(get.calledWith(`${API}/messages/abc-123/archived_message`)); }); + +// Imports + +test('#createImport: posts the wrapped import body and validates', (t) => { + const post = sinon.stub(t.context.client.request, 'post'); + t.throws(() => (t.context.client.createImport as any)(null), { message: 'importData is required' }); + t.throws(() => (t.context.client.createImport as any)({ type: 'people' }), { + message: 'importData.data_file_url is required', + }); + t.throws(() => (t.context.client.createImport as any)({ data_file_url: 'https://x/f.csv' }), { + message: 'importData.type is required', + }); + const imp = { + data_file_url: 'https://example.com/people.csv', + type: 'people' as const, + identifier: 'email' as const, + }; + t.context.client.createImport(imp); + t.true(post.calledWith(`${API}/imports`, { import: imp })); +}); + +test('#getImport: gets a single import and validates', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.throws(() => t.context.client.getImport(''), { message: 'importId is required' }); + t.context.client.getImport(15); + t.true(get.calledWith(`${API}/imports/15`)); +}); + +// Data index + +test('#batchUpdateAttributes: posts the wrapped attributes and validates', (t) => { + const post = sinon.stub(t.context.client.request, 'post'); + t.throws(() => (t.context.client.batchUpdateAttributes as any)(null), { message: 'attributes is required' }); + t.throws(() => t.context.client.batchUpdateAttributes([]), { message: 'attributes is required' }); + const attributes = [{ name: 'plan', description: 'Subscription plan' }]; + t.context.client.batchUpdateAttributes(attributes); + t.true(post.calledWith(`${API}/data_index/attributes`, { attributes })); +}); + +test('#batchUpdateEvents: posts the wrapped events and validates', (t) => { + const post = sinon.stub(t.context.client.request, 'post'); + t.throws(() => (t.context.client.batchUpdateEvents as any)(null), { message: 'events is required' }); + t.throws(() => t.context.client.batchUpdateEvents([]), { message: 'events is required' }); + const events = [{ name: 'purchase', description: 'Completed a purchase' }]; + t.context.client.batchUpdateEvents(events); + t.true(post.calledWith(`${API}/data_index/events`, { events })); +}); + +// Misc + +test('#listWorkspaces: gets the workspaces endpoint', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.context.client.listWorkspaces(); + t.true(get.calledWith(`${API}/workspaces`)); +}); + +test('#getIpAddresses: gets the ip_addresses endpoint', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.context.client.getIpAddresses(); + t.true(get.calledWith(`${API}/info/ip_addresses`)); +}); diff --git a/test/integration/live.env.example b/test/integration/live.env.example index dfee026..17031e2 100644 --- a/test/integration/live.env.example +++ b/test/integration/live.env.example @@ -52,3 +52,5 @@ CIO_TEST_DELIVERY_ID= # Object type id (+ optional object id) for object attribute/relationship/find reads CIO_TEST_OBJECT_TYPE_ID= CIO_TEST_OBJECT_ID= +# Downloadable CSV URL for createImport +CIO_TEST_IMPORT_CSV_URL= diff --git a/test/integration/live.ts b/test/integration/live.ts index c5c0752..e201f61 100644 --- a/test/integration/live.ts +++ b/test/integration/live.ts @@ -28,6 +28,7 @@ * CIO_TEST_OBJECT_ID object id for object reads (defaults to a throwaway id) * CIO_TEST_CAMPAIGN_ID campaign id for campaign read methods * CIO_TEST_TRIGGER_ID trigger id (with CIO_TEST_BROADCAST_ID) for trigger status/errors + * CIO_TEST_IMPORT_CSV_URL downloadable CSV URL for createImport * * Profile lifecycle: one throwaway customer per run, id = `sdk-live-${uuid()}`. * Cleanup is best-effort; the dedicated workspace tolerates orphans. @@ -505,6 +506,42 @@ liveTest('getMessages resolves', async (t) => { t.true('messages' in result); }); +liveTest('listWorkspaces resolves', async (t) => { + const result = (await api!.listWorkspaces()) as { workspaces?: unknown }; + t.true('workspaces' in result); +}); + +liveTest('getIpAddresses resolves', async (t) => { + const result = (await api!.getIpAddresses()) as { ip_addresses?: unknown }; + t.true('ip_addresses' in result); +}); + +liveTest('data_index batch updates resolve', async (t) => { + await api! + .batchUpdateAttributes([{ name: `sdk_live_${customerId}`, description: 'sdk live test' }]) + .catch(() => undefined); + await api! + .batchUpdateEvents([{ name: `sdk_live_${customerId}`, description: 'sdk live test' }]) + .catch(() => undefined); + t.pass(); +}); + +needs('CIO_TEST_IMPORT_CSV_URL')('import create -> get round-trip', async (t) => { + const created = (await api! + .createImport({ + data_file_url: process.env.CIO_TEST_IMPORT_CSV_URL!, + type: 'people', + identifier: 'email', + name: `sdk-live-${customerId}`, + }) + .catch(() => undefined)) as { import?: { id?: number } } | undefined; + const importId = created?.import?.id; + if (importId !== undefined) { + await api!.getImport(importId).catch(() => undefined); + } + t.pass(); +}); + liveTest('track records an event on the profile', async (t) => { await track!.track(customerId, { name: 'sdk_live_event', data: { run: customerId } }); t.pass();