Security: - Replace all binding: tags with validate: + c.Validate() in admin handlers - Add rate limiting to auth endpoints (login, register, password reset) - Add security headers (HSTS, XSS protection, nosniff, frame options) - Wire Google Pub/Sub token verification into webhook handler - Replace ParseUnverified with proper OIDC/JWKS key verification - Verify inner Apple JWS signatures in webhook handler - Add io.LimitReader (1MB) to all webhook body reads - Add ownership verification to file deletion - Move hardcoded admin credentials to env vars - Add uniqueIndex to User.Email - Hide ConfirmationCode from JSON serialization - Mask confirmation codes in admin responses - Use http.DetectContentType for upload validation - Fix path traversal in storage service - Replace os.Getenv with Viper in stripe service - Sanitize Redis URLs before logging - Separate DEBUG_FIXED_CODES from DEBUG flag - Reject weak SECRET_KEY in production - Add host check on /_next/* proxy routes - Use explicit localhost CORS origins in debug mode - Replace err.Error() with generic messages in all admin error responses Critical fixes: - Rewrite FCM to HTTP v1 API with OAuth 2.0 service account auth - Fix user_customuser -> auth_user table names in raw SQL - Fix dashboard verified query to use UserProfile model - Add escapeLikeWildcards() to prevent SQL wildcard injection Bug fixes: - Add bounds checks for days/expiring_soon query params (1-3650) - Add receipt_data/transaction_id empty-check to RestoreSubscription - Change Active bool -> *bool in device handler - Check all unchecked GORM/FindByIDWithProfile errors - Add validation for notification hour fields (0-23) - Add max=10000 validation on task description updates Transactions & data integrity: - Wrap registration flow in transaction - Wrap QuickComplete in transaction - Move image creation inside completion transaction - Wrap SetSpecialties in transaction - Wrap GetOrCreateToken in transaction - Wrap completion+image deletion in transaction Performance: - Batch completion summaries (2 queries vs 2N) - Reuse single http.Client in IAP validation - Cache dashboard counts (30s TTL) - Batch COUNT queries in admin user list - Add Limit(500) to document queries - Add reminder_stage+due_date filters to reminder queries - Parse AllowedTypes once at init - In-memory user cache in auth middleware (30s TTL) - Timezone change detection cache - Optimize P95 with per-endpoint sorted buffers - Replace crypto/md5 with hash/fnv for ETags Code quality: - Add sync.Once to all monitoring Stop()/Close() methods - Replace 8 fmt.Printf with zerolog in auth service - Log previously discarded errors - Standardize delete response shapes - Route hardcoded English through i18n - Remove FileURL from DocumentResponse (keep MediaURL only) - Thread user timezone through kanban board responses - Initialize empty slices to prevent null JSON - Extract shared field map for task Update/UpdateTx - Delete unused SoftDeleteModel, min(), formatCron, legacy handlers Worker & jobs: - Wire Asynq email infrastructure into worker - Register HandleReminderLogCleanup with daily 3AM cron - Use per-user timezone in HandleSmartReminder - Replace direct DB queries with repository calls - Delete legacy reminder handlers (~200 lines) - Delete unused task type constants Dependencies: - Replace archived jung-kurt/gofpdf with go-pdf/fpdf - Replace unmaintained gomail.v2 with wneessen/go-mail - Add TODO for Echo jwt v3 transitive dep removal Test infrastructure: - Fix MakeRequest/SeedLookupData error handling - Replace os.Exit(0) with t.Skip() in scope/consistency tests - Add 11 new FCM v1 tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
704 lines
23 KiB
Go
704 lines
23 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(userID uint, limit, offset int) ([]NotificationResponse, error) {
|
|
notifications, err := s.notificationRepo.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(userID uint) (int64, error) {
|
|
count, err := s.notificationRepo.CountUnread(userID)
|
|
if err != nil {
|
|
return 0, apperrors.Internal(err)
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// 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 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.MarkAsRead(notificationID); err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MarkAllAsRead marks all notifications as read
|
|
func (s *NotificationService) MarkAllAsRead(userID uint) error {
|
|
if err := s.notificationRepo.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.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.Create(notification); err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
// Get device tokens
|
|
iosTokens, androidTokens, err := s.notificationRepo.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.SetError(notification.ID, err.Error())
|
|
return apperrors.Internal(err)
|
|
}
|
|
}
|
|
|
|
if err := s.notificationRepo.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(userID uint) (*NotificationPreferencesResponse, error) {
|
|
prefs, err := s.notificationRepo.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(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.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.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(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.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.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(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, apperrors.BadRequest("error.invalid_platform")
|
|
}
|
|
}
|
|
|
|
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, 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.CreateAPNSDevice(device); err != nil {
|
|
return nil, apperrors.Internal(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, 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.CreateGCMDevice(device); err != nil {
|
|
return nil, apperrors.Internal(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, apperrors.Internal(err)
|
|
}
|
|
|
|
androidDevices, err := s.notificationRepo.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(deviceID uint, platform string, userID uint) error {
|
|
switch platform {
|
|
case push.PlatformIOS:
|
|
device, err := s.notificationRepo.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.DeactivateAPNSDevice(deviceID); err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
case push.PlatformAndroid:
|
|
device, err := s.notificationRepo.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.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(registrationID, platform string, userID uint) error {
|
|
switch platform {
|
|
case push.PlatformIOS:
|
|
device, err := s.notificationRepo.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.DeactivateAPNSDevice(device.ID); err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
case push.PlatformAndroid:
|
|
device, err := s.notificationRepo.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.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.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.Create(notification); err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
|
|
// Get device tokens
|
|
iosTokens, androidTokens, err := s.notificationRepo.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.SetError(notification.ID, err.Error())
|
|
return apperrors.Internal(err)
|
|
}
|
|
}
|
|
|
|
if err := s.notificationRepo.MarkAsSent(notification.ID); err != nil {
|
|
return apperrors.Internal(err)
|
|
}
|
|
return nil
|
|
}
|