From b938e0656ac34fca90c15b236c9a040e94eee477 Mon Sep 17 00:00:00 2001 From: David Stockley Date: Tue, 10 Feb 2026 11:48:03 +0000 Subject: [PATCH 01/10] feat: add contactBooks to sdk, add delete campaign public endpoint --- .../campaigns/delete-campaign.mdx | 3 + apps/docs/api-reference/openapi.json | 175 ++++++++ apps/docs/docs.json | 3 +- .../api/campaigns/delete-campaign.ts | 45 ++ apps/web/src/server/public-api/index.ts | 2 + packages/sdk/index.ts | 1 + packages/sdk/src/campaign.ts | 46 +- packages/sdk/src/contactBook.ts | 97 +++++ packages/sdk/src/usesend.ts | 2 + packages/sdk/types/schema.d.ts | 403 +++++++++++++++++- 10 files changed, 771 insertions(+), 6 deletions(-) create mode 100644 apps/docs/api-reference/campaigns/delete-campaign.mdx create mode 100644 apps/web/src/server/public-api/api/campaigns/delete-campaign.ts create mode 100644 packages/sdk/src/contactBook.ts diff --git a/apps/docs/api-reference/campaigns/delete-campaign.mdx b/apps/docs/api-reference/campaigns/delete-campaign.mdx new file mode 100644 index 00000000..d86e0e99 --- /dev/null +++ b/apps/docs/api-reference/campaigns/delete-campaign.mdx @@ -0,0 +1,3 @@ +--- +openapi: delete /v1/campaigns/{campaignId} +--- diff --git a/apps/docs/api-reference/openapi.json b/apps/docs/api-reference/openapi.json index 65f74a46..34346993 100644 --- a/apps/docs/api-reference/openapi.json +++ b/apps/docs/api-reference/openapi.json @@ -1707,6 +1707,92 @@ } }, "/v1/campaigns": { + "get": { + "parameters": [ + { + "schema": { "type": "string", "default": "1", "example": "1" }, + "required": false, + "name": "page", + "in": "query", + "description": "Page number for pagination (default: 1)" + }, + { + "schema": { + "type": "string", + "enum": [ + "DRAFT", + "SCHEDULED", + "IN_PROGRESS", + "PAUSED", + "COMPLETED", + "CANCELLED" + ], + "example": "DRAFT" + }, + "required": false, + "name": "status", + "in": "query", + "description": "Filter campaigns by status" + }, + { + "schema": { "type": "string", "example": "newsletter" }, + "required": false, + "name": "search", + "in": "query", + "description": "Search campaigns by name or subject" + } + ], + "responses": { + "200": { + "description": "Get list of campaigns", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "campaigns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "from": { "type": "string" }, + "subject": { "type": "string" }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" }, + "status": { "type": "string" }, + "scheduledAt": { "type": "string", "nullable": true, "format": "date-time" }, + "total": { "type": "integer" }, + "sent": { "type": "integer" }, + "delivered": { "type": "integer" }, + "unsubscribed": { "type": "integer" } + }, + "required": [ + "id", + "name", + "from", + "subject", + "createdAt", + "updatedAt", + "status", + "scheduledAt", + "total", + "sent", + "delivered", + "unsubscribed" + ] + } + }, + "totalPage": { "type": "integer" } + }, + "required": ["campaigns", "totalPage"] + } + } + } + } + } + }, "post": { "requestBody": { "required": true, @@ -1931,6 +2017,95 @@ } } } + }, + "delete": { + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "example": "cmp_123" + }, + "required": true, + "name": "campaignId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Delete campaign", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "from": { "type": "string" }, + "subject": { "type": "string" }, + "previewText": { "type": "string", "nullable": true }, + "contactBookId": { "type": "string", "nullable": true }, + "html": { "type": "string", "nullable": true }, + "content": { "type": "string", "nullable": true }, + "status": { "type": "string" }, + "scheduledAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "batchSize": { "type": "integer" }, + "batchWindowMinutes": { "type": "integer" }, + "total": { "type": "integer" }, + "sent": { "type": "integer" }, + "delivered": { "type": "integer" }, + "opened": { "type": "integer" }, + "clicked": { "type": "integer" }, + "unsubscribed": { "type": "integer" }, + "bounced": { "type": "integer" }, + "hardBounced": { "type": "integer" }, + "complained": { "type": "integer" }, + "replyTo": { + "type": "array", + "items": { "type": "string" } + }, + "cc": { "type": "array", "items": { "type": "string" } }, + "bcc": { "type": "array", "items": { "type": "string" } }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + }, + "required": [ + "id", + "name", + "from", + "subject", + "previewText", + "contactBookId", + "html", + "content", + "status", + "scheduledAt", + "batchSize", + "batchWindowMinutes", + "total", + "sent", + "delivered", + "opened", + "clicked", + "unsubscribed", + "bounced", + "hardBounced", + "complained", + "replyTo", + "cc", + "bcc", + "createdAt", + "updatedAt" + ] + } + } + } + } + } } }, "/v1/campaigns/{campaignId}/schedule": { diff --git a/apps/docs/docs.json b/apps/docs/docs.json index bb54345d..558318e4 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -103,7 +103,8 @@ "api-reference/campaigns/get-campaign", "api-reference/campaigns/schedule-campaign", "api-reference/campaigns/pause-campaign", - "api-reference/campaigns/resume-campaign" + "api-reference/campaigns/resume-campaign", + "api-reference/campaigns/delete-campaign" ] } ] diff --git a/apps/web/src/server/public-api/api/campaigns/delete-campaign.ts b/apps/web/src/server/public-api/api/campaigns/delete-campaign.ts new file mode 100644 index 00000000..b90188ee --- /dev/null +++ b/apps/web/src/server/public-api/api/campaigns/delete-campaign.ts @@ -0,0 +1,45 @@ +import { createRoute, z } from "@hono/zod-openapi"; +import { PublicAPIApp } from "~/server/public-api/hono"; +import { deleteCampaign as deleteCamapaignService } from "~/server/service/campaign-service"; +import { campaignResponseSchema } from "~/server/public-api/schemas/campaign-schema"; + +const route = createRoute({ + method: "delete", + path: "/v1/campaigns/{campaignId}", + request: { + params: z.object({ + campaignId: z + .string() + .min(1) + .openapi({ + param: { + name: "campaignId", + in: "path", + }, + example: "cmp_123", + }), + }), + }, + responses: { + 200: { + description: "Delete campaign", + content: { + "application/json": { + schema: campaignResponseSchema, + }, + }, + }, + }, +}); + +function deleteCampaign(app: PublicAPIApp) { + app.openapi(route, async (c) => { + const campaignId = c.req.param("campaignId"); + + const campaign = await deleteCamapaignService(campaignId); + + return c.json(campaign); + }); +} + +export default deleteCampaign; diff --git a/apps/web/src/server/public-api/index.ts b/apps/web/src/server/public-api/index.ts index 0a57bbb9..9aff5584 100644 --- a/apps/web/src/server/public-api/index.ts +++ b/apps/web/src/server/public-api/index.ts @@ -18,6 +18,7 @@ import deleteDomain from "./api/domains/delete-domain"; import sendBatch from "./api/emails/batch-email"; import createCampaign from "./api/campaigns/create-campaign"; import getCampaign from "./api/campaigns/get-campaign"; +import deleteCampaign from "./api/campaigns/delete-campaign"; import getCampaigns from "./api/campaigns/get-campaigns"; import scheduleCampaign from "./api/campaigns/schedule-campaign"; import pauseCampaign from "./api/campaigns/pause-campaign"; @@ -67,5 +68,6 @@ getCampaigns(app); scheduleCampaign(app); pauseCampaign(app); resumeCampaign(app); +deleteCampaign(app); export default app; diff --git a/packages/sdk/index.ts b/packages/sdk/index.ts index e4482443..5195f700 100644 --- a/packages/sdk/index.ts +++ b/packages/sdk/index.ts @@ -1,6 +1,7 @@ export { UseSend } from "./src/usesend"; export { UseSend as Unsend } from "./src/usesend"; // deprecated alias export { Campaigns } from "./src/campaign"; +export { ContactBooks } from "./src/contactBook"; export { Webhooks, WebhookVerificationError, diff --git a/packages/sdk/src/campaign.ts b/packages/sdk/src/campaign.ts index 39dbca46..3f5bdf56 100644 --- a/packages/sdk/src/campaign.ts +++ b/packages/sdk/src/campaign.ts @@ -11,7 +11,21 @@ type CreateCampaignResponse = { }; type CreateCampaignResponseSuccess = - paths["/v1/campaigns"]["post"]["responses"]["200"]["content"]["application/json"]; + paths["/v1/campaigns"]["post"]["responses"]["200"]["content"]["application/json"]; + +type GetAllCampaignsQuery = { + page?: string; + status?: string; + search?: string; +}; + +type GetAllCampaignsResponseSuccess = + paths["/v1/campaigns"]["get"]["responses"]["200"]["content"]["application/json"]; + +type GetAllCampaignsResponse = { + data: GetAllCampaignsResponseSuccess | null; + error: ErrorResponse | null; +}; type GetCampaignResponseSuccess = paths["/v1/campaigns/{campaignId}"]["get"]["responses"]["200"]["content"]["application/json"]; @@ -32,6 +46,14 @@ type ScheduleCampaignResponse = { error: ErrorResponse | null; }; +type DeleteCampaignResponseSuccess = + paths["/v1/campaigns/{campaignId}"]["delete"]["responses"]["200"]["content"]["application/json"]; + +type DeleteCampaignResponse = { + data: DeleteCampaignResponseSuccess | null; + error: ErrorResponse | null; +}; + type CampaignActionResponseSuccess = { success: boolean }; type CampaignActionResponse = { @@ -55,6 +77,21 @@ export class Campaigns { return data; } + async getAll( + query?: GetAllCampaignsQuery, + ): Promise { + const params = new URLSearchParams(); + if (query?.page) params.set("page", query.page); + if (query?.status) params.set("status", query.status); + if (query?.search) params.set("search", query.search); + + const queryString = params.toString(); + const path = queryString ? `/campaigns?${queryString}` : `/campaigns`; + + const data = await this.usesend.get(path); + return data; + } + async get(campaignId: string): Promise { const data = await this.usesend.get( `/campaigns/${campaignId}`, @@ -91,4 +128,11 @@ export class Campaigns { return data; } + + async delete(campaignId: string): Promise { + const data = await this.usesend.delete( + `/campaigns/${campaignId}`, + ); + return data; + } } diff --git a/packages/sdk/src/contactBook.ts b/packages/sdk/src/contactBook.ts new file mode 100644 index 00000000..98242ff9 --- /dev/null +++ b/packages/sdk/src/contactBook.ts @@ -0,0 +1,97 @@ +import { UseSend } from "./usesend"; +import { paths } from "../types/schema"; +import { ErrorResponse } from "../types"; + +type GetAllContactBooksResponseSuccess = + paths["/v1/contactBooks"]["get"]["responses"]["200"]["content"]["application/json"]; + +type GetAllContactBooksResponse = { + data: GetAllContactBooksResponseSuccess | null; + error: ErrorResponse | null; +}; + +type CreateContactBookPayload = + paths["/v1/contactBooks"]["post"]["requestBody"]["content"]["application/json"]; + +type CreateContactBookResponseSuccess = + paths["/v1/contactBooks"]["post"]["responses"]["200"]["content"]["application/json"]; + +type CreateContactBookResponse = { + data: CreateContactBookResponseSuccess | null; + error: ErrorResponse | null; +}; + +type GetContactBookResponseSuccess = + paths["/v1/contactBooks/{contactBookId}"]["get"]["responses"]["200"]["content"]["application/json"]; + +type GetContactBookResponse = { + data: GetContactBookResponseSuccess | null; + error: ErrorResponse | null; +}; + +type UpdateContactBookPayload = + paths["/v1/contactBooks/{contactBookId}"]["patch"]["requestBody"]["content"]["application/json"]; + +type UpdateContactBookResponseSuccess = + paths["/v1/contactBooks/{contactBookId}"]["patch"]["responses"]["200"]["content"]["application/json"]; + +type UpdateContactBookResponse = { + data: UpdateContactBookResponseSuccess | null; + error: ErrorResponse | null; +}; + +type DeleteContactBookResponseSuccess = + paths["/v1/contactBooks/{contactBookId}"]["delete"]["responses"]["200"]["content"]["application/json"]; + +type DeleteContactBookResponse = { + data: DeleteContactBookResponseSuccess | null; + error: ErrorResponse | null; +}; + +export class ContactBooks { + constructor(private readonly usesend: UseSend) { + this.usesend = usesend; + } + + async list(): Promise { + const data = await this.usesend.get( + `/contactBooks`, + ); + return data; + } + + async get(contactBookId: string): Promise { + const data = await this.usesend.get( + `/contactBooks/${contactBookId}`, + ); + return data; + } + + async create( + payload: CreateContactBookPayload, + ): Promise { + const data = await this.usesend.post( + `/contactBooks`, + payload, + ); + return data; + } + + async update( + contactBookId: string, + payload: UpdateContactBookPayload, + ): Promise { + const data = await this.usesend.patch( + `/contactBooks/${contactBookId}`, + payload, + ); + return data; + } + + async delete(contactBookId: string): Promise { + const data = await this.usesend.delete( + `/contactBooks/${contactBookId}`, + ); + return data; + } +} diff --git a/packages/sdk/src/usesend.ts b/packages/sdk/src/usesend.ts index 682c5966..438712f9 100644 --- a/packages/sdk/src/usesend.ts +++ b/packages/sdk/src/usesend.ts @@ -1,5 +1,6 @@ import { ErrorResponse } from "../types"; import { Contacts } from "./contact"; +import { ContactBooks } from "./contactBook"; import { Emails } from "./email"; import { Domains } from "./domain"; import { Campaigns } from "./campaign"; @@ -23,6 +24,7 @@ export class UseSend { readonly emails = new Emails(this); readonly domains = new Domains(this); readonly contacts = new Contacts(this); + readonly contactBooks = new ContactBooks(this); readonly campaigns = new Campaigns(this); url = baseUrl; diff --git a/packages/sdk/types/schema.d.ts b/packages/sdk/types/schema.d.ts index 6ef77a9c..1b700521 100644 --- a/packages/sdk/types/schema.d.ts +++ b/packages/sdk/types/schema.d.ts @@ -561,7 +561,9 @@ export interface paths { post: { parameters: { query?: never; - header?: never; + header?: { + "Idempotency-Key"?: string; + }; path?: never; cookie?: never; }; @@ -628,7 +630,9 @@ export interface paths { post: { parameters: { query?: never; - header?: never; + header?: { + "Idempotency-Key"?: string; + }; path?: never; cookie?: never; }; @@ -724,6 +728,300 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/contactBooks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Retrieve contact books accessible by the API key */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description The ID of the contact book + * @example clx1234567890 + */ + id: string; + /** + * @description The name of the contact book + * @example Newsletter Subscribers + */ + name: string; + /** + * @description The ID of the team + * @example 1 + */ + teamId: number; + /** + * @description Custom properties for the contact book + * @example { + * "customField1": "value1" + * } + */ + properties: { + [key: string]: string; + }; + /** + * @description The emoji associated with the contact book + * @example 📙 + */ + emoji: string; + /** @description The creation timestamp */ + createdAt: string; + /** @description The last update timestamp */ + updatedAt: string; + _count?: { + /** @description The number of contacts in the contact book */ + contacts?: number; + }; + }[]; + }; + }; + }; + }; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + emoji?: string; + properties?: { + [key: string]: string; + }; + }; + }; + }; + responses: { + /** @description Create a new contact book */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + teamId: number; + properties: { + [key: string]: string; + }; + emoji: string; + createdAt: string; + updatedAt: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/contactBooks/{contactBookId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + contactBookId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Retrieve the contact book */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + teamId: number; + properties: { + [key: string]: string; + }; + emoji: string; + createdAt: string; + updatedAt: string; + _count?: { + contacts?: number; + }; + }; + }; + }; + /** @description Forbidden - API key doesn't have access */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Contact book not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete: { + parameters: { + query?: never; + header?: never; + path: { + contactBookId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Contact book deleted successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + success: boolean; + message: string; + }; + }; + }; + /** @description Forbidden - API key doesn't have access */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Contact book not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + options?: never; + head?: never; + patch: { + parameters: { + query?: never; + header?: never; + path: { + contactBookId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name?: string; + emoji?: string; + properties?: { + [key: string]: string; + }; + }; + }; + }; + responses: { + /** @description Update the contact book */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + teamId: number; + properties: { + [key: string]: string; + }; + emoji: string; + createdAt: string; + updatedAt: string; + }; + }; + }; + /** @description Forbidden - API key doesn't have access */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Contact book not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + trace?: never; + }; "/v1/contactBooks/{contactBookId}/contacts": { parameters: { query?: never; @@ -863,6 +1161,7 @@ export interface paths { header?: never; path: { contactBookId: string; + contactId: string; }; cookie?: never; }; @@ -966,7 +1265,52 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + get: { + parameters: { + query?: { + /** @description Page number for pagination (default: 1) */ + page?: string; + /** @description Filter campaigns by status */ + status?: "DRAFT" | "SCHEDULED" | "IN_PROGRESS" | "PAUSED" | "COMPLETED" | "CANCELLED"; + /** @description Search campaigns by name or subject */ + search?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Get list of campaigns */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + campaigns: { + id: string; + name: string; + from: string; + subject: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + status: string; + /** Format: date-time */ + scheduledAt: string | null; + total: number; + sent: number; + delivered: number; + unsubscribed: number; + }[]; + totalPage: number; + }; + }; + }; + }; + }; put?: never; post: { parameters: { @@ -1104,7 +1448,58 @@ export interface paths { }; put?: never; post?: never; - delete?: never; + delete: { + parameters: { + query?: never; + header?: never; + path: { + campaignId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Delete campaign */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + name: string; + from: string; + subject: string; + previewText: string | null; + contactBookId: string | null; + html: string | null; + content: string | null; + status: string; + /** Format: date-time */ + scheduledAt: string | null; + batchSize: number; + batchWindowMinutes: number; + total: number; + sent: number; + delivered: number; + opened: number; + clicked: number; + unsubscribed: number; + bounced: number; + hardBounced: number; + complained: number; + replyTo: string[]; + cc: string[]; + bcc: string[]; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }; + }; + }; + }; + }; options?: never; head?: never; patch?: never; From 2686ba0c7b7fc9fc7628849fea69086ab02489f4 Mon Sep 17 00:00:00 2001 From: David Stockley Date: Tue, 10 Feb 2026 12:09:34 +0000 Subject: [PATCH 02/10] fix: pr review notes --- apps/docs/api-reference/openapi.json | 36 +++++++------------ .../api/campaigns/delete-campaign.ts | 4 +-- packages/sdk/src/campaign.ts | 2 +- packages/sdk/types/schema.d.ts | 4 ++- 4 files changed, 18 insertions(+), 28 deletions(-) diff --git a/apps/docs/api-reference/openapi.json b/apps/docs/api-reference/openapi.json index 34346993..07d3bcd6 100644 --- a/apps/docs/api-reference/openapi.json +++ b/apps/docs/api-reference/openapi.json @@ -1929,19 +1929,19 @@ } }, "/v1/campaigns/{campaignId}": { + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "example": "cmp_123" + }, + "required": true, + "name": "campaignId", + "in": "path" + } + ], "get": { - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1, - "example": "cmp_123" - }, - "required": true, - "name": "campaignId", - "in": "path" - } - ], "responses": { "200": { "description": "Get campaign details", @@ -2019,18 +2019,6 @@ } }, "delete": { - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1, - "example": "cmp_123" - }, - "required": true, - "name": "campaignId", - "in": "path" - } - ], "responses": { "200": { "description": "Delete campaign", diff --git a/apps/web/src/server/public-api/api/campaigns/delete-campaign.ts b/apps/web/src/server/public-api/api/campaigns/delete-campaign.ts index b90188ee..40e2bade 100644 --- a/apps/web/src/server/public-api/api/campaigns/delete-campaign.ts +++ b/apps/web/src/server/public-api/api/campaigns/delete-campaign.ts @@ -1,6 +1,6 @@ import { createRoute, z } from "@hono/zod-openapi"; import { PublicAPIApp } from "~/server/public-api/hono"; -import { deleteCampaign as deleteCamapaignService } from "~/server/service/campaign-service"; +import { deleteCampaign as deleteCampaignService } from "~/server/service/campaign-service"; import { campaignResponseSchema } from "~/server/public-api/schemas/campaign-schema"; const route = createRoute({ @@ -36,7 +36,7 @@ function deleteCampaign(app: PublicAPIApp) { app.openapi(route, async (c) => { const campaignId = c.req.param("campaignId"); - const campaign = await deleteCamapaignService(campaignId); + const campaign = await deleteCampaignService(campaignId); return c.json(campaign); }); diff --git a/packages/sdk/src/campaign.ts b/packages/sdk/src/campaign.ts index 3f5bdf56..0f48fba4 100644 --- a/packages/sdk/src/campaign.ts +++ b/packages/sdk/src/campaign.ts @@ -15,7 +15,7 @@ type CreateCampaignResponseSuccess = type GetAllCampaignsQuery = { page?: string; - status?: string; + status?: NonNullable["status"]; search?: string; }; diff --git a/packages/sdk/types/schema.d.ts b/packages/sdk/types/schema.d.ts index 1b700521..ea9e24af 100644 --- a/packages/sdk/types/schema.d.ts +++ b/packages/sdk/types/schema.d.ts @@ -1391,7 +1391,9 @@ export interface paths { parameters: { query?: never; header?: never; - path?: never; + path: { + campaignId: string; + }; cookie?: never; }; get: { From 1f35769c9af09941bb5cf80fe4314f225988ae47 Mon Sep 17 00:00:00 2001 From: David Stockley Date: Tue, 10 Feb 2026 12:24:43 +0000 Subject: [PATCH 03/10] refactor: pr feedback --- apps/docs/api-reference/openapi.json | 2 +- packages/sdk/src/campaign.ts | 5 ++--- packages/sdk/types/schema.d.ts | 3 ++- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/docs/api-reference/openapi.json b/apps/docs/api-reference/openapi.json index 07d3bcd6..b00691d0 100644 --- a/apps/docs/api-reference/openapi.json +++ b/apps/docs/api-reference/openapi.json @@ -1761,7 +1761,7 @@ "subject": { "type": "string" }, "createdAt": { "type": "string", "format": "date-time" }, "updatedAt": { "type": "string", "format": "date-time" }, - "status": { "type": "string" }, + "status": { "type": "string", "enum": ["DRAFT", "SCHEDULED", "IN_PROGRESS", "PAUSED", "COMPLETED", "CANCELLED"] }, "scheduledAt": { "type": "string", "nullable": true, "format": "date-time" }, "total": { "type": "integer" }, "sent": { "type": "integer" }, diff --git a/packages/sdk/src/campaign.ts b/packages/sdk/src/campaign.ts index 0f48fba4..2e7ae6f8 100644 --- a/packages/sdk/src/campaign.ts +++ b/packages/sdk/src/campaign.ts @@ -11,7 +11,7 @@ type CreateCampaignResponse = { }; type CreateCampaignResponseSuccess = - paths["/v1/campaigns"]["post"]["responses"]["200"]["content"]["application/json"]; + paths["/v1/campaigns"]["post"]["responses"]["200"]["content"]["application/json"]; type GetAllCampaignsQuery = { page?: string; @@ -19,8 +19,7 @@ type GetAllCampaignsQuery = { search?: string; }; -type GetAllCampaignsResponseSuccess = - paths["/v1/campaigns"]["get"]["responses"]["200"]["content"]["application/json"]; +type GetAllCampaignsResponseSuccess = paths["/v1/campaigns"]["get"]["responses"]["200"]["content"]["application/json"]; type GetAllCampaignsResponse = { data: GetAllCampaignsResponseSuccess | null; diff --git a/packages/sdk/types/schema.d.ts b/packages/sdk/types/schema.d.ts index ea9e24af..2ff5db43 100644 --- a/packages/sdk/types/schema.d.ts +++ b/packages/sdk/types/schema.d.ts @@ -1297,7 +1297,8 @@ export interface paths { createdAt: string; /** Format: date-time */ updatedAt: string; - status: string; + /** @enum {string} */ + status: "DRAFT" | "SCHEDULED" | "IN_PROGRESS" | "PAUSED" | "COMPLETED" | "CANCELLED"; /** Format: date-time */ scheduledAt: string | null; total: number; From dee4434c9fef185d22b6fd79ebdd35b7adeebc89 Mon Sep 17 00:00:00 2001 From: David Stockley Date: Wed, 11 Feb 2026 21:34:37 +0000 Subject: [PATCH 04/10] feat: bulk delete/create contacts --- .../contacts/bulk-create-contact.mdx | 3 + .../contacts/bulk-delete-contacts.mdx | 3 + apps/docs/api-reference/openapi.json | 108 ++++++++++++++++++ apps/docs/docs.json | 4 +- apps/web/src/server/api/routers/contacts.ts | 13 +++ .../api/contacts/bulk-add-contacts.ts | 73 ++++++++++++ .../api/contacts/bulk-delete-contacts.ts | 67 +++++++++++ apps/web/src/server/public-api/index.ts | 4 + .../web/src/server/service/contact-service.ts | 32 ++++++ packages/sdk/src/contact.ts | 46 ++++++++ packages/sdk/types/schema.d.ts | 82 +++++++++++++ 11 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 apps/docs/api-reference/contacts/bulk-create-contact.mdx create mode 100644 apps/docs/api-reference/contacts/bulk-delete-contacts.mdx create mode 100644 apps/web/src/server/public-api/api/contacts/bulk-add-contacts.ts create mode 100644 apps/web/src/server/public-api/api/contacts/bulk-delete-contacts.ts diff --git a/apps/docs/api-reference/contacts/bulk-create-contact.mdx b/apps/docs/api-reference/contacts/bulk-create-contact.mdx new file mode 100644 index 00000000..b00d8831 --- /dev/null +++ b/apps/docs/api-reference/contacts/bulk-create-contact.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /v1/contactBooks/{contactBookId}/contacts/bulk +--- diff --git a/apps/docs/api-reference/contacts/bulk-delete-contacts.mdx b/apps/docs/api-reference/contacts/bulk-delete-contacts.mdx new file mode 100644 index 00000000..ee564226 --- /dev/null +++ b/apps/docs/api-reference/contacts/bulk-delete-contacts.mdx @@ -0,0 +1,3 @@ +--- +openapi: delete /v1/contactBooks/{contactBookId}/contacts/bulk +--- diff --git a/apps/docs/api-reference/openapi.json b/apps/docs/api-reference/openapi.json index b00691d0..755b5f25 100644 --- a/apps/docs/api-reference/openapi.json +++ b/apps/docs/api-reference/openapi.json @@ -1517,6 +1517,114 @@ } } }, + "/v1/contactBooks/{contactBookId}/contacts/bulk": { + "delete": { + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 3, + "example": "cuiwqdj74rygf74" + }, + "required": true, + "name": "contactBookId", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "contactIds": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "maxItems": 1000 + } + }, + "required": ["contactIds"] + } + } + } + }, + "responses": { + "200": { + "description": "Bulk delete contacts from a contact book", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean" }, + "count": { "type": "number" } + }, + "required": ["success", "count"] + } + } + } + } + } + }, + "post": { + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 3, + "example": "cuiwqdj74rygf74" + }, + "required": true, + "name": "contactBookId", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "email": { "type": "string" }, + "firstName": { "type": "string" }, + "lastName": { "type": "string" }, + "properties": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "subscribed": { "type": "boolean" } + }, + "required": ["email"] + }, + "maxItems": 1000 + } + } + } + }, + "responses": { + "200": { + "description": "Bulk add contacts to a contact book", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "count": { "type": "number" } + }, + "required": ["message", "count"] + } + } + } + } + } + } + }, "/v1/contactBooks/{contactBookId}/contacts/{contactId}": { "patch": { "parameters": [ diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 558318e4..b70d6204 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -80,9 +80,11 @@ "api-reference/contacts/get-contact", "api-reference/contacts/get-contacts", "api-reference/contacts/create-contact", + "api-reference/contacts/bulk-create-contact", "api-reference/contacts/update-contact", "api-reference/contacts/upsert-contact", - "api-reference/contacts/delete-contact" + "api-reference/contacts/delete-contact", + "api-reference/contacts/bulk-delete-contacts" ] }, { diff --git a/apps/web/src/server/api/routers/contacts.ts b/apps/web/src/server/api/routers/contacts.ts index 30baa347..296ae15f 100644 --- a/apps/web/src/server/api/routers/contacts.ts +++ b/apps/web/src/server/api/routers/contacts.ts @@ -190,6 +190,19 @@ export const contactsRouter = createTRPCRouter({ return deletedContact; }), + bulkDeleteContacts: contactBookProcedure + .input(z.object({ contactIds: z.array(z.string()).min(1) })) + .mutation(async ({ ctx: { contactBook, team }, input }) => { + const deletedContacts = + await contactService.bulkDeleteContactsInContactBook( + input.contactIds, + contactBook.id, + team.id, + ); + + return { count: deletedContacts.length }; + }), + exportContacts: contactBookProcedure .input( z.object({ diff --git a/apps/web/src/server/public-api/api/contacts/bulk-add-contacts.ts b/apps/web/src/server/public-api/api/contacts/bulk-add-contacts.ts new file mode 100644 index 00000000..4e457a03 --- /dev/null +++ b/apps/web/src/server/public-api/api/contacts/bulk-add-contacts.ts @@ -0,0 +1,73 @@ +import { createRoute, z } from "@hono/zod-openapi"; +import { PublicAPIApp } from "~/server/public-api/hono"; +import { bulkAddContacts } from "~/server/service/contact-service"; +import { getContactBook } from "../../api-utils"; + +const contactSchema = z.object({ + email: z.string(), + firstName: z.string().optional(), + lastName: z.string().optional(), + properties: z.record(z.string()).optional(), + subscribed: z.boolean().optional(), +}); + +const route = createRoute({ + method: "post", + path: "/v1/contactBooks/{contactBookId}/contacts/bulk", + request: { + params: z.object({ + contactBookId: z + .string() + .min(3) + .openapi({ + param: { + name: "contactBookId", + in: "path", + }, + example: "cuiwqdj74rygf74", + }), + }), + body: { + required: true, + content: { + "application/json": { + schema: z.array(contactSchema).max(1000, { + message: + "Cannot add more than 1000 contacts in a single bulk request", + }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + message: z.string(), + count: z.number(), + }), + }, + }, + description: "Bulk add contacts to a contact book", + }, + }, +}); + +function bulkAddContactsHandle(app: PublicAPIApp) { + app.openapi(route, async (c) => { + const team = c.var.team; + + const contactBook = await getContactBook(c, team.id); + + const result = await bulkAddContacts( + contactBook.id, + c.req.valid("json"), + team.id, + ); + + return c.json(result); + }); +} + +export default bulkAddContactsHandle; diff --git a/apps/web/src/server/public-api/api/contacts/bulk-delete-contacts.ts b/apps/web/src/server/public-api/api/contacts/bulk-delete-contacts.ts new file mode 100644 index 00000000..2f550e90 --- /dev/null +++ b/apps/web/src/server/public-api/api/contacts/bulk-delete-contacts.ts @@ -0,0 +1,67 @@ +import { createRoute, z } from "@hono/zod-openapi"; +import { PublicAPIApp } from "~/server/public-api/hono"; +import { bulkDeleteContactsInContactBook } from "~/server/service/contact-service"; +import { getContactBook } from "../../api-utils"; + +const route = createRoute({ + method: "delete", + path: "/v1/contactBooks/{contactBookId}/contacts/bulk", + request: { + params: z.object({ + contactBookId: z + .string() + .min(3) + .openapi({ + param: { + name: "contactBookId", + in: "path", + }, + example: "cuiwqdj74rygf74", + }), + }), + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + contactIds: z.array(z.string()).min(1).max(1000, { + message: + "Cannot delete more than 1000 contacts in a single request", + }), + }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + success: z.boolean(), + count: z.number(), + }), + }, + }, + description: "Bulk delete contacts from a contact book", + }, + }, +}); + +function bulkDeleteContacts(app: PublicAPIApp) { + app.openapi(route, async (c) => { + const team = c.var.team; + + const contactBook = await getContactBook(c, team.id); + + const deletedContacts = await bulkDeleteContactsInContactBook( + c.req.valid("json").contactIds, + contactBook.id, + team.id, + ); + + return c.json({ success: true, count: deletedContacts.length }); + }); +} + +export default bulkDeleteContacts; diff --git a/apps/web/src/server/public-api/index.ts b/apps/web/src/server/public-api/index.ts index 9aff5584..58033ec4 100644 --- a/apps/web/src/server/public-api/index.ts +++ b/apps/web/src/server/public-api/index.ts @@ -28,6 +28,8 @@ import createContactBook from "./api/contacts/create-contact-book"; import getContactBook from "./api/contacts/get-contact-book"; import updateContactBook from "./api/contacts/update-contact-book"; import deleteContactBook from "./api/contacts/delete-contact-book"; +import bulkAddContactsHandle from "./api/contacts/bulk-add-contacts"; +import bulkDeleteContacts from "./api/contacts/bulk-delete-contacts"; export const app = getApp(); @@ -53,6 +55,8 @@ getContact(app); getContacts(app); upsertContact(app); deleteContact(app); +bulkAddContactsHandle(app); +bulkDeleteContacts(app); /**Contact Book related APIs */ getContactBooks(app); diff --git a/apps/web/src/server/service/contact-service.ts b/apps/web/src/server/service/contact-service.ts index f0772fe4..2d9f7d89 100644 --- a/apps/web/src/server/service/contact-service.ts +++ b/apps/web/src/server/service/contact-service.ts @@ -141,6 +141,38 @@ export async function deleteContactInContactBook( return deletedContact; } +export async function bulkDeleteContactsInContactBook( + contactIds: string[], + contactBookId: string, + teamId?: number, +) { + const contacts = await db.contact.findMany({ + where: { + id: { in: contactIds }, + contactBookId, + }, + }); + + if (contacts.length === 0) { + return []; + } + + await db.contact.deleteMany({ + where: { + id: { in: contacts.map((c) => c.id) }, + contactBookId, + }, + }); + + await Promise.all( + contacts.map((contact) => + emitContactEvent(contact, "contact.deleted", teamId), + ), + ); + + return contacts; +} + export async function bulkAddContacts( contactBookId: string, contacts: Array, diff --git a/packages/sdk/src/contact.ts b/packages/sdk/src/contact.ts index de0b7c72..a9f1ff0b 100644 --- a/packages/sdk/src/contact.ts +++ b/packages/sdk/src/contact.ts @@ -43,6 +43,28 @@ type UpsertContactResponse = { error: ErrorResponse | null; }; +type BulkCreateContactsPayload = + paths["/v1/contactBooks/{contactBookId}/contacts/bulk"]["post"]["requestBody"]["content"]["application/json"]; + +type BulkCreateContactsResponseSuccess = + paths["/v1/contactBooks/{contactBookId}/contacts/bulk"]["post"]["responses"]["200"]["content"]["application/json"]; + +type BulkCreateContactsResponse = { + data: BulkCreateContactsResponseSuccess | null; + error: ErrorResponse | null; +}; + +type BulkDeleteContactsPayload = + paths["/v1/contactBooks/{contactBookId}/contacts/bulk"]["delete"]["requestBody"]["content"]["application/json"]; + +type BulkDeleteContactsResponseSuccess = + paths["/v1/contactBooks/{contactBookId}/contacts/bulk"]["delete"]["responses"]["200"]["content"]["application/json"]; + +type BulkDeleteContactsResponse = { + data: BulkDeleteContactsResponseSuccess | null; + error: ErrorResponse | null; +}; + type DeleteContactResponse = { data: { success: boolean } | null; error: ErrorResponse | null; @@ -101,6 +123,30 @@ export class Contacts { return data; } + async bulkCreate( + contactBookId: string, + payload: BulkCreateContactsPayload + ): Promise { + const data = await this.usesend.post( + `/contactBooks/${contactBookId}/contacts/bulk`, + payload + ); + + return data; + } + + async bulkDelete( + contactBookId: string, + payload: BulkDeleteContactsPayload + ): Promise { + const data = await this.usesend.delete( + `/contactBooks/${contactBookId}/contacts/bulk`, + payload + ); + + return data; + } + async delete( contactBookId: string, contactId: string diff --git a/packages/sdk/types/schema.d.ts b/packages/sdk/types/schema.d.ts index 2ff5db43..bb9faf4b 100644 --- a/packages/sdk/types/schema.d.ts +++ b/packages/sdk/types/schema.d.ts @@ -1112,6 +1112,88 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/contactBooks/{contactBookId}/contacts/bulk": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path: { + contactBookId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + email: string; + firstName?: string; + lastName?: string; + properties?: { + [key: string]: string; + }; + subscribed?: boolean; + }[]; + }; + }; + responses: { + /** @description Bulk add contacts to a contact book */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message: string; + count: number; + }; + }; + }; + }; + }; + delete: { + parameters: { + query?: never; + header?: never; + path: { + contactBookId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + contactIds: string[]; + }; + }; + }; + responses: { + /** @description Bulk delete contacts from a contact book */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + success: boolean; + count: number; + }; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/contactBooks/{contactBookId}/contacts/{contactId}": { parameters: { query?: never; From 3b5f20e5e44a0b077daefbd17452e8e1e42e909d Mon Sep 17 00:00:00 2001 From: David Stockley Date: Wed, 11 Feb 2026 21:41:15 +0000 Subject: [PATCH 05/10] refactor: rename a few methods for consistency --- .../api/campaigns/delete-campaign.ts | 8 ++++---- .../public-api/api/campaigns/pause-campaign.ts | 10 ++++------ .../api/campaigns/resume-campaign.ts | 18 ++++-------------- .../api/campaigns/schedule-campaign.ts | 2 -- apps/web/src/server/public-api/index.ts | 12 ++++++------ 5 files changed, 18 insertions(+), 32 deletions(-) diff --git a/apps/web/src/server/public-api/api/campaigns/delete-campaign.ts b/apps/web/src/server/public-api/api/campaigns/delete-campaign.ts index 40e2bade..3d0cfd65 100644 --- a/apps/web/src/server/public-api/api/campaigns/delete-campaign.ts +++ b/apps/web/src/server/public-api/api/campaigns/delete-campaign.ts @@ -1,6 +1,6 @@ import { createRoute, z } from "@hono/zod-openapi"; import { PublicAPIApp } from "~/server/public-api/hono"; -import { deleteCampaign as deleteCampaignService } from "~/server/service/campaign-service"; +import { deleteCampaign } from "~/server/service/campaign-service"; import { campaignResponseSchema } from "~/server/public-api/schemas/campaign-schema"; const route = createRoute({ @@ -32,14 +32,14 @@ const route = createRoute({ }, }); -function deleteCampaign(app: PublicAPIApp) { +function deleteCampaignHandle(app: PublicAPIApp) { app.openapi(route, async (c) => { const campaignId = c.req.param("campaignId"); - const campaign = await deleteCampaignService(campaignId); + const campaign = await deleteCampaign(campaignId); return c.json(campaign); }); } -export default deleteCampaign; +export default deleteCampaignHandle; diff --git a/apps/web/src/server/public-api/api/campaigns/pause-campaign.ts b/apps/web/src/server/public-api/api/campaigns/pause-campaign.ts index 173b65c2..52f23fd3 100644 --- a/apps/web/src/server/public-api/api/campaigns/pause-campaign.ts +++ b/apps/web/src/server/public-api/api/campaigns/pause-campaign.ts @@ -1,10 +1,8 @@ import { createRoute, z } from "@hono/zod-openapi"; import { PublicAPIApp } from "~/server/public-api/hono"; import { - getCampaignForTeam, - pauseCampaign as pauseCampaignService, + pauseCampaign, } from "~/server/service/campaign-service"; -import { campaignResponseSchema } from "~/server/public-api/schemas/campaign-schema"; const route = createRoute({ method: "post", @@ -37,12 +35,12 @@ const route = createRoute({ }, }); -function pauseCampaign(app: PublicAPIApp) { +function pauseCampaignHandle(app: PublicAPIApp) { app.openapi(route, async (c) => { const team = c.var.team; const campaignId = c.req.param("campaignId"); - await pauseCampaignService({ + await pauseCampaign({ campaignId, teamId: team.id, }); @@ -51,4 +49,4 @@ function pauseCampaign(app: PublicAPIApp) { }); } -export default pauseCampaign; +export default pauseCampaignHandle; diff --git a/apps/web/src/server/public-api/api/campaigns/resume-campaign.ts b/apps/web/src/server/public-api/api/campaigns/resume-campaign.ts index 64743ab5..c4c1afad 100644 --- a/apps/web/src/server/public-api/api/campaigns/resume-campaign.ts +++ b/apps/web/src/server/public-api/api/campaigns/resume-campaign.ts @@ -1,13 +1,8 @@ import { createRoute, z } from "@hono/zod-openapi"; import { PublicAPIApp } from "~/server/public-api/hono"; import { - getCampaignForTeam, - resumeCampaign as resumeCampaignService, + resumeCampaign, } from "~/server/service/campaign-service"; -import { - campaignResponseSchema, - parseScheduledAt, -} from "~/server/public-api/schemas/campaign-schema"; const route = createRoute({ method: "post", @@ -40,17 +35,12 @@ const route = createRoute({ }, }); -function resumeCampaign(app: PublicAPIApp) { +function resumeCampaignHandle(app: PublicAPIApp) { app.openapi(route, async (c) => { const team = c.var.team; const campaignId = c.req.param("campaignId"); - await resumeCampaignService({ - campaignId, - teamId: team.id, - }); - - await getCampaignForTeam({ + await resumeCampaign({ campaignId, teamId: team.id, }); @@ -59,4 +49,4 @@ function resumeCampaign(app: PublicAPIApp) { }); } -export default resumeCampaign; +export default resumeCampaignHandle; diff --git a/apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts b/apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts index 6e9e5ac0..a626b4aa 100644 --- a/apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts +++ b/apps/web/src/server/public-api/api/campaigns/schedule-campaign.ts @@ -3,11 +3,9 @@ import { PublicAPIApp } from "~/server/public-api/hono"; import { campaignScheduleSchema, CampaignScheduleInput, - campaignResponseSchema, parseScheduledAt, } from "~/server/public-api/schemas/campaign-schema"; import { - getCampaignForTeam, scheduleCampaign as scheduleCampaignService, } from "~/server/service/campaign-service"; const route = createRoute({ diff --git a/apps/web/src/server/public-api/index.ts b/apps/web/src/server/public-api/index.ts index 58033ec4..5af42397 100644 --- a/apps/web/src/server/public-api/index.ts +++ b/apps/web/src/server/public-api/index.ts @@ -18,11 +18,11 @@ import deleteDomain from "./api/domains/delete-domain"; import sendBatch from "./api/emails/batch-email"; import createCampaign from "./api/campaigns/create-campaign"; import getCampaign from "./api/campaigns/get-campaign"; -import deleteCampaign from "./api/campaigns/delete-campaign"; +import deleteCampaignHandle from "./api/campaigns/delete-campaign"; import getCampaigns from "./api/campaigns/get-campaigns"; import scheduleCampaign from "./api/campaigns/schedule-campaign"; -import pauseCampaign from "./api/campaigns/pause-campaign"; -import resumeCampaign from "./api/campaigns/resume-campaign"; +import pauseCampaignHandle from "./api/campaigns/pause-campaign"; +import resumeCampaignHandle from "./api/campaigns/resume-campaign"; import getContactBooks from "./api/contacts/get-contact-books"; import createContactBook from "./api/contacts/create-contact-book"; import getContactBook from "./api/contacts/get-contact-book"; @@ -70,8 +70,8 @@ createCampaign(app); getCampaign(app); getCampaigns(app); scheduleCampaign(app); -pauseCampaign(app); -resumeCampaign(app); -deleteCampaign(app); +pauseCampaignHandle(app); +resumeCampaignHandle(app); +deleteCampaignHandle(app); export default app; From 5bdd668cf6a9a4f593dd0d9acddfb47052ebcf36 Mon Sep 17 00:00:00 2001 From: David Stockley Date: Wed, 11 Feb 2026 21:43:52 +0000 Subject: [PATCH 06/10] refactor: update openapi docs based on pr feedback --- apps/docs/api-reference/openapi.json | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/apps/docs/api-reference/openapi.json b/apps/docs/api-reference/openapi.json index 755b5f25..b481d052 100644 --- a/apps/docs/api-reference/openapi.json +++ b/apps/docs/api-reference/openapi.json @@ -1565,6 +1565,30 @@ } } } + }, + "403": { + "description": "Forbidden - API key doesn't have access", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "404": { + "description": "Contact book not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "error": { "type": "string" } }, + "required": ["error"] + } + } + } } } }, @@ -1601,6 +1625,7 @@ }, "required": ["email"] }, + "minItems": 1, "maxItems": 1000 } } @@ -1621,6 +1646,30 @@ } } } + }, + "403": { + "description": "Forbidden - API key doesn't have access", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "404": { + "description": "Contact book not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "error": { "type": "string" } }, + "required": ["error"] + } + } + } } } } From a5d5726332442a16e31f946d4f0cc5a5e9fc6730 Mon Sep 17 00:00:00 2001 From: David Stockley Date: Wed, 11 Feb 2026 21:48:13 +0000 Subject: [PATCH 07/10] refactor: update open api docs, based on pr feedback --- apps/docs/api-reference/openapi.json | 6 +++--- apps/web/src/server/api/routers/contacts.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/docs/api-reference/openapi.json b/apps/docs/api-reference/openapi.json index b481d052..e559d07e 100644 --- a/apps/docs/api-reference/openapi.json +++ b/apps/docs/api-reference/openapi.json @@ -2024,7 +2024,7 @@ "contactBookId": { "type": "string", "nullable": true }, "html": { "type": "string", "nullable": true }, "content": { "type": "string", "nullable": true }, - "status": { "type": "string" }, + "status": { "type": "string", "enum": ["DRAFT", "SCHEDULED", "IN_PROGRESS", "PAUSED", "COMPLETED", "CANCELLED"] }, "scheduledAt": { "type": "string", "nullable": true, @@ -2115,7 +2115,7 @@ "contactBookId": { "type": "string", "nullable": true }, "html": { "type": "string", "nullable": true }, "content": { "type": "string", "nullable": true }, - "status": { "type": "string" }, + "status": { "type": "string", "enum": ["DRAFT", "SCHEDULED", "IN_PROGRESS", "PAUSED", "COMPLETED", "CANCELLED"] }, "scheduledAt": { "type": "string", "nullable": true, @@ -2192,7 +2192,7 @@ "contactBookId": { "type": "string", "nullable": true }, "html": { "type": "string", "nullable": true }, "content": { "type": "string", "nullable": true }, - "status": { "type": "string" }, + "status": { "type": "string", "enum": ["DRAFT", "SCHEDULED", "IN_PROGRESS", "PAUSED", "COMPLETED", "CANCELLED"] }, "scheduledAt": { "type": "string", "nullable": true, diff --git a/apps/web/src/server/api/routers/contacts.ts b/apps/web/src/server/api/routers/contacts.ts index 296ae15f..eebf71c0 100644 --- a/apps/web/src/server/api/routers/contacts.ts +++ b/apps/web/src/server/api/routers/contacts.ts @@ -191,7 +191,7 @@ export const contactsRouter = createTRPCRouter({ }), bulkDeleteContacts: contactBookProcedure - .input(z.object({ contactIds: z.array(z.string()).min(1) })) + .input(z.object({ contactIds: z.array(z.string()).min(1).max(1000) })) .mutation(async ({ ctx: { contactBook, team }, input }) => { const deletedContacts = await contactService.bulkDeleteContactsInContactBook( From 010831576018221a8b962e3ec195eedff36af50b Mon Sep 17 00:00:00 2001 From: David Stockley Date: Mon, 16 Feb 2026 11:13:09 +0000 Subject: [PATCH 08/10] fix: delete campaign security issue --- .../public-api/api/campaigns/delete-campaign.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/src/server/public-api/api/campaigns/delete-campaign.ts b/apps/web/src/server/public-api/api/campaigns/delete-campaign.ts index 3d0cfd65..0a7a3147 100644 --- a/apps/web/src/server/public-api/api/campaigns/delete-campaign.ts +++ b/apps/web/src/server/public-api/api/campaigns/delete-campaign.ts @@ -1,6 +1,6 @@ import { createRoute, z } from "@hono/zod-openapi"; import { PublicAPIApp } from "~/server/public-api/hono"; -import { deleteCampaign } from "~/server/service/campaign-service"; +import { deleteCampaign, getCampaignForTeam } from "~/server/service/campaign-service"; import { campaignResponseSchema } from "~/server/public-api/schemas/campaign-schema"; const route = createRoute({ @@ -33,11 +33,16 @@ const route = createRoute({ }); function deleteCampaignHandle(app: PublicAPIApp) { - app.openapi(route, async (c) => { + app.openapi(route, async (c) => { + const team = c.var.team; const campaignId = c.req.param("campaignId"); - const campaign = await deleteCampaign(campaignId); + await getCampaignForTeam({ + campaignId, + teamId: team.id, + }); + const campaign = await deleteCampaign(campaignId); return c.json(campaign); }); } From 0081f0be576f3b85da7fa1057b250ea9616f09de Mon Sep 17 00:00:00 2001 From: David Stockley Date: Mon, 16 Feb 2026 11:21:02 +0000 Subject: [PATCH 09/10] refactor: delete campaign requires team id (from context) --- apps/web/src/server/api/routers/campaign.ts | 4 ++-- .../public-api/api/campaigns/delete-campaign.ts | 9 ++------- apps/web/src/server/service/campaign-service.ts | 13 ++++++++++++- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/web/src/server/api/routers/campaign.ts b/apps/web/src/server/api/routers/campaign.ts index 35a12480..a76a5e84 100644 --- a/apps/web/src/server/api/routers/campaign.ts +++ b/apps/web/src/server/api/routers/campaign.ts @@ -171,8 +171,8 @@ export const campaignRouter = createTRPCRouter({ return campaign; }), - deleteCampaign: campaignProcedure.mutation(async ({ input }) => { - return await campaignService.deleteCampaign(input.campaignId); + deleteCampaign: campaignProcedure.mutation(async ({ ctx: { team }, input }) => { + return await campaignService.deleteCampaign(input.campaignId, team.id); }), getCampaign: campaignProcedure.query(async ({ ctx: { db, team }, input }) => { diff --git a/apps/web/src/server/public-api/api/campaigns/delete-campaign.ts b/apps/web/src/server/public-api/api/campaigns/delete-campaign.ts index 0a7a3147..6117df44 100644 --- a/apps/web/src/server/public-api/api/campaigns/delete-campaign.ts +++ b/apps/web/src/server/public-api/api/campaigns/delete-campaign.ts @@ -1,6 +1,6 @@ import { createRoute, z } from "@hono/zod-openapi"; import { PublicAPIApp } from "~/server/public-api/hono"; -import { deleteCampaign, getCampaignForTeam } from "~/server/service/campaign-service"; +import { deleteCampaign } from "~/server/service/campaign-service"; import { campaignResponseSchema } from "~/server/public-api/schemas/campaign-schema"; const route = createRoute({ @@ -37,12 +37,7 @@ function deleteCampaignHandle(app: PublicAPIApp) { const team = c.var.team; const campaignId = c.req.param("campaignId"); - await getCampaignForTeam({ - campaignId, - teamId: team.id, - }); - - const campaign = await deleteCampaign(campaignId); + const campaign = await deleteCampaign(campaignId, team.id); return c.json(campaign); }); } diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts index 5ce49bfe..6a377306 100644 --- a/apps/web/src/server/service/campaign-service.ts +++ b/apps/web/src/server/service/campaign-service.ts @@ -669,7 +669,18 @@ export async function subscribeContact(id: string, hash: string) { } } -export async function deleteCampaign(id: string) { +export async function deleteCampaign(id: string, teamId: number) { + const existing = await db.campaign.findFirst({ + where: { id, teamId }, + }); + + if (!existing) { + throw new UnsendApiError({ + code: "NOT_FOUND", + message: "Campaign not found", + }); + } + const campaign = await db.$transaction(async (tx) => { await tx.campaignEmail.deleteMany({ where: { campaignId: id }, From e4caff989b0338800f05f7877cba9d671913bba5 Mon Sep 17 00:00:00 2001 From: David Stockley Date: Thu, 19 Feb 2026 11:01:43 +0000 Subject: [PATCH 10/10] fix: enums --- apps/docs/api-reference/openapi.json | 13 ++++++------- packages/sdk/types/schema.d.ts | 4 ++-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/docs/api-reference/openapi.json b/apps/docs/api-reference/openapi.json index e559d07e..cbbe4bc4 100644 --- a/apps/docs/api-reference/openapi.json +++ b/apps/docs/api-reference/openapi.json @@ -1879,10 +1879,9 @@ "enum": [ "DRAFT", "SCHEDULED", - "IN_PROGRESS", + "RUNNING", "PAUSED", - "COMPLETED", - "CANCELLED" + "SENT" ], "example": "DRAFT" }, @@ -1918,7 +1917,7 @@ "subject": { "type": "string" }, "createdAt": { "type": "string", "format": "date-time" }, "updatedAt": { "type": "string", "format": "date-time" }, - "status": { "type": "string", "enum": ["DRAFT", "SCHEDULED", "IN_PROGRESS", "PAUSED", "COMPLETED", "CANCELLED"] }, + "status": { "type": "string", "enum": ["DRAFT", "SCHEDULED", "RUNNING", "PAUSED", "SENT"] }, "scheduledAt": { "type": "string", "nullable": true, "format": "date-time" }, "total": { "type": "integer" }, "sent": { "type": "integer" }, @@ -2024,7 +2023,7 @@ "contactBookId": { "type": "string", "nullable": true }, "html": { "type": "string", "nullable": true }, "content": { "type": "string", "nullable": true }, - "status": { "type": "string", "enum": ["DRAFT", "SCHEDULED", "IN_PROGRESS", "PAUSED", "COMPLETED", "CANCELLED"] }, + "status": { "type": "string", "enum": ["DRAFT", "SCHEDULED", "RUNNING", "PAUSED", "SENT"] }, "scheduledAt": { "type": "string", "nullable": true, @@ -2115,7 +2114,7 @@ "contactBookId": { "type": "string", "nullable": true }, "html": { "type": "string", "nullable": true }, "content": { "type": "string", "nullable": true }, - "status": { "type": "string", "enum": ["DRAFT", "SCHEDULED", "IN_PROGRESS", "PAUSED", "COMPLETED", "CANCELLED"] }, + "status": { "type": "string", "enum": ["DRAFT", "SCHEDULED", "RUNNING", "PAUSED", "SENT"] }, "scheduledAt": { "type": "string", "nullable": true, @@ -2192,7 +2191,7 @@ "contactBookId": { "type": "string", "nullable": true }, "html": { "type": "string", "nullable": true }, "content": { "type": "string", "nullable": true }, - "status": { "type": "string", "enum": ["DRAFT", "SCHEDULED", "IN_PROGRESS", "PAUSED", "COMPLETED", "CANCELLED"] }, + "status": { "type": "string", "enum": ["DRAFT", "SCHEDULED", "RUNNING", "PAUSED", "SENT"] }, "scheduledAt": { "type": "string", "nullable": true, diff --git a/packages/sdk/types/schema.d.ts b/packages/sdk/types/schema.d.ts index bb9faf4b..467dba55 100644 --- a/packages/sdk/types/schema.d.ts +++ b/packages/sdk/types/schema.d.ts @@ -1353,7 +1353,7 @@ export interface paths { /** @description Page number for pagination (default: 1) */ page?: string; /** @description Filter campaigns by status */ - status?: "DRAFT" | "SCHEDULED" | "IN_PROGRESS" | "PAUSED" | "COMPLETED" | "CANCELLED"; + status?: "DRAFT" | "SCHEDULED" | "RUNNING" | "PAUSED" | "SENT"; /** @description Search campaigns by name or subject */ search?: string; }; @@ -1380,7 +1380,7 @@ export interface paths { /** Format: date-time */ updatedAt: string; /** @enum {string} */ - status: "DRAFT" | "SCHEDULED" | "IN_PROGRESS" | "PAUSED" | "COMPLETED" | "CANCELLED"; + status: "DRAFT" | "SCHEDULED" | "RUNNING" | "PAUSED" | "SENT"; /** Format: date-time */ scheduledAt: string | null; total: number;