feat: add templates for transactional emails (#103)

* add template migration & router

* template CRUD

* templated transactional emails API

* zod schema fix & rearranging template columns
This commit is contained in:
Ganapathy S
2025-03-07 00:50:25 +05:30
committed by KM Koushik
parent 1c2417df2f
commit 38314a35dc
16 changed files with 981 additions and 8 deletions

View File

@@ -6,6 +6,7 @@ import { teamRouter } from "./routers/team";
import { adminRouter } from "./routers/admin";
import { contactsRouter } from "./routers/contacts";
import { campaignRouter } from "./routers/campaign";
import { templateRouter } from "./routers/template";
/**
* This is the primary router for your server.
@@ -20,6 +21,7 @@ export const appRouter = createTRPCRouter({
admin: adminRouter,
contacts: contactsRouter,
campaign: campaignRouter,
template: templateRouter,
});
// export type definition of API

View File

@@ -16,9 +16,8 @@ import {
} from "~/server/service/campaign-service";
import { validateDomainFromEmail } from "~/server/service/domain-service";
import {
DEFAULT_BUCKET,
getDocumentUploadUrl,
isStorageConfigured,
isStorageConfigured
} from "~/server/service/storage-service";
const statuses = Object.values(CampaignStatus) as [CampaignStatus];

View File

@@ -0,0 +1,170 @@
import { Prisma } from "@prisma/client";
import { TRPCError } from "@trpc/server";
import { EmailRenderer } from "@unsend/email-editor/src/renderer";
import { z } from "zod";
import { env } from "~/env";
import {
teamProcedure,
createTRPCRouter,
templateProcedure
} from "~/server/api/trpc";
import { nanoid } from "~/server/nanoid";
import {
getDocumentUploadUrl,
isStorageConfigured
} from "~/server/service/storage-service";
export const templateRouter = createTRPCRouter({
getTemplates: 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.TemplateFindManyArgs["where"] = {
teamId: team.id,
};
const countP = db.template.count({ where: whereConditions });
const templatesP = db.template.findMany({
where: whereConditions,
select: {
id: true,
name: true,
subject: true,
createdAt: true,
updatedAt: true,
html: true,
},
orderBy: {
createdAt: "desc",
},
skip: offset,
take: limit,
});
const [templates, count] = await Promise.all([templatesP, countP]);
return { templates, totalPage: Math.ceil(count / limit) };
}),
createTemplate: teamProcedure
.input(
z.object({
name: z.string(),
subject: z.string(),
})
)
.mutation(async ({ ctx: { db, team }, input }) => {
const template = await db.template.create({
data: {
...input,
teamId: team.id,
},
});
return template;
}),
updateTemplate: templateProcedure
.input(
z.object({
name: z.string().optional(),
subject: z.string().optional(),
content: z.string().optional(),
})
)
.mutation(async ({ ctx: { db }, input }) => {
const { templateId, ...data } = input;
let html: string | null = null;
if (data.content) {
const jsonContent = data.content ? JSON.parse(data.content) : null;
const renderer = new EmailRenderer(jsonContent);
html = await renderer.render();
}
const template = await db.template.update({
where: { id: templateId },
data: {
...data,
html,
},
});
return template;
}),
deleteTemplate: templateProcedure.mutation(
async ({ ctx: { db, team }, input }) => {
const template = await db.template.delete({
where: { id: input.templateId, teamId: team.id },
});
return template;
}
),
getTemplate: templateProcedure.query(async ({ ctx: { db, team }, input }) => {
const template = await db.template.findUnique({
where: { id: input.templateId, teamId: team.id },
});
if (!template) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Template not found",
});
}
const imageUploadSupported = isStorageConfigured();
return {
...template,
imageUploadSupported,
};
}),
duplicateTemplate: templateProcedure.mutation(
async ({ ctx: { db, team, template }, input }) => {
const newTemplate = await db.template.create({
data: {
name: `${template.name} (Copy)`,
subject: template.subject,
content: template.content,
teamId: team.id
},
});
return newTemplate;
}
),
generateImagePresignedUrl: templateProcedure
.input(
z.object({
name: z.string(),
type: z.string(),
})
)
.mutation(async ({ ctx: { team }, input }) => {
const extension = input.name.split(".").pop();
const randomName = `${nanoid()}.${extension}`;
const url = await getDocumentUploadUrl(
`${team.id}/${randomName}`,
input.type
);
const imageUrl = `${env.S3_COMPATIBLE_PUBLIC_URL}/${team.id}/${randomName}`;
return { uploadUrl: url, imageUrl };
}),
});

View File

@@ -204,6 +204,27 @@ export const campaignProcedure = teamProcedure
return next({ ctx: { ...ctx, campaign } });
});
export const templateProcedure = teamProcedure
.input(
z.object({
templateId: z.string(),
})
)
.use(async ({ ctx, next, input }) => {
const template = await db.template.findUnique({
where: { id: input.templateId, teamId: ctx.team.id },
});
if (!template) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Template not found",
});
}
return next({ ctx: { ...ctx, template } });
});
/**
* To manage application settings, for hosted version, authenticated users will be considered as admin
*/