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>
468 lines
14 KiB
Go
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,
|
|
})
|
|
}
|