diff --git a/internal/worker/jobs/handler.go b/internal/worker/jobs/handler.go index 9b92d70..562cd63 100644 --- a/internal/worker/jobs/handler.go +++ b/internal/worker/jobs/handler.go @@ -560,7 +560,8 @@ func (h *Handler) HandleOnboardingEmails(ctx context.Context, task *asynq.Task) } // HandleSmartReminder processes frequency-aware task reminders. -// Unlike the old HandleTaskReminder and HandleOverdueReminder, this handler: +// 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) @@ -571,40 +572,90 @@ 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() - // Use the task_due_soon hour setting for smart reminders - systemDefaultHour := h.config.Worker.TaskReminderHour + 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 log.Info(). - Int("current_hour", currentHour). - Int("system_default_hour", systemDefaultHour). - Msg("Smart reminder check") + Int("total_sent", totalSent). + Int("total_skipped", totalSkipped). + Int("due_soon_sent", dueSoonSent). + Int("overdue_sent", overdueSent). + Msg("Smart reminder notifications completed") - // Step 1: Find users who should receive notifications THIS hour + return nil +} + +// 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 query := h.db.Model(&models.NotificationPreference{}). Select("user_id"). - Where("task_due_soon = true OR task_overdue = true") + Where(enabledColumn+" = true") - if currentHour == systemDefaultHour { - query = query.Where("task_due_soon_hour IS NULL OR task_due_soon_hour = ?", currentHour) + if currentHour == defaultHour { + // At default hour: notify users with NULL hour OR matching hour + query = query.Where(hourColumn+" IS NULL OR "+hourColumn+" = ?", currentHour) } else { - query = query.Where("task_due_soon_hour = ?", currentHour) + // At non-default hour: only notify users with this specific hour set + query = query.Where(hourColumn+" = ?", currentHour) } if err := query.Pluck("user_id", &eligibleUserIDs).Error; err != nil { - log.Error().Err(err).Msg("Failed to query eligible users for smart reminders") - return err + log.Error().Err(err).Str("type", reminderType).Msg("Failed to query eligible users") + return 0, 0 } if len(eligibleUserIDs) == 0 { - log.Debug().Int("hour", currentHour).Msg("No users scheduled for smart reminders this hour") - return nil + 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 } - log.Info().Int("eligible_users", len(eligibleUserIDs)).Msg("Found users eligible for smart reminders") + log.Info(). + Str("type", reminderType). + Int("eligible_users", len(eligibleUserIDs)). + Int("hour", currentHour). + Msg("Found users eligible for reminders") - // Step 2: Query all active tasks for eligible users + // Query active tasks for eligible users opts := repositories.TaskFilterOptions{ UserIDs: eligibleUserIDs, IncludeInProgress: true, @@ -613,18 +664,18 @@ func (h *Handler) HandleSmartReminder(ctx context.Context, task *asynq.Task) err PreloadFrequency: true, } - // Get all active, non-completed tasks activeTasks, err := h.taskRepo.GetActiveTasksForUsers(now, opts) if err != nil { - log.Error().Err(err).Msg("Failed to query active tasks") - return err + log.Error().Err(err).Str("type", reminderType).Msg("Failed to query active tasks") + return 0, 0 } - log.Info().Int("count", len(activeTasks)).Msg("Found active tasks for eligible users") - - // Step 3: Process each task - var sentCount, skippedCount int + log.Info(). + Str("type", reminderType). + Int("count", len(activeTasks)). + Msg("Found active tasks for eligible users") + // Process each task for _, t := range activeTasks { // Determine which user to notify var userID uint @@ -648,14 +699,13 @@ func (h *Handler) HandleSmartReminder(ctx context.Context, task *asynq.Task) err continue } - // Get the effective due date (NextDueDate takes precedence for recurring tasks) + // Get the effective due date var effectiveDate time.Time if t.NextDueDate != nil { effectiveDate = *t.NextDueDate } else if t.DueDate != nil { effectiveDate = *t.DueDate } else { - // No due date, skip continue } @@ -672,35 +722,48 @@ func (h *Handler) HandleSmartReminder(ctx context.Context, task *asynq.Task) err // Determine which reminder stage applies today stage := notifications.GetReminderStageForToday(effectiveDate, frequencyDays, today) if stage == "" { - continue // No reminder needed today + continue + } + + // Filter by reminder type: due_soon only gets pre-due stages, overdue only gets overdue stages + isOverdueStage := len(stage) >= 7 && stage[:7] == "overdue" + if reminderType == "due_soon" && isOverdueStage { + continue // Skip overdue stages for due_soon processing + } + if reminderType == "overdue" && !isOverdueStage { + continue // Skip pre-due stages for overdue processing } - // Convert stage string to ReminderStage type reminderStage := models.ReminderStage(stage) // 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).Msg("Failed to check reminder log") + log.Error().Err(err).Uint("task_id", t.ID).Str("type", reminderType).Msg("Failed to check reminder log") continue } if alreadySent { - skippedCount++ + skipped++ continue } - // Determine notification type based on stage + // Determine notification type var notificationType models.NotificationType - if stage == "day_of" || (len(stage) >= 8 && stage[:8] == "reminder") { - notificationType = models.NotificationTaskDueSoon - } else { + if isOverdueStage { notificationType = models.NotificationTaskOverdue + } else { + notificationType = models.NotificationTaskDueSoon } // Send notification if err := h.notificationService.CreateAndSendTaskNotification(ctx, userID, notificationType, &t); err != nil { - log.Error().Err(err).Uint("user_id", userID).Uint("task_id", t.ID).Str("stage", stage).Msg("Failed to send smart reminder") + log.Error().Err(err). + Uint("user_id", userID). + Uint("task_id", t.ID). + Str("stage", stage). + Str("type", reminderType). + Msg("Failed to send smart reminder") continue } @@ -709,15 +772,16 @@ func (h *Handler) HandleSmartReminder(ctx context.Context, task *asynq.Task) err log.Error().Err(err).Uint("task_id", t.ID).Str("stage", stage).Msg("Failed to log reminder") } - sentCount++ + sent++ } log.Info(). - Int("sent", sentCount). - Int("skipped_duplicates", skippedCount). - Msg("Smart reminder notifications completed") + Str("type", reminderType). + Int("sent", sent). + Int("skipped", skipped). + Msg("Completed processing for reminder type") - return nil + return sent, skipped } // HandleReminderLogCleanup cleans up old reminder logs to prevent table bloat