Add per-user notification time preferences
Allow users to customize when they receive notification reminders: - Add task_due_soon_hour, task_overdue_hour, warranty_expiring_hour fields - Store times in UTC, clients convert to/from local timezone - Worker runs hourly, queries only users scheduled for that hour - Early exit optimization when no users need notifications - Admin UI displays custom notification times 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -61,17 +61,46 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
|
||||
log.Info().Msg("Processing task reminder notifications...")
|
||||
|
||||
now := time.Now().UTC()
|
||||
currentHour := now.Hour()
|
||||
systemDefaultHour := h.config.Worker.TaskReminderHour
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
dayAfterTomorrow := today.AddDate(0, 0, 2)
|
||||
|
||||
// Query tasks due today or tomorrow with full task data for button types
|
||||
log.Info().Int("current_hour", currentHour).Int("system_default_hour", systemDefaultHour).Msg("Task reminder check")
|
||||
|
||||
// Step 1: Find users who should receive notifications THIS hour
|
||||
// Users with custom hour matching current hour, OR users with no custom hour when current hour is system default
|
||||
var eligibleUserIDs []uint
|
||||
err := h.db.Model(&models.NotificationPreference{}).
|
||||
Select("user_id").
|
||||
Where("task_due_soon = true").
|
||||
Where("(task_due_soon_hour = ? OR (task_due_soon_hour IS NULL AND ? = ?))",
|
||||
currentHour, currentHour, systemDefaultHour).
|
||||
Pluck("user_id", &eligibleUserIDs).Error
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to query eligible users for task reminders")
|
||||
return err
|
||||
}
|
||||
|
||||
// Early exit if no users need notifications this hour
|
||||
if len(eligibleUserIDs) == 0 {
|
||||
log.Debug().Int("hour", currentHour).Msg("No users scheduled for task reminder notifications this hour")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info().Int("eligible_users", len(eligibleUserIDs)).Msg("Found users eligible for task reminders this hour")
|
||||
|
||||
// Step 2: Query tasks due today or tomorrow only for eligible users
|
||||
var dueSoonTasks []models.Task
|
||||
err := h.db.Preload("Status").Preload("Completions").Preload("Residence").
|
||||
err = h.db.Preload("Status").Preload("Completions").Preload("Residence").
|
||||
Where("(due_date >= ? AND due_date < ?) OR (next_due_date >= ? AND next_due_date < ?)",
|
||||
today, dayAfterTomorrow, today, dayAfterTomorrow).
|
||||
Where("is_cancelled = false").
|
||||
Where("is_archived = false").
|
||||
Where("NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id AND tc.completed_at >= task_task.due_date)").
|
||||
Where("(assigned_to_id IN ? OR residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ?))",
|
||||
eligibleUserIDs, eligibleUserIDs).
|
||||
Find(&dueSoonTasks).Error
|
||||
|
||||
if err != nil {
|
||||
@@ -79,7 +108,7 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Int("count", len(dueSoonTasks)).Msg("Found tasks due today/tomorrow")
|
||||
log.Info().Int("count", len(dueSoonTasks)).Msg("Found tasks due today/tomorrow for eligible users")
|
||||
|
||||
// Group tasks by user (assigned_to or residence owner)
|
||||
userTasks := make(map[uint][]models.Task)
|
||||
@@ -92,25 +121,17 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
userTasks[userID] = append(userTasks[userID], t)
|
||||
// Only include if user is in eligible list
|
||||
for _, eligibleID := range eligibleUserIDs {
|
||||
if userID == eligibleID {
|
||||
userTasks[userID] = append(userTasks[userID], t)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send actionable notifications to each user
|
||||
// Step 3: Send notifications (no need to check preferences again - already filtered)
|
||||
for userID, taskList := 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
|
||||
}
|
||||
|
||||
// Send individual actionable notification for each task (up to 5)
|
||||
maxNotifications := 5
|
||||
if len(taskList) < maxNotifications {
|
||||
@@ -136,7 +157,7 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Msg("Task reminder notifications completed")
|
||||
log.Info().Int("users_notified", len(userTasks)).Msg("Task reminder notifications completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -145,16 +166,44 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
|
||||
log.Info().Msg("Processing overdue task notifications...")
|
||||
|
||||
now := time.Now().UTC()
|
||||
currentHour := now.Hour()
|
||||
systemDefaultHour := h.config.Worker.OverdueReminderHour
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// Query overdue tasks with full task data for button types
|
||||
log.Info().Int("current_hour", currentHour).Int("system_default_hour", systemDefaultHour).Msg("Overdue reminder check")
|
||||
|
||||
// Step 1: Find users who should receive notifications THIS hour
|
||||
// Users with custom hour matching current hour, OR users with no custom hour when current hour is system default
|
||||
var eligibleUserIDs []uint
|
||||
err := h.db.Model(&models.NotificationPreference{}).
|
||||
Select("user_id").
|
||||
Where("task_overdue = true").
|
||||
Where("(task_overdue_hour = ? OR (task_overdue_hour IS NULL AND ? = ?))",
|
||||
currentHour, currentHour, systemDefaultHour).
|
||||
Pluck("user_id", &eligibleUserIDs).Error
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to query eligible users for overdue reminders")
|
||||
return err
|
||||
}
|
||||
|
||||
// Early exit if no users need notifications this hour
|
||||
if len(eligibleUserIDs) == 0 {
|
||||
log.Debug().Int("hour", currentHour).Msg("No users scheduled for overdue notifications this hour")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info().Int("eligible_users", len(eligibleUserIDs)).Msg("Found users eligible for overdue reminders this hour")
|
||||
|
||||
// Step 2: Query overdue tasks only for eligible users
|
||||
var overdueTasks []models.Task
|
||||
err := h.db.Preload("Status").Preload("Completions").Preload("Residence").
|
||||
Joins("JOIN residence_residence r ON task_task.residence_id = r.id").
|
||||
Where("task_task.due_date < ? OR task_task.next_due_date < ?", today, today).
|
||||
Where("task_task.is_cancelled = false").
|
||||
Where("task_task.is_archived = false").
|
||||
err = h.db.Preload("Status").Preload("Completions").Preload("Residence").
|
||||
Where("due_date < ? OR next_due_date < ?", today, today).
|
||||
Where("is_cancelled = false").
|
||||
Where("is_archived = false").
|
||||
Where("NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id AND tc.completed_at >= task_task.due_date)").
|
||||
Where("(assigned_to_id IN ? OR residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ?))",
|
||||
eligibleUserIDs, eligibleUserIDs).
|
||||
Find(&overdueTasks).Error
|
||||
|
||||
if err != nil {
|
||||
@@ -162,7 +211,7 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Int("count", len(overdueTasks)).Msg("Found overdue tasks")
|
||||
log.Info().Int("count", len(overdueTasks)).Msg("Found overdue tasks for eligible users")
|
||||
|
||||
// Group tasks by user (assigned_to or residence owner)
|
||||
userTasks := make(map[uint][]models.Task)
|
||||
@@ -175,25 +224,17 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
userTasks[userID] = append(userTasks[userID], t)
|
||||
// Only include if user is in eligible list
|
||||
for _, eligibleID := range eligibleUserIDs {
|
||||
if userID == eligibleID {
|
||||
userTasks[userID] = append(userTasks[userID], t)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send actionable notifications to each user
|
||||
// Step 3: Send notifications (no need to check preferences again - already filtered)
|
||||
for userID, taskList := 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
|
||||
}
|
||||
|
||||
// Send individual actionable notification for each task (up to 5)
|
||||
maxNotifications := 5
|
||||
if len(taskList) < maxNotifications {
|
||||
@@ -219,7 +260,7 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Msg("Overdue task notifications completed")
|
||||
log.Info().Int("users_notified", len(userTasks)).Msg("Overdue task notifications completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user