From df3d9ff1ff2d75be4bd54541ec9690cf7e7fcf69 Mon Sep 17 00:00:00 2001 From: Mike Engel Date: Mon, 13 Jul 2026 22:12:49 +0200 Subject: [PATCH 1/2] feat: add App API Design Studio folder & email methods (CDP-6272) Batch 8. Adds the 10 Design Studio folder + email endpoints to APIClient: list/create/get/update/delete for both folders and emails. Verified against the services backend (ext_api/design_studio/router.go + designstudio/dsapi/*), not the OpenAPI spec: page-based list filters, tri-state parent_folder_id, and the email content/envelope/transformers bodies. PUT/DELETE return 204 (empty body -> {} in the request layer). Includes unit tests (100% coverage), live round-trip coverage, and docs. --- docs/app.md | 154 ++++++++++++++++++++ lib/api.ts | 293 +++++++++++++++++++++++++++++++++++++++ test/api.ts | 128 +++++++++++++++++ test/integration/live.ts | 42 ++++++ 4 files changed, 617 insertions(+) diff --git a/docs/app.md b/docs/app.md index 2c9fd25..3685318 100644 --- a/docs/app.md +++ b/docs/app.md @@ -1324,3 +1324,157 @@ api.deleteNewsletterTestGroupLanguage(8, 2, "fr"); - **newsletterId**: The newsletter's numeric id (required) - **testGroupId**: The test group's id (required) - **language**: The IETF language tag (required) + +## Design Studio + +Manage Design Studio folders and emails. Folders and emails are "nodes" identified by a UUID. + +The list endpoints share a common set of filters, sorting, and page-based pagination (they do **not** use the cursor pagination of the other App API list endpoints): + +- **parentFolderId**: Only list nodes directly within this folder (omit for the root) +- **directDescendantsOnly**: When `true`, return only direct children rather than the whole subtree +- **sortBy**: `"created"` / `"updated"` / `"name"` (default `"created"`) +- **sortOrder**: `"asc"` / `"desc"` (default `"asc"`) +- **createdBefore** / **createdAfter** / **updatedBefore** / **updatedAfter**: Unix timestamps (seconds) +- **page**: 1-based page number (default 1) +- **limit**: page size, 1–10000 (default 1000) + +In folder and email bodies, `parent_folder_id` is tri-state: **omit** it to keep the current parent, pass `null` to move to the root, or pass a folder UUID to move it into that folder. + +### api.listDesignStudioFolders(options) + +List Design Studio folders. + +```javascript +api.listDesignStudioFolders({ parentFolderId: "1f0…", directDescendantsOnly: true, limit: 50 }); +``` + +#### Options + +- **options**: Object (optional) — the shared list filters described above + +### api.createDesignStudioFolder(folder) + +Create a folder. + +```javascript +api.createDesignStudioFolder({ name: "Campaigns", parent_folder_id: null }); +``` + +#### Options + +- **folder**: The folder definition + - _name_: The folder's display name (required) + - _parent_folder_id_: Parent folder UUID, or `null`/omit for the root + +### api.getDesignStudioFolder(folderId) + +Get a single folder. + +```javascript +api.getDesignStudioFolder("1f0…"); +``` + +#### Options + +- **folderId**: The folder's UUID (required) + +### api.updateDesignStudioFolder(folderId, updates) + +Update a folder. At least one field must be provided. Returns no content on success. + +```javascript +api.updateDesignStudioFolder("1f0…", { name: "Renamed", parent_folder_id: null }); +``` + +#### Options + +- **folderId**: The folder's UUID (required) +- **updates**: Object with any of `name`, `parent_folder_id` + +### api.deleteDesignStudioFolder(folderId) + +Delete a folder. Returns no content on success. + +```javascript +api.deleteDesignStudioFolder("1f0…"); +``` + +#### Options + +- **folderId**: The folder's UUID (required) + +### api.listDesignStudioEmails(options) + +List Design Studio emails. In addition to the shared list filters, emails support three tri-state filters — each `"true"`, `"false"`, or `"any"` (no filter). + +```javascript +api.listDesignStudioEmails({ isTemplate: "true", hasTranslations: "any", limit: 25 }); +``` + +#### Options + +- **options**: Object (optional) — the shared list filters, plus: + - _isTemplate_: `"true"` / `"false"` / `"any"` + - _hasTranslations_: `"true"` / `"false"` / `"any"` + - _isLinked_: `"true"` / `"false"` / `"any"` (whether the email is linked to a message) + +### api.createDesignStudioEmail(email) + +Create an email. + +```javascript +api.createDesignStudioEmail({ + name: "Welcome", + is_template: false, + content: { subject: "Welcome!", html: "

Hi {{customer.first_name}}

" }, + envelope: { recipient: "{{customer.email}}" }, +}); +``` + +#### Options + +- **email**: The email definition + - _name_: The email's display name (required) + - _parent_folder_id_: Parent folder UUID, or `null`/omit for the root + - _is_template_: Whether the email is a reusable template + - _content_: `{ subject, preheader_text, html, amp, text }` + - _envelope_: `{ from_id, reply_to_id, recipient, bcc, fake_bcc, cc, headers }` + - _transformers_: Content transformers (e.g. `url_parameters`, `css_inliner`, `accessibility`) + +### api.getDesignStudioEmail(emailId) + +Get a single email. + +```javascript +api.getDesignStudioEmail("2a1…"); +``` + +#### Options + +- **emailId**: The email's UUID (required) + +### api.updateDesignStudioEmail(emailId, updates) + +Update an email. At least one field must be provided. Returns no content on success. + +```javascript +api.updateDesignStudioEmail("2a1…", { is_template: true, content: { subject: "Updated" } }); +``` + +#### Options + +- **emailId**: The email's UUID (required) +- **updates**: Object with any of `name`, `parent_folder_id`, `is_template`, `content`, `envelope`, `transformers` + +### api.deleteDesignStudioEmail(emailId) + +Delete an email. Returns no content on success. + +```javascript +api.deleteDesignStudioEmail("2a1…"); +``` + +#### Options + +- **emailId**: The email's UUID (required) diff --git a/lib/api.ts b/lib/api.ts index 568733b..ed4d902 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -250,6 +250,112 @@ export type NewsletterMessagesOptions = PaginationOptions & { get_tracked_responses?: boolean; }; +/** Sort field for Design Studio list endpoints. */ +export type DesignStudioSortBy = 'created' | 'updated' | 'name'; + +/** Filters, sorting, and pagination shared by the Design Studio list endpoints (page-based, not cursor-based). */ +export type DesignStudioListOptions = { + /** Only list nodes directly within this folder (omit for the root). */ + parentFolderId?: string; + /** When `true`, return only direct children rather than the whole subtree. */ + directDescendantsOnly?: boolean; + /** Field to sort by. Defaults to `created`. */ + sortBy?: DesignStudioSortBy; + /** Sort direction. Defaults to `asc`. */ + sortOrder?: SortDirection; + /** Only nodes created before this Unix timestamp (seconds). */ + createdBefore?: number; + /** Only nodes created after this Unix timestamp (seconds). */ + createdAfter?: number; + /** Only nodes updated before this Unix timestamp (seconds). */ + updatedBefore?: number; + /** Only nodes updated after this Unix timestamp (seconds). */ + updatedAfter?: number; + /** 1-based page number. Defaults to 1. */ + page?: number; + /** Page size, 1–10000. Defaults to 1000. */ + limit?: number; +}; + +/** Tri-state filter for {@link ListDesignStudioEmailsOptions}: `'true'`/`'false'` to filter, or `'any'` for no filter. */ +export type DesignStudioEmailFilter = 'true' | 'false' | 'any'; + +/** Options for {@link APIClient.listDesignStudioEmails}. */ +export type ListDesignStudioEmailsOptions = DesignStudioListOptions & { + /** Filter by the template flag, or `'any'`. */ + isTemplate?: DesignStudioEmailFilter; + /** Filter by the presence of translations, or `'any'`. */ + hasTranslations?: DesignStudioEmailFilter; + /** Filter by linked-to-a-message status, or `'any'`. */ + isLinked?: DesignStudioEmailFilter; +}; + +/** The `content` block of a Design Studio email. */ +export type DesignStudioEmailContent = { + subject?: string; + preheader_text?: string; + html?: string; + amp?: string; + text?: string; +}; + +/** The `envelope` block of a Design Studio email. */ +export type DesignStudioEmailEnvelope = { + from_id?: number; + reply_to_id?: number; + recipient?: string; + bcc?: string; + fake_bcc?: boolean; + cc?: string; + headers?: Array>; +}; + +/** Definition for creating a Design Studio email via {@link APIClient.createDesignStudioEmail}. */ +export type DesignStudioEmailInput = { + /** The email's display name (required). */ + name: string; + /** Parent folder UUID. Omit or pass `null` for the root. */ + parent_folder_id?: string | null; + /** Whether the email is a reusable template. */ + is_template?: boolean; + content?: DesignStudioEmailContent; + envelope?: DesignStudioEmailEnvelope; + /** Content transformers (e.g. `url_parameters`, `css_inliner`, `accessibility`). */ + transformers?: Record; +}; + +/** + * Fields for updating a Design Studio email via {@link APIClient.updateDesignStudioEmail}. + * At least one must be provided. `parent_folder_id` is tri-state: omit to keep the current + * parent, `null` to move to the root, or a UUID to move into that folder. + */ +export type DesignStudioEmailUpdate = { + name?: string; + parent_folder_id?: string | null; + is_template?: boolean; + content?: DesignStudioEmailContent; + envelope?: DesignStudioEmailEnvelope; + transformers?: Record; +}; + +/** Definition for creating a Design Studio folder via {@link APIClient.createDesignStudioFolder}. */ +export type DesignStudioFolderInput = { + /** The folder's display name (required). */ + name: string; + /** Parent folder UUID. Omit or pass `null` for the root. */ + parent_folder_id?: string | null; +}; + +/** + * Fields for updating a Design Studio folder via {@link APIClient.updateDesignStudioFolder}. + * At least one must be provided. `parent_folder_id` is tri-state: omit to keep the current + * parent, `null` to move to the root, or a UUID to move into that folder. + */ +export type DesignStudioFolderUpdate = { + name?: string; + parent_folder_id?: string | null; +}; + type APIDefaults = RequestDefaults & { region: Region; url?: string; retry?: Partial }; type Recipients = Record; @@ -2346,6 +2452,193 @@ export class APIClient { `${this.resourceBase('newsletters', newsletterId)}/test_group/${encodeURIComponent(testGroupId)}/language/${encodeURIComponent(language)}`, ); } + + /** + * List Design Studio folders. + * + * @param options Optional filters, sorting, and pagination. See {@link DesignStudioListOptions}. + * @returns The parsed JSON response body (`{ folders: [...], meta }`). + */ + listDesignStudioFolders(options: DesignStudioListOptions = {}) { + const query = buildQueryString({ + parent_folder_id: options.parentFolderId, + direct_descendants_only: options.directDescendantsOnly, + sort_by: options.sortBy, + sort_order: options.sortOrder, + created_before: options.createdBefore, + created_after: options.createdAfter, + updated_before: options.updatedBefore, + updated_after: options.updatedAfter, + page: options.page, + limit: options.limit, + }); + + return this.request.get(`${this.apiRoot}/design_studio/folders${query}`); + } + + /** + * Create a Design Studio folder. + * + * @param folder The folder definition. `name` is required. See {@link DesignStudioFolderInput}. + * @returns The parsed JSON response body (`{ folder: {...} }`). + * @throws {MissingParamError} If `folder` is missing/not an object, or `folder.name` is empty. + */ + createDesignStudioFolder(folder: DesignStudioFolderInput) { + if (folder == null || typeof folder !== 'object') { + throw new MissingParamError('folder'); + } + + if (isEmpty(folder.name)) { + throw new MissingParamError('folder.name'); + } + + return this.request.post(`${this.apiRoot}/design_studio/folders`, folder); + } + + /** + * Get a single Design Studio folder. + * + * @param folderId The folder's UUID. + * @returns The parsed JSON response body (`{ folder: {...} }`). + * @throws {MissingParamError} If `folderId` is empty. + */ + getDesignStudioFolder(folderId: string) { + if (isEmpty(folderId)) { + throw new MissingParamError('folderId'); + } + + return this.request.get(`${this.apiRoot}/design_studio/folders/${encodeURIComponent(folderId)}`); + } + + /** + * Update a Design Studio folder. At least one field must be provided. + * + * @param folderId The folder's UUID. + * @param updates The fields to change. See {@link DesignStudioFolderUpdate}. + * @returns The parsed JSON response body (empty on success — the API returns 204). + * @throws {MissingParamError} If `folderId` is empty or `updates` is missing/not an object. + */ + updateDesignStudioFolder(folderId: string, updates: DesignStudioFolderUpdate) { + if (isEmpty(folderId)) { + throw new MissingParamError('folderId'); + } + + if (updates == null || typeof updates !== 'object') { + throw new MissingParamError('updates'); + } + + return this.request.put(`${this.apiRoot}/design_studio/folders/${encodeURIComponent(folderId)}`, updates); + } + + /** + * Delete a Design Studio folder. + * + * @param folderId The folder's UUID. + * @returns The parsed JSON response body (empty on success — the API returns 204). + * @throws {MissingParamError} If `folderId` is empty. + */ + deleteDesignStudioFolder(folderId: string) { + if (isEmpty(folderId)) { + throw new MissingParamError('folderId'); + } + + return this.request.destroy(`${this.apiRoot}/design_studio/folders/${encodeURIComponent(folderId)}`); + } + + /** + * List Design Studio emails. + * + * @param options Optional filters, sorting, and pagination. See {@link ListDesignStudioEmailsOptions}. + * @returns The parsed JSON response body (`{ emails: [...], folders: [...], meta }`). + */ + listDesignStudioEmails(options: ListDesignStudioEmailsOptions = {}) { + const query = buildQueryString({ + parent_folder_id: options.parentFolderId, + direct_descendants_only: options.directDescendantsOnly, + sort_by: options.sortBy, + sort_order: options.sortOrder, + created_before: options.createdBefore, + created_after: options.createdAfter, + updated_before: options.updatedBefore, + updated_after: options.updatedAfter, + page: options.page, + limit: options.limit, + is_template: options.isTemplate, + has_translations: options.hasTranslations, + is_linked: options.isLinked, + }); + + return this.request.get(`${this.apiRoot}/design_studio/emails${query}`); + } + + /** + * Create a Design Studio email. + * + * @param email The email definition. `name` is required. See {@link DesignStudioEmailInput}. + * @returns The parsed JSON response body (`{ email: {...} }`). + * @throws {MissingParamError} If `email` is missing/not an object, or `email.name` is empty. + */ + createDesignStudioEmail(email: DesignStudioEmailInput) { + if (email == null || typeof email !== 'object') { + throw new MissingParamError('email'); + } + + if (isEmpty(email.name)) { + throw new MissingParamError('email.name'); + } + + return this.request.post(`${this.apiRoot}/design_studio/emails`, email); + } + + /** + * Get a single Design Studio email. + * + * @param emailId The email's UUID. + * @returns The parsed JSON response body (`{ email: {...} }`). + * @throws {MissingParamError} If `emailId` is empty. + */ + getDesignStudioEmail(emailId: string) { + if (isEmpty(emailId)) { + throw new MissingParamError('emailId'); + } + + return this.request.get(`${this.apiRoot}/design_studio/emails/${encodeURIComponent(emailId)}`); + } + + /** + * Update a Design Studio email. At least one field must be provided. + * + * @param emailId The email's UUID. + * @param updates The fields to change. See {@link DesignStudioEmailUpdate}. + * @returns The parsed JSON response body (empty on success — the API returns 204). + * @throws {MissingParamError} If `emailId` is empty or `updates` is missing/not an object. + */ + updateDesignStudioEmail(emailId: string, updates: DesignStudioEmailUpdate) { + if (isEmpty(emailId)) { + throw new MissingParamError('emailId'); + } + + if (updates == null || typeof updates !== 'object') { + throw new MissingParamError('updates'); + } + + return this.request.put(`${this.apiRoot}/design_studio/emails/${encodeURIComponent(emailId)}`, updates); + } + + /** + * Delete a Design Studio email. + * + * @param emailId The email's UUID. + * @returns The parsed JSON response body (empty on success — the API returns 204). + * @throws {MissingParamError} If `emailId` is empty. + */ + deleteDesignStudioEmail(emailId: string) { + if (isEmpty(emailId)) { + throw new MissingParamError('emailId'); + } + + return this.request.destroy(`${this.apiRoot}/design_studio/emails/${encodeURIComponent(emailId)}`); + } } export { diff --git a/test/api.ts b/test/api.ts index af22507..56dd5e2 100644 --- a/test/api.ts +++ b/test/api.ts @@ -1730,3 +1730,131 @@ test('#deleteNewsletterTestGroupLanguage: deletes a translation and validates', t.context.client.deleteNewsletterTestGroupLanguage(8, 2, 'fr'); t.true(destroy.calledWith(`${API}/newsletters/8/test_group/2/language/fr`)); }); + +// Design Studio: folders + +test('#listDesignStudioFolders: gets the folders endpoint with no query by default', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.context.client.listDesignStudioFolders(); + t.true(get.calledWith(`${API}/design_studio/folders`)); +}); + +test('#listDesignStudioFolders: forwards filters, sorting, and pagination', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.context.client.listDesignStudioFolders({ + parentFolderId: 'folder-1', + directDescendantsOnly: true, + sortBy: 'name', + sortOrder: 'desc', + createdBefore: 200, + createdAfter: 100, + updatedBefore: 400, + updatedAfter: 300, + page: 2, + limit: 50, + }); + t.true( + get.calledWith( + `${API}/design_studio/folders?parent_folder_id=folder-1&direct_descendants_only=true&sort_by=name&sort_order=desc&created_before=200&created_after=100&updated_before=400&updated_after=300&page=2&limit=50`, + ), + ); +}); + +test('#createDesignStudioFolder: posts the folder body and validates', (t) => { + const post = sinon.stub(t.context.client.request, 'post'); + t.throws(() => (t.context.client.createDesignStudioFolder as any)(null), { message: 'folder is required' }); + t.throws(() => (t.context.client.createDesignStudioFolder as any)('nope'), { message: 'folder is required' }); + t.throws(() => (t.context.client.createDesignStudioFolder as any)({}), { message: 'folder.name is required' }); + t.context.client.createDesignStudioFolder({ name: 'Campaigns', parent_folder_id: null }); + t.true(post.calledWith(`${API}/design_studio/folders`, { name: 'Campaigns', parent_folder_id: null })); +}); + +test('#getDesignStudioFolder: gets a single folder and validates', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.throws(() => t.context.client.getDesignStudioFolder(''), { message: 'folderId is required' }); + t.context.client.getDesignStudioFolder('folder-1'); + t.true(get.calledWith(`${API}/design_studio/folders/folder-1`)); +}); + +test('#updateDesignStudioFolder: puts the updates and validates', (t) => { + const put = sinon.stub(t.context.client.request, 'put'); + t.throws(() => t.context.client.updateDesignStudioFolder('', { name: 'x' }), { message: 'folderId is required' }); + t.throws(() => (t.context.client.updateDesignStudioFolder as any)('folder-1', null), { + message: 'updates is required', + }); + t.context.client.updateDesignStudioFolder('folder-1', { name: 'Renamed', parent_folder_id: null }); + t.true(put.calledWith(`${API}/design_studio/folders/folder-1`, { name: 'Renamed', parent_folder_id: null })); +}); + +test('#deleteDesignStudioFolder: deletes a folder and validates', (t) => { + const destroy = sinon.stub(t.context.client.request, 'destroy'); + t.throws(() => t.context.client.deleteDesignStudioFolder(''), { message: 'folderId is required' }); + t.context.client.deleteDesignStudioFolder('folder-1'); + t.true(destroy.calledWith(`${API}/design_studio/folders/folder-1`)); +}); + +// Design Studio: emails + +test('#listDesignStudioEmails: gets the emails endpoint with no query by default', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.context.client.listDesignStudioEmails(); + t.true(get.calledWith(`${API}/design_studio/emails`)); +}); + +test('#listDesignStudioEmails: forwards base and email-specific filters', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.context.client.listDesignStudioEmails({ + parentFolderId: 'folder-1', + sortBy: 'updated', + page: 3, + limit: 25, + isTemplate: 'true', + hasTranslations: 'false', + isLinked: 'any', + }); + t.true( + get.calledWith( + `${API}/design_studio/emails?parent_folder_id=folder-1&sort_by=updated&page=3&limit=25&is_template=true&has_translations=false&is_linked=any`, + ), + ); +}); + +test('#createDesignStudioEmail: posts the email body and validates', (t) => { + const post = sinon.stub(t.context.client.request, 'post'); + t.throws(() => (t.context.client.createDesignStudioEmail as any)(null), { message: 'email is required' }); + t.throws(() => (t.context.client.createDesignStudioEmail as any)('nope'), { message: 'email is required' }); + t.throws(() => (t.context.client.createDesignStudioEmail as any)({}), { message: 'email.name is required' }); + const email = { + name: 'Welcome', + is_template: true, + content: { subject: 'Hi', html: '

