saveinmed/backend/internal/notifications/fcm.go
Gabbriiel 90467db1ec refactor: substitui backend Medusa por backend Go e corrige testes do marketplace
- Remove backend Medusa.js (TypeScript) e substitui pelo backend Go (saveinmed-performance-core)
- Corrige testes auth.test.ts: alinha paths de API (v1/ sem barra inicial) e campo access_token
- Corrige GroupedProductCard.test.tsx: ajusta distância formatada (toFixed) e troca userEvent por fireEvent com fakeTimers
- Corrige AuthContext.test.tsx: usa vi.hoisted() para mocks e corrige parênteses no waitFor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 04:56:37 -06:00

202 lines
5.2 KiB
Go

package notifications
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"time"
"github.com/gofrs/uuid/v5"
"github.com/saveinmed/backend-go/internal/domain"
)
// FCMService implements push notifications via Firebase Cloud Messaging
type FCMService struct {
serverKey string
httpClient *http.Client
tokens map[uuid.UUID][]string // userID -> []deviceTokens (in-memory, should be DB in production)
}
// NewFCMService creates a new FCM notification service
func NewFCMService(serverKey string) *FCMService {
return &FCMService{
serverKey: serverKey,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
tokens: make(map[uuid.UUID][]string),
}
}
// RegisterToken stores a device token for a user
func (s *FCMService) RegisterToken(ctx context.Context, userID uuid.UUID, token string) error {
if token == "" {
return errors.New("token cannot be empty")
}
// Check if token already exists
for _, t := range s.tokens[userID] {
if t == token {
return nil // Already registered
}
}
s.tokens[userID] = append(s.tokens[userID], token)
log.Printf("📲 [FCM] Registered token for user %s: %s...", userID, token[:min(20, len(token))])
return nil
}
// UnregisterToken removes a device token
func (s *FCMService) UnregisterToken(ctx context.Context, userID uuid.UUID, token string) error {
tokens := s.tokens[userID]
for i, t := range tokens {
if t == token {
s.tokens[userID] = append(tokens[:i], tokens[i+1:]...)
log.Printf("📲 [FCM] Unregistered token for user %s", userID)
return nil
}
}
return nil
}
// FCMMessage represents the FCM request payload
type FCMMessage struct {
To string `json:"to,omitempty"`
Notification *FCMNotification `json:"notification,omitempty"`
Data map[string]string `json:"data,omitempty"`
}
type FCMNotification struct {
Title string `json:"title"`
Body string `json:"body"`
Icon string `json:"icon,omitempty"`
Click string `json:"click_action,omitempty"`
}
// SendPush sends a push notification to a user
func (s *FCMService) SendPush(ctx context.Context, userID uuid.UUID, title, body string, data map[string]string) error {
tokens := s.tokens[userID]
if len(tokens) == 0 {
log.Printf("📲 [FCM] No tokens registered for user %s, skipping push", userID)
return nil
}
for _, token := range tokens {
msg := FCMMessage{
To: token,
Notification: &FCMNotification{
Title: title,
Body: body,
Icon: "/favicon.ico",
},
Data: data,
}
if err := s.sendToFCM(ctx, msg); err != nil {
log.Printf("📲 [FCM] Error sending to token %s: %v", token[:min(20, len(token))], err)
continue
}
log.Printf("📲 [FCM] Sent push to user %s: %s", userID, title)
}
return nil
}
func (s *FCMService) sendToFCM(ctx context.Context, msg FCMMessage) error {
// If no server key configured, just log
if s.serverKey == "" {
log.Printf("📲 [FCM] (Mock) Would send: %s - %s", msg.Notification.Title, msg.Notification.Body)
return nil
}
body, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("failed to marshal FCM message: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", "https://fcm.googleapis.com/fcm/send", nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "key="+s.serverKey)
req.Header.Set("Content-Type", "application/json")
req.Body = http.NoBody // We'd set body here in real implementation
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("FCM request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("FCM returned status %d", resp.StatusCode)
}
_ = body // Use body in real implementation
return nil
}
// NotifyOrderCreated implements NotificationService for FCM
func (s *FCMService) NotifyOrderCreated(ctx context.Context, order *domain.Order, buyer, seller *domain.User) error {
// Notify seller
if err := s.SendPush(ctx, seller.ID,
"🛒 Novo Pedido!",
fmt.Sprintf("Você recebeu um pedido de R$ %.2f", float64(order.TotalCents)/100),
map[string]string{
"type": "new_order",
"order_id": order.ID.String(),
},
); err != nil {
log.Printf("Error notifying seller: %v", err)
}
// Notify buyer
if err := s.SendPush(ctx, buyer.ID,
"✅ Pedido Confirmado!",
fmt.Sprintf("Seu pedido #%s foi recebido", order.ID.String()[:8]),
map[string]string{
"type": "order_confirmed",
"order_id": order.ID.String(),
},
); err != nil {
log.Printf("Error notifying buyer: %v", err)
}
return nil
}
// NotifyOrderStatusChanged implements NotificationService for FCM
func (s *FCMService) NotifyOrderStatusChanged(ctx context.Context, order *domain.Order, buyer *domain.User) error {
statusEmoji := map[string]string{
"Pago": "💳",
"Faturado": "📄",
"Enviado": "🚚",
"Entregue": "✅",
}
emoji := statusEmoji[string(order.Status)]
if emoji == "" {
emoji = "📦"
}
return s.SendPush(ctx, buyer.ID,
fmt.Sprintf("%s Pedido Atualizado", emoji),
fmt.Sprintf("Seu pedido #%s está: %s", order.ID.String()[:8], string(order.Status)),
map[string]string{
"type": "order_status",
"order_id": order.ID.String(),
"status": string(order.Status),
},
)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}