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 {
db *gorm.DB
taskRepo *repositories.TaskRepository
residenceRepo *repositories.ResidenceRepository
reminderRepo *repositories.ReminderRepository
pushClient *push.Client
emailService *services.EmailService
@@ -53,6 +54,7 @@ func NewHandler(db *gorm.DB, pushClient *push.Client, emailService *services.Ema
return &Handler{
db: db,
taskRepo: repositories.NewTaskRepository(db),
residenceRepo: repositories.NewResidenceRepository(db),
reminderRepo: repositories.NewReminderRepository(db),
pushClient: pushClient,
emailService: emailService,
@@ -275,8 +277,6 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error
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)
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")
// 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.
var userStats []struct {
UserID uint
TotalTasks int
OverdueTasks int
DueThisWeek int
}
// Step 2: Get task statistics for each user using canonical repository functions
// This ensures consistency with kanban display - uses the same scopes:
// - task.ScopeOverdue (excludes in-progress tasks)
// - task.ScopeDueSoon (7 days for "due this week")
var usersNotified 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 {
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get user residences")
continue
}
if err != nil {
log.Error().Err(err).Msg("Failed to query user task statistics")
return err
}
if len(residenceIDs) == 0 {
continue // User has no residences
}
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
if stats.OverdueTasks == 0 && stats.DueThisWeek == 0 {
if overdueCount == 0 && dueThisWeekCount == 0 {
continue
}
@@ -367,25 +362,27 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error
title := "Daily Task Summary"
var body string
if stats.OverdueTasks > 0 && stats.DueThisWeek > 0 {
body = fmt.Sprintf("You have %d overdue task(s) and %d task(s) due this week", stats.OverdueTasks, stats.DueThisWeek)
} else if stats.OverdueTasks > 0 {
body = fmt.Sprintf("You have %d overdue task(s) that need attention", stats.OverdueTasks)
if overdueCount > 0 && dueThisWeekCount > 0 {
body = fmt.Sprintf("You have %d overdue task(s) and %d task(s) due this week", overdueCount, dueThisWeekCount)
} else if overdueCount > 0 {
body = fmt.Sprintf("You have %d overdue task(s) that need attention", overdueCount)
} 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
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",
"overdue": fmt.Sprintf("%d", stats.OverdueTasks),
"due_this_week": fmt.Sprintf("%d", stats.DueThisWeek),
"overdue": fmt.Sprintf("%d", overdueCount),
"due_this_week": fmt.Sprintf("%d", dueThisWeekCount),
}); 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
}