package services import ( "context" "encoding/json" "errors" "strconv" "gorm.io/gorm" "github.com/treytartt/casera-api/internal/models" "github.com/treytartt/casera-api/internal/push" "github.com/treytartt/casera-api/internal/repositories" ) // Notification-related errors var ( ErrNotificationNotFound = errors.New("notification not found") ErrDeviceNotFound = errors.New("device not found") ErrInvalidPlatform = errors.New("invalid platform, must be 'ios' or 'android'") ) // NotificationService handles notification business logic type NotificationService struct { notificationRepo *repositories.NotificationRepository pushClient *push.Client } // NewNotificationService creates a new notification service func NewNotificationService(notificationRepo *repositories.NotificationRepository, pushClient *push.Client) *NotificationService { return &NotificationService{ notificationRepo: notificationRepo, pushClient: pushClient, } } // === Notifications === // GetNotifications gets notifications for a user func (s *NotificationService) GetNotifications(userID uint, limit, offset int) ([]NotificationResponse, error) { notifications, err := s.notificationRepo.FindByUser(userID, limit, offset) if err != nil { return nil, err } result := make([]NotificationResponse, len(notifications)) for i, n := range notifications { result[i] = NewNotificationResponse(&n) } return result, nil } // GetUnreadCount gets the count of unread notifications func (s *NotificationService) GetUnreadCount(userID uint) (int64, error) { return s.notificationRepo.CountUnread(userID) } // MarkAsRead marks a notification as read func (s *NotificationService) MarkAsRead(notificationID, userID uint) error { notification, err := s.notificationRepo.FindByID(notificationID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrNotificationNotFound } return err } if notification.UserID != userID { return ErrNotificationNotFound } return s.notificationRepo.MarkAsRead(notificationID) } // MarkAllAsRead marks all notifications as read func (s *NotificationService) MarkAllAsRead(userID uint) error { return s.notificationRepo.MarkAllAsRead(userID) } // CreateAndSendNotification creates a notification and sends it via push func (s *NotificationService) CreateAndSendNotification(ctx context.Context, userID uint, notificationType models.NotificationType, title, body string, data map[string]interface{}) error { // Check user preferences prefs, err := s.notificationRepo.GetOrCreatePreferences(userID) if err != nil { return err } // Check if notification type is enabled if !s.isNotificationEnabled(prefs, notificationType) { return nil // Skip silently } // Create notification record dataJSON, _ := json.Marshal(data) notification := &models.Notification{ UserID: userID, NotificationType: notificationType, Title: title, Body: body, Data: string(dataJSON), } if err := s.notificationRepo.Create(notification); err != nil { return err } // Get device tokens iosTokens, androidTokens, err := s.notificationRepo.GetActiveTokensForUser(userID) if err != nil { return err } // Convert data for push pushData := make(map[string]string) for k, v := range data { switch val := v.(type) { case string: pushData[k] = val default: jsonVal, _ := json.Marshal(val) pushData[k] = string(jsonVal) } } pushData["notification_id"] = strconv.FormatUint(uint64(notification.ID), 10) // Send push notification if s.pushClient != nil { err = s.pushClient.SendToAll(ctx, iosTokens, androidTokens, title, body, pushData) if err != nil { s.notificationRepo.SetError(notification.ID, err.Error()) return err } } return s.notificationRepo.MarkAsSent(notification.ID) } // isNotificationEnabled checks if a notification type is enabled for user func (s *NotificationService) isNotificationEnabled(prefs *models.NotificationPreference, notificationType models.NotificationType) bool { switch notificationType { case models.NotificationTaskDueSoon: return prefs.TaskDueSoon case models.NotificationTaskOverdue: return prefs.TaskOverdue case models.NotificationTaskCompleted: return prefs.TaskCompleted case models.NotificationTaskAssigned: return prefs.TaskAssigned case models.NotificationResidenceShared: return prefs.ResidenceShared case models.NotificationWarrantyExpiring: return prefs.WarrantyExpiring default: return true } } // === Notification Preferences === // GetPreferences gets notification preferences for a user func (s *NotificationService) GetPreferences(userID uint) (*NotificationPreferencesResponse, error) { prefs, err := s.notificationRepo.GetOrCreatePreferences(userID) if err != nil { return nil, err } return NewNotificationPreferencesResponse(prefs), nil } // UpdatePreferences updates notification preferences func (s *NotificationService) UpdatePreferences(userID uint, req *UpdatePreferencesRequest) (*NotificationPreferencesResponse, error) { prefs, err := s.notificationRepo.GetOrCreatePreferences(userID) if err != nil { return nil, err } if req.TaskDueSoon != nil { prefs.TaskDueSoon = *req.TaskDueSoon } if req.TaskOverdue != nil { prefs.TaskOverdue = *req.TaskOverdue } if req.TaskCompleted != nil { prefs.TaskCompleted = *req.TaskCompleted } if req.TaskAssigned != nil { prefs.TaskAssigned = *req.TaskAssigned } if req.ResidenceShared != nil { prefs.ResidenceShared = *req.ResidenceShared } if req.WarrantyExpiring != nil { prefs.WarrantyExpiring = *req.WarrantyExpiring } if req.EmailTaskCompleted != nil { prefs.EmailTaskCompleted = *req.EmailTaskCompleted } // Update notification times (can be set to nil to use system default) // Note: We update if the field is present in the request (including null values) if req.TaskDueSoonHour != nil { prefs.TaskDueSoonHour = req.TaskDueSoonHour } if req.TaskOverdueHour != nil { prefs.TaskOverdueHour = req.TaskOverdueHour } if req.WarrantyExpiringHour != nil { prefs.WarrantyExpiringHour = req.WarrantyExpiringHour } if err := s.notificationRepo.UpdatePreferences(prefs); err != nil { return nil, err } return NewNotificationPreferencesResponse(prefs), nil } // === Device Registration === // RegisterDevice registers a device for push notifications func (s *NotificationService) RegisterDevice(userID uint, req *RegisterDeviceRequest) (*DeviceResponse, error) { switch req.Platform { case push.PlatformIOS: return s.registerAPNSDevice(userID, req) case push.PlatformAndroid: return s.registerGCMDevice(userID, req) default: return nil, ErrInvalidPlatform } } func (s *NotificationService) registerAPNSDevice(userID uint, req *RegisterDeviceRequest) (*DeviceResponse, error) { // Check if device exists existing, err := s.notificationRepo.FindAPNSDeviceByToken(req.RegistrationID) if err == nil { // Update existing device existing.UserID = &userID existing.Active = true existing.Name = req.Name existing.DeviceID = req.DeviceID if err := s.notificationRepo.UpdateAPNSDevice(existing); err != nil { return nil, err } return NewAPNSDeviceResponse(existing), nil } // Create new device device := &models.APNSDevice{ UserID: &userID, Name: req.Name, DeviceID: req.DeviceID, RegistrationID: req.RegistrationID, Active: true, } if err := s.notificationRepo.CreateAPNSDevice(device); err != nil { return nil, err } return NewAPNSDeviceResponse(device), nil } func (s *NotificationService) registerGCMDevice(userID uint, req *RegisterDeviceRequest) (*DeviceResponse, error) { // Check if device exists existing, err := s.notificationRepo.FindGCMDeviceByToken(req.RegistrationID) if err == nil { // Update existing device existing.UserID = &userID existing.Active = true existing.Name = req.Name existing.DeviceID = req.DeviceID if err := s.notificationRepo.UpdateGCMDevice(existing); err != nil { return nil, err } return NewGCMDeviceResponse(existing), nil } // Create new device device := &models.GCMDevice{ UserID: &userID, Name: req.Name, DeviceID: req.DeviceID, RegistrationID: req.RegistrationID, CloudMessageType: "FCM", Active: true, } if err := s.notificationRepo.CreateGCMDevice(device); err != nil { return nil, err } return NewGCMDeviceResponse(device), nil } // ListDevices lists all devices for a user func (s *NotificationService) ListDevices(userID uint) ([]DeviceResponse, error) { iosDevices, err := s.notificationRepo.FindAPNSDevicesByUser(userID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } androidDevices, err := s.notificationRepo.FindGCMDevicesByUser(userID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } result := make([]DeviceResponse, 0, len(iosDevices)+len(androidDevices)) for _, d := range iosDevices { result = append(result, *NewAPNSDeviceResponse(&d)) } for _, d := range androidDevices { result = append(result, *NewGCMDeviceResponse(&d)) } return result, nil } // DeleteDevice deletes a device func (s *NotificationService) DeleteDevice(deviceID uint, platform string, userID uint) error { switch platform { case push.PlatformIOS: return s.notificationRepo.DeactivateAPNSDevice(deviceID) case push.PlatformAndroid: return s.notificationRepo.DeactivateGCMDevice(deviceID) default: return ErrInvalidPlatform } } // === Response/Request Types === // NotificationResponse represents a notification in API response type NotificationResponse struct { ID uint `json:"id"` UserID uint `json:"user_id"` NotificationType models.NotificationType `json:"notification_type"` Title string `json:"title"` Body string `json:"body"` Data map[string]interface{} `json:"data"` Read bool `json:"read"` ReadAt *string `json:"read_at"` Sent bool `json:"sent"` SentAt *string `json:"sent_at"` CreatedAt string `json:"created_at"` } // NewNotificationResponse creates a NotificationResponse func NewNotificationResponse(n *models.Notification) NotificationResponse { resp := NotificationResponse{ ID: n.ID, UserID: n.UserID, NotificationType: n.NotificationType, Title: n.Title, Body: n.Body, Read: n.Read, Sent: n.Sent, CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"), } if n.Data != "" { json.Unmarshal([]byte(n.Data), &resp.Data) } if n.ReadAt != nil { t := n.ReadAt.Format("2006-01-02T15:04:05Z") resp.ReadAt = &t } if n.SentAt != nil { t := n.SentAt.Format("2006-01-02T15:04:05Z") resp.SentAt = &t } return resp } // NotificationPreferencesResponse represents notification preferences type NotificationPreferencesResponse struct { TaskDueSoon bool `json:"task_due_soon"` TaskOverdue bool `json:"task_overdue"` TaskCompleted bool `json:"task_completed"` TaskAssigned bool `json:"task_assigned"` ResidenceShared bool `json:"residence_shared"` WarrantyExpiring bool `json:"warranty_expiring"` // 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"` } // 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, WarrantyExpiringHour: p.WarrantyExpiringHour, } } // UpdatePreferencesRequest represents preferences update request type UpdatePreferencesRequest struct { TaskDueSoon *bool `json:"task_due_soon"` TaskOverdue *bool `json:"task_overdue"` TaskCompleted *bool `json:"task_completed"` TaskAssigned *bool `json:"task_assigned"` ResidenceShared *bool `json:"residence_shared"` WarrantyExpiring *bool `json:"warranty_expiring"` // Email preferences EmailTaskCompleted *bool `json:"email_task_completed"` // Custom notification times (UTC hour 0-23) // Use a special wrapper to differentiate between "not sent" and "set to null" TaskDueSoonHour *int `json:"task_due_soon_hour"` TaskOverdueHour *int `json:"task_overdue_hour"` WarrantyExpiringHour *int `json:"warranty_expiring_hour"` } // DeviceResponse represents a device in API response type DeviceResponse struct { ID uint `json:"id"` Name string `json:"name"` DeviceID string `json:"device_id"` RegistrationID string `json:"registration_id"` Platform string `json:"platform"` Active bool `json:"active"` DateCreated string `json:"date_created"` } // NewAPNSDeviceResponse creates a DeviceResponse from APNS device func NewAPNSDeviceResponse(d *models.APNSDevice) *DeviceResponse { return &DeviceResponse{ ID: d.ID, Name: d.Name, DeviceID: d.DeviceID, RegistrationID: d.RegistrationID, Platform: push.PlatformIOS, Active: d.Active, DateCreated: d.DateCreated.Format("2006-01-02T15:04:05Z"), } } // NewGCMDeviceResponse creates a DeviceResponse from GCM device func NewGCMDeviceResponse(d *models.GCMDevice) *DeviceResponse { return &DeviceResponse{ ID: d.ID, Name: d.Name, DeviceID: d.DeviceID, RegistrationID: d.RegistrationID, Platform: push.PlatformAndroid, Active: d.Active, DateCreated: d.DateCreated.Format("2006-01-02T15:04:05Z"), } } // RegisterDeviceRequest represents device registration request type RegisterDeviceRequest struct { Name string `json:"name"` DeviceID string `json:"device_id" binding:"required"` RegistrationID string `json:"registration_id" binding:"required"` Platform string `json:"platform" binding:"required,oneof=ios android"` } // === Task Notifications with Actions === // CreateAndSendTaskNotification creates and sends a task notification with actionable buttons // The backend always sends full notification data - the client decides how to display // based on its locally cached subscription status func (s *NotificationService) CreateAndSendTaskNotification( ctx context.Context, userID uint, notificationType models.NotificationType, task *models.Task, ) error { // Check user notification preferences prefs, err := s.notificationRepo.GetOrCreatePreferences(userID) if err != nil { return err } if !s.isNotificationEnabled(prefs, notificationType) { return nil // Skip silently } // Build notification content - always send full data title := GetTaskNotificationTitle(notificationType) body := task.Title // Get button types and iOS category based on task state buttonTypes := GetButtonTypesForTask(task, 30) // 30 days threshold iosCategoryID := GetIOSCategoryForTask(task) // Build data payload - always includes full task info // Client decides what to display based on local subscription status data := map[string]interface{}{ "task_id": task.ID, "task_name": task.Title, "residence_id": task.ResidenceID, "type": string(notificationType), "button_types": buttonTypes, "ios_category": iosCategoryID, } // Create notification record dataJSON, _ := json.Marshal(data) notification := &models.Notification{ UserID: userID, NotificationType: notificationType, Title: title, Body: body, Data: string(dataJSON), TaskID: &task.ID, } if err := s.notificationRepo.Create(notification); err != nil { return err } // Get device tokens iosTokens, androidTokens, err := s.notificationRepo.GetActiveTokensForUser(userID) if err != nil { return err } // Convert data for push payload pushData := make(map[string]string) for k, v := range data { switch val := v.(type) { case string: pushData[k] = val case uint: pushData[k] = strconv.FormatUint(uint64(val), 10) default: jsonVal, _ := json.Marshal(val) pushData[k] = string(jsonVal) } } pushData["notification_id"] = strconv.FormatUint(uint64(notification.ID), 10) // Send push notification with actionable support if s.pushClient != nil { err = s.pushClient.SendActionableNotification(ctx, iosTokens, androidTokens, title, body, pushData, iosCategoryID) if err != nil { s.notificationRepo.SetError(notification.ID, err.Error()) return err } } return s.notificationRepo.MarkAsSent(notification.ID) }