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>
319 lines
8.1 KiB
Go
319 lines
8.1 KiB
Go
package emailtemplates
|
|
|
|
import (
|
|
"Yimaru-Backend/internal/domain"
|
|
"Yimaru-Backend/internal/ports"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
var (
|
|
errSlugRequired = errors.New("slug is required")
|
|
errInvalidSlug = errors.New("slug must start with a letter and contain only lowercase letters, numbers, and underscores")
|
|
errInvalidStatus = fmt.Errorf("status must be one of %s, %s", domain.EmailTemplateStatusActive, domain.EmailTemplateStatusInactive)
|
|
)
|
|
|
|
const cacheTTL = 2 * time.Minute
|
|
|
|
type cacheEntry struct {
|
|
template domain.EmailTemplate
|
|
expiresAt time.Time
|
|
}
|
|
|
|
type Service struct {
|
|
store ports.EmailTemplateStore
|
|
|
|
mu sync.RWMutex
|
|
cache map[string]cacheEntry
|
|
}
|
|
|
|
func NewService(store ports.EmailTemplateStore) *Service {
|
|
return &Service{
|
|
store: store,
|
|
cache: make(map[string]cacheEntry),
|
|
}
|
|
}
|
|
|
|
func (s *Service) CreateEmailTemplate(ctx context.Context, input domain.CreateEmailTemplateInput) (domain.EmailTemplate, error) {
|
|
slug, err := normalizeSlug(input.Slug)
|
|
if err != nil {
|
|
return domain.EmailTemplate{}, err
|
|
}
|
|
|
|
input.Slug = slug
|
|
input.Name = strings.TrimSpace(input.Name)
|
|
input.Subject = strings.TrimSpace(input.Subject)
|
|
input.BodyText = strings.TrimSpace(input.BodyText)
|
|
input.BodyHTML = strings.TrimSpace(input.BodyHTML)
|
|
input.Variables = normalizeVariables(input.Variables)
|
|
|
|
if input.Name == "" {
|
|
return domain.EmailTemplate{}, fmt.Errorf("name is required")
|
|
}
|
|
if input.Subject == "" {
|
|
return domain.EmailTemplate{}, fmt.Errorf("subject is required")
|
|
}
|
|
if input.BodyText == "" {
|
|
return domain.EmailTemplate{}, fmt.Errorf("body_text is required")
|
|
}
|
|
if input.BodyHTML == "" {
|
|
return domain.EmailTemplate{}, fmt.Errorf("body_html is required")
|
|
}
|
|
|
|
status, err := normalizeStatus(input.Status)
|
|
if err != nil {
|
|
return domain.EmailTemplate{}, err
|
|
}
|
|
input.Status = &status
|
|
|
|
if err := s.validateTemplateSyntax(input.Subject, input.BodyText, input.BodyHTML); err != nil {
|
|
return domain.EmailTemplate{}, err
|
|
}
|
|
|
|
tmpl, err := s.store.CreateEmailTemplate(ctx, input)
|
|
if err != nil {
|
|
return domain.EmailTemplate{}, err
|
|
}
|
|
|
|
s.invalidateCache(tmpl.Slug)
|
|
return tmpl, nil
|
|
}
|
|
|
|
func (s *Service) UpdateEmailTemplate(ctx context.Context, id int64, input domain.UpdateEmailTemplateInput) (domain.EmailTemplate, error) {
|
|
if id <= 0 {
|
|
return domain.EmailTemplate{}, fmt.Errorf("invalid email template id")
|
|
}
|
|
|
|
if input.Name != nil {
|
|
trimmed := strings.TrimSpace(*input.Name)
|
|
if trimmed == "" {
|
|
return domain.EmailTemplate{}, fmt.Errorf("name cannot be empty")
|
|
}
|
|
input.Name = &trimmed
|
|
}
|
|
if input.Subject != nil {
|
|
trimmed := strings.TrimSpace(*input.Subject)
|
|
if trimmed == "" {
|
|
return domain.EmailTemplate{}, fmt.Errorf("subject cannot be empty")
|
|
}
|
|
input.Subject = &trimmed
|
|
}
|
|
if input.BodyText != nil {
|
|
trimmed := strings.TrimSpace(*input.BodyText)
|
|
if trimmed == "" {
|
|
return domain.EmailTemplate{}, fmt.Errorf("body_text cannot be empty")
|
|
}
|
|
input.BodyText = &trimmed
|
|
}
|
|
if input.BodyHTML != nil {
|
|
trimmed := strings.TrimSpace(*input.BodyHTML)
|
|
if trimmed == "" {
|
|
return domain.EmailTemplate{}, fmt.Errorf("body_html cannot be empty")
|
|
}
|
|
input.BodyHTML = &trimmed
|
|
}
|
|
if input.Variables != nil {
|
|
input.Variables = normalizeVariables(input.Variables)
|
|
}
|
|
if input.Status != nil {
|
|
status, err := normalizeStatus(input.Status)
|
|
if err != nil {
|
|
return domain.EmailTemplate{}, err
|
|
}
|
|
input.Status = &status
|
|
}
|
|
|
|
current, err := s.store.GetEmailTemplateByID(ctx, id, true)
|
|
if err != nil {
|
|
return domain.EmailTemplate{}, err
|
|
}
|
|
|
|
subject := current.Subject
|
|
if input.Subject != nil {
|
|
subject = *input.Subject
|
|
}
|
|
bodyText := current.BodyText
|
|
if input.BodyText != nil {
|
|
bodyText = *input.BodyText
|
|
}
|
|
bodyHTML := current.BodyHTML
|
|
if input.BodyHTML != nil {
|
|
bodyHTML = *input.BodyHTML
|
|
}
|
|
|
|
if err := s.validateTemplateSyntax(subject, bodyText, bodyHTML); err != nil {
|
|
return domain.EmailTemplate{}, err
|
|
}
|
|
|
|
tmpl, err := s.store.UpdateEmailTemplate(ctx, id, input)
|
|
if err != nil {
|
|
return domain.EmailTemplate{}, err
|
|
}
|
|
|
|
s.invalidateCache(tmpl.Slug)
|
|
return tmpl, nil
|
|
}
|
|
|
|
func (s *Service) GetEmailTemplateByID(ctx context.Context, id int64, includeInactive bool) (domain.EmailTemplate, error) {
|
|
if id <= 0 {
|
|
return domain.EmailTemplate{}, fmt.Errorf("invalid email template id")
|
|
}
|
|
return s.store.GetEmailTemplateByID(ctx, id, includeInactive)
|
|
}
|
|
|
|
func (s *Service) GetEmailTemplateBySlug(ctx context.Context, slug string, includeInactive bool) (domain.EmailTemplate, error) {
|
|
normalized, err := normalizeSlug(slug)
|
|
if err != nil {
|
|
return domain.EmailTemplate{}, err
|
|
}
|
|
return s.store.GetEmailTemplateBySlug(ctx, normalized, includeInactive)
|
|
}
|
|
|
|
func (s *Service) ListEmailTemplates(ctx context.Context, status *string, query *string, limit int32, offset int32) ([]domain.EmailTemplate, int64, error) {
|
|
if status != nil {
|
|
normalized, err := normalizeStatus(status)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
status = &normalized
|
|
}
|
|
if query != nil {
|
|
trimmed := strings.TrimSpace(*query)
|
|
if trimmed == "" {
|
|
query = nil
|
|
} else {
|
|
query = &trimmed
|
|
}
|
|
}
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
if limit > 200 {
|
|
limit = 200
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
return s.store.ListEmailTemplates(ctx, status, query, limit, offset)
|
|
}
|
|
|
|
func (s *Service) DeleteEmailTemplate(ctx context.Context, id int64) error {
|
|
if id <= 0 {
|
|
return fmt.Errorf("invalid email template id")
|
|
}
|
|
|
|
tmpl, err := s.store.GetEmailTemplateByID(ctx, id, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tmpl.IsSystem {
|
|
return fmt.Errorf("system email templates cannot be deleted")
|
|
}
|
|
|
|
if err := s.store.DeleteEmailTemplate(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.invalidateCache(tmpl.Slug)
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) PreviewEmailTemplate(ctx context.Context, input domain.PreviewEmailTemplateInput) (domain.RenderedEmail, error) {
|
|
slug, err := normalizeSlug(input.Slug)
|
|
if err != nil {
|
|
return domain.RenderedEmail{}, err
|
|
}
|
|
|
|
tmpl, err := s.resolveTemplate(ctx, slug, true)
|
|
if err != nil {
|
|
return domain.RenderedEmail{}, err
|
|
}
|
|
|
|
return renderTemplateFields(tmpl, input.Variables)
|
|
}
|
|
|
|
func (s *Service) Render(ctx context.Context, slug string, data map[string]any) (domain.RenderedEmail, error) {
|
|
normalized, err := normalizeSlug(slug)
|
|
if err != nil {
|
|
return domain.RenderedEmail{}, err
|
|
}
|
|
|
|
tmpl, err := s.resolveTemplate(ctx, normalized, false)
|
|
if err != nil {
|
|
return domain.RenderedEmail{}, err
|
|
}
|
|
|
|
return renderTemplateFields(tmpl, data)
|
|
}
|
|
|
|
func (s *Service) resolveTemplate(ctx context.Context, slug string, includeInactive bool) (domain.EmailTemplate, error) {
|
|
if !includeInactive {
|
|
if tmpl, ok := s.getCached(slug); ok {
|
|
return tmpl, nil
|
|
}
|
|
}
|
|
|
|
tmpl, err := s.store.GetEmailTemplateBySlug(ctx, slug, includeInactive)
|
|
if err == nil {
|
|
if !includeInactive && tmpl.Status == domain.EmailTemplateStatusActive {
|
|
s.setCached(slug, tmpl)
|
|
}
|
|
return tmpl, nil
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.EmailTemplate{}, err
|
|
}
|
|
|
|
fallback, ok := defaultTemplate(slug)
|
|
if !ok {
|
|
return domain.EmailTemplate{}, fmt.Errorf("email template not found: %s", slug)
|
|
}
|
|
return fallback, nil
|
|
}
|
|
|
|
func (s *Service) validateTemplateSyntax(subject, bodyText, bodyHTML string) error {
|
|
if _, err := newTextTemplate("validate-subject", subject); err != nil {
|
|
return fmt.Errorf("invalid subject template: %w", err)
|
|
}
|
|
if _, err := newTextTemplate("validate-text", bodyText); err != nil {
|
|
return fmt.Errorf("invalid body_text template: %w", err)
|
|
}
|
|
if _, err := newHTMLTemplate("validate-html", bodyHTML); err != nil {
|
|
return fmt.Errorf("invalid body_html template: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) getCached(slug string) (domain.EmailTemplate, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
entry, ok := s.cache[slug]
|
|
if !ok || time.Now().After(entry.expiresAt) {
|
|
return domain.EmailTemplate{}, false
|
|
}
|
|
return entry.template, true
|
|
}
|
|
|
|
func (s *Service) setCached(slug string, tmpl domain.EmailTemplate) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.cache[slug] = cacheEntry{
|
|
template: tmpl,
|
|
expiresAt: time.Now().Add(cacheTTL),
|
|
}
|
|
}
|
|
|
|
func (s *Service) invalidateCache(slug string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.cache, slug)
|
|
}
|