From 976115659724a350dc025ab0c6995b0aa8837dbd Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 8 Dec 2025 14:36:50 -0600 Subject: [PATCH] Add onboarding email campaign system with post-verification welcome email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../(dashboard)/onboarding-emails/page.tsx | 298 ++++++++++++++ .../src/app/(dashboard)/users/[id]/client.tsx | 83 +++- admin/src/components/app-sidebar.tsx | 2 + admin/src/lib/api.ts | 100 +++++ cmd/worker/main.go | 8 + .../admin/handlers/notification_handler.go | 47 +++ internal/admin/handlers/onboarding_handler.go | 352 +++++++++++++++++ internal/admin/routes.go | 23 +- internal/config/config.go | 3 + internal/database/database.go | 6 + internal/handlers/auth_handler.go | 9 + internal/handlers/tracking_handler.go | 45 +++ internal/models/onboarding_email.go | 34 ++ internal/router/router.go | 12 +- internal/services/email_service.go | 266 +++++++++++++ internal/services/onboarding_email_service.go | 370 ++++++++++++++++++ internal/worker/jobs/handler.go | 67 +++- 17 files changed, 1707 insertions(+), 18 deletions(-) create mode 100644 admin/src/app/(dashboard)/onboarding-emails/page.tsx create mode 100644 internal/admin/handlers/onboarding_handler.go create mode 100644 internal/handlers/tracking_handler.go create mode 100644 internal/models/onboarding_email.go create mode 100644 internal/services/onboarding_email_service.go diff --git a/admin/src/app/(dashboard)/onboarding-emails/page.tsx b/admin/src/app/(dashboard)/onboarding-emails/page.tsx new file mode 100644 index 0000000..7f39290 --- /dev/null +++ b/admin/src/app/(dashboard)/onboarding-emails/page.tsx @@ -0,0 +1,298 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { ColumnDef } from '@tanstack/react-table'; +import Link from 'next/link'; +import { MoreHorizontal, Mail, MailOpen, TrendingUp } from 'lucide-react'; + +import { onboardingEmailsApi } from '@/lib/api'; +import type { OnboardingEmail } from '@/lib/api'; +import { DataTable } from '@/components/data-table'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +const emailTypeLabels: Record = { + no_residence: 'No Residence', + no_tasks: 'No Tasks', +}; + +const emailTypeColors: Record = { + no_residence: 'default', + no_tasks: 'secondary', +}; + +const columns: ColumnDef[] = [ + { + accessorKey: 'id', + header: 'ID', + cell: ({ row }) => ( + {row.original.id} + ), + }, + { + accessorKey: 'user_name', + header: 'User', + cell: ({ row }) => ( +
+
{row.original.user_name || 'Unknown'}
+
{row.original.user_email}
+
+ ), + }, + { + accessorKey: 'email_type', + header: 'Type', + cell: ({ row }) => ( + + {emailTypeLabels[row.original.email_type] || row.original.email_type} + + ), + }, + { + accessorKey: 'sent_at', + header: 'Sent', + cell: ({ row }) => { + const date = new Date(row.original.sent_at); + return ( +
+
{date.toLocaleDateString()}
+
{date.toLocaleTimeString()}
+
+ ); + }, + }, + { + accessorKey: 'opened_at', + header: 'Opened', + cell: ({ row }) => { + if (row.original.opened_at) { + const date = new Date(row.original.opened_at); + return ( +
+ +
+
{date.toLocaleDateString()}
+
{date.toLocaleTimeString()}
+
+
+ ); + } + return ( +
+ + Not opened +
+ ); + }, + }, + { + accessorKey: 'tracking_id', + header: 'Tracking ID', + cell: ({ row }) => ( + + {row.original.tracking_id.substring(0, 12)}... + + ), + }, + { + id: 'actions', + cell: ({ row }) => { + const email = row.original; + return ( + + + + + + Actions + + View user + + + + ); + }, + }, +]; + +function StatsCards() { + const { data: stats, isLoading } = useQuery({ + queryKey: ['onboarding-emails-stats'], + queryFn: () => onboardingEmailsApi.getStats(), + }); + + if (isLoading) { + return ( +
+ {[...Array(5)].map((_, i) => ( + + + Loading... + + +
-
+
+
+ ))} +
+ ); + } + + return ( +
+ + + Total Sent + + + +
{stats?.total_sent ?? 0}
+

+ {stats?.total_opened ?? 0} opened +

+
+
+ + + + Overall Open Rate + + + +
+ {stats?.overall_open_rate?.toFixed(1) ?? 0}% +
+
+
+ + + + No Residence + Type 1 + + +
{stats?.no_residence_total ?? 0}
+

+ {stats?.no_residence_open_rate?.toFixed(1) ?? 0}% open rate +

+
+
+ + + + No Tasks + Type 2 + + +
{stats?.no_tasks_total ?? 0}
+

+ {stats?.no_tasks_open_rate?.toFixed(1) ?? 0}% open rate +

+
+
+ + + + Total Opened + + + +
{stats?.total_opened ?? 0}
+

+ of {stats?.total_sent ?? 0} sent +

+
+
+
+ ); +} + +export default function OnboardingEmailsPage() { + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [emailType, setEmailType] = useState('all'); + const [openedFilter, setOpenedFilter] = useState('all'); + + const { data, isLoading } = useQuery({ + queryKey: ['onboarding-emails', { page, page_size: pageSize, email_type: emailType, opened: openedFilter }], + queryFn: () => onboardingEmailsApi.list({ + page, + page_size: pageSize, + email_type: emailType !== 'all' ? emailType : undefined, + opened: openedFilter !== 'all' ? openedFilter as 'true' | 'false' : undefined, + }), + }); + + return ( +
+
+

Onboarding Emails

+

+ Track automated onboarding email campaigns and open rates +

+
+ + + +
+
+ +
+
+ +
+
+ + { + setPageSize(size); + setPage(1); + }} + isLoading={isLoading} + /> +
+ ); +} diff --git a/admin/src/app/(dashboard)/users/[id]/client.tsx b/admin/src/app/(dashboard)/users/[id]/client.tsx index 917686c..f0045e6 100644 --- a/admin/src/app/(dashboard)/users/[id]/client.tsx +++ b/admin/src/app/(dashboard)/users/[id]/client.tsx @@ -4,10 +4,10 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; -import { ArrowLeft, Edit, Trash2, Bell, Mail } from 'lucide-react'; +import { ArrowLeft, Edit, Trash2, Bell, Mail, MailCheck, Home, ClipboardList, Sparkles } from 'lucide-react'; import { toast } from 'sonner'; -import { usersApi, notificationsApi, emailsApi, subscriptionsApi } from '@/lib/api'; +import { usersApi, notificationsApi, emailsApi, subscriptionsApi, onboardingEmailsApi } from '@/lib/api'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; @@ -116,6 +116,31 @@ export function UserDetailClient() { }, }); + const sendOnboardingEmailMutation = useMutation({ + mutationFn: (emailType: 'no_residence' | 'no_tasks') => onboardingEmailsApi.send({ + user_id: userId, + email_type: emailType, + }), + onSuccess: (data) => { + toast.success(`Onboarding email sent to ${data.email}`); + }, + onError: (error: Error & { response?: { data?: { error?: string; details?: string } } }) => { + const errorMsg = error.response?.data?.error || error.response?.data?.details || error.message; + toast.error(`Failed to send onboarding email: ${errorMsg}`); + }, + }); + + const sendPostVerificationEmailMutation = useMutation({ + mutationFn: () => emailsApi.sendPostVerificationEmail({ user_id: userId }), + onSuccess: (data) => { + toast.success(`Post-verification email sent to ${data.to}`); + }, + onError: (error: Error & { response?: { data?: { error?: string; details?: string } } }) => { + const errorMsg = error.response?.data?.error || error.response?.data?.details || error.message; + toast.error(`Failed to send email: ${errorMsg}`); + }, + }); + if (isLoading) { return (
@@ -383,9 +408,10 @@ export function UserDetailClient() { Testing - Send test notifications and emails to this user + Send test notifications, emails, and onboarding campaigns to this user - + +
{/* Push Notification Dialog */} @@ -485,6 +511,55 @@ export function UserDetailClient() { +
+ + + + {/* Onboarding Emails */} +
+ +

+ Send onboarding campaign emails to encourage user engagement +

+
+ + + +
+
diff --git a/admin/src/components/app-sidebar.tsx b/admin/src/components/app-sidebar.tsx index 563e0d1..02532aa 100644 --- a/admin/src/components/app-sidebar.tsx +++ b/admin/src/components/app-sidebar.tsx @@ -20,6 +20,7 @@ import { Layers, Sparkles, Mail, + MailCheck, Share2, KeyRound, Smartphone, @@ -63,6 +64,7 @@ const menuItems = [ { title: 'Document Images', url: '/admin/document-images', icon: ImagePlus }, { title: 'Notifications', url: '/admin/notifications', icon: Bell }, { title: 'Notification Prefs', url: '/admin/notification-prefs', icon: BellRing }, + { title: 'Onboarding Emails', url: '/admin/onboarding-emails', icon: MailCheck }, { title: 'Devices', url: '/admin/devices', icon: Smartphone }, { title: 'Subscriptions', url: '/admin/subscriptions', icon: CreditCard }, ]; diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index cef90ca..1235c14 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -321,6 +321,11 @@ export const emailsApi = { const response = await api.post('/emails/send-test', data); return response.data; }, + + sendPostVerificationEmail: async (data: { user_id: number }): Promise<{ message: string; to: string }> => { + const response = await api.post('/emails/send-post-verification', data); + return response.data; + }, }; // Subscriptions API @@ -1499,4 +1504,99 @@ export const documentImagesApi = { }, }; +// Onboarding Email Types +export interface OnboardingEmail { + id: number; + user_id: number; + user_email?: string; + user_name?: string; + email_type: 'no_residence' | 'no_tasks'; + sent_at: string; + opened_at?: string; + tracking_id: string; + created_at: string; +} + +export interface OnboardingEmailListParams { + page?: number; + page_size?: number; + email_type?: string; + user_id?: number; + opened?: 'true' | 'false'; +} + +export interface OnboardingEmailListResponse { + data: OnboardingEmail[]; + total: number; + page: number; + page_size: number; + total_pages: number; +} + +export interface OnboardingEmailStats { + no_residence_total: number; + no_residence_opened: number; + no_residence_open_rate: number; + no_tasks_total: number; + no_tasks_opened: number; + no_tasks_open_rate: number; + total_sent: number; + total_opened: number; + overall_open_rate: number; +} + +export interface OnboardingEmailsByUserResponse { + data: OnboardingEmail[]; + user_id: number; + count: number; +} + +export interface SendOnboardingEmailRequest { + user_id: number; + email_type: 'no_residence' | 'no_tasks'; +} + +export interface SendOnboardingEmailResponse { + message: string; + user_id: number; + email: string; + email_type: string; +} + +// Onboarding Emails API +export const onboardingEmailsApi = { + list: async (params?: OnboardingEmailListParams): Promise => { + const response = await api.get('/onboarding-emails', { params }); + return response.data; + }, + + get: async (id: number): Promise => { + const response = await api.get(`/onboarding-emails/${id}`); + return response.data; + }, + + getByUser: async (userId: number): Promise => { + const response = await api.get(`/onboarding-emails/user/${userId}`); + return response.data; + }, + + getStats: async (): Promise => { + const response = await api.get('/onboarding-emails/stats'); + return response.data; + }, + + send: async (data: SendOnboardingEmailRequest): Promise => { + const response = await api.post('/onboarding-emails/send', data); + return response.data; + }, + + delete: async (id: number): Promise => { + await api.delete(`/onboarding-emails/${id}`); + }, + + bulkDelete: async (ids: number[]): Promise => { + await api.delete('/onboarding-emails/bulk', { data: { ids } }); + }, +}; + export default api; diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 967cea5..7c5ec35 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -100,6 +100,7 @@ func main() { mux.HandleFunc(jobs.TypeDailyDigest, jobHandler.HandleDailyDigest) mux.HandleFunc(jobs.TypeSendEmail, jobHandler.HandleSendEmail) mux.HandleFunc(jobs.TypeSendPush, jobHandler.HandleSendPush) + mux.HandleFunc(jobs.TypeOnboardingEmails, jobHandler.HandleOnboardingEmails) // Start scheduler for periodic tasks scheduler := asynq.NewScheduler(redisOpt, nil) @@ -124,6 +125,13 @@ func main() { } log.Info().Str("cron", "0 * * * *").Int("default_hour", cfg.Worker.DailyNotifHour).Msg("Registered daily digest job (runs hourly for per-user times)") + // Schedule onboarding emails (runs daily at 10:00 AM UTC) + // Sends emails to users who haven't created residences or tasks after registration + if _, err := scheduler.Register("0 10 * * *", asynq.NewTask(jobs.TypeOnboardingEmails, nil)); err != nil { + log.Fatal().Err(err).Msg("Failed to register onboarding emails job") + } + log.Info().Str("cron", "0 10 * * *").Msg("Registered onboarding emails job (runs daily at 10:00 AM UTC)") + // Handle graceful shutdown quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) diff --git a/internal/admin/handlers/notification_handler.go b/internal/admin/handlers/notification_handler.go index 5308a34..4730f10 100644 --- a/internal/admin/handlers/notification_handler.go +++ b/internal/admin/handlers/notification_handler.go @@ -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, + }) +} diff --git a/internal/admin/handlers/onboarding_handler.go b/internal/admin/handlers/onboarding_handler.go new file mode 100644 index 0000000..b195d43 --- /dev/null +++ b/internal/admin/handlers/onboarding_handler.go @@ -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 +} diff --git a/internal/admin/routes.go b/internal/admin/routes.go index 2c21737..02cebcc 100644 --- a/internal/admin/routes.go +++ b/internal/admin/routes.go @@ -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) + } } } diff --git a/internal/config/config.go b/internal/config/config.go index 35d4340..29d61b5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,6 +30,7 @@ type ServerConfig struct { AllowedHosts []string Timezone string StaticDir string // Directory for static landing page files + BaseURL string // Public base URL for email tracking links (e.g., https://casera.app) } type DatabaseConfig struct { @@ -146,6 +147,7 @@ func Load() (*Config, error) { AllowedHosts: strings.Split(viper.GetString("ALLOWED_HOSTS"), ","), Timezone: viper.GetString("TIMEZONE"), StaticDir: viper.GetString("STATIC_DIR"), + BaseURL: viper.GetString("BASE_URL"), }, Database: dbConfig, Redis: RedisConfig{ @@ -214,6 +216,7 @@ func setDefaults() { viper.SetDefault("ALLOWED_HOSTS", "localhost,127.0.0.1") viper.SetDefault("TIMEZONE", "UTC") viper.SetDefault("STATIC_DIR", "/app/static") + viper.SetDefault("BASE_URL", "https://casera.app") // Database defaults viper.SetDefault("DB_HOST", "localhost") diff --git a/internal/database/database.go b/internal/database/database.go index 053fda8..bdad7c0 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -152,12 +152,18 @@ func Migrate() error { &models.FeatureBenefit{}, &models.Promotion{}, &models.TierLimits{}, + + // Onboarding email tracking + &models.OnboardingEmail{}, ) if err != nil { return fmt.Errorf("failed to run migrations: %w", err) } + // Add unique constraint for onboarding emails (one email type per user) + db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_onboarding_emails_user_type ON onboarding_emails(user_id, email_type)`) + // Run GoAdmin migrations if err := migrateGoAdmin(); err != nil { return fmt.Errorf("failed to run GoAdmin migrations: %w", err) diff --git a/internal/handlers/auth_handler.go b/internal/handlers/auth_handler.go index 02458ad..12dc3d6 100644 --- a/internal/handlers/auth_handler.go +++ b/internal/handlers/auth_handler.go @@ -222,6 +222,15 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) { return } + // Send post-verification welcome email with tips (async) + if h.emailService != nil { + go func() { + if err := h.emailService.SendPostVerificationEmail(user.Email, user.FirstName); err != nil { + log.Error().Err(err).Str("email", user.Email).Msg("Failed to send post-verification email") + } + }() + } + c.JSON(http.StatusOK, responses.VerifyEmailResponse{ Message: i18n.LocalizedMessage(c, "message.email_verified"), Verified: true, diff --git a/internal/handlers/tracking_handler.go b/internal/handlers/tracking_handler.go new file mode 100644 index 0000000..a791b08 --- /dev/null +++ b/internal/handlers/tracking_handler.go @@ -0,0 +1,45 @@ +package handlers + +import ( + "encoding/base64" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/treytartt/casera-api/internal/services" +) + +// TrackingHandler handles email tracking endpoints +type TrackingHandler struct { + onboardingService *services.OnboardingEmailService +} + +// NewTrackingHandler creates a new tracking handler +func NewTrackingHandler(onboardingService *services.OnboardingEmailService) *TrackingHandler { + return &TrackingHandler{ + onboardingService: onboardingService, + } +} + +// 1x1 transparent GIF (43 bytes) +var transparentGIF, _ = base64.StdEncoding.DecodeString("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7") + +// TrackEmailOpen handles email open tracking via tracking pixel +// GET /api/track/open/:trackingID +func (h *TrackingHandler) TrackEmailOpen(c *gin.Context) { + trackingID := c.Param("trackingID") + + if trackingID != "" && h.onboardingService != nil { + // Record the open (async, don't block response) + go func() { + _ = h.onboardingService.RecordEmailOpened(trackingID) + }() + } + + // Return 1x1 transparent GIF + c.Header("Content-Type", "image/gif") + c.Header("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") + c.Header("Pragma", "no-cache") + c.Header("Expires", "0") + c.Data(http.StatusOK, "image/gif", transparentGIF) +} diff --git a/internal/models/onboarding_email.go b/internal/models/onboarding_email.go new file mode 100644 index 0000000..a3d5e3c --- /dev/null +++ b/internal/models/onboarding_email.go @@ -0,0 +1,34 @@ +package models + +import ( + "time" +) + +// OnboardingEmailType represents the type of onboarding email +type OnboardingEmailType string + +const ( + // OnboardingEmailNoResidence is sent when a user has not created a residence after 2 days + OnboardingEmailNoResidence OnboardingEmailType = "no_residence" + // OnboardingEmailNoTasks is sent when a user has created a residence but no tasks after 5 days + OnboardingEmailNoTasks OnboardingEmailType = "no_tasks" +) + +// OnboardingEmail tracks sent onboarding emails per user +// Each email type can only be sent once per user (enforced by unique constraint on user_id + email_type) +type OnboardingEmail struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + EmailType OnboardingEmailType `gorm:"type:varchar(50);not null;index" json:"email_type"` + SentAt time.Time `gorm:"not null" json:"sent_at"` + OpenedAt *time.Time `json:"opened_at"` + TrackingID string `gorm:"type:varchar(64);uniqueIndex" json:"tracking_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName returns the table name for OnboardingEmail +func (OnboardingEmail) TableName() string { + return "onboarding_emails" +} diff --git a/internal/router/router.go b/internal/router/router.go index 00370b0..753edef 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -68,6 +68,13 @@ func SetupRouter(deps *Dependencies) *gin.Engine { // Health check endpoint (no auth required) r.GET("/api/health/", healthCheck) + // Initialize onboarding email service for tracking handler + onboardingService := services.NewOnboardingEmailService(deps.DB, deps.EmailService, cfg.Server.BaseURL) + + // Email tracking endpoint (no auth required - used by email tracking pixels) + trackingHandler := handlers.NewTrackingHandler(onboardingService) + r.GET("/api/track/open/:trackingID", trackingHandler.TrackEmailOpen) + // NOTE: Public static file serving removed for security. // All uploaded media is now served through authenticated proxy endpoints: // - GET /api/media/document/:id @@ -132,8 +139,9 @@ func SetupRouter(deps *Dependencies) *gin.Engine { // Set up admin routes (separate auth system) adminDeps := &admin.Dependencies{ - EmailService: deps.EmailService, - PushClient: deps.PushClient, + EmailService: deps.EmailService, + PushClient: deps.PushClient, + OnboardingService: onboardingService, } admin.SetupRoutes(r, deps.DB, cfg, adminDeps) diff --git a/internal/services/email_service.go b/internal/services/email_service.go index d0adca4..1776633 100644 --- a/internal/services/email_service.go +++ b/internal/services/email_service.go @@ -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 + + + +

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.

+ + + + + + +
+

🏠 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.

+
+ + + + + + +
+

📅 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.

+
+ + + + + + +
+

📝 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.

+
+ + + + + + +
+

📄 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.

+
+ + + + + + +
+

👷 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

+ + + %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(``, baseURL, trackingID) + + bodyContent := fmt.Sprintf(` + %s + + + +

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

+
+ + + + + + +
+ Add Your First Property +
+ +

Just open the Casera app and tap the + button to get started. It only takes a minute!

+ +

Best regards,
The Casera Team

+ %s + + + %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(``, baseURL, trackingID) + + bodyContent := fmt.Sprintf(` + %s + + + +

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

+
+ + + + + + +
+ Create Your First Task +
+ +

Set up recurring tasks and Casera will remind you when they're due. No more forgotten maintenance!

+ +

Best regards,
The Casera Team

+ %s + + + %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 diff --git a/internal/services/onboarding_email_service.go b/internal/services/onboarding_email_service.go new file mode 100644 index 0000000..3b81553 --- /dev/null +++ b/internal/services/onboarding_email_service.go @@ -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 +} diff --git a/internal/worker/jobs/handler.go b/internal/worker/jobs/handler.go index 1336a10..6c25a31 100644 --- a/internal/worker/jobs/handler.go +++ b/internal/worker/jobs/handler.go @@ -18,29 +18,38 @@ import ( // Task types const ( - TypeTaskReminder = "notification:task_reminder" - TypeOverdueReminder = "notification:overdue_reminder" - TypeDailyDigest = "notification:daily_digest" - TypeSendEmail = "email:send" - TypeSendPush = "push:send" + TypeTaskReminder = "notification:task_reminder" + TypeOverdueReminder = "notification:overdue_reminder" + TypeDailyDigest = "notification:daily_digest" + TypeSendEmail = "email:send" + TypeSendPush = "push:send" + TypeOnboardingEmails = "email:onboarding" ) // Handler handles background job processing type Handler struct { - db *gorm.DB - pushClient *push.Client - emailService *services.EmailService - notificationService *services.NotificationService - config *config.Config + db *gorm.DB + pushClient *push.Client + emailService *services.EmailService + notificationService *services.NotificationService + onboardingService *services.OnboardingEmailService + config *config.Config } // NewHandler creates a new job handler func NewHandler(db *gorm.DB, pushClient *push.Client, emailService *services.EmailService, notificationService *services.NotificationService, cfg *config.Config) *Handler { + // Create onboarding email service + var onboardingService *services.OnboardingEmailService + if emailService != nil { + onboardingService = services.NewOnboardingEmailService(db, emailService, cfg.Server.BaseURL) + } + return &Handler{ db: db, pushClient: pushClient, emailService: emailService, notificationService: notificationService, + onboardingService: onboardingService, config: cfg, } } @@ -514,3 +523,41 @@ func NewSendPushTask(userID uint, title, message string, data map[string]string) } return asynq.NewTask(TypeSendPush, payload), nil } + +// HandleOnboardingEmails processes onboarding email campaigns +// Sends emails to: +// 1. Users who registered 2+ days ago but haven't created a residence +// 2. Users who created a residence 5+ days ago but haven't created any tasks +// Each email type is only sent once per user, ever. +func (h *Handler) HandleOnboardingEmails(ctx context.Context, task *asynq.Task) error { + log.Info().Msg("Processing onboarding emails...") + + if h.onboardingService == nil { + log.Warn().Msg("Onboarding email service not configured, skipping") + return nil + } + + // Send no-residence emails (users without any residences after 2 days) + noResCount, err := h.onboardingService.CheckAndSendNoResidenceEmails() + if err != nil { + log.Error().Err(err).Msg("Failed to process no-residence onboarding emails") + // Continue to next type, don't return error + } else { + log.Info().Int("count", noResCount).Msg("Sent no-residence onboarding emails") + } + + // Send no-tasks emails (users with residence but no tasks after 5 days) + noTasksCount, err := h.onboardingService.CheckAndSendNoTasksEmails() + if err != nil { + log.Error().Err(err).Msg("Failed to process no-tasks onboarding emails") + } else { + log.Info().Int("count", noTasksCount).Msg("Sent no-tasks onboarding emails") + } + + log.Info(). + Int("no_residence_sent", noResCount). + Int("no_tasks_sent", noTasksCount). + Msg("Onboarding email processing completed") + + return nil +}