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:
Trey t
2025-12-08 14:36:50 -06:00
parent e152a6308a
commit 9761156597
17 changed files with 1707 additions and 18 deletions

View File

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

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

View File

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