Yimaru-BackEnd/internal/web_server/handlers/email_template.go
Yared Yemane 5937c5505a Add admin-managed email templates and use them for OTP delivery
Adds CRUD and preview APIs, RBAC permissions, seeded system templates, and migrates OTP email/SMS to template rendering.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 01:28:48 -07:00

468 lines
14 KiB
Go

package handlers
import (
"Yimaru-Backend/internal/domain"
"errors"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
)
type createEmailTemplateReq struct {
Slug string `json:"slug" validate:"required"`
Name string `json:"name" validate:"required"`
Subject string `json:"subject" validate:"required"`
BodyText string `json:"body_text" validate:"required"`
BodyHTML string `json:"body_html" validate:"required"`
Variables []string `json:"variables"`
Status *string `json:"status"`
}
type updateEmailTemplateReq struct {
Name *string `json:"name"`
Subject *string `json:"subject"`
BodyText *string `json:"body_text"`
BodyHTML *string `json:"body_html"`
Variables []string `json:"variables"`
Status *string `json:"status"`
}
type previewEmailTemplateReq struct {
Variables map[string]any `json:"variables"`
}
type emailTemplateRes struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Subject string `json:"subject"`
BodyText string `json:"body_text"`
BodyHTML string `json:"body_html"`
Variables []string `json:"variables"`
IsSystem bool `json:"is_system"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
UpdatedAt *string `json:"updated_at,omitempty"`
}
type listEmailTemplatesRes struct {
Templates []emailTemplateRes `json:"templates"`
TotalCount int64 `json:"total_count"`
}
func mapEmailTemplateToRes(t domain.EmailTemplate) emailTemplateRes {
var updatedAt *string
if t.UpdatedAt != nil {
value := t.UpdatedAt.String()
updatedAt = &value
}
variables := t.Variables
if variables == nil {
variables = []string{}
}
return emailTemplateRes{
ID: t.ID,
Slug: t.Slug,
Name: t.Name,
Subject: t.Subject,
BodyText: t.BodyText,
BodyHTML: t.BodyHTML,
Variables: variables,
IsSystem: t.IsSystem,
Status: t.Status,
CreatedAt: t.CreatedAt.String(),
UpdatedAt: updatedAt,
}
}
// ListEmailTemplatesAdmin godoc
// @Summary List email templates (admin)
// @Description Returns email templates for admin management
// @Tags email-templates
// @Produce json
// @Param status query string false "ACTIVE or INACTIVE"
// @Param query query string false "Search by slug, name, or subject"
// @Param limit query int false "Limit (default 20)"
// @Param offset query int false "Offset (default 0)"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/email-templates [get]
func (h *Handler) ListEmailTemplatesAdmin(c *fiber.Ctx) error {
status := strings.ToUpper(strings.TrimSpace(c.Query("status")))
var statusPtr *string
if status != "" {
statusPtr = &status
}
search := strings.TrimSpace(c.Query("query"))
var searchPtr *string
if search != "" {
searchPtr = &search
}
limit, err := strconv.Atoi(c.Query("limit", "20"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid limit",
Error: err.Error(),
})
}
offset, err := strconv.Atoi(c.Query("offset", "0"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid offset",
Error: err.Error(),
})
}
templates, total, err := h.emailTemplateSvc.ListEmailTemplates(c.Context(), statusPtr, searchPtr, int32(limit), int32(offset))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to list email templates",
Error: err.Error(),
})
}
out := make([]emailTemplateRes, 0, len(templates))
for _, tmpl := range templates {
out = append(out, mapEmailTemplateToRes(tmpl))
}
return c.JSON(domain.Response{
Message: "Email templates retrieved successfully",
Data: listEmailTemplatesRes{
Templates: out,
TotalCount: total,
},
})
}
// GetEmailTemplateByIDAdmin godoc
// @Summary Get email template by ID (admin)
// @Description Returns one email template regardless of status
// @Tags email-templates
// @Produce json
// @Param id path int true "Email template ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/email-templates/{id} [get]
func (h *Handler) GetEmailTemplateByIDAdmin(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid email template ID",
Error: "id must be a positive integer",
})
}
tmpl, err := h.emailTemplateSvc.GetEmailTemplateByID(c.Context(), id, true)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Email template not found",
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get email template",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Email template retrieved successfully",
Data: mapEmailTemplateToRes(tmpl),
})
}
// GetEmailTemplateBySlugAdmin godoc
// @Summary Get email template by slug (admin)
// @Description Returns one email template by slug regardless of status
// @Tags email-templates
// @Produce json
// @Param slug path string true "Email template slug"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/email-templates/slug/{slug} [get]
func (h *Handler) GetEmailTemplateBySlugAdmin(c *fiber.Ctx) error {
slug := strings.TrimSpace(c.Params("slug"))
if slug == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid email template slug",
Error: "slug is required",
})
}
tmpl, err := h.emailTemplateSvc.GetEmailTemplateBySlug(c.Context(), slug, true)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Email template not found",
})
}
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to get email template",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Email template retrieved successfully",
Data: mapEmailTemplateToRes(tmpl),
})
}
// CreateEmailTemplate godoc
// @Summary Create email template
// @Description Creates a new custom email template
// @Tags email-templates
// @Accept json
// @Produce json
// @Param body body createEmailTemplateReq true "Create email template payload"
// @Success 201 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/email-templates [post]
func (h *Handler) CreateEmailTemplate(c *fiber.Ctx) error {
var req createEmailTemplateReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
tmpl, err := h.emailTemplateSvc.CreateEmailTemplate(c.Context(), domain.CreateEmailTemplateInput{
Slug: req.Slug,
Name: req.Name,
Subject: req.Subject,
BodyText: req.BodyText,
BodyHTML: req.BodyHTML,
Variables: req.Variables,
Status: req.Status,
})
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to create email template",
Error: err.Error(),
})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Email template created successfully",
Data: mapEmailTemplateToRes(tmpl),
})
}
// UpdateEmailTemplate godoc
// @Summary Update email template
// @Description Updates an existing email template
// @Tags email-templates
// @Accept json
// @Produce json
// @Param id path int true "Email template ID"
// @Param body body updateEmailTemplateReq true "Update email template payload"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/email-templates/{id} [put]
func (h *Handler) UpdateEmailTemplate(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid email template ID",
Error: "id must be a positive integer",
})
}
var req updateEmailTemplateReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
tmpl, err := h.emailTemplateSvc.UpdateEmailTemplate(c.Context(), id, domain.UpdateEmailTemplateInput{
Name: req.Name,
Subject: req.Subject,
BodyText: req.BodyText,
BodyHTML: req.BodyHTML,
Variables: req.Variables,
Status: req.Status,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Email template not found",
})
}
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to update email template",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Email template updated successfully",
Data: mapEmailTemplateToRes(tmpl),
})
}
// DeleteEmailTemplate godoc
// @Summary Delete email template
// @Description Deletes a custom email template
// @Tags email-templates
// @Produce json
// @Param id path int true "Email template ID"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/email-templates/{id} [delete]
func (h *Handler) DeleteEmailTemplate(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid email template ID",
Error: "id must be a positive integer",
})
}
if err := h.emailTemplateSvc.DeleteEmailTemplate(c.Context(), id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Email template not found",
})
}
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to delete email template",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Email template deleted successfully",
Data: fiber.Map{"id": id},
})
}
// PreviewEmailTemplateBySlug godoc
// @Summary Preview email template by slug
// @Description Renders an email template with sample variables without sending
// @Tags email-templates
// @Accept json
// @Produce json
// @Param slug path string true "Email template slug"
// @Param body body previewEmailTemplateReq true "Preview variables"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/email-templates/slug/{slug}/preview [post]
func (h *Handler) PreviewEmailTemplateBySlug(c *fiber.Ctx) error {
slug := strings.TrimSpace(c.Params("slug"))
if slug == "" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid email template slug",
Error: "slug is required",
})
}
var req previewEmailTemplateReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
rendered, err := h.emailTemplateSvc.PreviewEmailTemplate(c.Context(), domain.PreviewEmailTemplateInput{
Slug: slug,
Variables: req.Variables,
})
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to preview email template",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Email template preview generated successfully",
Data: rendered,
})
}
// PreviewEmailTemplateByID godoc
// @Summary Preview email template by ID
// @Description Renders an email template with sample variables without sending
// @Tags email-templates
// @Accept json
// @Produce json
// @Param id path int true "Email template ID"
// @Param body body previewEmailTemplateReq true "Preview variables"
// @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 404 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/admin/email-templates/{id}/preview [post]
func (h *Handler) PreviewEmailTemplateByID(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid email template ID",
Error: "id must be a positive integer",
})
}
var req previewEmailTemplateReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
tmpl, err := h.emailTemplateSvc.GetEmailTemplateByID(c.Context(), id, true)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Email template not found",
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to get email template",
Error: err.Error(),
})
}
rendered, err := h.emailTemplateSvc.PreviewEmailTemplate(c.Context(), domain.PreviewEmailTemplateInput{
Slug: tmpl.Slug,
Variables: req.Variables,
})
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to preview email template",
Error: err.Error(),
})
}
return c.JSON(domain.Response{
Message: "Email template preview generated successfully",
Data: rendered,
})
}