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
|
||||
}
|
||||
Reference in New Issue
Block a user