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 }