From 141994ab7f4c98b92a439aaeadf809144d73942f Mon Sep 17 00:00:00 2001 From: David Stockley Date: Sun, 4 Jan 2026 16:40:46 +0000 Subject: [PATCH 1/4] feat: v1/campaign public api endpoint --- .../api-reference/campaigns/get-campaigns.mdx | 3 + apps/docs/api-reference/openapi.json | 96 +++++++++++++ .../public-api/api/campaigns/get-campaigns.ts | 133 ++++++++++++++++++ apps/web/src/server/public-api/index.ts | 2 + 4 files changed, 234 insertions(+) create mode 100644 apps/docs/api-reference/campaigns/get-campaigns.mdx create mode 100644 apps/web/src/server/public-api/api/campaigns/get-campaigns.ts diff --git a/apps/docs/api-reference/campaigns/get-campaigns.mdx b/apps/docs/api-reference/campaigns/get-campaigns.mdx new file mode 100644 index 00000000..daaeb870 --- /dev/null +++ b/apps/docs/api-reference/campaigns/get-campaigns.mdx @@ -0,0 +1,3 @@ +--- +openapi: get /v1/campaigns +--- diff --git a/apps/docs/api-reference/openapi.json b/apps/docs/api-reference/openapi.json index 3e6610d4..0a97796e 100644 --- a/apps/docs/api-reference/openapi.json +++ b/apps/docs/api-reference/openapi.json @@ -1358,6 +1358,102 @@ } }, "/v1/campaigns": { + "get": { + "parameters": [ + { + "schema": { "type": "string", "example": "1" }, + "required": false, + "name": "page", + "in": "query", + "description": "Page number for pagination (default: 1)" + }, + { + "schema": { + "type": "string", + "enum": [ + "DRAFT", + "SCHEDULED", + "SENDING", + "PAUSED", + "SENT", + "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, diff --git a/apps/web/src/server/public-api/api/campaigns/get-campaigns.ts b/apps/web/src/server/public-api/api/campaigns/get-campaigns.ts new file mode 100644 index 00000000..a717afa0 --- /dev/null +++ b/apps/web/src/server/public-api/api/campaigns/get-campaigns.ts @@ -0,0 +1,133 @@ +import { createRoute, z } from "@hono/zod-openapi"; +import { CampaignStatus } from "@prisma/client"; +import { PublicAPIApp } from "~/server/public-api/hono"; +import { db } from "~/server/db"; + +const statuses = Object.values(CampaignStatus) as [CampaignStatus]; + +const route = createRoute({ + method: "get", + path: "/v1/campaigns", + request: { + query: z.object({ + page: z + .string() + .optional() + .openapi({ + description: "Page number for pagination (default: 1)", + example: "1", + }), + status: z + .enum(statuses) + .optional() + .openapi({ + description: "Filter campaigns by status", + example: "DRAFT", + }), + search: z + .string() + .optional() + .openapi({ + description: "Search campaigns by name or subject", + example: "newsletter", + }), + }), + }, + responses: { + 200: { + description: "Get list of campaigns", + content: { + "application/json": { + schema: z.object({ + campaigns: z.array( + z.object({ + id: z.string(), + name: z.string(), + from: z.string(), + subject: z.string(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + status: z.string(), + scheduledAt: z.string().datetime().nullable(), + total: z.number().int(), + sent: z.number().int(), + delivered: z.number().int(), + unsubscribed: z.number().int(), + }) + ), + totalPage: z.number().int(), + }), + }, + }, + }, + }, +}); + +function getCampaigns(app: PublicAPIApp) { + app.openapi(route, async (c) => { + const team = c.var.team; + const pageParam = c.req.query("page"); + const statusParam = c.req.query("status"); + const searchParam = c.req.query("search"); + + const page = pageParam ? Number(pageParam) : 1; + const limit = 30; + const offset = (page - 1) * limit; + + const whereConditions: any = { + teamId: team.id, + }; + + if (statusParam) { + whereConditions.status = statusParam; + } + + if (searchParam) { + whereConditions.OR = [ + { + name: { + contains: searchParam, + mode: "insensitive", + }, + }, + { + subject: { + contains: searchParam, + mode: "insensitive", + }, + }, + ]; + } + + const countP = db.campaign.count({ where: whereConditions }); + + const campaignsP = db.campaign.findMany({ + where: whereConditions, + select: { + id: true, + name: true, + from: true, + subject: true, + createdAt: true, + updatedAt: true, + status: true, + scheduledAt: true, + total: true, + sent: true, + delivered: true, + unsubscribed: true, + }, + orderBy: { + createdAt: "desc", + }, + skip: offset, + take: limit, + }); + + const [campaigns, count] = await Promise.all([campaignsP, countP]); + + return c.json({ campaigns, totalPage: Math.ceil(count / limit) }); + }); +} + +export default getCampaigns; diff --git a/apps/web/src/server/public-api/index.ts b/apps/web/src/server/public-api/index.ts index 96eacf54..9f4afdda 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 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"; @@ -50,6 +51,7 @@ deleteContact(app); /**Campaign related APIs */ createCampaign(app); getCampaign(app); +getCampaigns(app); scheduleCampaign(app); pauseCampaign(app); resumeCampaign(app); From ce6233b13bbcabe8b7c0886dd2591327174e5211 Mon Sep 17 00:00:00 2001 From: Dave Stockley Date: Sun, 4 Jan 2026 16:46:48 +0000 Subject: [PATCH 2/4] refactor: update apps/docs/api-reference/openapi.json Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- apps/docs/api-reference/openapi.json | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/docs/api-reference/openapi.json b/apps/docs/api-reference/openapi.json index 0a97796e..af5ffbd1 100644 --- a/apps/docs/api-reference/openapi.json +++ b/apps/docs/api-reference/openapi.json @@ -1418,7 +1418,17 @@ "type": "string", "format": "date-time" }, - "status": { "type": "string" }, + "status": { + "type": "string", + "enum": [ + "DRAFT", + "SCHEDULED", + "SENDING", + "PAUSED", + "SENT", + "CANCELLED" + ] + }, "scheduledAt": { "type": "string", "nullable": true, From 2b2d92d328d30cc120c25c0457506cbd898ab98c Mon Sep 17 00:00:00 2001 From: David Stockley Date: Sun, 4 Jan 2026 17:01:51 +0000 Subject: [PATCH 3/4] refactor: replace any with correct type --- .../public-api/api/campaigns/get-campaigns.ts | 217 +++++++++--------- 1 file changed, 105 insertions(+), 112 deletions(-) diff --git a/apps/web/src/server/public-api/api/campaigns/get-campaigns.ts b/apps/web/src/server/public-api/api/campaigns/get-campaigns.ts index a717afa0..af973dda 100644 --- a/apps/web/src/server/public-api/api/campaigns/get-campaigns.ts +++ b/apps/web/src/server/public-api/api/campaigns/get-campaigns.ts @@ -1,133 +1,126 @@ import { createRoute, z } from "@hono/zod-openapi"; -import { CampaignStatus } from "@prisma/client"; +import { CampaignStatus, Prisma } from "@prisma/client"; import { PublicAPIApp } from "~/server/public-api/hono"; import { db } from "~/server/db"; const statuses = Object.values(CampaignStatus) as [CampaignStatus]; const route = createRoute({ - method: "get", - path: "/v1/campaigns", - request: { - query: z.object({ - page: z - .string() - .optional() - .openapi({ - description: "Page number for pagination (default: 1)", - example: "1", - }), - status: z - .enum(statuses) - .optional() - .openapi({ - description: "Filter campaigns by status", - example: "DRAFT", - }), - search: z - .string() - .optional() - .openapi({ - description: "Search campaigns by name or subject", - example: "newsletter", - }), - }), - }, - responses: { - 200: { - description: "Get list of campaigns", - content: { - "application/json": { - schema: z.object({ - campaigns: z.array( - z.object({ - id: z.string(), - name: z.string(), - from: z.string(), - subject: z.string(), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), - status: z.string(), - scheduledAt: z.string().datetime().nullable(), - total: z.number().int(), - sent: z.number().int(), - delivered: z.number().int(), - unsubscribed: z.number().int(), - }) - ), - totalPage: z.number().int(), - }), - }, - }, - }, - }, + method: "get", + path: "/v1/campaigns", + request: { + query: z.object({ + page: z.string().optional().openapi({ + description: "Page number for pagination (default: 1)", + example: "1", + }), + status: z.enum(statuses).optional().openapi({ + description: "Filter campaigns by status", + example: "DRAFT", + }), + search: z.string().optional().openapi({ + description: "Search campaigns by name or subject", + example: "newsletter", + }), + }), + }, + responses: { + 200: { + description: "Get list of campaigns", + content: { + "application/json": { + schema: z.object({ + campaigns: z.array( + z.object({ + id: z.string(), + name: z.string(), + from: z.string(), + subject: z.string(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + status: z.string(), + scheduledAt: z.string().datetime().nullable(), + total: z.number().int(), + sent: z.number().int(), + delivered: z.number().int(), + unsubscribed: z.number().int(), + }) + ), + totalPage: z.number().int(), + }), + }, + }, + }, + }, }); function getCampaigns(app: PublicAPIApp) { - app.openapi(route, async (c) => { - const team = c.var.team; - const pageParam = c.req.query("page"); - const statusParam = c.req.query("status"); - const searchParam = c.req.query("search"); + app.openapi(route, async (c) => { + const team = c.var.team; + const pageParam = c.req.query("page"); + const statusParam = c.req.query("status") as + | Prisma.EnumCampaignStatusFilter<"Campaign"> + | undefined; + const searchParam = c.req.query("search"); - const page = pageParam ? Number(pageParam) : 1; - const limit = 30; - const offset = (page - 1) * limit; + const page = pageParam ? Number(pageParam) : 1; + const limit = 30; + const offset = (page - 1) * limit; - const whereConditions: any = { - teamId: team.id, - }; + const whereConditions: Prisma.CampaignWhereInput = { + teamId: team.id, + }; - if (statusParam) { - whereConditions.status = statusParam; - } + if (statusParam) { + whereConditions.status = statusParam; + } - if (searchParam) { - whereConditions.OR = [ - { - name: { - contains: searchParam, - mode: "insensitive", - }, - }, - { - subject: { - contains: searchParam, - mode: "insensitive", - }, - }, - ]; - } + if (searchParam) { + whereConditions.OR = [ + { + name: { + contains: searchParam, + mode: "insensitive", + }, + }, + { + subject: { + contains: searchParam, + mode: "insensitive", + }, + }, + ]; + } - const countP = db.campaign.count({ where: whereConditions }); + const countP = db.campaign.count({ where: whereConditions }); - const campaignsP = db.campaign.findMany({ - where: whereConditions, - select: { - id: true, - name: true, - from: true, - subject: true, - createdAt: true, - updatedAt: true, - status: true, - scheduledAt: true, - total: true, - sent: true, - delivered: true, - unsubscribed: true, - }, - orderBy: { - createdAt: "desc", - }, - skip: offset, - take: limit, - }); + const campaignsP = db.campaign.findMany({ + where: whereConditions, + select: { + id: true, + name: true, + from: true, + subject: true, + createdAt: true, + updatedAt: true, + status: true, + scheduledAt: true, + total: true, + sent: true, + delivered: true, + unsubscribed: true, + }, + orderBy: { + createdAt: "desc", + }, + skip: offset, + take: limit, + }); - const [campaigns, count] = await Promise.all([campaignsP, countP]); + const [campaigns, count] = await Promise.all([campaignsP, countP]); - return c.json({ campaigns, totalPage: Math.ceil(count / limit) }); - }); + return c.json({ campaigns, totalPage: Math.ceil(count / limit) }); + }); } export default getCampaigns; From 032c7ef7844c0b56c3c2fc50b6fd870a566502da Mon Sep 17 00:00:00 2001 From: David Stockley Date: Sun, 4 Jan 2026 17:52:48 +0000 Subject: [PATCH 4/4] docs: add doc link --- apps/docs/docs.json | 321 ++++++++++++++++++++++---------------------- 1 file changed, 161 insertions(+), 160 deletions(-) diff --git a/apps/docs/docs.json b/apps/docs/docs.json index eac7cf29..a30a757f 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -1,162 +1,163 @@ { - "$schema": "https://mintlify.com/docs.json", - "theme": "maple", - "name": "useSend", - "colors": { - "primary": "#21453D", - "light": "#E6FAF5", - "dark": "#21453D" - }, - "background": { - "color": { - "light": "#F5F5F5", - "dark": "#181825" - } - }, - "fonts": { - "family": "IBM Plex Mono" - }, - "favicon": "/favicon.svg", - "navigation": { - "tabs": [ - { - "tab": "Documentation", - "groups": [ - { - "group": "Getting Started", - "pages": [ - "introduction", - "get-started/nodejs", - "get-started/python", - "get-started/local", - "get-started/smtp" - ] - }, - { - "group": "Self Hosting", - "pages": ["self-hosting/overview", "self-hosting/railway"] - }, - { - "group": "Guides", - "pages": ["guides/use-with-react-email"] - }, - { - "group": "Community SDKs", - "pages": ["community-sdk/python", "community-sdk/go"] - } - ] - }, - { - "tab": "API Reference", - "groups": [ - { - "group": "API Reference", - "pages": ["api-reference/introduction"] - }, - { - "group": "Emails", - "pages": [ - "api-reference/emails/get-email", - "api-reference/emails/list-emails", - "api-reference/emails/send-email", - "api-reference/emails/batch-email", - "api-reference/emails/update-schedule", - "api-reference/emails/cancel-schedule" - ] - }, - { - "group": "Contacts", - "pages": [ - "api-reference/contacts/get-contact", - "api-reference/contacts/get-contacts", - "api-reference/contacts/create-contact", - "api-reference/contacts/update-contact", - "api-reference/contacts/upsert-contact", - "api-reference/contacts/delete-contact" - ] - }, - { - "group": "Domains", - "pages": [ - "api-reference/domains/get-domain", - "api-reference/domains/list-domains", - "api-reference/domains/create-domain", - "api-reference/domains/verify-domain", - "api-reference/domains/delete-domain" - ] - }, - { - "group": "Campaigns", - "pages": [ - "api-reference/campaigns/create-campaign", - "api-reference/campaigns/get-campaign", - "api-reference/campaigns/schedule-campaign", - "api-reference/campaigns/pause-campaign", - "api-reference/campaigns/resume-campaign" - ] - } - ] - }, - { - "tab": "Changelog", - "groups": [ - { - "group": "Updates", - "pages": ["changelog"] - } - ] - } - ], - "global": { - "anchors": [ - { - "anchor": "GitHub", - "href": "https://github.com/usesend/usesend", - "icon": "github" - }, - { - "anchor": "Community", - "href": "https://discord.gg/BU8n8pJv8S", - "icon": "discord" - } - ] - } - }, - "logo": { - "light": "/logo/logo-wordmark.svg", - "dark": "/logo/logo-wordmark-dark.svg" - }, - "api": { - "playground": { - "display": "interactive" - }, - "mdx": { - "server": "https://mintlify.com/api", - "auth": { - "method": "bearer" - } - } - }, - "navbar": { - "links": [ - { - "label": "Support", - "href": "mailto:hey@usesend.com" - } - ], - "primary": { - "type": "button", - "label": "Dashboard", - "href": "https://app.usesend.com" - } - }, - "footer": { - "socials": { - "x": "https://x.com/useSend_com", - "github": "https://github.com/usesend" - } - }, - "contextual": { - "options": ["copy", "view", "chatgpt", "claude", "perplexity"] - } + "$schema": "https://mintlify.com/docs.json", + "theme": "maple", + "name": "useSend", + "colors": { + "primary": "#21453D", + "light": "#E6FAF5", + "dark": "#21453D" + }, + "background": { + "color": { + "light": "#F5F5F5", + "dark": "#181825" + } + }, + "fonts": { + "family": "IBM Plex Mono" + }, + "favicon": "/favicon.svg", + "navigation": { + "tabs": [ + { + "tab": "Documentation", + "groups": [ + { + "group": "Getting Started", + "pages": [ + "introduction", + "get-started/nodejs", + "get-started/python", + "get-started/local", + "get-started/smtp" + ] + }, + { + "group": "Self Hosting", + "pages": ["self-hosting/overview", "self-hosting/railway"] + }, + { + "group": "Guides", + "pages": ["guides/use-with-react-email"] + }, + { + "group": "Community SDKs", + "pages": ["community-sdk/python", "community-sdk/go"] + } + ] + }, + { + "tab": "API Reference", + "groups": [ + { + "group": "API Reference", + "pages": ["api-reference/introduction"] + }, + { + "group": "Emails", + "pages": [ + "api-reference/emails/get-email", + "api-reference/emails/list-emails", + "api-reference/emails/send-email", + "api-reference/emails/batch-email", + "api-reference/emails/update-schedule", + "api-reference/emails/cancel-schedule" + ] + }, + { + "group": "Contacts", + "pages": [ + "api-reference/contacts/get-contact", + "api-reference/contacts/get-contacts", + "api-reference/contacts/create-contact", + "api-reference/contacts/update-contact", + "api-reference/contacts/upsert-contact", + "api-reference/contacts/delete-contact" + ] + }, + { + "group": "Domains", + "pages": [ + "api-reference/domains/get-domain", + "api-reference/domains/list-domains", + "api-reference/domains/create-domain", + "api-reference/domains/verify-domain", + "api-reference/domains/delete-domain" + ] + }, + { + "group": "Campaigns", + "pages": [ + "api-reference/campaigns/create-campaign", + "api-reference/campaigns/get-campaigns", + "api-reference/campaigns/get-campaign", + "api-reference/campaigns/schedule-campaign", + "api-reference/campaigns/pause-campaign", + "api-reference/campaigns/resume-campaign" + ] + } + ] + }, + { + "tab": "Changelog", + "groups": [ + { + "group": "Updates", + "pages": ["changelog"] + } + ] + } + ], + "global": { + "anchors": [ + { + "anchor": "GitHub", + "href": "https://github.com/usesend/usesend", + "icon": "github" + }, + { + "anchor": "Community", + "href": "https://discord.gg/BU8n8pJv8S", + "icon": "discord" + } + ] + } + }, + "logo": { + "light": "/logo/logo-wordmark.svg", + "dark": "/logo/logo-wordmark-dark.svg" + }, + "api": { + "playground": { + "display": "interactive" + }, + "mdx": { + "server": "https://mintlify.com/api", + "auth": { + "method": "bearer" + } + } + }, + "navbar": { + "links": [ + { + "label": "Support", + "href": "mailto:hey@usesend.com" + } + ], + "primary": { + "type": "button", + "label": "Dashboard", + "href": "https://app.usesend.com" + } + }, + "footer": { + "socials": { + "x": "https://x.com/useSend_com", + "github": "https://github.com/usesend" + } + }, + "contextual": { + "options": ["copy", "view", "chatgpt", "claude", "perplexity"] + } }