package gateway import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "time" "go.uber.org/zap" ) // PushNotificationService handles sending push notifications via Expo type PushNotificationService struct { logger *zap.Logger client *http.Client } // ExpoTicket represents the response from Expo API type ExpoTicket struct { ID string `json:"id"` Error string `json:"error,omitempty"` } // ExpoPushMessage represents a message to send via Expo type ExpoPushMessage struct { To string `json:"to"` Title string `json:"title"` Body string `json:"body"` Data map[string]interface{} `json:"data,omitempty"` Sound string `json:"sound,omitempty"` Badge int `json:"badge,omitempty"` Priority string `json:"priority,omitempty"` // iOS specific MutableContent bool `json:"mutableContent,omitempty"` IosIcon string `json:"iosIcon,omitempty"` // Android specific AndroidBigLargeIcon string `json:"androidBigLargeIcon,omitempty"` ChannelID string `json:"channelId,omitempty"` } // NewPushNotificationService creates a new push notification service func NewPushNotificationService(logger *zap.Logger) *PushNotificationService { return &PushNotificationService{ logger: logger, client: &http.Client{ Timeout: 10 * time.Second, }, } } // SendNotification sends a push notification via Expo func (pns *PushNotificationService) SendNotification( ctx context.Context, expoPushToken string, title string, body string, data map[string]interface{}, avatarURL string, ) error { if expoPushToken == "" { return fmt.Errorf("empty expo push token") } message := ExpoPushMessage{ To: expoPushToken, Title: title, Body: body, Data: data, Sound: "default", Priority: "high", // Enable mutable content for iOS to allow Notification Service Extension MutableContent: true, ChannelID: "messages", AndroidBigLargeIcon: avatarURL, } // For iOS, include avatar in data so Notification Service Extension can fetch it if avatarURL != "" { if message.Data == nil { message.Data = make(map[string]interface{}) } message.Data["avatar_url"] = avatarURL } return pns.sendExpoRequest(ctx, message) } // SendBulkNotifications sends notifications to multiple users func (pns *PushNotificationService) SendBulkNotifications( ctx context.Context, expoPushTokens []string, title string, body string, data map[string]interface{}, avatarURL string, ) []error { errors := make([]error, 0) for _, token := range expoPushTokens { if err := pns.SendNotification(ctx, token, title, body, data, avatarURL); err != nil { errors = append(errors, fmt.Errorf("failed to send to token %s: %w", token, err)) } } return errors } // sendExpoRequest sends a request to the Expo push notification API func (pns *PushNotificationService) sendExpoRequest(ctx context.Context, message ExpoPushMessage) error { const expoAPIURL = "https://exp.host/--/api/v2/push/send" body, err := json.Marshal(message) if err != nil { pns.logger.Error("failed to marshal push notification", zap.Error(err), zap.String("to", message.To)) return fmt.Errorf("marshal error: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, expoAPIURL, bytes.NewBuffer(body)) if err != nil { pns.logger.Error("failed to create push notification request", zap.Error(err), zap.String("to", message.To)) return fmt.Errorf("request creation error: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := pns.client.Do(req) if err != nil { pns.logger.Error("failed to send push notification", zap.Error(err), zap.String("to", message.To)) return fmt.Errorf("send error: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { pns.logger.Error("failed to read push notification response", zap.Error(err), zap.String("to", message.To)) return fmt.Errorf("response read error: %w", err) } // Check for API errors if resp.StatusCode != http.StatusOK { pns.logger.Warn("push notification API error", zap.Int("status_code", resp.StatusCode), zap.String("response", string(respBody)), zap.String("to", message.To)) return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBody)) } // Parse response var tickets []ExpoTicket if err := json.Unmarshal(respBody, &tickets); err != nil { pns.logger.Error("failed to parse push notification response", zap.Error(err), zap.String("response", string(respBody))) return fmt.Errorf("parse error: %w", err) } // Check for errors in tickets for _, ticket := range tickets { if ticket.Error != "" { pns.logger.Warn("push notification error in ticket", zap.String("error", ticket.Error), zap.String("to", message.To)) return fmt.Errorf("ticket error: %s", ticket.Error) } } pns.logger.Info("push notification sent successfully", zap.String("to", message.To), zap.String("title", message.Title)) return nil }