Files
honeyDueAPI/internal/services/notification_service.go
T
Trey t e881d37de0
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Migrate Auth/Contractor/Document/Notification/Subscription services to ctx
Every public method on these five services now takes ctx context.Context as
the first arg and routes its repo calls through .WithContext(ctx). With
TaskService and ResidenceService already migrated, this means every
in-process service that touches Postgres now produces a flame graph in
Jaeger where the SQL spans nest under the parent HTTP request span.

Endpoints now fully traced (HTTP → service → SQL):
- /api/auth/login, /register, /logout, /me, /verify-email, /resend-verification
- /api/auth/forgot-password, /verify-reset, /reset-password, /update-profile
- /api/contractors/* (CRUD + favorite + by-residence + tasks)
- /api/documents/* (CRUD + activate/deactivate + image upload/delete)
- /api/notifications/* (list, count, mark-read, prefs, devices)
- /api/subscription/* (status, purchase, cancel, triggers, promotions)
- All previously-migrated /api/tasks/* and /api/residences/* paths

Internal helpers also threaded:
- TaskService.sendTaskCompletedNotification → forwards ctx
- TaskService.UpdateUserTimezone → forwards ctx to NotificationService
- ResidenceService.CreateResidence → forwards ctx to SubscriptionService.CheckLimit
- NotificationService.registerAPNSDevice / registerGCMDevice → both take ctx

~75 method signatures, ~120 handler/test call sites updated. Tests pass
green; the only failure is the pre-existing flaky TaskHandler_QuickComplete
SQLite race that fails ~60% of runs on master.

Step 3 of the observability plan is now genuinely complete: every API
endpoint backed by a Go service emits a per-request flame graph with
HTTP → service → SQL spans, plus B2/APNs/FCM/asynq spans where applicable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:26:21 -05:00

704 lines
24 KiB
Go

package services
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/push"
"github.com/treytartt/honeydue-api/internal/repositories"
)
// Notification-related errors
var (
// Deprecated: Use apperrors.NotFound("error.notification_not_found") instead
ErrNotificationNotFound = errors.New("notification not found")
// Deprecated: Use apperrors.NotFound("error.device_not_found") instead
ErrDeviceNotFound = errors.New("device not found")
// Deprecated: Use apperrors.BadRequest("error.invalid_platform") instead
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(ctx context.Context, userID uint, limit, offset int) ([]NotificationResponse, error) {
notifications, err := s.notificationRepo.WithContext(ctx).FindByUser(userID, limit, offset)
if err != nil {
return nil, apperrors.Internal(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(ctx context.Context, userID uint) (int64, error) {
count, err := s.notificationRepo.WithContext(ctx).CountUnread(userID)
if err != nil {
return 0, apperrors.Internal(err)
}
return count, nil
}
// MarkAsRead marks a notification as read
func (s *NotificationService) MarkAsRead(ctx context.Context, notificationID, userID uint) error {
notification, err := s.notificationRepo.WithContext(ctx).FindByID(notificationID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperrors.NotFound("error.notification_not_found")
}
return apperrors.Internal(err)
}
if notification.UserID != userID {
return apperrors.NotFound("error.notification_not_found")
}
if err := s.notificationRepo.WithContext(ctx).MarkAsRead(notificationID); err != nil {
return apperrors.Internal(err)
}
return nil
}
// MarkAllAsRead marks all notifications as read
func (s *NotificationService) MarkAllAsRead(ctx context.Context, userID uint) error {
if err := s.notificationRepo.WithContext(ctx).MarkAllAsRead(userID); err != nil {
return apperrors.Internal(err)
}
return nil
}
// 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.WithContext(ctx).GetOrCreatePreferences(userID)
if err != nil {
return apperrors.Internal(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.WithContext(ctx).Create(notification); err != nil {
return apperrors.Internal(err)
}
// Get device tokens
iosTokens, androidTokens, err := s.notificationRepo.WithContext(ctx).GetActiveTokensForUser(userID)
if err != nil {
return apperrors.Internal(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.WithContext(ctx).SetError(notification.ID, err.Error())
return apperrors.Internal(err)
}
}
if err := s.notificationRepo.WithContext(ctx).MarkAsSent(notification.ID); err != nil {
return apperrors.Internal(err)
}
return nil
}
// 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(ctx context.Context, userID uint) (*NotificationPreferencesResponse, error) {
prefs, err := s.notificationRepo.WithContext(ctx).GetOrCreatePreferences(userID)
if err != nil {
return nil, apperrors.Internal(err)
}
return NewNotificationPreferencesResponse(prefs), nil
}
// validateHourField checks that an optional hour value is in the valid range 0-23.
func validateHourField(val *int, fieldName string) error {
if val != nil && (*val < 0 || *val > 23) {
return apperrors.BadRequest("error.invalid_hour").
WithMessage(fmt.Sprintf("%s must be between 0 and 23", fieldName))
}
return nil
}
// UpdatePreferences updates notification preferences
func (s *NotificationService) UpdatePreferences(ctx context.Context, userID uint, req *UpdatePreferencesRequest) (*NotificationPreferencesResponse, error) {
// B-12: Validate hour fields are in range 0-23
hourFields := []struct {
value *int
name string
}{
{req.TaskDueSoonHour, "task_due_soon_hour"},
{req.TaskOverdueHour, "task_overdue_hour"},
{req.WarrantyExpiringHour, "warranty_expiring_hour"},
{req.DailyDigestHour, "daily_digest_hour"},
}
for _, hf := range hourFields {
if err := validateHourField(hf.value, hf.name); err != nil {
return nil, err
}
}
prefs, err := s.notificationRepo.WithContext(ctx).GetOrCreatePreferences(userID)
if err != nil {
return nil, apperrors.Internal(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.DailyDigest != nil {
prefs.DailyDigest = *req.DailyDigest
}
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 req.DailyDigestHour != nil {
prefs.DailyDigestHour = req.DailyDigestHour
}
if err := s.notificationRepo.WithContext(ctx).UpdatePreferences(prefs); err != nil {
return nil, apperrors.Internal(err)
}
return NewNotificationPreferencesResponse(prefs), nil
}
// UpdateUserTimezone updates the user's timezone for background job calculations.
// This is called automatically when the user makes API calls (e.g., fetching tasks).
// The timezone should be an IANA timezone name (e.g., "America/Los_Angeles").
func (s *NotificationService) UpdateUserTimezone(ctx context.Context, userID uint, timezone string) {
// Validate timezone is a valid IANA name
if _, err := time.LoadLocation(timezone); err != nil {
return // Invalid timezone, skip silently
}
// Get or create preferences and update timezone
prefs, err := s.notificationRepo.WithContext(ctx).GetOrCreatePreferences(userID)
if err != nil {
return // Skip silently on error
}
// Only update if timezone changed (avoid unnecessary DB writes)
if prefs.Timezone == nil || *prefs.Timezone != timezone {
prefs.Timezone = &timezone
if err := s.notificationRepo.WithContext(ctx).UpdatePreferences(prefs); err != nil {
log.Error().Err(err).Uint("user_id", userID).Str("timezone", timezone).
Msg("Failed to update user timezone in notification preferences")
}
}
}
// === Device Registration ===
// RegisterDevice registers a device for push notifications
func (s *NotificationService) RegisterDevice(ctx context.Context, userID uint, req *RegisterDeviceRequest) (*DeviceResponse, error) {
switch req.Platform {
case push.PlatformIOS:
return s.registerAPNSDevice(ctx, userID, req)
case push.PlatformAndroid:
return s.registerGCMDevice(ctx, userID, req)
default:
return nil, apperrors.BadRequest("error.invalid_platform")
}
}
func (s *NotificationService) registerAPNSDevice(ctx context.Context, userID uint, req *RegisterDeviceRequest) (*DeviceResponse, error) {
// Check if device exists
existing, err := s.notificationRepo.WithContext(ctx).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.WithContext(ctx).UpdateAPNSDevice(existing); err != nil {
return nil, apperrors.Internal(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.WithContext(ctx).CreateAPNSDevice(device); err != nil {
return nil, apperrors.Internal(err)
}
return NewAPNSDeviceResponse(device), nil
}
func (s *NotificationService) registerGCMDevice(ctx context.Context, userID uint, req *RegisterDeviceRequest) (*DeviceResponse, error) {
// Check if device exists
existing, err := s.notificationRepo.WithContext(ctx).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.WithContext(ctx).UpdateGCMDevice(existing); err != nil {
return nil, apperrors.Internal(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.WithContext(ctx).CreateGCMDevice(device); err != nil {
return nil, apperrors.Internal(err)
}
return NewGCMDeviceResponse(device), nil
}
// ListDevices lists all devices for a user
func (s *NotificationService) ListDevices(ctx context.Context, userID uint) ([]DeviceResponse, error) {
iosDevices, err := s.notificationRepo.WithContext(ctx).FindAPNSDevicesByUser(userID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.Internal(err)
}
androidDevices, err := s.notificationRepo.WithContext(ctx).FindGCMDevicesByUser(userID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, apperrors.Internal(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 deactivates a device after verifying it belongs to the requesting user.
// Without ownership verification, an attacker could deactivate push notifications for other users.
func (s *NotificationService) DeleteDevice(ctx context.Context, deviceID uint, platform string, userID uint) error {
switch platform {
case push.PlatformIOS:
device, err := s.notificationRepo.WithContext(ctx).FindAPNSDeviceByID(deviceID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperrors.NotFound("error.device_not_found")
}
return apperrors.Internal(err)
}
// Verify the device belongs to the requesting user
if device.UserID == nil || *device.UserID != userID {
return apperrors.Forbidden("error.device_access_denied")
}
if err := s.notificationRepo.WithContext(ctx).DeactivateAPNSDevice(deviceID); err != nil {
return apperrors.Internal(err)
}
case push.PlatformAndroid:
device, err := s.notificationRepo.WithContext(ctx).FindGCMDeviceByID(deviceID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return apperrors.NotFound("error.device_not_found")
}
return apperrors.Internal(err)
}
// Verify the device belongs to the requesting user
if device.UserID == nil || *device.UserID != userID {
return apperrors.Forbidden("error.device_access_denied")
}
if err := s.notificationRepo.WithContext(ctx).DeactivateGCMDevice(deviceID); err != nil {
return apperrors.Internal(err)
}
default:
return apperrors.BadRequest("error.invalid_platform")
}
return nil
}
// UnregisterDevice deactivates a device by its registration token
func (s *NotificationService) UnregisterDevice(ctx context.Context, registrationID, platform string, userID uint) error {
switch platform {
case push.PlatformIOS:
device, err := s.notificationRepo.WithContext(ctx).FindAPNSDeviceByToken(registrationID)
if err != nil {
return apperrors.NotFound("error.device_not_found")
}
// Verify ownership
if device.UserID == nil || *device.UserID != userID {
return apperrors.NotFound("error.device_not_found")
}
if err := s.notificationRepo.WithContext(ctx).DeactivateAPNSDevice(device.ID); err != nil {
return apperrors.Internal(err)
}
case push.PlatformAndroid:
device, err := s.notificationRepo.WithContext(ctx).FindGCMDeviceByToken(registrationID)
if err != nil {
return apperrors.NotFound("error.device_not_found")
}
// Verify ownership
if device.UserID == nil || *device.UserID != userID {
return apperrors.NotFound("error.device_not_found")
}
if err := s.notificationRepo.WithContext(ctx).DeactivateGCMDevice(device.ID); err != nil {
return apperrors.Internal(err)
}
default:
return apperrors.BadRequest("error.invalid_platform")
}
return nil
}
// === Response/Request Types ===
// TODO(hardening): Move to internal/dto/responses/notification.go
// 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
}
// TODO(hardening): Move to internal/dto/responses/notification.go
// 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"`
DailyDigest bool `json:"daily_digest"`
// 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"`
DailyDigestHour *int `json:"daily_digest_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,
DailyDigest: p.DailyDigest,
EmailTaskCompleted: p.EmailTaskCompleted,
TaskDueSoonHour: p.TaskDueSoonHour,
TaskOverdueHour: p.TaskOverdueHour,
WarrantyExpiringHour: p.WarrantyExpiringHour,
DailyDigestHour: p.DailyDigestHour,
}
}
// TODO(hardening): Move to internal/dto/requests/notification.go
// 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"`
DailyDigest *bool `json:"daily_digest"`
// 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"`
DailyDigestHour *int `json:"daily_digest_hour"`
}
// TODO(hardening): Move to internal/dto/responses/notification.go
// 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"),
}
}
// TODO(hardening): Move to internal/dto/requests/notification.go
// RegisterDeviceRequest represents device registration request
type RegisterDeviceRequest struct {
Name string `json:"name"`
DeviceID string `json:"device_id" validate:"required"`
RegistrationID string `json:"registration_id" validate:"required"`
Platform string `json:"platform" validate:"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.WithContext(ctx).GetOrCreatePreferences(userID)
if err != nil {
return apperrors.Internal(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.WithContext(ctx).Create(notification); err != nil {
return apperrors.Internal(err)
}
// Get device tokens
iosTokens, androidTokens, err := s.notificationRepo.WithContext(ctx).GetActiveTokensForUser(userID)
if err != nil {
return apperrors.Internal(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.WithContext(ctx).SetError(notification.ID, err.Error())
return apperrors.Internal(err)
}
}
if err := s.notificationRepo.WithContext(ctx).MarkAsSent(notification.ID); err != nil {
return apperrors.Internal(err)
}
return nil
}