ultisuite-backend/internal/push/fcm.go
R3D347HR4Y f97988b51f
Some checks failed
CI / Go tests (push) Has been cancelled
CI / Integration tests (push) Has been cancelled
CI / DB migrations (push) Has been cancelled
feat(devices): implement mobile device token management and push notifications
- Added device token management API for mobile devices, including registration, unregistration, and listing of devices.
- Implemented push notification functionality using FCM for Android and APNS for iOS.
- Introduced new endpoints for device registration and management in the devices API.
- Enhanced the configuration to support mobile push notifications with optional credentials for FCM and APNS.
- Updated database schema to include a new table for storing device tokens.
- Added integration tests for device management and push notification features.
2026-06-17 00:11:25 +02:00

146 lines
3.6 KiB
Go

package push
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
const fcmMessagingScope = "https://www.googleapis.com/auth/firebase.messaging"
// fcmClient sends notifications through the FCM HTTP v1 API.
type fcmClient struct {
projectID string
tokenSource oauth2.TokenSource
http *http.Client
}
// newFCMClient returns a configured client, or nil (no error) when FCM is not
// configured. An error is returned only when provided credentials are invalid.
func newFCMClient(cfg Config) (*fcmClient, error) {
raw := strings.TrimSpace(cfg.FCMServiceAccountJSON)
if raw == "" {
return nil, nil
}
creds, err := google.CredentialsFromJSON(context.Background(), []byte(raw), fcmMessagingScope)
if err != nil {
return nil, fmt.Errorf("parse fcm service account: %w", err)
}
projectID := strings.TrimSpace(cfg.FCMProjectID)
if projectID == "" {
projectID = creds.ProjectID
}
if projectID == "" {
// Fall back to parsing the JSON directly for project_id.
var sa struct {
ProjectID string `json:"project_id"`
}
_ = json.Unmarshal([]byte(raw), &sa)
projectID = strings.TrimSpace(sa.ProjectID)
}
if projectID == "" {
return nil, fmt.Errorf("fcm project id missing (set FCM_PROJECT_ID or include project_id in service account)")
}
return &fcmClient{
projectID: projectID,
tokenSource: creds.TokenSource,
http: &http.Client{Timeout: 10 * time.Second},
}, nil
}
type fcmMessage struct {
Message fcmMessageBody `json:"message"`
}
type fcmMessageBody struct {
Token string `json:"token"`
Notification fcmNotification `json:"notification"`
Data map[string]string `json:"data,omitempty"`
}
type fcmNotification struct {
Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"`
}
func (c *fcmClient) send(ctx context.Context, deviceToken string, n Notification) error {
accessToken, err := c.tokenSource.Token()
if err != nil {
return fmt.Errorf("fcm access token: %w", err)
}
payload := fcmMessage{
Message: fcmMessageBody{
Token: deviceToken,
Notification: fcmNotification{
Title: n.Title,
Body: n.Body,
},
Data: n.Data,
},
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
url := fmt.Sprintf("https://fcm.googleapis.com/v1/projects/%s/messages:send", c.projectID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+accessToken.AccessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
// A 404 (NOT_FOUND) or UNREGISTERED error means the token is dead.
if resp.StatusCode == http.StatusNotFound || fcmIsUnregistered(respBody) {
return errTokenUnregistered
}
return fmt.Errorf("fcm send status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
func fcmIsUnregistered(body []byte) bool {
var parsed struct {
Error struct {
Status string `json:"status"`
Details []struct {
ErrorCode string `json:"errorCode"`
} `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return false
}
if parsed.Error.Status == "NOT_FOUND" {
return true
}
for _, d := range parsed.Error.Details {
if d.ErrorCode == "UNREGISTERED" {
return true
}
}
return false
}