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

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
}