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 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user