Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
72 changes: 55 additions & 17 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 All @@ -142,8 +146,6 @@ function CampaignEditor({
);
}

console.log("file type: ", file.type);

const { uploadUrl, imageUrl } = await getUploadUrl.mutateAsync({
name: file.name,
type: file.type,
Expand Down Expand Up @@ -175,7 +177,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 +235,9 @@ function CampaignEditor({
setSubject(e.target.value);
}}
onBlur={() => {
if (isApiCampaign) {
return;
}
if (subject === campaign.subject || !subject) {
return;
}
Expand All @@ -245,6 +255,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 +275,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<[email protected]>"
onBlur={() => {
if (isApiCampaign) {
return;
}
if (from === campaign.from || !from) {
return;
}
Expand All @@ -279,6 +294,8 @@ function CampaignEditor({
}
);
}}
disabled={isApiCampaign}
readOnly={isApiCampaign}
/>
</div>
<div className="flex items-center gap-4">
Expand All @@ -294,6 +311,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="[email protected]"
onBlur={() => {
if (isApiCampaign) {
return;
}
if (replyTo === campaign.replyTo[0]) {
return;
}
Expand All @@ -310,6 +330,8 @@ function CampaignEditor({
}
);
}}
disabled={isApiCampaign}
readOnly={isApiCampaign}
/>
</div>

Expand All @@ -324,6 +346,9 @@ function CampaignEditor({
setPreviewText(e.target.value);
}}
onBlur={() => {
if (isApiCampaign) {
return;
}
if (
previewText === campaign.previewText ||
!previewText
Expand All @@ -344,6 +369,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 +382,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 +426,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
31 changes: 19 additions & 12 deletions apps/web/src/server/api/routers/campaign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,14 @@ 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 { html: htmlInput, ...data } = input;
const campaignId = campaignOld.id;
if (data.contactBookId) {
const contactBook = await db.contactBook.findUnique({
where: { id: data.contactBookId },
Expand All @@ -143,22 +145,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 148 to +157
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 @@ -201,10 +210,7 @@ export const campaignRouter = createTRPCRouter({
teamId: team.id,
campaignId: campaign.id,
},
orderBy: [
{ updatedAt: "desc" },
{ createdAt: "desc" },
],
orderBy: [{ updatedAt: "desc" }, { createdAt: "desc" }],
take: 10,
select: {
id: true,
Expand Down Expand Up @@ -240,6 +246,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