Add onboarding email campaign system with post-verification welcome email
Implements automated onboarding emails to encourage user engagement: - Post-verification welcome email with 5 tips (sent after email verification) - "No Residence" email (2+ days after registration with no property) - "No Tasks" email (5+ days after first residence with no tasks) Key features: - Each onboarding email type sent only once per user (enforced by unique constraint) - Email open tracking via tracking pixel endpoint - Daily scheduled job at 10:00 AM UTC to process eligible users - Admin panel UI for viewing sent emails, stats, and manual sending - Admin can send any email type to users from the user detail Testing section New files: - internal/models/onboarding_email.go - Database model with tracking - internal/services/onboarding_email_service.go - Business logic and eligibility queries - internal/handlers/tracking_handler.go - Email open tracking endpoint - internal/admin/handlers/onboarding_handler.go - Admin API endpoints - admin/src/app/(dashboard)/onboarding-emails/ - Admin UI pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -402,3 +402,50 @@ func (h *AdminNotificationHandler) SendTestEmail(c *gin.Context) {
|
||||
"to": user.Email,
|
||||
})
|
||||
}
|
||||
|
||||
// SendPostVerificationEmail handles POST /api/admin/emails/send-post-verification
|
||||
func (h *AdminNotificationHandler) SendPostVerificationEmail(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID uint `json:"user_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify user exists
|
||||
var user models.User
|
||||
if err := h.db.First(&user, req.UserID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.Email == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "User has no email address"})
|
||||
return
|
||||
}
|
||||
|
||||
// Send email
|
||||
if h.emailService == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Email service not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.emailService.SendPostVerificationEmail(user.Email, user.FirstName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to send email",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Post-verification email sent successfully",
|
||||
"to": user.Email,
|
||||
})
|
||||
}
|
||||
|
||||
352
internal/admin/handlers/onboarding_handler.go
Normal file
352
internal/admin/handlers/onboarding_handler.go
Normal file
@@ -0,0 +1,352 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/models"
|
||||
"github.com/treytartt/casera-api/internal/services"
|
||||
)
|
||||
|
||||
// AdminOnboardingHandler handles admin onboarding email operations
|
||||
type AdminOnboardingHandler struct {
|
||||
db *gorm.DB
|
||||
onboardingService *services.OnboardingEmailService
|
||||
}
|
||||
|
||||
// NewAdminOnboardingHandler creates a new admin onboarding handler
|
||||
func NewAdminOnboardingHandler(db *gorm.DB, onboardingService *services.OnboardingEmailService) *AdminOnboardingHandler {
|
||||
return &AdminOnboardingHandler{
|
||||
db: db,
|
||||
onboardingService: onboardingService,
|
||||
}
|
||||
}
|
||||
|
||||
// OnboardingEmailResponse represents an onboarding email in API responses
|
||||
type OnboardingEmailResponse struct {
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"user_id"`
|
||||
UserEmail string `json:"user_email,omitempty"`
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
EmailType string `json:"email_type"`
|
||||
SentAt string `json:"sent_at"`
|
||||
OpenedAt *string `json:"opened_at,omitempty"`
|
||||
TrackingID string `json:"tracking_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// OnboardingEmailListResponse is the paginated list response
|
||||
type OnboardingEmailListResponse struct {
|
||||
Data []OnboardingEmailResponse `json:"data"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// OnboardingStatsResponse contains email statistics
|
||||
type OnboardingStatsResponse struct {
|
||||
NoResidenceTotal int64 `json:"no_residence_total"`
|
||||
NoResidenceOpened int64 `json:"no_residence_opened"`
|
||||
NoResidenceRate float64 `json:"no_residence_open_rate"`
|
||||
NoTasksTotal int64 `json:"no_tasks_total"`
|
||||
NoTasksOpened int64 `json:"no_tasks_opened"`
|
||||
NoTasksRate float64 `json:"no_tasks_open_rate"`
|
||||
TotalSent int64 `json:"total_sent"`
|
||||
TotalOpened int64 `json:"total_opened"`
|
||||
OverallRate float64 `json:"overall_open_rate"`
|
||||
}
|
||||
|
||||
// List returns paginated list of onboarding emails
|
||||
// GET /api/admin/onboarding-emails
|
||||
func (h *AdminOnboardingHandler) List(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
emailType := c.Query("email_type")
|
||||
userID, _ := strconv.Atoi(c.Query("user_id"))
|
||||
opened := c.Query("opened")
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
// Build query
|
||||
query := h.db.Model(&models.OnboardingEmail{}).Preload("User")
|
||||
|
||||
if emailType != "" {
|
||||
query = query.Where("email_type = ?", emailType)
|
||||
}
|
||||
if userID > 0 {
|
||||
query = query.Where("user_id = ?", userID)
|
||||
}
|
||||
if opened == "true" {
|
||||
query = query.Where("opened_at IS NOT NULL")
|
||||
} else if opened == "false" {
|
||||
query = query.Where("opened_at IS NULL")
|
||||
}
|
||||
|
||||
// Count total
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count emails"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get paginated results
|
||||
var emails []models.OnboardingEmail
|
||||
if err := query.Order("sent_at DESC").Offset(offset).Limit(pageSize).Find(&emails).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch emails"})
|
||||
return
|
||||
}
|
||||
|
||||
// Transform to response
|
||||
data := make([]OnboardingEmailResponse, len(emails))
|
||||
for i, email := range emails {
|
||||
data[i] = transformOnboardingEmail(email)
|
||||
}
|
||||
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, OnboardingEmailListResponse{
|
||||
Data: data,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
TotalPages: totalPages,
|
||||
})
|
||||
}
|
||||
|
||||
// GetStats returns onboarding email statistics
|
||||
// GET /api/admin/onboarding-emails/stats
|
||||
func (h *AdminOnboardingHandler) GetStats(c *gin.Context) {
|
||||
var stats OnboardingStatsResponse
|
||||
|
||||
// No residence email stats
|
||||
h.db.Model(&models.OnboardingEmail{}).
|
||||
Where("email_type = ?", models.OnboardingEmailNoResidence).
|
||||
Count(&stats.NoResidenceTotal)
|
||||
h.db.Model(&models.OnboardingEmail{}).
|
||||
Where("email_type = ? AND opened_at IS NOT NULL", models.OnboardingEmailNoResidence).
|
||||
Count(&stats.NoResidenceOpened)
|
||||
|
||||
// No tasks email stats
|
||||
h.db.Model(&models.OnboardingEmail{}).
|
||||
Where("email_type = ?", models.OnboardingEmailNoTasks).
|
||||
Count(&stats.NoTasksTotal)
|
||||
h.db.Model(&models.OnboardingEmail{}).
|
||||
Where("email_type = ? AND opened_at IS NOT NULL", models.OnboardingEmailNoTasks).
|
||||
Count(&stats.NoTasksOpened)
|
||||
|
||||
// Calculate rates
|
||||
if stats.NoResidenceTotal > 0 {
|
||||
stats.NoResidenceRate = float64(stats.NoResidenceOpened) / float64(stats.NoResidenceTotal) * 100
|
||||
}
|
||||
if stats.NoTasksTotal > 0 {
|
||||
stats.NoTasksRate = float64(stats.NoTasksOpened) / float64(stats.NoTasksTotal) * 100
|
||||
}
|
||||
|
||||
stats.TotalSent = stats.NoResidenceTotal + stats.NoTasksTotal
|
||||
stats.TotalOpened = stats.NoResidenceOpened + stats.NoTasksOpened
|
||||
if stats.TotalSent > 0 {
|
||||
stats.OverallRate = float64(stats.TotalOpened) / float64(stats.TotalSent) * 100
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// GetByUser returns onboarding emails for a specific user
|
||||
// GET /api/admin/onboarding-emails/user/:user_id
|
||||
func (h *AdminOnboardingHandler) GetByUser(c *gin.Context) {
|
||||
userID, err := strconv.ParseUint(c.Param("user_id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var emails []models.OnboardingEmail
|
||||
if err := h.db.Where("user_id = ?", userID).Order("sent_at DESC").Find(&emails).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch emails"})
|
||||
return
|
||||
}
|
||||
|
||||
// Transform to response
|
||||
data := make([]OnboardingEmailResponse, len(emails))
|
||||
for i, email := range emails {
|
||||
data[i] = transformOnboardingEmail(email)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": data,
|
||||
"user_id": userID,
|
||||
"count": len(data),
|
||||
})
|
||||
}
|
||||
|
||||
// Get returns a single onboarding email by ID
|
||||
// GET /api/admin/onboarding-emails/:id
|
||||
func (h *AdminOnboardingHandler) Get(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var email models.OnboardingEmail
|
||||
if err := h.db.Preload("User").First(&email, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Email not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch email"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, transformOnboardingEmail(email))
|
||||
}
|
||||
|
||||
// Delete removes an onboarding email record
|
||||
// DELETE /api/admin/onboarding-emails/:id
|
||||
func (h *AdminOnboardingHandler) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
result := h.db.Delete(&models.OnboardingEmail{}, id)
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete email"})
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Email not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Email record deleted"})
|
||||
}
|
||||
|
||||
// BulkDelete removes multiple onboarding email records
|
||||
// DELETE /api/admin/onboarding-emails/bulk
|
||||
func (h *AdminOnboardingHandler) BulkDelete(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []uint `json:"ids" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No IDs provided"})
|
||||
return
|
||||
}
|
||||
|
||||
result := h.db.Delete(&models.OnboardingEmail{}, req.IDs)
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete emails"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Emails deleted",
|
||||
"count": result.RowsAffected,
|
||||
})
|
||||
}
|
||||
|
||||
// SendOnboardingEmailRequest represents a request to send an onboarding email
|
||||
type SendOnboardingEmailRequest struct {
|
||||
UserID uint `json:"user_id" binding:"required"`
|
||||
EmailType string `json:"email_type" binding:"required"`
|
||||
}
|
||||
|
||||
// Send sends an onboarding email to a specific user
|
||||
// POST /api/admin/onboarding-emails/send
|
||||
func (h *AdminOnboardingHandler) Send(c *gin.Context) {
|
||||
if h.onboardingService == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Onboarding email service not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
var req SendOnboardingEmailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: user_id and email_type are required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate email type
|
||||
var emailType models.OnboardingEmailType
|
||||
switch req.EmailType {
|
||||
case "no_residence":
|
||||
emailType = models.OnboardingEmailNoResidence
|
||||
case "no_tasks":
|
||||
emailType = models.OnboardingEmailNoTasks
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid email_type. Must be 'no_residence' or 'no_tasks'"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user email for response
|
||||
var user models.User
|
||||
if err := h.db.First(&user, req.UserID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user"})
|
||||
return
|
||||
}
|
||||
|
||||
// Send the email
|
||||
if err := h.onboardingService.SendOnboardingEmailToUser(req.UserID, emailType); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Onboarding email sent successfully",
|
||||
"user_id": req.UserID,
|
||||
"email": user.Email,
|
||||
"email_type": req.EmailType,
|
||||
})
|
||||
}
|
||||
|
||||
// transformOnboardingEmail converts a model to response format
|
||||
func transformOnboardingEmail(email models.OnboardingEmail) OnboardingEmailResponse {
|
||||
resp := OnboardingEmailResponse{
|
||||
ID: email.ID,
|
||||
UserID: email.UserID,
|
||||
EmailType: string(email.EmailType),
|
||||
SentAt: email.SentAt.Format("2006-01-02T15:04:05Z"),
|
||||
TrackingID: email.TrackingID,
|
||||
CreatedAt: email.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
|
||||
if email.OpenedAt != nil {
|
||||
opened := email.OpenedAt.Format("2006-01-02T15:04:05Z")
|
||||
resp.OpenedAt = &opened
|
||||
}
|
||||
|
||||
// Include user info if loaded
|
||||
if email.User.ID != 0 {
|
||||
resp.UserEmail = email.User.Email
|
||||
if email.User.FirstName != "" || email.User.LastName != "" {
|
||||
resp.UserName = email.User.FirstName + " " + email.User.LastName
|
||||
} else {
|
||||
resp.UserName = email.User.Username
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
@@ -19,8 +19,9 @@ import (
|
||||
|
||||
// Dependencies holds optional services for admin routes
|
||||
type Dependencies struct {
|
||||
EmailService *services.EmailService
|
||||
PushClient *push.Client
|
||||
EmailService *services.EmailService
|
||||
PushClient *push.Client
|
||||
OnboardingService *services.OnboardingEmailService
|
||||
}
|
||||
|
||||
// SetupRoutes configures all admin routes
|
||||
@@ -134,6 +135,7 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
|
||||
emails := protected.Group("/emails")
|
||||
{
|
||||
emails.POST("/send-test", notificationHandler.SendTestEmail)
|
||||
emails.POST("/send-post-verification", notificationHandler.SendPostVerificationEmail)
|
||||
}
|
||||
|
||||
// Subscription management
|
||||
@@ -414,6 +416,23 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
|
||||
limitations.PUT("/upgrade-triggers/:id", limitationsHandler.UpdateUpgradeTrigger)
|
||||
limitations.DELETE("/upgrade-triggers/:id", limitationsHandler.DeleteUpgradeTrigger)
|
||||
}
|
||||
|
||||
// Onboarding emails management
|
||||
var onboardingService *services.OnboardingEmailService
|
||||
if deps != nil {
|
||||
onboardingService = deps.OnboardingService
|
||||
}
|
||||
onboardingHandler := handlers.NewAdminOnboardingHandler(db, onboardingService)
|
||||
onboardingEmails := protected.Group("/onboarding-emails")
|
||||
{
|
||||
onboardingEmails.GET("", onboardingHandler.List)
|
||||
onboardingEmails.GET("/stats", onboardingHandler.GetStats)
|
||||
onboardingEmails.POST("/send", onboardingHandler.Send)
|
||||
onboardingEmails.DELETE("/bulk", onboardingHandler.BulkDelete)
|
||||
onboardingEmails.GET("/user/:user_id", onboardingHandler.GetByUser)
|
||||
onboardingEmails.GET("/:id", onboardingHandler.Get)
|
||||
onboardingEmails.DELETE("/:id", onboardingHandler.Delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user