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>
This commit is contained in:
Yared Yemane 2026-05-22 01:28:48 -07:00
parent 1f7b38861e
commit 5937c5505a
16 changed files with 1413 additions and 28 deletions

View File

@ -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,

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS email_templates;

View File

@ -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.',
'<p>Welcome to Yimaru Online Learning Platform{{if .FirstName}}, <strong>{{.FirstName}}</strong>{{end}}.</p><p>Your one-time password is <strong>{{.OTP}}</strong>.</p><p>It expires in {{.ExpiresMinutes}} minutes. Please do not share it with anyone.</p>',
'["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}}',
'<p>Hi{{if .FirstName}} <strong>{{.FirstName}}</strong>{{end}},</p><p>You have been invited{{if .InviterName}} by <strong>{{.InviterName}}</strong>{{end}} to join Yimaru Online Learning Platform.</p><p><a href="{{.InviteLink}}">Accept your invitation</a></p>',
'["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.',
'<p>Hi{{if .FirstName}} <strong>{{.FirstName}}</strong>{{end}},</p><p>Use the link below to reset your password. It expires in {{.ExpiresMinutes}} minutes.</p><p><a href="{{.ResetLink}}">Reset your password</a></p>',
'["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.',
'<p>Hi{{if .FirstName}} <strong>{{.FirstName}}</strong>{{end}},</p><p>Welcome to Yimaru Online Learning Platform!</p><p><a href="{{.LoginURL}}">Sign in to get started</a></p>',
'["FirstName", "LoginURL"]'::jsonb,
TRUE,
'ACTIVE'
),
(
'custom_message',
'Custom Message',
'{{.Subject}}',
'{{.Message}}',
'<p>{{.Message}}</p>',
'["Subject", "Message"]'::jsonb,
TRUE,
'ACTIVE'
)
ON CONFLICT (slug) DO NOTHING;

View File

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

View File

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

View File

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

View File

@ -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: "<p>Welcome to Yimaru Online Learning Platform{{if .FirstName}}, <strong>{{.FirstName}}</strong>{{end}}.</p><p>Your one-time password is <strong>{{.OTP}}</strong>.</p><p>It expires in {{.ExpiresMinutes}} minutes. Please do not share it with anyone.</p>",
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: "<p>Hi{{if .FirstName}} <strong>{{.FirstName}}</strong>{{end}},</p><p>You have been invited{{if .InviterName}} by <strong>{{.InviterName}}</strong>{{end}} to join Yimaru Online Learning Platform.</p><p><a href=\"{{.InviteLink}}\">Accept your invitation</a></p>",
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: "<p>Hi{{if .FirstName}} <strong>{{.FirstName}}</strong>{{end}},</p><p>Use the link below to reset your password. It expires in {{.ExpiresMinutes}} minutes.</p><p><a href=\"{{.ResetLink}}\">Reset your password</a></p>",
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: "<p>Hi{{if .FirstName}} <strong>{{.FirstName}}</strong>{{end}},</p><p>Welcome to Yimaru Online Learning Platform!</p><p><a href=\"{{.LoginURL}}\">Sign in to get started</a></p>",
Variables: []string{"FirstName", "LoginURL"},
Status: domain.EmailTemplateStatusActive,
},
domain.EmailTemplateSlugCustomMessage: {
Slug: domain.EmailTemplateSlugCustomMessage,
Name: "Custom Message",
Subject: "{{.Subject}}",
BodyText: "{{.Message}}",
BodyHTML: "<p>{{.Message}}</p>",
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
}

View File

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

View File

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

View File

@ -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",

View File

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

View File

@ -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"
)
@ -16,6 +17,7 @@ type Service struct {
userStore ports.UserStore
otpStore ports.OtpStore
messengerSvc *messenger.Service
emailTemplateSvc *emailtemplates.Service
config *config.Config
}
@ -24,6 +26,7 @@ func NewService(
userStore ports.UserStore,
otpStore ports.OtpStore,
messengerSvc *messenger.Service,
emailTemplateSvc *emailtemplates.Service,
cfg *config.Config,
) *Service {
return &Service{
@ -31,6 +34,7 @@ func NewService(
userStore: userStore,
otpStore: otpStore,
messengerSvc: messengerSvc,
emailTemplateSvc: emailTemplateSvc,
config: cfg,
}
}

View File

@ -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,

View File

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

View File

@ -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,

View File

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