diff --git a/package.json b/package.json index 03d5772e..3bb50fbc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "resend", - "version": "6.9.3", + "version": "6.10.0-preview-workflows.3", "description": "Node.js library for the Resend API", "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/src/automation-run-steps/automation-run-steps.spec.ts b/src/automation-run-steps/automation-run-steps.spec.ts new file mode 100644 index 00000000..dfa6bf89 --- /dev/null +++ b/src/automation-run-steps/automation-run-steps.spec.ts @@ -0,0 +1,207 @@ +import createFetchMock from 'vitest-fetch-mock'; +import { Resend } from '../resend'; +import { + mockErrorResponse, + mockSuccessResponse, +} from '../test-utils/mock-fetch'; +import type { + GetAutomationRunStepOptions, + GetAutomationRunStepResponseSuccess, +} from './interfaces/get-automation-run-step.interface'; +import type { + ListAutomationRunStepsOptions, + ListAutomationRunStepsResponseSuccess, +} from './interfaces/list-automation-run-steps.interface'; + +const fetchMocker = createFetchMock(vi); +fetchMocker.enableMocks(); + +afterEach(() => fetchMock.resetMocks()); +afterAll(() => fetchMocker.disableMocks()); + +describe('get', () => { + it('gets a automation run step', async () => { + const options: GetAutomationRunStepOptions = { + automationId: 'wf_123', + runId: 'wr_456', + stepId: 'wrs_789', + }; + const response: GetAutomationRunStepResponseSuccess = { + object: 'automation_run_step', + id: 'wrs_789', + step_id: 'step_1', + type: 'trigger', + config: { event_name: 'user.created' }, + status: 'completed', + started_at: '2024-01-01T00:00:00.000Z', + completed_at: '2024-01-01T00:01:00.000Z', + created_at: '2024-01-01T00:00:00.000Z', + }; + + mockSuccessResponse(response, {}); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + await expect( + resend.automations.runs.steps.get(options), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "completed_at": "2024-01-01T00:01:00.000Z", + "config": { + "event_name": "user.created", + }, + "created_at": "2024-01-01T00:00:00.000Z", + "id": "wrs_789", + "object": "automation_run_step", + "started_at": "2024-01-01T00:00:00.000Z", + "status": "completed", + "step_id": "step_1", + "type": "trigger", + }, + "error": null, + "headers": { + "content-type": "application/json", + }, + } + `); + }); + + it('returns error', async () => { + const options: GetAutomationRunStepOptions = { + automationId: 'wf_123', + runId: 'wr_456', + stepId: 'wrs_invalid', + }; + + mockErrorResponse( + { name: 'not_found', message: 'Automation run step not found' }, + {}, + ); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + const result = await resend.automations.runs.steps.get(options); + expect(result.error).not.toBeNull(); + }); +}); + +describe('list', () => { + it('lists automation run steps', async () => { + const options: ListAutomationRunStepsOptions = { + automationId: 'wf_123', + runId: 'wr_456', + }; + const response: ListAutomationRunStepsResponseSuccess = { + object: 'list', + data: [ + { + id: 'wrs_789', + step_id: 'step_1', + type: 'trigger', + status: 'completed', + started_at: '2024-01-01T00:00:00.000Z', + completed_at: '2024-01-01T00:01:00.000Z', + created_at: '2024-01-01T00:00:00.000Z', + }, + ], + has_more: false, + }; + + mockSuccessResponse(response, {}); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + await expect( + resend.automations.runs.steps.list(options), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "data": [ + { + "completed_at": "2024-01-01T00:01:00.000Z", + "created_at": "2024-01-01T00:00:00.000Z", + "id": "wrs_789", + "started_at": "2024-01-01T00:00:00.000Z", + "status": "completed", + "step_id": "step_1", + "type": "trigger", + }, + ], + "has_more": false, + "object": "list", + }, + "error": null, + "headers": { + "content-type": "application/json", + }, + } + `); + }); + + it('lists automation run steps with pagination', async () => { + const options: ListAutomationRunStepsOptions = { + automationId: 'wf_123', + runId: 'wr_456', + limit: 1, + after: 'wrs_cursor', + }; + const response: ListAutomationRunStepsResponseSuccess = { + object: 'list', + data: [ + { + id: 'wrs_101', + step_id: 'step_2', + type: 'send_email', + status: 'running', + started_at: '2024-01-02T00:00:00.000Z', + completed_at: null, + created_at: '2024-01-02T00:00:00.000Z', + }, + ], + has_more: true, + }; + + mockSuccessResponse(response, {}); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + await expect( + resend.automations.runs.steps.list(options), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "data": [ + { + "completed_at": null, + "created_at": "2024-01-02T00:00:00.000Z", + "id": "wrs_101", + "started_at": "2024-01-02T00:00:00.000Z", + "status": "running", + "step_id": "step_2", + "type": "send_email", + }, + ], + "has_more": true, + "object": "list", + }, + "error": null, + "headers": { + "content-type": "application/json", + }, + } + `); + }); + + it('returns error', async () => { + const options: ListAutomationRunStepsOptions = { + automationId: 'wf_invalid', + runId: 'wr_invalid', + }; + + mockErrorResponse( + { name: 'not_found', message: 'Automation not found' }, + {}, + ); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + const result = await resend.automations.runs.steps.list(options); + expect(result.error).not.toBeNull(); + }); +}); diff --git a/src/automation-run-steps/automation-run-steps.ts b/src/automation-run-steps/automation-run-steps.ts new file mode 100644 index 00000000..5b2b33d1 --- /dev/null +++ b/src/automation-run-steps/automation-run-steps.ts @@ -0,0 +1,38 @@ +import { buildPaginationQuery } from '../common/utils/build-pagination-query'; +import type { Resend } from '../resend'; +import type { + GetAutomationRunStepOptions, + GetAutomationRunStepResponse, + GetAutomationRunStepResponseSuccess, +} from './interfaces/get-automation-run-step.interface'; +import type { + ListAutomationRunStepsOptions, + ListAutomationRunStepsResponse, + ListAutomationRunStepsResponseSuccess, +} from './interfaces/list-automation-run-steps.interface'; + +export class AutomationRunSteps { + constructor(private readonly resend: Resend) {} + + async get( + options: GetAutomationRunStepOptions, + ): Promise { + const data = await this.resend.get( + `/automations/${options.automationId}/runs/${options.runId}/steps/${options.stepId}`, + ); + return data; + } + + async list( + options: ListAutomationRunStepsOptions, + ): Promise { + const queryString = buildPaginationQuery(options); + const url = queryString + ? `/automations/${options.automationId}/runs/${options.runId}/steps?${queryString}` + : `/automations/${options.automationId}/runs/${options.runId}/steps`; + + const data = + await this.resend.get(url); + return data; + } +} diff --git a/src/automation-run-steps/interfaces/automation-run-step.ts b/src/automation-run-steps/interfaces/automation-run-step.ts new file mode 100644 index 00000000..5fa5dcaf --- /dev/null +++ b/src/automation-run-steps/interfaces/automation-run-step.ts @@ -0,0 +1,32 @@ +import type { AutomationStepType } from '../../automations/interfaces/automation-step.interface'; + +export type AutomationRunStepStatus = + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'skipped' + | 'waiting'; + +export interface AutomationRunStep { + object: 'automation_run_step'; + id: string; + step_id: string; + type: AutomationStepType; + config: Record; + status: AutomationRunStepStatus; + started_at: string | null; + completed_at: string | null; + created_at: string; +} + +export type AutomationRunStepItem = Pick< + AutomationRunStep, + | 'id' + | 'step_id' + | 'type' + | 'status' + | 'started_at' + | 'completed_at' + | 'created_at' +>; diff --git a/src/automation-run-steps/interfaces/get-automation-run-step.interface.ts b/src/automation-run-steps/interfaces/get-automation-run-step.interface.ts new file mode 100644 index 00000000..fbf9e44d --- /dev/null +++ b/src/automation-run-steps/interfaces/get-automation-run-step.interface.ts @@ -0,0 +1,13 @@ +import type { Response } from '../../interfaces'; +import type { AutomationRunStep } from './automation-run-step'; + +export interface GetAutomationRunStepOptions { + automationId: string; + runId: string; + stepId: string; +} + +export type GetAutomationRunStepResponseSuccess = AutomationRunStep; + +export type GetAutomationRunStepResponse = + Response; diff --git a/src/automation-run-steps/interfaces/index.ts b/src/automation-run-steps/interfaces/index.ts new file mode 100644 index 00000000..c7cd82ad --- /dev/null +++ b/src/automation-run-steps/interfaces/index.ts @@ -0,0 +1,3 @@ +export * from './automation-run-step'; +export * from './get-automation-run-step.interface'; +export * from './list-automation-run-steps.interface'; diff --git a/src/automation-run-steps/interfaces/list-automation-run-steps.interface.ts b/src/automation-run-steps/interfaces/list-automation-run-steps.interface.ts new file mode 100644 index 00000000..7e1c49da --- /dev/null +++ b/src/automation-run-steps/interfaces/list-automation-run-steps.interface.ts @@ -0,0 +1,18 @@ +import type { + PaginatedData, + PaginationOptions, +} from '../../common/interfaces/pagination-options.interface'; +import type { Response } from '../../interfaces'; +import type { AutomationRunStepItem } from './automation-run-step'; + +export type ListAutomationRunStepsOptions = PaginationOptions & { + automationId: string; + runId: string; +}; + +export type ListAutomationRunStepsResponseSuccess = PaginatedData< + AutomationRunStepItem[] +>; + +export type ListAutomationRunStepsResponse = + Response; diff --git a/src/automation-runs/automation-runs.spec.ts b/src/automation-runs/automation-runs.spec.ts new file mode 100644 index 00000000..766d0194 --- /dev/null +++ b/src/automation-runs/automation-runs.spec.ts @@ -0,0 +1,202 @@ +import createFetchMock from 'vitest-fetch-mock'; +import { Resend } from '../resend'; +import { + mockErrorResponse, + mockSuccessResponse, +} from '../test-utils/mock-fetch'; +import type { + GetAutomationRunOptions, + GetAutomationRunResponseSuccess, +} from './interfaces/get-automation-run.interface'; +import type { + ListAutomationRunsOptions, + ListAutomationRunsResponseSuccess, +} from './interfaces/list-automation-runs.interface'; + +const fetchMocker = createFetchMock(vi); +fetchMocker.enableMocks(); + +afterEach(() => fetchMock.resetMocks()); +afterAll(() => fetchMocker.disableMocks()); + +describe('get', () => { + it('gets an automation run', async () => { + const options: GetAutomationRunOptions = { + automationId: 'wf_123', + runId: 'wr_456', + }; + const response: GetAutomationRunResponseSuccess = { + object: 'automation_run', + id: 'wr_456', + status: 'completed', + trigger: { + event_name: 'user.created', + payload: { email: 'jane@example.com' }, + }, + started_at: '2024-01-01T00:00:00.000Z', + completed_at: '2024-01-01T00:01:00.000Z', + created_at: '2024-01-01T00:00:00.000Z', + }; + + mockSuccessResponse(response, {}); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + await expect( + resend.automations.runs.get(options), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "completed_at": "2024-01-01T00:01:00.000Z", + "created_at": "2024-01-01T00:00:00.000Z", + "id": "wr_456", + "object": "automation_run", + "started_at": "2024-01-01T00:00:00.000Z", + "status": "completed", + "trigger": { + "event_name": "user.created", + "payload": { + "email": "jane@example.com", + }, + }, + }, + "error": null, + "headers": { + "content-type": "application/json", + }, + } + `); + }); + + it('returns error', async () => { + const options: GetAutomationRunOptions = { + automationId: 'wf_123', + runId: 'wr_invalid', + }; + + mockErrorResponse( + { name: 'not_found', message: 'Automation run not found' }, + {}, + ); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + const result = await resend.automations.runs.get(options); + expect(result.error).not.toBeNull(); + }); +}); + +describe('list', () => { + it('lists automation runs', async () => { + const options: ListAutomationRunsOptions = { + automationId: 'wf_123', + }; + const response: ListAutomationRunsResponseSuccess = { + object: 'list', + data: [ + { + id: 'wr_456', + status: 'completed', + trigger: { event_name: 'user.created' }, + started_at: '2024-01-01T00:00:00.000Z', + completed_at: '2024-01-01T00:01:00.000Z', + created_at: '2024-01-01T00:00:00.000Z', + }, + ], + has_more: false, + }; + + mockSuccessResponse(response, {}); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + await expect( + resend.automations.runs.list(options), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "data": [ + { + "completed_at": "2024-01-01T00:01:00.000Z", + "created_at": "2024-01-01T00:00:00.000Z", + "id": "wr_456", + "started_at": "2024-01-01T00:00:00.000Z", + "status": "completed", + "trigger": { + "event_name": "user.created", + }, + }, + ], + "has_more": false, + "object": "list", + }, + "error": null, + "headers": { + "content-type": "application/json", + }, + } + `); + }); + + it('lists automation runs with pagination', async () => { + const options: ListAutomationRunsOptions = { + automationId: 'wf_123', + limit: 1, + after: 'wr_cursor', + }; + const response: ListAutomationRunsResponseSuccess = { + object: 'list', + data: [ + { + id: 'wr_789', + status: 'running', + trigger: null, + started_at: '2024-01-02T00:00:00.000Z', + completed_at: null, + created_at: '2024-01-02T00:00:00.000Z', + }, + ], + has_more: true, + }; + + mockSuccessResponse(response, {}); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + await expect( + resend.automations.runs.list(options), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "data": [ + { + "completed_at": null, + "created_at": "2024-01-02T00:00:00.000Z", + "id": "wr_789", + "started_at": "2024-01-02T00:00:00.000Z", + "status": "running", + "trigger": null, + }, + ], + "has_more": true, + "object": "list", + }, + "error": null, + "headers": { + "content-type": "application/json", + }, + } + `); + }); + + it('returns error', async () => { + const options: ListAutomationRunsOptions = { + automationId: 'wf_invalid', + }; + + mockErrorResponse( + { name: 'not_found', message: 'Automation not found' }, + {}, + ); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + const result = await resend.automations.runs.list(options); + expect(result.error).not.toBeNull(); + }); +}); diff --git a/src/automation-runs/automation-runs.ts b/src/automation-runs/automation-runs.ts new file mode 100644 index 00000000..e3094870 --- /dev/null +++ b/src/automation-runs/automation-runs.ts @@ -0,0 +1,42 @@ +import { AutomationRunSteps } from '../automation-run-steps/automation-run-steps'; +import { buildPaginationQuery } from '../common/utils/build-pagination-query'; +import type { Resend } from '../resend'; +import type { + GetAutomationRunOptions, + GetAutomationRunResponse, + GetAutomationRunResponseSuccess, +} from './interfaces/get-automation-run.interface'; +import type { + ListAutomationRunsOptions, + ListAutomationRunsResponse, + ListAutomationRunsResponseSuccess, +} from './interfaces/list-automation-runs.interface'; + +export class AutomationRuns { + readonly steps: AutomationRunSteps; + + constructor(private readonly resend: Resend) { + this.steps = new AutomationRunSteps(resend); + } + + async get( + options: GetAutomationRunOptions, + ): Promise { + const data = await this.resend.get( + `/automations/${options.automationId}/runs/${options.runId}`, + ); + return data; + } + + async list( + options: ListAutomationRunsOptions, + ): Promise { + const queryString = buildPaginationQuery(options); + const url = queryString + ? `/automations/${options.automationId}/runs?${queryString}` + : `/automations/${options.automationId}/runs`; + + const data = await this.resend.get(url); + return data; + } +} diff --git a/src/automation-runs/interfaces/automation-run.ts b/src/automation-runs/interfaces/automation-run.ts new file mode 100644 index 00000000..b2edf8a6 --- /dev/null +++ b/src/automation-runs/interfaces/automation-run.ts @@ -0,0 +1,25 @@ +export interface AutomationRunTrigger { + event_name: string; + payload?: Record; +} + +export type AutomationRunStatus = + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface AutomationRun { + object: 'automation_run'; + id: string; + status: AutomationRunStatus; + trigger: AutomationRunTrigger | null; + started_at: string | null; + completed_at: string | null; + created_at: string; +} + +export type AutomationRunItem = Pick< + AutomationRun, + 'id' | 'status' | 'trigger' | 'started_at' | 'completed_at' | 'created_at' +>; diff --git a/src/automation-runs/interfaces/get-automation-run.interface.ts b/src/automation-runs/interfaces/get-automation-run.interface.ts new file mode 100644 index 00000000..4a981840 --- /dev/null +++ b/src/automation-runs/interfaces/get-automation-run.interface.ts @@ -0,0 +1,12 @@ +import type { Response } from '../../interfaces'; +import type { AutomationRun } from './automation-run'; + +export interface GetAutomationRunOptions { + automationId: string; + runId: string; +} + +export type GetAutomationRunResponseSuccess = AutomationRun; + +export type GetAutomationRunResponse = + Response; diff --git a/src/automation-runs/interfaces/index.ts b/src/automation-runs/interfaces/index.ts new file mode 100644 index 00000000..6bc5e2cd --- /dev/null +++ b/src/automation-runs/interfaces/index.ts @@ -0,0 +1,3 @@ +export * from './automation-run'; +export * from './get-automation-run.interface'; +export * from './list-automation-runs.interface'; diff --git a/src/automation-runs/interfaces/list-automation-runs.interface.ts b/src/automation-runs/interfaces/list-automation-runs.interface.ts new file mode 100644 index 00000000..c07637e1 --- /dev/null +++ b/src/automation-runs/interfaces/list-automation-runs.interface.ts @@ -0,0 +1,17 @@ +import type { + PaginatedData, + PaginationOptions, +} from '../../common/interfaces/pagination-options.interface'; +import type { Response } from '../../interfaces'; +import type { AutomationRunItem } from './automation-run'; + +export type ListAutomationRunsOptions = PaginationOptions & { + automationId: string; +}; + +export type ListAutomationRunsResponseSuccess = PaginatedData< + AutomationRunItem[] +>; + +export type ListAutomationRunsResponse = + Response; diff --git a/src/automations/automations.spec.ts b/src/automations/automations.spec.ts new file mode 100644 index 00000000..dfe778da --- /dev/null +++ b/src/automations/automations.spec.ts @@ -0,0 +1,403 @@ +import createFetchMock from 'vitest-fetch-mock'; +import type { ErrorResponse } from '../interfaces'; +import { Resend } from '../resend'; +import { mockSuccessResponse } from '../test-utils/mock-fetch'; +import type { + CreateAutomationOptions, + CreateAutomationResponseSuccess, +} from './interfaces/create-automation-options.interface'; +import type { GetAutomationResponseSuccess } from './interfaces/get-automation.interface'; +import type { ListAutomationsResponseSuccess } from './interfaces/list-automation.interface'; +import type { RemoveAutomationResponseSuccess } from './interfaces/remove-automation.interface'; +import type { UpdateAutomationResponseSuccess } from './interfaces/update-automation.interface'; + +const fetchMocker = createFetchMock(vi); +fetchMocker.enableMocks(); + +const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + +afterEach(() => fetchMock.resetMocks()); +afterAll(() => fetchMocker.disableMocks()); + +describe('create', () => { + it('creates an automation', async () => { + const response: CreateAutomationResponseSuccess = { + object: 'automation', + id: '71cdfe68-cf79-473a-a9d7-21f91db6a526', + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + const payload: CreateAutomationOptions = { + name: 'Welcome Flow', + status: 'enabled', + steps: [ + { + ref: 'trigger', + type: 'trigger', + config: { eventName: 'user.created' }, + }, + { + ref: 'welcome_email', + type: 'send_email', + config: { templateId: 'tpl-123' }, + }, + ], + edges: [{ from: 'trigger', to: 'welcome_email', edgeType: 'default' }], + }; + + const data = await resend.automations.create(payload); + expect(data).toMatchInlineSnapshot(` + { + "data": { + "id": "71cdfe68-cf79-473a-a9d7-21f91db6a526", + "object": "automation", + }, + "error": null, + "headers": { + "content-type": "application/json", + }, + } + `); + }); + + it('throws an error when an ErrorResponse is returned', async () => { + const response: ErrorResponse = { + name: 'missing_required_field', + statusCode: 422, + message: 'Missing `name` field.', + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 422, + headers: { + 'content-type': 'application/json', + }, + }); + + const data = await resend.automations.create({ + name: '', + steps: [], + edges: [], + }); + expect(data).toMatchInlineSnapshot(` + { + "data": null, + "error": { + "message": "Missing \`name\` field.", + "name": "missing_required_field", + "statusCode": 422, + }, + "headers": { + "content-type": "application/json", + }, + } + `); + }); +}); + +describe('list', () => { + const response: ListAutomationsResponseSuccess = { + object: 'list', + has_more: false, + data: [ + { + id: '49a3999c-0ce1-4ea6-ab68-afcd6dc2e794', + name: 'Welcome Flow', + status: 'enabled', + created_at: '2025-01-01T00:00:00.000Z', + updated_at: '2025-01-01T00:00:00.000Z', + }, + { + id: '559ac32e-9ef5-46fb-82a1-b76b840c0f7b', + name: 'Onboarding Flow', + status: 'disabled', + created_at: '2025-02-01T00:00:00.000Z', + updated_at: '2025-02-01T00:00:00.000Z', + }, + ], + }; + + describe('when no pagination options are provided', () => { + it('lists automations', async () => { + mockSuccessResponse(response, { + headers: {}, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + const result = await resend.automations.list(); + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/automations', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + }); + + describe('when pagination options are provided', () => { + it('passes limit param and returns a response', async () => { + mockSuccessResponse(response, { + headers: {}, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + const result = await resend.automations.list({ limit: 1 }); + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/automations?limit=1', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + + it('passes after param and returns a response', async () => { + mockSuccessResponse(response, { + headers: {}, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + const result = await resend.automations.list({ + limit: 1, + after: 'cursor-value', + }); + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/automations?limit=1&after=cursor-value', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + + it('passes before param and returns a response', async () => { + mockSuccessResponse(response, { + headers: {}, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + const result = await resend.automations.list({ + limit: 1, + before: 'cursor-value', + }); + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/automations?limit=1&before=cursor-value', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + }); +}); + +describe('get', () => { + describe('when automation not found', () => { + it('returns error', async () => { + const response: ErrorResponse = { + name: 'not_found', + statusCode: 404, + message: 'Automation not found', + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 404, + headers: { + 'content-type': 'application/json', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + const result = resend.automations.get( + '559ac32e-9ef5-46fb-82a1-b76b840c0f7b', + ); + + await expect(result).resolves.toMatchInlineSnapshot(` + { + "data": null, + "error": { + "message": "Automation not found", + "name": "not_found", + "statusCode": 404, + }, + "headers": { + "content-type": "application/json", + }, + } + `); + }); + }); + + it('gets an automation', async () => { + const response: GetAutomationResponseSuccess = { + object: 'automation', + id: '559ac32e-9ef5-46fb-82a1-b76b840c0f7b', + name: 'Welcome Flow', + status: 'enabled', + created_at: '2025-01-01T00:00:00.000Z', + updated_at: '2025-01-01T00:00:00.000Z', + steps: [ + { + id: 'step-1', + type: 'trigger', + config: { event_name: 'user.created' }, + }, + ], + edges: [], + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + await expect( + resend.automations.get('559ac32e-9ef5-46fb-82a1-b76b840c0f7b'), + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "created_at": "2025-01-01T00:00:00.000Z", + "edges": [], + "id": "559ac32e-9ef5-46fb-82a1-b76b840c0f7b", + "name": "Welcome Flow", + "object": "automation", + "status": "enabled", + "steps": [ + { + "config": { + "event_name": "user.created", + }, + "id": "step-1", + "type": "trigger", + }, + ], + "updated_at": "2025-01-01T00:00:00.000Z", + }, + "error": null, + "headers": { + "content-type": "application/json", + }, + } + `); + }); +}); + +describe('remove', () => { + it('removes an automation', async () => { + const id = 'b01e0de9-7c27-4a53-bf38-2e3f98389a65'; + const response: RemoveAutomationResponseSuccess = { + object: 'automation', + id, + deleted: true, + }; + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + + await expect(resend.automations.remove(id)).resolves.toMatchInlineSnapshot(` + { + "data": { + "deleted": true, + "id": "b01e0de9-7c27-4a53-bf38-2e3f98389a65", + "object": "automation", + }, + "error": null, + "headers": { + "content-type": "application/json", + }, + } + `); + }); +}); + +describe('update', () => { + it('updates an automation', async () => { + const id = '71cdfe68-cf79-473a-a9d7-21f91db6a526'; + const response: UpdateAutomationResponseSuccess = { + object: 'automation', + id, + status: 'disabled', + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + const data = await resend.automations.update(id, { status: 'disabled' }); + expect(data).toMatchInlineSnapshot(` + { + "data": { + "id": "71cdfe68-cf79-473a-a9d7-21f91db6a526", + "object": "automation", + "status": "disabled", + }, + "error": null, + "headers": { + "content-type": "application/json", + }, + } + `); + + expect(fetchMock).toHaveBeenCalledWith( + `https://api.resend.com/automations/${id}`, + expect.objectContaining({ + method: 'PATCH', + headers: expect.any(Headers), + body: JSON.stringify({ status: 'disabled' }), + }), + ); + }); +}); diff --git a/src/automations/automations.ts b/src/automations/automations.ts new file mode 100644 index 00000000..2ef88ff5 --- /dev/null +++ b/src/automations/automations.ts @@ -0,0 +1,81 @@ +import { AutomationRuns } from '../automation-runs/automation-runs'; +import { buildPaginationQuery } from '../common/utils/build-pagination-query'; +import { parseAutomationToApiOptions } from '../common/utils/parse-automation-to-api-options'; +import type { Resend } from '../resend'; +import type { + CreateAutomationOptions, + CreateAutomationResponse, + CreateAutomationResponseSuccess, +} from './interfaces/create-automation-options.interface'; +import type { + GetAutomationResponse, + GetAutomationResponseSuccess, +} from './interfaces/get-automation.interface'; +import type { + ListAutomationsOptions, + ListAutomationsResponse, + ListAutomationsResponseSuccess, +} from './interfaces/list-automation.interface'; +import type { + RemoveAutomationResponse, + RemoveAutomationResponseSuccess, +} from './interfaces/remove-automation.interface'; +import type { + UpdateAutomationOptions, + UpdateAutomationResponse, + UpdateAutomationResponseSuccess, +} from './interfaces/update-automation.interface'; + +export class Automations { + readonly runs: AutomationRuns; + + constructor(private readonly resend: Resend) { + this.runs = new AutomationRuns(this.resend); + } + + async create( + payload: CreateAutomationOptions, + ): Promise { + const data = await this.resend.post( + '/automations', + parseAutomationToApiOptions(payload), + ); + + return data; + } + + async list( + options: ListAutomationsOptions = {}, + ): Promise { + const queryString = buildPaginationQuery(options); + const url = queryString ? `/automations?${queryString}` : '/automations'; + + const data = await this.resend.get(url); + return data; + } + + async get(id: string): Promise { + const data = await this.resend.get( + `/automations/${id}`, + ); + return data; + } + + async remove(id: string): Promise { + const data = await this.resend.delete( + `/automations/${id}`, + ); + return data; + } + + async update( + id: string, + payload: UpdateAutomationOptions, + ): Promise { + const data = await this.resend.patch( + `/automations/${id}`, + payload, + ); + return data; + } +} diff --git a/src/automations/interfaces/automation-step.interface.ts b/src/automations/interfaces/automation-step.interface.ts new file mode 100644 index 00000000..8894d49b --- /dev/null +++ b/src/automations/interfaces/automation-step.interface.ts @@ -0,0 +1,95 @@ +export type ConditionRule = + | { + type: 'rule'; + field: string; + operator: 'eq' | 'neq'; + value: string | number | boolean | null; + } + | { + type: 'rule'; + field: string; + operator: 'gt' | 'gte' | 'lt' | 'lte'; + value: number; + } + | { + type: 'rule'; + field: string; + operator: 'contains' | 'starts_with' | 'ends_with'; + value: string; + } + | { + type: 'rule'; + field: string; + operator: 'exists' | 'is_empty'; + } + | { type: 'and'; rules: ConditionRule[] } + | { type: 'or'; rules: ConditionRule[] }; + +export type TemplateVariableValue = + | string + | number + | boolean + | { var: string } + | Record + | Array< + string | number | boolean | Record + >; + +export interface TriggerStepConfig { + eventName: string; +} + +export interface DelayStepConfig { + seconds: number; +} + +export interface SendEmailStepConfig { + templateId: string; + subject?: string; + from?: string; + replyTo?: string; + variables?: Record; +} + +export interface WaitForEventStepConfig { + eventName: string; + timeoutSeconds?: number; + filterRule?: ConditionRule; +} + +export type ConditionStepConfig = ConditionRule; + +export type AutomationStep = + | { ref: string; type: 'trigger'; config: TriggerStepConfig } + | { ref: string; type: 'delay'; config: DelayStepConfig } + | { ref: string; type: 'send_email'; config: SendEmailStepConfig } + | { ref: string; type: 'wait_for_event'; config: WaitForEventStepConfig } + | { ref: string; type: 'condition'; config: ConditionStepConfig }; + +export type AutomationEdgeType = + | 'default' + | 'condition_met' + | 'condition_not_met' + | 'timeout' + | 'event_received'; + +export interface AutomationEdge { + from: string; + to: string; + edgeType?: AutomationEdgeType; +} + +export type AutomationStepType = AutomationStep['type']; + +export interface AutomationResponseStep { + id: string; + type: AutomationStepType; + config: Record; +} + +export interface AutomationResponseEdge { + id: string; + from_step_id: string; + to_step_id: string; + edge_type: AutomationEdgeType; +} diff --git a/src/automations/interfaces/automation.ts b/src/automations/interfaces/automation.ts new file mode 100644 index 00000000..cc766ca5 --- /dev/null +++ b/src/automations/interfaces/automation.ts @@ -0,0 +1,9 @@ +export interface Automation { + id: string; + name: string; + status: 'enabled' | 'disabled'; + created_at: string; + updated_at: string | null; +} + +export type AutomationStatus = Automation['status']; diff --git a/src/automations/interfaces/create-automation-options.interface.ts b/src/automations/interfaces/create-automation-options.interface.ts new file mode 100644 index 00000000..e4335e0e --- /dev/null +++ b/src/automations/interfaces/create-automation-options.interface.ts @@ -0,0 +1,21 @@ +import type { Response } from '../../interfaces'; +import type { AutomationStatus } from './automation'; +import type { + AutomationEdge, + AutomationStep, +} from './automation-step.interface'; + +export interface CreateAutomationOptions { + name: string; + status?: AutomationStatus; + steps: AutomationStep[]; + edges: AutomationEdge[]; +} + +export interface CreateAutomationResponseSuccess { + object: 'automation'; + id: string; +} + +export type CreateAutomationResponse = + Response; diff --git a/src/automations/interfaces/get-automation.interface.ts b/src/automations/interfaces/get-automation.interface.ts new file mode 100644 index 00000000..a2243223 --- /dev/null +++ b/src/automations/interfaces/get-automation.interface.ts @@ -0,0 +1,14 @@ +import type { Response } from '../../interfaces'; +import type { Automation } from './automation'; +import type { + AutomationResponseEdge, + AutomationResponseStep, +} from './automation-step.interface'; + +export interface GetAutomationResponseSuccess extends Automation { + object: 'automation'; + steps: AutomationResponseStep[]; + edges: AutomationResponseEdge[]; +} + +export type GetAutomationResponse = Response; diff --git a/src/automations/interfaces/index.ts b/src/automations/interfaces/index.ts new file mode 100644 index 00000000..c5f183aa --- /dev/null +++ b/src/automations/interfaces/index.ts @@ -0,0 +1,7 @@ +export * from './automation'; +export * from './automation-step.interface'; +export * from './create-automation-options.interface'; +export * from './get-automation.interface'; +export * from './list-automation.interface'; +export * from './remove-automation.interface'; +export * from './update-automation.interface'; diff --git a/src/automations/interfaces/list-automation.interface.ts b/src/automations/interfaces/list-automation.interface.ts new file mode 100644 index 00000000..de2ee49d --- /dev/null +++ b/src/automations/interfaces/list-automation.interface.ts @@ -0,0 +1,12 @@ +import type { + PaginatedData, + PaginationOptions, +} from '../../common/interfaces/pagination-options.interface'; +import type { Response } from '../../interfaces'; +import type { Automation } from './automation'; + +export type ListAutomationsOptions = PaginationOptions; + +export type ListAutomationsResponseSuccess = PaginatedData; + +export type ListAutomationsResponse = Response; diff --git a/src/automations/interfaces/remove-automation.interface.ts b/src/automations/interfaces/remove-automation.interface.ts new file mode 100644 index 00000000..dc23e9ad --- /dev/null +++ b/src/automations/interfaces/remove-automation.interface.ts @@ -0,0 +1,11 @@ +import type { Response } from '../../interfaces'; +import type { Automation } from './automation'; + +export interface RemoveAutomationResponseSuccess + extends Pick { + object: 'automation'; + deleted: boolean; +} + +export type RemoveAutomationResponse = + Response; diff --git a/src/automations/interfaces/update-automation.interface.ts b/src/automations/interfaces/update-automation.interface.ts new file mode 100644 index 00000000..53c231cb --- /dev/null +++ b/src/automations/interfaces/update-automation.interface.ts @@ -0,0 +1,14 @@ +import type { Response } from '../../interfaces'; +import type { Automation, AutomationStatus } from './automation'; + +export interface UpdateAutomationOptions { + status: AutomationStatus; +} + +export interface UpdateAutomationResponseSuccess + extends Pick { + object: 'automation'; +} + +export type UpdateAutomationResponse = + Response; diff --git a/src/common/utils/parse-automation-to-api-options.spec.ts b/src/common/utils/parse-automation-to-api-options.spec.ts new file mode 100644 index 00000000..3e451331 --- /dev/null +++ b/src/common/utils/parse-automation-to-api-options.spec.ts @@ -0,0 +1,219 @@ +import type { CreateAutomationOptions } from '../../automations/interfaces/create-automation-options.interface'; +import type { SendEventOptions } from '../../events/interfaces/send-event.interface'; +import { + parseAutomationToApiOptions, + parseEventToApiOptions, +} from './parse-automation-to-api-options'; + +describe('parseAutomationToApiOptions', () => { + it('converts full payload with all step types from camelCase to snake_case', () => { + const automation: CreateAutomationOptions = { + name: 'Welcome Automation', + status: 'enabled', + steps: [ + { + ref: 'trigger_1', + type: 'trigger', + config: { eventName: 'user.signed_up' }, + }, + { + ref: 'delay_1', + type: 'delay', + config: { seconds: 3600 }, + }, + { + ref: 'send_1', + type: 'send_email', + config: { + templateId: 'tmpl_123', + subject: 'Welcome!', + from: 'hello@example.com', + replyTo: 'support@example.com', + variables: { userName: { var: 'contact.name' } }, + }, + }, + { + ref: 'wait_1', + type: 'wait_for_event', + config: { + eventName: 'user.confirmed', + timeoutSeconds: 86400, + filterRule: { + type: 'rule', + field: 'status', + operator: 'eq', + value: 'confirmed', + }, + }, + }, + { + ref: 'cond_1', + type: 'condition', + config: { + type: 'rule', + field: 'plan', + operator: 'eq', + value: 'pro', + }, + }, + ], + edges: [ + { from: 'trigger_1', to: 'delay_1' }, + { from: 'delay_1', to: 'send_1', edgeType: 'default' }, + { from: 'send_1', to: 'wait_1' }, + { from: 'wait_1', to: 'cond_1', edgeType: 'event_received' }, + ], + }; + + const apiOptions = parseAutomationToApiOptions(automation); + + expect(apiOptions).toEqual({ + name: 'Welcome Automation', + status: 'enabled', + steps: [ + { + ref: 'trigger_1', + type: 'trigger', + config: { event_name: 'user.signed_up' }, + }, + { + ref: 'delay_1', + type: 'delay', + config: { seconds: 3600 }, + }, + { + ref: 'send_1', + type: 'send_email', + config: { + template_id: 'tmpl_123', + subject: 'Welcome!', + from: 'hello@example.com', + reply_to: 'support@example.com', + variables: { userName: { var: 'contact.name' } }, + }, + }, + { + ref: 'wait_1', + type: 'wait_for_event', + config: { + event_name: 'user.confirmed', + timeout_seconds: 86400, + filter_rule: { + type: 'rule', + field: 'status', + operator: 'eq', + value: 'confirmed', + }, + }, + }, + { + ref: 'cond_1', + type: 'condition', + config: { + type: 'rule', + field: 'plan', + operator: 'eq', + value: 'pro', + }, + }, + ], + edges: [ + { from: 'trigger_1', to: 'delay_1', edge_type: undefined }, + { from: 'delay_1', to: 'send_1', edge_type: 'default' }, + { from: 'send_1', to: 'wait_1', edge_type: undefined }, + { from: 'wait_1', to: 'cond_1', edge_type: 'event_received' }, + ], + }); + }); + + it('converts edge edgeType to edge_type', () => { + const automation: CreateAutomationOptions = { + name: 'Edge Test', + steps: [ + { + ref: 'trigger_1', + type: 'trigger', + config: { eventName: 'test.event' }, + }, + ], + edges: [ + { from: 'trigger_1', to: 'step_2', edgeType: 'condition_met' }, + { from: 'trigger_1', to: 'step_3', edgeType: 'condition_not_met' }, + { from: 'step_2', to: 'step_4', edgeType: 'timeout' }, + ], + }; + + const apiOptions = parseAutomationToApiOptions(automation); + + expect(apiOptions.edges).toEqual([ + { from: 'trigger_1', to: 'step_2', edge_type: 'condition_met' }, + { from: 'trigger_1', to: 'step_3', edge_type: 'condition_not_met' }, + { from: 'step_2', to: 'step_4', edge_type: 'timeout' }, + ]); + }); + + it('handles minimal payload with only required fields', () => { + const automation: CreateAutomationOptions = { + name: 'Minimal Automation', + steps: [ + { + ref: 'trigger_1', + type: 'trigger', + config: { eventName: 'test.event' }, + }, + ], + edges: [{ from: 'trigger_1', to: 'step_2' }], + }; + + const apiOptions = parseAutomationToApiOptions(automation); + + expect(apiOptions).toEqual({ + name: 'Minimal Automation', + status: undefined, + steps: [ + { + ref: 'trigger_1', + type: 'trigger', + config: { event_name: 'test.event' }, + }, + ], + edges: [{ from: 'trigger_1', to: 'step_2', edge_type: undefined }], + }); + }); +}); + +describe('parseEventToApiOptions', () => { + it('converts contactId to contact_id', () => { + const event: SendEventOptions = { + event: 'user.signed_up', + contactId: 'contact_abc123', + payload: { plan: 'pro' }, + }; + + const apiOptions = parseEventToApiOptions(event); + + expect(apiOptions).toEqual({ + event: 'user.signed_up', + contact_id: 'contact_abc123', + email: undefined, + payload: { plan: 'pro' }, + }); + }); + + it('passes email through without conversion', () => { + const event: SendEventOptions = { + event: 'user.signed_up', + email: 'user@example.com', + payload: { source: 'website' }, + }; + + const apiOptions = parseEventToApiOptions(event); + + expect(apiOptions).toEqual({ + event: 'user.signed_up', + contact_id: undefined, + email: 'user@example.com', + payload: { source: 'website' }, + }); + }); +}); diff --git a/src/common/utils/parse-automation-to-api-options.ts b/src/common/utils/parse-automation-to-api-options.ts new file mode 100644 index 00000000..af794668 --- /dev/null +++ b/src/common/utils/parse-automation-to-api-options.ts @@ -0,0 +1,100 @@ +import type { + AutomationEdge, + AutomationEdgeType, + AutomationStep, +} from '../../automations/interfaces/automation-step.interface'; +import type { CreateAutomationOptions } from '../../automations/interfaces/create-automation-options.interface'; +import type { SendEventOptions } from '../../events/interfaces/send-event.interface'; + +interface AutomationStepApiOptions { + ref: string; + type: string; + config: unknown; +} + +interface AutomationEdgeApiOptions { + from: string; + to: string; + edge_type?: AutomationEdgeType; +} + +interface AutomationApiOptions { + name: string; + status?: 'enabled' | 'disabled'; + steps?: AutomationStepApiOptions[]; + edges?: AutomationEdgeApiOptions[]; +} + +interface EventApiOptions { + event: string; + contact_id?: string; + email?: string; + payload?: Record; +} + +function parseStepConfig(step: AutomationStep): AutomationStepApiOptions { + switch (step.type) { + case 'trigger': + return { + ref: step.ref, + type: step.type, + config: { event_name: step.config.eventName }, + }; + case 'delay': + return { ref: step.ref, type: step.type, config: step.config }; + case 'send_email': + return { + ref: step.ref, + type: step.type, + config: { + template_id: step.config.templateId, + subject: step.config.subject, + from: step.config.from, + reply_to: step.config.replyTo, + variables: step.config.variables, + }, + }; + case 'wait_for_event': + return { + ref: step.ref, + type: step.type, + config: { + event_name: step.config.eventName, + timeout_seconds: step.config.timeoutSeconds, + filter_rule: step.config.filterRule, + }, + }; + case 'condition': + return { ref: step.ref, type: step.type, config: step.config }; + } +} + +function parseEdge(edge: AutomationEdge): AutomationEdgeApiOptions { + return { + from: edge.from, + to: edge.to, + edge_type: edge.edgeType, + }; +} + +export function parseAutomationToApiOptions( + automation: CreateAutomationOptions, +): AutomationApiOptions { + return { + name: automation.name, + status: automation.status, + steps: automation.steps.map(parseStepConfig), + edges: automation.edges.map(parseEdge), + }; +} + +export function parseEventToApiOptions( + event: SendEventOptions, +): EventApiOptions { + return { + event: event.event, + contact_id: event.contactId, + email: event.email, + payload: event.payload, + }; +} diff --git a/src/events/events.spec.ts b/src/events/events.spec.ts new file mode 100644 index 00000000..7d546893 --- /dev/null +++ b/src/events/events.spec.ts @@ -0,0 +1,417 @@ +import createFetchMock from 'vitest-fetch-mock'; +import type { ErrorResponse } from '../interfaces'; +import { Resend } from '../resend'; +import { mockSuccessResponse } from '../test-utils/mock-fetch'; +import type { CreateEventResponseSuccess } from './interfaces/create-event.interface'; +import type { GetEventResponseSuccess } from './interfaces/get-event.interface'; +import type { ListEventsResponseSuccess } from './interfaces/list-events.interface'; +import type { RemoveEventResponseSuccess } from './interfaces/remove-event.interface'; +import type { SendEventResponseSuccess } from './interfaces/send-event.interface'; +import type { UpdateEventResponseSuccess } from './interfaces/update-event.interface'; + +const fetchMocker = createFetchMock(vi); +fetchMocker.enableMocks(); + +const resend = new Resend('re_zKa4RCko_Lhm9ost2YjNCctnPjbLw8Nop'); + +describe('Events', () => { + afterEach(() => fetchMock.resetMocks()); + afterAll(() => fetchMocker.disableMocks()); + + describe('send', () => { + it('sends an event with contactId', async () => { + const response: SendEventResponseSuccess = { + object: 'event', + event: 'user.created', + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + const data = await resend.events.send({ + event: 'user.created', + contactId: 'contact-123', + payload: { name: 'John' }, + }); + + expect(data).toMatchInlineSnapshot(` + { + "data": { + "event": "user.created", + "object": "event", + }, + "error": null, + "headers": { + "content-type": "application/json", + }, + } + `); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/events/send', + expect.objectContaining({ + method: 'POST', + headers: expect.any(Headers), + body: JSON.stringify({ + event: 'user.created', + contact_id: 'contact-123', + payload: { name: 'John' }, + }), + }), + ); + }); + + it('sends an event with email', async () => { + const response: SendEventResponseSuccess = { + object: 'event', + event: 'user.created', + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + const data = await resend.events.send({ + event: 'user.created', + email: 'john@example.com', + }); + + expect(data).toMatchInlineSnapshot(` + { + "data": { + "event": "user.created", + "object": "event", + }, + "error": null, + "headers": { + "content-type": "application/json", + }, + } + `); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/events/send', + expect.objectContaining({ + method: 'POST', + headers: expect.any(Headers), + body: JSON.stringify({ + event: 'user.created', + email: 'john@example.com', + }), + }), + ); + }); + }); + + describe('create', () => { + it('creates an event', async () => { + const response: CreateEventResponseSuccess = { + object: 'event', + id: 'evt-123', + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + const data = await resend.events.create({ + name: 'user.created', + schema: { name: 'string', age: 'number' }, + }); + + expect(data).toMatchInlineSnapshot(` + { + "data": { + "id": "evt-123", + "object": "event", + }, + "error": null, + "headers": { + "content-type": "application/json", + }, + } + `); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/events', + expect.objectContaining({ + method: 'POST', + headers: expect.any(Headers), + body: JSON.stringify({ + name: 'user.created', + schema: { name: 'string', age: 'number' }, + }), + }), + ); + }); + + it('returns error on failure', async () => { + const response: ErrorResponse = { + name: 'missing_required_field', + statusCode: 422, + message: 'Missing `name` field.', + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 422, + headers: { + 'content-type': 'application/json', + }, + }); + + const data = await resend.events.create({ name: '' }); + expect(data).toMatchInlineSnapshot(` + { + "data": null, + "error": { + "message": "Missing \`name\` field.", + "name": "missing_required_field", + "statusCode": 422, + }, + "headers": { + "content-type": "application/json", + }, + } + `); + }); + }); + + describe('get', () => { + it('gets an event by id', async () => { + const response: GetEventResponseSuccess = { + object: 'event', + id: 'evt-123', + name: 'user.created', + schema: { name: 'string' }, + created_at: '2025-01-01T00:00:00.000Z', + updated_at: '2025-01-01T00:00:00.000Z', + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + const data = await resend.events.get('evt-123'); + expect(data).toMatchInlineSnapshot(` + { + "data": { + "created_at": "2025-01-01T00:00:00.000Z", + "id": "evt-123", + "name": "user.created", + "object": "event", + "schema": { + "name": "string", + }, + "updated_at": "2025-01-01T00:00:00.000Z", + }, + "error": null, + "headers": { + "content-type": "application/json", + }, + } + `); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/events/evt-123', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + + it('gets an event by name', async () => { + const response: GetEventResponseSuccess = { + object: 'event', + id: 'evt-123', + name: 'user.created', + schema: null, + created_at: '2025-01-01T00:00:00.000Z', + updated_at: '2025-01-01T00:00:00.000Z', + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + await resend.events.get('user.created'); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/events/user.created', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + }); + + describe('list', () => { + const response: ListEventsResponseSuccess = { + object: 'list', + has_more: false, + data: [ + { + id: 'evt-123', + name: 'user.created', + schema: null, + created_at: '2025-01-01T00:00:00.000Z', + updated_at: '2025-01-01T00:00:00.000Z', + }, + { + id: 'evt-456', + name: 'user.updated', + schema: null, + created_at: '2025-02-01T00:00:00.000Z', + updated_at: '2025-02-01T00:00:00.000Z', + }, + ], + }; + + it('lists events without pagination', async () => { + mockSuccessResponse(response, { + headers: {}, + }); + + const data = await resend.events.list(); + expect(data).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/events', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + + it('lists events with pagination', async () => { + mockSuccessResponse(response, { + headers: {}, + }); + + const data = await resend.events.list({ limit: 10, after: 'cursor-abc' }); + expect(data).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/events?limit=10&after=cursor-abc', + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers), + }), + ); + }); + }); + + describe('update', () => { + it('updates an event', async () => { + const response: UpdateEventResponseSuccess = { + object: 'event', + id: 'evt-123', + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + const data = await resend.events.update('evt-123', { + schema: { name: 'string', active: 'boolean' }, + }); + + expect(data).toMatchInlineSnapshot(` + { + "data": { + "id": "evt-123", + "object": "event", + }, + "error": null, + "headers": { + "content-type": "application/json", + }, + } + `); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/events/evt-123', + expect.objectContaining({ + method: 'PATCH', + headers: expect.any(Headers), + body: JSON.stringify({ + schema: { name: 'string', active: 'boolean' }, + }), + }), + ); + }); + }); + + describe('remove', () => { + it('removes an event', async () => { + const response: RemoveEventResponseSuccess = { + object: 'event', + id: 'evt-123', + deleted: true, + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + const data = await resend.events.remove('evt-123'); + expect(data).toMatchInlineSnapshot(` + { + "data": { + "deleted": true, + "id": "evt-123", + "object": "event", + }, + "error": null, + "headers": { + "content-type": "application/json", + }, + } + `); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.resend.com/events/evt-123', + expect.objectContaining({ + method: 'DELETE', + headers: expect.any(Headers), + }), + ); + }); + }); +}); diff --git a/src/events/events.ts b/src/events/events.ts new file mode 100644 index 00000000..a71a769b --- /dev/null +++ b/src/events/events.ts @@ -0,0 +1,86 @@ +import { buildPaginationQuery } from '../common/utils/build-pagination-query'; +import { parseEventToApiOptions } from '../common/utils/parse-automation-to-api-options'; +import type { Resend } from '../resend'; +import type { + CreateEventOptions, + CreateEventResponse, + CreateEventResponseSuccess, +} from './interfaces/create-event.interface'; +import type { + GetEventResponse, + GetEventResponseSuccess, +} from './interfaces/get-event.interface'; +import type { + ListEventsOptions, + ListEventsResponse, + ListEventsResponseSuccess, +} from './interfaces/list-events.interface'; +import type { + RemoveEventResponse, + RemoveEventResponseSuccess, +} from './interfaces/remove-event.interface'; +import type { + SendEventOptions, + SendEventResponse, + SendEventResponseSuccess, +} from './interfaces/send-event.interface'; +import type { + UpdateEventOptions, + UpdateEventResponse, + UpdateEventResponseSuccess, +} from './interfaces/update-event.interface'; + +export class Events { + constructor(private readonly resend: Resend) {} + + async send(payload: SendEventOptions): Promise { + const data = await this.resend.post( + '/events/send', + parseEventToApiOptions(payload), + ); + + return data; + } + + async create(payload: CreateEventOptions): Promise { + const data = await this.resend.post( + '/events', + payload, + ); + + return data; + } + + async get(identifier: string): Promise { + const data = await this.resend.get( + `/events/${encodeURIComponent(identifier)}`, + ); + return data; + } + + async list(options: ListEventsOptions = {}): Promise { + const queryString = buildPaginationQuery(options); + const url = queryString ? `/events?${queryString}` : '/events'; + + const data = await this.resend.get(url); + return data; + } + + async update( + identifier: string, + payload: UpdateEventOptions, + ): Promise { + const data = await this.resend.patch( + `/events/${encodeURIComponent(identifier)}`, + payload, + ); + return data; + } + + async remove(identifier: string): Promise { + const data = await this.resend.delete( + `/events/${encodeURIComponent(identifier)}`, + ); + return data; + } +} diff --git a/src/events/interfaces/create-event.interface.ts b/src/events/interfaces/create-event.interface.ts new file mode 100644 index 00000000..a85d2d22 --- /dev/null +++ b/src/events/interfaces/create-event.interface.ts @@ -0,0 +1,14 @@ +import type { Response } from '../../interfaces'; +import type { EventSchemaMap } from './event'; + +export interface CreateEventOptions { + name: string; + schema?: EventSchemaMap | null; +} + +export interface CreateEventResponseSuccess { + object: 'event'; + id: string; +} + +export type CreateEventResponse = Response; diff --git a/src/events/interfaces/event.ts b/src/events/interfaces/event.ts new file mode 100644 index 00000000..a15d2387 --- /dev/null +++ b/src/events/interfaces/event.ts @@ -0,0 +1,11 @@ +export type EventSchemaType = 'string' | 'number' | 'boolean' | 'date'; + +export type EventSchemaMap = Record; + +export interface Event { + id: string; + name: string; + schema: EventSchemaMap | null; + created_at: string; + updated_at: string | null; +} diff --git a/src/events/interfaces/get-event.interface.ts b/src/events/interfaces/get-event.interface.ts new file mode 100644 index 00000000..85c0ac6d --- /dev/null +++ b/src/events/interfaces/get-event.interface.ts @@ -0,0 +1,8 @@ +import type { Response } from '../../interfaces'; +import type { Event } from './event'; + +export interface GetEventResponseSuccess extends Event { + object: 'event'; +} + +export type GetEventResponse = Response; diff --git a/src/events/interfaces/index.ts b/src/events/interfaces/index.ts new file mode 100644 index 00000000..bb9950d2 --- /dev/null +++ b/src/events/interfaces/index.ts @@ -0,0 +1,7 @@ +export * from './create-event.interface'; +export * from './event'; +export * from './get-event.interface'; +export * from './list-events.interface'; +export * from './remove-event.interface'; +export * from './send-event.interface'; +export * from './update-event.interface'; diff --git a/src/events/interfaces/list-events.interface.ts b/src/events/interfaces/list-events.interface.ts new file mode 100644 index 00000000..c995e0bf --- /dev/null +++ b/src/events/interfaces/list-events.interface.ts @@ -0,0 +1,13 @@ +import type { PaginationOptions } from '../../common/interfaces'; +import type { Response } from '../../interfaces'; +import type { Event } from './event'; + +export type ListEventsOptions = PaginationOptions; + +export interface ListEventsResponseSuccess { + object: 'list'; + has_more: boolean; + data: Event[]; +} + +export type ListEventsResponse = Response; diff --git a/src/events/interfaces/remove-event.interface.ts b/src/events/interfaces/remove-event.interface.ts new file mode 100644 index 00000000..b5c35b4e --- /dev/null +++ b/src/events/interfaces/remove-event.interface.ts @@ -0,0 +1,9 @@ +import type { Response } from '../../interfaces'; +import type { Event } from './event'; + +export interface RemoveEventResponseSuccess extends Pick { + object: 'event'; + deleted: boolean; +} + +export type RemoveEventResponse = Response; diff --git a/src/events/interfaces/send-event.interface.ts b/src/events/interfaces/send-event.interface.ts new file mode 100644 index 00000000..d7cf8091 --- /dev/null +++ b/src/events/interfaces/send-event.interface.ts @@ -0,0 +1,22 @@ +import type { Response } from '../../interfaces'; + +export type SendEventOptions = + | { + event: string; + contactId: string; + email?: never; + payload?: Record; + } + | { + event: string; + email: string; + contactId?: never; + payload?: Record; + }; + +export interface SendEventResponseSuccess { + object: 'event'; + event: string; +} + +export type SendEventResponse = Response; diff --git a/src/events/interfaces/update-event.interface.ts b/src/events/interfaces/update-event.interface.ts new file mode 100644 index 00000000..a7ebe7f8 --- /dev/null +++ b/src/events/interfaces/update-event.interface.ts @@ -0,0 +1,13 @@ +import type { Response } from '../../interfaces'; +import type { EventSchemaMap } from './event'; + +export interface UpdateEventOptions { + schema: EventSchemaMap | null; +} + +export interface UpdateEventResponseSuccess { + object: 'event'; + id: string; +} + +export type UpdateEventResponse = Response; diff --git a/src/index.ts b/src/index.ts index d3e97719..7ff7d073 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,7 @@ export * from './api-keys/interfaces'; +export * from './automation-run-steps/interfaces'; +export * from './automation-runs/interfaces'; +export * from './automations/interfaces'; export * from './batch/interfaces'; export * from './broadcasts/interfaces'; export * from './common/interfaces'; @@ -10,6 +13,7 @@ export * from './domains/interfaces'; export * from './emails/attachments/interfaces'; export * from './emails/interfaces'; export * from './emails/receiving/interfaces'; +export * from './events/interfaces'; export type { ErrorResponse, Response } from './interfaces'; export { Resend } from './resend'; export * from './segments/interfaces'; diff --git a/src/resend.ts b/src/resend.ts index a19f3754..3986d7f4 100644 --- a/src/resend.ts +++ b/src/resend.ts @@ -1,5 +1,6 @@ import { version } from '../package.json'; import { ApiKeys } from './api-keys/api-keys'; +import { Automations } from './automations/automations'; import { Batch } from './batch/batch'; import { Broadcasts } from './broadcasts/broadcasts'; import type { GetOptions, PostOptions, PutOptions } from './common/interfaces'; @@ -9,6 +10,7 @@ import { ContactProperties } from './contact-properties/contact-properties'; import { Contacts } from './contacts/contacts'; import { Domains } from './domains/domains'; import { Emails } from './emails/emails'; +import { Events } from './events/events'; import type { ErrorResponse, Response } from './interfaces'; import { Segments } from './segments/segments'; import { Templates } from './templates/templates'; @@ -35,12 +37,14 @@ export class Resend { * @deprecated Use segments instead */ readonly audiences = this.segments; + readonly automations = new Automations(this); readonly batch = new Batch(this); readonly broadcasts = new Broadcasts(this); readonly contacts = new Contacts(this); readonly contactProperties = new ContactProperties(this); readonly domains = new Domains(this); readonly emails = new Emails(this); + readonly events = new Events(this); readonly webhooks = new Webhooks(this); readonly templates = new Templates(this); readonly topics = new Topics(this);