diff --git a/admin/src/app/(dashboard)/notification-prefs/page.tsx b/admin/src/app/(dashboard)/notification-prefs/page.tsx index 42b0443..d506521 100644 --- a/admin/src/app/(dashboard)/notification-prefs/page.tsx +++ b/admin/src/app/(dashboard)/notification-prefs/page.tsx @@ -164,6 +164,16 @@ export default function NotificationPrefsPage() { /> ), }, + { + accessorKey: 'daily_digest', + header: 'Daily Digest', + cell: ({ row }) => ( + handleToggle(row.original.id, 'daily_digest', row.original.daily_digest)} + /> + ), + }, { accessorKey: 'email_task_completed', header: 'Email: Completed', @@ -183,8 +193,8 @@ export default function NotificationPrefsPage() { ), cell: ({ row }) => { - const { task_due_soon_hour, task_overdue_hour, warranty_expiring_hour } = row.original; - const hasCustomTimes = task_due_soon_hour !== null || task_overdue_hour !== null || warranty_expiring_hour !== null; + const { task_due_soon_hour, task_overdue_hour, warranty_expiring_hour, daily_digest_hour } = row.original; + const hasCustomTimes = task_due_soon_hour !== null || task_overdue_hour !== null || warranty_expiring_hour !== null || daily_digest_hour !== null; if (!hasCustomTimes) { return Default; @@ -207,6 +217,11 @@ export default function NotificationPrefsPage() { Warranty: {formatHour(warranty_expiring_hour)} )} + {daily_digest_hour !== null && ( +
+ Daily Digest: {formatHour(daily_digest_hour)} +
+ )} ); }, diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index 0f2261f..cef90ca 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -597,12 +597,14 @@ export interface NotificationPreference { task_assigned: boolean; residence_shared: boolean; warranty_expiring: boolean; + daily_digest: boolean; // Email preferences email_task_completed: boolean; // Custom notification times (UTC hour 0-23, null means use system default) task_due_soon_hour: number | null; task_overdue_hour: number | null; warranty_expiring_hour: number | null; + daily_digest_hour: number | null; created_at: string; updated_at: string; } @@ -622,8 +624,11 @@ export interface UpdateNotificationPrefRequest { task_assigned?: boolean; residence_shared?: boolean; warranty_expiring?: boolean; + daily_digest?: boolean; // Email preferences email_task_completed?: boolean; + // Custom notification times + daily_digest_hour?: number | null; } // Notification Preferences API diff --git a/cmd/worker/main.go b/cmd/worker/main.go index d8168f9..967cea5 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -117,12 +117,12 @@ func main() { } log.Info().Str("cron", "0 * * * *").Int("default_hour", cfg.Worker.OverdueReminderHour).Msg("Registered overdue reminder job (runs hourly for per-user times)") - // Schedule daily digest (runs at configured hour - no per-user customization yet) - dailyCron := formatCron(cfg.Worker.DailyNotifHour) - if _, err := scheduler.Register(dailyCron, asynq.NewTask(jobs.TypeDailyDigest, nil)); err != nil { + // Schedule daily digest (runs every hour to support per-user custom times) + // The job handler filters users based on their preferred notification hour + if _, err := scheduler.Register("0 * * * *", asynq.NewTask(jobs.TypeDailyDigest, nil)); err != nil { log.Fatal().Err(err).Msg("Failed to register daily digest job") } - log.Info().Str("cron", dailyCron).Msg("Registered daily digest job") + log.Info().Str("cron", "0 * * * *").Int("default_hour", cfg.Worker.DailyNotifHour).Msg("Registered daily digest job (runs hourly for per-user times)") // Handle graceful shutdown quit := make(chan os.Signal, 1) diff --git a/internal/admin/handlers/notification_prefs_handler.go b/internal/admin/handlers/notification_prefs_handler.go index 7dfcf8b..0a091bd 100644 --- a/internal/admin/handlers/notification_prefs_handler.go +++ b/internal/admin/handlers/notification_prefs_handler.go @@ -33,6 +33,7 @@ type NotificationPrefResponse struct { TaskAssigned bool `json:"task_assigned"` ResidenceShared bool `json:"residence_shared"` WarrantyExpiring bool `json:"warranty_expiring"` + DailyDigest bool `json:"daily_digest"` // Email preferences EmailTaskCompleted bool `json:"email_task_completed"` @@ -41,6 +42,7 @@ type NotificationPrefResponse struct { TaskDueSoonHour *int `json:"task_due_soon_hour"` TaskOverdueHour *int `json:"task_overdue_hour"` WarrantyExpiringHour *int `json:"warranty_expiring_hour"` + DailyDigestHour *int `json:"daily_digest_hour"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` @@ -117,10 +119,12 @@ func (h *AdminNotificationPrefsHandler) List(c *gin.Context) { TaskAssigned: pref.TaskAssigned, ResidenceShared: pref.ResidenceShared, WarrantyExpiring: pref.WarrantyExpiring, + DailyDigest: pref.DailyDigest, EmailTaskCompleted: pref.EmailTaskCompleted, TaskDueSoonHour: pref.TaskDueSoonHour, TaskOverdueHour: pref.TaskOverdueHour, WarrantyExpiringHour: pref.WarrantyExpiringHour, + DailyDigestHour: pref.DailyDigestHour, CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"), } @@ -161,10 +165,12 @@ func (h *AdminNotificationPrefsHandler) Get(c *gin.Context) { TaskAssigned: pref.TaskAssigned, ResidenceShared: pref.ResidenceShared, WarrantyExpiring: pref.WarrantyExpiring, + DailyDigest: pref.DailyDigest, EmailTaskCompleted: pref.EmailTaskCompleted, TaskDueSoonHour: pref.TaskDueSoonHour, TaskOverdueHour: pref.TaskOverdueHour, WarrantyExpiringHour: pref.WarrantyExpiringHour, + DailyDigestHour: pref.DailyDigestHour, CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"), }) @@ -178,6 +184,7 @@ type UpdateNotificationPrefRequest struct { TaskAssigned *bool `json:"task_assigned"` ResidenceShared *bool `json:"residence_shared"` WarrantyExpiring *bool `json:"warranty_expiring"` + DailyDigest *bool `json:"daily_digest"` // Email preferences EmailTaskCompleted *bool `json:"email_task_completed"` @@ -186,6 +193,7 @@ type UpdateNotificationPrefRequest struct { TaskDueSoonHour *int `json:"task_due_soon_hour"` TaskOverdueHour *int `json:"task_overdue_hour"` WarrantyExpiringHour *int `json:"warranty_expiring_hour"` + DailyDigestHour *int `json:"daily_digest_hour"` } // Update handles PUT /api/admin/notification-prefs/:id @@ -231,6 +239,9 @@ func (h *AdminNotificationPrefsHandler) Update(c *gin.Context) { if req.WarrantyExpiring != nil { pref.WarrantyExpiring = *req.WarrantyExpiring } + if req.DailyDigest != nil { + pref.DailyDigest = *req.DailyDigest + } if req.EmailTaskCompleted != nil { pref.EmailTaskCompleted = *req.EmailTaskCompleted } @@ -245,6 +256,9 @@ func (h *AdminNotificationPrefsHandler) Update(c *gin.Context) { if req.WarrantyExpiringHour != nil { pref.WarrantyExpiringHour = req.WarrantyExpiringHour } + if req.DailyDigestHour != nil { + pref.DailyDigestHour = req.DailyDigestHour + } if err := h.db.Save(&pref).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update notification preference"}) @@ -265,10 +279,12 @@ func (h *AdminNotificationPrefsHandler) Update(c *gin.Context) { TaskAssigned: pref.TaskAssigned, ResidenceShared: pref.ResidenceShared, WarrantyExpiring: pref.WarrantyExpiring, + DailyDigest: pref.DailyDigest, EmailTaskCompleted: pref.EmailTaskCompleted, TaskDueSoonHour: pref.TaskDueSoonHour, TaskOverdueHour: pref.TaskOverdueHour, WarrantyExpiringHour: pref.WarrantyExpiringHour, + DailyDigestHour: pref.DailyDigestHour, CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"), }) @@ -327,10 +343,12 @@ func (h *AdminNotificationPrefsHandler) GetByUser(c *gin.Context) { TaskAssigned: pref.TaskAssigned, ResidenceShared: pref.ResidenceShared, WarrantyExpiring: pref.WarrantyExpiring, + DailyDigest: pref.DailyDigest, EmailTaskCompleted: pref.EmailTaskCompleted, TaskDueSoonHour: pref.TaskDueSoonHour, TaskOverdueHour: pref.TaskOverdueHour, WarrantyExpiringHour: pref.WarrantyExpiringHour, + DailyDigestHour: pref.DailyDigestHour, CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"), }) diff --git a/internal/models/notification.go b/internal/models/notification.go index 248e996..092c53d 100644 --- a/internal/models/notification.go +++ b/internal/models/notification.go @@ -21,6 +21,9 @@ type NotificationPreference struct { // Document notifications WarrantyExpiring bool `gorm:"column:warranty_expiring;default:true" json:"warranty_expiring"` + // Summary notifications + DailyDigest bool `gorm:"column:daily_digest;default:true" json:"daily_digest"` + // Email preferences EmailTaskCompleted bool `gorm:"column:email_task_completed;default:true" json:"email_task_completed"` @@ -29,6 +32,7 @@ type NotificationPreference struct { 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"` + DailyDigestHour *int `gorm:"column:daily_digest_hour" json:"daily_digest_hour"` } // TableName returns the table name for GORM diff --git a/internal/services/notification_service.go b/internal/services/notification_service.go index 2e2bbeb..ef06e74 100644 --- a/internal/services/notification_service.go +++ b/internal/services/notification_service.go @@ -191,6 +191,9 @@ func (s *NotificationService) UpdatePreferences(userID uint, req *UpdatePreferen if req.WarrantyExpiring != nil { prefs.WarrantyExpiring = *req.WarrantyExpiring } + if req.DailyDigest != nil { + prefs.DailyDigest = *req.DailyDigest + } if req.EmailTaskCompleted != nil { prefs.EmailTaskCompleted = *req.EmailTaskCompleted } @@ -206,6 +209,9 @@ func (s *NotificationService) UpdatePreferences(userID uint, req *UpdatePreferen if req.WarrantyExpiringHour != nil { prefs.WarrantyExpiringHour = req.WarrantyExpiringHour } + if req.DailyDigestHour != nil { + prefs.DailyDigestHour = req.DailyDigestHour + } if err := s.notificationRepo.UpdatePreferences(prefs); err != nil { return nil, err @@ -374,6 +380,7 @@ type NotificationPreferencesResponse struct { TaskAssigned bool `json:"task_assigned"` ResidenceShared bool `json:"residence_shared"` WarrantyExpiring bool `json:"warranty_expiring"` + DailyDigest bool `json:"daily_digest"` // Email preferences EmailTaskCompleted bool `json:"email_task_completed"` @@ -382,21 +389,24 @@ type NotificationPreferencesResponse struct { TaskDueSoonHour *int `json:"task_due_soon_hour"` TaskOverdueHour *int `json:"task_overdue_hour"` WarrantyExpiringHour *int `json:"warranty_expiring_hour"` + DailyDigestHour *int `json:"daily_digest_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, - TaskDueSoonHour: p.TaskDueSoonHour, - TaskOverdueHour: p.TaskOverdueHour, + TaskDueSoon: p.TaskDueSoon, + TaskOverdue: p.TaskOverdue, + TaskCompleted: p.TaskCompleted, + TaskAssigned: p.TaskAssigned, + ResidenceShared: p.ResidenceShared, + WarrantyExpiring: p.WarrantyExpiring, + DailyDigest: p.DailyDigest, + EmailTaskCompleted: p.EmailTaskCompleted, + TaskDueSoonHour: p.TaskDueSoonHour, + TaskOverdueHour: p.TaskOverdueHour, WarrantyExpiringHour: p.WarrantyExpiringHour, + DailyDigestHour: p.DailyDigestHour, } } @@ -408,6 +418,7 @@ type UpdatePreferencesRequest struct { TaskAssigned *bool `json:"task_assigned"` ResidenceShared *bool `json:"residence_shared"` WarrantyExpiring *bool `json:"warranty_expiring"` + DailyDigest *bool `json:"daily_digest"` // Email preferences EmailTaskCompleted *bool `json:"email_task_completed"` @@ -417,6 +428,7 @@ type UpdatePreferencesRequest struct { TaskDueSoonHour *int `json:"task_due_soon_hour"` TaskOverdueHour *int `json:"task_overdue_hour"` WarrantyExpiringHour *int `json:"warranty_expiring_hour"` + DailyDigestHour *int `json:"daily_digest_hour"` } // DeviceResponse represents a device in API response diff --git a/internal/worker/jobs/handler.go b/internal/worker/jobs/handler.go index 23efd14..1336a10 100644 --- a/internal/worker/jobs/handler.go +++ b/internal/worker/jobs/handler.go @@ -69,19 +69,22 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro 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 + // 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 - // Build query based on whether current hour is system default query := h.db.Model(&models.NotificationPreference{}). Select("user_id"). Where("task_due_soon = true") if currentHour == systemDefaultHour { - // Current hour is the system default, so include users with custom hour OR no custom hour (NULL) - query = query.Where("(task_due_soon_hour = ? OR task_due_soon_hour IS NULL)", currentHour) + // 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 { - // Current hour is not system default, so only include users with this specific custom hour + // 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) } @@ -144,30 +147,13 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro } // 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 { - // Send individual actionable notification for each task (up to 5) - maxNotifications := 5 - if len(taskList) < maxNotifications { - maxNotifications = len(taskList) - } - - for i := 0; i < maxNotifications; i++ { - t := taskList[i] + 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") } } - - // If more than 5 tasks, send a summary notification - if len(taskList) > 5 { - title := "More Tasks Due Soon" - body := fmt.Sprintf("You have %d more tasks due soon", len(taskList)-5) - if err := h.sendPushToUser(ctx, userID, title, body, map[string]string{ - "type": "task_reminder_summary", - }); err != nil { - log.Error().Err(err).Uint("user_id", userID).Msg("Failed to send task reminder summary") - } - } } log.Info().Int("users_notified", len(userTasks)).Msg("Task reminder notifications completed") @@ -186,19 +172,22 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e 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 + // 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 - // Build query based on whether current hour is system default query := h.db.Model(&models.NotificationPreference{}). Select("user_id"). Where("task_overdue = true") if currentHour == systemDefaultHour { - // Current hour is the system default, so include users with custom hour OR no custom hour (NULL) - query = query.Where("(task_overdue_hour = ? OR task_overdue_hour IS NULL)", currentHour) + // 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 { - // Current hour is not system default, so only include users with this specific custom hour + // 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) } @@ -260,30 +249,13 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e } // 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 { - // Send individual actionable notification for each task (up to 5) - maxNotifications := 5 - if len(taskList) < maxNotifications { - maxNotifications = len(taskList) - } - - for i := 0; i < maxNotifications; i++ { - t := taskList[i] + 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") } } - - // If more than 5 tasks, send a summary notification - if len(taskList) > 5 { - title := "More Overdue Tasks" - body := fmt.Sprintf("You have %d more overdue tasks that need attention", len(taskList)-5) - if err := h.sendPushToUser(ctx, userID, title, body, map[string]string{ - "type": "overdue_summary", - }); err != nil { - log.Error().Err(err).Uint("user_id", userID).Msg("Failed to send overdue summary") - } - } } log.Info().Int("users_notified", len(userTasks)).Msg("Overdue task notifications completed") @@ -295,10 +267,46 @@ 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 today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) nextWeek := today.AddDate(0, 0, 7) - // Get all users with their task statistics + 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 only for eligible users // Completion detection logic matches internal/task/predicates.IsCompleted: // A task is "completed" when NextDueDate == nil AND has at least one completion. // We use COALESCE(next_due_date, due_date) as the effective date for categorization. @@ -309,7 +317,7 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error DueThisWeek int } - err := h.db.Raw(` + err = h.db.Raw(` SELECT u.id as user_id, COUNT(DISTINCT t.id) as total_tasks, @@ -330,18 +338,19 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error JOIN task_task t ON t.residence_id = r.id AND t.is_cancelled = false AND t.is_archived = false - WHERE u.is_active = true + WHERE u.is_active = true AND u.id IN ? GROUP BY u.id HAVING COUNT(DISTINCT t.id) > 0 - `, today, today, nextWeek).Scan(&userStats).Error + `, today, today, nextWeek, eligibleUserIDs).Scan(&userStats).Error if err != nil { log.Error().Err(err).Msg("Failed to query user task statistics") return err } - log.Info().Int("users", len(userStats)).Msg("Processing daily digest for users") + log.Info().Int("users_with_tasks", len(userStats)).Msg("Processing daily digest for users") + // Step 3: Send notifications for _, stats := range userStats { // Skip users with no actionable items if stats.OverdueTasks == 0 && stats.DueThisWeek == 0 { @@ -362,15 +371,15 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error // Send push notification if err := h.sendPushToUser(ctx, stats.UserID, title, body, map[string]string{ - "type": "daily_digest", - "overdue": fmt.Sprintf("%d", stats.OverdueTasks), + "type": "daily_digest", + "overdue": fmt.Sprintf("%d", stats.OverdueTasks), "due_this_week": fmt.Sprintf("%d", stats.DueThisWeek), }); err != nil { log.Error().Err(err).Uint("user_id", stats.UserID).Msg("Failed to send daily digest push") } } - log.Info().Msg("Daily digest notifications completed") + log.Info().Int("users_notified", len(userStats)).Msg("Daily digest notifications completed") return nil }