Add per-user notification time preferences

Allow users to customize when they receive notification reminders:
- Add task_due_soon_hour, task_overdue_hour, warranty_expiring_hour fields
- Store times in UTC, clients convert to/from local timezone
- Worker runs hourly, queries only users scheduled for that hour
- Early exit optimization when no users need notifications
- Admin UI displays custom notification times

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-07 00:23:57 -06:00
parent af87bd943e
commit dd16019ce2
7 changed files with 267 additions and 113 deletions

View File

@@ -37,6 +37,11 @@ type NotificationPrefResponse struct {
// Email preferences
EmailTaskCompleted bool `json:"email_task_completed"`
// Custom notification times (UTC hour 0-23, null means use system default)
TaskDueSoonHour *int `json:"task_due_soon_hour"`
TaskOverdueHour *int `json:"task_overdue_hour"`
WarrantyExpiringHour *int `json:"warranty_expiring_hour"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
@@ -102,19 +107,22 @@ func (h *AdminNotificationPrefsHandler) List(c *gin.Context) {
for i, pref := range prefs {
user := userMap[pref.UserID]
responses[i] = NotificationPrefResponse{
ID: pref.ID,
UserID: pref.UserID,
Username: user.Username,
Email: user.Email,
TaskDueSoon: pref.TaskDueSoon,
TaskOverdue: pref.TaskOverdue,
TaskCompleted: pref.TaskCompleted,
TaskAssigned: pref.TaskAssigned,
ResidenceShared: pref.ResidenceShared,
WarrantyExpiring: pref.WarrantyExpiring,
EmailTaskCompleted: pref.EmailTaskCompleted,
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
ID: pref.ID,
UserID: pref.UserID,
Username: user.Username,
Email: user.Email,
TaskDueSoon: pref.TaskDueSoon,
TaskOverdue: pref.TaskOverdue,
TaskCompleted: pref.TaskCompleted,
TaskAssigned: pref.TaskAssigned,
ResidenceShared: pref.ResidenceShared,
WarrantyExpiring: pref.WarrantyExpiring,
EmailTaskCompleted: pref.EmailTaskCompleted,
TaskDueSoonHour: pref.TaskDueSoonHour,
TaskOverdueHour: pref.TaskOverdueHour,
WarrantyExpiringHour: pref.WarrantyExpiringHour,
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
@@ -143,19 +151,22 @@ func (h *AdminNotificationPrefsHandler) Get(c *gin.Context) {
h.db.First(&user, pref.UserID)
c.JSON(http.StatusOK, NotificationPrefResponse{
ID: pref.ID,
UserID: pref.UserID,
Username: user.Username,
Email: user.Email,
TaskDueSoon: pref.TaskDueSoon,
TaskOverdue: pref.TaskOverdue,
TaskCompleted: pref.TaskCompleted,
TaskAssigned: pref.TaskAssigned,
ResidenceShared: pref.ResidenceShared,
WarrantyExpiring: pref.WarrantyExpiring,
EmailTaskCompleted: pref.EmailTaskCompleted,
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
ID: pref.ID,
UserID: pref.UserID,
Username: user.Username,
Email: user.Email,
TaskDueSoon: pref.TaskDueSoon,
TaskOverdue: pref.TaskOverdue,
TaskCompleted: pref.TaskCompleted,
TaskAssigned: pref.TaskAssigned,
ResidenceShared: pref.ResidenceShared,
WarrantyExpiring: pref.WarrantyExpiring,
EmailTaskCompleted: pref.EmailTaskCompleted,
TaskDueSoonHour: pref.TaskDueSoonHour,
TaskOverdueHour: pref.TaskOverdueHour,
WarrantyExpiringHour: pref.WarrantyExpiringHour,
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
})
}
@@ -170,6 +181,11 @@ type UpdateNotificationPrefRequest struct {
// Email preferences
EmailTaskCompleted *bool `json:"email_task_completed"`
// Custom notification times (UTC hour 0-23)
TaskDueSoonHour *int `json:"task_due_soon_hour"`
TaskOverdueHour *int `json:"task_overdue_hour"`
WarrantyExpiringHour *int `json:"warranty_expiring_hour"`
}
// Update handles PUT /api/admin/notification-prefs/:id
@@ -219,6 +235,17 @@ func (h *AdminNotificationPrefsHandler) Update(c *gin.Context) {
pref.EmailTaskCompleted = *req.EmailTaskCompleted
}
// Apply notification time updates
if req.TaskDueSoonHour != nil {
pref.TaskDueSoonHour = req.TaskDueSoonHour
}
if req.TaskOverdueHour != nil {
pref.TaskOverdueHour = req.TaskOverdueHour
}
if req.WarrantyExpiringHour != nil {
pref.WarrantyExpiringHour = req.WarrantyExpiringHour
}
if err := h.db.Save(&pref).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update notification preference"})
return
@@ -228,19 +255,22 @@ func (h *AdminNotificationPrefsHandler) Update(c *gin.Context) {
h.db.First(&user, pref.UserID)
c.JSON(http.StatusOK, NotificationPrefResponse{
ID: pref.ID,
UserID: pref.UserID,
Username: user.Username,
Email: user.Email,
TaskDueSoon: pref.TaskDueSoon,
TaskOverdue: pref.TaskOverdue,
TaskCompleted: pref.TaskCompleted,
TaskAssigned: pref.TaskAssigned,
ResidenceShared: pref.ResidenceShared,
WarrantyExpiring: pref.WarrantyExpiring,
EmailTaskCompleted: pref.EmailTaskCompleted,
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
ID: pref.ID,
UserID: pref.UserID,
Username: user.Username,
Email: user.Email,
TaskDueSoon: pref.TaskDueSoon,
TaskOverdue: pref.TaskOverdue,
TaskCompleted: pref.TaskCompleted,
TaskAssigned: pref.TaskAssigned,
ResidenceShared: pref.ResidenceShared,
WarrantyExpiring: pref.WarrantyExpiring,
EmailTaskCompleted: pref.EmailTaskCompleted,
TaskDueSoonHour: pref.TaskDueSoonHour,
TaskOverdueHour: pref.TaskOverdueHour,
WarrantyExpiringHour: pref.WarrantyExpiringHour,
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
})
}
@@ -287,18 +317,21 @@ func (h *AdminNotificationPrefsHandler) GetByUser(c *gin.Context) {
h.db.First(&user, pref.UserID)
c.JSON(http.StatusOK, NotificationPrefResponse{
ID: pref.ID,
UserID: pref.UserID,
Username: user.Username,
Email: user.Email,
TaskDueSoon: pref.TaskDueSoon,
TaskOverdue: pref.TaskOverdue,
TaskCompleted: pref.TaskCompleted,
TaskAssigned: pref.TaskAssigned,
ResidenceShared: pref.ResidenceShared,
WarrantyExpiring: pref.WarrantyExpiring,
EmailTaskCompleted: pref.EmailTaskCompleted,
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
ID: pref.ID,
UserID: pref.UserID,
Username: user.Username,
Email: user.Email,
TaskDueSoon: pref.TaskDueSoon,
TaskOverdue: pref.TaskOverdue,
TaskCompleted: pref.TaskCompleted,
TaskAssigned: pref.TaskAssigned,
ResidenceShared: pref.ResidenceShared,
WarrantyExpiring: pref.WarrantyExpiring,
EmailTaskCompleted: pref.EmailTaskCompleted,
TaskDueSoonHour: pref.TaskDueSoonHour,
TaskOverdueHour: pref.TaskOverdueHour,
WarrantyExpiringHour: pref.WarrantyExpiringHour,
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
})
}

View File

@@ -23,6 +23,12 @@ type NotificationPreference struct {
// Email preferences
EmailTaskCompleted bool `gorm:"column:email_task_completed;default:true" json:"email_task_completed"`
// Custom notification times (nullable, stored as UTC hour 0-23)
// When nil, system defaults from config are used
TaskDueSoonHour *int `gorm:"column:task_due_soon_hour" json:"task_due_soon_hour"`
TaskOverdueHour *int `gorm:"column:task_overdue_hour" json:"task_overdue_hour"`
WarrantyExpiringHour *int `gorm:"column:warranty_expiring_hour" json:"warranty_expiring_hour"`
}
// TableName returns the table name for GORM

View File

@@ -195,6 +195,18 @@ func (s *NotificationService) UpdatePreferences(userID uint, req *UpdatePreferen
prefs.EmailTaskCompleted = *req.EmailTaskCompleted
}
// Update notification times (can be set to nil to use system default)
// Note: We update if the field is present in the request (including null values)
if req.TaskDueSoonHour != nil {
prefs.TaskDueSoonHour = req.TaskDueSoonHour
}
if req.TaskOverdueHour != nil {
prefs.TaskOverdueHour = req.TaskOverdueHour
}
if req.WarrantyExpiringHour != nil {
prefs.WarrantyExpiringHour = req.WarrantyExpiringHour
}
if err := s.notificationRepo.UpdatePreferences(prefs); err != nil {
return nil, err
}
@@ -365,18 +377,26 @@ type NotificationPreferencesResponse struct {
// Email preferences
EmailTaskCompleted bool `json:"email_task_completed"`
// Custom notification times (UTC hour 0-23, null means use system default)
TaskDueSoonHour *int `json:"task_due_soon_hour"`
TaskOverdueHour *int `json:"task_overdue_hour"`
WarrantyExpiringHour *int `json:"warranty_expiring_hour"`
}
// NewNotificationPreferencesResponse creates a NotificationPreferencesResponse
func NewNotificationPreferencesResponse(p *models.NotificationPreference) *NotificationPreferencesResponse {
return &NotificationPreferencesResponse{
TaskDueSoon: p.TaskDueSoon,
TaskOverdue: p.TaskOverdue,
TaskCompleted: p.TaskCompleted,
TaskAssigned: p.TaskAssigned,
ResidenceShared: p.ResidenceShared,
WarrantyExpiring: p.WarrantyExpiring,
EmailTaskCompleted: p.EmailTaskCompleted,
TaskDueSoon: p.TaskDueSoon,
TaskOverdue: p.TaskOverdue,
TaskCompleted: p.TaskCompleted,
TaskAssigned: p.TaskAssigned,
ResidenceShared: p.ResidenceShared,
WarrantyExpiring: p.WarrantyExpiring,
EmailTaskCompleted: p.EmailTaskCompleted,
TaskDueSoonHour: p.TaskDueSoonHour,
TaskOverdueHour: p.TaskOverdueHour,
WarrantyExpiringHour: p.WarrantyExpiringHour,
}
}
@@ -391,6 +411,12 @@ type UpdatePreferencesRequest struct {
// Email preferences
EmailTaskCompleted *bool `json:"email_task_completed"`
// Custom notification times (UTC hour 0-23)
// Use a special wrapper to differentiate between "not sent" and "set to null"
TaskDueSoonHour *int `json:"task_due_soon_hour"`
TaskOverdueHour *int `json:"task_overdue_hour"`
WarrantyExpiringHour *int `json:"warranty_expiring_hour"`
}
// DeviceResponse represents a device in API response

View File

@@ -61,17 +61,46 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
log.Info().Msg("Processing task reminder notifications...")
now := time.Now().UTC()
currentHour := now.Hour()
systemDefaultHour := h.config.Worker.TaskReminderHour
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
dayAfterTomorrow := today.AddDate(0, 0, 2)
// Query tasks due today or tomorrow with full task data for button types
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
// Users with custom hour matching current hour, OR users with no custom hour when current hour is system default
var eligibleUserIDs []uint
err := h.db.Model(&models.NotificationPreference{}).
Select("user_id").
Where("task_due_soon = true").
Where("(task_due_soon_hour = ? OR (task_due_soon_hour IS NULL AND ? = ?))",
currentHour, currentHour, systemDefaultHour).
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 only for eligible users
var dueSoonTasks []models.Task
err := h.db.Preload("Status").Preload("Completions").Preload("Residence").
err = h.db.Preload("Status").Preload("Completions").Preload("Residence").
Where("(due_date >= ? AND due_date < ?) OR (next_due_date >= ? AND next_due_date < ?)",
today, dayAfterTomorrow, today, dayAfterTomorrow).
Where("is_cancelled = false").
Where("is_archived = false").
Where("NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id AND tc.completed_at >= task_task.due_date)").
Where("(assigned_to_id IN ? OR residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ?))",
eligibleUserIDs, eligibleUserIDs).
Find(&dueSoonTasks).Error
if err != nil {
@@ -79,7 +108,7 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
return err
}
log.Info().Int("count", len(dueSoonTasks)).Msg("Found tasks due today/tomorrow")
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)
@@ -92,25 +121,17 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
} else {
continue
}
userTasks[userID] = append(userTasks[userID], t)
// Only include if user is in eligible list
for _, eligibleID := range eligibleUserIDs {
if userID == eligibleID {
userTasks[userID] = append(userTasks[userID], t)
break
}
}
}
// Send actionable notifications to each user
// Step 3: Send notifications (no need to check preferences again - already filtered)
for userID, taskList := range userTasks {
// Check user notification preferences
var prefs models.NotificationPreference
err := h.db.Where("user_id = ?", userID).First(&prefs).Error
if err != nil && err != gorm.ErrRecordNotFound {
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get notification preferences")
continue
}
// Skip if user has disabled task due soon notifications
if err == nil && !prefs.TaskDueSoon {
log.Debug().Uint("user_id", userID).Msg("User has disabled task due soon notifications")
continue
}
// Send individual actionable notification for each task (up to 5)
maxNotifications := 5
if len(taskList) < maxNotifications {
@@ -136,7 +157,7 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
}
}
log.Info().Msg("Task reminder notifications completed")
log.Info().Int("users_notified", len(userTasks)).Msg("Task reminder notifications completed")
return nil
}
@@ -145,16 +166,44 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
log.Info().Msg("Processing overdue task notifications...")
now := time.Now().UTC()
currentHour := now.Hour()
systemDefaultHour := h.config.Worker.OverdueReminderHour
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
// Query overdue tasks with full task data for button types
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
// Users with custom hour matching current hour, OR users with no custom hour when current hour is system default
var eligibleUserIDs []uint
err := h.db.Model(&models.NotificationPreference{}).
Select("user_id").
Where("task_overdue = true").
Where("(task_overdue_hour = ? OR (task_overdue_hour IS NULL AND ? = ?))",
currentHour, currentHour, systemDefaultHour).
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 only for eligible users
var overdueTasks []models.Task
err := h.db.Preload("Status").Preload("Completions").Preload("Residence").
Joins("JOIN residence_residence r ON task_task.residence_id = r.id").
Where("task_task.due_date < ? OR task_task.next_due_date < ?", today, today).
Where("task_task.is_cancelled = false").
Where("task_task.is_archived = false").
err = h.db.Preload("Status").Preload("Completions").Preload("Residence").
Where("due_date < ? OR next_due_date < ?", today, today).
Where("is_cancelled = false").
Where("is_archived = false").
Where("NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id AND tc.completed_at >= task_task.due_date)").
Where("(assigned_to_id IN ? OR residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ?))",
eligibleUserIDs, eligibleUserIDs).
Find(&overdueTasks).Error
if err != nil {
@@ -162,7 +211,7 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
return err
}
log.Info().Int("count", len(overdueTasks)).Msg("Found overdue tasks")
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)
@@ -175,25 +224,17 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
} else {
continue
}
userTasks[userID] = append(userTasks[userID], t)
// Only include if user is in eligible list
for _, eligibleID := range eligibleUserIDs {
if userID == eligibleID {
userTasks[userID] = append(userTasks[userID], t)
break
}
}
}
// Send actionable notifications to each user
// Step 3: Send notifications (no need to check preferences again - already filtered)
for userID, taskList := range userTasks {
// Check user notification preferences
var prefs models.NotificationPreference
err := h.db.Where("user_id = ?", userID).First(&prefs).Error
if err != nil && err != gorm.ErrRecordNotFound {
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get notification preferences")
continue
}
// Skip if user has disabled overdue notifications
if err == nil && !prefs.TaskOverdue {
log.Debug().Uint("user_id", userID).Msg("User has disabled overdue task notifications")
continue
}
// Send individual actionable notification for each task (up to 5)
maxNotifications := 5
if len(taskList) < maxNotifications {
@@ -219,7 +260,7 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
}
}
log.Info().Msg("Overdue task notifications completed")
log.Info().Int("users_notified", len(userTasks)).Msg("Overdue task notifications completed")
return nil
}