- 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.
184 lines
5.0 KiB
Go
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")
|