Files
honeyDueAPI/internal/services/notification_service.go
T
Trey t c77ff07ce9
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
fix(security): remediate 2026-05-12 audit findings (Stages 2–5)
Remediation of the 2026-05-12/13 audits (78 findings + cluster gaps),
tracked in deploy-k3s/SECURITY.md, plus fixes from two independent
post-remediation reviews.

Auth & sessions:
- SHA-256 hashed auth-token storage (C1); prior-token cache eviction on
  re-login (MEDIUM-1)
- local Google JWKS verification, iss/aud/exp checks (C2/C3)
- constant-time login + generic errors (L1/LIVE-L11/LIVE-L13)
- per-account login lockout keyed on distinct source IPs (M5/MEDIUM-3)
- verified-email gating, login rate limiting (LIVE-L19, H1-H3)

IAP & webhooks:
- Apple/Google cross-account replay protection (C5/C6/C10/C13, H5/H6)
- migrations 000003-000006 (token hashing, IAP replay, audit_log +
  webhook_event_log table creation, append-only audit log)

Authorization & races:
- file-ownership owner-OR-member fix (C7), atomic share-code join
  (C9/H9), device-token reassignment (C8/LOW-3)

Secrets & deploy:
- secrets file-mounted at /etc/honeydue/secrets, not env (F8); Redis
  password out of the ConfigMap (HIGH-1); B2 keys reconciled
- digest-pinned images, admin ingress hardening, CSP/HSTS, /metrics
  lockdown; kubeconfig 0600, etcd secrets-encryption, fail2ban +
  unattended-upgrades at provision; secret-rotation runbook

Build, vet, and the full test suite (incl. -race) pass; the goose
migration chain is verified against PostgreSQL 16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:28:33 -05:00

726 lines
25 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 {
// Audit C8 / LOW-3: APNs device tokens are recycled across devices,
// app reinstalls and OS reassignments, so a token already bound to a
// different account is a stale binding — not a hijack. Reassign it to
// the current (authenticated) registrant rather than reject: a 409
// here would lock the legitimate new owner of a recycled token out of
// push entirely. The reassignment is logged as a security-relevant
// event so a genuine token-takeover attempt is still traceable.
if existing.UserID != nil && *existing.UserID != userID {
log.Warn().Uint("user_id", userID).Uint("previous_owner_id", *existing.UserID).
Msg("APNS device token reassigned to a new account")
}
// Update existing device — reassign to the current user
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 {
// Audit C8 / LOW-3: FCM device tokens are recycled across devices,
// app reinstalls and OS reassignments, so a token already bound to a
// different account is a stale binding — not a hijack. Reassign it to
// the current (authenticated) registrant rather than reject: a 409
// here would lock the legitimate new owner of a recycled token out of
// push entirely. The reassignment is logged as a security-relevant
// event so a genuine token-takeover attempt is still traceable.
if existing.UserID != nil && *existing.UserID != userID {
log.Warn().Uint("user_id", userID).Uint("previous_owner_id", *existing.UserID).
Msg("GCM device token reassigned to a new account")
}
// Update existing device — reassign to the current user
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
}