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:
Trey t
2025-11-29 12:04:31 -06:00
parent c4118c8186
commit a15e847098
15 changed files with 649 additions and 257 deletions

134
internal/push/apns.go Normal file
View 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
View 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
View 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
}

View File

@@ -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
}