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"),
})
}