diff --git a/cmd/main.go b/cmd/main.go index 466acde..1ccea52 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -19,6 +19,7 @@ import ( "Yimaru-Backend/internal/services/authentication" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" coursesservice "Yimaru-Backend/internal/services/courses" + "Yimaru-Backend/internal/services/emailtemplates" "Yimaru-Backend/internal/services/examprep" "Yimaru-Backend/internal/services/faqs" issuereporting "Yimaru-Backend/internal/services/issue_reporting" @@ -107,16 +108,14 @@ func main() { settingSvc := settings.NewService(settingRepo) messengerSvc := messenger.NewService(settingSvc, cfg) - // statSvc := stats.NewService( - // repository.NewCompanyStatStore(store), - // repository.NewBranchStatStore(store), - // ) + emailTemplateSvc := emailtemplates.NewService(repository.NewEmailTemplateStore(store)) userSvc := user.NewService( repository.NewTokenStore(store), repository.NewUserStore(store), repository.NewOTPStore(store), messengerSvc, + emailTemplateSvc, cfg, ) @@ -468,6 +467,7 @@ func main() { assessmentSvc, questionsSvc, faqSvc, + emailTemplateSvc, personasSvc, examPrepSvc, programSvc, diff --git a/db/migrations/000066_email_templates.down.sql b/db/migrations/000066_email_templates.down.sql new file mode 100644 index 0000000..a4cc7f1 --- /dev/null +++ b/db/migrations/000066_email_templates.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS email_templates; diff --git a/db/migrations/000066_email_templates.up.sql b/db/migrations/000066_email_templates.up.sql new file mode 100644 index 0000000..370cd13 --- /dev/null +++ b/db/migrations/000066_email_templates.up.sql @@ -0,0 +1,70 @@ +CREATE TABLE IF NOT EXISTS email_templates ( + id BIGSERIAL PRIMARY KEY, + slug VARCHAR(100) NOT NULL UNIQUE, + name VARCHAR(200) NOT NULL, + subject TEXT NOT NULL, + body_text TEXT NOT NULL, + body_html TEXT NOT NULL, + variables JSONB NOT NULL DEFAULT '[]'::jsonb, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INACTIVE')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_email_templates_status ON email_templates(status); +CREATE INDEX IF NOT EXISTS idx_email_templates_slug ON email_templates(slug); + +INSERT INTO email_templates (slug, name, subject, body_text, body_html, variables, is_system, status) +VALUES +( + 'otp', + 'One-Time Password', + 'Yimaru - One Time Password', + 'Welcome to Yimaru Online Learning Platform{{if .FirstName}}, {{.FirstName}}{{end}}. Your OTP is {{.OTP}}. It expires in {{.ExpiresMinutes}} minutes. Please do not share it with anyone.', + '
Welcome to Yimaru Online Learning Platform{{if .FirstName}}, {{.FirstName}}{{end}}.
Your one-time password is {{.OTP}}.
It expires in {{.ExpiresMinutes}} minutes. Please do not share it with anyone.
', + '["OTP", "FirstName", "ExpiresMinutes"]'::jsonb, + TRUE, + 'ACTIVE' +), +( + 'invitation', + 'User Invitation', + 'You are invited to join Yimaru', + 'Hi{{if .FirstName}} {{.FirstName}}{{end}}, you have been invited{{if .InviterName}} by {{.InviterName}}{{end}} to join Yimaru Online Learning Platform. Accept your invitation: {{.InviteLink}}', + 'Hi{{if .FirstName}} {{.FirstName}}{{end}},
You have been invited{{if .InviterName}} by {{.InviterName}}{{end}} to join Yimaru Online Learning Platform.
', + '["FirstName", "InviterName", "InviteLink"]'::jsonb, + TRUE, + 'ACTIVE' +), +( + 'password_reset', + 'Password Reset', + 'Reset your Yimaru password', + 'Hi{{if .FirstName}} {{.FirstName}}{{end}}, use this link to reset your password: {{.ResetLink}}. The link expires in {{.ExpiresMinutes}} minutes.', + 'Hi{{if .FirstName}} {{.FirstName}}{{end}},
Use the link below to reset your password. It expires in {{.ExpiresMinutes}} minutes.
', + '["FirstName", "ResetLink", "ExpiresMinutes"]'::jsonb, + TRUE, + 'ACTIVE' +), +( + 'welcome', + 'Welcome Email', + 'Welcome to Yimaru', + 'Hi{{if .FirstName}} {{.FirstName}}{{end}}, welcome to Yimaru Online Learning Platform! Sign in at {{.LoginURL}} to get started.', + 'Hi{{if .FirstName}} {{.FirstName}}{{end}},
Welcome to Yimaru Online Learning Platform!
', + '["FirstName", "LoginURL"]'::jsonb, + TRUE, + 'ACTIVE' +), +( + 'custom_message', + 'Custom Message', + '{{.Subject}}', + '{{.Message}}', + '{{.Message}}
', + '["Subject", "Message"]'::jsonb, + TRUE, + 'ACTIVE' +) +ON CONFLICT (slug) DO NOTHING; diff --git a/internal/domain/email_template.go b/internal/domain/email_template.go new file mode 100644 index 0000000..71ddd00 --- /dev/null +++ b/internal/domain/email_template.go @@ -0,0 +1,58 @@ +package domain + +import "time" + +const ( + EmailTemplateStatusActive = "ACTIVE" + EmailTemplateStatusInactive = "INACTIVE" + + EmailTemplateSlugOTP = "otp" + EmailTemplateSlugInvitation = "invitation" + EmailTemplateSlugPasswordReset = "password_reset" + EmailTemplateSlugWelcome = "welcome" + EmailTemplateSlugCustomMessage = "custom_message" +) + +type EmailTemplate 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 time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type RenderedEmail struct { + Subject string `json:"subject"` + Text string `json:"text"` + HTML string `json:"html"` +} + +type CreateEmailTemplateInput struct { + Slug string + Name string + Subject string + BodyText string + BodyHTML string + Variables []string + Status *string +} + +type UpdateEmailTemplateInput struct { + Name *string + Subject *string + BodyText *string + BodyHTML *string + Variables []string + Status *string +} + +type PreviewEmailTemplateInput struct { + Slug string + Variables map[string]any +} diff --git a/internal/ports/email_template.go b/internal/ports/email_template.go new file mode 100644 index 0000000..664077d --- /dev/null +++ b/internal/ports/email_template.go @@ -0,0 +1,15 @@ +package ports + +import ( + "Yimaru-Backend/internal/domain" + "context" +) + +type EmailTemplateStore interface { + CreateEmailTemplate(ctx context.Context, input domain.CreateEmailTemplateInput) (domain.EmailTemplate, error) + UpdateEmailTemplate(ctx context.Context, id int64, input domain.UpdateEmailTemplateInput) (domain.EmailTemplate, error) + GetEmailTemplateByID(ctx context.Context, id int64, includeInactive bool) (domain.EmailTemplate, error) + GetEmailTemplateBySlug(ctx context.Context, slug string, includeInactive bool) (domain.EmailTemplate, error) + ListEmailTemplates(ctx context.Context, status *string, query *string, limit int32, offset int32) ([]domain.EmailTemplate, int64, error) + DeleteEmailTemplate(ctx context.Context, id int64) error +} diff --git a/internal/repository/email_templates.go b/internal/repository/email_templates.go new file mode 100644 index 0000000..6a47fbb --- /dev/null +++ b/internal/repository/email_templates.go @@ -0,0 +1,230 @@ +package repository + +import ( + "Yimaru-Backend/internal/domain" + "Yimaru-Backend/internal/ports" + "context" + "encoding/json" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +func NewEmailTemplateStore(s *Store) ports.EmailTemplateStore { return s } + +func emailTemplateToDomain( + id int64, + slug string, + name string, + subject string, + bodyText string, + bodyHTML string, + variables []byte, + isSystem bool, + status string, + createdAt pgtype.Timestamptz, + updatedAt pgtype.Timestamptz, +) (domain.EmailTemplate, error) { + var vars []string + if len(variables) > 0 { + if err := json.Unmarshal(variables, &vars); err != nil { + return domain.EmailTemplate{}, err + } + } + if vars == nil { + vars = []string{} + } + + return domain.EmailTemplate{ + ID: id, + Slug: slug, + Name: name, + Subject: subject, + BodyText: bodyText, + BodyHTML: bodyHTML, + Variables: vars, + IsSystem: isSystem, + Status: status, + CreatedAt: createdAt.Time, + UpdatedAt: timePtr(updatedAt), + }, nil +} + +func marshalEmailTemplateVariables(vars []string) ([]byte, error) { + if vars == nil { + vars = []string{} + } + return json.Marshal(vars) +} + +func (s *Store) CreateEmailTemplate(ctx context.Context, input domain.CreateEmailTemplateInput) (domain.EmailTemplate, error) { + status := domain.EmailTemplateStatusActive + if input.Status != nil { + status = *input.Status + } + + variablesJSON, err := marshalEmailTemplateVariables(input.Variables) + if err != nil { + return domain.EmailTemplate{}, err + } + + row := s.conn.QueryRow(ctx, ` + INSERT INTO email_templates (slug, name, subject, body_text, body_html, variables, is_system, status) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, FALSE, $7) + RETURNING id, slug, name, subject, body_text, body_html, variables, is_system, status, created_at, updated_at + `, input.Slug, input.Name, input.Subject, input.BodyText, input.BodyHTML, variablesJSON, status) + + return scanEmailTemplateRow(row) +} + +func (s *Store) UpdateEmailTemplate(ctx context.Context, id int64, input domain.UpdateEmailTemplateInput) (domain.EmailTemplate, error) { + variablesSet := input.Variables != nil + variablesJSON, err := marshalEmailTemplateVariables(input.Variables) + if err != nil { + return domain.EmailTemplate{}, err + } + + row := s.conn.QueryRow(ctx, ` + UPDATE email_templates + SET name = COALESCE($2, name), + subject = COALESCE($3, subject), + body_text = COALESCE($4, body_text), + body_html = COALESCE($5, body_html), + variables = CASE WHEN $6::boolean THEN $7::jsonb ELSE variables END, + status = COALESCE($8, status), + updated_at = NOW() + WHERE id = $1 + RETURNING id, slug, name, subject, body_text, body_html, variables, is_system, status, created_at, updated_at + `, id, input.Name, input.Subject, input.BodyText, input.BodyHTML, variablesSet, variablesJSON, input.Status) + + return scanEmailTemplateRow(row) +} + +func (s *Store) GetEmailTemplateByID(ctx context.Context, id int64, includeInactive bool) (domain.EmailTemplate, error) { + row := s.conn.QueryRow(ctx, ` + SELECT id, slug, name, subject, body_text, body_html, variables, is_system, status, created_at, updated_at + FROM email_templates + WHERE id = $1 + AND ($2::boolean = TRUE OR status = 'ACTIVE') + `, id, includeInactive) + + return scanEmailTemplateRow(row) +} + +func (s *Store) GetEmailTemplateBySlug(ctx context.Context, slug string, includeInactive bool) (domain.EmailTemplate, error) { + row := s.conn.QueryRow(ctx, ` + SELECT id, slug, name, subject, body_text, body_html, variables, is_system, status, created_at, updated_at + FROM email_templates + WHERE slug = $1 + AND ($2::boolean = TRUE OR status = 'ACTIVE') + `, slug, includeInactive) + + return scanEmailTemplateRow(row) +} + +func (s *Store) ListEmailTemplates(ctx context.Context, status *string, query *string, limit int32, offset int32) ([]domain.EmailTemplate, int64, error) { + rows, err := s.conn.Query(ctx, ` + SELECT id, slug, name, subject, body_text, body_html, variables, is_system, status, created_at, updated_at + FROM email_templates + WHERE ($1::text IS NULL OR status = $1) + AND ( + $2::text IS NULL + OR slug ILIKE '%' || $2 || '%' + OR name ILIKE '%' || $2 || '%' + OR subject ILIKE '%' || $2 || '%' + ) + ORDER BY is_system DESC, name ASC, id ASC + LIMIT $3 OFFSET $4 + `, toPgText(status), toPgText(query), limit, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + templates := make([]domain.EmailTemplate, 0) + for rows.Next() { + tmpl, err := scanEmailTemplateRows(rows) + if err != nil { + return nil, 0, err + } + templates = append(templates, tmpl) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + + var totalCount int64 + if err := s.conn.QueryRow(ctx, ` + SELECT COUNT(*) + FROM email_templates + WHERE ($1::text IS NULL OR status = $1) + AND ( + $2::text IS NULL + OR slug ILIKE '%' || $2 || '%' + OR name ILIKE '%' || $2 || '%' + OR subject ILIKE '%' || $2 || '%' + ) + `, toPgText(status), toPgText(query)).Scan(&totalCount); err != nil { + return nil, 0, err + } + + return templates, totalCount, nil +} + +func (s *Store) DeleteEmailTemplate(ctx context.Context, id int64) error { + cmd, err := s.conn.Exec(ctx, ` + DELETE FROM email_templates + WHERE id = $1 AND is_system = FALSE + `, id) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil +} + +type emailTemplateScanner interface { + Scan(dest ...any) error +} + +func scanEmailTemplateRow(row emailTemplateScanner) (domain.EmailTemplate, error) { + var ( + id int64 + slug string + name string + subject string + bodyText string + bodyHTML string + variables []byte + isSystem bool + status string + createdAt pgtype.Timestamptz + updatedAt pgtype.Timestamptz + ) + if err := row.Scan(&id, &slug, &name, &subject, &bodyText, &bodyHTML, &variables, &isSystem, &status, &createdAt, &updatedAt); err != nil { + return domain.EmailTemplate{}, err + } + return emailTemplateToDomain(id, slug, name, subject, bodyText, bodyHTML, variables, isSystem, status, createdAt, updatedAt) +} + +func scanEmailTemplateRows(rows pgx.Rows) (domain.EmailTemplate, error) { + var ( + id int64 + slug string + name string + subject string + bodyText string + bodyHTML string + variables []byte + isSystem bool + status string + createdAt pgtype.Timestamptz + updatedAt pgtype.Timestamptz + ) + if err := rows.Scan(&id, &slug, &name, &subject, &bodyText, &bodyHTML, &variables, &isSystem, &status, &createdAt, &updatedAt); err != nil { + return domain.EmailTemplate{}, err + } + return emailTemplateToDomain(id, slug, name, subject, bodyText, bodyHTML, variables, isSystem, status, createdAt, updatedAt) +} diff --git a/internal/services/emailtemplates/defaults.go b/internal/services/emailtemplates/defaults.go new file mode 100644 index 0000000..4551646 --- /dev/null +++ b/internal/services/emailtemplates/defaults.go @@ -0,0 +1,112 @@ +package emailtemplates + +import ( + "Yimaru-Backend/internal/domain" + "bytes" +) + +var defaultTemplates = map[string]domain.EmailTemplate{ + domain.EmailTemplateSlugOTP: { + Slug: domain.EmailTemplateSlugOTP, + Name: "One-Time Password", + Subject: "Yimaru - One Time Password", + BodyText: "Welcome to Yimaru Online Learning Platform{{if .FirstName}}, {{.FirstName}}{{end}}. Your OTP is {{.OTP}}. It expires in {{.ExpiresMinutes}} minutes. Please do not share it with anyone.", + BodyHTML: "Welcome to Yimaru Online Learning Platform{{if .FirstName}}, {{.FirstName}}{{end}}.
Your one-time password is {{.OTP}}.
It expires in {{.ExpiresMinutes}} minutes. Please do not share it with anyone.
", + Variables: []string{"OTP", "FirstName", "ExpiresMinutes"}, + Status: domain.EmailTemplateStatusActive, + }, + domain.EmailTemplateSlugInvitation: { + Slug: domain.EmailTemplateSlugInvitation, + Name: "User Invitation", + Subject: "You are invited to join Yimaru", + BodyText: "Hi{{if .FirstName}} {{.FirstName}}{{end}}, you have been invited{{if .InviterName}} by {{.InviterName}}{{end}} to join Yimaru Online Learning Platform. Accept your invitation: {{.InviteLink}}", + BodyHTML: "Hi{{if .FirstName}} {{.FirstName}}{{end}},
You have been invited{{if .InviterName}} by {{.InviterName}}{{end}} to join Yimaru Online Learning Platform.
", + Variables: []string{"FirstName", "InviterName", "InviteLink"}, + Status: domain.EmailTemplateStatusActive, + }, + domain.EmailTemplateSlugPasswordReset: { + Slug: domain.EmailTemplateSlugPasswordReset, + Name: "Password Reset", + Subject: "Reset your Yimaru password", + BodyText: "Hi{{if .FirstName}} {{.FirstName}}{{end}}, use this link to reset your password: {{.ResetLink}}. The link expires in {{.ExpiresMinutes}} minutes.", + BodyHTML: "Hi{{if .FirstName}} {{.FirstName}}{{end}},
Use the link below to reset your password. It expires in {{.ExpiresMinutes}} minutes.
", + Variables: []string{"FirstName", "ResetLink", "ExpiresMinutes"}, + Status: domain.EmailTemplateStatusActive, + }, + domain.EmailTemplateSlugWelcome: { + Slug: domain.EmailTemplateSlugWelcome, + Name: "Welcome Email", + Subject: "Welcome to Yimaru", + BodyText: "Hi{{if .FirstName}} {{.FirstName}}{{end}}, welcome to Yimaru Online Learning Platform! Sign in at {{.LoginURL}} to get started.", + BodyHTML: "Hi{{if .FirstName}} {{.FirstName}}{{end}},
Welcome to Yimaru Online Learning Platform!
", + Variables: []string{"FirstName", "LoginURL"}, + Status: domain.EmailTemplateStatusActive, + }, + domain.EmailTemplateSlugCustomMessage: { + Slug: domain.EmailTemplateSlugCustomMessage, + Name: "Custom Message", + Subject: "{{.Subject}}", + BodyText: "{{.Message}}", + BodyHTML: "{{.Message}}
", + Variables: []string{"Subject", "Message"}, + Status: domain.EmailTemplateStatusActive, + }, +} + +func defaultTemplate(slug string) (domain.EmailTemplate, bool) { + tmpl, ok := defaultTemplates[slug] + return tmpl, ok +} + +func renderTemplateFields(tmpl domain.EmailTemplate, data map[string]any) (domain.RenderedEmail, error) { + if data == nil { + data = map[string]any{} + } + + subject, err := executeTextTemplate("subject:"+tmpl.Slug, tmpl.Subject, data) + if err != nil { + return domain.RenderedEmail{}, err + } + + text, err := executeTextTemplate("text:"+tmpl.Slug, tmpl.BodyText, data) + if err != nil { + return domain.RenderedEmail{}, err + } + + html, err := executeHTMLTemplate("html:"+tmpl.Slug, tmpl.BodyHTML, data) + if err != nil { + return domain.RenderedEmail{}, err + } + + return domain.RenderedEmail{ + Subject: subject, + Text: text, + HTML: html, + }, nil +} + +func executeTextTemplate(name, content string, data map[string]any) (string, error) { + tmpl, err := newTextTemplate(name, content) + if err != nil { + return "", err + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} + +func executeHTMLTemplate(name, content string, data map[string]any) (string, error) { + tmpl, err := newHTMLTemplate(name, content) + if err != nil { + return "", err + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} diff --git a/internal/services/emailtemplates/service.go b/internal/services/emailtemplates/service.go new file mode 100644 index 0000000..6939401 --- /dev/null +++ b/internal/services/emailtemplates/service.go @@ -0,0 +1,318 @@ +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) +} diff --git a/internal/services/emailtemplates/template_utils.go b/internal/services/emailtemplates/template_utils.go new file mode 100644 index 0000000..0e628d6 --- /dev/null +++ b/internal/services/emailtemplates/template_utils.go @@ -0,0 +1,63 @@ +package emailtemplates + +import ( + "Yimaru-Backend/internal/domain" + "html/template" + "regexp" + "strings" + texttemplate "text/template" +) + +var slugPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) + +func normalizeSlug(slug string) (string, error) { + value := strings.ToLower(strings.TrimSpace(slug)) + if value == "" { + return "", errSlugRequired + } + if !slugPattern.MatchString(value) { + return "", errInvalidSlug + } + return value, nil +} + +func normalizeStatus(status *string) (string, error) { + if status == nil || strings.TrimSpace(*status) == "" { + return domain.EmailTemplateStatusActive, nil + } + value := strings.ToUpper(strings.TrimSpace(*status)) + switch value { + case domain.EmailTemplateStatusActive, domain.EmailTemplateStatusInactive: + return value, nil + default: + return "", errInvalidStatus + } +} + +func normalizeVariables(vars []string) []string { + if len(vars) == 0 { + return []string{} + } + out := make([]string, 0, len(vars)) + seen := make(map[string]struct{}, len(vars)) + for _, variable := range vars { + trimmed := strings.TrimSpace(variable) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + return out +} + +func newTextTemplate(name, content string) (*texttemplate.Template, error) { + return texttemplate.New(name).Parse(content) +} + +func newHTMLTemplate(name, content string) (*template.Template, error) { + return template.New(name).Parse(content) +} diff --git a/internal/services/rbac/seeds.go b/internal/services/rbac/seeds.go index 497f192..9ad6350 100644 --- a/internal/services/rbac/seeds.go +++ b/internal/services/rbac/seeds.go @@ -246,6 +246,14 @@ var AllPermissions = []domain.PermissionSeed{ {Key: "faqs.update", Name: "Update FAQ", Description: "Update a FAQ item", GroupName: "FAQs"}, {Key: "faqs.delete", Name: "Delete FAQ", Description: "Delete a FAQ item", GroupName: "FAQs"}, + // Email templates + {Key: "email_templates.create", Name: "Create Email Template", Description: "Create an email template", GroupName: "Email Templates"}, + {Key: "email_templates.list", Name: "List Email Templates", Description: "List email templates for admin management", GroupName: "Email Templates"}, + {Key: "email_templates.get", Name: "Get Email Template", Description: "Get an email template by ID or slug", GroupName: "Email Templates"}, + {Key: "email_templates.update", Name: "Update Email Template", Description: "Update an email template", GroupName: "Email Templates"}, + {Key: "email_templates.delete", Name: "Delete Email Template", Description: "Delete a custom email template", GroupName: "Email Templates"}, + {Key: "email_templates.preview", Name: "Preview Email Template", Description: "Preview a rendered email template", GroupName: "Email Templates"}, + // Analytics {Key: "analytics.dashboard", Name: "View Dashboard", Description: "View analytics dashboard", GroupName: "Analytics"}, @@ -448,6 +456,9 @@ var DefaultRolePermissions = map[string][]string{ // FAQs "faqs.create", "faqs.list", "faqs.get", "faqs.update", "faqs.delete", + // Email templates + "email_templates.create", "email_templates.list", "email_templates.get", "email_templates.update", "email_templates.delete", "email_templates.preview", + // Analytics (previously OnlyAdminAndAbove) "analytics.dashboard", diff --git a/internal/services/user/common.go b/internal/services/user/common.go index f0c23a7..f722b49 100644 --- a/internal/services/user/common.go +++ b/internal/services/user/common.go @@ -10,6 +10,14 @@ import ( "golang.org/x/crypto/bcrypt" ) +func (s *Service) renderOtpMessage(ctx context.Context, otpCode, firstName string) (domain.RenderedEmail, error) { + return s.emailTemplateSvc.Render(ctx, domain.EmailTemplateSlugOTP, map[string]any{ + "OTP": otpCode, + "FirstName": firstName, + "ExpiresMinutes": int(OtpExpiry.Minutes()), + }) +} + func (s *Service) ResendOtp( ctx context.Context, email, phone string, @@ -22,29 +30,28 @@ func (s *Service) ResendOtp( otpCode := helpers.GenerateOTP() - message := fmt.Sprintf( - "Welcome to Yimaru Online Learning Platform, your OTP is %s please don't share with anyone.", - otpCode, - ) - otp, err := s.otpStore.GetOtp(ctx, user.ID) if err != nil { return err } - // Broadcast OTP (same logic as SendOtp) + rendered, err := s.renderOtpMessage(ctx, otpCode, user.FirstName) + if err != nil { + return err + } + switch otp.Medium { case domain.OtpMediumSms: - if err := s.messengerSvc.SendAfroMessageSMSLatest(ctx, otp.SentTo, message, nil); err != nil { + if err := s.messengerSvc.SendAfroMessageSMSLatest(ctx, otp.SentTo, rendered.Text, nil); err != nil { return err } case domain.OtpMediumEmail: if err := s.messengerSvc.SendEmail( ctx, otp.SentTo, - message, - message, - "Yimaru - One Time Password", + rendered.Text, + rendered.HTML, + rendered.Subject, ); err != nil { return err } @@ -63,16 +70,26 @@ func (s *Service) ResendOtp( func (s *Service) SendOtp(ctx context.Context, userID int64, sentTo string, otpFor domain.OtpFor, medium domain.OtpMedium, provider domain.SMSProvider) error { otpCode := helpers.GenerateOTP() - message := fmt.Sprintf("Welcome to Yimaru Online Learning Platform, your OTP is %s please don't share with anyone.", otpCode) + firstName := "" + if userID > 0 { + user, err := s.userStore.GetUserByID(ctx, userID) + if err == nil { + firstName = user.FirstName + } + } + + rendered, err := s.renderOtpMessage(ctx, otpCode, firstName) + if err != nil { + return err + } switch medium { case domain.OtpMediumSms: - - if err := s.messengerSvc.SendAfroMessageSMSLatest(ctx, sentTo, message, nil); err != nil { + if err := s.messengerSvc.SendAfroMessageSMSLatest(ctx, sentTo, rendered.Text, nil); err != nil { return err } case domain.OtpMediumEmail: - if err := s.messengerSvc.SendEmail(ctx, sentTo, message, message, "Yimaru - One Time Password"); err != nil { + if err := s.messengerSvc.SendEmail(ctx, sentTo, rendered.Text, rendered.HTML, rendered.Subject); err != nil { return err } } diff --git a/internal/services/user/service.go b/internal/services/user/service.go index aacef04..309324e 100644 --- a/internal/services/user/service.go +++ b/internal/services/user/service.go @@ -3,6 +3,7 @@ package user import ( "Yimaru-Backend/internal/config" "Yimaru-Backend/internal/ports" + emailtemplates "Yimaru-Backend/internal/services/emailtemplates" "Yimaru-Backend/internal/services/messenger" "time" ) @@ -12,11 +13,12 @@ const ( ) type Service struct { - tokenStore ports.TokenStore - userStore ports.UserStore - otpStore ports.OtpStore - messengerSvc *messenger.Service - config *config.Config + tokenStore ports.TokenStore + userStore ports.UserStore + otpStore ports.OtpStore + messengerSvc *messenger.Service + emailTemplateSvc *emailtemplates.Service + config *config.Config } func NewService( @@ -24,13 +26,15 @@ func NewService( userStore ports.UserStore, otpStore ports.OtpStore, messengerSvc *messenger.Service, + emailTemplateSvc *emailtemplates.Service, cfg *config.Config, ) *Service { return &Service{ - tokenStore: tokenStore, - userStore: userStore, - otpStore: otpStore, - messengerSvc: messengerSvc, - config: cfg, + tokenStore: tokenStore, + userStore: userStore, + otpStore: otpStore, + messengerSvc: messengerSvc, + emailTemplateSvc: emailTemplateSvc, + config: cfg, } } diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 866691f..13d336a 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -10,6 +10,7 @@ import ( "Yimaru-Backend/internal/services/authentication" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" "Yimaru-Backend/internal/services/courses" + "Yimaru-Backend/internal/services/emailtemplates" "Yimaru-Backend/internal/services/examprep" "Yimaru-Backend/internal/services/faqs" issuereporting "Yimaru-Backend/internal/services/issue_reporting" @@ -50,6 +51,7 @@ type App struct { assessmentSvc *assessment.Service questionsSvc *questions.Service faqSvc *faqs.Service + emailTemplateSvc *emailtemplates.Service personaSvc *personas.Service examPrepSvc *examprep.Service programSvc *programs.Service @@ -91,6 +93,7 @@ func NewApp( assessmentSvc *assessment.Service, questionsSvc *questions.Service, faqSvc *faqs.Service, + emailTemplateSvc *emailtemplates.Service, personaSvc *personas.Service, examPrepSvc *examprep.Service, programSvc *programs.Service, @@ -144,6 +147,7 @@ func NewApp( assessmentSvc: assessmentSvc, questionsSvc: questionsSvc, faqSvc: faqSvc, + emailTemplateSvc: emailTemplateSvc, personaSvc: personaSvc, examPrepSvc: examPrepSvc, programSvc: programSvc, diff --git a/internal/web_server/handlers/email_template.go b/internal/web_server/handlers/email_template.go new file mode 100644 index 0000000..c4b01b7 --- /dev/null +++ b/internal/web_server/handlers/email_template.go @@ -0,0 +1,467 @@ +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, + }) +} diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index 94af31e..cacf016 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -13,6 +13,7 @@ import ( "Yimaru-Backend/internal/services/authentication" cloudconvertservice "Yimaru-Backend/internal/services/cloudconvert" "Yimaru-Backend/internal/services/courses" + "Yimaru-Backend/internal/services/emailtemplates" "Yimaru-Backend/internal/services/examprep" "Yimaru-Backend/internal/services/faqs" issuereporting "Yimaru-Backend/internal/services/issue_reporting" @@ -49,6 +50,7 @@ type Handler struct { assessmentSvc *assessment.Service questionsSvc *questions.Service faqSvc *faqs.Service + emailTemplateSvc *emailtemplates.Service personaSvc *personas.Service examPrepSvc *examprep.Service programSvc *programs.Service @@ -86,6 +88,7 @@ func New( assessmentSvc *assessment.Service, questionsSvc *questions.Service, faqSvc *faqs.Service, + emailTemplateSvc *emailtemplates.Service, personaSvc *personas.Service, examPrepSvc *examprep.Service, programSvc *programs.Service, @@ -122,6 +125,7 @@ func New( assessmentSvc: assessmentSvc, questionsSvc: questionsSvc, faqSvc: faqSvc, + emailTemplateSvc: emailTemplateSvc, personaSvc: personaSvc, examPrepSvc: examPrepSvc, programSvc: programSvc, diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index e012897..8ef3208 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -16,6 +16,7 @@ func (a *App) initAppRoutes() { a.assessmentSvc, a.questionsSvc, a.faqSvc, + a.emailTemplateSvc, a.personaSvc, a.examPrepSvc, a.programSvc, @@ -196,6 +197,16 @@ func (a *App) initAppRoutes() { groupV1.Put("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.update"), h.UpdateFAQ) groupV1.Delete("/admin/faqs/:id", a.authMiddleware, a.RequirePermission("faqs.delete"), h.DeleteFAQ) + // Email templates + groupV1.Get("/admin/email-templates", a.authMiddleware, a.RequirePermission("email_templates.list"), h.ListEmailTemplatesAdmin) + groupV1.Get("/admin/email-templates/slug/:slug", a.authMiddleware, a.RequirePermission("email_templates.get"), h.GetEmailTemplateBySlugAdmin) + groupV1.Post("/admin/email-templates/slug/:slug/preview", a.authMiddleware, a.RequirePermission("email_templates.preview"), h.PreviewEmailTemplateBySlug) + groupV1.Get("/admin/email-templates/:id", a.authMiddleware, a.RequirePermission("email_templates.get"), h.GetEmailTemplateByIDAdmin) + groupV1.Post("/admin/email-templates/:id/preview", a.authMiddleware, a.RequirePermission("email_templates.preview"), h.PreviewEmailTemplateByID) + groupV1.Post("/admin/email-templates", a.authMiddleware, a.RequirePermission("email_templates.create"), h.CreateEmailTemplate) + groupV1.Put("/admin/email-templates/:id", a.authMiddleware, a.RequirePermission("email_templates.update"), h.UpdateEmailTemplate) + groupV1.Delete("/admin/email-templates/:id", a.authMiddleware, a.RequirePermission("email_templates.delete"), h.DeleteEmailTemplate) + // Question Sets groupV1.Post("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.create"), h.CreateQuestionSet) groupV1.Get("/question-sets", a.authMiddleware, a.RequirePermission("question_sets.list"), h.GetQuestionSetsByType)