- remove backend-old (Medusa), saveinmed-frontend (Next.js/Appwrite) and marketplace dirs - split Go usecases by domain and move notifications/payments to infrastructure - reorganize frontend pages into auth, dashboard and marketplace modules - add Makefile, docker-compose.yml and architecture docs
202 lines
5.2 KiB
Go
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
|
|
}
|