Add Daily Digest notification preferences with custom time support
- Add daily_digest boolean and daily_digest_hour fields to NotificationPreference model - Update HandleDailyDigest to check user preferences and custom notification times - Change Daily Digest scheduler to run hourly (supports per-user custom times) - Update notification service DTOs for new fields - Add Daily Digest toggle and custom time to admin notification prefs page - Fix notification handlers to only notify users at their designated hour 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -164,6 +164,16 @@ export default function NotificationPrefsPage() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'daily_digest',
|
||||||
|
header: 'Daily Digest',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Switch
|
||||||
|
checked={row.original.daily_digest}
|
||||||
|
onCheckedChange={() => handleToggle(row.original.id, 'daily_digest', row.original.daily_digest)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'email_task_completed',
|
accessorKey: 'email_task_completed',
|
||||||
header: 'Email: Completed',
|
header: 'Email: Completed',
|
||||||
@@ -183,8 +193,8 @@ export default function NotificationPrefsPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const { task_due_soon_hour, task_overdue_hour, warranty_expiring_hour } = row.original;
|
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;
|
const hasCustomTimes = task_due_soon_hour !== null || task_overdue_hour !== null || warranty_expiring_hour !== null || daily_digest_hour !== null;
|
||||||
|
|
||||||
if (!hasCustomTimes) {
|
if (!hasCustomTimes) {
|
||||||
return <span className="text-muted-foreground text-sm">Default</span>;
|
return <span className="text-muted-foreground text-sm">Default</span>;
|
||||||
@@ -207,6 +217,11 @@ export default function NotificationPrefsPage() {
|
|||||||
<span className="text-muted-foreground">Warranty:</span> {formatHour(warranty_expiring_hour)}
|
<span className="text-muted-foreground">Warranty:</span> {formatHour(warranty_expiring_hour)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{daily_digest_hour !== null && (
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-muted-foreground">Daily Digest:</span> {formatHour(daily_digest_hour)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -597,12 +597,14 @@ export interface NotificationPreference {
|
|||||||
task_assigned: boolean;
|
task_assigned: boolean;
|
||||||
residence_shared: boolean;
|
residence_shared: boolean;
|
||||||
warranty_expiring: boolean;
|
warranty_expiring: boolean;
|
||||||
|
daily_digest: boolean;
|
||||||
// Email preferences
|
// Email preferences
|
||||||
email_task_completed: boolean;
|
email_task_completed: boolean;
|
||||||
// Custom notification times (UTC hour 0-23, null means use system default)
|
// Custom notification times (UTC hour 0-23, null means use system default)
|
||||||
task_due_soon_hour: number | null;
|
task_due_soon_hour: number | null;
|
||||||
task_overdue_hour: number | null;
|
task_overdue_hour: number | null;
|
||||||
warranty_expiring_hour: number | null;
|
warranty_expiring_hour: number | null;
|
||||||
|
daily_digest_hour: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -622,8 +624,11 @@ export interface UpdateNotificationPrefRequest {
|
|||||||
task_assigned?: boolean;
|
task_assigned?: boolean;
|
||||||
residence_shared?: boolean;
|
residence_shared?: boolean;
|
||||||
warranty_expiring?: boolean;
|
warranty_expiring?: boolean;
|
||||||
|
daily_digest?: boolean;
|
||||||
// Email preferences
|
// Email preferences
|
||||||
email_task_completed?: boolean;
|
email_task_completed?: boolean;
|
||||||
|
// Custom notification times
|
||||||
|
daily_digest_hour?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notification Preferences API
|
// Notification Preferences API
|
||||||
|
|||||||
@@ -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)")
|
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)
|
// Schedule daily digest (runs every hour to support per-user custom times)
|
||||||
dailyCron := formatCron(cfg.Worker.DailyNotifHour)
|
// The job handler filters users based on their preferred notification hour
|
||||||
if _, err := scheduler.Register(dailyCron, asynq.NewTask(jobs.TypeDailyDigest, nil)); err != nil {
|
if _, err := scheduler.Register("0 * * * *", asynq.NewTask(jobs.TypeDailyDigest, nil)); err != nil {
|
||||||
log.Fatal().Err(err).Msg("Failed to register daily digest job")
|
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
|
// Handle graceful shutdown
|
||||||
quit := make(chan os.Signal, 1)
|
quit := make(chan os.Signal, 1)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type NotificationPrefResponse struct {
|
|||||||
TaskAssigned bool `json:"task_assigned"`
|
TaskAssigned bool `json:"task_assigned"`
|
||||||
ResidenceShared bool `json:"residence_shared"`
|
ResidenceShared bool `json:"residence_shared"`
|
||||||
WarrantyExpiring bool `json:"warranty_expiring"`
|
WarrantyExpiring bool `json:"warranty_expiring"`
|
||||||
|
DailyDigest bool `json:"daily_digest"`
|
||||||
|
|
||||||
// Email preferences
|
// Email preferences
|
||||||
EmailTaskCompleted bool `json:"email_task_completed"`
|
EmailTaskCompleted bool `json:"email_task_completed"`
|
||||||
@@ -41,6 +42,7 @@ type NotificationPrefResponse struct {
|
|||||||
TaskDueSoonHour *int `json:"task_due_soon_hour"`
|
TaskDueSoonHour *int `json:"task_due_soon_hour"`
|
||||||
TaskOverdueHour *int `json:"task_overdue_hour"`
|
TaskOverdueHour *int `json:"task_overdue_hour"`
|
||||||
WarrantyExpiringHour *int `json:"warranty_expiring_hour"`
|
WarrantyExpiringHour *int `json:"warranty_expiring_hour"`
|
||||||
|
DailyDigestHour *int `json:"daily_digest_hour"`
|
||||||
|
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
@@ -117,10 +119,12 @@ func (h *AdminNotificationPrefsHandler) List(c *gin.Context) {
|
|||||||
TaskAssigned: pref.TaskAssigned,
|
TaskAssigned: pref.TaskAssigned,
|
||||||
ResidenceShared: pref.ResidenceShared,
|
ResidenceShared: pref.ResidenceShared,
|
||||||
WarrantyExpiring: pref.WarrantyExpiring,
|
WarrantyExpiring: pref.WarrantyExpiring,
|
||||||
|
DailyDigest: pref.DailyDigest,
|
||||||
EmailTaskCompleted: pref.EmailTaskCompleted,
|
EmailTaskCompleted: pref.EmailTaskCompleted,
|
||||||
TaskDueSoonHour: pref.TaskDueSoonHour,
|
TaskDueSoonHour: pref.TaskDueSoonHour,
|
||||||
TaskOverdueHour: pref.TaskOverdueHour,
|
TaskOverdueHour: pref.TaskOverdueHour,
|
||||||
WarrantyExpiringHour: pref.WarrantyExpiringHour,
|
WarrantyExpiringHour: pref.WarrantyExpiringHour,
|
||||||
|
DailyDigestHour: pref.DailyDigestHour,
|
||||||
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
UpdatedAt: pref.UpdatedAt.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,
|
TaskAssigned: pref.TaskAssigned,
|
||||||
ResidenceShared: pref.ResidenceShared,
|
ResidenceShared: pref.ResidenceShared,
|
||||||
WarrantyExpiring: pref.WarrantyExpiring,
|
WarrantyExpiring: pref.WarrantyExpiring,
|
||||||
|
DailyDigest: pref.DailyDigest,
|
||||||
EmailTaskCompleted: pref.EmailTaskCompleted,
|
EmailTaskCompleted: pref.EmailTaskCompleted,
|
||||||
TaskDueSoonHour: pref.TaskDueSoonHour,
|
TaskDueSoonHour: pref.TaskDueSoonHour,
|
||||||
TaskOverdueHour: pref.TaskOverdueHour,
|
TaskOverdueHour: pref.TaskOverdueHour,
|
||||||
WarrantyExpiringHour: pref.WarrantyExpiringHour,
|
WarrantyExpiringHour: pref.WarrantyExpiringHour,
|
||||||
|
DailyDigestHour: pref.DailyDigestHour,
|
||||||
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
UpdatedAt: pref.UpdatedAt.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"`
|
TaskAssigned *bool `json:"task_assigned"`
|
||||||
ResidenceShared *bool `json:"residence_shared"`
|
ResidenceShared *bool `json:"residence_shared"`
|
||||||
WarrantyExpiring *bool `json:"warranty_expiring"`
|
WarrantyExpiring *bool `json:"warranty_expiring"`
|
||||||
|
DailyDigest *bool `json:"daily_digest"`
|
||||||
|
|
||||||
// Email preferences
|
// Email preferences
|
||||||
EmailTaskCompleted *bool `json:"email_task_completed"`
|
EmailTaskCompleted *bool `json:"email_task_completed"`
|
||||||
@@ -186,6 +193,7 @@ type UpdateNotificationPrefRequest struct {
|
|||||||
TaskDueSoonHour *int `json:"task_due_soon_hour"`
|
TaskDueSoonHour *int `json:"task_due_soon_hour"`
|
||||||
TaskOverdueHour *int `json:"task_overdue_hour"`
|
TaskOverdueHour *int `json:"task_overdue_hour"`
|
||||||
WarrantyExpiringHour *int `json:"warranty_expiring_hour"`
|
WarrantyExpiringHour *int `json:"warranty_expiring_hour"`
|
||||||
|
DailyDigestHour *int `json:"daily_digest_hour"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update handles PUT /api/admin/notification-prefs/:id
|
// Update handles PUT /api/admin/notification-prefs/:id
|
||||||
@@ -231,6 +239,9 @@ func (h *AdminNotificationPrefsHandler) Update(c *gin.Context) {
|
|||||||
if req.WarrantyExpiring != nil {
|
if req.WarrantyExpiring != nil {
|
||||||
pref.WarrantyExpiring = *req.WarrantyExpiring
|
pref.WarrantyExpiring = *req.WarrantyExpiring
|
||||||
}
|
}
|
||||||
|
if req.DailyDigest != nil {
|
||||||
|
pref.DailyDigest = *req.DailyDigest
|
||||||
|
}
|
||||||
if req.EmailTaskCompleted != nil {
|
if req.EmailTaskCompleted != nil {
|
||||||
pref.EmailTaskCompleted = *req.EmailTaskCompleted
|
pref.EmailTaskCompleted = *req.EmailTaskCompleted
|
||||||
}
|
}
|
||||||
@@ -245,6 +256,9 @@ func (h *AdminNotificationPrefsHandler) Update(c *gin.Context) {
|
|||||||
if req.WarrantyExpiringHour != nil {
|
if req.WarrantyExpiringHour != nil {
|
||||||
pref.WarrantyExpiringHour = req.WarrantyExpiringHour
|
pref.WarrantyExpiringHour = req.WarrantyExpiringHour
|
||||||
}
|
}
|
||||||
|
if req.DailyDigestHour != nil {
|
||||||
|
pref.DailyDigestHour = req.DailyDigestHour
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.db.Save(&pref).Error; err != nil {
|
if err := h.db.Save(&pref).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update notification preference"})
|
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,
|
TaskAssigned: pref.TaskAssigned,
|
||||||
ResidenceShared: pref.ResidenceShared,
|
ResidenceShared: pref.ResidenceShared,
|
||||||
WarrantyExpiring: pref.WarrantyExpiring,
|
WarrantyExpiring: pref.WarrantyExpiring,
|
||||||
|
DailyDigest: pref.DailyDigest,
|
||||||
EmailTaskCompleted: pref.EmailTaskCompleted,
|
EmailTaskCompleted: pref.EmailTaskCompleted,
|
||||||
TaskDueSoonHour: pref.TaskDueSoonHour,
|
TaskDueSoonHour: pref.TaskDueSoonHour,
|
||||||
TaskOverdueHour: pref.TaskOverdueHour,
|
TaskOverdueHour: pref.TaskOverdueHour,
|
||||||
WarrantyExpiringHour: pref.WarrantyExpiringHour,
|
WarrantyExpiringHour: pref.WarrantyExpiringHour,
|
||||||
|
DailyDigestHour: pref.DailyDigestHour,
|
||||||
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
UpdatedAt: pref.UpdatedAt.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,
|
TaskAssigned: pref.TaskAssigned,
|
||||||
ResidenceShared: pref.ResidenceShared,
|
ResidenceShared: pref.ResidenceShared,
|
||||||
WarrantyExpiring: pref.WarrantyExpiring,
|
WarrantyExpiring: pref.WarrantyExpiring,
|
||||||
|
DailyDigest: pref.DailyDigest,
|
||||||
EmailTaskCompleted: pref.EmailTaskCompleted,
|
EmailTaskCompleted: pref.EmailTaskCompleted,
|
||||||
TaskDueSoonHour: pref.TaskDueSoonHour,
|
TaskDueSoonHour: pref.TaskDueSoonHour,
|
||||||
TaskOverdueHour: pref.TaskOverdueHour,
|
TaskOverdueHour: pref.TaskOverdueHour,
|
||||||
WarrantyExpiringHour: pref.WarrantyExpiringHour,
|
WarrantyExpiringHour: pref.WarrantyExpiringHour,
|
||||||
|
DailyDigestHour: pref.DailyDigestHour,
|
||||||
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ type NotificationPreference struct {
|
|||||||
// Document notifications
|
// Document notifications
|
||||||
WarrantyExpiring bool `gorm:"column:warranty_expiring;default:true" json:"warranty_expiring"`
|
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
|
// Email preferences
|
||||||
EmailTaskCompleted bool `gorm:"column:email_task_completed;default:true" json:"email_task_completed"`
|
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"`
|
TaskDueSoonHour *int `gorm:"column:task_due_soon_hour" json:"task_due_soon_hour"`
|
||||||
TaskOverdueHour *int `gorm:"column:task_overdue_hour" json:"task_overdue_hour"`
|
TaskOverdueHour *int `gorm:"column:task_overdue_hour" json:"task_overdue_hour"`
|
||||||
WarrantyExpiringHour *int `gorm:"column:warranty_expiring_hour" json:"warranty_expiring_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
|
// TableName returns the table name for GORM
|
||||||
|
|||||||
@@ -191,6 +191,9 @@ func (s *NotificationService) UpdatePreferences(userID uint, req *UpdatePreferen
|
|||||||
if req.WarrantyExpiring != nil {
|
if req.WarrantyExpiring != nil {
|
||||||
prefs.WarrantyExpiring = *req.WarrantyExpiring
|
prefs.WarrantyExpiring = *req.WarrantyExpiring
|
||||||
}
|
}
|
||||||
|
if req.DailyDigest != nil {
|
||||||
|
prefs.DailyDigest = *req.DailyDigest
|
||||||
|
}
|
||||||
if req.EmailTaskCompleted != nil {
|
if req.EmailTaskCompleted != nil {
|
||||||
prefs.EmailTaskCompleted = *req.EmailTaskCompleted
|
prefs.EmailTaskCompleted = *req.EmailTaskCompleted
|
||||||
}
|
}
|
||||||
@@ -206,6 +209,9 @@ func (s *NotificationService) UpdatePreferences(userID uint, req *UpdatePreferen
|
|||||||
if req.WarrantyExpiringHour != nil {
|
if req.WarrantyExpiringHour != nil {
|
||||||
prefs.WarrantyExpiringHour = req.WarrantyExpiringHour
|
prefs.WarrantyExpiringHour = req.WarrantyExpiringHour
|
||||||
}
|
}
|
||||||
|
if req.DailyDigestHour != nil {
|
||||||
|
prefs.DailyDigestHour = req.DailyDigestHour
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.notificationRepo.UpdatePreferences(prefs); err != nil {
|
if err := s.notificationRepo.UpdatePreferences(prefs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -374,6 +380,7 @@ type NotificationPreferencesResponse struct {
|
|||||||
TaskAssigned bool `json:"task_assigned"`
|
TaskAssigned bool `json:"task_assigned"`
|
||||||
ResidenceShared bool `json:"residence_shared"`
|
ResidenceShared bool `json:"residence_shared"`
|
||||||
WarrantyExpiring bool `json:"warranty_expiring"`
|
WarrantyExpiring bool `json:"warranty_expiring"`
|
||||||
|
DailyDigest bool `json:"daily_digest"`
|
||||||
|
|
||||||
// Email preferences
|
// Email preferences
|
||||||
EmailTaskCompleted bool `json:"email_task_completed"`
|
EmailTaskCompleted bool `json:"email_task_completed"`
|
||||||
@@ -382,6 +389,7 @@ type NotificationPreferencesResponse struct {
|
|||||||
TaskDueSoonHour *int `json:"task_due_soon_hour"`
|
TaskDueSoonHour *int `json:"task_due_soon_hour"`
|
||||||
TaskOverdueHour *int `json:"task_overdue_hour"`
|
TaskOverdueHour *int `json:"task_overdue_hour"`
|
||||||
WarrantyExpiringHour *int `json:"warranty_expiring_hour"`
|
WarrantyExpiringHour *int `json:"warranty_expiring_hour"`
|
||||||
|
DailyDigestHour *int `json:"daily_digest_hour"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewNotificationPreferencesResponse creates a NotificationPreferencesResponse
|
// NewNotificationPreferencesResponse creates a NotificationPreferencesResponse
|
||||||
@@ -393,10 +401,12 @@ func NewNotificationPreferencesResponse(p *models.NotificationPreference) *Notif
|
|||||||
TaskAssigned: p.TaskAssigned,
|
TaskAssigned: p.TaskAssigned,
|
||||||
ResidenceShared: p.ResidenceShared,
|
ResidenceShared: p.ResidenceShared,
|
||||||
WarrantyExpiring: p.WarrantyExpiring,
|
WarrantyExpiring: p.WarrantyExpiring,
|
||||||
|
DailyDigest: p.DailyDigest,
|
||||||
EmailTaskCompleted: p.EmailTaskCompleted,
|
EmailTaskCompleted: p.EmailTaskCompleted,
|
||||||
TaskDueSoonHour: p.TaskDueSoonHour,
|
TaskDueSoonHour: p.TaskDueSoonHour,
|
||||||
TaskOverdueHour: p.TaskOverdueHour,
|
TaskOverdueHour: p.TaskOverdueHour,
|
||||||
WarrantyExpiringHour: p.WarrantyExpiringHour,
|
WarrantyExpiringHour: p.WarrantyExpiringHour,
|
||||||
|
DailyDigestHour: p.DailyDigestHour,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,6 +418,7 @@ type UpdatePreferencesRequest struct {
|
|||||||
TaskAssigned *bool `json:"task_assigned"`
|
TaskAssigned *bool `json:"task_assigned"`
|
||||||
ResidenceShared *bool `json:"residence_shared"`
|
ResidenceShared *bool `json:"residence_shared"`
|
||||||
WarrantyExpiring *bool `json:"warranty_expiring"`
|
WarrantyExpiring *bool `json:"warranty_expiring"`
|
||||||
|
DailyDigest *bool `json:"daily_digest"`
|
||||||
|
|
||||||
// Email preferences
|
// Email preferences
|
||||||
EmailTaskCompleted *bool `json:"email_task_completed"`
|
EmailTaskCompleted *bool `json:"email_task_completed"`
|
||||||
@@ -417,6 +428,7 @@ type UpdatePreferencesRequest struct {
|
|||||||
TaskDueSoonHour *int `json:"task_due_soon_hour"`
|
TaskDueSoonHour *int `json:"task_due_soon_hour"`
|
||||||
TaskOverdueHour *int `json:"task_overdue_hour"`
|
TaskOverdueHour *int `json:"task_overdue_hour"`
|
||||||
WarrantyExpiringHour *int `json:"warranty_expiring_hour"`
|
WarrantyExpiringHour *int `json:"warranty_expiring_hour"`
|
||||||
|
DailyDigestHour *int `json:"daily_digest_hour"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeviceResponse represents a device in API response
|
// DeviceResponse represents a device in API response
|
||||||
|
|||||||
@@ -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")
|
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
|
// 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
|
var eligibleUserIDs []uint
|
||||||
|
|
||||||
// Build query based on whether current hour is system default
|
|
||||||
query := h.db.Model(&models.NotificationPreference{}).
|
query := h.db.Model(&models.NotificationPreference{}).
|
||||||
Select("user_id").
|
Select("user_id").
|
||||||
Where("task_due_soon = true")
|
Where("task_due_soon = true")
|
||||||
|
|
||||||
if currentHour == systemDefaultHour {
|
if currentHour == systemDefaultHour {
|
||||||
// Current hour is the system default, so include users with custom hour OR no custom hour (NULL)
|
// 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 = ? OR task_due_soon_hour IS NULL)", currentHour)
|
query = query.Where("task_due_soon_hour IS NULL OR task_due_soon_hour = ?", currentHour)
|
||||||
} else {
|
} 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)
|
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)
|
// 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 userID, taskList := range userTasks {
|
||||||
// Send individual actionable notification for each task (up to 5)
|
for _, t := range taskList {
|
||||||
maxNotifications := 5
|
|
||||||
if len(taskList) < maxNotifications {
|
|
||||||
maxNotifications = len(taskList)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < maxNotifications; i++ {
|
|
||||||
t := taskList[i]
|
|
||||||
if err := h.notificationService.CreateAndSendTaskNotification(ctx, userID, models.NotificationTaskDueSoon, &t); err != nil {
|
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.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")
|
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")
|
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
|
// 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
|
var eligibleUserIDs []uint
|
||||||
|
|
||||||
// Build query based on whether current hour is system default
|
|
||||||
query := h.db.Model(&models.NotificationPreference{}).
|
query := h.db.Model(&models.NotificationPreference{}).
|
||||||
Select("user_id").
|
Select("user_id").
|
||||||
Where("task_overdue = true")
|
Where("task_overdue = true")
|
||||||
|
|
||||||
if currentHour == systemDefaultHour {
|
if currentHour == systemDefaultHour {
|
||||||
// Current hour is the system default, so include users with custom hour OR no custom hour (NULL)
|
// At system default hour: notify users who have NO custom hour (NULL) OR whose custom hour equals default
|
||||||
query = query.Where("(task_overdue_hour = ? OR task_overdue_hour IS NULL)", currentHour)
|
query = query.Where("task_overdue_hour IS NULL OR task_overdue_hour = ?", currentHour)
|
||||||
} else {
|
} 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)
|
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)
|
// 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 userID, taskList := range userTasks {
|
||||||
// Send individual actionable notification for each task (up to 5)
|
for _, t := range taskList {
|
||||||
maxNotifications := 5
|
|
||||||
if len(taskList) < maxNotifications {
|
|
||||||
maxNotifications = len(taskList)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < maxNotifications; i++ {
|
|
||||||
t := taskList[i]
|
|
||||||
if err := h.notificationService.CreateAndSendTaskNotification(ctx, userID, models.NotificationTaskOverdue, &t); err != nil {
|
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.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")
|
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...")
|
log.Info().Msg("Processing daily digest notifications...")
|
||||||
|
|
||||||
now := time.Now().UTC()
|
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)
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
nextWeek := today.AddDate(0, 0, 7)
|
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:
|
// Completion detection logic matches internal/task/predicates.IsCompleted:
|
||||||
// A task is "completed" when NextDueDate == nil AND has at least one completion.
|
// 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.
|
// 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
|
DueThisWeek int
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.db.Raw(`
|
err = h.db.Raw(`
|
||||||
SELECT
|
SELECT
|
||||||
u.id as user_id,
|
u.id as user_id,
|
||||||
COUNT(DISTINCT t.id) as total_tasks,
|
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
|
JOIN task_task t ON t.residence_id = r.id
|
||||||
AND t.is_cancelled = false
|
AND t.is_cancelled = false
|
||||||
AND t.is_archived = false
|
AND t.is_archived = false
|
||||||
WHERE u.is_active = true
|
WHERE u.is_active = true AND u.id IN ?
|
||||||
GROUP BY u.id
|
GROUP BY u.id
|
||||||
HAVING COUNT(DISTINCT t.id) > 0
|
HAVING COUNT(DISTINCT t.id) > 0
|
||||||
`, today, today, nextWeek).Scan(&userStats).Error
|
`, today, today, nextWeek, eligibleUserIDs).Scan(&userStats).Error
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to query user task statistics")
|
log.Error().Err(err).Msg("Failed to query user task statistics")
|
||||||
return err
|
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 {
|
for _, stats := range userStats {
|
||||||
// Skip users with no actionable items
|
// Skip users with no actionable items
|
||||||
if stats.OverdueTasks == 0 && stats.DueThisWeek == 0 {
|
if stats.OverdueTasks == 0 && stats.DueThisWeek == 0 {
|
||||||
@@ -370,7 +379,7 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info().Msg("Daily digest notifications completed")
|
log.Info().Int("users_notified", len(userStats)).Msg("Daily digest notifications completed")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user