Add email notification preference for task completion

- 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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-04 20:04:24 -06:00
parent 0825fd9a12
commit 5a6fbe0a36
7 changed files with 125 additions and 78 deletions

View File

@@ -156,6 +156,16 @@ export default function NotificationPrefsPage() {
/> />
), ),
}, },
{
accessorKey: 'email_task_completed',
header: 'Email: Completed',
cell: ({ row }) => (
<Switch
checked={row.original.email_task_completed}
onCheckedChange={() => handleToggle(row.original.id, 'email_task_completed', row.original.email_task_completed)}
/>
),
},
{ {
id: 'actions', id: 'actions',
cell: ({ row }) => { cell: ({ row }) => {
@@ -194,7 +204,7 @@ export default function NotificationPrefsPage() {
<div> <div>
<h1 className="text-2xl font-bold">Notification Preferences</h1> <h1 className="text-2xl font-bold">Notification Preferences</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Manage user notification preferences for push notifications Manage user notification preferences for push notifications and emails
</p> </p>
</div> </div>
</div> </div>

View File

@@ -597,6 +597,8 @@ export interface NotificationPreference {
task_assigned: boolean; task_assigned: boolean;
residence_shared: boolean; residence_shared: boolean;
warranty_expiring: boolean; warranty_expiring: boolean;
// Email preferences
email_task_completed: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -616,6 +618,8 @@ export interface UpdateNotificationPrefRequest {
task_assigned?: boolean; task_assigned?: boolean;
residence_shared?: boolean; residence_shared?: boolean;
warranty_expiring?: boolean; warranty_expiring?: boolean;
// Email preferences
email_task_completed?: boolean;
} }
// Notification Preferences API // Notification Preferences API

View File

@@ -33,8 +33,12 @@ 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"`
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 // List handles GET /api/admin/notification-prefs
@@ -98,18 +102,19 @@ func (h *AdminNotificationPrefsHandler) List(c *gin.Context) {
for i, pref := range prefs { for i, pref := range prefs {
user := userMap[pref.UserID] user := userMap[pref.UserID]
responses[i] = NotificationPrefResponse{ responses[i] = NotificationPrefResponse{
ID: pref.ID, ID: pref.ID,
UserID: pref.UserID, UserID: pref.UserID,
Username: user.Username, Username: user.Username,
Email: user.Email, Email: user.Email,
TaskDueSoon: pref.TaskDueSoon, TaskDueSoon: pref.TaskDueSoon,
TaskOverdue: pref.TaskOverdue, TaskOverdue: pref.TaskOverdue,
TaskCompleted: pref.TaskCompleted, TaskCompleted: pref.TaskCompleted,
TaskAssigned: pref.TaskAssigned, TaskAssigned: pref.TaskAssigned,
ResidenceShared: pref.ResidenceShared, ResidenceShared: pref.ResidenceShared,
WarrantyExpiring: pref.WarrantyExpiring, WarrantyExpiring: pref.WarrantyExpiring,
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"), EmailTaskCompleted: pref.EmailTaskCompleted,
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"), 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) h.db.First(&user, pref.UserID)
c.JSON(http.StatusOK, NotificationPrefResponse{ c.JSON(http.StatusOK, NotificationPrefResponse{
ID: pref.ID, ID: pref.ID,
UserID: pref.UserID, UserID: pref.UserID,
Username: user.Username, Username: user.Username,
Email: user.Email, Email: user.Email,
TaskDueSoon: pref.TaskDueSoon, TaskDueSoon: pref.TaskDueSoon,
TaskOverdue: pref.TaskOverdue, TaskOverdue: pref.TaskOverdue,
TaskCompleted: pref.TaskCompleted, TaskCompleted: pref.TaskCompleted,
TaskAssigned: pref.TaskAssigned, TaskAssigned: pref.TaskAssigned,
ResidenceShared: pref.ResidenceShared, ResidenceShared: pref.ResidenceShared,
WarrantyExpiring: pref.WarrantyExpiring, WarrantyExpiring: pref.WarrantyExpiring,
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"), EmailTaskCompleted: pref.EmailTaskCompleted,
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"), 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"` 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"`
// Email preferences
EmailTaskCompleted *bool `json:"email_task_completed"`
} }
// Update handles PUT /api/admin/notification-prefs/:id // Update handles PUT /api/admin/notification-prefs/:id
@@ -206,6 +215,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.EmailTaskCompleted != nil {
pref.EmailTaskCompleted = *req.EmailTaskCompleted
}
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"})
@@ -216,18 +228,19 @@ func (h *AdminNotificationPrefsHandler) Update(c *gin.Context) {
h.db.First(&user, pref.UserID) h.db.First(&user, pref.UserID)
c.JSON(http.StatusOK, NotificationPrefResponse{ c.JSON(http.StatusOK, NotificationPrefResponse{
ID: pref.ID, ID: pref.ID,
UserID: pref.UserID, UserID: pref.UserID,
Username: user.Username, Username: user.Username,
Email: user.Email, Email: user.Email,
TaskDueSoon: pref.TaskDueSoon, TaskDueSoon: pref.TaskDueSoon,
TaskOverdue: pref.TaskOverdue, TaskOverdue: pref.TaskOverdue,
TaskCompleted: pref.TaskCompleted, TaskCompleted: pref.TaskCompleted,
TaskAssigned: pref.TaskAssigned, TaskAssigned: pref.TaskAssigned,
ResidenceShared: pref.ResidenceShared, ResidenceShared: pref.ResidenceShared,
WarrantyExpiring: pref.WarrantyExpiring, WarrantyExpiring: pref.WarrantyExpiring,
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"), EmailTaskCompleted: pref.EmailTaskCompleted,
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"), 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) h.db.First(&user, pref.UserID)
c.JSON(http.StatusOK, NotificationPrefResponse{ c.JSON(http.StatusOK, NotificationPrefResponse{
ID: pref.ID, ID: pref.ID,
UserID: pref.UserID, UserID: pref.UserID,
Username: user.Username, Username: user.Username,
Email: user.Email, Email: user.Email,
TaskDueSoon: pref.TaskDueSoon, TaskDueSoon: pref.TaskDueSoon,
TaskOverdue: pref.TaskOverdue, TaskOverdue: pref.TaskOverdue,
TaskCompleted: pref.TaskCompleted, TaskCompleted: pref.TaskCompleted,
TaskAssigned: pref.TaskAssigned, TaskAssigned: pref.TaskAssigned,
ResidenceShared: pref.ResidenceShared, ResidenceShared: pref.ResidenceShared,
WarrantyExpiring: pref.WarrantyExpiring, WarrantyExpiring: pref.WarrantyExpiring,
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"), EmailTaskCompleted: pref.EmailTaskCompleted,
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"), CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}) })
} }

View File

@@ -20,6 +20,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"`
// Email preferences
EmailTaskCompleted bool `gorm:"column:email_task_completed;default:true" json:"email_task_completed"`
} }
// TableName returns the table name for GORM // TableName returns the table name for GORM

View File

@@ -142,13 +142,14 @@ func (r *NotificationRepository) GetOrCreatePreferences(userID uint) (*models.No
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
prefs = &models.NotificationPreference{ prefs = &models.NotificationPreference{
UserID: userID, UserID: userID,
TaskDueSoon: true, TaskDueSoon: true,
TaskOverdue: true, TaskOverdue: true,
TaskCompleted: true, TaskCompleted: true,
TaskAssigned: true, TaskAssigned: true,
ResidenceShared: true, ResidenceShared: true,
WarrantyExpiring: true, WarrantyExpiring: true,
EmailTaskCompleted: true,
} }
if err := r.CreatePreferences(prefs); err != nil { if err := r.CreatePreferences(prefs); err != nil {
return nil, err return nil, err

View File

@@ -190,6 +190,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.EmailTaskCompleted != nil {
prefs.EmailTaskCompleted = *req.EmailTaskCompleted
}
if err := s.notificationRepo.UpdatePreferences(prefs); err != nil { if err := s.notificationRepo.UpdatePreferences(prefs); err != nil {
return nil, err return nil, err
@@ -358,17 +361,21 @@ 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"`
// Email preferences
EmailTaskCompleted bool `json:"email_task_completed"`
} }
// NewNotificationPreferencesResponse creates a NotificationPreferencesResponse // NewNotificationPreferencesResponse creates a NotificationPreferencesResponse
func NewNotificationPreferencesResponse(p *models.NotificationPreference) *NotificationPreferencesResponse { func NewNotificationPreferencesResponse(p *models.NotificationPreference) *NotificationPreferencesResponse {
return &NotificationPreferencesResponse{ return &NotificationPreferencesResponse{
TaskDueSoon: p.TaskDueSoon, TaskDueSoon: p.TaskDueSoon,
TaskOverdue: p.TaskOverdue, TaskOverdue: p.TaskOverdue,
TaskCompleted: p.TaskCompleted, TaskCompleted: p.TaskCompleted,
TaskAssigned: p.TaskAssigned, TaskAssigned: p.TaskAssigned,
ResidenceShared: p.ResidenceShared, ResidenceShared: p.ResidenceShared,
WarrantyExpiring: p.WarrantyExpiring, WarrantyExpiring: p.WarrantyExpiring,
EmailTaskCompleted: p.EmailTaskCompleted,
} }
} }
@@ -380,6 +387,9 @@ 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"`
// Email preferences
EmailTaskCompleted *bool `json:"email_task_completed"`
} }
// DeviceResponse represents a device in API response // DeviceResponse represents a device in API response

View File

@@ -570,20 +570,25 @@ func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completio
} }
// Send email notification (to everyone INCLUDING the person who completed it) // Send email notification (to everyone INCLUDING the person who completed it)
if s.emailService != nil && user.Email != "" { // Check user's email notification preferences first
go func(u models.User) { if s.emailService != nil && user.Email != "" && s.notificationService != nil {
if err := s.emailService.SendTaskCompletedEmail( prefs, err := s.notificationService.GetPreferences(user.ID)
u.Email, if err != nil || (prefs != nil && prefs.EmailTaskCompleted) {
u.GetFullName(), // Send email if we couldn't get prefs (fail-open) or if email notifications are enabled
task.Title, go func(u models.User) {
completedByName, if err := s.emailService.SendTaskCompletedEmail(
residenceName, u.Email,
); err != nil { u.GetFullName(),
log.Error().Err(err).Str("email", u.Email).Uint("task_id", task.ID).Msg("Failed to send task completion email") task.Title,
} else { completedByName,
log.Info().Str("email", u.Email).Uint("task_id", task.ID).Msg("Task completion email sent") residenceName,
} ); err != nil {
}(user) 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)
}
} }
} }
} }