Yimaru-BackEnd/internal/services/emailtemplates/service.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

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)
}