Files
honeyDueAPI/internal/worker/jobs/handler.go
Trey t 12ae11ca59 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>
2025-12-23 22:03:40 -06:00

787 lines
26 KiB
Go

package jobs
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/hibiken/asynq"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/notifications"
"github.com/treytartt/casera-api/internal/push"
"github.com/treytartt/casera-api/internal/repositories"
"github.com/treytartt/casera-api/internal/services"
)
// Task types
const (
TypeTaskReminder = "notification:task_reminder"
TypeOverdueReminder = "notification:overdue_reminder"
TypeSmartReminder = "notification:smart_reminder" // Frequency-aware reminders
TypeDailyDigest = "notification:daily_digest"
TypeSendEmail = "email:send"
TypeSendPush = "push:send"
TypeOnboardingEmails = "email:onboarding"
TypeReminderLogCleanup = "maintenance:reminder_log_cleanup"
)
// Handler handles background job processing
type Handler struct {
db *gorm.DB
taskRepo *repositories.TaskRepository
residenceRepo *repositories.ResidenceRepository
reminderRepo *repositories.ReminderRepository
pushClient *push.Client
emailService *services.EmailService
notificationService *services.NotificationService
onboardingService *services.OnboardingEmailService
config *config.Config
}
// NewHandler creates a new job handler
func NewHandler(db *gorm.DB, pushClient *push.Client, emailService *services.EmailService, notificationService *services.NotificationService, cfg *config.Config) *Handler {
// Create onboarding email service
var onboardingService *services.OnboardingEmailService
if emailService != nil {
onboardingService = services.NewOnboardingEmailService(db, emailService, cfg.Server.BaseURL)
}
return &Handler{
db: db,
taskRepo: repositories.NewTaskRepository(db),
residenceRepo: repositories.NewResidenceRepository(db),
reminderRepo: repositories.NewReminderRepository(db),
pushClient: pushClient,
emailService: emailService,
notificationService: notificationService,
onboardingService: onboardingService,
config: cfg,
}
}
// TaskReminderData represents a task due soon for reminder notifications
type TaskReminderData struct {
TaskID uint
TaskTitle string
DueDate time.Time
UserID uint
UserEmail string
UserName string
ResidenceName string
}
// HandleTaskReminder processes task reminder notifications for tasks due today or tomorrow with actionable buttons
func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) error {
log.Info().Msg("Processing task reminder notifications...")
now := time.Now().UTC()
currentHour := now.Hour()
systemDefaultHour := h.config.Worker.TaskReminderHour
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
// 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
query := h.db.Model(&models.NotificationPreference{}).
Select("user_id").
Where("task_due_soon = 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("task_due_soon_hour IS NULL OR task_due_soon_hour = ?", currentHour)
} else {
// 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)
}
err := query.Pluck("user_id", &eligibleUserIDs).Error
if err != nil {
log.Error().Err(err).Msg("Failed to query eligible users for task reminders")
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 task reminder notifications this hour")
return nil
}
log.Info().Int("eligible_users", len(eligibleUserIDs)).Msg("Found users eligible for task reminders this hour")
// Step 2: Query tasks due today or tomorrow using the single-purpose repository function
// Uses the same scopes as kanban for consistency, with IncludeInProgress=true
// so users still get notified about in-progress tasks that are due soon.
opts := repositories.TaskFilterOptions{
UserIDs: eligibleUserIDs,
IncludeInProgress: true, // Notifications should include in-progress tasks
PreloadResidence: true,
PreloadCompletions: true,
}
// Due soon = due within 2 days (today and tomorrow)
dueSoonTasks, err := h.taskRepo.GetDueSoonTasks(now, 2, opts)
if err != nil {
log.Error().Err(err).Msg("Failed to query tasks due soon")
return err
}
log.Info().Int("count", len(dueSoonTasks)).Msg("Found tasks due today/tomorrow for eligible users")
// Group tasks by user (assigned_to or residence owner)
userTasks := make(map[uint][]models.Task)
for _, t := range dueSoonTasks {
var userID uint
if t.AssignedToID != nil {
userID = *t.AssignedToID
} else if t.Residence.ID != 0 {
userID = t.Residence.OwnerID
} else {
continue
}
// Only include if user is in eligible list
for _, eligibleID := range eligibleUserIDs {
if userID == eligibleID {
userTasks[userID] = append(userTasks[userID], t)
break
}
}
}
// 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 {
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")
}
}
}
log.Info().Int("users_notified", len(userTasks)).Msg("Task reminder notifications completed")
return nil
}
// HandleOverdueReminder processes overdue task notifications with actionable buttons
func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) error {
log.Info().Msg("Processing overdue task notifications...")
now := time.Now().UTC()
currentHour := now.Hour()
systemDefaultHour := h.config.Worker.OverdueReminderHour
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
// 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
query := h.db.Model(&models.NotificationPreference{}).
Select("user_id").
Where("task_overdue = 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("task_overdue_hour IS NULL OR task_overdue_hour = ?", currentHour)
} else {
// 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)
}
err := query.Pluck("user_id", &eligibleUserIDs).Error
if err != nil {
log.Error().Err(err).Msg("Failed to query eligible users for overdue reminders")
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 overdue notifications this hour")
return nil
}
log.Info().Int("eligible_users", len(eligibleUserIDs)).Msg("Found users eligible for overdue reminders this hour")
// Step 2: Query overdue tasks using the single-purpose repository function
// Uses the same scopes as kanban for consistency, with IncludeInProgress=true
// so users still get notified about in-progress tasks that are overdue.
opts := repositories.TaskFilterOptions{
UserIDs: eligibleUserIDs,
IncludeInProgress: true, // Notifications should include in-progress tasks
PreloadResidence: true,
PreloadCompletions: true,
}
overdueTasks, err := h.taskRepo.GetOverdueTasks(now, opts)
if err != nil {
log.Error().Err(err).Msg("Failed to query overdue tasks")
return err
}
log.Info().Int("count", len(overdueTasks)).Msg("Found overdue tasks for eligible users")
// Group tasks by user (assigned_to or residence owner)
userTasks := make(map[uint][]models.Task)
for _, t := range overdueTasks {
var userID uint
if t.AssignedToID != nil {
userID = *t.AssignedToID
} else if t.Residence.ID != 0 {
userID = t.Residence.OwnerID
} else {
continue
}
// Only include if user is in eligible list
for _, eligibleID := range eligibleUserIDs {
if userID == eligibleID {
userTasks[userID] = append(userTasks[userID], t)
break
}
}
}
// 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 {
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")
}
}
}
log.Info().Int("users_notified", len(userTasks)).Msg("Overdue task notifications completed")
return nil
}
// HandleDailyDigest processes daily digest notifications with task statistics
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
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 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
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 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)
// Skip users with no actionable items
if overdueCount == 0 && dueThisWeekCount == 0 {
continue
}
// Build notification message
title := "Daily Task Summary"
var body string
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", dueThisWeekCount)
}
// Send push notification
if err := h.sendPushToUser(ctx, userID, title, body, map[string]string{
"type": "daily_digest",
"overdue": fmt.Sprintf("%d", overdueCount),
"due_this_week": fmt.Sprintf("%d", dueThisWeekCount),
}); err != nil {
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to send daily digest push")
} else {
usersNotified++
}
}
log.Info().Int("users_notified", usersNotified).Msg("Daily digest notifications completed")
return nil
}
// EmailPayload represents the payload for email tasks
type EmailPayload struct {
To string `json:"to"`
Subject string `json:"subject"`
HTMLBody string `json:"html_body"`
TextBody string `json:"text_body"`
}
// HandleSendEmail processes email sending tasks
func (h *Handler) HandleSendEmail(ctx context.Context, task *asynq.Task) error {
var payload EmailPayload
if err := json.Unmarshal(task.Payload(), &payload); err != nil {
return fmt.Errorf("failed to unmarshal payload: %w", err)
}
log.Info().
Str("to", payload.To).
Str("subject", payload.Subject).
Msg("Sending email...")
if h.emailService == nil {
log.Warn().Msg("Email service not configured, skipping email")
return nil
}
// Use the email service to send the email
if err := h.emailService.SendEmail(payload.To, payload.Subject, payload.HTMLBody, payload.TextBody); err != nil {
log.Error().Err(err).Str("to", payload.To).Msg("Failed to send email")
return err
}
log.Info().Str("to", payload.To).Msg("Email sent successfully")
return nil
}
// PushPayload represents the payload for push notification tasks
type PushPayload struct {
UserID uint `json:"user_id"`
Title string `json:"title"`
Message string `json:"message"`
Data map[string]string `json:"data,omitempty"`
}
// HandleSendPush processes push notification tasks
func (h *Handler) HandleSendPush(ctx context.Context, task *asynq.Task) error {
var payload PushPayload
if err := json.Unmarshal(task.Payload(), &payload); err != nil {
return fmt.Errorf("failed to unmarshal payload: %w", err)
}
log.Info().
Uint("user_id", payload.UserID).
Str("title", payload.Title).
Msg("Sending push notification...")
if err := h.sendPushToUser(ctx, payload.UserID, payload.Title, payload.Message, payload.Data); err != nil {
log.Error().Err(err).Uint("user_id", payload.UserID).Msg("Failed to send push notification")
return err
}
log.Info().Uint("user_id", payload.UserID).Msg("Push notification sent successfully")
return nil
}
// sendPushToUser sends a push notification to all of a user's devices
func (h *Handler) sendPushToUser(ctx context.Context, userID uint, title, message string, data map[string]string) error {
if h.pushClient == nil {
log.Warn().Msg("Push client not configured, skipping notification")
return nil
}
// Get iOS device tokens
var iosTokens []string
err := h.db.Model(&models.APNSDevice{}).
Where("user_id = ? AND active = ?", userID, true).
Pluck("registration_id", &iosTokens).Error
if err != nil {
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get iOS tokens")
}
// Get Android device tokens
var androidTokens []string
err = h.db.Model(&models.GCMDevice{}).
Where("user_id = ? AND active = ?", userID, true).
Pluck("registration_id", &androidTokens).Error
if err != nil {
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get Android tokens")
}
if len(iosTokens) == 0 && len(androidTokens) == 0 {
log.Debug().Uint("user_id", userID).Msg("No device tokens found for user")
return nil
}
log.Debug().
Uint("user_id", userID).
Int("ios_tokens", len(iosTokens)).
Int("android_tokens", len(androidTokens)).
Msg("Sending push to user devices")
// Send to all devices
return h.pushClient.SendToAll(ctx, iosTokens, androidTokens, title, message, data)
}
// NewSendEmailTask creates a new email sending task
func NewSendEmailTask(to, subject, htmlBody, textBody string) (*asynq.Task, error) {
payload, err := json.Marshal(EmailPayload{
To: to,
Subject: subject,
HTMLBody: htmlBody,
TextBody: textBody,
})
if err != nil {
return nil, err
}
return asynq.NewTask(TypeSendEmail, payload), nil
}
// NewSendPushTask creates a new push notification task
func NewSendPushTask(userID uint, title, message string, data map[string]string) (*asynq.Task, error) {
payload, err := json.Marshal(PushPayload{
UserID: userID,
Title: title,
Message: message,
Data: data,
})
if err != nil {
return nil, err
}
return asynq.NewTask(TypeSendPush, payload), nil
}
// HandleOnboardingEmails processes onboarding email campaigns
// Sends emails to:
// 1. Users who registered 2+ days ago but haven't created a residence
// 2. Users who created a residence 5+ days ago but haven't created any tasks
// Each email type is only sent once per user, ever.
func (h *Handler) HandleOnboardingEmails(ctx context.Context, task *asynq.Task) error {
log.Info().Msg("Processing onboarding emails...")
if h.onboardingService == nil {
log.Warn().Msg("Onboarding email service not configured, skipping")
return nil
}
// Send no-residence emails (users without any residences after 2 days)
noResCount, err := h.onboardingService.CheckAndSendNoResidenceEmails()
if err != nil {
log.Error().Err(err).Msg("Failed to process no-residence onboarding emails")
// Continue to next type, don't return error
} else {
log.Info().Int("count", noResCount).Msg("Sent no-residence onboarding emails")
}
// Send no-tasks emails (users with residence but no tasks after 5 days)
noTasksCount, err := h.onboardingService.CheckAndSendNoTasksEmails()
if err != nil {
log.Error().Err(err).Msg("Failed to process no-tasks onboarding emails")
} else {
log.Info().Int("count", noTasksCount).Msg("Sent no-tasks onboarding emails")
}
log.Info().
Int("no_residence_sent", noResCount).
Int("no_tasks_sent", noTasksCount).
Msg("Onboarding email processing completed")
return nil
}
// userReminderPrefs holds a user's notification preferences for smart reminders
type userReminderPrefs struct {
UserID uint `gorm:"column:user_id"`
WantsDueSoon bool `gorm:"column:wants_due_soon"`
WantsOverdue bool `gorm:"column:wants_overdue"`
}
// HandleSmartReminder processes frequency-aware task reminders.
// Features:
// 1. Single query to get users who want either notification type at current hour
// 2. Single query to get both due-soon AND overdue tasks for those users
// 3. Uses frequency-based schedules (weekly = day-of only, annual = 30d, 14d, 7d, day-of)
// 4. Tracks sent reminders to prevent duplicates
// 5. Tapers off overdue reminders (daily for 3 days, then every 3 days, stop after 14)
func (h *Handler) HandleSmartReminder(ctx context.Context, task *asynq.Task) error {
log.Info().Msg("Processing smart task reminders...")
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
currentHour := now.Hour()
dueSoonDefault := h.config.Worker.TaskReminderHour
overdueDefault := h.config.Worker.OverdueReminderHour
log.Info().
Int("current_hour", currentHour).
Int("due_soon_default", dueSoonDefault).
Int("overdue_default", overdueDefault).
Msg("Smart reminder check")
// Step 1: Single query to get all users who want ANY notification type at this hour
// Each user gets flags for which types they want
var userPrefs []userReminderPrefs
// Build hour matching conditions (reused in SELECT and WHERE)
// User matches if: they set this hour explicitly, OR they have no preference and current hour is the default
dueSoonHourMatch := fmt.Sprintf(
"(task_due_soon_hour IS NULL AND %d = %d) OR task_due_soon_hour = %d",
currentHour, dueSoonDefault, currentHour,
)
overdueHourMatch := fmt.Sprintf(
"(task_overdue_hour IS NULL AND %d = %d) OR task_overdue_hour = %d",
currentHour, overdueDefault, currentHour,
)
query := fmt.Sprintf(`
SELECT
user_id,
(task_due_soon = true AND (%s)) as wants_due_soon,
(task_overdue = true AND (%s)) as wants_overdue
FROM notifications_notificationpreference
WHERE (task_due_soon = true AND (%s))
OR (task_overdue = true AND (%s))
`, dueSoonHourMatch, overdueHourMatch, dueSoonHourMatch, overdueHourMatch)
err := h.db.Raw(query).Scan(&userPrefs).Error
if err != nil {
log.Error().Err(err).Msg("Failed to query user notification preferences")
return err
}
if len(userPrefs) == 0 {
log.Debug().Int("hour", currentHour).Msg("No users scheduled for any reminder type this hour")
return nil
}
// Build lookup maps for quick access
userWantsDueSoon := make(map[uint]bool)
userWantsOverdue := make(map[uint]bool)
var allUserIDs []uint
for _, pref := range userPrefs {
allUserIDs = append(allUserIDs, pref.UserID)
if pref.WantsDueSoon {
userWantsDueSoon[pref.UserID] = true
}
if pref.WantsOverdue {
userWantsOverdue[pref.UserID] = true
}
}
log.Info().
Int("total_users", len(allUserIDs)).
Int("want_due_soon", len(userWantsDueSoon)).
Int("want_overdue", len(userWantsOverdue)).
Msg("Found users eligible for reminders")
// Step 2: Single query to get ALL active tasks (both due-soon and overdue) for these users
opts := repositories.TaskFilterOptions{
UserIDs: allUserIDs,
IncludeInProgress: true,
PreloadResidence: true,
PreloadCompletions: true,
PreloadFrequency: true,
}
activeTasks, err := h.taskRepo.GetActiveTasksForUsers(now, opts)
if err != nil {
log.Error().Err(err).Msg("Failed to query active tasks")
return err
}
log.Info().Int("count", len(activeTasks)).Msg("Found active tasks for eligible users")
// Step 3: Process each task once, sending appropriate notification based on user prefs
var dueSoonSent, dueSoonSkipped, overdueSent, overdueSkipped int
for _, t := range activeTasks {
// Determine which user to notify
var userID uint
if t.AssignedToID != nil {
userID = *t.AssignedToID
} else if t.Residence.ID != 0 {
userID = t.Residence.OwnerID
} else {
continue
}
// Get the effective due date
var effectiveDate time.Time
if t.NextDueDate != nil {
effectiveDate = *t.NextDueDate
} else if t.DueDate != nil {
effectiveDate = *t.DueDate
} else {
continue
}
// Get frequency interval days
var frequencyDays *int
if t.Frequency != nil && t.Frequency.Days != nil {
days := int(*t.Frequency.Days)
frequencyDays = &days
} else if t.CustomIntervalDays != nil {
days := int(*t.CustomIntervalDays)
frequencyDays = &days
}
// Determine which reminder stage applies today
stage := notifications.GetReminderStageForToday(effectiveDate, frequencyDays, today)
if stage == "" {
continue
}
// Determine if this is an overdue or due-soon stage
isOverdueStage := len(stage) >= 7 && stage[:7] == "overdue"
// Check if user wants this notification type
if isOverdueStage && !userWantsOverdue[userID] {
continue
}
if !isOverdueStage && !userWantsDueSoon[userID] {
continue
}
reminderStage := models.ReminderStage(stage)
// Check if already sent
alreadySent, err := h.reminderRepo.HasSentReminder(t.ID, userID, effectiveDate, reminderStage)
if err != nil {
log.Error().Err(err).Uint("task_id", t.ID).Msg("Failed to check reminder log")
continue
}
if alreadySent {
if isOverdueStage {
overdueSkipped++
} else {
dueSoonSkipped++
}
continue
}
// Determine notification type
var notificationType models.NotificationType
if isOverdueStage {
notificationType = models.NotificationTaskOverdue
} else {
notificationType = models.NotificationTaskDueSoon
}
// Send notification
if err := h.notificationService.CreateAndSendTaskNotification(ctx, userID, notificationType, &t); err != nil {
log.Error().Err(err).
Uint("user_id", userID).
Uint("task_id", t.ID).
Str("stage", stage).
Msg("Failed to send smart reminder")
continue
}
// Log the reminder
if _, err := h.reminderRepo.LogReminder(t.ID, userID, effectiveDate, reminderStage, nil); err != nil {
log.Error().Err(err).Uint("task_id", t.ID).Str("stage", stage).Msg("Failed to log reminder")
}
if isOverdueStage {
overdueSent++
} else {
dueSoonSent++
}
}
log.Info().
Int("due_soon_sent", dueSoonSent).
Int("due_soon_skipped", dueSoonSkipped).
Int("overdue_sent", overdueSent).
Int("overdue_skipped", overdueSkipped).
Msg("Smart reminder notifications completed")
return nil
}
// HandleReminderLogCleanup cleans up old reminder logs to prevent table bloat
func (h *Handler) HandleReminderLogCleanup(ctx context.Context, task *asynq.Task) error {
log.Info().Msg("Processing reminder log cleanup...")
// Clean up logs older than 90 days
deleted, err := h.reminderRepo.CleanupOldLogs(90)
if err != nil {
log.Error().Err(err).Msg("Failed to cleanup old reminder logs")
return err
}
log.Info().Int64("deleted", deleted).Msg("Reminder log cleanup completed")
return nil
}