From 0fac96672b858e54688f748ffda2c9f09c44dfac Mon Sep 17 00:00:00 2001 From: Harish-Naruto Date: Tue, 16 Jun 2026 19:39:04 +0530 Subject: [PATCH] feat: add EmailTemplate model and CRUD endpoints for managing email templates --- .../migration.sql | 23 +++++ prisma/schema.prisma | 17 ++++ src/controllers/emailTemplate.controller.ts | 66 +++++++++++++ src/routes/email.ts | 94 +++++++++++++++++++ src/routes/index.ts | 2 + src/services/emailTemplate.service.ts | 47 ++++++++++ 6 files changed, 249 insertions(+) create mode 100644 prisma/migrations/20260616135723_email_format_support/migration.sql create mode 100644 src/controllers/emailTemplate.controller.ts create mode 100644 src/routes/email.ts create mode 100644 src/services/emailTemplate.service.ts diff --git a/prisma/migrations/20260616135723_email_format_support/migration.sql b/prisma/migrations/20260616135723_email_format_support/migration.sql new file mode 100644 index 0000000..7826244 --- /dev/null +++ b/prisma/migrations/20260616135723_email_format_support/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "EmailTemplate" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "htmlBody" TEXT NOT NULL, + "textBody" TEXT, + "createdById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedById" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EmailTemplate_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "EmailTemplate_name_key" ON "EmailTemplate"("name"); + +-- AddForeignKey +ALTER TABLE "EmailTemplate" ADD CONSTRAINT "EmailTemplate_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "Member"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EmailTemplate" ADD CONSTRAINT "EmailTemplate_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "Member"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7e1cf95..577e789 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -54,6 +54,8 @@ model Member { updatedProjects Project[] @relation("ProjectUpdatedBy") updatedSitePageContent SitePageContent[] @relation("SitePageContentUpdatedBy") updatedSiteActions SiteAction[] @relation("SiteActionUpdatedBy") + createdEmailTemplates EmailTemplate[] @relation("EmailTemplateCreatedBy") + updatedEmailTemplates EmailTemplate[] @relation("EmailTemplateUpdatedBy") } model Account { @@ -219,3 +221,18 @@ model SiteAction { updatedAt DateTime @updatedAt } + +model EmailTemplate { + id Int @id @default(autoincrement()) + name String @unique + subject String + htmlBody String + textBody String? + + createdBy Member? @relation("EmailTemplateCreatedBy", fields: [createdById], references: [id], onDelete: SetNull) + createdById String? + createdAt DateTime @default(now()) + updatedBy Member? @relation("EmailTemplateUpdatedBy", fields: [updatedById], references: [id], onDelete: SetNull) + updatedById String? + updatedAt DateTime @updatedAt +} diff --git a/src/controllers/emailTemplate.controller.ts b/src/controllers/emailTemplate.controller.ts new file mode 100644 index 0000000..ad27a33 --- /dev/null +++ b/src/controllers/emailTemplate.controller.ts @@ -0,0 +1,66 @@ +import { Request, Response } from "express"; +import * as emailTemplateService from "../services/emailTemplate.service"; +import { ApiError } from "../utils/apiError"; + +// GET /email/templates +export const listTemplates = async (_req: Request, res: Response) => { + const templates = await emailTemplateService.listTemplates(); + res.status(200).json({ success: true, templates }); +}; + +// GET /email/templates/:id +export const getTemplate = async (req: Request, res: Response) => { + const id = Number(req.params.id); + if (isNaN(id)) throw new ApiError("Invalid template id", 400); + + const template = await emailTemplateService.getTemplateById(id); + if (!template) throw new ApiError("Template not found", 404); + + res.status(200).json({ success: true, template }); +}; + +// POST /email/templates +export const createTemplate = async (req: Request, res: Response) => { + const { name, subject, htmlBody, textBody, createdById } = req.body; + + if (!name || !subject || !htmlBody) { + throw new ApiError("name, subject and htmlBody are required", 400); + } + + const template = await emailTemplateService.createTemplate( + name, + subject, + htmlBody, + textBody, + createdById, + ); + + res.status(201).json({ success: true, template }); +}; + +// PATCH /email/templates/:id +export const updateTemplate = async (req: Request, res: Response) => { + const id = Number(req.params.id); + if (isNaN(id)) throw new ApiError("Invalid template id", 400); + + const { name, subject, htmlBody, textBody, updatedById } = req.body; + + const template = await emailTemplateService.updateTemplate(id, { + name, + subject, + htmlBody, + textBody, + updatedById, + }); + + res.status(200).json({ success: true, template }); +}; + +// DELETE /email/templates/:id +export const deleteTemplate = async (req: Request, res: Response) => { + const id = Number(req.params.id); + if (isNaN(id)) throw new ApiError("Invalid template id", 400); + + await emailTemplateService.deleteTemplate(id); + res.status(200).json({ success: true, message: "Template deleted" }); +}; diff --git a/src/routes/email.ts b/src/routes/email.ts new file mode 100644 index 0000000..ec8f06b --- /dev/null +++ b/src/routes/email.ts @@ -0,0 +1,94 @@ +import express from "express"; +import * as emailTemplateCtrl from "../controllers/emailTemplate.controller"; + +export default function emailRouter() { + const router = express.Router(); + + /** + * @api {get} /email/templates List all email templates + * @apiName ListEmailTemplates + * @apiGroup EmailTemplate + * + * @apiSuccess {Object[]} templates Array of email templates. + * + * @apiExample {curl} Example usage: + * curl -X GET http://localhost:3000/api/v1/email/templates + */ + router.get("/templates", emailTemplateCtrl.listTemplates); + + /** + * @api {get} /email/templates/:id Get a single email template + * @apiName GetEmailTemplate + * @apiGroup EmailTemplate + * + * @apiParam (URL Params) {Number} id Template ID. + * + * @apiSuccess {Object} template Email template object. + * + * @apiExample {curl} Example usage: + * curl -X GET http://localhost:3000/api/v1/email/templates/1 + */ + router.get("/templates/:id", emailTemplateCtrl.getTemplate); + + /** + * @api {post} /email/templates Save a new email template + * @apiName CreateEmailTemplate + * @apiGroup EmailTemplate + * + * @apiBody {String} name Unique template name (e.g. "welcome"). (Required) + * @apiBody {String} subject Email subject line. (Required) + * @apiBody {String} htmlBody HTML email body. Supports {{name}}, {{email}}, + * {{whatsapp_link}}, {{discord_link}}, {{year}} + * placeholders. (Required) + * @apiBody {String} [textBody] Plain-text fallback body. + * + * @apiSuccess {Object} template Saved template object. + * @apiError (400) BadRequest Missing required fields. + * + * @apiExample {curl} Example usage: + * curl -X POST http://localhost:3000/api/v1/email/templates \ + * -H "Content-Type: application/json" \ + * -d '{ + * "name": "welcome", + * "subject": "Welcome to Call of Code!", + * "htmlBody": "

