Add smart notification reminder system with frequency-aware scheduling
Replaces one-size-fits-all "2 days before" reminders with intelligent
scheduling based on task frequency. Infrequent tasks (annual) get 30-day
advance notice while frequent tasks (weekly) only get day-of reminders.
Key features:
- Frequency-aware pre-reminders: annual (30d, 14d, 7d), quarterly (7d, 3d),
monthly (3d), bi-weekly (1d), daily/weekly/once (day-of only)
- Overdue tapering: daily for 3 days, then every 3 days, stops after 14 days
- Reminder log table prevents duplicate notifications per due date/stage
- Admin endpoint displays notification schedules for all frequencies
- Comprehensive test suite (100 random tasks, 61 days each, 10 test functions)
New files:
- internal/notifications/reminder_config.go - Editable schedule configuration
- internal/notifications/reminder_schedule.go - Schedule lookup logic
- internal/notifications/reminder_schedule_test.go - Dynamic test suite
- internal/models/reminder_log.go - TaskReminderLog model
- internal/repositories/reminder_repo.go - Reminder log repository
- migrations/010_add_task_reminder_log.{up,down}.sql
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"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"
|
||||
@@ -19,18 +20,21 @@ import (
|
||||
|
||||
// Task types
|
||||
const (
|
||||
TypeTaskReminder = "notification:task_reminder"
|
||||
TypeOverdueReminder = "notification:overdue_reminder"
|
||||
TypeDailyDigest = "notification:daily_digest"
|
||||
TypeSendEmail = "email:send"
|
||||
TypeSendPush = "push:send"
|
||||
TypeOnboardingEmails = "email:onboarding"
|
||||
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
|
||||
reminderRepo *repositories.ReminderRepository
|
||||
pushClient *push.Client
|
||||
emailService *services.EmailService
|
||||
notificationService *services.NotificationService
|
||||
@@ -49,6 +53,7 @@ func NewHandler(db *gorm.DB, pushClient *push.Client, emailService *services.Ema
|
||||
return &Handler{
|
||||
db: db,
|
||||
taskRepo: repositories.NewTaskRepository(db),
|
||||
reminderRepo: repositories.NewReminderRepository(db),
|
||||
pushClient: pushClient,
|
||||
emailService: emailService,
|
||||
notificationService: notificationService,
|
||||
@@ -553,3 +558,179 @@ func (h *Handler) HandleOnboardingEmails(ctx context.Context, task *asynq.Task)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleSmartReminder processes frequency-aware task reminders.
|
||||
// Unlike the old HandleTaskReminder and HandleOverdueReminder, this handler:
|
||||
// 1. Uses frequency-based schedules (weekly = day-of only, annual = 30d, 14d, 7d, day-of)
|
||||
// 2. Tracks sent reminders to prevent duplicates
|
||||
// 3. 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()
|
||||
|
||||
// Use the task_due_soon hour setting for smart reminders
|
||||
systemDefaultHour := h.config.Worker.TaskReminderHour
|
||||
|
||||
log.Info().
|
||||
Int("current_hour", currentHour).
|
||||
Int("system_default_hour", systemDefaultHour).
|
||||
Msg("Smart reminder check")
|
||||
|
||||
// Step 1: Find users who should receive notifications THIS hour
|
||||
var eligibleUserIDs []uint
|
||||
|
||||
query := h.db.Model(&models.NotificationPreference{}).
|
||||
Select("user_id").
|
||||
Where("task_due_soon = true OR task_overdue = true")
|
||||
|
||||
if currentHour == systemDefaultHour {
|
||||
query = query.Where("task_due_soon_hour IS NULL OR task_due_soon_hour = ?", currentHour)
|
||||
} else {
|
||||
query = query.Where("task_due_soon_hour = ?", currentHour)
|
||||
}
|
||||
|
||||
if err := query.Pluck("user_id", &eligibleUserIDs).Error; err != nil {
|
||||
log.Error().Err(err).Msg("Failed to query eligible users for smart reminders")
|
||||
return err
|
||||
}
|
||||
|
||||
if len(eligibleUserIDs) == 0 {
|
||||
log.Debug().Int("hour", currentHour).Msg("No users scheduled for smart reminders this hour")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info().Int("eligible_users", len(eligibleUserIDs)).Msg("Found users eligible for smart reminders")
|
||||
|
||||
// Step 2: Query all active tasks for eligible users
|
||||
opts := repositories.TaskFilterOptions{
|
||||
UserIDs: eligibleUserIDs,
|
||||
IncludeInProgress: true,
|
||||
PreloadResidence: true,
|
||||
PreloadCompletions: true,
|
||||
PreloadFrequency: true,
|
||||
}
|
||||
|
||||
// Get all active, non-completed tasks
|
||||
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
|
||||
var sentCount, skippedCount 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
|
||||
}
|
||||
|
||||
// Check if user is in eligible list
|
||||
eligible := false
|
||||
for _, eligibleID := range eligibleUserIDs {
|
||||
if userID == eligibleID {
|
||||
eligible = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !eligible {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the effective due date (NextDueDate takes precedence for recurring tasks)
|
||||
var effectiveDate time.Time
|
||||
if t.NextDueDate != nil {
|
||||
effectiveDate = *t.NextDueDate
|
||||
} else if t.DueDate != nil {
|
||||
effectiveDate = *t.DueDate
|
||||
} else {
|
||||
// No due date, skip
|
||||
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 // No reminder needed today
|
||||
}
|
||||
|
||||
// Convert stage string to ReminderStage type
|
||||
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 {
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine notification type based on stage
|
||||
var notificationType models.NotificationType
|
||||
if stage == "day_of" || (len(stage) >= 8 && stage[:8] == "reminder") {
|
||||
notificationType = models.NotificationTaskDueSoon
|
||||
} else {
|
||||
notificationType = models.NotificationTaskOverdue
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
sentCount++
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Int("sent", sentCount).
|
||||
Int("skipped_duplicates", skippedCount).
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user