From 1be7fbaba627445b105febadc9a4cb54de9baad3 Mon Sep 17 00:00:00 2001 From: voj-tech-j Date: Mon, 25 May 2026 13:29:50 +0200 Subject: [PATCH 1/2] feat(audience): add audience SDK routes --- packages/lettr/src/audience-contacts.ts | 133 ++++ packages/lettr/src/audience-lists.ts | 66 ++ packages/lettr/src/audience-properties.ts | 58 ++ packages/lettr/src/audience-segments.ts | 56 ++ packages/lettr/src/audience-topics.ts | 56 ++ packages/lettr/src/audience.test.ts | 699 ++++++++++++++++++++++ packages/lettr/src/audience.ts | 22 + packages/lettr/src/client.test.ts | 6 + packages/lettr/src/client.ts | 3 + packages/lettr/src/index.ts | 52 ++ packages/lettr/src/types.ts | 278 +++++++++ 11 files changed, 1429 insertions(+) create mode 100644 packages/lettr/src/audience-contacts.ts create mode 100644 packages/lettr/src/audience-lists.ts create mode 100644 packages/lettr/src/audience-properties.ts create mode 100644 packages/lettr/src/audience-segments.ts create mode 100644 packages/lettr/src/audience-topics.ts create mode 100644 packages/lettr/src/audience.test.ts create mode 100644 packages/lettr/src/audience.ts diff --git a/packages/lettr/src/audience-contacts.ts b/packages/lettr/src/audience-contacts.ts new file mode 100644 index 0000000..44b514a --- /dev/null +++ b/packages/lettr/src/audience-contacts.ts @@ -0,0 +1,133 @@ +import type { HttpClient } from "./http"; +import type { + AudienceContact, + BulkAttachContactsListsData, + BulkAudienceContactListsRequest, + BulkCreateAudienceContactsData, + BulkCreateAudienceContactsRequest, + BulkDetachContactsListsData, + CreateAudienceContactRequest, + ListAudienceContactsData, + ListAudienceContactsParams, + Result, + UpdateAudienceContactRequest, +} from "./types"; + +export class AudienceContacts { + constructor(private http: HttpClient) {} + + async list( + params?: ListAudienceContactsParams + ): Promise> { + return this.http.request( + "GET", + "/audience/contacts", + { query: params as Record } + ); + } + + async create( + data: CreateAudienceContactRequest + ): Promise> { + return this.http.request("POST", "/audience/contacts", { + body: data, + }); + } + + async bulkCreate( + data: BulkCreateAudienceContactsRequest + ): Promise> { + return this.http.request( + "POST", + "/audience/contacts/bulk", + { body: data } + ); + } + + async get(contactId: string): Promise> { + return this.http.request( + "GET", + `/audience/contacts/${encodeURIComponent(contactId)}` + ); + } + + async update( + contactId: string, + data: UpdateAudienceContactRequest + ): Promise> { + return this.http.request( + "PATCH", + `/audience/contacts/${encodeURIComponent(contactId)}`, + { body: data } + ); + } + + async delete(contactId: string): Promise> { + return this.http.request( + "DELETE", + `/audience/contacts/${encodeURIComponent(contactId)}` + ); + } + + async attachList( + contactId: string, + listId: string + ): Promise> { + return this.http.request<{ message: string }>( + "POST", + `/audience/contacts/${encodeURIComponent(contactId)}/lists/${encodeURIComponent(listId)}`, + { unwrap: false } + ); + } + + async detachList( + contactId: string, + listId: string + ): Promise> { + return this.http.request( + "DELETE", + `/audience/contacts/${encodeURIComponent(contactId)}/lists/${encodeURIComponent(listId)}` + ); + } + + async bulkAttachLists( + data: BulkAudienceContactListsRequest + ): Promise> { + return this.http.request( + "POST", + "/audience/contacts/lists/bulk", + { body: data } + ); + } + + async bulkDetachLists( + data: BulkAudienceContactListsRequest + ): Promise> { + return this.http.request( + "DELETE", + "/audience/contacts/lists/bulk", + { body: data } + ); + } + + async subscribeTopic( + contactId: string, + topicId: string + ): Promise> { + return this.http.request<{ message: string }>( + "POST", + `/audience/contacts/${encodeURIComponent(contactId)}/topics/${encodeURIComponent(topicId)}`, + { unwrap: false } + ); + } + + async unsubscribeTopic( + contactId: string, + topicId: string + ): Promise> { + return this.http.request( + "DELETE", + `/audience/contacts/${encodeURIComponent(contactId)}/topics/${encodeURIComponent(topicId)}` + ); + } +} diff --git a/packages/lettr/src/audience-lists.ts b/packages/lettr/src/audience-lists.ts new file mode 100644 index 0000000..00661ee --- /dev/null +++ b/packages/lettr/src/audience-lists.ts @@ -0,0 +1,66 @@ +import type { HttpClient } from "./http"; +import type { + AudienceList, + BulkDeleteAudienceListsData, + BulkDeleteAudienceListsRequest, + CreateAudienceListRequest, + ListAudienceListsData, + ListAudienceListsParams, + Result, + UpdateAudienceListRequest, +} from "./types"; + +export class AudienceLists { + constructor(private http: HttpClient) {} + + async list( + params?: ListAudienceListsParams + ): Promise> { + return this.http.request("GET", "/audience/lists", { + query: params as Record, + }); + } + + async create( + data: CreateAudienceListRequest + ): Promise> { + return this.http.request("POST", "/audience/lists", { + body: data, + }); + } + + async get(listId: string): Promise> { + return this.http.request( + "GET", + `/audience/lists/${encodeURIComponent(listId)}` + ); + } + + async update( + listId: string, + data: UpdateAudienceListRequest + ): Promise> { + return this.http.request( + "PATCH", + `/audience/lists/${encodeURIComponent(listId)}`, + { body: data } + ); + } + + async delete(listId: string): Promise> { + return this.http.request( + "DELETE", + `/audience/lists/${encodeURIComponent(listId)}` + ); + } + + async bulkDelete( + data: BulkDeleteAudienceListsRequest + ): Promise> { + return this.http.request( + "DELETE", + "/audience/lists/bulk", + { body: data } + ); + } +} diff --git a/packages/lettr/src/audience-properties.ts b/packages/lettr/src/audience-properties.ts new file mode 100644 index 0000000..9d6435d --- /dev/null +++ b/packages/lettr/src/audience-properties.ts @@ -0,0 +1,58 @@ +import type { HttpClient } from "./http"; +import type { + AudienceProperty, + CreateAudiencePropertyRequest, + ListAudiencePropertiesData, + ListAudiencePropertiesParams, + Result, + UpdateAudiencePropertyRequest, +} from "./types"; + +export class AudienceProperties { + constructor(private http: HttpClient) {} + + async list( + params?: ListAudiencePropertiesParams + ): Promise> { + return this.http.request( + "GET", + "/audience/properties", + { query: params as Record } + ); + } + + async create( + data: CreateAudiencePropertyRequest + ): Promise> { + return this.http.request( + "POST", + "/audience/properties", + { body: data } + ); + } + + async get(propertyId: string): Promise> { + return this.http.request( + "GET", + `/audience/properties/${encodeURIComponent(propertyId)}` + ); + } + + async update( + propertyId: string, + data: UpdateAudiencePropertyRequest + ): Promise> { + return this.http.request( + "PATCH", + `/audience/properties/${encodeURIComponent(propertyId)}`, + { body: data } + ); + } + + async delete(propertyId: string): Promise> { + return this.http.request( + "DELETE", + `/audience/properties/${encodeURIComponent(propertyId)}` + ); + } +} diff --git a/packages/lettr/src/audience-segments.ts b/packages/lettr/src/audience-segments.ts new file mode 100644 index 0000000..fd75833 --- /dev/null +++ b/packages/lettr/src/audience-segments.ts @@ -0,0 +1,56 @@ +import type { HttpClient } from "./http"; +import type { + AudienceSegment, + CreateAudienceSegmentRequest, + ListAudienceSegmentsData, + ListAudienceSegmentsParams, + Result, + UpdateAudienceSegmentRequest, +} from "./types"; + +export class AudienceSegments { + constructor(private http: HttpClient) {} + + async list( + params?: ListAudienceSegmentsParams + ): Promise> { + return this.http.request( + "GET", + "/audience/segments", + { query: params as Record } + ); + } + + async create( + data: CreateAudienceSegmentRequest + ): Promise> { + return this.http.request("POST", "/audience/segments", { + body: data, + }); + } + + async get(segmentId: string): Promise> { + return this.http.request( + "GET", + `/audience/segments/${encodeURIComponent(segmentId)}` + ); + } + + async update( + segmentId: string, + data: UpdateAudienceSegmentRequest + ): Promise> { + return this.http.request( + "PATCH", + `/audience/segments/${encodeURIComponent(segmentId)}`, + { body: data } + ); + } + + async delete(segmentId: string): Promise> { + return this.http.request( + "DELETE", + `/audience/segments/${encodeURIComponent(segmentId)}` + ); + } +} diff --git a/packages/lettr/src/audience-topics.ts b/packages/lettr/src/audience-topics.ts new file mode 100644 index 0000000..ff6ff44 --- /dev/null +++ b/packages/lettr/src/audience-topics.ts @@ -0,0 +1,56 @@ +import type { HttpClient } from "./http"; +import type { + AudienceTopic, + CreateAudienceTopicRequest, + ListAudienceTopicsData, + ListAudienceTopicsParams, + Result, + UpdateAudienceTopicRequest, +} from "./types"; + +export class AudienceTopics { + constructor(private http: HttpClient) {} + + async list( + params?: ListAudienceTopicsParams + ): Promise> { + return this.http.request( + "GET", + "/audience/topics", + { query: params as Record } + ); + } + + async create( + data: CreateAudienceTopicRequest + ): Promise> { + return this.http.request("POST", "/audience/topics", { + body: data, + }); + } + + async get(topicId: string): Promise> { + return this.http.request( + "GET", + `/audience/topics/${encodeURIComponent(topicId)}` + ); + } + + async update( + topicId: string, + data: UpdateAudienceTopicRequest + ): Promise> { + return this.http.request( + "PATCH", + `/audience/topics/${encodeURIComponent(topicId)}`, + { body: data } + ); + } + + async delete(topicId: string): Promise> { + return this.http.request( + "DELETE", + `/audience/topics/${encodeURIComponent(topicId)}` + ); + } +} diff --git a/packages/lettr/src/audience.test.ts b/packages/lettr/src/audience.test.ts new file mode 100644 index 0000000..4252f4a --- /dev/null +++ b/packages/lettr/src/audience.test.ts @@ -0,0 +1,699 @@ +import { describe, it, expect, beforeEach, mock } from "bun:test"; +import { Lettr } from "./client"; +import type { + AudienceContact, + AudienceList, + AudienceProperty, + AudienceSegment, + AudienceTopic, + AudiencePagination, +} from "./types"; + +const mockFetch = mock(); +globalThis.fetch = mockFetch as unknown as typeof fetch; + +const pagination: AudiencePagination = { + total: 1, + per_page: 20, + current_page: 1, + last_page: 1, +}; + +const listData: AudienceList = { + id: "0193e6a8-1f3a-7c2a-b9e2-1aa1d2e5d3f0", + name: "Newsletter subscribers", + contacts_count: 12, +}; + +const contactData: AudienceContact = { + id: "0193e6b0-9c1d-7d4f-a8f1-cef9a1b2d3e4", + email: "jane@example.com", + status: "subscribed", + properties: { first_name: "Jane" }, + created_at: "2024-01-15T10:30:00Z", + lists: [], + topics: [], +}; + +const topicData: AudienceTopic = { + id: "0193e6c0-1111-7c2a-b9e2-1aa1d2e5d3f0", + name: "Product updates", + description: null, + default_subscription: "opt_in", + visibility: "private", + contacts_count: 0, + created_at: "2024-01-15T10:30:00Z", +}; + +const propertyData: AudienceProperty = { + id: "0193e6d0-2222-7c2a-b9e2-1aa1d2e5d3f0", + name: "first_name", + type: "string", + fallback_value: "Friend", + created_at: "2024-01-15T10:30:00Z", +}; + +const segmentData: AudienceSegment = { + id: "0193e6e0-3333-7c2a-b9e2-1aa1d2e5d3f0", + name: "Engaged subscribers", + list_id: null, + list_name: null, + condition_groups: [ + { + conditions: [{ field: "email", operator: "contains", value: "@example.com" }], + }, + ], + cached_contacts_count: 0, + created_at: "2024-01-15T10:30:00Z", +}; + +describe("Audience.Lists", () => { + beforeEach(() => mockFetch.mockReset()); + + it("lists with pagination params", async () => { + const responseData = { lists: [listData], pagination }; + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ message: "Lists retrieved.", data: responseData }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.lists.list({ page: 1, per_page: 20 }); + + expect(result.data).toEqual(responseData); + expect(result.error).toBeNull(); + const calledUrl = mockFetch.mock.calls[0]![0] as string; + expect(calledUrl).toBe( + "https://app.lettr.com/api/audience/lists?page=1&per_page=20" + ); + }); + + it("returns api error on 401", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ message: "API key is required." }), + }); + + const client = new Lettr("bad-key"); + const result = await client.audience.lists.list(); + + expect(result.data).toBeNull(); + expect(result.error).toEqual({ + type: "api", + message: "API key is required.", + error_code: "unauthorized", + }); + }); + + it("creates a list", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ message: "Created.", data: listData }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.lists.create({ name: "Newsletter subscribers" }); + + expect(result.data).toEqual(listData); + const [, init] = mockFetch.mock.calls[0]!; + expect(init.method).toBe("POST"); + const body = JSON.parse(init.body as string); + expect(body.name).toBe("Newsletter subscribers"); + }); + + it("returns validation error on create 422", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 422, + json: async () => ({ + message: "Validation failed.", + errors: { name: ["The name field is required."] }, + }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.lists.create({ name: "" }); + + expect(result.data).toBeNull(); + expect(result.error).toEqual({ + type: "validation", + message: "Validation failed.", + errors: { name: ["The name field is required."] }, + }); + }); + + it("gets a list", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ message: "Retrieved.", data: listData }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.lists.get(listData.id); + + expect(result.data).toEqual(listData); + const calledUrl = mockFetch.mock.calls[0]![0] as string; + expect(calledUrl).toBe( + `https://app.lettr.com/api/audience/lists/${listData.id}` + ); + }); + + it("updates a list with PATCH", async () => { + const updated = { ...listData, name: "Renamed" }; + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ message: "Updated.", data: updated }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.lists.update(listData.id, { name: "Renamed" }); + + expect(result.data).toEqual(updated); + const [, init] = mockFetch.mock.calls[0]!; + expect(init.method).toBe("PATCH"); + }); + + it("deletes a list returning 204", async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 204, json: async () => ({}) }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.lists.delete(listData.id); + + expect(result.data).toBeUndefined(); + expect(result.error).toBeNull(); + const [, init] = mockFetch.mock.calls[0]!; + expect(init.method).toBe("DELETE"); + }); + + it("returns 404 on delete", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({ message: "List not found.", error_code: "not_found" }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.lists.delete("nonexistent"); + + expect(result.data).toBeNull(); + expect(result.error).toEqual({ + type: "api", + message: "List not found.", + error_code: "not_found", + }); + }); + + it("bulk deletes lists", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ message: "Deleted.", data: { deleted: 2 } }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.lists.bulkDelete({ + list_ids: [listData.id, "0193e6a8-2a4b-7d1c-a3f5-2bb2e3f6e4a1"], + }); + + expect(result.data).toEqual({ deleted: 2 }); + const calledUrl = mockFetch.mock.calls[0]![0] as string; + expect(calledUrl).toBe("https://app.lettr.com/api/audience/lists/bulk"); + const [, init] = mockFetch.mock.calls[0]!; + expect(init.method).toBe("DELETE"); + const body = JSON.parse(init.body as string); + expect(body.list_ids).toHaveLength(2); + }); +}); + +describe("Audience.Contacts", () => { + beforeEach(() => mockFetch.mockReset()); + + it("lists contacts with filters", async () => { + const responseData = { contacts: [contactData], pagination }; + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ message: "Retrieved.", data: responseData }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.contacts.list({ + search: "jane", + status: "subscribed", + list_id: listData.id, + }); + + expect(result.data).toEqual(responseData); + const calledUrl = mockFetch.mock.calls[0]![0] as string; + expect(calledUrl).toContain("search=jane"); + expect(calledUrl).toContain("status=subscribed"); + expect(calledUrl).toContain(`list_id=${listData.id}`); + }); + + it("creates a contact", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ message: "Created.", data: contactData }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.contacts.create({ + email: "jane@example.com", + list_id: listData.id, + properties: { first_name: "Jane" }, + }); + + expect(result.data).toEqual(contactData); + const body = JSON.parse(mockFetch.mock.calls[0]![1].body as string); + expect(body.email).toBe("jane@example.com"); + expect(body.list_id).toBe(listData.id); + }); + + it("creates a contact with double opt-in", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ + message: "Created.", + data: { ...contactData, status: "unverified" }, + }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.contacts.create({ + email: "jane@example.com", + double_opt_in: { + from: "no-reply@example.com", + subject: "Confirm subscription", + template_slug: "double-opt-in", + redirect_url: "https://example.com/confirmed", + }, + }); + + expect(result.data!.status).toBe("unverified"); + const body = JSON.parse(mockFetch.mock.calls[0]![1].body as string); + expect(body.double_opt_in.template_slug).toBe("double-opt-in"); + }); + + it("bulk creates contacts", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ + message: "Created.", + data: { created: 2, already_existed: 0 }, + }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.contacts.bulkCreate({ + emails: ["jane@example.com", "joe@example.com"], + }); + + expect(result.data).toEqual({ created: 2, already_existed: 0 }); + const calledUrl = mockFetch.mock.calls[0]![0] as string; + expect(calledUrl).toBe("https://app.lettr.com/api/audience/contacts/bulk"); + }); + + it("updates a contact with PATCH", async () => { + const updated = { ...contactData, status: "unsubscribed" as const }; + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ message: "Updated.", data: updated }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.contacts.update(contactData.id, { + status: "unsubscribed", + }); + + expect(result.data!.status).toBe("unsubscribed"); + const [, init] = mockFetch.mock.calls[0]!; + expect(init.method).toBe("PATCH"); + }); + + it("returns 404 when updating missing contact", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({ message: "Contact not found.", error_code: "not_found" }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.contacts.update("nonexistent", { email: "x@y.com" }); + + expect(result.data).toBeNull(); + expect(result.error).toEqual({ + type: "api", + message: "Contact not found.", + error_code: "not_found", + }); + }); + + it("attaches contact to list (message-only response)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ message: "Contact attached to list." }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.contacts.attachList( + contactData.id, + listData.id + ); + + expect(result.data).toEqual({ message: "Contact attached to list." }); + const calledUrl = mockFetch.mock.calls[0]![0] as string; + expect(calledUrl).toBe( + `https://app.lettr.com/api/audience/contacts/${contactData.id}/lists/${listData.id}` + ); + const [, init] = mockFetch.mock.calls[0]!; + expect(init.method).toBe("POST"); + }); + + it("detaches contact from list (204)", async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 204, json: async () => ({}) }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.contacts.detachList( + contactData.id, + listData.id + ); + + expect(result.data).toBeUndefined(); + expect(result.error).toBeNull(); + }); + + it("bulk attaches contacts to lists", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + message: "Attached.", + data: { attached: 2, already_attached: 0, total_pairs: 2 }, + }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.contacts.bulkAttachLists({ + contact_ids: [contactData.id], + list_ids: [listData.id, "0193e6a8-2a4b-7d1c-a3f5-2bb2e3f6e4a1"], + }); + + expect(result.data).toEqual({ attached: 2, already_attached: 0, total_pairs: 2 }); + const [, init] = mockFetch.mock.calls[0]!; + expect(init.method).toBe("POST"); + }); + + it("bulk detaches contacts from lists", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + message: "Detached.", + data: { detached: 1, not_present: 1, total_pairs: 2 }, + }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.contacts.bulkDetachLists({ + contact_ids: [contactData.id], + list_ids: [listData.id, "0193e6a8-2a4b-7d1c-a3f5-2bb2e3f6e4a1"], + }); + + expect(result.data).toEqual({ detached: 1, not_present: 1, total_pairs: 2 }); + const [, init] = mockFetch.mock.calls[0]!; + expect(init.method).toBe("DELETE"); + }); + + it("subscribes contact to topic", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ message: "Contact subscribed to topic." }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.contacts.subscribeTopic( + contactData.id, + topicData.id + ); + + expect(result.data).toEqual({ message: "Contact subscribed to topic." }); + }); + + it("unsubscribes contact from topic (204)", async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 204, json: async () => ({}) }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.contacts.unsubscribeTopic( + contactData.id, + topicData.id + ); + + expect(result.data).toBeUndefined(); + expect(result.error).toBeNull(); + }); +}); + +describe("Audience.Topics", () => { + beforeEach(() => mockFetch.mockReset()); + + it("lists topics", async () => { + const responseData = { topics: [topicData], pagination }; + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ message: "Retrieved.", data: responseData }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.topics.list(); + + expect(result.data).toEqual(responseData); + }); + + it("creates a topic", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ message: "Created.", data: topicData }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.topics.create({ + name: "Product updates", + default_subscription: "opt_in", + }); + + expect(result.data).toEqual(topicData); + }); + + it("gets, updates with PATCH, deletes a topic", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ message: "Retrieved.", data: topicData }), + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ message: "Updated.", data: { ...topicData, name: "Renamed" } }), + }); + mockFetch.mockResolvedValueOnce({ ok: true, status: 204, json: async () => ({}) }); + + const client = new Lettr("test-api-key"); + expect((await client.audience.topics.get(topicData.id)).data).toEqual(topicData); + + const updated = await client.audience.topics.update(topicData.id, { name: "Renamed" }); + expect(updated.data!.name).toBe("Renamed"); + expect(mockFetch.mock.calls[1]![1].method).toBe("PATCH"); + + const del = await client.audience.topics.delete(topicData.id); + expect(del.data).toBeUndefined(); + expect(del.error).toBeNull(); + }); +}); + +describe("Audience.Properties", () => { + beforeEach(() => mockFetch.mockReset()); + + it("lists properties", async () => { + const responseData = { properties: [propertyData], pagination }; + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ message: "Retrieved.", data: responseData }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.properties.list(); + + expect(result.data).toEqual(responseData); + }); + + it("creates a property", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ message: "Created.", data: propertyData }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.properties.create({ + name: "first_name", + type: "string", + fallback_value: "Friend", + }); + + expect(result.data).toEqual(propertyData); + }); + + it("returns 422 on invalid property name", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 422, + json: async () => ({ + message: "Validation failed.", + errors: { name: ["The name format is invalid."] }, + }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.properties.create({ + name: "InvalidName", + type: "string", + }); + + expect(result.data).toBeNull(); + expect(result.error).toEqual({ + type: "validation", + message: "Validation failed.", + errors: { name: ["The name format is invalid."] }, + }); + }); + + it("updates fallback_value with PATCH and deletes", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + message: "Updated.", + data: { ...propertyData, fallback_value: "Hi" }, + }), + }); + mockFetch.mockResolvedValueOnce({ ok: true, status: 204, json: async () => ({}) }); + + const client = new Lettr("test-api-key"); + const updated = await client.audience.properties.update(propertyData.id, { + fallback_value: "Hi", + }); + expect(updated.data!.fallback_value).toBe("Hi"); + expect(mockFetch.mock.calls[0]![1].method).toBe("PATCH"); + + const del = await client.audience.properties.delete(propertyData.id); + expect(del.data).toBeUndefined(); + }); +}); + +describe("Audience.Segments", () => { + beforeEach(() => mockFetch.mockReset()); + + it("lists segments filtered by list_id", async () => { + const responseData = { segments: [segmentData], pagination }; + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ message: "Retrieved.", data: responseData }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.segments.list({ list_id: listData.id }); + + expect(result.data).toEqual(responseData); + const calledUrl = mockFetch.mock.calls[0]![0] as string; + expect(calledUrl).toContain(`list_id=${listData.id}`); + }); + + it("creates a segment with conditions", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ message: "Created.", data: segmentData }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.segments.create({ + name: "Engaged subscribers", + conditions: { + groups: [ + { + conditions: [ + { field: "email", operator: "contains", value: "@example.com" }, + ], + }, + ], + }, + }); + + expect(result.data).toEqual(segmentData); + const body = JSON.parse(mockFetch.mock.calls[0]![1].body as string); + expect(body.conditions.groups[0].conditions[0].operator).toBe("contains"); + }); + + it("gets, updates with PATCH, deletes a segment", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ message: "Retrieved.", data: segmentData }), + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + message: "Updated.", + data: { ...segmentData, name: "Renamed" }, + }), + }); + mockFetch.mockResolvedValueOnce({ ok: true, status: 204, json: async () => ({}) }); + + const client = new Lettr("test-api-key"); + expect((await client.audience.segments.get(segmentData.id)).data).toEqual(segmentData); + + const updated = await client.audience.segments.update(segmentData.id, { + name: "Renamed", + }); + expect(updated.data!.name).toBe("Renamed"); + expect(mockFetch.mock.calls[1]![1].method).toBe("PATCH"); + + const del = await client.audience.segments.delete(segmentData.id); + expect(del.data).toBeUndefined(); + expect(del.error).toBeNull(); + }); + + it("returns 404 on missing segment", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({ message: "Segment not found.", error_code: "not_found" }), + }); + + const client = new Lettr("test-api-key"); + const result = await client.audience.segments.get("nonexistent"); + + expect(result.data).toBeNull(); + expect(result.error).toEqual({ + type: "api", + message: "Segment not found.", + error_code: "not_found", + }); + }); +}); diff --git a/packages/lettr/src/audience.ts b/packages/lettr/src/audience.ts new file mode 100644 index 0000000..da955ec --- /dev/null +++ b/packages/lettr/src/audience.ts @@ -0,0 +1,22 @@ +import type { HttpClient } from "./http"; +import { AudienceLists } from "./audience-lists"; +import { AudienceContacts } from "./audience-contacts"; +import { AudienceTopics } from "./audience-topics"; +import { AudienceProperties } from "./audience-properties"; +import { AudienceSegments } from "./audience-segments"; + +export class Audience { + public readonly lists: AudienceLists; + public readonly contacts: AudienceContacts; + public readonly topics: AudienceTopics; + public readonly properties: AudienceProperties; + public readonly segments: AudienceSegments; + + constructor(http: HttpClient) { + this.lists = new AudienceLists(http); + this.contacts = new AudienceContacts(http); + this.topics = new AudienceTopics(http); + this.properties = new AudienceProperties(http); + this.segments = new AudienceSegments(http); + } +} diff --git a/packages/lettr/src/client.test.ts b/packages/lettr/src/client.test.ts index fa3b897..05f485f 100644 --- a/packages/lettr/src/client.test.ts +++ b/packages/lettr/src/client.test.ts @@ -16,6 +16,12 @@ describe("Lettr", () => { expect(client.templates).toBeDefined(); expect(client.webhooks).toBeDefined(); expect(client.projects).toBeDefined(); + expect(client.audience).toBeDefined(); + expect(client.audience.lists).toBeDefined(); + expect(client.audience.contacts).toBeDefined(); + expect(client.audience.topics).toBeDefined(); + expect(client.audience.properties).toBeDefined(); + expect(client.audience.segments).toBeDefined(); }); describe("health", () => { diff --git a/packages/lettr/src/client.ts b/packages/lettr/src/client.ts index c77afb6..b902b6b 100644 --- a/packages/lettr/src/client.ts +++ b/packages/lettr/src/client.ts @@ -4,6 +4,7 @@ import { Domains } from "./domains"; import { Templates } from "./templates"; import { Webhooks } from "./webhooks"; import { Projects } from "./projects"; +import { Audience } from "./audience"; import type { HealthResponse, AuthCheckResponse, Result } from "./types"; const BASE_URL = "https://app.lettr.com/api"; @@ -14,6 +15,7 @@ export class Lettr { public readonly templates: Templates; public readonly webhooks: Webhooks; public readonly projects: Projects; + public readonly audience: Audience; private http: HttpClient; @@ -24,6 +26,7 @@ export class Lettr { this.templates = new Templates(this.http); this.webhooks = new Webhooks(this.http); this.projects = new Projects(this.http); + this.audience = new Audience(this.http); } async health(): Promise> { diff --git a/packages/lettr/src/index.ts b/packages/lettr/src/index.ts index e4f8eda..dbc8885 100644 --- a/packages/lettr/src/index.ts +++ b/packages/lettr/src/index.ts @@ -4,6 +4,12 @@ export { Domains } from "./domains"; export { Templates } from "./templates"; export { Webhooks } from "./webhooks"; export { Projects } from "./projects"; +export { Audience } from "./audience"; +export { AudienceLists } from "./audience-lists"; +export { AudienceContacts } from "./audience-contacts"; +export { AudienceTopics } from "./audience-topics"; +export { AudienceProperties } from "./audience-properties"; +export { AudienceSegments } from "./audience-segments"; export type { // Shared LettrError, @@ -88,6 +94,52 @@ export type { ListProjectsParams, ListProjectsResponse, + // Audience + AudiencePagination, + AudienceContactStatus, + AudienceList, + ListAudienceListsParams, + ListAudienceListsData, + CreateAudienceListRequest, + UpdateAudienceListRequest, + BulkDeleteAudienceListsRequest, + BulkDeleteAudienceListsData, + AudienceContact, + AudienceContactListLink, + AudienceContactTopicLink, + ListAudienceContactsParams, + ListAudienceContactsData, + DoubleOptInConfig, + CreateAudienceContactRequest, + UpdateAudienceContactRequest, + BulkCreateAudienceContactsRequest, + BulkCreateAudienceContactsData, + BulkAudienceContactListsRequest, + BulkAttachContactsListsData, + BulkDetachContactsListsData, + AudienceTopic, + AudienceTopicVisibility, + AudienceTopicDefaultSubscription, + ListAudienceTopicsParams, + ListAudienceTopicsData, + CreateAudienceTopicRequest, + UpdateAudienceTopicRequest, + AudienceProperty, + AudiencePropertyType, + ListAudiencePropertiesParams, + ListAudiencePropertiesData, + CreateAudiencePropertyRequest, + UpdateAudiencePropertyRequest, + AudienceSegment, + SegmentOperator, + SegmentCondition, + SegmentConditionGroup, + SegmentConditionsInput, + ListAudienceSegmentsParams, + ListAudienceSegmentsData, + CreateAudienceSegmentRequest, + UpdateAudienceSegmentRequest, + // System HealthResponse, AuthCheckResponse, diff --git a/packages/lettr/src/types.ts b/packages/lettr/src/types.ts index e21d725..fe59767 100644 --- a/packages/lettr/src/types.ts +++ b/packages/lettr/src/types.ts @@ -680,6 +680,284 @@ export interface ListProjectsResponse { }; } +// ---------- Audience ---------- + +export interface AudiencePagination { + total: number; + per_page: number; + current_page: number; + last_page: number; +} + +export type AudienceContactStatus = + | "subscribed" + | "unsubscribed" + | "bounced" + | "complained" + | "unverified"; + +// Audience: Lists + +export interface AudienceList { + id: string; + name: string; + contacts_count: number; +} + +export interface ListAudienceListsParams { + page?: number; + per_page?: number; +} + +export interface ListAudienceListsData { + lists: AudienceList[]; + pagination: AudiencePagination; +} + +export interface CreateAudienceListRequest { + name: string; +} + +export interface UpdateAudienceListRequest { + name?: string; +} + +export interface BulkDeleteAudienceListsRequest { + list_ids: string[]; +} + +export interface BulkDeleteAudienceListsData { + deleted: number; +} + +// Audience: Contacts + +export interface AudienceContactListLink { + id: string; + name: string; +} + +export interface AudienceContactTopicLink { + id: string; + name: string; +} + +export interface AudienceContact { + id: string; + email: string; + status: AudienceContactStatus; + properties: Record; + created_at: string; + lists: AudienceContactListLink[]; + topics: AudienceContactTopicLink[]; +} + +export interface ListAudienceContactsParams { + page?: number; + per_page?: number; + search?: string; + status?: AudienceContactStatus; + list_id?: string; + segment_id?: string; +} + +export interface ListAudienceContactsData { + contacts: AudienceContact[]; + pagination: AudiencePagination; +} + +export interface DoubleOptInConfig { + from: string; + from_name?: string | null; + subject: string; + template_slug: string; + redirect_url: string; +} + +export interface CreateAudienceContactRequest { + email: string; + list_id?: string | null; + properties?: Record | null; + double_opt_in?: DoubleOptInConfig; +} + +export interface UpdateAudienceContactRequest { + email?: string; + status?: "subscribed" | "unsubscribed"; + properties?: Record; +} + +export interface BulkCreateAudienceContactsRequest { + emails: string[]; + list_id?: string | null; + properties?: Record | null; +} + +export interface BulkCreateAudienceContactsData { + created: number; + already_existed: number; +} + +export interface BulkAudienceContactListsRequest { + contact_ids: string[]; + list_ids: string[]; +} + +export interface BulkAttachContactsListsData { + attached: number; + already_attached: number; + total_pairs: number; +} + +export interface BulkDetachContactsListsData { + detached: number; + not_present: number; + total_pairs: number; +} + +// Audience: Topics + +export type AudienceTopicVisibility = "private" | "public"; + +export type AudienceTopicDefaultSubscription = "opt_in" | "opt_out"; + +export interface AudienceTopic { + id: string; + name: string; + description: string | null; + default_subscription: AudienceTopicDefaultSubscription; + visibility: AudienceTopicVisibility; + contacts_count: number; + created_at: string | null; +} + +export interface ListAudienceTopicsParams { + page?: number; + per_page?: number; +} + +export interface ListAudienceTopicsData { + topics: AudienceTopic[]; + pagination: AudiencePagination; +} + +export interface CreateAudienceTopicRequest { + name: string; + description?: string | null; + default_subscription?: AudienceTopicDefaultSubscription; + visibility?: AudienceTopicVisibility; +} + +export interface UpdateAudienceTopicRequest { + name?: string; + description?: string | null; + visibility?: AudienceTopicVisibility; +} + +// Audience: Properties + +export type AudiencePropertyType = + | "string" + | "number" + | "boolean" + | "date" + | "json"; + +export interface AudienceProperty { + id: string; + name: string; + type: AudiencePropertyType; + fallback_value: string | null; + created_at: string; +} + +export interface ListAudiencePropertiesParams { + page?: number; + per_page?: number; +} + +export interface ListAudiencePropertiesData { + properties: AudienceProperty[]; + pagination: AudiencePagination; +} + +export interface CreateAudiencePropertyRequest { + name: string; + type: AudiencePropertyType; + fallback_value?: string | null; +} + +export interface UpdateAudiencePropertyRequest { + fallback_value?: string | null; +} + +// Audience: Segments + +export type SegmentOperator = + | "contains" + | "not_contains" + | "equals" + | "not_equals" + | "starts_with" + | "not_starts_with" + | "ends_with" + | "not_ends_with" + | "is_true" + | "is_false" + | "greater_than" + | "greater_than_or_equal" + | "less_than" + | "less_than_or_equal" + | "before" + | "after"; + +export interface SegmentCondition { + field: string; + operator: SegmentOperator; + value?: string | null; +} + +export interface SegmentConditionGroup { + conditions: SegmentCondition[]; +} + +export interface SegmentConditionsInput { + groups: SegmentConditionGroup[]; +} + +export interface AudienceSegment { + id: string; + name: string; + list_id: string | null; + list_name: string | null; + condition_groups: SegmentConditionGroup[]; + cached_contacts_count: number | null; + created_at: string; +} + +export interface ListAudienceSegmentsParams { + page?: number; + per_page?: number; + list_id?: string; +} + +export interface ListAudienceSegmentsData { + segments: AudienceSegment[]; + pagination: AudiencePagination; +} + +export interface CreateAudienceSegmentRequest { + name: string; + list_id?: string | null; + conditions: SegmentConditionsInput; +} + +export interface UpdateAudienceSegmentRequest { + name?: string; + list_id?: string | null; + conditions?: SegmentConditionsInput; +} + // ---------- System ---------- export interface HealthResponse { From 8f9d3787db2df51f38b596d97dedefde6f646e55 Mon Sep 17 00:00:00 2001 From: voj-tech-j Date: Mon, 25 May 2026 13:34:33 +0200 Subject: [PATCH 2/2] chore: add changeset for audience routes --- .changeset/brave-lizards-stare.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/brave-lizards-stare.md diff --git a/.changeset/brave-lizards-stare.md b/.changeset/brave-lizards-stare.md new file mode 100644 index 0000000..01e63bf --- /dev/null +++ b/.changeset/brave-lizards-stare.md @@ -0,0 +1,5 @@ +--- +"lettr": minor +--- + +Add /audience routes (lists, contacts, topics, properties, segments) under client.audience