Yimaru-BackEnd/internal/repository/email_templates.go
Yared Yemane 5937c5505a Add admin-managed email templates and use them for OTP delivery
Adds CRUD and preview APIs, RBAC permissions, seeded system templates, and migrates OTP email/SMS to template rendering.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 01:28:48 -07:00

231 lines
6.5 KiB
Go

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