Replace Gorush with direct APNs/FCM integration
- Add direct APNs client using sideshow/apns2 for iOS push - Add direct FCM client using legacy HTTP API for Android push - Remove Gorush dependency (no external push server needed) - Update all services/handlers to use new push.Client - Update config for APNS_PRODUCTION flag 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
134
internal/push/apns.go
Normal file
134
internal/push/apns.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/sideshow/apns2"
|
||||
"github.com/sideshow/apns2/payload"
|
||||
"github.com/sideshow/apns2/token"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/config"
|
||||
)
|
||||
|
||||
// APNsClient handles direct communication with Apple Push Notification service
|
||||
type APNsClient struct {
|
||||
client *apns2.Client
|
||||
topic string
|
||||
}
|
||||
|
||||
// NewAPNsClient creates a new APNs client using token-based authentication
|
||||
func NewAPNsClient(cfg *config.PushConfig) (*APNsClient, error) {
|
||||
if cfg.APNSKeyPath == "" || cfg.APNSKeyID == "" || cfg.APNSTeamID == "" {
|
||||
return nil, fmt.Errorf("APNs configuration incomplete: key_path=%s, key_id=%s, team_id=%s",
|
||||
cfg.APNSKeyPath, cfg.APNSKeyID, cfg.APNSTeamID)
|
||||
}
|
||||
|
||||
// Load the APNs auth key (.p8 file)
|
||||
authKey, err := token.AuthKeyFromFile(cfg.APNSKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load APNs auth key from %s: %w", cfg.APNSKeyPath, err)
|
||||
}
|
||||
|
||||
// Create token for authentication
|
||||
authToken := &token.Token{
|
||||
AuthKey: authKey,
|
||||
KeyID: cfg.APNSKeyID,
|
||||
TeamID: cfg.APNSTeamID,
|
||||
}
|
||||
|
||||
// Create client - production or sandbox
|
||||
// Use APNSProduction if set, otherwise fall back to inverse of APNSSandbox
|
||||
var client *apns2.Client
|
||||
useProduction := cfg.APNSProduction || !cfg.APNSSandbox
|
||||
if useProduction {
|
||||
client = apns2.NewTokenClient(authToken).Production()
|
||||
log.Info().Msg("APNs client configured for PRODUCTION")
|
||||
} else {
|
||||
client = apns2.NewTokenClient(authToken).Development()
|
||||
log.Info().Msg("APNs client configured for DEVELOPMENT/SANDBOX")
|
||||
}
|
||||
|
||||
return &APNsClient{
|
||||
client: client,
|
||||
topic: cfg.APNSTopic,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Send sends a push notification to iOS devices
|
||||
func (c *APNsClient) Send(ctx context.Context, tokens []string, title, message string, data map[string]string) error {
|
||||
if len(tokens) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build the notification payload
|
||||
p := payload.NewPayload().
|
||||
AlertTitle(title).
|
||||
AlertBody(message).
|
||||
Sound("default").
|
||||
MutableContent()
|
||||
|
||||
// Add custom data
|
||||
for key, value := range data {
|
||||
p.Custom(key, value)
|
||||
}
|
||||
|
||||
var errors []error
|
||||
successCount := 0
|
||||
|
||||
for _, deviceToken := range tokens {
|
||||
notification := &apns2.Notification{
|
||||
DeviceToken: deviceToken,
|
||||
Topic: c.topic,
|
||||
Payload: p,
|
||||
Priority: apns2.PriorityHigh,
|
||||
}
|
||||
|
||||
res, err := c.client.PushWithContext(ctx, notification)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("token", truncateToken(deviceToken)).
|
||||
Msg("Failed to send APNs notification")
|
||||
errors = append(errors, fmt.Errorf("token %s: %w", truncateToken(deviceToken), err))
|
||||
continue
|
||||
}
|
||||
|
||||
if !res.Sent() {
|
||||
log.Error().
|
||||
Str("token", truncateToken(deviceToken)).
|
||||
Str("reason", res.Reason).
|
||||
Int("status", res.StatusCode).
|
||||
Msg("APNs notification not sent")
|
||||
errors = append(errors, fmt.Errorf("token %s: %s (status %d)", truncateToken(deviceToken), res.Reason, res.StatusCode))
|
||||
continue
|
||||
}
|
||||
|
||||
successCount++
|
||||
log.Debug().
|
||||
Str("token", truncateToken(deviceToken)).
|
||||
Str("apns_id", res.ApnsID).
|
||||
Msg("APNs notification sent successfully")
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Int("total", len(tokens)).
|
||||
Int("success", successCount).
|
||||
Int("failed", len(errors)).
|
||||
Msg("APNs batch send complete")
|
||||
|
||||
if len(errors) > 0 && successCount == 0 {
|
||||
return fmt.Errorf("all APNs notifications failed: %v", errors)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// truncateToken returns first 8 chars of token for logging
|
||||
func truncateToken(token string) string {
|
||||
if len(token) > 8 {
|
||||
return token[:8] + "..."
|
||||
}
|
||||
return token
|
||||
}
|
||||
113
internal/push/client.go
Normal file
113
internal/push/client.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/config"
|
||||
)
|
||||
|
||||
// Platform constants
|
||||
const (
|
||||
PlatformIOS = "ios"
|
||||
PlatformAndroid = "android"
|
||||
)
|
||||
|
||||
// Client provides a unified interface for sending push notifications
|
||||
type Client struct {
|
||||
apns *APNsClient
|
||||
fcm *FCMClient
|
||||
}
|
||||
|
||||
// NewClient creates a new unified push notification client
|
||||
func NewClient(cfg *config.PushConfig) (*Client, error) {
|
||||
client := &Client{}
|
||||
|
||||
// Initialize APNs client (iOS)
|
||||
if cfg.APNSKeyPath != "" && cfg.APNSKeyID != "" && cfg.APNSTeamID != "" {
|
||||
apnsClient, err := NewAPNsClient(cfg)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to initialize APNs client - iOS push disabled")
|
||||
} else {
|
||||
client.apns = apnsClient
|
||||
log.Info().Msg("APNs client initialized successfully")
|
||||
}
|
||||
} else {
|
||||
log.Warn().Msg("APNs not configured - iOS push disabled")
|
||||
}
|
||||
|
||||
// Initialize FCM client (Android)
|
||||
if cfg.FCMServerKey != "" {
|
||||
fcmClient, err := NewFCMClient(cfg)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to initialize FCM client - Android push disabled")
|
||||
} else {
|
||||
client.fcm = fcmClient
|
||||
log.Info().Msg("FCM client initialized successfully")
|
||||
}
|
||||
} else {
|
||||
log.Warn().Msg("FCM not configured - Android push disabled")
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// SendToIOS sends a push notification to iOS devices
|
||||
func (c *Client) SendToIOS(ctx context.Context, tokens []string, title, message string, data map[string]string) error {
|
||||
if c.apns == nil {
|
||||
log.Warn().Msg("APNs client not initialized, skipping iOS push")
|
||||
return nil
|
||||
}
|
||||
return c.apns.Send(ctx, tokens, title, message, data)
|
||||
}
|
||||
|
||||
// SendToAndroid sends a push notification to Android devices
|
||||
func (c *Client) SendToAndroid(ctx context.Context, tokens []string, title, message string, data map[string]string) error {
|
||||
if c.fcm == nil {
|
||||
log.Warn().Msg("FCM client not initialized, skipping Android push")
|
||||
return nil
|
||||
}
|
||||
return c.fcm.Send(ctx, tokens, title, message, data)
|
||||
}
|
||||
|
||||
// SendToAll sends a push notification to both iOS and Android devices
|
||||
func (c *Client) SendToAll(ctx context.Context, iosTokens, androidTokens []string, title, message string, data map[string]string) error {
|
||||
var lastErr error
|
||||
|
||||
if len(iosTokens) > 0 {
|
||||
if err := c.SendToIOS(ctx, iosTokens, title, message, data); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to send iOS notifications")
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
if len(androidTokens) > 0 {
|
||||
if err := c.SendToAndroid(ctx, androidTokens, title, message, data); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to send Android notifications")
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// IsIOSEnabled returns true if iOS push is configured
|
||||
func (c *Client) IsIOSEnabled() bool {
|
||||
return c.apns != nil
|
||||
}
|
||||
|
||||
// IsAndroidEnabled returns true if Android push is configured
|
||||
func (c *Client) IsAndroidEnabled() bool {
|
||||
return c.fcm != nil
|
||||
}
|
||||
|
||||
// HealthCheck checks if the push services are available
|
||||
func (c *Client) HealthCheck(ctx context.Context) error {
|
||||
// For direct clients, we can't easily health check without sending a notification
|
||||
// Return nil if at least one platform is configured
|
||||
if c.apns != nil || c.fcm != nil {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
139
internal/push/fcm.go
Normal file
139
internal/push/fcm.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/treytartt/casera-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 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
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/config"
|
||||
)
|
||||
|
||||
// Platform constants
|
||||
const (
|
||||
PlatformIOS = "ios"
|
||||
PlatformAndroid = "android"
|
||||
)
|
||||
|
||||
// GorushClient handles communication with Gorush server
|
||||
type GorushClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
config *config.PushConfig
|
||||
}
|
||||
|
||||
// NewGorushClient creates a new Gorush client
|
||||
func NewGorushClient(cfg *config.PushConfig) *GorushClient {
|
||||
return &GorushClient{
|
||||
baseURL: cfg.GorushURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// PushNotification represents a push notification request
|
||||
type PushNotification struct {
|
||||
Tokens []string `json:"tokens"`
|
||||
Platform int `json:"platform"` // 1 = iOS, 2 = Android
|
||||
Message string `json:"message"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Topic string `json:"topic,omitempty"` // iOS bundle ID
|
||||
Badge *int `json:"badge,omitempty"` // iOS badge count
|
||||
Sound string `json:"sound,omitempty"` // Notification sound
|
||||
ContentAvailable bool `json:"content_available,omitempty"` // iOS background notification
|
||||
MutableContent bool `json:"mutable_content,omitempty"` // iOS mutable content
|
||||
Data map[string]string `json:"data,omitempty"` // Custom data payload
|
||||
Priority string `json:"priority,omitempty"` // high or normal
|
||||
ThreadID string `json:"thread_id,omitempty"` // iOS thread grouping
|
||||
CollapseKey string `json:"collapse_key,omitempty"` // Android collapse key
|
||||
}
|
||||
|
||||
// GorushRequest represents the full Gorush API request
|
||||
type GorushRequest struct {
|
||||
Notifications []PushNotification `json:"notifications"`
|
||||
}
|
||||
|
||||
// GorushResponse represents the Gorush API response
|
||||
type GorushResponse struct {
|
||||
Counts int `json:"counts"`
|
||||
Logs []GorushLog `json:"logs,omitempty"`
|
||||
Success string `json:"success,omitempty"`
|
||||
}
|
||||
|
||||
// GorushLog represents a log entry from Gorush
|
||||
type GorushLog struct {
|
||||
Type string `json:"type"`
|
||||
Platform string `json:"platform"`
|
||||
Token string `json:"token"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// SendToIOS sends a push notification to iOS devices
|
||||
func (c *GorushClient) SendToIOS(ctx context.Context, tokens []string, title, message string, data map[string]string) error {
|
||||
if len(tokens) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
notification := PushNotification{
|
||||
Tokens: tokens,
|
||||
Platform: 1, // iOS
|
||||
Title: title,
|
||||
Message: message,
|
||||
Topic: c.config.APNSTopic,
|
||||
Sound: "default",
|
||||
MutableContent: true,
|
||||
Data: data,
|
||||
Priority: "high",
|
||||
}
|
||||
|
||||
return c.send(ctx, notification)
|
||||
}
|
||||
|
||||
// SendToAndroid sends a push notification to Android devices
|
||||
func (c *GorushClient) SendToAndroid(ctx context.Context, tokens []string, title, message string, data map[string]string) error {
|
||||
if len(tokens) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
notification := PushNotification{
|
||||
Tokens: tokens,
|
||||
Platform: 2, // Android
|
||||
Title: title,
|
||||
Message: message,
|
||||
Data: data,
|
||||
Priority: "high",
|
||||
}
|
||||
|
||||
return c.send(ctx, notification)
|
||||
}
|
||||
|
||||
// SendToAll sends a push notification to both iOS and Android devices
|
||||
func (c *GorushClient) SendToAll(ctx context.Context, iosTokens, androidTokens []string, title, message string, data map[string]string) error {
|
||||
var errs []error
|
||||
|
||||
if len(iosTokens) > 0 {
|
||||
if err := c.SendToIOS(ctx, iosTokens, title, message, data); err != nil {
|
||||
errs = append(errs, fmt.Errorf("iOS: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(androidTokens) > 0 {
|
||||
if err := c.SendToAndroid(ctx, androidTokens, title, message, data); err != nil {
|
||||
errs = append(errs, fmt.Errorf("Android: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("push notification errors: %v", errs)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// send sends the notification to Gorush
|
||||
func (c *GorushClient) send(ctx context.Context, notification PushNotification) error {
|
||||
req := GorushRequest{
|
||||
Notifications: []PushNotification{notification},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/push", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("gorush returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var gorushResp GorushResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&gorushResp); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Int("counts", gorushResp.Counts).
|
||||
Int("tokens", len(notification.Tokens)).
|
||||
Msg("Push notification sent")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HealthCheck checks if Gorush is healthy
|
||||
func (c *GorushClient) HealthCheck(ctx context.Context) error {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/stat/go", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("gorush health check failed: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user