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

@@ -275,6 +275,116 @@ The Casera Team
return s.SendEmail(to, subject, htmlBody, textBody)
}
// SendPostVerificationEmail sends a welcome email after user verifies their email address
func (s *EmailService) SendPostVerificationEmail(to, firstName string) error {
subject := "You're All Set! Getting Started with Casera"
name := firstName
if name == "" {
name = "there"
}
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<tr>
<td style="background: #FFFFFF; padding: 40px 30px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 18px; font-weight: 600; color: #1a1a1a; margin: 0 0 20px 0;">Hi %s,</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; line-height: 1.6; color: #4B5563; margin: 0 0 24px 0;">Your email is now verified and your Casera account is ready to go! Here are some tips to help you get the most out of the app.</p>
<!-- Tip 1 -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="margin-bottom: 16px;">
<tr>
<td style="background: #F0FDF4; border-left: 4px solid #22C55E; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; font-weight: 700; color: #166534; margin: 0 0 8px 0;">&#127968; Start with Your Property</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 0;">Add your home or rental property to begin tracking everything in one place. You can add multiple properties and even share access with family members or co-owners.</p>
</td>
</tr>
</table>
<!-- Tip 2 -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="margin-bottom: 16px;">
<tr>
<td style="background: #EFF6FF; border-left: 4px solid #3B82F6; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; font-weight: 700; color: #1E40AF; margin: 0 0 8px 0;">&#128197; Set Up Recurring Tasks</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 0;">Create recurring tasks for regular maintenance like HVAC filter changes, gutter cleaning, or lawn care. Casera will remind you when they're due so nothing falls through the cracks.</p>
</td>
</tr>
</table>
<!-- Tip 3 -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="margin-bottom: 16px;">
<tr>
<td style="background: #FEF3C7; border-left: 4px solid #F59E0B; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; font-weight: 700; color: #92400E; margin: 0 0 8px 0;">&#128221; Track Your Maintenance History</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 0;">When you complete a task, add notes and photos to build a maintenance history. This is invaluable for warranty claims, selling your home, or just remembering when something was last serviced.</p>
</td>
</tr>
</table>
<!-- Tip 4 -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="margin-bottom: 16px;">
<tr>
<td style="background: #F5F3FF; border-left: 4px solid #8B5CF6; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; font-weight: 700; color: #5B21B6; margin: 0 0 8px 0;">&#128196; Store Important Documents</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 0;">Upload warranties, appliance manuals, insurance policies, and receipts. When you need them, they'll be right at your fingertips instead of buried in a drawer somewhere.</p>
</td>
</tr>
</table>
<!-- Tip 5 -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="margin-bottom: 24px;">
<tr>
<td style="background: #FDF2F8; border-left: 4px solid #EC4899; border-radius: 0 8px 8px 0; padding: 16px 20px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 15px; font-weight: 700; color: #9D174D; margin: 0 0 8px 0;">&#128119; Save Your Contractors</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 0;">Add your trusted plumber, electrician, and other service providers to your contractor list. You'll never have to dig through old emails or papers to find their contact info again.</p>
</td>
</tr>
</table>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #6B7280; margin: 0;">We're excited to have you on board. If you have any questions or feedback, we'd love to hear from you at support@casera.app.</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #6B7280; margin: 30px 0 0 0;">Happy homeowning!<br><strong style="color: #1a1a1a;">The Casera Team</strong></p>
</td>
</tr>
%s`,
emailHeader("You're Verified!"),
name,
emailFooter(time.Now().Year()))
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
textBody := fmt.Sprintf(`
You're All Set! Getting Started with Casera
Hi %s,
Your email is now verified and your Casera account is ready to go! Here are some tips to help you get the most out of the app.
1. START WITH YOUR PROPERTY
Add your home or rental property to begin tracking everything in one place. You can add multiple properties and even share access with family members or co-owners.
2. SET UP RECURRING TASKS
Create recurring tasks for regular maintenance like HVAC filter changes, gutter cleaning, or lawn care. Casera will remind you when they're due so nothing falls through the cracks.
3. TRACK YOUR MAINTENANCE HISTORY
When you complete a task, add notes and photos to build a maintenance history. This is invaluable for warranty claims, selling your home, or just remembering when something was last serviced.
4. STORE IMPORTANT DOCUMENTS
Upload warranties, appliance manuals, insurance policies, and receipts. When you need them, they'll be right at your fingertips instead of buried in a drawer somewhere.
5. SAVE YOUR CONTRACTORS
Add your trusted plumber, electrician, and other service providers to your contractor list. You'll never have to dig through old emails or papers to find their contact info again.
We're excited to have you on board. If you have any questions or feedback, we'd love to hear from you at support@casera.app.
Happy homeowning!
The Casera Team
`, name)
return s.SendEmail(to, subject, htmlBody, textBody)
}
// SendVerificationEmail sends an email verification code
func (s *EmailService) SendVerificationEmail(to, firstName, code string) error {
subject := "Casera - Verify Your Email"
@@ -605,6 +715,162 @@ The Casera Team
return s.SendEmailWithAttachment(to, subject, htmlBody, textBody, attachment)
}
// SendNoResidenceOnboardingEmail sends an onboarding email to users who haven't created a residence
func (s *EmailService) SendNoResidenceOnboardingEmail(to, firstName, baseURL, trackingID string) error {
subject := "Get started with Casera - Add your first property"
name := firstName
if name == "" {
name = "there"
}
trackingPixel := fmt.Sprintf(`<img src="%s/api/track/open/%s" width="1" height="1" style="display:none;" alt="" />`, baseURL, trackingID)
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<tr>
<td style="background: #FFFFFF; padding: 40px 30px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 18px; font-weight: 600; color: #1a1a1a; margin: 0 0 20px 0;">Hi %s,</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; line-height: 1.6; color: #4B5563; margin: 0 0 24px 0;">We noticed you haven't added your first property to Casera yet. Adding a property is the first step to staying on top of your home maintenance!</p>
<!-- Benefits Box -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0">
<tr>
<td style="background: #F8FAFC; border-radius: 12px; padding: 24px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; font-weight: 700; color: #1a1a1a; margin: 0 0 16px 0;">Why add a property?</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#127968; <strong>Track all your homes</strong> - Manage single-family homes, apartments, or investment properties</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#128197; <strong>Never miss maintenance</strong> - Set up recurring tasks with smart reminders</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#128196; <strong>Store important documents</strong> - Keep warranties, manuals, and records in one place</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#128119; <strong>Manage contractors</strong> - Keep your trusted pros organized and accessible</p>
</td>
</tr>
</table>
<!-- CTA Button -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="margin: 32px 0;">
<tr>
<td style="text-align: center;">
<a href="casera://add-property" style="display: inline-block; background: linear-gradient(135deg, #0079FF 0%%, #14B8A6 100%%); color: #FFFFFF; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; font-weight: 700; text-decoration: none; padding: 16px 32px; border-radius: 12px;">Add Your First Property</a>
</td>
</tr>
</table>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #6B7280; margin: 24px 0 0 0;">Just open the Casera app and tap the + button to get started. It only takes a minute!</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #6B7280; margin: 30px 0 0 0;">Best regards,<br><strong style="color: #1a1a1a;">The Casera Team</strong></p>
%s
</td>
</tr>
%s`,
emailHeader("Get Started!"),
name,
trackingPixel,
emailFooter(time.Now().Year()))
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
textBody := fmt.Sprintf(`
Get started with Casera - Add your first property
Hi %s,
We noticed you haven't added your first property to Casera yet. Adding a property is the first step to staying on top of your home maintenance!
Why add a property?
- Track all your homes: Manage single-family homes, apartments, or investment properties
- Never miss maintenance: Set up recurring tasks with smart reminders
- Store important documents: Keep warranties, manuals, and records in one place
- Manage contractors: Keep your trusted pros organized and accessible
Just open the Casera app and tap the + button to get started. It only takes a minute!
Best regards,
The Casera Team
`, name)
return s.SendEmail(to, subject, htmlBody, textBody)
}
// SendNoTasksOnboardingEmail sends an onboarding email to users who have a property but no tasks
func (s *EmailService) SendNoTasksOnboardingEmail(to, firstName, baseURL, trackingID string) error {
subject := "Stay on top of home maintenance with Casera"
name := firstName
if name == "" {
name = "there"
}
trackingPixel := fmt.Sprintf(`<img src="%s/api/track/open/%s" width="1" height="1" style="display:none;" alt="" />`, baseURL, trackingID)
bodyContent := fmt.Sprintf(`
%s
<!-- Body -->
<tr>
<td style="background: #FFFFFF; padding: 40px 30px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 18px; font-weight: 600; color: #1a1a1a; margin: 0 0 20px 0;">Hi %s,</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; line-height: 1.6; color: #4B5563; margin: 0 0 24px 0;">Great job adding your property to Casera! Now it's time to set up your first maintenance task and start tracking your home care.</p>
<!-- Benefits Box -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0">
<tr>
<td style="background: #F8FAFC; border-radius: 12px; padding: 24px;">
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; font-weight: 700; color: #1a1a1a; margin: 0 0 16px 0;">Task ideas to get you started:</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#127777;&#65039; <strong>HVAC Filter Replacement</strong> - Monthly or quarterly</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#128167; <strong>Water Heater Flush</strong> - Annually</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#127807; <strong>Lawn Care</strong> - Weekly or bi-weekly</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#128374; <strong>Gutter Cleaning</strong> - Seasonal</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #4B5563; margin: 12px 0;">&#128293; <strong>Smoke Detector Test</strong> - Monthly</p>
</td>
</tr>
</table>
<!-- CTA Button -->
<table role="presentation" width="100%%" cellspacing="0" cellpadding="0" style="margin: 32px 0;">
<tr>
<td style="text-align: center;">
<a href="casera://add-task" style="display: inline-block; background: linear-gradient(135deg, #0079FF 0%%, #14B8A6 100%%); color: #FFFFFF; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; font-weight: 700; text-decoration: none; padding: 16px 32px; border-radius: 12px;">Create Your First Task</a>
</td>
</tr>
</table>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #6B7280; margin: 24px 0 0 0;">Set up recurring tasks and Casera will remind you when they're due. No more forgotten maintenance!</p>
<p style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #6B7280; margin: 30px 0 0 0;">Best regards,<br><strong style="color: #1a1a1a;">The Casera Team</strong></p>
%s
</td>
</tr>
%s`,
emailHeader("Track Your Tasks!"),
name,
trackingPixel,
emailFooter(time.Now().Year()))
htmlBody := fmt.Sprintf(baseEmailTemplate(), subject, bodyContent)
textBody := fmt.Sprintf(`
Stay on top of home maintenance with Casera
Hi %s,
Great job adding your property to Casera! Now it's time to set up your first maintenance task and start tracking your home care.
Task ideas to get you started:
- HVAC Filter Replacement: Monthly or quarterly
- Water Heater Flush: Annually
- Lawn Care: Weekly or bi-weekly
- Gutter Cleaning: Seasonal
- Smoke Detector Test: Monthly
Set up recurring tasks and Casera will remind you when they're due. No more forgotten maintenance!
Best regards,
The Casera Team
`, name)
return s.SendEmail(to, subject, htmlBody, textBody)
}
// EmailTemplate represents an email template
type EmailTemplate struct {
name string

View File

@@ -0,0 +1,370 @@
package services
import (
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/models"
)
// OnboardingEmailService handles sending and tracking onboarding emails
type OnboardingEmailService struct {
db *gorm.DB
emailService *EmailService
baseURL string // Base URL for tracking pixel
}
// NewOnboardingEmailService creates a new onboarding email service
func NewOnboardingEmailService(db *gorm.DB, emailService *EmailService, baseURL string) *OnboardingEmailService {
return &OnboardingEmailService{
db: db,
emailService: emailService,
baseURL: baseURL,
}
}
// generateTrackingID generates a unique tracking ID for email open tracking
func generateTrackingID() string {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(bytes)
}
// HasSentEmail checks if a specific email type has already been sent to a user
func (s *OnboardingEmailService) HasSentEmail(userID uint, emailType models.OnboardingEmailType) bool {
var count int64
s.db.Model(&models.OnboardingEmail{}).
Where("user_id = ? AND email_type = ?", userID, emailType).
Count(&count)
return count > 0
}
// RecordEmailSent records that an email was sent to a user
func (s *OnboardingEmailService) RecordEmailSent(userID uint, emailType models.OnboardingEmailType, trackingID string) error {
email := &models.OnboardingEmail{
UserID: userID,
EmailType: emailType,
SentAt: time.Now().UTC(),
TrackingID: trackingID,
}
if err := s.db.Create(email).Error; err != nil {
return fmt.Errorf("failed to record email sent: %w", err)
}
return nil
}
// RecordEmailOpened records that an email was opened based on tracking ID
func (s *OnboardingEmailService) RecordEmailOpened(trackingID string) error {
now := time.Now().UTC()
result := s.db.Model(&models.OnboardingEmail{}).
Where("tracking_id = ? AND opened_at IS NULL", trackingID).
Update("opened_at", now)
if result.Error != nil {
return fmt.Errorf("failed to record email opened: %w", result.Error)
}
if result.RowsAffected > 0 {
log.Info().Str("tracking_id", trackingID).Msg("Email open recorded")
}
return nil
}
// GetEmailHistory gets all onboarding emails for a specific user
func (s *OnboardingEmailService) GetEmailHistory(userID uint) ([]models.OnboardingEmail, error) {
var emails []models.OnboardingEmail
if err := s.db.Where("user_id = ?", userID).Order("sent_at DESC").Find(&emails).Error; err != nil {
return nil, err
}
return emails, nil
}
// GetAllEmailHistory gets all onboarding emails with pagination
func (s *OnboardingEmailService) GetAllEmailHistory(page, pageSize int) ([]models.OnboardingEmail, int64, error) {
var emails []models.OnboardingEmail
var total int64
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
offset := (page - 1) * pageSize
// Count total
if err := s.db.Model(&models.OnboardingEmail{}).Count(&total).Error; err != nil {
return nil, 0, err
}
// Get paginated results with user info
if err := s.db.Preload("User").
Order("sent_at DESC").
Offset(offset).
Limit(pageSize).
Find(&emails).Error; err != nil {
return nil, 0, err
}
return emails, total, nil
}
// GetEmailStats returns statistics about onboarding emails
func (s *OnboardingEmailService) GetEmailStats() (*OnboardingEmailStats, error) {
stats := &OnboardingEmailStats{}
// No residence email stats
var noResTotal, noResOpened int64
s.db.Model(&models.OnboardingEmail{}).
Where("email_type = ?", models.OnboardingEmailNoResidence).
Count(&noResTotal)
s.db.Model(&models.OnboardingEmail{}).
Where("email_type = ? AND opened_at IS NOT NULL", models.OnboardingEmailNoResidence).
Count(&noResOpened)
stats.NoResidenceTotal = noResTotal
stats.NoResidenceOpened = noResOpened
// No tasks email stats
var noTasksTotal, noTasksOpened int64
s.db.Model(&models.OnboardingEmail{}).
Where("email_type = ?", models.OnboardingEmailNoTasks).
Count(&noTasksTotal)
s.db.Model(&models.OnboardingEmail{}).
Where("email_type = ? AND opened_at IS NOT NULL", models.OnboardingEmailNoTasks).
Count(&noTasksOpened)
stats.NoTasksTotal = noTasksTotal
stats.NoTasksOpened = noTasksOpened
return stats, nil
}
// OnboardingEmailStats represents statistics about onboarding emails
type OnboardingEmailStats struct {
NoResidenceTotal int64 `json:"no_residence_total"`
NoResidenceOpened int64 `json:"no_residence_opened"`
NoTasksTotal int64 `json:"no_tasks_total"`
NoTasksOpened int64 `json:"no_tasks_opened"`
}
// UsersNeedingNoResidenceEmail finds verified users who registered 2+ days ago but have no residence
func (s *OnboardingEmailService) UsersNeedingNoResidenceEmail() ([]models.User, error) {
var users []models.User
twoDaysAgo := time.Now().UTC().AddDate(0, 0, -2)
// Find users who:
// 1. Are verified
// 2. Registered 2+ days ago
// 3. Have no residences
// 4. Haven't received this email type yet
err := s.db.Raw(`
SELECT u.* FROM users u
LEFT JOIN residences r ON r.owner_id = u.id AND r.is_active = true
LEFT JOIN onboarding_emails oe ON oe.user_id = u.id AND oe.email_type = ?
WHERE u.verified = true
AND u.date_joined < ?
AND r.id IS NULL
AND oe.id IS NULL
`, models.OnboardingEmailNoResidence, twoDaysAgo).Scan(&users).Error
if err != nil {
return nil, fmt.Errorf("failed to find users needing no-residence email: %w", err)
}
return users, nil
}
// UsersNeedingNoTasksEmail finds verified users who created their first residence 5+ days ago but have no tasks
func (s *OnboardingEmailService) UsersNeedingNoTasksEmail() ([]models.User, error) {
var users []models.User
fiveDaysAgo := time.Now().UTC().AddDate(0, 0, -5)
// Find users who:
// 1. Are verified
// 2. Have at least one residence
// 3. Their first residence was created 5+ days ago
// 4. Have no tasks across ANY of their residences
// 5. Haven't received this email type yet
err := s.db.Raw(`
SELECT DISTINCT u.* FROM users u
INNER JOIN residences r ON r.owner_id = u.id AND r.is_active = true
LEFT JOIN tasks t ON t.residence_id IN (SELECT id FROM residences WHERE owner_id = u.id AND is_active = true) AND t.is_active = true
LEFT JOIN onboarding_emails oe ON oe.user_id = u.id AND oe.email_type = ?
WHERE u.verified = true
AND t.id IS NULL
AND oe.id IS NULL
AND EXISTS (
SELECT 1 FROM residences r2
WHERE r2.owner_id = u.id
AND r2.is_active = true
AND r2.created_at < ?
)
`, models.OnboardingEmailNoTasks, fiveDaysAgo).Scan(&users).Error
if err != nil {
return nil, fmt.Errorf("failed to find users needing no-tasks email: %w", err)
}
return users, nil
}
// CheckAndSendNoResidenceEmails finds eligible users and sends them the no-residence onboarding email
func (s *OnboardingEmailService) CheckAndSendNoResidenceEmails() (int, error) {
users, err := s.UsersNeedingNoResidenceEmail()
if err != nil {
return 0, err
}
sentCount := 0
for _, user := range users {
if err := s.sendNoResidenceEmail(user); err != nil {
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to send no-residence onboarding email")
continue
}
sentCount++
}
if sentCount > 0 {
log.Info().Int("count", sentCount).Msg("Sent no-residence onboarding emails")
}
return sentCount, nil
}
// CheckAndSendNoTasksEmails finds eligible users and sends them the no-tasks onboarding email
func (s *OnboardingEmailService) CheckAndSendNoTasksEmails() (int, error) {
users, err := s.UsersNeedingNoTasksEmail()
if err != nil {
return 0, err
}
sentCount := 0
for _, user := range users {
if err := s.sendNoTasksEmail(user); err != nil {
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to send no-tasks onboarding email")
continue
}
sentCount++
}
if sentCount > 0 {
log.Info().Int("count", sentCount).Msg("Sent no-tasks onboarding emails")
}
return sentCount, nil
}
// sendNoResidenceEmail sends the no-residence onboarding email to a user
func (s *OnboardingEmailService) sendNoResidenceEmail(user models.User) error {
if user.Email == "" {
return fmt.Errorf("user has no email address")
}
// Generate tracking ID
trackingID := generateTrackingID()
// Send email
if err := s.emailService.SendNoResidenceOnboardingEmail(user.Email, user.FirstName, s.baseURL, trackingID); err != nil {
return err
}
// Record that email was sent
if err := s.RecordEmailSent(user.ID, models.OnboardingEmailNoResidence, trackingID); err != nil {
// Email was sent but we failed to record it - log but don't fail
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to record no-residence email sent")
}
return nil
}
// sendNoTasksEmail sends the no-tasks onboarding email to a user
func (s *OnboardingEmailService) sendNoTasksEmail(user models.User) error {
if user.Email == "" {
return fmt.Errorf("user has no email address")
}
// Generate tracking ID
trackingID := generateTrackingID()
// Send email
if err := s.emailService.SendNoTasksOnboardingEmail(user.Email, user.FirstName, s.baseURL, trackingID); err != nil {
return err
}
// Record that email was sent
if err := s.RecordEmailSent(user.ID, models.OnboardingEmailNoTasks, trackingID); err != nil {
// Email was sent but we failed to record it - log but don't fail
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to record no-tasks email sent")
}
return nil
}
// SendOnboardingEmailToUser manually sends an onboarding email to a specific user
// This is used by admin to force-send emails regardless of eligibility criteria
func (s *OnboardingEmailService) SendOnboardingEmailToUser(userID uint, emailType models.OnboardingEmailType) error {
// Load the user
var user models.User
if err := s.db.First(&user, userID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return fmt.Errorf("user not found")
}
return fmt.Errorf("failed to load user: %w", err)
}
if user.Email == "" {
return fmt.Errorf("user has no email address")
}
// Check if already sent (for tracking purposes, we still record but warn)
alreadySent := s.HasSentEmail(userID, emailType)
// Generate tracking ID
trackingID := generateTrackingID()
// Send email based on type
var sendErr error
switch emailType {
case models.OnboardingEmailNoResidence:
sendErr = s.emailService.SendNoResidenceOnboardingEmail(user.Email, user.FirstName, s.baseURL, trackingID)
case models.OnboardingEmailNoTasks:
sendErr = s.emailService.SendNoTasksOnboardingEmail(user.Email, user.FirstName, s.baseURL, trackingID)
default:
return fmt.Errorf("unknown email type: %s", emailType)
}
if sendErr != nil {
return fmt.Errorf("failed to send email: %w", sendErr)
}
// If already sent before, delete the old record first to allow re-recording
// This allows admins to "resend" emails while still tracking them
if alreadySent {
s.db.Where("user_id = ? AND email_type = ?", userID, emailType).Delete(&models.OnboardingEmail{})
}
// Record that email was sent
if err := s.RecordEmailSent(userID, emailType, trackingID); err != nil {
log.Error().Err(err).Uint("user_id", userID).Str("email_type", string(emailType)).Msg("Failed to record onboarding email sent")
}
log.Info().
Uint("user_id", userID).
Str("email_type", string(emailType)).
Str("email", user.Email).
Bool("was_resend", alreadySent).
Msg("Onboarding email sent manually")
return nil
}