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:
parent
1f7b38861e
commit
5937c5505a
|
|
@ -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,
|
||||
|
|
|
|||
1
db/migrations/000066_email_templates.down.sql
Normal file
1
db/migrations/000066_email_templates.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS email_templates;
|
||||
70
db/migrations/000066_email_templates.up.sql
Normal file
70
db/migrations/000066_email_templates.up.sql
Normal 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;
|
||||
58
internal/domain/email_template.go
Normal file
58
internal/domain/email_template.go
Normal 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
|
||||
}
|
||||
15
internal/ports/email_template.go
Normal file
15
internal/ports/email_template.go
Normal 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
|
||||
}
|
||||
230
internal/repository/email_templates.go
Normal file
230
internal/repository/email_templates.go
Normal 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)
|
||||
}
|
||||
112
internal/services/emailtemplates/defaults.go
Normal file
112
internal/services/emailtemplates/defaults.go
Normal 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
|
||||
}
|
||||
318
internal/services/emailtemplates/service.go
Normal file
318
internal/services/emailtemplates/service.go
Normal 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)
|
||||
}
|
||||
63
internal/services/emailtemplates/template_utils.go
Normal file
63
internal/services/emailtemplates/template_utils.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
467
internal/web_server/handlers/email_template.go
Normal file
467
internal/web_server/handlers/email_template.go
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user