Files
honeyDueAPI/internal/worker/jobs/handler.go
Trey t 7690f07a2b Harden API security: input validation, safe auth extraction, new tests, and deploy config
Comprehensive security hardening from audit findings:
- Add validation tags to all DTO request structs (max lengths, ranges, enums)
- Replace unsafe type assertions with MustGetAuthUser helper across all handlers
- Remove query-param token auth from admin middleware (prevents URL token leakage)
- Add request validation calls in handlers that were missing c.Validate()
- Remove goroutines in handlers (timezone update now synchronous)
- Add sanitize middleware and path traversal protection (path_utils)
- Stop resetting admin passwords on migration restart
- Warn on well-known default SECRET_KEY
- Add ~30 new test files covering security regressions, auth safety, repos, and services
- Add deploy/ config, audit digests, and AUDIT_FINDINGS documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:48:01 -06:00

849 lines
28 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")
// Build set for O(1) eligibility lookups instead of O(N) linear scan
eligibleSet := make(map[uint]bool, len(eligibleUserIDs))
for _, id := range eligibleUserIDs {
eligibleSet[id] = true
}
// 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 set (O(1) lookup)
if eligibleSet[userID] {
userTasks[userID] = append(userTasks[userID], t)
}
}
// 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")
// Build set for O(1) eligibility lookups instead of O(N) linear scan
eligibleSet := make(map[uint]bool, len(eligibleUserIDs))
for _, id := range eligibleUserIDs {
eligibleSet[id] = true
}
// 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 set (O(1) lookup)
if eligibleSet[userID] {
userTasks[userID] = append(userTasks[userID], t)
}
}
// 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 timezone from notification preferences for accurate overdue calculation
// This ensures the daily digest matches what the user sees in the kanban UI
var userNow time.Time
var prefs models.NotificationPreference
if err := h.db.Where("user_id = ?", userID).First(&prefs).Error; err == nil && prefs.Timezone != nil {
if loc, err := time.LoadLocation(*prefs.Timezone); err == nil {
// Use start of day in user's timezone (matches kanban behavior)
userNowInTz := time.Now().In(loc)
userNow = time.Date(userNowInTz.Year(), userNowInTz.Month(), userNowInTz.Day(), 0, 0, 0, 0, loc)
log.Debug().Uint("user_id", userID).Str("timezone", *prefs.Timezone).Time("user_now", userNow).Msg("Using user timezone for daily digest")
} else {
userNow = now // Fallback to UTC
}
} else {
userNow = now // Fallback to UTC if no timezone stored
}
// 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)
// Uses userNow (timezone-aware) for accurate overdue detection
opts := repositories.TaskFilterOptions{
ResidenceIDs: residenceIDs,
IncludeInProgress: false, // Match kanban: in-progress tasks not in overdue column
PreloadCompletions: true,
}
overdueTasks, err := h.taskRepo.GetOverdueTasks(userNow, 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(userNow, 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 {
if !h.config.Features.OnboardingEmailsEnabled {
log.Debug().Msg("Onboarding emails disabled by feature flag, skipping")
return nil
}
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: Pre-process tasks to determine stages and build batch reminder check
type candidateReminder struct {
taskIndex int
userID uint
effectiveDate time.Time
stage string
isOverdue bool
reminderStage models.ReminderStage
}
var candidates []candidateReminder
var reminderKeys []repositories.ReminderKey
for i, 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)
candidates = append(candidates, candidateReminder{
taskIndex: i,
userID: userID,
effectiveDate: effectiveDate,
stage: stage,
isOverdue: isOverdueStage,
reminderStage: reminderStage,
})
reminderKeys = append(reminderKeys, repositories.ReminderKey{
TaskID: t.ID,
UserID: userID,
DueDate: effectiveDate,
Stage: reminderStage,
})
}
// Batch check which reminders have already been sent (single query)
alreadySentMap, err := h.reminderRepo.HasSentReminderBatch(reminderKeys)
if err != nil {
log.Error().Err(err).Msg("Failed to batch check reminder logs")
return err
}
// Step 4: Send notifications for candidates that haven't been sent yet
var dueSoonSent, dueSoonSkipped, overdueSent, overdueSkipped int
for i, c := range candidates {
if alreadySentMap[i] {
if c.isOverdue {
overdueSkipped++
} else {
dueSoonSkipped++
}
continue
}
t := activeTasks[c.taskIndex]
// Determine notification type
var notificationType models.NotificationType
if c.isOverdue {
notificationType = models.NotificationTaskOverdue
} else {
notificationType = models.NotificationTaskDueSoon
}
// Send notification
if err := h.notificationService.CreateAndSendTaskNotification(ctx, c.userID, notificationType, &t); err != nil {
log.Error().Err(err).
Uint("user_id", c.userID).
Uint("task_id", t.ID).
Str("stage", c.stage).
Msg("Failed to send smart reminder")
continue
}
// Log the reminder
if _, err := h.reminderRepo.LogReminder(t.ID, c.userID, c.effectiveDate, c.reminderStage, nil); err != nil {
log.Error().Err(err).Uint("task_id", t.ID).Str("stage", c.stage).Msg("Failed to log reminder")
}
if c.isOverdue {
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
}