ultisuite-backend/internal/push/push.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

184 lines
5.0 KiB
Go

// Package push delivers mobile push notifications to registered device tokens
// via FCM (Android, HTTP v1) and APNS (iOS, HTTP/2). When credentials are not
// configured the dispatcher degrades to a no-op so local development keeps
// working without Firebase/Apple secrets.
package push
import (
"context"
"errors"
"log/slog"
"github.com/jackc/pgx/v5/pgxpool"
)
// Config holds FCM/APNS credentials. All fields are optional; a provider is only
// enabled when its required fields are present.
type Config struct {
// FCM (Android) — service account JSON (HTTP v1 API).
FCMServiceAccountJSON string
FCMProjectID string // optional; derived from the service account JSON when empty
// APNS (iOS) — token-based auth (.p8 key).
APNSPrivateKey string // PEM contents of the AuthKey_XXXX.p8 file
APNSKeyID string
APNSTeamID string
APNSBundleID string // apns-topic (app bundle identifier)
APNSProduction bool // false -> sandbox gateway
}
// Notification is the platform-agnostic payload fanned out to devices.
type Notification struct {
Title string
Body string
// Data is delivered as the FCM data map / APNS custom keys. Values must be strings.
Data map[string]string
}
// deviceToken is a single registered device row.
type deviceToken struct {
id string
platform string
app string
pushToken string
}
// Dispatcher loads device tokens and sends notifications best-effort.
type Dispatcher struct {
db *pgxpool.Pool
fcm *fcmClient
apns *apnsClient
logger *slog.Logger
}
// NewDispatcher builds a dispatcher. Missing credentials disable the relevant
// provider; an entirely unconfigured dispatcher silently skips all sends.
func NewDispatcher(db *pgxpool.Pool, cfg Config) *Dispatcher {
logger := slog.Default().With("component", "push")
d := &Dispatcher{db: db, logger: logger}
if fcm, err := newFCMClient(cfg); err != nil {
logger.Warn("fcm push disabled", "error", err)
} else if fcm != nil {
d.fcm = fcm
logger.Info("fcm push enabled", "project_id", fcm.projectID)
}
if apns, err := newAPNSClient(cfg); err != nil {
logger.Warn("apns push disabled", "error", err)
} else if apns != nil {
d.apns = apns
logger.Info("apns push enabled", "bundle_id", apns.topic, "production", cfg.APNSProduction)
}
return d
}
// Enabled reports whether at least one provider is configured.
func (d *Dispatcher) Enabled() bool {
return d != nil && (d.fcm != nil || d.apns != nil)
}
// NotifyUser fans a notification out to every registered device of the given
// internal user id (users.id UUID). It is best-effort: failures are logged and
// stale tokens are pruned, but no error is returned.
func (d *Dispatcher) NotifyUser(ctx context.Context, userID string, n Notification) {
if d == nil || d.db == nil || userID == "" {
return
}
if !d.Enabled() {
return
}
tokens, err := d.loadTokens(ctx, userID)
if err != nil {
d.logger.Error("load device tokens", "user_id", userID, "error", err)
return
}
if len(tokens) == 0 {
return
}
for _, t := range tokens {
var sendErr error
switch t.platform {
case "android":
if d.fcm == nil {
continue
}
sendErr = d.fcm.send(ctx, t.pushToken, n)
case "ios":
if d.apns == nil {
continue
}
sendErr = d.apns.send(ctx, t.pushToken, n)
default:
continue
}
if sendErr == nil {
continue
}
if errors.Is(sendErr, errTokenUnregistered) {
d.deleteToken(ctx, t.id)
d.logger.Info("pruned unregistered device token", "platform", t.platform, "app", t.app)
continue
}
d.logger.Warn("push send failed", "platform", t.platform, "app", t.app, "error", sendErr)
}
}
// NotifyNewMail sends a "new mail" notification to the user's devices. It
// satisfies the imap.Pusher interface used by the mail sync pipeline.
func (d *Dispatcher) NotifyNewMail(ctx context.Context, userID, messageID, accountID, sender, subject string) {
if d == nil || !d.Enabled() {
return
}
title := sender
if title == "" {
title = "New mail"
}
d.NotifyUser(ctx, userID, Notification{
Title: title,
Body: subject,
Data: map[string]string{
"type": "mail.created",
"message_id": messageID,
"account_id": accountID,
},
})
}
func (d *Dispatcher) loadTokens(ctx context.Context, userID string) ([]deviceToken, error) {
rows, err := d.db.Query(ctx, `
SELECT id::text, platform, app, push_token
FROM device_tokens
WHERE user_id = $1
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []deviceToken
for rows.Next() {
var t deviceToken
if err := rows.Scan(&t.id, &t.platform, &t.app, &t.pushToken); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (d *Dispatcher) deleteToken(ctx context.Context, id string) {
if _, err := d.db.Exec(ctx, `DELETE FROM device_tokens WHERE id = $1`, id); err != nil {
d.logger.Warn("delete stale device token", "id", id, "error", err)
}
}
// errTokenUnregistered signals that a device token is no longer valid and
// should be removed from storage.
var errTokenUnregistered = errors.New("push: device token unregistered")