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/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 61040e54..be894589 100644 --- a/apps/docs/api-reference/openapi.json +++ b/apps/docs/api-reference/openapi.json @@ -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, diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 4a68fbad..d6832bd7 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -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" ] } ] diff --git a/apps/web/src/server/api/routers/campaign.ts b/apps/web/src/server/api/routers/campaign.ts index 6e385c42..a321ab0e 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/api/routers/contacts.ts b/apps/web/src/server/api/routers/contacts.ts index 33903891..4d2e6e63 100644 --- a/apps/web/src/server/api/routers/contacts.ts +++ b/apps/web/src/server/api/routers/contacts.ts @@ -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() })) 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..6117df44 --- /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 } 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; 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/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 0a57bbb9..5af42397 100644 --- a/apps/web/src/server/public-api/index.ts +++ b/apps/web/src/server/public-api/index.ts @@ -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; diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts index baa43008..8d0f8e58 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 }, diff --git a/apps/web/src/server/service/contact-service.ts b/apps/web/src/server/service/contact-service.ts index a5b00d49..314470f4 100644 --- a/apps/web/src/server/service/contact-service.ts +++ b/apps/web/src/server/service/contact-service.ts @@ -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, 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..2e7ae6f8 100644 --- a/packages/sdk/src/campaign.ts +++ b/packages/sdk/src/campaign.ts @@ -13,6 +13,19 @@ type CreateCampaignResponse = { type CreateCampaignResponseSuccess = paths["/v1/campaigns"]["post"]["responses"]["200"]["content"]["application/json"]; +type GetAllCampaignsQuery = { + page?: string; + status?: NonNullable["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 { + 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 +127,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/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/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..467dba55 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; @@ -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;