diff --git a/src/__tests__/groups.test.ts b/src/__tests__/groups.test.ts new file mode 100644 index 0000000..72129f7 --- /dev/null +++ b/src/__tests__/groups.test.ts @@ -0,0 +1,305 @@ +import { SmashSend } from '../index'; +import { HttpClient } from '../utils/http-client'; + +jest.mock('../utils/http-client'); +const MockedHttpClient = HttpClient as jest.MockedClass; + +describe('Groups API', () => { + let smashsend: SmashSend; + let mockHttpClient: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + smashsend = new SmashSend('test-api-key'); + mockHttpClient = MockedHttpClient.mock.instances[0] as jest.Mocked; + }); + + describe('create() method', () => { + it('should create a group with publicId only', async () => { + const mockResponse = { + group: { + id: 'grp_123abc', + publicId: 'company_123', + createdAt: '2024-01-15T10:30:00Z', + workspaceId: 'wks_456', + }, + }; + + mockHttpClient.post.mockResolvedValue(mockResponse); + + const result = await smashsend.groups.create({ + publicId: 'company_123', + }); + + expect(mockHttpClient.post).toHaveBeenCalledWith('/groups', { + publicId: 'company_123', + }); + expect(result).toEqual(mockResponse); + }); + + it('should create a group with displayName and traits', async () => { + const mockResponse = { + group: { + id: 'grp_123abc', + publicId: 'company_456', + displayName: 'Acme Corp', + traits: { + industry: 'technology', + employees: 500, + }, + createdAt: '2024-01-15T10:30:00Z', + workspaceId: 'wks_456', + }, + }; + + mockHttpClient.post.mockResolvedValue(mockResponse); + + const createData = { + publicId: 'company_456', + displayName: 'Acme Corp', + traits: { + industry: 'technology', + employees: 500, + }, + }; + + const result = await smashsend.groups.create(createData); + + expect(mockHttpClient.post).toHaveBeenCalledWith('/groups', createData); + expect(result).toEqual(mockResponse); + }); + }); + + describe('list() method', () => { + it('should list groups without parameters', async () => { + const mockResponse = { + cursor: null, + hasMore: false, + items: [ + { + id: 'grp_123', + publicId: 'company_123', + displayName: 'Acme Corp', + createdAt: '2024-01-15T10:30:00Z', + workspaceId: 'wks_456', + }, + { + id: 'grp_456', + publicId: 'company_456', + displayName: 'Beta Inc', + createdAt: '2024-01-16T10:30:00Z', + workspaceId: 'wks_456', + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await smashsend.groups.list(); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/groups', { params: undefined }); + expect(result).toEqual(mockResponse); + }); + + it('should list groups with pagination parameters', async () => { + const mockResponse = { + cursor: 'next_cursor_123', + hasMore: true, + items: [ + { + id: 'grp_123', + publicId: 'company_123', + displayName: 'Acme Corp', + createdAt: '2024-01-15T10:30:00Z', + workspaceId: 'wks_456', + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await smashsend.groups.list({ limit: 10, cursor: 'prev_cursor' }); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/groups', { + params: { limit: 10, cursor: 'prev_cursor' }, + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe('get() method', () => { + it('should get a group by ID', async () => { + const mockResponse = { + group: { + id: 'grp_123abc', + publicId: 'company_123', + displayName: 'Acme Corp', + traits: { industry: 'technology' }, + createdAt: '2024-01-15T10:30:00Z', + workspaceId: 'wks_456', + }, + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await smashsend.groups.get('grp_123abc'); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/groups/grp_123abc'); + expect(result).toEqual(mockResponse); + }); + }); + + describe('update() method', () => { + it('should update a group displayName', async () => { + const mockResponse = { + group: { + id: 'grp_123abc', + publicId: 'company_123', + displayName: 'Acme Corporation', + createdAt: '2024-01-15T10:30:00Z', + updatedAt: '2024-01-20T15:00:00Z', + workspaceId: 'wks_456', + }, + }; + + mockHttpClient.patch.mockResolvedValue(mockResponse); + + const result = await smashsend.groups.update('grp_123abc', { + displayName: 'Acme Corporation', + }); + + expect(mockHttpClient.patch).toHaveBeenCalledWith('/groups/grp_123abc', { + displayName: 'Acme Corporation', + }); + expect(result).toEqual(mockResponse); + }); + + it('should update a group traits', async () => { + const mockResponse = { + group: { + id: 'grp_123abc', + publicId: 'company_123', + displayName: 'Acme Corp', + traits: { employees: 600, revenue: '10M' }, + createdAt: '2024-01-15T10:30:00Z', + updatedAt: '2024-01-20T15:00:00Z', + workspaceId: 'wks_456', + }, + }; + + mockHttpClient.patch.mockResolvedValue(mockResponse); + + const result = await smashsend.groups.update('grp_123abc', { + traits: { employees: 600, revenue: '10M' }, + }); + + expect(mockHttpClient.patch).toHaveBeenCalledWith('/groups/grp_123abc', { + traits: { employees: 600, revenue: '10M' }, + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe('delete() method', () => { + it('should delete a group', async () => { + const mockResponse = { + group: { + id: 'grp_123abc', + publicId: 'company_123', + displayName: 'Acme Corp', + createdAt: '2024-01-15T10:30:00Z', + workspaceId: 'wks_456', + }, + isDeleted: true, + }; + + mockHttpClient.delete.mockResolvedValue(mockResponse); + + const result = await smashsend.groups.delete('grp_123abc'); + + expect(mockHttpClient.delete).toHaveBeenCalledWith('/groups/grp_123abc'); + expect(result).toEqual(mockResponse); + expect(result.isDeleted).toBe(true); + }); + }); + + describe('addContact() method', () => { + it('should add a contact to a group', async () => { + const mockResponse = { + groupId: 'grp_123abc', + contactId: 'ctc_456def', + addedAt: '2024-01-20T15:00:00Z', + }; + + mockHttpClient.post.mockResolvedValue(mockResponse); + + const result = await smashsend.groups.addContact('grp_123abc', 'ctc_456def'); + + expect(mockHttpClient.post).toHaveBeenCalledWith('/groups/grp_123abc/contacts', { + contactId: 'ctc_456def', + }); + expect(result).toEqual(mockResponse); + }); + }); + + describe('removeContact() method', () => { + it('should remove a contact from a group', async () => { + const mockResponse = { + groupId: 'grp_123abc', + contactId: 'ctc_456def', + isRemoved: true, + }; + + mockHttpClient.delete.mockResolvedValue(mockResponse); + + const result = await smashsend.groups.removeContact('grp_123abc', 'ctc_456def'); + + expect(mockHttpClient.delete).toHaveBeenCalledWith('/groups/grp_123abc/contacts/ctc_456def'); + expect(result).toEqual(mockResponse); + expect(result.isRemoved).toBe(true); + }); + }); + + describe('listContacts() method', () => { + it('should list contacts in a group without parameters', async () => { + const mockResponse = { + cursor: null, + hasMore: false, + items: [ + { contactId: 'ctc_123', addedAt: '2024-01-15T10:30:00Z' }, + { contactId: 'ctc_456', addedAt: '2024-01-16T10:30:00Z' }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await smashsend.groups.listContacts('grp_123abc'); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/groups/grp_123abc/contacts', { + params: undefined, + }); + expect(result).toEqual(mockResponse); + }); + + it('should list contacts in a group with pagination', async () => { + const mockResponse = { + cursor: 'next_cursor_456', + hasMore: true, + items: [{ contactId: 'ctc_789', addedAt: '2024-01-17T10:30:00Z' }], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await smashsend.groups.listContacts('grp_123abc', { + limit: 5, + cursor: 'prev_cursor', + }); + + expect(mockHttpClient.get).toHaveBeenCalledWith('/groups/grp_123abc/contacts', { + params: { limit: 5, cursor: 'prev_cursor' }, + }); + expect(result).toEqual(mockResponse); + }); + }); +}); + diff --git a/src/api/contacts.ts b/src/api/contacts.ts index 9aa9b6d..2c90140 100644 --- a/src/api/contacts.ts +++ b/src/api/contacts.ts @@ -35,6 +35,27 @@ export class Contacts { return response.contact; } + /** + * Create or update a contact by email. If a contact with the given email + * already exists, its properties will be updated; otherwise a new contact + * is created. Ideal for signup flows where you don't know the contact ID yet. + * + * @param options The contact upsert options (email required) + * @returns The created or updated contact + * + * @example + * ```typescript + * const contact = await smashsend.contacts.upsert({ + * email: 'user@example.com', + * firstName: 'Jane', + * customProperties: { plan: 'pro' }, + * }); + * ``` + */ + async upsert(options: ContactCreateOptions): Promise { + return this.create(options); + } + /** * Create multiple contacts in a single batch operation * diff --git a/src/api/groups.ts b/src/api/groups.ts new file mode 100644 index 0000000..4a36fca --- /dev/null +++ b/src/api/groups.ts @@ -0,0 +1,181 @@ +import { HttpClient } from '../utils/http-client'; +import { + Group, + GroupCreateOptions, + GroupUpdateOptions, + GroupListOptions, + GroupListResponse, + GroupContactListOptions, + GroupContactListResponse, + GroupCreateResponse, + GroupGetResponse, + GroupDeleteResponse, + GroupAddContactResponse, + GroupRemoveContactResponse, +} from '../interfaces/groups'; + +export class Groups { + private httpClient: HttpClient; + + constructor(httpClient: HttpClient) { + this.httpClient = httpClient; + } + + /** + * Create a new group + * @param data Group creation options + * @returns The created group + * + * @example + * ```typescript + * const response = await smashsend.groups.create({ + * publicId: 'company_123', + * displayName: 'Acme Corp', + * traits: { + * industry: 'technology', + * employees: 500 + * } + * }); + * + * console.log(`Group created: ${response.group.id}`); + * ``` + */ + async create(data: GroupCreateOptions): Promise { + return await this.httpClient.post('/groups', data); + } + + /** + * List all groups with optional pagination + * @param params Pagination parameters + * @returns List of groups + * + * @example + * ```typescript + * const response = await smashsend.groups.list({ limit: 10 }); + * + * response.items.forEach(group => { + * console.log(`${group.displayName}: ${group.publicId}`); + * }); + * + * if (response.hasMore) { + * const nextPage = await smashsend.groups.list({ cursor: response.cursor }); + * } + * ``` + */ + async list(params?: GroupListOptions): Promise { + return await this.httpClient.get('/groups', { params }); + } + + /** + * Get a group by ID + * @param groupId The group ID + * @returns The group + * + * @example + * ```typescript + * const response = await smashsend.groups.get('grp_123abc'); + * console.log(response.group.displayName); + * ``` + */ + async get(groupId: string): Promise { + return await this.httpClient.get(`/groups/${groupId}`); + } + + /** + * Update a group + * @param groupId The group ID + * @param data Update options + * @returns The updated group + * + * @example + * ```typescript + * const response = await smashsend.groups.update('grp_123abc', { + * displayName: 'Acme Corporation', + * traits: { employees: 600 } + * }); + * ``` + */ + async update(groupId: string, data: GroupUpdateOptions): Promise { + return await this.httpClient.patch(`/groups/${groupId}`, data); + } + + /** + * Delete a group + * @param groupId The group ID + * @returns Deletion confirmation + * + * @example + * ```typescript + * const response = await smashsend.groups.delete('grp_123abc'); + * if (response.isDeleted) { + * console.log('Group deleted successfully'); + * } + * ``` + */ + async delete(groupId: string): Promise { + return await this.httpClient.delete(`/groups/${groupId}`); + } + + /** + * Add a contact to a group + * @param groupId The group ID + * @param contactId The contact ID + * @returns Confirmation of contact addition + * + * @example + * ```typescript + * const response = await smashsend.groups.addContact('grp_123abc', 'ctc_456def'); + * console.log(`Contact added at ${response.addedAt}`); + * ``` + */ + async addContact(groupId: string, contactId: string): Promise { + return await this.httpClient.post(`/groups/${groupId}/contacts`, { + contactId, + }); + } + + /** + * Remove a contact from a group + * @param groupId The group ID + * @param contactId The contact ID + * @returns Confirmation of contact removal + * + * @example + * ```typescript + * const response = await smashsend.groups.removeContact('grp_123abc', 'ctc_456def'); + * if (response.isRemoved) { + * console.log('Contact removed from group'); + * } + * ``` + */ + async removeContact(groupId: string, contactId: string): Promise { + return await this.httpClient.delete( + `/groups/${groupId}/contacts/${contactId}` + ); + } + + /** + * List contacts in a group + * @param groupId The group ID + * @param params Pagination parameters + * @returns List of contacts in the group + * + * @example + * ```typescript + * const response = await smashsend.groups.listContacts('grp_123abc', { limit: 20 }); + * + * response.items.forEach(item => { + * console.log(`Contact ${item.contactId} added at ${item.addedAt}`); + * }); + * ``` + */ + async listContacts( + groupId: string, + params?: GroupContactListOptions + ): Promise { + return await this.httpClient.get(`/groups/${groupId}/contacts`, { + params, + }); + } +} + diff --git a/src/index.ts b/src/index.ts index db8f79c..16f0dbd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { Webhooks } from './api/webhooks'; import { ApiKeys } from './api/api-keys'; import { Domains } from './api/domains'; import { Events } from './api/events'; +import { Groups } from './api/groups'; import { HttpClient } from './utils/http-client'; import { SmashSendClientOptions } from './interfaces/types'; import { @@ -46,6 +47,11 @@ export class SmashSend { */ public readonly events: Events; + /** + * The Groups API resource + */ + public readonly groups: Groups; + private httpClient: HttpClient; /** @@ -76,6 +82,7 @@ export class SmashSend { this.domains = new Domains(this.httpClient); this.emails = new Emails(this.httpClient); this.events = new Events(this.httpClient); + this.groups = new Groups(this.httpClient); this.webhooks = new Webhooks(this.httpClient); } @@ -124,6 +131,21 @@ export class SmashSend { // Export types and errors export type { SmashSendClientOptions } from './interfaces/types'; +export type { + Group, + GroupCreateOptions, + GroupUpdateOptions, + GroupListOptions, + GroupListResponse, + GroupContactListOptions, + GroupContactListResponse, + GroupCreateResponse, + GroupGetResponse, + GroupDeleteResponse, + GroupAddContactResponse, + GroupRemoveContactResponse, + GroupTraits, +} from './interfaces/groups'; export type { RawEmailSendOptions, TemplatedEmailSendOptions, diff --git a/src/interfaces/groups.ts b/src/interfaces/groups.ts new file mode 100644 index 0000000..38df173 --- /dev/null +++ b/src/interfaces/groups.ts @@ -0,0 +1,75 @@ +export interface GroupTraits { + [key: string]: any; +} + +export interface Group { + id: string; + publicId: string; + displayName?: string; + traits?: GroupTraits; + createdAt: string; + updatedAt?: string; + workspaceId: string; +} + +export interface GroupCreateOptions { + publicId: string; + displayName?: string; + traits?: GroupTraits; +} + +export interface GroupUpdateOptions { + displayName?: string; + traits?: GroupTraits; +} + +export interface GroupListOptions { + limit?: number; + cursor?: string; +} + +export interface GroupListResponse { + cursor: string | null; + hasMore: boolean; + items: Group[]; +} + +export interface GroupContactListOptions { + limit?: number; + cursor?: string; +} + +export interface GroupContactListResponse { + cursor: string | null; + hasMore: boolean; + items: Array<{ + contactId: string; + addedAt: string; + }>; +} + +export interface GroupCreateResponse { + group: Group; +} + +export interface GroupGetResponse { + group: Group; +} + +export interface GroupDeleteResponse { + group: Group; + isDeleted: boolean; +} + +export interface GroupAddContactResponse { + groupId: string; + contactId: string; + addedAt: string; +} + +export interface GroupRemoveContactResponse { + groupId: string; + contactId: string; + isRemoved: boolean; +} +