e881d37de0
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>
704 lines
24 KiB
Go
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
|
|
}
|