From 5a6fbe0a362e51f92ff54c8e1f169a2c8fe98497 Mon Sep 17 00:00:00 2001 From: Trey t Date: Thu, 4 Dec 2025 20:04:24 -0600 Subject: [PATCH] Add email notification preference for task completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add EmailTaskCompleted field to NotificationPreference model - Update notification repository to include email preference in queries - Check email preference before sending task completion emails - Add email preference toggle to admin panel notification-prefs page - Update API types for email preference support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../(dashboard)/notification-prefs/page.tsx | 12 +- admin/src/lib/api.ts | 4 + .../handlers/notification_prefs_handler.go | 114 ++++++++++-------- internal/models/notification.go | 3 + internal/repositories/notification_repo.go | 15 +-- internal/services/notification_service.go | 22 +++- internal/services/task_service.go | 33 ++--- 7 files changed, 125 insertions(+), 78 deletions(-) diff --git a/admin/src/app/(dashboard)/notification-prefs/page.tsx b/admin/src/app/(dashboard)/notification-prefs/page.tsx index 92cda73..d650aa0 100644 --- a/admin/src/app/(dashboard)/notification-prefs/page.tsx +++ b/admin/src/app/(dashboard)/notification-prefs/page.tsx @@ -156,6 +156,16 @@ export default function NotificationPrefsPage() { /> ), }, + { + accessorKey: 'email_task_completed', + header: 'Email: Completed', + cell: ({ row }) => ( + handleToggle(row.original.id, 'email_task_completed', row.original.email_task_completed)} + /> + ), + }, { id: 'actions', cell: ({ row }) => { @@ -194,7 +204,7 @@ export default function NotificationPrefsPage() {

Notification Preferences

- Manage user notification preferences for push notifications + Manage user notification preferences for push notifications and emails

diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index b95bedf..b605c12 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -597,6 +597,8 @@ export interface NotificationPreference { task_assigned: boolean; residence_shared: boolean; warranty_expiring: boolean; + // Email preferences + email_task_completed: boolean; created_at: string; updated_at: string; } @@ -616,6 +618,8 @@ export interface UpdateNotificationPrefRequest { task_assigned?: boolean; residence_shared?: boolean; warranty_expiring?: boolean; + // Email preferences + email_task_completed?: boolean; } // Notification Preferences API diff --git a/internal/admin/handlers/notification_prefs_handler.go b/internal/admin/handlers/notification_prefs_handler.go index 1859f8f..8df9752 100644 --- a/internal/admin/handlers/notification_prefs_handler.go +++ b/internal/admin/handlers/notification_prefs_handler.go @@ -33,8 +33,12 @@ type NotificationPrefResponse struct { TaskAssigned bool `json:"task_assigned"` ResidenceShared bool `json:"residence_shared"` WarrantyExpiring bool `json:"warranty_expiring"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + + // Email preferences + EmailTaskCompleted bool `json:"email_task_completed"` + + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } // List handles GET /api/admin/notification-prefs @@ -98,18 +102,19 @@ 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, - 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, + CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"), } } @@ -138,18 +143,19 @@ 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, - 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, + CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"), }) } @@ -161,6 +167,9 @@ type UpdateNotificationPrefRequest struct { TaskAssigned *bool `json:"task_assigned"` ResidenceShared *bool `json:"residence_shared"` WarrantyExpiring *bool `json:"warranty_expiring"` + + // Email preferences + EmailTaskCompleted *bool `json:"email_task_completed"` } // Update handles PUT /api/admin/notification-prefs/:id @@ -206,6 +215,9 @@ func (h *AdminNotificationPrefsHandler) Update(c *gin.Context) { if req.WarrantyExpiring != nil { pref.WarrantyExpiring = *req.WarrantyExpiring } + if req.EmailTaskCompleted != nil { + pref.EmailTaskCompleted = *req.EmailTaskCompleted + } if err := h.db.Save(&pref).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update notification preference"}) @@ -216,18 +228,19 @@ 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, - 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, + CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"), }) } @@ -274,17 +287,18 @@ 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, - 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, + 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 3873f89..9970736 100644 --- a/internal/models/notification.go +++ b/internal/models/notification.go @@ -20,6 +20,9 @@ type NotificationPreference struct { // Document notifications WarrantyExpiring bool `gorm:"column:warranty_expiring;default:true" json:"warranty_expiring"` + + // Email preferences + EmailTaskCompleted bool `gorm:"column:email_task_completed;default:true" json:"email_task_completed"` } // TableName returns the table name for GORM diff --git a/internal/repositories/notification_repo.go b/internal/repositories/notification_repo.go index 0974d5d..0d89903 100644 --- a/internal/repositories/notification_repo.go +++ b/internal/repositories/notification_repo.go @@ -142,13 +142,14 @@ func (r *NotificationRepository) GetOrCreatePreferences(userID uint) (*models.No if err == gorm.ErrRecordNotFound { prefs = &models.NotificationPreference{ - UserID: userID, - TaskDueSoon: true, - TaskOverdue: true, - TaskCompleted: true, - TaskAssigned: true, - ResidenceShared: true, - WarrantyExpiring: true, + UserID: userID, + TaskDueSoon: true, + TaskOverdue: true, + TaskCompleted: true, + TaskAssigned: true, + ResidenceShared: true, + WarrantyExpiring: true, + EmailTaskCompleted: true, } if err := r.CreatePreferences(prefs); err != nil { return nil, err diff --git a/internal/services/notification_service.go b/internal/services/notification_service.go index 322d712..e97b8b5 100644 --- a/internal/services/notification_service.go +++ b/internal/services/notification_service.go @@ -190,6 +190,9 @@ func (s *NotificationService) UpdatePreferences(userID uint, req *UpdatePreferen if req.WarrantyExpiring != nil { prefs.WarrantyExpiring = *req.WarrantyExpiring } + if req.EmailTaskCompleted != nil { + prefs.EmailTaskCompleted = *req.EmailTaskCompleted + } if err := s.notificationRepo.UpdatePreferences(prefs); err != nil { return nil, err @@ -358,17 +361,21 @@ type NotificationPreferencesResponse struct { TaskAssigned bool `json:"task_assigned"` ResidenceShared bool `json:"residence_shared"` WarrantyExpiring bool `json:"warranty_expiring"` + + // Email preferences + EmailTaskCompleted bool `json:"email_task_completed"` } // 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, + TaskDueSoon: p.TaskDueSoon, + TaskOverdue: p.TaskOverdue, + TaskCompleted: p.TaskCompleted, + TaskAssigned: p.TaskAssigned, + ResidenceShared: p.ResidenceShared, + WarrantyExpiring: p.WarrantyExpiring, + EmailTaskCompleted: p.EmailTaskCompleted, } } @@ -380,6 +387,9 @@ type UpdatePreferencesRequest struct { TaskAssigned *bool `json:"task_assigned"` ResidenceShared *bool `json:"residence_shared"` WarrantyExpiring *bool `json:"warranty_expiring"` + + // Email preferences + EmailTaskCompleted *bool `json:"email_task_completed"` } // DeviceResponse represents a device in API response diff --git a/internal/services/task_service.go b/internal/services/task_service.go index ccac8cb..b649a6e 100644 --- a/internal/services/task_service.go +++ b/internal/services/task_service.go @@ -570,20 +570,25 @@ func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completio } // Send email notification (to everyone INCLUDING the person who completed it) - if s.emailService != nil && user.Email != "" { - go func(u models.User) { - if err := s.emailService.SendTaskCompletedEmail( - u.Email, - u.GetFullName(), - task.Title, - completedByName, - residenceName, - ); err != nil { - log.Error().Err(err).Str("email", u.Email).Uint("task_id", task.ID).Msg("Failed to send task completion email") - } else { - log.Info().Str("email", u.Email).Uint("task_id", task.ID).Msg("Task completion email sent") - } - }(user) + // Check user's email notification preferences first + if s.emailService != nil && user.Email != "" && s.notificationService != nil { + prefs, err := s.notificationService.GetPreferences(user.ID) + if err != nil || (prefs != nil && prefs.EmailTaskCompleted) { + // Send email if we couldn't get prefs (fail-open) or if email notifications are enabled + go func(u models.User) { + if err := s.emailService.SendTaskCompletedEmail( + u.Email, + u.GetFullName(), + task.Title, + completedByName, + residenceName, + ); err != nil { + log.Error().Err(err).Str("email", u.Email).Uint("task_id", task.ID).Msg("Failed to send task completion email") + } else { + log.Info().Str("email", u.Email).Uint("task_id", task.ID).Msg("Task completion email sent") + } + }(user) + } } } }