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:
Trey t
2025-12-19 23:03:28 -06:00
parent 7a57a902bb
commit 69206c6930
13 changed files with 1733 additions and 28 deletions

View File

@@ -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
}