feat: add contactBooks to sdk, add delete campaign public endpoint (#352)

* feat: add contactBooks to sdk, add delete campaign public endpoint

* fix: pr review notes

* refactor: pr feedback

* feat: bulk delete/create contacts

* refactor: rename a few methods for consistency

* refactor: update openapi docs based on pr feedback

* refactor: update open api docs, based on pr feedback

* fix: delete campaign security issue

* refactor: delete campaign requires team id (from context)

* fix: enums
This commit is contained in:
Dave Stockley
2026-03-03 20:10:43 +00:00
committed by GitHub
parent 73416dc481
commit 7428a1fbfa
22 changed files with 1365 additions and 218 deletions
@@ -0,0 +1,3 @@
---
openapi: delete /v1/campaigns/{campaignId}
---
@@ -0,0 +1,3 @@
---
openapi: post /v1/contactBooks/{contactBookId}/contacts/bulk
---
@@ -0,0 +1,3 @@
---
openapi: delete /v1/contactBooks/{contactBookId}/contacts/bulk
---
+251 -18
View File
@@ -1585,6 +1585,163 @@
}
}
},
"/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"]
}
}
}
},
"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"]
}
}
}
}
}
},
"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"]
},
"minItems": 1,
"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"]
}
}
}
},
"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"]
}
}
}
}
}
}
},
"/v1/contactBooks/{contactBookId}/contacts/{contactId}": {
"patch": {
"parameters": [
@@ -1790,10 +1947,9 @@
"enum": [
"DRAFT",
"SCHEDULED",
"IN_PROGRESS",
"RUNNING",
"PAUSED",
"COMPLETED",
"CANCELLED"
"SENT"
],
"example": "DRAFT"
},
@@ -1829,7 +1985,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", "RUNNING", "PAUSED", "SENT"] },
"scheduledAt": { "type": "string", "nullable": true, "format": "date-time" },
"total": { "type": "integer" },
"sent": { "type": "integer" },
@@ -1935,7 +2091,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", "RUNNING", "PAUSED", "SENT"] },
"scheduledAt": {
"type": "string",
"nullable": true,
@@ -1997,19 +2153,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",
@@ -2026,7 +2182,84 @@
"contactBookId": { "type": "string", "nullable": true },
"html": { "type": "string", "nullable": true },
"content": { "type": "string", "nullable": true },
"status": { "type": "string" },
"status": { "type": "string", "enum": ["DRAFT", "SCHEDULED", "RUNNING", "PAUSED", "SENT"] },
"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"
]
}
}
}
}
}
},
"delete": {
"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", "enum": ["DRAFT", "SCHEDULED", "RUNNING", "PAUSED", "SENT"] },
"scheduledAt": {
"type": "string",
"nullable": true,
+5 -2
View File
@@ -77,9 +77,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"
]
},
{
@@ -100,7 +102,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"
]
}
]
+2 -2
View File
@@ -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 }) => {
+177 -164
View File
@@ -11,188 +11,201 @@ import * as contactService from "~/server/service/contact-service";
import * as contactBookService from "~/server/service/contact-book-service";
export const contactsRouter = createTRPCRouter({
getContactBooks: teamProcedure
.input(z.object({ search: z.string().optional() }))
.query(async ({ ctx: { team }, input }) => {
return contactBookService.getContactBooks(team.id, input.search);
}),
getContactBooks: teamProcedure
.input(z.object({ search: z.string().optional() }))
.query(async ({ ctx: { team }, input }) => {
return contactBookService.getContactBooks(team.id, input.search);
}),
createContactBook: teamProcedure
.input(
z.object({
name: z.string(),
}),
)
.mutation(async ({ ctx: { team }, input }) => {
const { name } = input;
return contactBookService.createContactBook(team.id, name);
}),
createContactBook: teamProcedure
.input(
z.object({
name: z.string(),
}),
)
.mutation(async ({ ctx: { team }, input }) => {
const { name } = input;
return contactBookService.createContactBook(team.id, name);
}),
getContactBookDetails: contactBookProcedure.query(
async ({ ctx: { contactBook } }) => {
const { totalContacts, unsubscribedContacts, campaigns } =
await contactBookService.getContactBookDetails(contactBook.id);
getContactBookDetails: contactBookProcedure.query(
async ({ ctx: { contactBook } }) => {
const { totalContacts, unsubscribedContacts, campaigns } =
await contactBookService.getContactBookDetails(contactBook.id);
return {
...contactBook,
totalContacts,
unsubscribedContacts,
campaigns,
};
},
),
return {
...contactBook,
totalContacts,
unsubscribedContacts,
campaigns,
};
},
),
updateContactBook: contactBookProcedure
.input(
z.object({
contactBookId: z.string(),
name: z.string().optional(),
properties: z.record(z.string()).optional(),
emoji: z.string().optional(),
doubleOptInEnabled: z.boolean().optional(),
doubleOptInFrom: z.string().nullable().optional(),
doubleOptInSubject: z.string().optional(),
doubleOptInContent: z.string().optional(),
}),
)
.mutation(async ({ ctx: { contactBook }, input }) => {
const { contactBookId, ...data } = input;
return contactBookService.updateContactBook(contactBook.id, data);
}),
updateContactBook: contactBookProcedure
.input(
z.object({
contactBookId: z.string(),
name: z.string().optional(),
properties: z.record(z.string()).optional(),
emoji: z.string().optional(),
doubleOptInEnabled: z.boolean().optional(),
doubleOptInFrom: z.string().nullable().optional(),
doubleOptInSubject: z.string().optional(),
doubleOptInContent: z.string().optional(),
}),
)
.mutation(async ({ ctx: { contactBook }, input }) => {
const { contactBookId, ...data } = input;
return contactBookService.updateContactBook(contactBook.id, data);
}),
deleteContactBook: contactBookProcedure
.input(z.object({ contactBookId: z.string() }))
.mutation(async ({ ctx: { contactBook }, input }) => {
return contactBookService.deleteContactBook(contactBook.id);
}),
deleteContactBook: contactBookProcedure
.input(z.object({ contactBookId: z.string() }))
.mutation(async ({ ctx: { contactBook }, input }) => {
return contactBookService.deleteContactBook(contactBook.id);
}),
contacts: contactBookProcedure
.input(
z.object({
page: z.number().optional(),
subscribed: z.boolean().optional(),
search: z.string().optional(),
}),
)
.query(async ({ ctx: { db }, input }) => {
const page = input.page || 1;
const limit = 30;
const offset = (page - 1) * limit;
contacts: contactBookProcedure
.input(
z.object({
page: z.number().optional(),
subscribed: z.boolean().optional(),
search: z.string().optional(),
}),
)
.query(async ({ ctx: { db }, input }) => {
const page = input.page || 1;
const limit = 30;
const offset = (page - 1) * limit;
const whereConditions: Prisma.ContactFindManyArgs["where"] = {
contactBookId: input.contactBookId,
...(input.subscribed !== undefined
? { subscribed: input.subscribed }
: {}),
...(input.search
? {
OR: [
{ email: { contains: input.search, mode: "insensitive" } },
{ firstName: { contains: input.search, mode: "insensitive" } },
{ lastName: { contains: input.search, mode: "insensitive" } },
],
}
: {}),
};
const whereConditions: Prisma.ContactFindManyArgs["where"] = {
contactBookId: input.contactBookId,
...(input.subscribed !== undefined
? { subscribed: input.subscribed }
: {}),
...(input.search
? {
OR: [
{ email: { contains: input.search, mode: "insensitive" } },
{ firstName: { contains: input.search, mode: "insensitive" } },
{ lastName: { contains: input.search, mode: "insensitive" } },
],
}
: {}),
};
const countP = db.contact.count({ where: whereConditions });
const countP = db.contact.count({ where: whereConditions });
const contactsP = db.contact.findMany({
where: whereConditions,
select: {
id: true,
email: true,
firstName: true,
lastName: true,
subscribed: true,
createdAt: true,
contactBookId: true,
unsubscribeReason: true,
},
orderBy: {
createdAt: "desc",
},
skip: offset,
take: limit,
});
const contactsP = db.contact.findMany({
where: whereConditions,
select: {
id: true,
email: true,
firstName: true,
lastName: true,
subscribed: true,
createdAt: true,
contactBookId: true,
unsubscribeReason: true,
},
orderBy: {
createdAt: "desc",
},
skip: offset,
take: limit,
});
const [contacts, count] = await Promise.all([contactsP, countP]);
const [contacts, count] = await Promise.all([contactsP, countP]);
return { contacts, totalPage: Math.ceil(count / limit) };
}),
return { contacts, totalPage: Math.ceil(count / limit) };
}),
addContacts: contactBookProcedure
.input(
z.object({
contacts: z
.array(
z.object({
email: z.string(),
firstName: z.string().optional(),
lastName: z.string().optional(),
properties: z.record(z.string()).optional(),
subscribed: z.boolean().optional(),
}),
)
.max(50000),
}),
)
.mutation(async ({ ctx: { contactBook, team }, input }) => {
return contactService.bulkAddContacts(
contactBook.id,
input.contacts,
team.id,
);
}),
addContacts: contactBookProcedure
.input(
z.object({
contacts: z
.array(
z.object({
email: z.string(),
firstName: z.string().optional(),
lastName: z.string().optional(),
properties: z.record(z.string()).optional(),
subscribed: z.boolean().optional(),
}),
)
.max(50000),
}),
)
.mutation(async ({ ctx: { contactBook, team }, input }) => {
return contactService.bulkAddContacts(
contactBook.id,
input.contacts,
team.id,
);
}),
updateContact: contactBookProcedure
.input(
z.object({
contactId: z.string(),
email: z.string().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
properties: z.record(z.string()).optional(),
subscribed: z.boolean().optional(),
}),
)
.mutation(async ({ ctx: { contactBook, team }, input }) => {
const { contactId, ...contact } = input;
const updatedContact = await contactService.updateContactInContactBook(
contactId,
contactBook.id,
contact,
team.id,
);
updateContact: contactBookProcedure
.input(
z.object({
contactId: z.string(),
email: z.string().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
properties: z.record(z.string()).optional(),
subscribed: z.boolean().optional(),
}),
)
.mutation(async ({ ctx: { contactBook, team }, input }) => {
const { contactId, ...contact } = input;
const updatedContact = await contactService.updateContactInContactBook(
contactId,
contactBook.id,
contact,
team.id,
);
if (!updatedContact) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Contact not found",
});
}
if (!updatedContact) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Contact not found",
});
}
return updatedContact;
}),
return updatedContact;
}),
deleteContact: contactBookProcedure
.input(z.object({ contactId: z.string() }))
.mutation(async ({ ctx: { contactBook, team }, input }) => {
const deletedContact = await contactService.deleteContactInContactBook(
input.contactId,
contactBook.id,
team.id,
);
deleteContact: contactBookProcedure
.input(z.object({ contactId: z.string() }))
.mutation(async ({ ctx: { contactBook, team }, input }) => {
const deletedContact = await contactService.deleteContactInContactBook(
input.contactId,
contactBook.id,
team.id,
);
if (!deletedContact) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Contact not found",
});
}
if (!deletedContact) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Contact not found",
});
}
return deletedContact;
}),
return deletedContact;
}),
bulkDeleteContacts: contactBookProcedure
.input(z.object({ contactIds: z.array(z.string()).min(1).max(1000) }))
.mutation(async ({ ctx: { contactBook, team }, input }) => {
const deletedContacts =
await contactService.bulkDeleteContactsInContactBook(
input.contactIds,
contactBook.id,
team.id,
);
return { count: deletedContacts.length };
}),
resendDoubleOptInConfirmation: contactBookProcedure
.input(z.object({ contactId: z.string() }))
@@ -0,0 +1,45 @@
import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
import { deleteCampaign } 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 deleteCampaignHandle(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = c.var.team;
const campaignId = c.req.param("campaignId");
const campaign = await deleteCampaign(campaignId, team.id);
return c.json(campaign);
});
}
export default deleteCampaignHandle;
@@ -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;
@@ -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;
@@ -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({
@@ -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;
@@ -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;
+10 -4
View File
@@ -18,15 +18,18 @@ 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 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";
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();
@@ -52,6 +55,8 @@ getContact(app);
getContacts(app);
upsertContact(app);
deleteContact(app);
bulkAddContactsHandle(app);
bulkDeleteContacts(app);
/**Contact Book related APIs */
getContactBooks(app);
@@ -65,7 +70,8 @@ createCampaign(app);
getCampaign(app);
getCampaigns(app);
scheduleCampaign(app);
pauseCampaign(app);
resumeCampaign(app);
pauseCampaignHandle(app);
resumeCampaignHandle(app);
deleteCampaignHandle(app);
export default app;
@@ -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 },
@@ -214,6 +214,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 resendDoubleOptInConfirmationInContactBook(
contactId: string,
contactBookId: string,
+1
View File
@@ -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,
+43
View File
@@ -13,6 +13,19 @@ type CreateCampaignResponse = {
type CreateCampaignResponseSuccess =
paths["/v1/campaigns"]["post"]["responses"]["200"]["content"]["application/json"];
type GetAllCampaignsQuery = {
page?: string;
status?: NonNullable<paths["/v1/campaigns"]["get"]["parameters"]["query"]>["status"];
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 +45,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 +76,21 @@ export class Campaigns {
return data;
}
async getAll(
query?: GetAllCampaignsQuery,
): Promise<GetAllCampaignsResponse> {
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<GetAllCampaignsResponseSuccess>(path);
return data;
}
async get(campaignId: string): Promise<GetCampaignResponse> {
const data = await this.usesend.get<GetCampaignResponseSuccess>(
`/campaigns/${campaignId}`,
@@ -91,4 +127,11 @@ export class Campaigns {
return data;
}
async delete(campaignId: string): Promise<DeleteCampaignResponse> {
const data = await this.usesend.delete<DeleteCampaignResponseSuccess>(
`/campaigns/${campaignId}`,
);
return data;
}
}
+46
View File
@@ -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<BulkCreateContactsResponse> {
const data = await this.usesend.post<BulkCreateContactsResponseSuccess>(
`/contactBooks/${contactBookId}/contacts/bulk`,
payload
);
return data;
}
async bulkDelete(
contactBookId: string,
payload: BulkDeleteContactsPayload
): Promise<BulkDeleteContactsResponse> {
const data = await this.usesend.delete<BulkDeleteContactsResponseSuccess>(
`/contactBooks/${contactBookId}/contacts/bulk`,
payload
);
return data;
}
async delete(
contactBookId: string,
contactId: string
+97
View File
@@ -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<GetAllContactBooksResponse> {
const data = await this.usesend.get<GetAllContactBooksResponseSuccess>(
`/contactBooks`,
);
return data;
}
async get(contactBookId: string): Promise<GetContactBookResponse> {
const data = await this.usesend.get<GetContactBookResponseSuccess>(
`/contactBooks/${contactBookId}`,
);
return data;
}
async create(
payload: CreateContactBookPayload,
): Promise<CreateContactBookResponse> {
const data = await this.usesend.post<CreateContactBookResponseSuccess>(
`/contactBooks`,
payload,
);
return data;
}
async update(
contactBookId: string,
payload: UpdateContactBookPayload,
): Promise<UpdateContactBookResponse> {
const data = await this.usesend.patch<UpdateContactBookResponseSuccess>(
`/contactBooks/${contactBookId}`,
payload,
);
return data;
}
async delete(contactBookId: string): Promise<DeleteContactBookResponse> {
const data = await this.usesend.delete<DeleteContactBookResponseSuccess>(
`/contactBooks/${contactBookId}`,
);
return data;
}
}
+2
View File
@@ -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;
+485 -5
View File
@@ -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;
@@ -814,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;
@@ -863,6 +1243,7 @@ export interface paths {
header?: never;
path: {
contactBookId: string;
contactId: string;
};
cookie?: never;
};
@@ -966,7 +1347,53 @@ 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" | "RUNNING" | "PAUSED" | "SENT";
/** @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;
/** @enum {string} */
status: "DRAFT" | "SCHEDULED" | "RUNNING" | "PAUSED" | "SENT";
/** Format: date-time */
scheduledAt: string | null;
total: number;
sent: number;
delivered: number;
unsubscribed: number;
}[];
totalPage: number;
};
};
};
};
};
put?: never;
post: {
parameters: {
@@ -1047,7 +1474,9 @@ export interface paths {
parameters: {
query?: never;
header?: never;
path?: never;
path: {
campaignId: string;
};
cookie?: never;
};
get: {
@@ -1104,7 +1533,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;