Hi {{name}}

Join WhatsApp: {{whatsapp_link}}

" + * }' + */ + router.post("/templates", emailTemplateCtrl.createTemplate); + + /** + * @api {patch} /email/templates/:id Update an email template + * @apiName UpdateEmailTemplate + * @apiGroup EmailTemplate + * + * @apiParam (URL Params) {Number} id Template ID. + * @apiBody {String} [name] New unique name. + * @apiBody {String} [subject] New subject. + * @apiBody {String} [htmlBody] New HTML body. + * @apiBody {String} [textBody] New plain-text body. + * + * @apiSuccess {Object} template Updated template object. + * + * @apiExample {curl} Example usage: + * curl -X PATCH http://localhost:3000/api/v1/email/templates/1 \ + * -H "Content-Type: application/json" \ + * -d '{"subject": "Welcome aboard!"}' + */ + router.patch("/templates/:id", emailTemplateCtrl.updateTemplate); + + /** + * @api {delete} /email/templates/:id Delete an email template + * @apiName DeleteEmailTemplate + * @apiGroup EmailTemplate + * + * @apiParam (URL Params) {Number} id Template ID. + * + * @apiSuccess {String} message Confirmation message. + * + * @apiExample {curl} Example usage: + * curl -X DELETE http://localhost:3000/api/v1/email/templates/1 + */ + router.delete("/templates/:id", emailTemplateCtrl.deleteTemplate); + + return router; +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 80a3e14..74ddb5b 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -9,6 +9,7 @@ import quetionsRouter from './questions' import progressRouter from './progress' import membersRouter from './members' import siteContentRouter from './site-content' +import emailRouter from './email' export default function routes(upload: Multer) { const router = Router(); @@ -27,6 +28,7 @@ export default function routes(upload: Multer) { router.use("/progress", progressRouter()); router.use("/site-content", siteContentRouter(upload)); + router.use("/email", emailRouter()); return router; } diff --git a/src/services/emailTemplate.service.ts b/src/services/emailTemplate.service.ts new file mode 100644 index 0000000..dcd4cbe --- /dev/null +++ b/src/services/emailTemplate.service.ts @@ -0,0 +1,47 @@ + import prisma from "../db/client"; +import { ApiError } from "../utils/apiError"; + +export const createTemplate = async ( + name: string, + subject: string, + htmlBody: string, + textBody?: string, + createdById?: string, +) => { + return await prisma.emailTemplate.create({ + data: { name, subject, htmlBody, textBody, createdById }, + }); +}; + +export const listTemplates = async () => { + return await prisma.emailTemplate.findMany({ + orderBy: { createdAt: "desc" }, + }); +}; + +export const getTemplateById = async (id: number) => { + return await prisma.emailTemplate.findUnique({ where: { id } }); +}; + +export const updateTemplate = async ( + id: number, + payload: Partial<{ + name: string; + subject: string; + htmlBody: string; + textBody: string; + updatedById: string; + }>, +) => { + const exists = await prisma.emailTemplate.findUnique({ where: { id } }); + if (!exists) throw new ApiError("Template not found", 404); + + return await prisma.emailTemplate.update({ where: { id }, data: payload }); +}; + +export const deleteTemplate = async (id: number) => { + const exists = await prisma.emailTemplate.findUnique({ where: { id } }); + if (!exists) throw new ApiError("Template not found", 404); + + return await prisma.emailTemplate.delete({ where: { id } }); +};