Optimize smart reminder to use single query for users and tasks
Refactored to avoid duplicate queries: - One SQL query gets users with flags for which notification types they want - One task query gets all active tasks for those users - Single loop processes tasks, using map lookup for user preferences Previously called processSmartRemindersForType twice (due_soon, overdue), each doing separate user query + task query. Users with both types at same hour had their tasks queried twice. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -559,12 +559,20 @@ func (h *Handler) HandleOnboardingEmails(ctx context.Context, task *asynq.Task)
|
|||||||
return nil
|
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.
|
// HandleSmartReminder processes frequency-aware task reminders.
|
||||||
// Each notification type (due soon, overdue) runs independently with its own hour setting.
|
|
||||||
// Features:
|
// Features:
|
||||||
// 1. Uses frequency-based schedules (weekly = day-of only, annual = 30d, 14d, 7d, day-of)
|
// 1. Single query to get users who want either notification type at current hour
|
||||||
// 2. Tracks sent reminders to prevent duplicates
|
// 2. Single query to get both due-soon AND overdue tasks for those users
|
||||||
// 3. Tapers off overdue reminders (daily for 3 days, then every 3 days, stop after 14)
|
// 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 {
|
func (h *Handler) HandleSmartReminder(ctx context.Context, task *asynq.Task) error {
|
||||||
log.Info().Msg("Processing smart task reminders...")
|
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)
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
currentHour := now.Hour()
|
currentHour := now.Hour()
|
||||||
|
|
||||||
log.Info().Int("current_hour", currentHour).Msg("Smart reminder check")
|
dueSoonDefault := h.config.Worker.TaskReminderHour
|
||||||
|
overdueDefault := h.config.Worker.OverdueReminderHour
|
||||||
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("total_sent", totalSent).
|
Int("current_hour", currentHour).
|
||||||
Int("total_skipped", totalSkipped).
|
Int("due_soon_default", dueSoonDefault).
|
||||||
Int("due_soon_sent", dueSoonSent).
|
Int("overdue_default", overdueDefault).
|
||||||
Int("overdue_sent", overdueSent).
|
Msg("Smart reminder check")
|
||||||
Msg("Smart reminder notifications completed")
|
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// 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 := 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)
|
||||||
|
|
||||||
|
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 len(userPrefs) == 0 {
|
||||||
|
log.Debug().Int("hour", currentHour).Msg("No users scheduled for any reminder type this hour")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// processSmartRemindersForType processes reminders for a specific notification type
|
// Build lookup maps for quick access
|
||||||
// (due_soon or overdue) using the appropriate hour setting.
|
userWantsDueSoon := make(map[uint]bool)
|
||||||
func (h *Handler) processSmartRemindersForType(
|
userWantsOverdue := make(map[uint]bool)
|
||||||
ctx context.Context,
|
var allUserIDs []uint
|
||||||
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{}).
|
for _, pref := range userPrefs {
|
||||||
Select("user_id").
|
allUserIDs = append(allUserIDs, pref.UserID)
|
||||||
Where(enabledColumn+" = true")
|
if pref.WantsDueSoon {
|
||||||
|
userWantsDueSoon[pref.UserID] = true
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
if pref.WantsOverdue {
|
||||||
if err := query.Pluck("user_id", &eligibleUserIDs).Error; err != nil {
|
userWantsOverdue[pref.UserID] = true
|
||||||
log.Error().Err(err).Str("type", reminderType).Msg("Failed to query eligible users")
|
|
||||||
return 0, 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info().
|
log.Info().
|
||||||
Str("type", reminderType).
|
Int("total_users", len(allUserIDs)).
|
||||||
Int("eligible_users", len(eligibleUserIDs)).
|
Int("want_due_soon", len(userWantsDueSoon)).
|
||||||
Int("hour", currentHour).
|
Int("want_overdue", len(userWantsOverdue)).
|
||||||
Msg("Found users eligible for reminders")
|
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{
|
opts := repositories.TaskFilterOptions{
|
||||||
UserIDs: eligibleUserIDs,
|
UserIDs: allUserIDs,
|
||||||
IncludeInProgress: true,
|
IncludeInProgress: true,
|
||||||
PreloadResidence: true,
|
PreloadResidence: true,
|
||||||
PreloadCompletions: true,
|
PreloadCompletions: true,
|
||||||
@@ -666,16 +658,15 @@ func (h *Handler) processSmartRemindersForType(
|
|||||||
|
|
||||||
activeTasks, err := h.taskRepo.GetActiveTasksForUsers(now, opts)
|
activeTasks, err := h.taskRepo.GetActiveTasksForUsers(now, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("type", reminderType).Msg("Failed to query active tasks")
|
log.Error().Err(err).Msg("Failed to query active tasks")
|
||||||
return 0, 0
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info().
|
log.Info().Int("count", len(activeTasks)).Msg("Found active tasks for eligible users")
|
||||||
Str("type", reminderType).
|
|
||||||
Int("count", len(activeTasks)).
|
// Step 3: Process each task once, sending appropriate notification based on user prefs
|
||||||
Msg("Found active tasks for eligible users")
|
var dueSoonSent, dueSoonSkipped, overdueSent, overdueSkipped int
|
||||||
|
|
||||||
// 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
|
||||||
@@ -687,18 +678,6 @@ func (h *Handler) processSmartRemindersForType(
|
|||||||
continue
|
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
|
// Get the effective due date
|
||||||
var effectiveDate time.Time
|
var effectiveDate time.Time
|
||||||
if t.NextDueDate != nil {
|
if t.NextDueDate != nil {
|
||||||
@@ -725,13 +704,15 @@ func (h *Handler) processSmartRemindersForType(
|
|||||||
continue
|
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"
|
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 {
|
if !isOverdueStage && !userWantsDueSoon[userID] {
|
||||||
continue // Skip pre-due stages for overdue processing
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
reminderStage := models.ReminderStage(stage)
|
reminderStage := models.ReminderStage(stage)
|
||||||
@@ -739,12 +720,16 @@ func (h *Handler) processSmartRemindersForType(
|
|||||||
// 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).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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if alreadySent {
|
if alreadySent {
|
||||||
skipped++
|
if isOverdueStage {
|
||||||
|
overdueSkipped++
|
||||||
|
} else {
|
||||||
|
dueSoonSkipped++
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,7 +747,6 @@ func (h *Handler) processSmartRemindersForType(
|
|||||||
Uint("user_id", userID).
|
Uint("user_id", userID).
|
||||||
Uint("task_id", t.ID).
|
Uint("task_id", t.ID).
|
||||||
Str("stage", stage).
|
Str("stage", stage).
|
||||||
Str("type", reminderType).
|
|
||||||
Msg("Failed to send smart reminder")
|
Msg("Failed to send smart reminder")
|
||||||
continue
|
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")
|
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().
|
log.Info().
|
||||||
Str("type", reminderType).
|
Int("due_soon_sent", dueSoonSent).
|
||||||
Int("sent", sent).
|
Int("due_soon_skipped", dueSoonSkipped).
|
||||||
Int("skipped", skipped).
|
Int("overdue_sent", overdueSent).
|
||||||
Msg("Completed processing for reminder type")
|
Int("overdue_skipped", overdueSkipped).
|
||||||
|
Msg("Smart reminder notifications completed")
|
||||||
|
|
||||||
return sent, skipped
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleReminderLogCleanup cleans up old reminder logs to prevent table bloat
|
// HandleReminderLogCleanup cleans up old reminder logs to prevent table bloat
|
||||||
|
|||||||
Reference in New Issue
Block a user