package push import ( "bytes" "context" "encoding/json" "fmt" "net/http" "time" "github.com/rs/zerolog/log" "github.com/treytartt/honeydue-api/internal/config" ) const fcmEndpoint = "https://fcm.googleapis.com/fcm/send" // FCMClient handles direct communication with Firebase Cloud Messaging type FCMClient struct { serverKey string httpClient *http.Client } // FCMMessage represents an FCM message payload type FCMMessage struct { To string `json:"to,omitempty"` RegistrationIDs []string `json:"registration_ids,omitempty"` Notification *FCMNotification `json:"notification,omitempty"` Data map[string]string `json:"data,omitempty"` Priority string `json:"priority,omitempty"` ContentAvailable bool `json:"content_available,omitempty"` } // FCMNotification represents the notification payload type FCMNotification struct { Title string `json:"title,omitempty"` Body string `json:"body,omitempty"` Sound string `json:"sound,omitempty"` Badge string `json:"badge,omitempty"` Icon string `json:"icon,omitempty"` } // FCMResponse represents the FCM API response type FCMResponse struct { MulticastID int64 `json:"multicast_id"` Success int `json:"success"` Failure int `json:"failure"` CanonicalIDs int `json:"canonical_ids"` Results []FCMResult `json:"results"` } // FCMResult represents a single result in the FCM response type FCMResult struct { MessageID string `json:"message_id,omitempty"` RegistrationID string `json:"registration_id,omitempty"` Error string `json:"error,omitempty"` } // NewFCMClient creates a new FCM client func NewFCMClient(cfg *config.PushConfig) (*FCMClient, error) { if cfg.FCMServerKey == "" { return nil, fmt.Errorf("FCM server key not configured") } return &FCMClient{ serverKey: cfg.FCMServerKey, httpClient: &http.Client{ Timeout: 30 * time.Second, }, }, nil } // Send sends a push notification to Android devices func (c *FCMClient) Send(ctx context.Context, tokens []string, title, message string, data map[string]string) error { if len(tokens) == 0 { return nil } msg := FCMMessage{ RegistrationIDs: tokens, Notification: &FCMNotification{ Title: title, Body: message, Sound: "default", }, Data: data, Priority: "high", } body, err := json.Marshal(msg) if err != nil { return fmt.Errorf("failed to marshal FCM message: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", fcmEndpoint, bytes.NewReader(body)) if err != nil { return fmt.Errorf("failed to create FCM request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "key="+c.serverKey) resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send FCM request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("FCM returned status %d", resp.StatusCode) } var fcmResp FCMResponse if err := json.NewDecoder(resp.Body).Decode(&fcmResp); err != nil { return fmt.Errorf("failed to decode FCM response: %w", err) } // Log individual results for i, result := range fcmResp.Results { if i >= len(tokens) { break } if result.Error != "" { log.Error(). Str("token", truncateToken(tokens[i])). Str("error", result.Error). Msg("FCM notification failed") } } log.Info(). Int("total", len(tokens)). Int("success", fcmResp.Success). Int("failure", fcmResp.Failure). Msg("FCM batch send complete") if fcmResp.Success == 0 && fcmResp.Failure > 0 { return fmt.Errorf("all FCM notifications failed") } return nil }