Add unsend campaign feature (#45)

* Add unsend email editor

Add email editor

Add more email editor

Add renderer partial

Add more marketing email features

* Add more campaign feature

* Add variables

* Getting there

* campaign is there mfs

* Add migration
This commit is contained in:
KM Koushik
2024-08-10 10:09:10 +10:00
committed by GitHub
parent 0c072579b9
commit 5ddc0a7bb9
92 changed files with 11766 additions and 338 deletions

View File

@@ -26,12 +26,32 @@ export const adminRouter = createTRPCRouter({
z.object({
region: z.string(),
unsendUrl: z.string().url(),
sendRate: z.number(),
transactionalQuota: z.number(),
})
)
.mutation(async ({ input }) => {
return SesSettingsService.createSesSetting({
region: input.region,
unsendUrl: input.unsendUrl,
sendingRateLimit: input.sendRate,
transactionalQuota: input.transactionalQuota,
});
}),
updateSesSettings: adminProcedure
.input(
z.object({
settingsId: z.string(),
sendRate: z.number(),
transactionalQuota: z.number(),
})
)
.mutation(async ({ input }) => {
return SesSettingsService.updateSesSetting({
id: input.settingsId,
sendingRateLimit: input.sendRate,
transactionalQuota: input.transactionalQuota,
});
}),

View File

@@ -0,0 +1,183 @@
import { Prisma } from "@prisma/client";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
teamProcedure,
createTRPCRouter,
campaignProcedure,
publicProcedure,
} from "~/server/api/trpc";
import {
sendCampaign,
subscribeContact,
} from "~/server/service/campaign-service";
import { validateDomainFromEmail } from "~/server/service/domain-service";
export const campaignRouter = createTRPCRouter({
getCampaigns: teamProcedure
.input(
z.object({
page: z.number().optional(),
})
)
.query(async ({ ctx: { db, team }, input }) => {
const page = input.page || 1;
const limit = 30;
const offset = (page - 1) * limit;
const whereConditions: Prisma.CampaignFindManyArgs["where"] = {
teamId: team.id,
};
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,
},
orderBy: {
createdAt: "desc",
},
skip: offset,
take: limit,
});
const [campaigns, count] = await Promise.all([campaignsP, countP]);
return { campaigns, totalPage: Math.ceil(count / limit) };
}),
createCampaign: teamProcedure
.input(
z.object({
name: z.string(),
from: z.string(),
subject: z.string(),
})
)
.mutation(async ({ ctx: { db, team }, input }) => {
const domain = await validateDomainFromEmail(input.from, team.id);
const campaign = await db.campaign.create({
data: {
...input,
teamId: team.id,
domainId: domain.id,
},
});
return campaign;
}),
updateCampaign: campaignProcedure
.input(
z.object({
name: z.string().optional(),
from: z.string().optional(),
subject: z.string().optional(),
previewText: z.string().optional(),
content: z.string().optional(),
contactBookId: z.string().optional(),
})
)
.mutation(async ({ ctx: { db, team, campaign: campaignOld }, input }) => {
const { campaignId, ...data } = input;
if (data.contactBookId) {
const contactBook = await db.contactBook.findUnique({
where: { id: data.contactBookId },
});
if (!contactBook) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Contact book not found",
});
}
}
let domainId = campaignOld.domainId;
if (data.from) {
const domain = await validateDomainFromEmail(data.from, team.id);
domainId = domain.id;
}
const campaign = await db.campaign.update({
where: { id: campaignId },
data: {
...data,
domainId,
},
});
return campaign;
}),
deleteCampaign: campaignProcedure.mutation(
async ({ ctx: { db, team }, input }) => {
const campaign = await db.campaign.delete({
where: { id: input.campaignId, teamId: team.id },
});
return campaign;
}
),
getCampaign: campaignProcedure.query(async ({ ctx: { db, team }, input }) => {
const campaign = await db.campaign.findUnique({
where: { id: input.campaignId, teamId: team.id },
});
if (!campaign) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Campaign not found",
});
}
if (campaign?.contactBookId) {
const contactBook = await db.contactBook.findUnique({
where: { id: campaign.contactBookId },
});
return { ...campaign, contactBook };
}
return { ...campaign, contactBook: null };
}),
sendCampaign: campaignProcedure.mutation(
async ({ ctx: { db, team }, input }) => {
await sendCampaign(input.campaignId);
}
),
reSubscribeContact: publicProcedure
.input(
z.object({
id: z.string(),
hash: z.string(),
})
)
.mutation(async ({ ctx: { db }, input }) => {
await subscribeContact(input.id, input.hash);
}),
duplicateCampaign: campaignProcedure.mutation(
async ({ ctx: { db, team, campaign }, input }) => {
const newCampaign = await db.campaign.create({
data: {
name: `${campaign.name} (Copy)`,
from: campaign.from,
subject: campaign.subject,
content: campaign.content,
teamId: team.id,
domainId: campaign.domainId,
contactBookId: campaign.contactBookId,
},
});
return newCampaign;
}
),
});

