ultisuite-backend/internal/api/devices/service.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.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
}