Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
525 changes: 525 additions & 0 deletions apps/docs/api-reference/openapi.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Campaign" ADD COLUMN "isApi" BOOLEAN NOT NULL DEFAULT false;
1 change: 1 addition & 0 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ model Campaign {
bounced Int @default(0)
hardBounced Int @default(0)
complained Int @default(0)
isApi Boolean @default(false)
status CampaignStatus @default(DRAFT)
batchSize Int @default(500)
batchWindowMinutes Int @default(0)
Expand Down
70 changes: 55 additions & 15 deletions apps/web/src/app/(dashboard)/campaigns/[campaignId]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ function CampaignEditor({
campaign: Campaign & { imageUploadSupported: boolean };
}) {
const router = useRouter();
const isApiCampaign = campaign.isApi;
const contactBooksQuery = api.contacts.getContactBooks.useQuery({});
const utils = api.useUtils();

Expand Down Expand Up @@ -124,6 +125,9 @@ function CampaignEditor({
const getUploadUrl = api.campaign.generateImagePresignedUrl.useMutation();

function updateEditorContent() {
if (isApiCampaign) {
return;
}
updateCampaignMutation.mutate({
campaignId: campaign.id,
content: JSON.stringify(json),
Expand Down Expand Up @@ -175,7 +179,12 @@ function CampaignEditor({
value={name}
onChange={(e) => setName(e.target.value)}
className=" border-0 focus:ring-0 focus:outline-none px-0.5 w-[300px]"
disabled={isApiCampaign}
readOnly={isApiCampaign}
onBlur={() => {
if (isApiCampaign) {
return;
}
if (name === campaign.name || !name) {
return;
}
Expand Down Expand Up @@ -228,6 +237,9 @@ function CampaignEditor({
setSubject(e.target.value);
}}
onBlur={() => {
if (isApiCampaign) {
return;
}
if (subject === campaign.subject || !subject) {
return;
}
Expand All @@ -245,6 +257,8 @@ function CampaignEditor({
);
}}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent"
disabled={isApiCampaign}
readOnly={isApiCampaign}
/>
<AccordionTrigger className="py-0"></AccordionTrigger>
</div>
Expand All @@ -263,6 +277,9 @@ function CampaignEditor({
className="mt-1 py-1 w-full text-sm outline-none border-b border-transparent focus:border-border bg-transparent"
placeholder="Friendly name<hello@example.com>"
onBlur={() => {
if (isApiCampaign) {
return;
}
if (from === campaign.from || !from) {
return;
}
Expand All @@ -279,6 +296,8 @@ function CampaignEditor({
}
);
}}
disabled={isApiCampaign}
readOnly={isApiCampaign}
/>
</div>
<div className="flex items-center gap-4">
Expand All @@ -294,6 +313,9 @@ function CampaignEditor({
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border"
placeholder="hello@example.com"
onBlur={() => {
if (isApiCampaign) {
return;
}
if (replyTo === campaign.replyTo[0]) {
return;
}
Expand All @@ -310,6 +332,8 @@ function CampaignEditor({
}
);
}}
disabled={isApiCampaign}
readOnly={isApiCampaign}
/>
</div>

Expand All @@ -324,6 +348,9 @@ function CampaignEditor({
setPreviewText(e.target.value);
}}
onBlur={() => {
if (isApiCampaign) {
return;
}
if (
previewText === campaign.previewText ||
!previewText
Expand All @@ -344,6 +371,8 @@ function CampaignEditor({
);
}}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border"
disabled={isApiCampaign}
readOnly={isApiCampaign}
/>
</div>
<div className=" flex items-center gap-2">
Expand All @@ -355,7 +384,11 @@ function CampaignEditor({
) : (
<Select
value={contactBookId ?? ""}
disabled={isApiCampaign}
onValueChange={(val) => {
if (isApiCampaign) {
return;
}
// Update the campaign's contactBookId
updateCampaignMutation.mutate(
{
Expand Down Expand Up @@ -395,22 +428,29 @@ function CampaignEditor({
</AccordionItem>
</Accordion>

<div className=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10">
<div className="w-[600px] mx-auto">
<Editor
initialContent={json}
onUpdate={(content) => {
setJson(content.getJSON());
setIsSaving(true);
deboucedUpdateCampaign();
}}
variables={["email", "firstName", "lastName"]}
uploadImage={
campaign.imageUploadSupported ? handleFileChange : undefined
}
/>
{isApiCampaign ? (
<p className="text-sm text-center text-muted-foreground">
Email created from API. Campaign content can only be updated via
API.
</p>
) : (
<div className=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10">
<div className="w-[600px] mx-auto">
<Editor
initialContent={json}
onUpdate={(content) => {
setJson(content.getJSON());
setIsSaving(true);
deboucedUpdateCampaign();
}}
variables={["email", "firstName", "lastName"]}
uploadImage={
campaign.imageUploadSupported ? handleFileChange : undefined
}
/>
</div>
</div>
</div>
)}
</div>
</div>
);
Expand Down
25 changes: 17 additions & 8 deletions apps/web/src/server/api/routers/campaign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,13 @@ export const campaignRouter = createTRPCRouter({
subject: z.string().optional(),
previewText: z.string().optional(),
content: z.string().optional(),
html: z.string().optional(),
contactBookId: z.string().optional(),
replyTo: z.string().array().optional(),
})
)
.mutation(async ({ ctx: { db, team, campaign: campaignOld }, input }) => {
const { campaignId, ...data } = input;
const { campaignId, html: htmlInput, ...data } = input;
if (data.contactBookId) {
const contactBook = await db.contactBook.findUnique({
where: { id: data.contactBookId },
Comment on lines 127 to 131
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Use ctx.campaign id instead of input; avoid id forgery and missing input

Rely on campaignOld.id from context (already authorized) instead of input.campaignId.

As per coding guidelines

-      const { campaignId, html: htmlInput, ...data } = input;
+      const { html: htmlInput, ...data } = input;
@@
-      const campaign = await db.campaign.update({
-        where: { id: campaignId },
-        data: campaignUpdateData,
-      });
+      const campaign = await db.campaign.update({
+        where: { id: campaignOld.id },
+        data: campaignUpdateData,
+      });

Also applies to: 167-170

🤖 Prompt for AI Agents
In apps/web/src/server/api/routers/campaign.ts around lines 127-131 (and
similarly at lines 167-170), the mutation uses input.campaignId which allows id
forgery or missing input; replace uses of input.campaignId with the authorized
campaign id from the context (campaignOld.id or ctx.campaign.id) everywhere in
this handler so the DB queries and updates always use the server-provided,
authorized campaign id; remove or ignore campaignId from input and adjust
variable extraction accordingly.

Expand All @@ -143,22 +144,29 @@ export const campaignRouter = createTRPCRouter({
domainId = domain.id;
}

let html: string | null = null;
let htmlToSave: string | undefined;

if (data.content) {
const jsonContent = data.content ? JSON.parse(data.content) : null;

const renderer = new EmailRenderer(jsonContent);
html = await renderer.render();
htmlToSave = await renderer.render();
} else if (typeof htmlInput === "string") {
htmlToSave = htmlInput;
}
Comment on lines 149 to +156
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Guard JSON parsing and drop unnecessary await

Prevent crashes on invalid JSON; render() is synchronous.

-      if (data.content) {
-        const jsonContent = data.content ? JSON.parse(data.content) : null;
-
-        const renderer = new EmailRenderer(jsonContent);
-        htmlToSave = await renderer.render();
-      } else if (typeof htmlInput === "string") {
+      if (data.content) {
+        let jsonContent: unknown = null;
+        try {
+          jsonContent = JSON.parse(data.content);
+        } catch {
+          throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid content JSON" });
+        }
+        const renderer = new EmailRenderer(jsonContent as any);
+        htmlToSave = renderer.render();
+      } else if (typeof htmlInput === "string") {
         htmlToSave = htmlInput;
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (data.content) {
const jsonContent = data.content ? JSON.parse(data.content) : null;
const renderer = new EmailRenderer(jsonContent);
html = await renderer.render();
htmlToSave = await renderer.render();
} else if (typeof htmlInput === "string") {
htmlToSave = htmlInput;
}
if (data.content) {
let jsonContent: unknown = null;
try {
jsonContent = JSON.parse(data.content);
} catch {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid content JSON" });
}
const renderer = new EmailRenderer(jsonContent as any);
htmlToSave = renderer.render();
} else if (typeof htmlInput === "string") {
htmlToSave = htmlInput;
}
🤖 Prompt for AI Agents
In apps/web/src/server/api/routers/campaign.ts around lines 149 to 156, the code
currently unconditionally JSON.parse(data.content) which can throw on invalid
JSON and calls await on renderer.render() though render() is synchronous; update
it to first check data.content is a non-empty string, then try to JSON.parse
inside a try/catch and handle parse errors (e.g., set jsonContent = null or
return a validation error), instantiate EmailRenderer with the parsed or null
value, and call renderer.render() without await (assign directly) so no
unnecessary await is used.


const campaignUpdateData: Prisma.CampaignUpdateInput = {
...data,
domainId,
};

if (htmlToSave !== undefined) {
campaignUpdateData.html = htmlToSave;
}

const campaign = await db.campaign.update({
where: { id: campaignId },
data: {
...data,
html,
domainId,
},
data: campaignUpdateData,
});
return campaign;
}),
Expand Down Expand Up @@ -240,6 +248,7 @@ export const campaignRouter = createTRPCRouter({
from: campaign.from,
subject: campaign.subject,
content: campaign.content,
html: campaign.html,
teamId: team.id,
domainId: campaign.domainId,
contactBookId: campaign.contactBookId,
Expand Down
82 changes: 82 additions & 0 deletions apps/web/src/server/public-api/api/campaigns/create-campaign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
import {
campaignCreateSchema,
CampaignCreateInput,
campaignResponseSchema,
parseScheduledAt,
} from "~/server/public-api/schemas/campaign-schema";
import {
createCampaignFromApi,
getCampaignForTeam,
scheduleCampaign,
} from "~/server/service/campaign-service";
const route = createRoute({
method: "post",
path: "/v1/campaigns",
request: {
body: {
required: true,
content: {
"application/json": {
schema: campaignCreateSchema,
},
},
},
},
responses: {
200: {
description: "Create a campaign",
content: {
"application/json": {
schema: campaignResponseSchema,
},
},
},
},
});

function createCampaign(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = c.var.team;
const body: CampaignCreateInput = c.req.valid("json");

const campaign = await createCampaignFromApi({
teamId: team.id,
apiKeyId: team.apiKeyId,
name: body.name,
from: body.from,
subject: body.subject,
previewText: body.previewText,
content: body.content,
html: body.html,
contactBookId: body.contactBookId,
replyTo: body.replyTo,
cc: body.cc,
bcc: body.bcc,
batchSize: body.batchSize,
});

if (body.sendNow || body.scheduledAt) {
const scheduledAtInput = body.sendNow
? new Date()
: parseScheduledAt(body.scheduledAt);

await scheduleCampaign({
campaignId: campaign.id,
teamId: team.id,
scheduledAt: scheduledAtInput,
batchSize: body.batchSize,
});
}

const latestCampaign = await getCampaignForTeam({
campaignId: campaign.id,
teamId: team.id,
});

return c.json(latestCampaign);
});
}

export default createCampaign;
49 changes: 49 additions & 0 deletions apps/web/src/server/public-api/api/campaigns/get-campaign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
import { getCampaignForTeam } from "~/server/service/campaign-service";
import { campaignResponseSchema } from "~/server/public-api/schemas/campaign-schema";

const route = createRoute({
method: "get",
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: "Get campaign details",
content: {
"application/json": {
schema: campaignResponseSchema,
},
},
},
},
});

function getCampaign(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = c.var.team;
const campaignId = c.req.param("campaignId");

const campaign = await getCampaignForTeam({
campaignId,
teamId: team.id,
});

return c.json(campaign);
});
}

export default getCampaign;
Loading