diff --git a/internal/worker/jobs/handler.go b/internal/worker/jobs/handler.go index 562cd63..cd046e2 100644 --- a/internal/worker/jobs/handler.go +++ b/internal/worker/jobs/handler.go @@ -559,12 +559,20 @@ func (h *Handler) HandleOnboardingEmails(ctx context.Context, task *asynq.Task) return nil } +// userReminderPrefs holds a user's notification preferences for smart reminders +type userReminderPrefs struct { + UserID uint `gorm:"column:user_id"` + WantsDueSoon bool `gorm:"column:wants_due_soon"` + WantsOverdue bool `gorm:"column:wants_overdue"` +} + // HandleSmartReminder processes frequency-aware task reminders. -// Each notification type (due soon, overdue) runs independently with its own hour setting. // Features: -// 1. Uses frequency-based schedules (weekly = day-of only, annual = 30d, 14d, 7d, day-of) -// 2. Tracks sent reminders to prevent duplicates -// 3. Tapers off overdue reminders (daily for 3 days, then every 3 days, stop after 14) +// 1. Single query to get users who want either notification type at current hour +// 2. Single query to get both due-soon AND overdue tasks for those users +// 3. Uses frequency-based schedules (weekly = day-of only, annual = 30d, 14d, 7d, day-of) +// 4. Tracks sent reminders to prevent duplicates +// 5. Tapers off overdue reminders (daily for 3 days, then every 3 days, stop after 14) func (h *Handler) HandleSmartReminder(ctx context.Context, task *asynq.Task) error { log.Info().Msg("Processing smart task reminders...") @@ -572,92 +580,76 @@ func (h *Handler) HandleSmartReminder(ctx context.Context, task *asynq.Task) err today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) currentHour := now.Hour() - log.Info().Int("current_hour", currentHour).Msg("Smart reminder check") - - var totalSent, totalSkipped int - - // Process due-soon reminders for users whose task_due_soon_hour matches - dueSoonSent, dueSoonSkipped := h.processSmartRemindersForType( - ctx, now, today, currentHour, - "due_soon", - h.config.Worker.TaskReminderHour, - "task_due_soon", - "task_due_soon_hour", - ) - totalSent += dueSoonSent - totalSkipped += dueSoonSkipped - - // Process overdue reminders for users whose task_overdue_hour matches - overdueSent, overdueSkipped := h.processSmartRemindersForType( - ctx, now, today, currentHour, - "overdue", - h.config.Worker.OverdueReminderHour, - "task_overdue", - "task_overdue_hour", - ) - totalSent += overdueSent - totalSkipped += overdueSkipped + dueSoonDefault := h.config.Worker.TaskReminderHour + overdueDefault := h.config.Worker.OverdueReminderHour log.Info(). - Int("total_sent", totalSent). - Int("total_skipped", totalSkipped). - Int("due_soon_sent", dueSoonSent). - Int("overdue_sent", overdueSent). - Msg("Smart reminder notifications completed") + Int("current_hour", currentHour). + Int("due_soon_default", dueSoonDefault). + Int("overdue_default", overdueDefault). + Msg("Smart reminder check") - return nil -} + // Step 1: Single query to get all users who want ANY notification type at this hour + // Each user gets flags for which types they want + var userPrefs []userReminderPrefs -// processSmartRemindersForType processes reminders for a specific notification type -// (due_soon or overdue) using the appropriate hour setting. -func (h *Handler) processSmartRemindersForType( - ctx context.Context, - now time.Time, - today time.Time, - currentHour int, - reminderType string, - defaultHour int, - enabledColumn string, - hourColumn string, -) (sent int, skipped int) { - // Find users who should receive this notification type THIS hour - var eligibleUserIDs []uint + // Build hour matching conditions (reused in SELECT and WHERE) + // User matches if: they set this hour explicitly, OR they have no preference and current hour is the default + dueSoonHourMatch := fmt.Sprintf( + "(task_due_soon_hour IS NULL AND %d = %d) OR task_due_soon_hour = %d", + currentHour, dueSoonDefault, currentHour, + ) + overdueHourMatch := fmt.Sprintf( + "(task_overdue_hour IS NULL AND %d = %d) OR task_overdue_hour = %d", + currentHour, overdueDefault, currentHour, + ) - query := h.db.Model(&models.NotificationPreference{}). - Select("user_id"). - Where(enabledColumn+" = true") + query := fmt.Sprintf(` + SELECT + user_id, + (task_due_soon = true AND (%s)) as wants_due_soon, + (task_overdue = true AND (%s)) as wants_overdue + FROM notifications_notificationpreference + WHERE (task_due_soon = true AND (%s)) + OR (task_overdue = true AND (%s)) + `, dueSoonHourMatch, overdueHourMatch, dueSoonHourMatch, overdueHourMatch) - if currentHour == defaultHour { - // At default hour: notify users with NULL hour OR matching hour - query = query.Where(hourColumn+" IS NULL OR "+hourColumn+" = ?", currentHour) - } else { - // At non-default hour: only notify users with this specific hour set - query = query.Where(hourColumn+" = ?", currentHour) + err := h.db.Raw(query).Scan(&userPrefs).Error + + if err != nil { + log.Error().Err(err).Msg("Failed to query user notification preferences") + return err } - if err := query.Pluck("user_id", &eligibleUserIDs).Error; err != nil { - log.Error().Err(err).Str("type", reminderType).Msg("Failed to query eligible users") - return 0, 0 + if len(userPrefs) == 0 { + log.Debug().Int("hour", currentHour).Msg("No users scheduled for any reminder type this hour") + return nil } - if len(eligibleUserIDs) == 0 { - log.Debug(). - Str("type", reminderType). - Int("hour", currentHour). - Int("default_hour", defaultHour). - Msg("No users scheduled for this reminder type this hour") - return 0, 0 + // Build lookup maps for quick access + userWantsDueSoon := make(map[uint]bool) + userWantsOverdue := make(map[uint]bool) + var allUserIDs []uint + + for _, pref := range userPrefs { + allUserIDs = append(allUserIDs, pref.UserID) + if pref.WantsDueSoon { + userWantsDueSoon[pref.UserID] = true + } + if pref.WantsOverdue { + userWantsOverdue[pref.UserID] = true + } } log.Info(). - Str("type", reminderType). - Int("eligible_users", len(eligibleUserIDs)). - Int("hour", currentHour). + Int("total_users", len(allUserIDs)). + Int("want_due_soon", len(userWantsDueSoon)). + Int("want_overdue", len(userWantsOverdue)). Msg("Found users eligible for reminders") - // Query active tasks for eligible users + // Step 2: Single query to get ALL active tasks (both due-soon and overdue) for these users opts := repositories.TaskFilterOptions{ - UserIDs: eligibleUserIDs, + UserIDs: allUserIDs, IncludeInProgress: true, PreloadResidence: true, PreloadCompletions: true, @@ -666,16 +658,15 @@ func (h *Handler) processSmartRemindersForType( activeTasks, err := h.taskRepo.GetActiveTasksForUsers(now, opts) if err != nil { - log.Error().Err(err).Str("type", reminderType).Msg("Failed to query active tasks") - return 0, 0 + log.Error().Err(err).Msg("Failed to query active tasks") + return err } - log.Info(). - Str("type", reminderType). - Int("count", len(activeTasks)). - Msg("Found active tasks for eligible users") + log.Info().Int("count", len(activeTasks)).Msg("Found active tasks for eligible users") + + // Step 3: Process each task once, sending appropriate notification based on user prefs + var dueSoonSent, dueSoonSkipped, overdueSent, overdueSkipped int - // Process each task for _, t := range activeTasks { // Determine which user to notify var userID uint @@ -687,18 +678,6 @@ func (h *Handler) processSmartRemindersForType( continue } - // Check if user is in eligible list - eligible := false - for _, eligibleID := range eligibleUserIDs { - if userID == eligibleID { - eligible = true - break - } - } - if !eligible { - continue - } - // Get the effective due date var effectiveDate time.Time if t.NextDueDate != nil { @@ -725,13 +704,15 @@ func (h *Handler) processSmartRemindersForType( continue } - // Filter by reminder type: due_soon only gets pre-due stages, overdue only gets overdue stages + // Determine if this is an overdue or due-soon stage isOverdueStage := len(stage) >= 7 && stage[:7] == "overdue" - if reminderType == "due_soon" && isOverdueStage { - continue // Skip overdue stages for due_soon processing + + // Check if user wants this notification type + if isOverdueStage && !userWantsOverdue[userID] { + continue } - if reminderType == "overdue" && !isOverdueStage { - continue // Skip pre-due stages for overdue processing + if !isOverdueStage && !userWantsDueSoon[userID] { + continue } reminderStage := models.ReminderStage(stage) @@ -739,12 +720,16 @@ func (h *Handler) processSmartRemindersForType( // Check if already sent alreadySent, err := h.reminderRepo.HasSentReminder(t.ID, userID, effectiveDate, reminderStage) if err != nil { - log.Error().Err(err).Uint("task_id", t.ID).Str("type", reminderType).Msg("Failed to check reminder log") + log.Error().Err(err).Uint("task_id", t.ID).Msg("Failed to check reminder log") continue } if alreadySent { - skipped++ + if isOverdueStage { + overdueSkipped++ + } else { + dueSoonSkipped++ + } continue } @@ -762,7 +747,6 @@ func (h *Handler) processSmartRemindersForType( Uint("user_id", userID). Uint("task_id", t.ID). Str("stage", stage). - Str("type", reminderType). Msg("Failed to send smart reminder") continue } @@ -772,16 +756,21 @@ func (h *Handler) processSmartRemindersForType( log.Error().Err(err).Uint("task_id", t.ID).Str("stage", stage).Msg("Failed to log reminder") } - sent++ + if isOverdueStage { + overdueSent++ + } else { + dueSoonSent++ + } } log.Info(). - Str("type", reminderType). - Int("sent", sent). - Int("skipped", skipped). - Msg("Completed processing for reminder type") + Int("due_soon_sent", dueSoonSent). + Int("due_soon_skipped", dueSoonSkipped). + Int("overdue_sent", overdueSent). + Int("overdue_skipped", overdueSkipped). + Msg("Smart reminder notifications completed") - return sent, skipped + return nil } // HandleReminderLogCleanup cleans up old reminder logs to prevent table bloat