package jobs import ( "context" "encoding/json" "fmt" "time" "github.com/hibiken/asynq" "github.com/rs/zerolog/log" "gorm.io/gorm" "github.com/treytartt/casera-api/internal/config" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/push" "github.com/treytartt/casera-api/internal/services" ) // Task types const ( TypeTaskReminder = "notification:task_reminder" TypeOverdueReminder = "notification:overdue_reminder" TypeDailyDigest = "notification:daily_digest" TypeSendEmail = "email:send" TypeSendPush = "push:send" ) // Handler handles background job processing type Handler struct { db *gorm.DB pushClient *push.Client emailService *services.EmailService config *config.Config } // NewHandler creates a new job handler func NewHandler(db *gorm.DB, pushClient *push.Client, emailService *services.EmailService, cfg *config.Config) *Handler { return &Handler{ db: db, pushClient: pushClient, emailService: emailService, config: cfg, } } // TaskReminderData represents a task due soon for reminder notifications type TaskReminderData struct { TaskID uint TaskTitle string DueDate time.Time UserID uint UserEmail string UserName string ResidenceName string } // HandleTaskReminder processes task reminder notifications for tasks due today or tomorrow func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) error { log.Info().Msg("Processing task reminder notifications...") now := time.Now().UTC() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) tomorrow := today.AddDate(0, 0, 1) dayAfterTomorrow := today.AddDate(0, 0, 2) // Query tasks due today or tomorrow that are not completed, cancelled, or archived var tasks []struct { TaskID uint TaskTitle string DueDate time.Time UserID uint ResidenceName string } err := h.db.Raw(` SELECT DISTINCT t.id as task_id, t.title as task_title, t.due_date, COALESCE(t.assigned_to_id, r.owner_id) as user_id, r.name as residence_name FROM task_task t JOIN residence_residence r ON t.residence_id = r.id LEFT JOIN task_taskcompletion tc ON t.id = tc.task_id WHERE t.due_date >= ? AND t.due_date < ? AND t.is_cancelled = false AND t.is_archived = false AND tc.id IS NULL `, today, dayAfterTomorrow).Scan(&tasks).Error if err != nil { log.Error().Err(err).Msg("Failed to query tasks due soon") return err } log.Info().Int("count", len(tasks)).Msg("Found tasks due today/tomorrow") // Group by user and check preferences userTasks := make(map[uint][]struct { TaskID uint TaskTitle string DueDate time.Time ResidenceName string }) for _, t := range tasks { userTasks[t.UserID] = append(userTasks[t.UserID], struct { TaskID uint TaskTitle string DueDate time.Time ResidenceName string }{t.TaskID, t.TaskTitle, t.DueDate, t.ResidenceName}) } // Send notifications to each user for userID, userTaskList := range userTasks { // Check user notification preferences var prefs models.NotificationPreference err := h.db.Where("user_id = ?", userID).First(&prefs).Error if err != nil && err != gorm.ErrRecordNotFound { log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get notification preferences") continue } // Skip if user has disabled task due soon notifications if err == nil && !prefs.TaskDueSoon { log.Debug().Uint("user_id", userID).Msg("User has disabled task due soon notifications") continue } // Build notification message var title, body string if len(userTaskList) == 1 { t := userTaskList[0] dueText := "today" if t.DueDate.After(tomorrow) { dueText = "tomorrow" } title = fmt.Sprintf("Task Due %s", dueText) body = fmt.Sprintf("%s at %s is due %s", t.TaskTitle, t.ResidenceName, dueText) } else { todayCount := 0 tomorrowCount := 0 for _, t := range userTaskList { if t.DueDate.Before(tomorrow) { todayCount++ } else { tomorrowCount++ } } title = "Tasks Due Soon" body = fmt.Sprintf("You have %d task(s) due today and %d task(s) due tomorrow", todayCount, tomorrowCount) } // Send push notification if err := h.sendPushToUser(ctx, userID, title, body, map[string]string{ "type": "task_reminder", }); err != nil { log.Error().Err(err).Uint("user_id", userID).Msg("Failed to send task reminder push") } // Create in-app notification record for _, t := range userTaskList { notification := &models.Notification{ UserID: userID, NotificationType: models.NotificationTaskDueSoon, Title: title, Body: body, TaskID: &t.TaskID, } if err := h.db.Create(notification).Error; err != nil { log.Error().Err(err).Msg("Failed to create notification record") } } } log.Info().Msg("Task reminder notifications completed") return nil } // HandleOverdueReminder processes overdue task notifications func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) error { log.Info().Msg("Processing overdue task notifications...") now := time.Now().UTC() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) // Query overdue tasks that are not completed, cancelled, or archived var tasks []struct { TaskID uint TaskTitle string DueDate time.Time DaysOverdue int UserID uint ResidenceName string } err := h.db.Raw(` SELECT DISTINCT t.id as task_id, t.title as task_title, t.due_date, EXTRACT(DAY FROM ? - t.due_date)::int as days_overdue, COALESCE(t.assigned_to_id, r.owner_id) as user_id, r.name as residence_name FROM task_task t JOIN residence_residence r ON t.residence_id = r.id LEFT JOIN task_taskcompletion tc ON t.id = tc.task_id WHERE t.due_date < ? AND t.is_cancelled = false AND t.is_archived = false AND tc.id IS NULL ORDER BY t.due_date ASC `, today, today).Scan(&tasks).Error if err != nil { log.Error().Err(err).Msg("Failed to query overdue tasks") return err } log.Info().Int("count", len(tasks)).Msg("Found overdue tasks") // Group by user userTasks := make(map[uint][]struct { TaskID uint TaskTitle string DaysOverdue int ResidenceName string }) for _, t := range tasks { userTasks[t.UserID] = append(userTasks[t.UserID], struct { TaskID uint TaskTitle string DaysOverdue int ResidenceName string }{t.TaskID, t.TaskTitle, t.DaysOverdue, t.ResidenceName}) } // Send notifications to each user for userID, userTaskList := range userTasks { // Check user notification preferences var prefs models.NotificationPreference err := h.db.Where("user_id = ?", userID).First(&prefs).Error if err != nil && err != gorm.ErrRecordNotFound { log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get notification preferences") continue } // Skip if user has disabled overdue notifications if err == nil && !prefs.TaskOverdue { log.Debug().Uint("user_id", userID).Msg("User has disabled overdue task notifications") continue } // Build notification message var title, body string if len(userTaskList) == 1 { t := userTaskList[0] title = "Overdue Task" if t.DaysOverdue == 1 { body = fmt.Sprintf("%s at %s is 1 day overdue", t.TaskTitle, t.ResidenceName) } else { body = fmt.Sprintf("%s at %s is %d days overdue", t.TaskTitle, t.ResidenceName, t.DaysOverdue) } } else { title = "Overdue Tasks" body = fmt.Sprintf("You have %d overdue tasks that need attention", len(userTaskList)) } // Send push notification if err := h.sendPushToUser(ctx, userID, title, body, map[string]string{ "type": "overdue_reminder", }); err != nil { log.Error().Err(err).Uint("user_id", userID).Msg("Failed to send overdue reminder push") } // Create in-app notification record notification := &models.Notification{ UserID: userID, NotificationType: models.NotificationTaskOverdue, Title: title, Body: body, } if err := h.db.Create(notification).Error; err != nil { log.Error().Err(err).Msg("Failed to create notification record") } } log.Info().Msg("Overdue task notifications completed") return nil } // HandleDailyDigest processes daily digest notifications with task statistics func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error { log.Info().Msg("Processing daily digest notifications...") now := time.Now().UTC() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) nextWeek := today.AddDate(0, 0, 7) // Get all users with their task statistics var userStats []struct { UserID uint TotalTasks int OverdueTasks int DueThisWeek int } err := h.db.Raw(` SELECT u.id as user_id, COUNT(DISTINCT t.id) as total_tasks, COUNT(DISTINCT CASE WHEN t.due_date < ? AND tc.id IS NULL THEN t.id END) as overdue_tasks, COUNT(DISTINCT CASE WHEN t.due_date >= ? AND t.due_date < ? AND tc.id IS NULL THEN t.id END) as due_this_week FROM user_user u JOIN residence_residence r ON r.owner_id = u.id OR r.id IN ( SELECT residence_id FROM residence_residence_users WHERE user_id = u.id ) JOIN task_task t ON t.residence_id = r.id AND t.is_cancelled = false AND t.is_archived = false LEFT JOIN task_taskcompletion tc ON t.id = tc.task_id WHERE u.is_active = true GROUP BY u.id HAVING COUNT(DISTINCT t.id) > 0 `, today, today, nextWeek).Scan(&userStats).Error if err != nil { log.Error().Err(err).Msg("Failed to query user task statistics") return err } log.Info().Int("users", len(userStats)).Msg("Processing daily digest for users") for _, stats := range userStats { // Skip users with no actionable items if stats.OverdueTasks == 0 && stats.DueThisWeek == 0 { continue } // Build notification message title := "Daily Task Summary" var body string if stats.OverdueTasks > 0 && stats.DueThisWeek > 0 { body = fmt.Sprintf("You have %d overdue task(s) and %d task(s) due this week", stats.OverdueTasks, stats.DueThisWeek) } else if stats.OverdueTasks > 0 { body = fmt.Sprintf("You have %d overdue task(s) that need attention", stats.OverdueTasks) } else { body = fmt.Sprintf("You have %d task(s) due this week", stats.DueThisWeek) } // Send push notification if err := h.sendPushToUser(ctx, stats.UserID, title, body, map[string]string{ "type": "daily_digest", "overdue": fmt.Sprintf("%d", stats.OverdueTasks), "due_this_week": fmt.Sprintf("%d", stats.DueThisWeek), }); err != nil { log.Error().Err(err).Uint("user_id", stats.UserID).Msg("Failed to send daily digest push") } } log.Info().Msg("Daily digest notifications completed") return nil } // EmailPayload represents the payload for email tasks type EmailPayload struct { To string `json:"to"` Subject string `json:"subject"` HTMLBody string `json:"html_body"` TextBody string `json:"text_body"` } // HandleSendEmail processes email sending tasks func (h *Handler) HandleSendEmail(ctx context.Context, task *asynq.Task) error { var payload EmailPayload if err := json.Unmarshal(task.Payload(), &payload); err != nil { return fmt.Errorf("failed to unmarshal payload: %w", err) } log.Info(). Str("to", payload.To). Str("subject", payload.Subject). Msg("Sending email...") if h.emailService == nil { log.Warn().Msg("Email service not configured, skipping email") return nil } // Use the email service to send the email if err := h.emailService.SendEmail(payload.To, payload.Subject, payload.HTMLBody, payload.TextBody); err != nil { log.Error().Err(err).Str("to", payload.To).Msg("Failed to send email") return err } log.Info().Str("to", payload.To).Msg("Email sent successfully") return nil } // PushPayload represents the payload for push notification tasks type PushPayload struct { UserID uint `json:"user_id"` Title string `json:"title"` Message string `json:"message"` Data map[string]string `json:"data,omitempty"` } // HandleSendPush processes push notification tasks func (h *Handler) HandleSendPush(ctx context.Context, task *asynq.Task) error { var payload PushPayload if err := json.Unmarshal(task.Payload(), &payload); err != nil { return fmt.Errorf("failed to unmarshal payload: %w", err) } log.Info(). Uint("user_id", payload.UserID). Str("title", payload.Title). Msg("Sending push notification...") if err := h.sendPushToUser(ctx, payload.UserID, payload.Title, payload.Message, payload.Data); err != nil { log.Error().Err(err).Uint("user_id", payload.UserID).Msg("Failed to send push notification") return err } log.Info().Uint("user_id", payload.UserID).Msg("Push notification sent successfully") return nil } // sendPushToUser sends a push notification to all of a user's devices func (h *Handler) sendPushToUser(ctx context.Context, userID uint, title, message string, data map[string]string) error { if h.pushClient == nil { log.Warn().Msg("Push client not configured, skipping notification") return nil } // Get iOS device tokens var iosTokens []string err := h.db.Model(&models.APNSDevice{}). Where("user_id = ? AND active = ?", userID, true). Pluck("registration_id", &iosTokens).Error if err != nil { log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get iOS tokens") } // Get Android device tokens var androidTokens []string err = h.db.Model(&models.GCMDevice{}). Where("user_id = ? AND active = ?", userID, true). Pluck("registration_id", &androidTokens).Error if err != nil { log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get Android tokens") } if len(iosTokens) == 0 && len(androidTokens) == 0 { log.Debug().Uint("user_id", userID).Msg("No device tokens found for user") return nil } log.Debug(). Uint("user_id", userID). Int("ios_tokens", len(iosTokens)). Int("android_tokens", len(androidTokens)). Msg("Sending push to user devices") // Send to all devices return h.pushClient.SendToAll(ctx, iosTokens, androidTokens, title, message, data) } // NewSendEmailTask creates a new email sending task func NewSendEmailTask(to, subject, htmlBody, textBody string) (*asynq.Task, error) { payload, err := json.Marshal(EmailPayload{ To: to, Subject: subject, HTMLBody: htmlBody, TextBody: textBody, }) if err != nil { return nil, err } return asynq.NewTask(TypeSendEmail, payload), nil } // NewSendPushTask creates a new push notification task func NewSendPushTask(userID uint, title, message string, data map[string]string) (*asynq.Task, error) { payload, err := json.Marshal(PushPayload{ UserID: userID, Title: title, Message: message, Data: data, }) if err != nil { return nil, err } return asynq.NewTask(TypeSendPush, payload), nil }