Refactor daily digest to use canonical repository functions
Replace raw SQL in HandleDailyDigest with repository functions that use the canonical task scopes. This ensures the daily digest push notification uses the exact same overdue/due-soon logic as the kanban display. Changes: - Add residenceRepo to Handler struct for user residence lookups - Use taskRepo.GetOverdueTasks() instead of raw SQL (uses ScopeOverdue) - Use taskRepo.GetDueSoonTasks() instead of raw SQL (uses ScopeDueSoon) - Set IncludeInProgress: false to match kanban behavior Fixes bug where notification reported 3 overdue tasks when kanban showed 2 (in-progress tasks were incorrectly counted as overdue in the digest). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@ const (
|
|||||||
type Handler struct {
|
type Handler struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
taskRepo *repositories.TaskRepository
|
taskRepo *repositories.TaskRepository
|
||||||
|
residenceRepo *repositories.ResidenceRepository
|
||||||
reminderRepo *repositories.ReminderRepository
|
reminderRepo *repositories.ReminderRepository
|
||||||
pushClient *push.Client
|
pushClient *push.Client
|
||||||
emailService *services.EmailService
|
emailService *services.EmailService
|
||||||
@@ -53,6 +54,7 @@ func NewHandler(db *gorm.DB, pushClient *push.Client, emailService *services.Ema
|
|||||||
return &Handler{
|
return &Handler{
|
||||||
db: db,
|
db: db,
|
||||||
taskRepo: repositories.NewTaskRepository(db),
|
taskRepo: repositories.NewTaskRepository(db),
|
||||||
|
residenceRepo: repositories.NewResidenceRepository(db),
|
||||||
reminderRepo: repositories.NewReminderRepository(db),
|
reminderRepo: repositories.NewReminderRepository(db),
|
||||||
pushClient: pushClient,
|
pushClient: pushClient,
|
||||||
emailService: emailService,
|
emailService: emailService,
|
||||||
@@ -275,8 +277,6 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error
|
|||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
currentHour := now.Hour()
|
currentHour := now.Hour()
|
||||||
systemDefaultHour := h.config.Worker.DailyNotifHour
|
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)
|
|
||||||
|
|
||||||
log.Info().Int("current_hour", currentHour).Int("system_default_hour", systemDefaultHour).Msg("Daily digest check")
|
log.Info().Int("current_hour", currentHour).Int("system_default_hour", systemDefaultHour).Msg("Daily digest check")
|
||||||
|
|
||||||
@@ -312,54 +312,49 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error
|
|||||||
|
|
||||||
log.Info().Int("eligible_users", len(eligibleUserIDs)).Msg("Found users eligible for daily digest this hour")
|
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
|
// Step 2: Get task statistics for each user using canonical repository functions
|
||||||
// Completion detection logic matches internal/task/predicates.IsCompleted:
|
// This ensures consistency with kanban display - uses the same scopes:
|
||||||
// A task is "completed" when NextDueDate == nil AND has at least one completion.
|
// - task.ScopeOverdue (excludes in-progress tasks)
|
||||||
// We use COALESCE(next_due_date, due_date) as the effective date for categorization.
|
// - task.ScopeDueSoon (7 days for "due this week")
|
||||||
var userStats []struct {
|
var usersNotified int
|
||||||
UserID uint
|
|
||||||
TotalTasks int
|
|
||||||
OverdueTasks int
|
|
||||||
DueThisWeek int
|
|
||||||
}
|
|
||||||
|
|
||||||
err = h.db.Raw(`
|
|
||||||
SELECT
|
|
||||||
u.id as user_id,
|
|
||||||
COUNT(DISTINCT t.id) as total_tasks,
|
|
||||||
COUNT(DISTINCT CASE
|
|
||||||
WHEN COALESCE(t.next_due_date, t.due_date) < ?
|
|
||||||
AND NOT (t.next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = t.id))
|
|
||||||
THEN t.id
|
|
||||||
END) as overdue_tasks,
|
|
||||||
COUNT(DISTINCT CASE
|
|
||||||
WHEN COALESCE(t.next_due_date, t.due_date) >= ? AND COALESCE(t.next_due_date, t.due_date) < ?
|
|
||||||
AND NOT (t.next_due_date IS NULL AND EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = t.id))
|
|
||||||
THEN t.id
|
|
||||||
END) as due_this_week
|
|
||||||
FROM auth_user u
|
|
||||||
JOIN residence_residence r ON (r.owner_id = u.id OR r.id IN (
|
|
||||||
SELECT residence_id FROM residence_residence_users WHERE user_id = u.id
|
|
||||||
)) AND r.is_active = true
|
|
||||||
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 AND u.id IN ?
|
|
||||||
GROUP BY u.id
|
|
||||||
HAVING COUNT(DISTINCT t.id) > 0
|
|
||||||
`, today, today, nextWeek, eligibleUserIDs).Scan(&userStats).Error
|
|
||||||
|
|
||||||
|
for _, userID := range eligibleUserIDs {
|
||||||
|
// Get user's residence IDs (owned + member)
|
||||||
|
residenceIDs, err := h.residenceRepo.FindResidenceIDsByUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to query user task statistics")
|
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get user residences")
|
||||||
return err
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info().Int("users_with_tasks", len(userStats)).Msg("Processing daily digest for users")
|
if len(residenceIDs) == 0 {
|
||||||
|
continue // User has no residences
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query overdue tasks using canonical scopes (excludes in-progress)
|
||||||
|
opts := repositories.TaskFilterOptions{
|
||||||
|
ResidenceIDs: residenceIDs,
|
||||||
|
IncludeInProgress: false, // Match kanban: in-progress tasks not in overdue column
|
||||||
|
PreloadCompletions: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
overdueTasks, err := h.taskRepo.GetOverdueTasks(now, opts)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get overdue tasks")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query due-this-week tasks (7 days threshold)
|
||||||
|
dueSoonTasks, err := h.taskRepo.GetDueSoonTasks(now, 7, opts)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get due-soon tasks")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
overdueCount := len(overdueTasks)
|
||||||
|
dueThisWeekCount := len(dueSoonTasks)
|
||||||
|
|
||||||
// Step 3: Send notifications
|
|
||||||
for _, stats := range userStats {
|
|
||||||
// Skip users with no actionable items
|
// Skip users with no actionable items
|
||||||
if stats.OverdueTasks == 0 && stats.DueThisWeek == 0 {
|
if overdueCount == 0 && dueThisWeekCount == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,25 +362,27 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error
|
|||||||
title := "Daily Task Summary"
|
title := "Daily Task Summary"
|
||||||
var body string
|
var body string
|
||||||
|
|
||||||
if stats.OverdueTasks > 0 && stats.DueThisWeek > 0 {
|
if overdueCount > 0 && dueThisWeekCount > 0 {
|
||||||
body = fmt.Sprintf("You have %d overdue task(s) and %d task(s) due this week", stats.OverdueTasks, stats.DueThisWeek)
|
body = fmt.Sprintf("You have %d overdue task(s) and %d task(s) due this week", overdueCount, dueThisWeekCount)
|
||||||
} else if stats.OverdueTasks > 0 {
|
} else if overdueCount > 0 {
|
||||||
body = fmt.Sprintf("You have %d overdue task(s) that need attention", stats.OverdueTasks)
|
body = fmt.Sprintf("You have %d overdue task(s) that need attention", overdueCount)
|
||||||
} else {
|
} else {
|
||||||
body = fmt.Sprintf("You have %d task(s) due this week", stats.DueThisWeek)
|
body = fmt.Sprintf("You have %d task(s) due this week", dueThisWeekCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send push notification
|
// Send push notification
|
||||||
if err := h.sendPushToUser(ctx, stats.UserID, title, body, map[string]string{
|
if err := h.sendPushToUser(ctx, userID, title, body, map[string]string{
|
||||||
"type": "daily_digest",
|
"type": "daily_digest",
|
||||||
"overdue": fmt.Sprintf("%d", stats.OverdueTasks),
|
"overdue": fmt.Sprintf("%d", overdueCount),
|
||||||
"due_this_week": fmt.Sprintf("%d", stats.DueThisWeek),
|
"due_this_week": fmt.Sprintf("%d", dueThisWeekCount),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error().Err(err).Uint("user_id", stats.UserID).Msg("Failed to send daily digest push")
|
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to send daily digest push")
|
||||||
|
} else {
|
||||||
|
usersNotified++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info().Int("users_notified", len(userStats)).Msg("Daily digest notifications completed")
|
log.Info().Int("users_notified", usersNotified).Msg("Daily digest notifications completed")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user