- 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.
146 lines
3.8 KiB
Go
146 lines
3.8 KiB
Go
package devices
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/ultisuite/ulti-backend/internal/users"
|
|
)
|
|
|
|
// ErrUserNotFound is returned when the authenticated user has no provisioned row.
|
|
var ErrUserNotFound = errors.New("user not found")
|
|
|
|
// ErrDeviceNotFound is returned when a delete affects no rows.
|
|
var ErrDeviceNotFound = errors.New("device token not found")
|
|
|
|
// Device is a registered mobile device push token.
|
|
type Device struct {
|
|
ID string `json:"id"`
|
|
Platform string `json:"platform"`
|
|
App string `json:"app"`
|
|
DeviceID string `json:"device_id,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// Service persists device tokens keyed by the internal user id.
|
|
type Service struct {
|
|
db *pgxpool.Pool
|
|
}
|
|
|
|
func NewService(db *pgxpool.Pool) *Service {
|
|
return &Service{db: db}
|
|
}
|
|
|
|
// Register upserts a device token for the given external user id (OIDC sub),
|
|
// returning the row id. Re-registering an identical (user, app, push_token)
|
|
// refreshes platform/device_id/updated_at.
|
|
func (s *Service) Register(ctx context.Context, externalID, platform, app, pushToken, deviceID string) (string, error) {
|
|
userID, err := s.resolveUser(ctx, externalID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var devicePtr *string
|
|
if deviceID != "" {
|
|
devicePtr = &deviceID
|
|
}
|
|
|
|
var id string
|
|
err = s.db.QueryRow(ctx, `
|
|
INSERT INTO device_tokens (user_id, platform, app, push_token, device_id)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (user_id, app, push_token)
|
|
DO UPDATE SET platform = EXCLUDED.platform,
|
|
device_id = EXCLUDED.device_id,
|
|
updated_at = now()
|
|
RETURNING id::text
|
|
`, userID, platform, app, pushToken, devicePtr).Scan(&id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// UnregisterByID removes a device token owned by the user.
|
|
func (s *Service) UnregisterByID(ctx context.Context, externalID, id string) error {
|
|
userID, err := s.resolveUser(ctx, externalID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tag, err := s.db.Exec(ctx, `
|
|
DELETE FROM device_tokens WHERE id = $1 AND user_id = $2
|
|
`, id, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrDeviceNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UnregisterByToken removes a device token by its push token for the user.
|
|
func (s *Service) UnregisterByToken(ctx context.Context, externalID, pushToken string) error {
|
|
userID, err := s.resolveUser(ctx, externalID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tag, err := s.db.Exec(ctx, `
|
|
DELETE FROM device_tokens WHERE push_token = $1 AND user_id = $2
|
|
`, pushToken, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrDeviceNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// List returns the user's registered devices, most recently updated first.
|
|
func (s *Service) List(ctx context.Context, externalID string) ([]Device, error) {
|
|
userID, err := s.resolveUser(ctx, externalID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT id::text, platform, app, coalesce(device_id, ''), created_at, updated_at
|
|
FROM device_tokens
|
|
WHERE user_id = $1
|
|
ORDER BY updated_at DESC
|
|
`, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]Device, 0)
|
|
for rows.Next() {
|
|
var d Device
|
|
if err := rows.Scan(&d.ID, &d.Platform, &d.App, &d.DeviceID, &d.CreatedAt, &d.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Service) resolveUser(ctx context.Context, externalID string) (string, error) {
|
|
userID, err := users.LookupUserID(ctx, s.db, externalID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", ErrUserNotFound
|
|
}
|
|
return "", err
|
|
}
|
|
if userID == "" {
|
|
return "", ErrUserNotFound
|
|
}
|
|
return userID, nil
|
|
}
|