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:
Trey t
2025-12-23 22:03:40 -06:00
parent 914e2f82b0
commit 12ae11ca59

View File

@@ -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(` for _, userID := range eligibleUserIDs {
SELECT // Get user's residence IDs (owned + member)
u.id as user_id, residenceIDs, err := h.residenceRepo.FindResidenceIDsByUser(userID)
COUNT(DISTINCT t.id) as total_tasks, if err != nil {
COUNT(DISTINCT CASE log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get user residences")
WHEN COALESCE(t.next_due_date, t.due_date) < ? continue
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
if err != nil { if len(residenceIDs) == 0 {
log.Error().Err(err).Msg("Failed to query user task statistics") continue // User has no residences
return err }
}
log.Info().Int("users_with_tasks", len(userStats)).Msg("Processing daily digest for users") // 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
} }