Hi

' }, + envelope: { recipient: '{{customer.email}}' }, + transformers: { css_inliner: { enabled: true } }, + }; + t.context.client.createDesignStudioEmail(email); + t.true(post.calledWith(`${API}/design_studio/emails`, email)); +}); + +test('#getDesignStudioEmail: gets a single email and validates', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.throws(() => t.context.client.getDesignStudioEmail(''), { message: 'emailId is required' }); + t.context.client.getDesignStudioEmail('email-1'); + t.true(get.calledWith(`${API}/design_studio/emails/email-1`)); +}); + +test('#updateDesignStudioEmail: puts the updates and validates', (t) => { + const put = sinon.stub(t.context.client.request, 'put'); + t.throws(() => t.context.client.updateDesignStudioEmail('', { name: 'x' }), { message: 'emailId is required' }); + t.throws(() => (t.context.client.updateDesignStudioEmail as any)('email-1', null), { + message: 'updates is required', + }); + t.context.client.updateDesignStudioEmail('email-1', { name: 'Renamed', parent_folder_id: null }); + t.true(put.calledWith(`${API}/design_studio/emails/email-1`, { name: 'Renamed', parent_folder_id: null })); +}); + +test('#deleteDesignStudioEmail: deletes an email and validates', (t) => { + const destroy = sinon.stub(t.context.client.request, 'destroy'); + t.throws(() => t.context.client.deleteDesignStudioEmail(''), { message: 'emailId is required' }); + t.context.client.deleteDesignStudioEmail('email-1'); + t.true(destroy.calledWith(`${API}/design_studio/emails/email-1`)); +}); diff --git a/test/integration/live.ts b/test/integration/live.ts index abec36e..8d529e8 100644 --- a/test/integration/live.ts +++ b/test/integration/live.ts @@ -303,6 +303,48 @@ needs('CIO_TEST_NEWSLETTER_ID')('newsletter test-group + language reads resolve' t.pass(); }); +liveTest('listDesignStudioFolders resolves', async (t) => { + const result = (await api!.listDesignStudioFolders({ limit: 5 })) as { folders?: unknown }; + t.true('folders' in result); +}); + +liveTest('listDesignStudioEmails resolves', async (t) => { + const result = (await api!.listDesignStudioEmails({ limit: 5 })) as { emails?: unknown }; + t.true('emails' in result); +}); + +liveTest('design studio folder + email create -> read -> update -> delete round-trip', async (t) => { + const folder = (await api!.createDesignStudioFolder({ name: `sdk-live-${customerId}` })) as { + folder?: { id?: string }; + }; + const folderId = folder.folder?.id; + + let emailId: string | undefined; + if (folderId) { + await api!.getDesignStudioFolder(folderId).catch(() => undefined); + await api!.updateDesignStudioFolder(folderId, { name: `sdk-live-${customerId}-renamed` }).catch(() => undefined); + + const email = (await api! + .createDesignStudioEmail({ + name: `sdk-live-email-${customerId}`, + parent_folder_id: folderId, + content: { subject: 'SDK live', html: '

