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 if err := s.db.Model(&models.OnboardingEmail{}). Where("user_id = ? AND email_type = ?", userID, emailType). Count(&count).Error; err != nil { log.Error().Err(err).Uint("user_id", userID).Str("email_type", string(emailType)).Msg("Failed to check if email was sent") return false } 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 if err := s.db.Model(&models.OnboardingEmail{}). Where("email_type = ?", models.OnboardingEmailNoResidence). Count(&noResTotal).Error; err != nil { log.Error().Err(err).Msg("Failed to count no-residence emails") } if err := s.db.Model(&models.OnboardingEmail{}). Where("email_type = ? AND opened_at IS NOT NULL", models.OnboardingEmailNoResidence). Count(&noResOpened).Error; err != nil { log.Error().Err(err).Msg("Failed to count opened no-residence emails") } stats.NoResidenceTotal = noResTotal stats.NoResidenceOpened = noResOpened // No tasks email stats var noTasksTotal, noTasksOpened int64 if err := s.db.Model(&models.OnboardingEmail{}). Where("email_type = ?", models.OnboardingEmailNoTasks). Count(&noTasksTotal).Error; err != nil { log.Error().Err(err).Msg("Failed to count no-tasks emails") } if err := s.db.Model(&models.OnboardingEmail{}). Where("email_type = ? AND opened_at IS NOT NULL", models.OnboardingEmailNoTasks). Count(&noTasksOpened).Error; err != nil { log.Error().Err(err).Msg("Failed to count opened no-tasks emails") } 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 auth_user u LEFT JOIN residence_residence 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.is_active = 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 auth_user u INNER JOIN residence_residence r ON r.owner_id = u.id AND r.is_active = true LEFT JOIN task_task t ON t.residence_id IN (SELECT id FROM residence_residence WHERE owner_id = u.id AND is_active = true) AND t.is_cancelled = false AND t.is_archived = false LEFT JOIN onboarding_emails oe ON oe.user_id = u.id AND oe.email_type = ? WHERE u.is_active = true AND t.id IS NULL AND oe.id IS NULL AND EXISTS ( SELECT 1 FROM residence_residence 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 { if err := s.db.Where("user_id = ? AND email_type = ?", userID, emailType).Delete(&models.OnboardingEmail{}).Error; err != nil { log.Error().Err(err).Uint("user_id", userID).Str("email_type", string(emailType)).Msg("Failed to delete old onboarding email record before resend") } } // 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 }