Fix smart reminder to process each notification type independently

Each notification type (due_soon, overdue) now runs at its own configured
hour. Due-soon uses task_due_soon_hour preference, overdue uses
task_overdue_hour preference. Previously only task_due_soon_hour was
checked, causing overdue notifications to never fire for users with
custom overdue hours.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-22 21:41:33 -06:00
parent 8962850003
commit 1757dfb83f

View File

@@ -560,7 +560,8 @@ func (h *Handler) HandleOnboardingEmails(ctx context.Context, task *asynq.Task)
} }
// HandleSmartReminder processes frequency-aware task reminders. // 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) // 1. Uses frequency-based schedules (weekly = day-of only, annual = 30d, 14d, 7d, day-of)
// 2. Tracks sent reminders to prevent duplicates // 2. Tracks sent reminders to prevent duplicates
// 3. Tapers off overdue reminders (daily for 3 days, then every 3 days, stop after 14) // 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) today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
currentHour := now.Hour() currentHour := now.Hour()
// Use the task_due_soon hour setting for smart reminders log.Info().Int("current_hour", currentHour).Msg("Smart reminder check")
systemDefaultHour := h.config.Worker.TaskReminderHour
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(). log.Info().
Int("current_hour", currentHour). Int("total_sent", totalSent).
Int("system_default_hour", systemDefaultHour). Int("total_skipped", totalSkipped).
Msg("Smart reminder check") 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 var eligibleUserIDs []uint
query := h.db.Model(&models.NotificationPreference{}). query := h.db.Model(&models.NotificationPreference{}).
Select("user_id"). Select("user_id").
Where("task_due_soon = true OR task_overdue = true") Where(enabledColumn+" = true")
if currentHour == systemDefaultHour { if currentHour == defaultHour {
query = query.Where("task_due_soon_hour IS NULL OR task_due_soon_hour = ?", currentHour) // At default hour: notify users with NULL hour OR matching hour
query = query.Where(hourColumn+" IS NULL OR "+hourColumn+" = ?", currentHour)
} else { } 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 { if err := query.Pluck("user_id", &eligibleUserIDs).Error; err != nil {
log.Error().Err(err).Msg("Failed to query eligible users for smart reminders") log.Error().Err(err).Str("type", reminderType).Msg("Failed to query eligible users")
return err return 0, 0
} }
if len(eligibleUserIDs) == 0 { if len(eligibleUserIDs) == 0 {
log.Debug().Int("hour", currentHour).Msg("No users scheduled for smart reminders this hour") log.Debug().
return nil 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{ opts := repositories.TaskFilterOptions{
UserIDs: eligibleUserIDs, UserIDs: eligibleUserIDs,
IncludeInProgress: true, IncludeInProgress: true,
@@ -613,18 +664,18 @@ func (h *Handler) HandleSmartReminder(ctx context.Context, task *asynq.Task) err
PreloadFrequency: true, PreloadFrequency: true,
} }
// Get all active, non-completed tasks
activeTasks, err := h.taskRepo.GetActiveTasksForUsers(now, opts) activeTasks, err := h.taskRepo.GetActiveTasksForUsers(now, opts)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to query active tasks") log.Error().Err(err).Str("type", reminderType).Msg("Failed to query active tasks")
return err return 0, 0
} }
log.Info().Int("count", len(activeTasks)).Msg("Found active tasks for eligible users") log.Info().
Str("type", reminderType).
// Step 3: Process each task Int("count", len(activeTasks)).
var sentCount, skippedCount int Msg("Found active tasks for eligible users")
// Process each task
for _, t := range activeTasks { for _, t := range activeTasks {
// Determine which user to notify // Determine which user to notify
var userID uint var userID uint
@@ -648,14 +699,13 @@ func (h *Handler) HandleSmartReminder(ctx context.Context, task *asynq.Task) err
continue continue
} }
// Get the effective due date (NextDueDate takes precedence for recurring tasks) // Get the effective due date
var effectiveDate time.Time var effectiveDate time.Time
if t.NextDueDate != nil { if t.NextDueDate != nil {
effectiveDate = *t.NextDueDate effectiveDate = *t.NextDueDate
} else if t.DueDate != nil { } else if t.DueDate != nil {
effectiveDate = *t.DueDate effectiveDate = *t.DueDate
} else { } else {
// No due date, skip
continue continue
} }
@@ -672,35 +722,48 @@ func (h *Handler) HandleSmartReminder(ctx context.Context, task *asynq.Task) err
// Determine which reminder stage applies today // Determine which reminder stage applies today
stage := notifications.GetReminderStageForToday(effectiveDate, frequencyDays, today) stage := notifications.GetReminderStageForToday(effectiveDate, frequencyDays, today)
if stage == "" { 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) reminderStage := models.ReminderStage(stage)
// Check if already sent // Check if already sent
alreadySent, err := h.reminderRepo.HasSentReminder(t.ID, userID, effectiveDate, reminderStage) alreadySent, err := h.reminderRepo.HasSentReminder(t.ID, userID, effectiveDate, reminderStage)
if err != nil { 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 continue
} }
if alreadySent { if alreadySent {
skippedCount++ skipped++
continue continue
} }
// Determine notification type based on stage // Determine notification type
var notificationType models.NotificationType var notificationType models.NotificationType
if stage == "day_of" || (len(stage) >= 8 && stage[:8] == "reminder") { if isOverdueStage {
notificationType = models.NotificationTaskDueSoon
} else {
notificationType = models.NotificationTaskOverdue notificationType = models.NotificationTaskOverdue
} else {
notificationType = models.NotificationTaskDueSoon
} }
// Send notification // Send notification
if err := h.notificationService.CreateAndSendTaskNotification(ctx, userID, notificationType, &t); err != nil { 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 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") log.Error().Err(err).Uint("task_id", t.ID).Str("stage", stage).Msg("Failed to log reminder")
} }
sentCount++ sent++
} }
log.Info(). log.Info().
Int("sent", sentCount). Str("type", reminderType).
Int("skipped_duplicates", skippedCount). Int("sent", sent).
Msg("Smart reminder notifications completed") Int("skipped", skipped).
Msg("Completed processing for reminder type")
return nil return sent, skipped
} }
// HandleReminderLogCleanup cleans up old reminder logs to prevent table bloat // HandleReminderLogCleanup cleans up old reminder logs to prevent table bloat