hi

' }, + }) + .catch(() => undefined)) as { email?: { id?: string } } | undefined; + emailId = email?.email?.id; + + if (emailId) { + await api!.getDesignStudioEmail(emailId).catch(() => undefined); + await api!.updateDesignStudioEmail(emailId, { is_template: true }).catch(() => undefined); + await api!.deleteDesignStudioEmail(emailId).catch(() => undefined); + } + + await api!.deleteDesignStudioFolder(folderId).catch(() => undefined); + } + + t.truthy(folder); +}); + liveTest('track records an event on the profile', async (t) => { await track!.track(customerId, { name: 'sdk_live_event', data: { run: customerId } }); t.pass(); From 6f64be9b1c06cdefe4b0dbc7527f562bbdb6025e Mon Sep 17 00:00:00 2001 From: Mike Engel Date: Tue, 14 Jul 2026 13:32:27 +0200 Subject: [PATCH 2/2] feat: add App API Design Studio language & component methods (#240) --- docs/app.md | 287 ++++++++++++++++++++ lib/api.ts | 548 +++++++++++++++++++++++++++++++++++++++ lib/request.ts | 31 ++- test/api.ts | 253 ++++++++++++++++++ test/integration/live.ts | 80 ++++++ test/request.ts | 57 +++- 6 files changed, 1251 insertions(+), 5 deletions(-) diff --git a/docs/app.md b/docs/app.md index 3685318..549761e 100644 --- a/docs/app.md +++ b/docs/app.md @@ -1478,3 +1478,290 @@ api.deleteDesignStudioEmail("2a1…"); #### Options - **emailId**: The email's UUID (required) + +### api.listDesignStudioEmailLanguages(emailId) + +List the translations (languages) of an email. + +```javascript +api.listDesignStudioEmailLanguages("2a1…"); +``` + +#### Options + +- **emailId**: The email's UUID (required) + +### api.createDesignStudioEmailLanguage(emailId, translation) + +Create a translation of an email. Content blocks you omit are inherited from the default-language email. + +```javascript +api.createDesignStudioEmailLanguage("2a1…", { + language: "fr", + content: { subject: "Bonjour" }, +}); +``` + +#### Options + +- **emailId**: The email's UUID (required) +- **translation**: The translation definition + - _language_: IETF language tag (required) + - _content_: `{ subject, preheader_text, html, amp, text }` + - _envelope_: `{ from_id, reply_to_id, recipient, bcc, fake_bcc, cc, headers }` + - _transformers_: Content transformers + +### api.getDesignStudioEmailLanguage(emailId, language) + +Get a single-language translation of an email. + +```javascript +api.getDesignStudioEmailLanguage("2a1…", "fr"); +``` + +#### Options + +- **emailId**: The email's UUID (required) +- **language**: The IETF language tag (required) + +### api.updateDesignStudioEmailLanguage(emailId, language, updates) + +Update a translation. At least one field must be provided. The language itself is immutable. Returns no content on success. + +```javascript +api.updateDesignStudioEmailLanguage("2a1…", "fr", { content: { subject: "Salut" } }); +``` + +#### Options + +- **emailId**: The email's UUID (required) +- **language**: The IETF language tag (required) +- **updates**: Object with any of `content`, `envelope`, `transformers` + +### api.deleteDesignStudioEmailLanguage(emailId, language) + +Delete a translation. Returns no content on success. + +```javascript +api.deleteDesignStudioEmailLanguage("2a1…", "fr"); +``` + +#### Options + +- **emailId**: The email's UUID (required) +- **language**: The IETF language tag (required) + +### api.listDesignStudioComponents(options) + +List Design Studio components. + +```javascript +api.listDesignStudioComponents({ tag: "header", limit: 25 }); +``` + +#### Options + +- **options**: Object (optional) — the shared list filters described above, plus: + - _tag_: Only list components with this tag + +### api.createDesignStudioComponent(component) + +Create a component. + +```javascript +api.createDesignStudioComponent({ name: "Header", tag: "header", content: "
…
" }); +``` + +#### Options + +- **component**: The component definition + - _name_: The component's display name (required) + - _tag_: The component's tag, unique per workspace (required) + - _parent_folder_id_: Parent folder UUID, or `null`/omit for the root + - _content_: The component's HTML content + +### api.getDesignStudioComponent(componentId) + +Get a single component. + +```javascript +api.getDesignStudioComponent("3b2…"); +``` + +#### Options + +- **componentId**: The component's UUID (required) + +### api.updateDesignStudioComponent(componentId, updates) + +Update a component. At least one field must be provided. Returns no content on success. + +```javascript +api.updateDesignStudioComponent("3b2…", { content: "
updated
" }); +``` + +#### Options + +- **componentId**: The component's UUID (required) +- **updates**: Object with any of `name`, `tag`, `parent_folder_id`, `content` + +### api.deleteDesignStudioComponent(componentId) + +Delete a component. Returns no content on success. + +```javascript +api.deleteDesignStudioComponent("3b2…"); +``` + +#### Options + +- **componentId**: The component's UUID (required) + +## Assets + +Manage uploaded files (images, PDFs) and the folders that organize them. Asset and folder ids are **integers**. + +The list endpoints share folder filtering and page-based pagination: + +- **parentFolderId**: Only list items within this folder id (omit for all/root) +- **directDescendantsOnly**: When `true`, return only direct children rather than the whole subtree +- **page**: 1-based page number (default 1) +- **limit**: page size, 1–10000 (default 1000) + +On file and folder updates, `parent_folder_id` is tri-state: **omit** to keep the current parent, pass `null` to move to the root, or pass a folder id to move it. + +### api.listAssets(options) + +List uploaded files. + +```javascript +api.listAssets({ parentFolderId: 5, limit: 50 }); +``` + +#### Options + +- **options**: Object (optional) — `parentFolderId`, `directDescendantsOnly`, `page`, `limit` + +### api.createAsset(file) + +Upload a file (`multipart/form-data`). The API accepts images (`image/bmp`, `image/jpeg`, `image/jpg`, `image/png`, `image/gif`) and `application/pdf`, up to 2 MB (images max 4096px per side). + +```javascript +const fs = require("fs"); + +api.createAsset({ + data: fs.readFileSync("logo.png"), + filename: "logo.png", + contentType: "image/png", + parentFolderId: 5, +}); +``` + +#### Options + +- **file**: The upload definition + - _data_: File contents — any `Buffer`/`Blob`-compatible value (required) + - _filename_: Filename; also the default asset name and, when `contentType` is omitted, the source for the derived content type (required) + - _contentType_: MIME type of the upload; when omitted the SDK derives it from the filename extension (`.bmp`, `.jpg`/`.jpeg`, `.png`, `.gif`, `.pdf`) + - _name_: Asset name; defaults to `filename` + - _parentFolderId_: Parent folder id; omit for the root + +### api.getAsset(assetId) + +Get a single file. + +```javascript +api.getAsset(42); +``` + +#### Options + +- **assetId**: The asset's numeric id (required) + +### api.updateAsset(assetId, updates) + +Rename and/or move a file. At least one field must be provided; the file bytes cannot be changed. Returns no content on success. + +```javascript +api.updateAsset(42, { name: "renamed.png", parent_folder_id: null }); +``` + +#### Options + +- **assetId**: The asset's numeric id (required) +- **updates**: Object with any of `name`, `parent_folder_id` + +### api.deleteAsset(assetId) + +Delete a file. Returns no content on success. + +```javascript +api.deleteAsset(42); +``` + +#### Options + +- **assetId**: The asset's numeric id (required) + +### api.listAssetFolders(options) + +List asset folders. + +```javascript +api.listAssetFolders({ parentFolderId: 5, limit: 50 }); +``` + +#### Options + +- **options**: Object (optional) — `parentFolderId`, `directDescendantsOnly`, `page`, `limit` + +### api.createAssetFolder(folder) + +Create an asset folder. + +```javascript +api.createAssetFolder({ name: "Product images", parent_folder_id: 5 }); +``` + +#### Options + +- **folder**: The folder definition + - _name_: The folder's display name (required) + - _parent_folder_id_: Parent folder id; omit for the root + +### api.getAssetFolder(folderId) + +Get a single asset folder. + +```javascript +api.getAssetFolder(5); +``` + +#### Options + +- **folderId**: The folder's numeric id (required) + +### api.updateAssetFolder(folderId, updates) + +Rename and/or move a folder. At least one field must be provided. Returns no content on success. + +```javascript +api.updateAssetFolder(5, { name: "Renamed", parent_folder_id: null }); +``` + +#### Options + +- **folderId**: The folder's numeric id (required) +- **updates**: Object with any of `name`, `parent_folder_id` + +### api.deleteAssetFolder(folderId) + +Delete an asset folder. The folder must be empty. + +```javascript +api.deleteAssetFolder(5); +``` + +#### Options + +- **folderId**: The folder's numeric id (required) diff --git a/lib/api.ts b/lib/api.ts index ed4d902..cd7a808 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -356,6 +356,143 @@ export type DesignStudioFolderUpdate = { parent_folder_id?: string | null; }; +/** Definition for creating a Design Studio email translation via {@link APIClient.createDesignStudioEmailLanguage}. */ +export type DesignStudioEmailTranslationInput = { + /** IETF language tag for the translation (required). */ + language: string; + /** Content overrides. Omitted blocks are inherited from the default-language email. */ + content?: DesignStudioEmailContent; + envelope?: DesignStudioEmailEnvelope; + transformers?: Record; +}; + +/** + * Fields for updating a Design Studio email translation via {@link APIClient.updateDesignStudioEmailLanguage}. + * At least one must be provided. The language itself is immutable (taken from the path). + */ +export type DesignStudioEmailTranslationUpdate = { + content?: DesignStudioEmailContent; + envelope?: DesignStudioEmailEnvelope; + transformers?: Record; +}; + +/** Options for {@link APIClient.listDesignStudioComponents}. */ +export type ListDesignStudioComponentsOptions = DesignStudioListOptions & { + /** Only list components with this tag. */ + tag?: string; +}; + +/** Definition for creating a Design Studio component via {@link APIClient.createDesignStudioComponent}. */ +export type DesignStudioComponentInput = { + /** The component's display name (required). */ + name: string; + /** The component's tag — unique per workspace (required). */ + tag: string; + /** Parent folder UUID. Omit or pass `null` for the root. */ + parent_folder_id?: string | null; + /** The component's HTML content. */ + content?: string; +}; + +/** + * Fields for updating a Design Studio component via {@link APIClient.updateDesignStudioComponent}. + * At least one must be provided. `parent_folder_id` is tri-state: omit to keep the current parent, + * `null` to move to the root, or a UUID to move into that folder. + */ +export type DesignStudioComponentUpdate = { + name?: string; + tag?: string; + parent_folder_id?: string | null; + content?: string; +}; + +/** Filters and pagination shared by the asset list endpoints (page-based). */ +export type AssetListOptions = { + /** Only list assets directly within this folder id (omit for all/root). */ + parentFolderId?: number; + /** When `true`, return only direct children rather than the whole subtree. */ + directDescendantsOnly?: boolean; + /** 1-based page number. Defaults to 1. */ + page?: number; + /** Page size, 1–10000. Defaults to 1000. */ + limit?: number; +}; + +/** + * Definition for uploading a file via {@link APIClient.createAsset}. + * + * The API accepts images (`image/bmp`, `image/jpeg`, `image/jpg`, `image/png`, + * `image/gif`) and `application/pdf`, up to 2 MB (images max 4096px per side). + */ +export type CreateAssetInput = { + /** File contents. A `Uint8Array` (a `Buffer`, e.g. from `fs.readFileSync`, is one), `ArrayBuffer`, `Blob`, or `string`. */ + data: Uint8Array | ArrayBuffer | Blob | string; + /** Filename — also the multipart filename, the default asset `name`, and (when `contentType` is omitted) the source for the derived content type. */ + filename: string; + /** + * MIME type of the upload. When omitted, the SDK derives it from the `filename` extension + * for the accepted image/PDF types (`.bmp`, `.jpg`/`.jpeg`, `.png`, `.gif`, `.pdf`). + */ + contentType?: string; + /** Asset name. Defaults to `filename`. */ + name?: string; + /** Parent folder id. Omit for the root. */ + parentFolderId?: number; +}; + +/** + * Accepted upload extensions mapped to their MIME type. Used to set the multipart + * part's `Content-Type` client-side when the caller omits `contentType`: an untyped + * `Blob` is serialized as `application/octet-stream`, which the Assets API rejects, + * and the API's own extension fallback only runs when the part carries no type at all. + */ +const ASSET_CONTENT_TYPE_BY_EXTENSION: Record = { + bmp: 'image/bmp', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + pdf: 'application/pdf', +}; + +/** Derive an accepted asset MIME type from a filename's extension, or `undefined` if unrecognized. */ +const assetContentTypeForFilename = (filename: string): string | undefined => { + const dot = filename.lastIndexOf('.'); + if (dot < 0) { + return undefined; + } + + return ASSET_CONTENT_TYPE_BY_EXTENSION[filename.slice(dot + 1).toLowerCase()]; +}; + +/** + * Fields for updating an asset via {@link APIClient.updateAsset}. At least one must be provided; + * file bytes cannot be changed. `parent_folder_id` is tri-state: omit to keep the current parent, + * `null` to move to the root, or a folder id to move it into that folder. + */ +export type AssetUpdate = { + name?: string; + parent_folder_id?: number | null; +}; + +/** Definition for creating an asset folder via {@link APIClient.createAssetFolder}. */ +export type AssetFolderInput = { + /** The folder's display name (required). */ + name: string; + /** Parent folder id. Omit for the root. */ + parent_folder_id?: number; +}; + +/** + * Fields for updating an asset folder via {@link APIClient.updateAssetFolder}. At least one must + * be provided. `parent_folder_id` is tri-state: omit to keep the current parent, `null` to move to + * the root, or a folder id to move it into that folder. + */ +export type AssetFolderUpdate = { + name?: string; + parent_folder_id?: number | null; +}; + type APIDefaults = RequestDefaults & { region: Region; url?: string; retry?: Partial }; type Recipients = Record; @@ -2639,6 +2776,417 @@ export class APIClient { return this.request.destroy(`${this.apiRoot}/design_studio/emails/${encodeURIComponent(emailId)}`); } + + /** + * List the translations (languages) of a Design Studio email. + * + * @param emailId The email's UUID. + * @returns The parsed JSON response body (`{ email_translations: [...] }`). + * @throws {MissingParamError} If `emailId` is empty. + */ + listDesignStudioEmailLanguages(emailId: string) { + if (isEmpty(emailId)) { + throw new MissingParamError('emailId'); + } + + return this.request.get(`${this.apiRoot}/design_studio/emails/${encodeURIComponent(emailId)}/languages`); + } + + /** + * Create a translation of a Design Studio email. Content blocks you omit are + * inherited from the default-language email. + * + * @param emailId The email's UUID. + * @param translation The translation definition. `language` is required. See {@link DesignStudioEmailTranslationInput}. + * @returns The parsed JSON response body (`{ email_translation: {...} }`). + * @throws {MissingParamError} If `emailId` is empty, `translation` is missing/not an object, or `translation.language` is empty. + */ + createDesignStudioEmailLanguage(emailId: string, translation: DesignStudioEmailTranslationInput) { + if (isEmpty(emailId)) { + throw new MissingParamError('emailId'); + } + + if (translation == null || typeof translation !== 'object') { + throw new MissingParamError('translation'); + } + + if (isEmpty(translation.language)) { + throw new MissingParamError('translation.language'); + } + + return this.request.post( + `${this.apiRoot}/design_studio/emails/${encodeURIComponent(emailId)}/languages`, + translation, + ); + } + + /** + * Get a single-language translation of a Design Studio email. + * + * @param emailId The email's UUID. + * @param language The IETF language tag. + * @returns The parsed JSON response body (`{ email_translation: {...} }`). + * @throws {MissingParamError} If `emailId` or `language` is empty. + */ + getDesignStudioEmailLanguage(emailId: string, language: string) { + if (isEmpty(emailId)) { + throw new MissingParamError('emailId'); + } + + if (isEmpty(language)) { + throw new MissingParamError('language'); + } + + return this.request.get( + `${this.apiRoot}/design_studio/emails/${encodeURIComponent(emailId)}/languages/${encodeURIComponent(language)}`, + ); + } + + /** + * Update a single-language translation of a Design Studio email. At least one field must be provided. + * + * @param emailId The email's UUID. + * @param language The IETF language tag. + * @param updates The fields to change. See {@link DesignStudioEmailTranslationUpdate}. + * @returns The parsed JSON response body (empty on success — the API returns 204). + * @throws {MissingParamError} If `emailId` or `language` is empty, or `updates` is missing/not an object. + */ + updateDesignStudioEmailLanguage(emailId: string, language: string, updates: DesignStudioEmailTranslationUpdate) { + if (isEmpty(emailId)) { + throw new MissingParamError('emailId'); + } + + if (isEmpty(language)) { + throw new MissingParamError('language'); + } + + if (updates == null || typeof updates !== 'object') { + throw new MissingParamError('updates'); + } + + return this.request.put( + `${this.apiRoot}/design_studio/emails/${encodeURIComponent(emailId)}/languages/${encodeURIComponent(language)}`, + updates, + ); + } + + /** + * Delete a single-language translation of a Design Studio email. + * + * @param emailId The email's UUID. + * @param language The IETF language tag. + * @returns The parsed JSON response body (empty on success — the API returns 204). + * @throws {MissingParamError} If `emailId` or `language` is empty. + */ + deleteDesignStudioEmailLanguage(emailId: string, language: string) { + if (isEmpty(emailId)) { + throw new MissingParamError('emailId'); + } + + if (isEmpty(language)) { + throw new MissingParamError('language'); + } + + return this.request.destroy( + `${this.apiRoot}/design_studio/emails/${encodeURIComponent(emailId)}/languages/${encodeURIComponent(language)}`, + ); + } + + /** + * List Design Studio components. + * + * @param options Optional filters, sorting, and pagination. See {@link ListDesignStudioComponentsOptions}. + * @returns The parsed JSON response body (`{ components: [...], folders: [...], meta }`). + */ + listDesignStudioComponents(options: ListDesignStudioComponentsOptions = {}) { + const query = buildQueryString({ + parent_folder_id: options.parentFolderId, + direct_descendants_only: options.directDescendantsOnly, + sort_by: options.sortBy, + sort_order: options.sortOrder, + created_before: options.createdBefore, + created_after: options.createdAfter, + updated_before: options.updatedBefore, + updated_after: options.updatedAfter, + page: options.page, + limit: options.limit, + tag: options.tag, + }); + + return this.request.get(`${this.apiRoot}/design_studio/components${query}`); + } + + /** + * Create a Design Studio component. + * + * @param component The component definition. `name` and `tag` are required. See {@link DesignStudioComponentInput}. + * @returns The parsed JSON response body (`{ component: {...} }`). + * @throws {MissingParamError} If `component` is missing/not an object, or `component.name`/`component.tag` is empty. + */ + createDesignStudioComponent(component: DesignStudioComponentInput) { + if (component == null || typeof component !== 'object') { + throw new MissingParamError('component'); + } + + if (isEmpty(component.name)) { + throw new MissingParamError('component.name'); + } + + if (isEmpty(component.tag)) { + throw new MissingParamError('component.tag'); + } + + return this.request.post(`${this.apiRoot}/design_studio/components`, component); + } + + /** + * Get a single Design Studio component. + * + * @param componentId The component's UUID. + * @returns The parsed JSON response body (`{ component: {...} }`). + * @throws {MissingParamError} If `componentId` is empty. + */ + getDesignStudioComponent(componentId: string) { + if (isEmpty(componentId)) { + throw new MissingParamError('componentId'); + } + + return this.request.get(`${this.apiRoot}/design_studio/components/${encodeURIComponent(componentId)}`); + } + + /** + * Update a Design Studio component. At least one field must be provided. + * + * @param componentId The component's UUID. + * @param updates The fields to change. See {@link DesignStudioComponentUpdate}. + * @returns The parsed JSON response body (empty on success — the API returns 204). + * @throws {MissingParamError} If `componentId` is empty or `updates` is missing/not an object. + */ + updateDesignStudioComponent(componentId: string, updates: DesignStudioComponentUpdate) { + if (isEmpty(componentId)) { + throw new MissingParamError('componentId'); + } + + if (updates == null || typeof updates !== 'object') { + throw new MissingParamError('updates'); + } + + return this.request.put(`${this.apiRoot}/design_studio/components/${encodeURIComponent(componentId)}`, updates); + } + + /** + * Delete a Design Studio component. + * + * @param componentId The component's UUID. + * @returns The parsed JSON response body (empty on success — the API returns 204). + * @throws {MissingParamError} If `componentId` is empty. + */ + deleteDesignStudioComponent(componentId: string) { + if (isEmpty(componentId)) { + throw new MissingParamError('componentId'); + } + + return this.request.destroy(`${this.apiRoot}/design_studio/components/${encodeURIComponent(componentId)}`); + } + + /** + * List uploaded assets (files). + * + * @param options Optional folder filter and pagination. See {@link AssetListOptions}. + * @returns The parsed JSON response body (`{ assets: [...], meta }`). + */ + listAssets(options: AssetListOptions = {}) { + const query = buildQueryString({ + parent_folder_id: options.parentFolderId, + direct_descendants_only: options.directDescendantsOnly, + page: options.page, + limit: options.limit, + }); + + return this.request.get(`${this.apiRoot}/assets${query}`); + } + + /** + * Upload a file asset (`multipart/form-data`). + * + * @param file The file to upload. `data` and `filename` are required. See {@link CreateAssetInput}. + * @returns The parsed JSON response body (`{ asset: {...} }`). + * @throws {MissingParamError} If `file` is missing/not an object, or `file.data`/`file.filename` is missing. + */ + createAsset(file: CreateAssetInput) { + if (file == null || typeof file !== 'object') { + throw new MissingParamError('file'); + } + + if (file.data == null) { + throw new MissingParamError('file.data'); + } + + if (isEmpty(file.filename)) { + throw new MissingParamError('file.filename'); + } + + // Set the part's Content-Type explicitly. An untyped Blob is serialized as + // `application/octet-stream`, which the API rejects, so resolve a type from + // (in order) an explicit `contentType`, the filename extension, or the type + // already on `data` when it is a Blob. An empty-string `contentType` is + // treated as absent so it still falls through to derivation. + const explicitType = isEmpty(file.contentType) ? undefined : file.contentType; + const contentType = + explicitType ?? + assetContentTypeForFilename(file.filename) ?? + (file.data instanceof Blob && file.data.type ? file.data.type : undefined); + const form = new FormData(); + const blob = contentType ? new Blob([file.data], { type: contentType }) : new Blob([file.data]); + form.append('file', blob, file.filename); + + if (file.name !== undefined) { + form.append('name', file.name); + } + + if (file.parentFolderId !== undefined) { + form.append('parent_folder_id', String(file.parentFolderId)); + } + + return this.request.postForm(`${this.apiRoot}/assets/files`, form); + } + + /** + * Get a single asset (file). + * + * @param assetId The asset's numeric id. + * @returns The parsed JSON response body (`{ asset: {...} }`). + * @throws {MissingParamError} If `assetId` is empty. + */ + getAsset(assetId: string | number) { + if (isEmpty(assetId)) { + throw new MissingParamError('assetId'); + } + + return this.request.get(`${this.apiRoot}/assets/files/${encodeURIComponent(assetId)}`); + } + + /** + * Update an asset's name and/or parent folder. At least one field must be provided; + * the file bytes cannot be changed. + * + * @param assetId The asset's numeric id. + * @param updates The fields to change. See {@link AssetUpdate}. + * @returns The parsed JSON response body (empty on success — the API returns 204). + * @throws {MissingParamError} If `assetId` is empty or `updates` is missing/not an object. + */ + updateAsset(assetId: string | number, updates: AssetUpdate) { + if (isEmpty(assetId)) { + throw new MissingParamError('assetId'); + } + + if (updates == null || typeof updates !== 'object') { + throw new MissingParamError('updates'); + } + + return this.request.put(`${this.apiRoot}/assets/files/${encodeURIComponent(assetId)}`, updates); + } + + /** + * Delete an asset (file). + * + * @param assetId The asset's numeric id. + * @returns The parsed JSON response body (empty on success — the API returns 204). + * @throws {MissingParamError} If `assetId` is empty. + */ + deleteAsset(assetId: string | number) { + if (isEmpty(assetId)) { + throw new MissingParamError('assetId'); + } + + return this.request.destroy(`${this.apiRoot}/assets/files/${encodeURIComponent(assetId)}`); + } + + /** + * List asset folders. + * + * @param options Optional folder filter and pagination. See {@link AssetListOptions}. + * @returns The parsed JSON response body (`{ folders: [...], meta }`). + */ + listAssetFolders(options: AssetListOptions = {}) { + const query = buildQueryString({ + parent_folder_id: options.parentFolderId, + direct_descendants_only: options.directDescendantsOnly, + page: options.page, + limit: options.limit, + }); + + return this.request.get(`${this.apiRoot}/assets/folders${query}`); + } + + /** + * Create an asset folder. + * + * @param folder The folder definition. `name` is required. See {@link AssetFolderInput}. + * @returns The parsed JSON response body (`{ folder: {...} }`). + * @throws {MissingParamError} If `folder` is missing/not an object, or `folder.name` is empty. + */ + createAssetFolder(folder: AssetFolderInput) { + if (folder == null || typeof folder !== 'object') { + throw new MissingParamError('folder'); + } + + if (isEmpty(folder.name)) { + throw new MissingParamError('folder.name'); + } + + return this.request.post(`${this.apiRoot}/assets/folders`, folder); + } + + /** + * Get a single asset folder. + * + * @param folderId The folder's numeric id. + * @returns The parsed JSON response body (`{ folder: {...} }`). + * @throws {MissingParamError} If `folderId` is empty. + */ + getAssetFolder(folderId: string | number) { + if (isEmpty(folderId)) { + throw new MissingParamError('folderId'); + } + + return this.request.get(`${this.apiRoot}/assets/folders/${encodeURIComponent(folderId)}`); + } + + /** + * Update an asset folder. At least one field must be provided. + * + * @param folderId The folder's numeric id. + * @param updates The fields to change. See {@link AssetFolderUpdate}. + * @returns The parsed JSON response body (empty on success — the API returns 204). + * @throws {MissingParamError} If `folderId` is empty or `updates` is missing/not an object. + */ + updateAssetFolder(folderId: string | number, updates: AssetFolderUpdate) { + if (isEmpty(folderId)) { + throw new MissingParamError('folderId'); + } + + if (updates == null || typeof updates !== 'object') { + throw new MissingParamError('updates'); + } + + return this.request.put(`${this.apiRoot}/assets/folders/${encodeURIComponent(folderId)}`, updates); + } + + /** + * Delete an asset folder. The folder must be empty. + * + * @param folderId The folder's numeric id. + * @returns The parsed JSON response body. + * @throws {MissingParamError} If `folderId` is empty. + */ + deleteAssetFolder(folderId: string | number) { + if (isEmpty(folderId)) { + throw new MissingParamError('folderId'); + } + + return this.request.destroy(`${this.apiRoot}/assets/folders/${encodeURIComponent(folderId)}`); + } } export { diff --git a/lib/request.ts b/lib/request.ts index d6fb610..a445497 100644 --- a/lib/request.ts +++ b/lib/request.ts @@ -35,7 +35,7 @@ export interface RequestHandlerOptions { method: string; uri: string; headers: Record; - body?: string | null; + body?: string | FormData | null; } export interface PushRequestData { delivery_id?: string; @@ -152,6 +152,31 @@ export default class CIORequest { return { method, uri, headers, body }; } + /** + * Build request options for a `multipart/form-data` upload. Unlike + * {@link options}, the `Content-Type` is deliberately left unset so `fetch` + * adds it with the correct multipart boundary, and no `Content-Length` is + * computed (the runtime streams the body). + */ + formOptions(uri: string, method: string, form: FormData): RequestHandlerOptions { + const customHeaders = (this.defaults.headers ?? {}) as Record; + const headers: Record = {}; + + // Merge caller headers, but never a Content-Type: multipart uploads rely on + // `fetch` setting `multipart/form-data` with a boundary, so a stray + // Content-Type from `defaults.headers` would corrupt the request body. + for (const [key, value] of Object.entries(customHeaders)) { + if (key.toLowerCase() !== 'content-type') { + headers[key] = value; + } + } + + headers.Authorization = this.auth; + headers['User-Agent'] = `Customer.io Node Client/${version}`; + + return { method, uri, headers, body: form }; + } + private async execute({ uri, body, method, headers }: RequestHandlerOptions): Promise> { // Network failures and timeouts surface as native fetch errors: // - DNS / refused / reset → `TypeError("fetch failed")` with the underlying @@ -340,4 +365,8 @@ export default class CIORequest { post(uri: string, data: RequestData = {}) { return this.handler(this.options(uri, 'POST', data)); } + + postForm(uri: string, form: FormData) { + return this.handler(this.formOptions(uri, 'POST', form)); + } } diff --git a/test/api.ts b/test/api.ts index 56dd5e2..b16d22e 100644 --- a/test/api.ts +++ b/test/api.ts @@ -1858,3 +1858,256 @@ test('#deleteDesignStudioEmail: deletes an email and validates', (t) => { t.context.client.deleteDesignStudioEmail('email-1'); t.true(destroy.calledWith(`${API}/design_studio/emails/email-1`)); }); + +// Design Studio: email languages (translations) + +test('#listDesignStudioEmailLanguages: gets the languages endpoint and validates', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.throws(() => t.context.client.listDesignStudioEmailLanguages(''), { message: 'emailId is required' }); + t.context.client.listDesignStudioEmailLanguages('email-1'); + t.true(get.calledWith(`${API}/design_studio/emails/email-1/languages`)); +}); + +test('#createDesignStudioEmailLanguage: posts the translation body and validates', (t) => { + const post = sinon.stub(t.context.client.request, 'post'); + t.throws(() => (t.context.client.createDesignStudioEmailLanguage as any)('', { language: 'fr' }), { + message: 'emailId is required', + }); + t.throws(() => (t.context.client.createDesignStudioEmailLanguage as any)('email-1', null), { + message: 'translation is required', + }); + t.throws(() => (t.context.client.createDesignStudioEmailLanguage as any)('email-1', {}), { + message: 'translation.language is required', + }); + const translation = { language: 'fr', content: { subject: 'Bonjour' } }; + t.context.client.createDesignStudioEmailLanguage('email-1', translation); + t.true(post.calledWith(`${API}/design_studio/emails/email-1/languages`, translation)); +}); + +test('#getDesignStudioEmailLanguage: gets a single translation and validates', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.throws(() => t.context.client.getDesignStudioEmailLanguage('', 'fr'), { message: 'emailId is required' }); + t.throws(() => t.context.client.getDesignStudioEmailLanguage('email-1', ''), { message: 'language is required' }); + t.context.client.getDesignStudioEmailLanguage('email-1', 'fr'); + t.true(get.calledWith(`${API}/design_studio/emails/email-1/languages/fr`)); +}); + +test('#updateDesignStudioEmailLanguage: puts the updates and validates', (t) => { + const put = sinon.stub(t.context.client.request, 'put'); + t.throws(() => t.context.client.updateDesignStudioEmailLanguage('', 'fr', { content: {} }), { + message: 'emailId is required', + }); + t.throws(() => t.context.client.updateDesignStudioEmailLanguage('email-1', '', { content: {} }), { + message: 'language is required', + }); + t.throws(() => (t.context.client.updateDesignStudioEmailLanguage as any)('email-1', 'fr', null), { + message: 'updates is required', + }); + const updates = { content: { subject: 'Salut' } }; + t.context.client.updateDesignStudioEmailLanguage('email-1', 'fr', updates); + t.true(put.calledWith(`${API}/design_studio/emails/email-1/languages/fr`, updates)); +}); + +test('#deleteDesignStudioEmailLanguage: deletes a translation and validates', (t) => { + const destroy = sinon.stub(t.context.client.request, 'destroy'); + t.throws(() => t.context.client.deleteDesignStudioEmailLanguage('', 'fr'), { message: 'emailId is required' }); + t.throws(() => t.context.client.deleteDesignStudioEmailLanguage('email-1', ''), { message: 'language is required' }); + t.context.client.deleteDesignStudioEmailLanguage('email-1', 'fr'); + t.true(destroy.calledWith(`${API}/design_studio/emails/email-1/languages/fr`)); +}); + +// Design Studio: components + +test('#listDesignStudioComponents: gets the components endpoint with no query by default', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.context.client.listDesignStudioComponents(); + t.true(get.calledWith(`${API}/design_studio/components`)); +}); + +test('#listDesignStudioComponents: forwards base filters plus tag', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.context.client.listDesignStudioComponents({ parentFolderId: 'folder-1', sortBy: 'name', limit: 10, tag: 'header' }); + t.true(get.calledWith(`${API}/design_studio/components?parent_folder_id=folder-1&sort_by=name&limit=10&tag=header`)); +}); + +test('#createDesignStudioComponent: posts the component body and validates', (t) => { + const post = sinon.stub(t.context.client.request, 'post'); + t.throws(() => (t.context.client.createDesignStudioComponent as any)(null), { message: 'component is required' }); + t.throws(() => (t.context.client.createDesignStudioComponent as any)({ tag: 'header' }), { + message: 'component.name is required', + }); + t.throws(() => (t.context.client.createDesignStudioComponent as any)({ name: 'Header' }), { + message: 'component.tag is required', + }); + const component = { name: 'Header', tag: 'header', content: '
' }; + t.context.client.createDesignStudioComponent(component); + t.true(post.calledWith(`${API}/design_studio/components`, component)); +}); + +test('#getDesignStudioComponent: gets a single component and validates', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.throws(() => t.context.client.getDesignStudioComponent(''), { message: 'componentId is required' }); + t.context.client.getDesignStudioComponent('comp-1'); + t.true(get.calledWith(`${API}/design_studio/components/comp-1`)); +}); + +test('#updateDesignStudioComponent: puts the updates and validates', (t) => { + const put = sinon.stub(t.context.client.request, 'put'); + t.throws(() => t.context.client.updateDesignStudioComponent('', { name: 'x' }), { + message: 'componentId is required', + }); + t.throws(() => (t.context.client.updateDesignStudioComponent as any)('comp-1', null), { + message: 'updates is required', + }); + t.context.client.updateDesignStudioComponent('comp-1', { tag: 'footer', parent_folder_id: null }); + t.true(put.calledWith(`${API}/design_studio/components/comp-1`, { tag: 'footer', parent_folder_id: null })); +}); + +test('#deleteDesignStudioComponent: deletes a component and validates', (t) => { + const destroy = sinon.stub(t.context.client.request, 'destroy'); + t.throws(() => t.context.client.deleteDesignStudioComponent(''), { message: 'componentId is required' }); + t.context.client.deleteDesignStudioComponent('comp-1'); + t.true(destroy.calledWith(`${API}/design_studio/components/comp-1`)); +}); + +// Assets: files + +test('#listAssets: gets the assets endpoint with no query by default', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.context.client.listAssets(); + t.true(get.calledWith(`${API}/assets`)); +}); + +test('#listAssets: forwards folder filter and pagination', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.context.client.listAssets({ parentFolderId: 5, directDescendantsOnly: true, page: 2, limit: 50 }); + t.true(get.calledWith(`${API}/assets?parent_folder_id=5&direct_descendants_only=true&page=2&limit=50`)); +}); + +test('#createAsset: uploads a multipart form with all fields', (t) => { + const postForm = sinon.stub(t.context.client.request, 'postForm'); + t.throws(() => (t.context.client.createAsset as any)(null), { message: 'file is required' }); + t.throws(() => (t.context.client.createAsset as any)({ filename: 'a.png' }), { message: 'file.data is required' }); + t.throws(() => (t.context.client.createAsset as any)({ data: Buffer.from('x') }), { + message: 'file.filename is required', + }); + + t.context.client.createAsset({ + data: Buffer.from('hello'), + filename: 'hello.png', + contentType: 'image/png', + name: 'Hello', + parentFolderId: 7, + }); + + t.true(postForm.calledOnce); + const [uri, form] = postForm.getCall(0).args as [string, FormData]; + t.is(uri, `${API}/assets/files`); + const filePart = form.get('file') as any; + t.is(filePart.type, 'image/png'); + t.is(filePart.name, 'hello.png'); + t.is(form.get('name'), 'Hello'); + t.is(form.get('parent_folder_id'), '7'); +}); + +test('#createAsset: derives the content type from the filename when unset', (t) => { + const postForm = sinon.stub(t.context.client.request, 'postForm'); + t.context.client.createAsset({ data: Buffer.from('hello'), filename: 'hello.PNG' }); + + const form = postForm.getCall(0).args[1] as FormData; + const filePart = form.get('file') as any; + // Derived from the (case-insensitive) extension, so the wire part is not application/octet-stream. + t.is(filePart.type, 'image/png'); + t.is(form.get('name'), null); + t.is(form.get('parent_folder_id'), null); +}); + +test('#createAsset: treats an empty contentType as absent and derives from the filename', (t) => { + const postForm = sinon.stub(t.context.client.request, 'postForm'); + t.context.client.createAsset({ data: Buffer.from('hello'), filename: 'hello.png', contentType: '' }); + + const filePart = (postForm.getCall(0).args[1] as FormData).get('file') as any; + t.is(filePart.type, 'image/png'); +}); + +test('#createAsset: preserves the type of a Blob passed as data when nothing else resolves', (t) => { + const postForm = sinon.stub(t.context.client.request, 'postForm'); + t.context.client.createAsset({ data: new Blob([Buffer.from('hello')], { type: 'image/gif' }), filename: 'noext' }); + + const filePart = (postForm.getCall(0).args[1] as FormData).get('file') as any; + t.is(filePart.type, 'image/gif'); +}); + +test('#createAsset: leaves the content type unset for an unrecognized extension', (t) => { + const postForm = sinon.stub(t.context.client.request, 'postForm'); + t.context.client.createAsset({ data: Buffer.from('hello'), filename: 'notes' }); + + const filePart = (postForm.getCall(0).args[1] as FormData).get('file') as any; + t.is(filePart.type, ''); +}); + +test('#getAsset: gets a single asset and validates', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.throws(() => t.context.client.getAsset(''), { message: 'assetId is required' }); + t.context.client.getAsset(3); + t.true(get.calledWith(`${API}/assets/files/3`)); +}); + +test('#updateAsset: puts the updates and validates', (t) => { + const put = sinon.stub(t.context.client.request, 'put'); + t.throws(() => t.context.client.updateAsset('', { name: 'x' }), { message: 'assetId is required' }); + t.throws(() => (t.context.client.updateAsset as any)(3, null), { message: 'updates is required' }); + t.context.client.updateAsset(3, { name: 'Renamed', parent_folder_id: null }); + t.true(put.calledWith(`${API}/assets/files/3`, { name: 'Renamed', parent_folder_id: null })); +}); + +test('#deleteAsset: deletes an asset and validates', (t) => { + const destroy = sinon.stub(t.context.client.request, 'destroy'); + t.throws(() => t.context.client.deleteAsset(''), { message: 'assetId is required' }); + t.context.client.deleteAsset(3); + t.true(destroy.calledWith(`${API}/assets/files/3`)); +}); + +// Assets: folders + +test('#listAssetFolders: gets the folders endpoint with no query by default', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.context.client.listAssetFolders(); + t.true(get.calledWith(`${API}/assets/folders`)); +}); + +test('#listAssetFolders: forwards folder filter and pagination', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.context.client.listAssetFolders({ parentFolderId: 5, limit: 10 }); + t.true(get.calledWith(`${API}/assets/folders?parent_folder_id=5&limit=10`)); +}); + +test('#createAssetFolder: posts the folder body and validates', (t) => { + const post = sinon.stub(t.context.client.request, 'post'); + t.throws(() => (t.context.client.createAssetFolder as any)(null), { message: 'folder is required' }); + t.throws(() => (t.context.client.createAssetFolder as any)({}), { message: 'folder.name is required' }); + t.context.client.createAssetFolder({ name: 'Images', parent_folder_id: 2 }); + t.true(post.calledWith(`${API}/assets/folders`, { name: 'Images', parent_folder_id: 2 })); +}); + +test('#getAssetFolder: gets a single folder and validates', (t) => { + const get = sinon.stub(t.context.client.request, 'get'); + t.throws(() => t.context.client.getAssetFolder(''), { message: 'folderId is required' }); + t.context.client.getAssetFolder(2); + t.true(get.calledWith(`${API}/assets/folders/2`)); +}); + +test('#updateAssetFolder: puts the updates and validates', (t) => { + const put = sinon.stub(t.context.client.request, 'put'); + t.throws(() => t.context.client.updateAssetFolder('', { name: 'x' }), { message: 'folderId is required' }); + t.throws(() => (t.context.client.updateAssetFolder as any)(2, null), { message: 'updates is required' }); + t.context.client.updateAssetFolder(2, { name: 'Renamed', parent_folder_id: null }); + t.true(put.calledWith(`${API}/assets/folders/2`, { name: 'Renamed', parent_folder_id: null })); +}); + +test('#deleteAssetFolder: deletes a folder and validates', (t) => { + const destroy = sinon.stub(t.context.client.request, 'destroy'); + t.throws(() => t.context.client.deleteAssetFolder(''), { message: 'folderId is required' }); + t.context.client.deleteAssetFolder(2); + t.true(destroy.calledWith(`${API}/assets/folders/2`)); +}); diff --git a/test/integration/live.ts b/test/integration/live.ts index 8d529e8..24f3866 100644 --- a/test/integration/live.ts +++ b/test/integration/live.ts @@ -336,6 +336,18 @@ liveTest('design studio folder + email create -> read -> update -> delete round- if (emailId) { await api!.getDesignStudioEmail(emailId).catch(() => undefined); await api!.updateDesignStudioEmail(emailId, { is_template: true }).catch(() => undefined); + + // Email translations (languages). + await api!.listDesignStudioEmailLanguages(emailId).catch(() => undefined); + await api! + .createDesignStudioEmailLanguage(emailId, { language: 'fr', content: { subject: 'Bonjour' } }) + .catch(() => undefined); + await api!.getDesignStudioEmailLanguage(emailId, 'fr').catch(() => undefined); + await api! + .updateDesignStudioEmailLanguage(emailId, 'fr', { content: { subject: 'Salut' } }) + .catch(() => undefined); + await api!.deleteDesignStudioEmailLanguage(emailId, 'fr').catch(() => undefined); + await api!.deleteDesignStudioEmail(emailId).catch(() => undefined); } @@ -345,6 +357,74 @@ liveTest('design studio folder + email create -> read -> update -> delete round- t.truthy(folder); }); +liveTest('listDesignStudioComponents resolves', async (t) => { + const result = (await api!.listDesignStudioComponents({ limit: 5 })) as { components?: unknown }; + t.true('components' in result); +}); + +liveTest('design studio component create -> read -> update -> delete round-trip', async (t) => { + const created = (await api! + .createDesignStudioComponent({ + name: `sdk-live-component-${customerId}`, + tag: `sdk-live-${customerId}`, + content: '
hi
', + }) + .catch(() => undefined)) as { component?: { id?: string } } | undefined; + const componentId = created?.component?.id; + + if (componentId) { + await api!.getDesignStudioComponent(componentId).catch(() => undefined); + await api!.updateDesignStudioComponent(componentId, { content: '
updated
' }).catch(() => undefined); + await api!.deleteDesignStudioComponent(componentId).catch(() => undefined); + } + + t.pass(); +}); + +liveTest('listAssets resolves', async (t) => { + const result = (await api!.listAssets({ limit: 5 })) as { assets?: unknown }; + t.true('assets' in result); +}); + +liveTest('listAssetFolders resolves', async (t) => { + const result = (await api!.listAssetFolders({ limit: 5 })) as { folders?: unknown }; + t.true('folders' in result); +}); + +liveTest('asset folder + file create -> read -> update -> delete round-trip', async (t) => { + // A minimal valid 1x1 PNG — the backend validates that uploads are real images. + const pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + const pngBytes = Buffer.from(pngBase64, 'base64'); + + const folder = (await api!.createAssetFolder({ name: `sdk-live-assets-${customerId}` }).catch(() => undefined)) as + | { folder?: { id?: number } } + | undefined; + const folderId = folder?.folder?.id; + + const uploaded = (await api! + .createAsset({ + data: pngBytes, + // No contentType: exercises the filename-extension derivation end-to-end. + filename: `sdk-live-${customerId}.png`, + parentFolderId: folderId, + }) + .catch(() => undefined)) as { asset?: { id?: number } } | undefined; + const assetId = uploaded?.asset?.id; + + if (assetId !== undefined) { + await api!.getAsset(assetId).catch(() => undefined); + await api!.updateAsset(assetId, { name: `sdk-live-${customerId}-renamed.png` }).catch(() => undefined); + await api!.deleteAsset(assetId).catch(() => undefined); + } + + if (folderId !== undefined) { + await api!.getAssetFolder(folderId).catch(() => undefined); + await api!.deleteAssetFolder(folderId).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(); diff --git a/test/request.ts b/test/request.ts index 827040e..daa5b3d 100644 --- a/test/request.ts +++ b/test/request.ts @@ -159,6 +159,53 @@ test.serial('#options does not allow defaults.headers to clobber the standard he t.is((result.headers as Record)['User-Agent'], `Customer.io Node Client/${PACKAGE_VERSION}`); }); +test.serial( + '#formOptions omits Content-Type (fetch adds the multipart boundary) and keeps the standard headers', + (t) => { + const form = new FormData(); + form.append('file', new Blob([Buffer.from('x')], { type: 'image/png' }), 'x.png'); + + const result = t.context.req.formOptions(uri, 'POST', form); + + t.is(result.method, 'POST'); + t.is(result.uri, uri); + t.is(result.body, form); + const headers = result.headers as Record; + t.false('Content-Type' in headers); + t.false('Content-Length' in headers); + t.is(headers.Authorization, trackAuth); + t.is(headers['User-Agent'], `Customer.io Node Client/${PACKAGE_VERSION}`); + }, +); + +test.serial('#formOptions merges custom headers but drops any Content-Type from defaults.headers', (t) => { + const req = new Request( + { siteid, apikey }, + { headers: { 'X-Strict-Mode': '1', 'Content-Type': 'application/json' } }, + ); + const result = req.formOptions(uri, 'POST', new FormData()); + const headers = result.headers as Record; + t.is(headers['X-Strict-Mode'], '1'); + t.false('Content-Type' in headers); + t.is(headers.Authorization, trackAuth); +}); + +test.serial('#postForm sends the FormData body to fetch without a JSON content type', async (t) => { + const form = new FormData(); + form.append('file', new Blob([Buffer.from('hello')], { type: 'image/png' }), 'hello.png'); + createMockRequest(t.context.fetchStub, 200, { asset: { id: 1 } }); + + const res = await t.context.req.postForm(uri, form); + + const call = t.context.fetchStub.getCall(0); + t.is(call.args[0], uri); + const init = call.args[1] as RequestInit; + t.is(init.method, 'POST'); + t.is(init.body, form); + t.false('Content-Type' in (init.headers as Record)); + t.deepEqual(res, { asset: { id: 1 } }); +}); + test.serial('#handler returns a promise', (t) => { createMockRequest(t.context.fetchStub, 200); const promise = t.context.req.handler(putOptions); @@ -235,10 +282,12 @@ test.serial('#handler does not let defaults override the SDK-owned fetch fields' // `method`/`body`/`redirect`/`signal` are owned by the SDK. Even if a caller // smuggles them in via defaults (they're omitted from the type), the // transport's own values must win. - const req = new Request( - { siteid, apikey }, - { timeout: 5000, redirect: 'follow', method: 'PATCH', body: 'nope' } as never, - ); + const req = new Request({ siteid, apikey }, { + timeout: 5000, + redirect: 'follow', + method: 'PATCH', + body: 'nope', + } as never); createMockRequest(t.context.fetchStub, 200, {}); await req.handler(req.options(uri, 'GET'));