Add Daily Digest notification preferences with custom time support
- Add daily_digest boolean and daily_digest_hour fields to NotificationPreference model - Update HandleDailyDigest to check user preferences and custom notification times - Change Daily Digest scheduler to run hourly (supports per-user custom times) - Update notification service DTOs for new fields - Add Daily Digest toggle and custom time to admin notification prefs page - Fix notification handlers to only notify users at their designated hour 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -69,19 +69,22 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
|
||||
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
|
||||
// Logic: Each user gets notified ONCE per day at exactly ONE hour:
|
||||
// - If user has custom hour set: notify ONLY at that custom hour
|
||||
// - If user has NO custom hour (NULL): notify ONLY at system default hour
|
||||
// This prevents duplicates: a user with custom hour is NEVER notified at default hour
|
||||
var eligibleUserIDs []uint
|
||||
|
||||
// Build query based on whether current hour is system default
|
||||
query := h.db.Model(&models.NotificationPreference{}).
|
||||
Select("user_id").
|
||||
Where("task_due_soon = true")
|
||||
|
||||
if currentHour == systemDefaultHour {
|
||||
// Current hour is the system default, so include users with custom hour OR no custom hour (NULL)
|
||||
query = query.Where("(task_due_soon_hour = ? OR task_due_soon_hour IS NULL)", currentHour)
|
||||
// At system default hour: notify users who have NO custom hour (NULL) OR whose custom hour equals default
|
||||
query = query.Where("task_due_soon_hour IS NULL OR task_due_soon_hour = ?", currentHour)
|
||||
} else {
|
||||
// Current hour is not system default, so only include users with this specific custom hour
|
||||
// At non-default hour: only notify users who have this specific custom hour set
|
||||
// Exclude users with NULL (they get notified at default hour only)
|
||||
query = query.Where("task_due_soon_hour = ?", currentHour)
|
||||
}
|
||||
|
||||
@@ -144,30 +147,13 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
|
||||
}
|
||||
|
||||
// Step 3: Send notifications (no need to check preferences again - already filtered)
|
||||
// Send individual task-specific notification for each task (all tasks, no limit)
|
||||
for userID, taskList := range userTasks {
|
||||
// Send individual actionable notification for each task (up to 5)
|
||||
maxNotifications := 5
|
||||
if len(taskList) < maxNotifications {
|
||||
maxNotifications = len(taskList)
|
||||
}
|
||||
|
||||
for i := 0; i < maxNotifications; i++ {
|
||||
t := taskList[i]
|
||||
for _, t := range taskList {
|
||||
if err := h.notificationService.CreateAndSendTaskNotification(ctx, userID, models.NotificationTaskDueSoon, &t); err != nil {
|
||||
log.Error().Err(err).Uint("user_id", userID).Uint("task_id", t.ID).Msg("Failed to send task reminder notification")
|
||||
}
|
||||
}
|
||||
|
||||
// If more than 5 tasks, send a summary notification
|
||||
if len(taskList) > 5 {
|
||||
title := "More Tasks Due Soon"
|
||||
body := fmt.Sprintf("You have %d more tasks due soon", len(taskList)-5)
|
||||
if err := h.sendPushToUser(ctx, userID, title, body, map[string]string{
|
||||
"type": "task_reminder_summary",
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to send task reminder summary")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Int("users_notified", len(userTasks)).Msg("Task reminder notifications completed")
|
||||
@@ -186,19 +172,22 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
|
||||
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
|
||||
// Logic: Each user gets notified ONCE per day at exactly ONE hour:
|
||||
// - If user has custom hour set: notify ONLY at that custom hour
|
||||
// - If user has NO custom hour (NULL): notify ONLY at system default hour
|
||||
// This prevents duplicates: a user with custom hour is NEVER notified at default hour
|
||||
var eligibleUserIDs []uint
|
||||
|
||||
// Build query based on whether current hour is system default
|
||||
query := h.db.Model(&models.NotificationPreference{}).
|
||||
Select("user_id").
|
||||
Where("task_overdue = true")
|
||||
|
||||
if currentHour == systemDefaultHour {
|
||||
// Current hour is the system default, so include users with custom hour OR no custom hour (NULL)
|
||||
query = query.Where("(task_overdue_hour = ? OR task_overdue_hour IS NULL)", currentHour)
|
||||
// At system default hour: notify users who have NO custom hour (NULL) OR whose custom hour equals default
|
||||
query = query.Where("task_overdue_hour IS NULL OR task_overdue_hour = ?", currentHour)
|
||||
} else {
|
||||
// Current hour is not system default, so only include users with this specific custom hour
|
||||
// At non-default hour: only notify users who have this specific custom hour set
|
||||
// Exclude users with NULL (they get notified at default hour only)
|
||||
query = query.Where("task_overdue_hour = ?", currentHour)
|
||||
}
|
||||
|
||||
@@ -260,30 +249,13 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
|
||||
}
|
||||
|
||||
// Step 3: Send notifications (no need to check preferences again - already filtered)
|
||||
// Send individual task-specific notification for each task (all tasks, no limit)
|
||||
for userID, taskList := range userTasks {
|
||||
// Send individual actionable notification for each task (up to 5)
|
||||
maxNotifications := 5
|
||||
if len(taskList) < maxNotifications {
|
||||
maxNotifications = len(taskList)
|
||||
}
|
||||
|
||||
for i := 0; i < maxNotifications; i++ {
|
||||
t := taskList[i]
|
||||
for _, t := range taskList {
|
||||
if err := h.notificationService.CreateAndSendTaskNotification(ctx, userID, models.NotificationTaskOverdue, &t); err != nil {
|
||||
log.Error().Err(err).Uint("user_id", userID).Uint("task_id", t.ID).Msg("Failed to send overdue notification")
|
||||
}
|
||||
}
|
||||
|
||||
// If more than 5 tasks, send a summary notification
|
||||
if len(taskList) > 5 {
|
||||
title := "More Overdue Tasks"
|
||||
body := fmt.Sprintf("You have %d more overdue tasks that need attention", len(taskList)-5)
|
||||
if err := h.sendPushToUser(ctx, userID, title, body, map[string]string{
|
||||
"type": "overdue_summary",
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to send overdue summary")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Int("users_notified", len(userTasks)).Msg("Overdue task notifications completed")
|
||||
@@ -295,10 +267,46 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error
|
||||
log.Info().Msg("Processing daily digest notifications...")
|
||||
|
||||
now := time.Now().UTC()
|
||||
currentHour := now.Hour()
|
||||
systemDefaultHour := h.config.Worker.DailyNotifHour
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
nextWeek := today.AddDate(0, 0, 7)
|
||||
|
||||
// Get all users with their task statistics
|
||||
log.Info().Int("current_hour", currentHour).Int("system_default_hour", systemDefaultHour).Msg("Daily digest check")
|
||||
|
||||
// Step 1: Find users who should receive daily digest THIS hour
|
||||
// Logic: Each user gets notified ONCE per day at exactly ONE hour:
|
||||
// - If user has custom hour set: notify ONLY at that custom hour
|
||||
// - If user has NO custom hour (NULL): notify ONLY at system default hour
|
||||
var eligibleUserIDs []uint
|
||||
|
||||
query := h.db.Model(&models.NotificationPreference{}).
|
||||
Select("user_id").
|
||||
Where("daily_digest = true")
|
||||
|
||||
if currentHour == systemDefaultHour {
|
||||
// At system default hour: notify users who have NO custom hour (NULL) OR whose custom hour equals default
|
||||
query = query.Where("daily_digest_hour IS NULL OR daily_digest_hour = ?", currentHour)
|
||||
} else {
|
||||
// At non-default hour: only notify users who have this specific custom hour set
|
||||
query = query.Where("daily_digest_hour = ?", currentHour)
|
||||
}
|
||||
|
||||
err := query.Pluck("user_id", &eligibleUserIDs).Error
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to query eligible users for daily digest")
|
||||
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 daily digest notifications this hour")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info().Int("eligible_users", len(eligibleUserIDs)).Msg("Found users eligible for daily digest this hour")
|
||||
|
||||
// Step 2: Get task statistics only for eligible users
|
||||
// Completion detection logic matches internal/task/predicates.IsCompleted:
|
||||
// A task is "completed" when NextDueDate == nil AND has at least one completion.
|
||||
// We use COALESCE(next_due_date, due_date) as the effective date for categorization.
|
||||
@@ -309,7 +317,7 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error
|
||||
DueThisWeek int
|
||||
}
|
||||
|
||||
err := h.db.Raw(`
|
||||
err = h.db.Raw(`
|
||||
SELECT
|
||||
u.id as user_id,
|
||||
COUNT(DISTINCT t.id) as total_tasks,
|
||||
@@ -330,18 +338,19 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error
|
||||
JOIN task_task t ON t.residence_id = r.id
|
||||
AND t.is_cancelled = false
|
||||
AND t.is_archived = false
|
||||
WHERE u.is_active = true
|
||||
WHERE u.is_active = true AND u.id IN ?
|
||||
GROUP BY u.id
|
||||
HAVING COUNT(DISTINCT t.id) > 0
|
||||
`, today, today, nextWeek).Scan(&userStats).Error
|
||||
`, today, today, nextWeek, eligibleUserIDs).Scan(&userStats).Error
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to query user task statistics")
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Int("users", len(userStats)).Msg("Processing daily digest for users")
|
||||
log.Info().Int("users_with_tasks", len(userStats)).Msg("Processing daily digest for users")
|
||||
|
||||
// Step 3: Send notifications
|
||||
for _, stats := range userStats {
|
||||
// Skip users with no actionable items
|
||||
if stats.OverdueTasks == 0 && stats.DueThisWeek == 0 {
|
||||
@@ -362,15 +371,15 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error
|
||||
|
||||
// Send push notification
|
||||
if err := h.sendPushToUser(ctx, stats.UserID, title, body, map[string]string{
|
||||
"type": "daily_digest",
|
||||
"overdue": fmt.Sprintf("%d", stats.OverdueTasks),
|
||||
"type": "daily_digest",
|
||||
"overdue": fmt.Sprintf("%d", stats.OverdueTasks),
|
||||
"due_this_week": fmt.Sprintf("%d", stats.DueThisWeek),
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Uint("user_id", stats.UserID).Msg("Failed to send daily digest push")
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Msg("Daily digest notifications completed")
|
||||
log.Info().Int("users_notified", len(userStats)).Msg("Daily digest notifications completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user