View File

@@ -0,0 +1,168 @@
import { Prisma } from "@prisma/client";
import { z } from "zod";
import {
contactBookProcedure,
createTRPCRouter,
teamProcedure,
} from "~/server/api/trpc";
import * as contactService from "~/server/service/contact-service";
export const contactsRouter = createTRPCRouter({
getContactBooks: teamProcedure.query(async ({ ctx: { db, team } }) => {
return db.contactBook.findMany({
where: {
teamId: team.id,
},
include: {
_count: {
select: { contacts: true },
},
},
});
}),
createContactBook: teamProcedure
.input(
z.object({
name: z.string(),
})
)
.mutation(async ({ ctx: { db, team }, input }) => {
const { name } = input;
const contactBook = await db.contactBook.create({
data: {
name,
teamId: team.id,
properties: {},
},
});
return contactBook;
}),
getContactBookDetails: contactBookProcedure.query(
async ({ ctx: { contactBook, db } }) => {
const [totalContacts, unsubscribedContacts] = await Promise.all([
db.contact.count({
where: { contactBookId: contactBook.id },
}),
db.contact.count({
where: { contactBookId: contactBook.id, subscribed: false },
}),
]);
return {
...contactBook,
totalContacts,
unsubscribedContacts,
};
}
),
updateContactBook: contactBookProcedure
.input(
z.object({
contactBookId: z.string(),
name: z.string().optional(),
properties: z.record(z.string()).optional(),
})
)
.mutation(async ({ ctx: { db }, input }) => {
const { contactBookId, ...data } = input;
return db.contactBook.update({
where: { id: contactBookId },
data,
});
}),
deleteContactBook: contactBookProcedure
.input(z.object({ contactBookId: z.string() }))
.mutation(async ({ ctx: { db }, input }) => {
return db.contactBook.delete({ where: { id: input.contactBookId } });
}),
contacts: contactBookProcedure
.input(
z.object({
page: z.number().optional(),
subscribed: z.boolean().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 }
: {}),
};
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,
},
orderBy: {
createdAt: "desc",
},
skip: offset,
take: limit,
});
const [contacts, count] = await Promise.all([contactsP, countP]);
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(),
})
),
})
)
.mutation(async ({ ctx: { contactBook }, input }) => {
return contactService.bulkAddContacts(contactBook.id, input.contacts);
}),
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 ({ input }) => {
const { contactId, ...contact } = input;
return contactService.updateContact(contactId, contact);
}),
deleteContact: contactBookProcedure
.input(z.object({ contactId: z.string() }))
.mutation(async ({ input }) => {
return contactService.deleteContact(input.contactId);
}),
});

View File

@@ -175,7 +175,7 @@ export const emailRouter = createTRPCRouter({
select: {
emailEvents: {
orderBy: {
createdAt: "asc",
status: "asc",
},
},
id: true,